feat: add stocks panel, NPC panel tab rework, and stockpile pickup mechanic

- Add R(esources) tab to left panel showing stockpile totals and activity log
- Rework NPC panel tabs to vertical left-edge S/R/H/I style
- Add pickup system for NPCs to take materials from stockpiles for crafting
- Move inventory display to dedicated I(nventory) tab on NPC panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 03:01:02 +00:00
14 changed files with 634 additions and 147 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client';
import type { ServerEvents, ClientEvents, WorldState, StateUpdate, PlayerJoined, PlayerLeft, PlayerInput, SuperlativesData, NarrationEvent, MemoryEvent, InventionSummary } from '@dflike/shared';
import type { ServerEvents, ClientEvents, WorldState, StateUpdate, PlayerJoined, PlayerLeft, PlayerInput, SuperlativesData, NarrationEvent, MemoryEvent, InventionSummary, StockpileLogEntry } from '@dflike/shared';
type TypedSocket = Socket<ServerEvents, ClientEvents>;
@@ -22,6 +22,9 @@ export class SocketClient {
onMemoryHistory: ((data: { entityId: number; events: MemoryEvent[] }) => void) | null = null;
onInventionEvent: ((data: InventionSummary) => void) | null = null;
onInventionHistory: ((data: InventionSummary[]) => void) | null = null;
onStockpileEvent: ((data: StockpileLogEntry) => void) | null = null;
onStockpileHistory: ((data: StockpileLogEntry[]) => void) | null = null;
onStockpileSummary: ((data: Record<string, number>) => void) | null = null;
constructor(url: string) {
this.socket = io(url);
@@ -58,6 +61,9 @@ export class SocketClient {
this.socket.on('memory-history', (data) => this.onMemoryHistory?.(data));
this.socket.on('invention-event', (data) => this.onInventionEvent?.(data));
this.socket.on('invention-history', (data) => this.onInventionHistory?.(data));
this.socket.on('stockpile-event', (data) => this.onStockpileEvent?.(data));
this.socket.on('stockpile-history', (data) => this.onStockpileHistory?.(data));
this.socket.on('stockpile-summary', (data) => this.onStockpileSummary?.(data));
}
get playerId(): string | null { return this._playerId; }
+25
View File
@@ -8,6 +8,7 @@ import { SuperlativesPanel } from '../ui/SuperlativesPanel.js';
import { LeftPanel } from '../ui/LeftPanel.js';
import { EventsFeed } from '../ui/EventsFeed.js';
import { InventionsPanel } from '../ui/InventionsPanel.js';
import { StocksPanel } from '../ui/StocksPanel.js';
import { InteractionEmojiManager } from '../ui/InteractionEmoji.js';
import type { WorldState, StateUpdate, EntityState, Appearance, NarrationEvent, MemoryEvent } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared';
@@ -39,6 +40,7 @@ export class GameScene extends Phaser.Scene {
private leftPanel!: LeftPanel;
private eventsFeed!: EventsFeed;
private inventionsPanel!: InventionsPanel;
private stocksPanel!: StocksPanel;
private highlightEnabled = false;
private highlightedSpriteId: number | null = null;
private dayNightOverlay!: Phaser.GameObjects.Graphics;
@@ -75,10 +77,12 @@ export class GameScene extends Phaser.Scene {
this.superlativesPanel = new SuperlativesPanel();
this.eventsFeed = new EventsFeed();
this.inventionsPanel = new InventionsPanel();
this.stocksPanel = new StocksPanel();
this.leftPanel = new LeftPanel(
this.superlativesPanel.getElement(),
this.eventsFeed.getElement(),
this.inventionsPanel.getElement(),
this.stocksPanel.getElement(),
);
this.inventionsPanel.onUnseenChanged = (hasUnseen) => {
@@ -87,6 +91,12 @@ export class GameScene extends Phaser.Scene {
}
};
this.stocksPanel.onUnseenChanged = (hasUnseen) => {
if (hasUnseen && this.leftPanel.getActiveTab() !== 'resources') {
this.leftPanel.showResourcesNotificationDot();
}
};
document.addEventListener('left-panel-opened', ((e: CustomEvent) => {
if (e.detail.tab === 'stats') {
this.client.subscribeSuperlatives();
@@ -94,6 +104,9 @@ export class GameScene extends Phaser.Scene {
if (e.detail.tab === 'inventions') {
this.inventionsPanel.clearUnseen();
}
if (e.detail.tab === 'resources') {
this.stocksPanel.clearUnseen();
}
}) as EventListener);
document.addEventListener('left-panel-closed', () => {
@@ -256,6 +269,18 @@ export class GameScene extends Phaser.Scene {
this.inventionsPanel.loadHistory(data);
};
this.client.onStockpileEvent = (data) => {
this.stocksPanel.addLogEntry(data);
};
this.client.onStockpileHistory = (data) => {
this.stocksPanel.loadHistory(data);
};
this.client.onStockpileSummary = (data) => {
this.stocksPanel.updateSummary(data);
};
document.addEventListener('narration-follow', ((e: CustomEvent) => {
const { entityId } = e.detail;
const npcIds = this.getNpcIds();
+52 -5
View File
@@ -7,16 +7,20 @@ export class LeftPanel {
private eventsTab: HTMLDivElement;
private inventionsTab: HTMLDivElement;
private inventionsContent: HTMLDivElement;
private resourcesTab: HTMLDivElement;
private resourcesContent: HTMLDivElement;
private resourcesNotificationDot: HTMLDivElement;
private notificationDot: HTMLDivElement;
private expanded = false;
private activeTab: 'stats' | 'events' | 'inventions' | null = null;
private activeTab: 'stats' | 'events' | 'inventions' | 'resources' | null = null;
private superlativesContent: HTMLDivElement;
private eventsFeedContent: HTMLDivElement;
constructor(superlativesContent: HTMLDivElement, eventsFeedContent: HTMLDivElement, inventionsContent: HTMLDivElement) {
constructor(superlativesContent: HTMLDivElement, eventsFeedContent: HTMLDivElement, inventionsContent: HTMLDivElement, resourcesContent: HTMLDivElement) {
this.superlativesContent = superlativesContent;
this.eventsFeedContent = eventsFeedContent;
this.inventionsContent = inventionsContent;
this.resourcesContent = resourcesContent;
// Outer container for positioning
this.container = document.createElement('div');
@@ -137,9 +141,33 @@ export class LeftPanel {
}
});
// Resources tab ("R")
this.resourcesTab = this.createTab('R');
this.resourcesTab.style.position = 'relative';
this.resourcesNotificationDot = document.createElement('div');
this.resourcesNotificationDot.style.cssText = `
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: #f0d060;
border-radius: 50%;
display: none;
`;
this.resourcesTab.appendChild(this.resourcesNotificationDot);
this.resourcesTab.addEventListener('click', () => {
if (this.activeTab === 'resources' && this.expanded) {
this.collapse();
} else {
this.expand('resources');
}
});
tabContainer.appendChild(this.statsTab);
tabContainer.appendChild(this.eventsTab);
tabContainer.appendChild(this.inventionsTab);
tabContainer.appendChild(this.resourcesTab);
this.container.appendChild(this.panel);
this.container.appendChild(tabContainer);
@@ -204,9 +232,14 @@ export class LeftPanel {
this.activeTab === 'inventions' && this.expanded ? '#2a2a4e' : '#1a1a2e';
this.inventionsTab.dataset.active =
this.activeTab === 'inventions' && this.expanded ? 'true' : 'false';
this.resourcesTab.style.background =
this.activeTab === 'resources' && this.expanded ? '#2a2a4e' : '#1a1a2e';
this.resourcesTab.dataset.active =
this.activeTab === 'resources' && this.expanded ? 'true' : 'false';
}
private showContent(tab: 'stats' | 'events' | 'inventions'): void {
private showContent(tab: 'stats' | 'events' | 'inventions' | 'resources'): void {
// Remove current content
while (this.contentArea.firstChild) {
this.contentArea.removeChild(this.contentArea.firstChild);
@@ -221,10 +254,13 @@ export class LeftPanel {
} else if (tab === 'inventions') {
this.headerEl.textContent = 'INVENTIONS';
this.contentArea.appendChild(this.inventionsContent);
} else if (tab === 'resources') {
this.headerEl.textContent = 'RESOURCES';
this.contentArea.appendChild(this.resourcesContent);
}
}
expand(tab: 'stats' | 'events' | 'inventions'): void {
expand(tab: 'stats' | 'events' | 'inventions' | 'resources'): void {
this.activeTab = tab;
this.expanded = true;
this.showContent(tab);
@@ -233,6 +269,9 @@ export class LeftPanel {
if (tab === 'inventions') {
this.hideNotificationDot();
}
if (tab === 'resources') {
this.hideResourcesNotificationDot();
}
document.dispatchEvent(
new CustomEvent('left-panel-opened', { detail: { tab } }),
);
@@ -249,7 +288,7 @@ export class LeftPanel {
return this.expanded;
}
getActiveTab(): 'stats' | 'events' | 'inventions' | null {
getActiveTab(): 'stats' | 'events' | 'inventions' | 'resources' | null {
return this.expanded ? this.activeTab : null;
}
@@ -261,6 +300,14 @@ export class LeftPanel {
this.notificationDot.style.display = 'none';
}
showResourcesNotificationDot(): void {
this.resourcesNotificationDot.style.display = '';
}
hideResourcesNotificationDot(): void {
this.resourcesNotificationDot.style.display = 'none';
}
destroy(): void {
this.container.remove();
}
+122 -119
View File
@@ -8,6 +8,7 @@ const ACTIVITY_LABELS: Record<string, string> = {
craft: 'Crafting',
build: 'Building',
dropoff: 'Dropping off',
pickup: 'Picking up',
};
// EarthBound-inspired color palette
@@ -31,7 +32,6 @@ const EB = {
};
export class NpcInfoPanel {
private container: HTMLDivElement;
private outerFrame: HTMLDivElement;
private portraitImg: HTMLImageElement;
private nameEl: HTMLDivElement;
@@ -44,46 +44,81 @@ export class NpcInfoPanel {
private backstoryEl: HTMLDivElement;
private statsContainer: HTMLDivElement;
private statElements: Map<string, HTMLDivElement> = new Map();
private inventorySeparator: HTMLDivElement;
private inventoryContainer: HTMLDivElement;
private statusTab!: HTMLDivElement;
private relationshipsTab!: HTMLDivElement;
private historyTab!: HTMLDivElement;
private inventoryTab!: HTMLDivElement;
private statusContent!: HTMLDivElement;
private relationshipsContent!: HTMLDivElement;
private historyContent!: HTMLDivElement;
private activeTab: 'status' | 'relationships' | 'history' = 'status';
private inventoryContent!: HTMLDivElement;
private activeTab: 'status' | 'relationships' | 'history' | 'inventory' = 'status';
private expandedTiers: Set<string> = new Set();
private visible = false;
constructor() {
// Outer frame (provides the doubled border)
// Outer frame (flex row: tabColumn + panelBody)
this.outerFrame = document.createElement('div');
this.outerFrame.id = 'npc-info-panel';
this.outerFrame.style.cssText = `
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: row;
z-index: 1000;
max-height: calc(100vh - 32px);
transform: translateX(410px);
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
`;
// Vertical tab column (left edge)
const tabColumn = document.createElement('div');
tabColumn.style.cssText = `
display: flex;
flex-direction: column;
align-self: center;
gap: 2px;
flex-shrink: 0;
`;
this.statusTab = this.createTab('S');
this.relationshipsTab = this.createTab('R');
this.historyTab = this.createTab('H');
this.inventoryTab = this.createTab('I');
this.statusTab.dataset.active = 'true';
this.statusTab.style.background = '#2a2a4e';
this.statusTab.addEventListener('click', () => this.switchTab('status'));
this.relationshipsTab.addEventListener('click', () => this.switchTab('relationships'));
this.historyTab.addEventListener('click', () => this.switchTab('history'));
this.inventoryTab.addEventListener('click', () => this.switchTab('inventory'));
tabColumn.appendChild(this.statusTab);
tabColumn.appendChild(this.relationshipsTab);
tabColumn.appendChild(this.historyTab);
tabColumn.appendChild(this.inventoryTab);
// Panel body (has border/shadow, wraps inner container)
const panelBody = document.createElement('div');
panelBody.style.cssText = `
width: 340px;
border-radius: 16px;
border: 3px solid ${EB.borderOuter};
background: ${EB.borderGap};
z-index: 1000;
max-height: calc(100vh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateX(410px);
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow:
0 0 20px rgba(80, 60, 160, 0.4),
0 8px 32px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(160, 160, 255, 0.15);
overflow: hidden;
`;
// Inner container (creates the gap effect)
this.container = document.createElement('div');
this.container.style.cssText = `
const innerContainer = document.createElement('div');
innerContainer.style.cssText = `
margin: 3px;
border-radius: 12px;
border: 2px solid ${EB.borderInner};
@@ -110,7 +145,7 @@ export class NpcInfoPanel {
pointer-events: none;
z-index: 1;
`;
this.container.appendChild(shine);
innerContainer.appendChild(shine);
// Portrait well
const portraitWell = document.createElement('div');
@@ -156,63 +191,9 @@ export class NpcInfoPanel {
portraitInner.appendChild(this.portraitImg);
portraitFrame.appendChild(portraitInner);
portraitWell.appendChild(portraitFrame);
this.container.appendChild(portraitWell);
innerContainer.appendChild(portraitWell);
// Tab bar
const tabBar = document.createElement('div');
tabBar.style.cssText = `
display: flex;
border-bottom: 2px solid ${EB.borderInner};
font-family: 'Press Start 2P', monospace;
flex-shrink: 0;
`;
this.statusTab = document.createElement('div');
this.statusTab.textContent = 'Status';
this.statusTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
cursor: pointer;
color: ${EB.textPrimary};
border-bottom: 2px solid ${EB.borderOuter};
user-select: none;
`;
this.relationshipsTab = document.createElement('div');
this.relationshipsTab.textContent = 'Relations';
this.relationshipsTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
cursor: pointer;
color: ${EB.textMuted};
user-select: none;
`;
this.historyTab = document.createElement('div');
this.historyTab.textContent = 'History';
this.historyTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
cursor: pointer;
color: ${EB.textMuted};
user-select: none;
`;
this.statusTab.addEventListener('click', () => this.switchTab('status'));
this.relationshipsTab.addEventListener('click', () => this.switchTab('relationships'));
this.historyTab.addEventListener('click', () => this.switchTab('history'));
tabBar.appendChild(this.statusTab);
tabBar.appendChild(this.relationshipsTab);
tabBar.appendChild(this.historyTab);
this.container.appendChild(tabBar);
// Content wrapper (scrollable area below portrait + tabs)
// Content wrapper (scrollable area below portrait)
const contentWrapper = document.createElement('div');
contentWrapper.style.cssText = `
overflow-y: auto;
@@ -339,30 +320,6 @@ export class NpcInfoPanel {
`;
this.statusContent.appendChild(this.statsContainer);
// Inventory separator (hidden when empty)
this.inventorySeparator = document.createElement('div');
this.inventorySeparator.style.cssText = `
text-align: center;
font-size: 11px;
color: ${EB.textMuted};
padding: 4px 0;
letter-spacing: 6px;
user-select: none;
display: none;
`;
this.inventorySeparator.textContent = '\u25C6\u25C6\u25C6';
this.statusContent.appendChild(this.inventorySeparator);
// Inventory container - two columns like stats
this.inventoryContainer = document.createElement('div');
this.inventoryContainer.style.cssText = `
display: none;
grid-template-columns: 1fr 1fr;
gap: 3px 8px;
font-family: 'Press Start 2P', monospace;
`;
this.statusContent.appendChild(this.inventoryContainer);
contentWrapper.appendChild(this.statusContent);
this.relationshipsContent = document.createElement('div');
@@ -381,11 +338,62 @@ export class NpcInfoPanel {
`;
contentWrapper.appendChild(this.historyContent);
this.container.appendChild(contentWrapper);
this.outerFrame.appendChild(this.container);
this.inventoryContent = document.createElement('div');
this.inventoryContent.style.cssText = `
display: none;
padding: 10px 12px 12px;
font-family: 'Press Start 2P', monospace;
`;
contentWrapper.appendChild(this.inventoryContent);
innerContainer.appendChild(contentWrapper);
panelBody.appendChild(innerContainer);
this.outerFrame.appendChild(tabColumn);
this.outerFrame.appendChild(panelBody);
document.body.appendChild(this.outerFrame);
}
private createTab(label: string): HTMLDivElement {
const tab = document.createElement('div');
tab.style.cssText = `
width: 32px;
height: 56px;
background: #1a1a2e;
border: 3px solid ${EB.borderOuter};
border-right: none;
border-radius: 6px 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s;
`;
const tabInner = document.createElement('div');
tabInner.style.cssText = `
color: #e0d0b0;
font-size: 20px;
font-family: 'Press Start 2P', monospace;
`;
tabInner.textContent = label;
tab.appendChild(tabInner);
tab.addEventListener('mouseenter', () => {
if (tab.dataset.active !== 'true') {
tab.style.background = '#2a2a4e';
}
});
tab.addEventListener('mouseleave', () => {
if (tab.dataset.active !== 'true') {
tab.style.background = '#1a1a2e';
}
});
return tab;
}
show(entity: EntityState, portraitDataUrl: string): void {
this.portraitImg.src = portraitDataUrl;
this.updateInfo(entity);
@@ -442,29 +450,22 @@ export class NpcInfoPanel {
}
private updateInventory(inventory?: Record<string, number>): void {
this.inventoryContent.innerHTML = '';
const items = inventory ? Object.entries(inventory).filter(([, qty]) => qty > 0) : [];
if (items.length === 0) {
this.inventorySeparator.style.display = 'none';
this.inventoryContainer.style.display = 'none';
const empty = document.createElement('div');
empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 14px; padding: 16px 0;`;
empty.textContent = 'No items';
this.inventoryContent.appendChild(empty);
return;
}
this.inventorySeparator.style.display = '';
this.inventoryContainer.style.display = 'grid';
this.inventoryContainer.innerHTML = '';
// Section label spanning both columns
const label = document.createElement('div');
label.style.cssText = `
grid-column: 1 / -1;
font-size: 14px;
color: ${EB.textSecondary};
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 2px;
const grid = document.createElement('div');
grid.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 8px;
`;
label.textContent = 'Inventory';
this.inventoryContainer.appendChild(label);
for (const [itemId, qty] of items) {
const el = document.createElement('div');
@@ -481,8 +482,9 @@ export class NpcInfoPanel {
qtyEl.textContent = String(qty);
el.appendChild(nameEl);
el.appendChild(qtyEl);
this.inventoryContainer.appendChild(el);
grid.appendChild(el);
}
this.inventoryContent.appendChild(grid);
}
updateRecentEvents(events: NarrationEvent[]): void {
@@ -667,23 +669,24 @@ export class NpcInfoPanel {
valueEl.textContent = String(value);
}
private switchTab(tab: 'status' | 'relationships' | 'history'): void {
private switchTab(tab: 'status' | 'relationships' | 'history' | 'inventory'): void {
this.activeTab = tab;
const tabs = [
{ key: 'status' as const, tabEl: this.statusTab, contentEl: this.statusContent },
{ key: 'relationships' as const, tabEl: this.relationshipsTab, contentEl: this.relationshipsContent },
{ key: 'history' as const, tabEl: this.historyTab, contentEl: this.historyContent },
{ key: 'inventory' as const, tabEl: this.inventoryTab, contentEl: this.inventoryContent },
];
for (const t of tabs) {
if (t.key === tab) {
t.contentEl.style.display = '';
t.tabEl.style.color = EB.textPrimary;
t.tabEl.style.borderBottom = `2px solid ${EB.borderOuter}`;
t.tabEl.dataset.active = 'true';
t.tabEl.style.background = '#2a2a4e';
} else {
t.contentEl.style.display = 'none';
t.tabEl.style.color = EB.textMuted;
t.tabEl.style.borderBottom = 'none';
t.tabEl.dataset.active = 'false';
t.tabEl.style.background = '#1a1a2e';
}
}
}
@@ -717,7 +720,7 @@ export class NpcInfoPanel {
const tierIcons: Record<string, string> = {
'Partner': '\u2665', 'Devoted': '\u2764', 'Close Friend': '\u2605', 'Friend': '\u25C6',
'Acquaintance': '\u25CB', 'Stranger': '\u00B7',
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2620', 'Nemesis': '\u2620',
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2920', 'Nemesis': '\u2920',
};
for (const tier of tierOrder) {
+169
View File
@@ -0,0 +1,169 @@
import type { StockpileLogEntry } from '@dflike/shared';
const MAX_LOG_ENTRIES = 50;
export class StocksPanel {
private container: HTMLDivElement;
private summaryEl: HTMLDivElement;
private logEl: HTMLDivElement;
private emptyEl: HTMLDivElement;
private logEntries: StockpileLogEntry[] = [];
private _hasUnseen = false;
onUnseenChanged: ((hasUnseen: boolean) => void) | null = null;
constructor() {
this.container = document.createElement('div');
this.container.style.cssText = `
display: flex;
flex-direction: column;
height: 100%;
gap: 8px;
`;
// Summary section
const summaryLabel = document.createElement('div');
summaryLabel.style.cssText = `
color: #e0d0b0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
padding-bottom: 4px;
border-bottom: 1px solid #8878a8;
flex-shrink: 0;
`;
summaryLabel.textContent = 'Stockpiled Resources';
this.container.appendChild(summaryLabel);
this.summaryEl = document.createElement('div');
this.summaryEl.style.cssText = `
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
padding-bottom: 6px;
border-bottom: 1px solid #8878a8;
`;
this.container.appendChild(this.summaryEl);
// Log section
const logLabel = document.createElement('div');
logLabel.style.cssText = `
color: #e0d0b0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
flex-shrink: 0;
`;
logLabel.textContent = 'Activity';
this.container.appendChild(logLabel);
this.emptyEl = document.createElement('div');
this.emptyEl.style.cssText = `
color: #8878a8;
font-size: 10px;
text-align: center;
padding: 20px 0;
`;
this.emptyEl.textContent = 'No activity yet...';
this.container.appendChild(this.emptyEl);
this.logEl = document.createElement('div');
this.logEl.style.cssText = `
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
`;
this.container.appendChild(this.logEl);
}
getElement(): HTMLDivElement {
return this.container;
}
get hasUnseen(): boolean {
return this._hasUnseen;
}
clearUnseen(): void {
this._hasUnseen = false;
this.onUnseenChanged?.(false);
}
updateSummary(data: Record<string, number>): void {
this.summaryEl.innerHTML = '';
const items = Object.entries(data).filter(([, qty]) => qty > 0);
if (items.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'color: #8878a8; font-size: 10px; padding: 4px 0;';
empty.textContent = 'Empty';
this.summaryEl.appendChild(empty);
return;
}
for (const [itemId, qty] of items) {
const row = document.createElement('div');
row.style.cssText = `
display: flex;
justify-content: space-between;
font-size: 11px;
`;
const nameEl = document.createElement('span');
nameEl.style.cssText = 'color: #a0a0c0; text-transform: capitalize;';
nameEl.textContent = itemId.replace(/_/g, ' ');
const qtyEl = document.createElement('span');
qtyEl.style.cssText = 'color: #e0d0b0; text-shadow: 1px 1px 0 rgba(0,0,0,0.5);';
qtyEl.textContent = String(qty);
row.appendChild(nameEl);
row.appendChild(qtyEl);
this.summaryEl.appendChild(row);
}
}
addLogEntry(entry: StockpileLogEntry): void {
this.logEntries.push(entry);
if (this.logEntries.length > MAX_LOG_ENTRIES) {
this.logEntries.shift();
}
this.emptyEl.style.display = 'none';
const el = this.createLogLine(entry);
this.logEl.insertBefore(el, this.logEl.firstChild);
// Trim DOM if over limit
while (this.logEl.children.length > MAX_LOG_ENTRIES) {
this.logEl.removeChild(this.logEl.lastChild!);
}
this._hasUnseen = true;
this.onUnseenChanged?.(true);
}
loadHistory(entries: StockpileLogEntry[]): void {
this.logEntries = [...entries];
this.logEl.innerHTML = '';
if (entries.length === 0) {
this.emptyEl.style.display = '';
return;
}
this.emptyEl.style.display = 'none';
// Show newest first
for (let i = entries.length - 1; i >= 0; i--) {
this.logEl.appendChild(this.createLogLine(entries[i]));
}
}
private createLogLine(entry: StockpileLogEntry): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = `
font-size: 9px;
color: #a0a0c0;
padding: 2px 0;
border-bottom: 1px solid #1c1450;
line-height: 1.5;
`;
const verb = entry.action === 'dropoff' ? 'dropped off' : 'took';
const itemName = entry.itemId.replace(/_/g, ' ');
el.textContent = `${entry.npcName} ${verb} ${entry.quantity} ${itemName}`;
return el;
}
}
+43 -2
View File
@@ -15,17 +15,21 @@ import { industrySystem } from '../systems/industrySystem.js';
import { craftingSystem } from '../systems/craftingSystem.js';
import { buildingSystem } from '../systems/buildingSystem.js';
import { dropoffSystem } from '../systems/dropoffSystem.js';
import { pickupSystem } from '../systems/pickupSystem.js';
import { RecipeRegistry } from '../industry/recipeRegistry.js';
import { spawnNPC } from './spawner.js';
import { createLlmService, type LlmService } from '../llm/llmService.js';
import { generateBackstory } from '../llm/backstoryGenerator.js';
import { createNarrationService, type NarrationService } from '../llm/narrationService.js';
import { narrationEmitter } from '../systems/narrationEmitter.js';
import type { EntityId, InventionSummary } from '@dflike/shared';
import type { EntityId, InventionSummary, Position } from '@dflike/shared';
import type { StructureData } from '../systems/buildingSystem.js';
import { createThoughtSystem, type ThoughtSystem } from '../systems/thoughtSystem.js';
import { createEventMemoryService, type EventMemoryService } from '../llm/eventMemoryService.js';
import { createInventionSystem, type InventionSystem } from '../systems/inventionSystem.js';
import { createInventionTimeline } from '../industry/inventionTimeline.js';
import { createStockpileLog } from '../industry/stockpileLog.js';
import type { StockpileLogEntry } from '@dflike/shared';
export class GameLoop {
readonly world: World;
@@ -37,6 +41,7 @@ export class GameLoop {
readonly inventionSystem: InventionSystem;
public followedEntityIds: Set<EntityId> = new Set();
public onInventionCreated: ((summary: InventionSummary) => void) | null = null;
public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null;
private tick = 0;
private interval: ReturnType<typeof setInterval> | null = null;
private onBroadcast: (() => void) | null = null;
@@ -47,6 +52,7 @@ export class GameLoop {
this.world.setSingleton('itemRegistry', ItemRegistry.createDefault());
this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
this.world.setSingleton('inventionTimeline', createInventionTimeline());
this.world.setSingleton('stockpileLog', createStockpileLog());
this.map = new GameMap();
this.llmService = createLlmService();
this.narrationService = createNarrationService(this.llmService);
@@ -59,6 +65,7 @@ export class GameLoop {
(summary) => this.onInventionCreated?.(summary),
);
this.setupMap();
this.spawnDefaultStockpiles();
this.spawnInitialNPCs(8);
}
@@ -83,6 +90,39 @@ export class GameLoop {
}
}
private spawnDefaultStockpiles(): void {
const cx = Math.floor(this.map.width / 2);
const cy = Math.floor(this.map.height / 2);
// Find two walkable positions near the center for wood and stone stockpiles
const positions: Position[] = [];
outer:
for (let r = 0; positions.length < 2; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (r > 0 && Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const x = cx + dx;
const y = cy + dy;
if (this.map.isWalkable(x, y) && !positions.some(p => p.x === x && p.y === y)) {
positions.push({ x, y });
if (positions.length >= 2) break outer;
}
}
}
}
const subtypes = ['wood', 'stone'];
for (let i = 0; i < positions.length; i++) {
const entity = this.world.createEntity();
this.world.addComponent<Position>(entity, 'position', positions[i]);
this.world.addComponent<StructureData>(entity, 'structure', {
type: 'stockpile',
subtype: subtypes[i],
inventory: new Map(),
});
}
}
generateNpcBackstory(entityId: number): void {
generateBackstory(this.world, entityId, this.llmService);
}
@@ -122,10 +162,11 @@ export class GameLoop {
narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService);
relationshipSystem(this.world, this.eventMemoryService);
industrySystem(this.world, this.map);
pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry));
gatheringSystem(this.world, this.map);
craftingSystem(this.world);
buildingSystem(this.world, this.map);
dropoffSystem(this.world, this.map);
dropoffSystem(this.world, this.map, this.tick, (entry) => this.onStockpileEvent?.(entry));
movementSystem(this.world);
this.thoughtSystem.update(this.world, this.followedEntityIds, this.tick);
this.inventionSystem.update(this.world, this.tick);
+25
View File
@@ -0,0 +1,25 @@
import type { StockpileLogEntry } from '@dflike/shared';
export interface StockpileLog {
record(entry: StockpileLogEntry): void;
getRecent(): StockpileLogEntry[];
}
const MAX_ENTRIES = 50;
export function createStockpileLog(): StockpileLog {
const entries: StockpileLogEntry[] = [];
return {
record(entry: StockpileLogEntry): void {
entries.push(entry);
if (entries.length > MAX_ENTRIES) {
entries.shift();
}
},
getRecent(): StockpileLogEntry[] {
return [...entries];
},
};
}
+26
View File
@@ -9,6 +9,8 @@ import { serializeWorldState, serializeStateUpdate } from './stateSerializer.js'
import { computeSuperlatives } from '../systems/superlativesComputer.js';
import type { BondRegistry } from '../systems/bondRegistry.js';
import type { InventionTimeline } from '../industry/inventionTimeline.js';
import type { StockpileLog } from '../industry/stockpileLog.js';
import type { StructureData } from '../systems/buildingSystem.js';
export class SocketServer {
private io: Server<ClientEvents, ServerEvents>;
@@ -51,6 +53,11 @@ export class SocketServer {
this.io.emit('invention-event', summary);
};
this.gameLoop.onStockpileEvent = (entry) => {
this.io.emit('stockpile-event', entry);
this.io.emit('stockpile-summary', this.computeStockpileSummary());
};
this.gameLoop.eventMemoryService.onEventRecorded = (entityId, event) => {
// Only send to clients following this NPC
for (const [socketId, followedId] of this.followedEntities) {
@@ -94,6 +101,13 @@ export class SocketServer {
socket.emit('invention-history', inventionTimeline.getSummaries());
}
// Send stockpile history and summary
const stockpileLog = this.gameLoop.world.getSingleton<StockpileLog>('stockpileLog');
if (stockpileLog) {
socket.emit('stockpile-history', stockpileLog.getRecent());
}
socket.emit('stockpile-summary', this.computeStockpileSummary());
// Notify others
socket.broadcast.emit('player-joined', { playerId, entityId: entity });
@@ -163,6 +177,18 @@ export class SocketServer {
});
}
private computeStockpileSummary(): Record<string, number> {
const summary: Record<string, number> = {};
for (const entity of this.gameLoop.world.query('structure')) {
const s = this.gameLoop.world.getComponent<StructureData>(entity, 'structure')!;
if (s.type !== 'stockpile' || s.buildProgress !== undefined) continue;
for (const [itemId, qty] of s.inventory) {
summary[itemId] = (summary[itemId] ?? 0) + qty;
}
}
return summary;
}
private broadcastSuperlatives(): void {
if (this.superlativesSubscribers.size === 0) return;
const registry = this.gameLoop.world.getSingleton<BondRegistry>('bondRegistry');
+14 -2
View File
@@ -1,10 +1,18 @@
import type { Movement, NPCBrain, Position } from '@dflike/shared';
import type { Movement, NPCBrain, Position, StockpileLogEntry } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import type { StructureData } from './buildingSystem.js';
import type { StockpileLog } from '../industry/stockpileLog.js';
import { addItem } from '../industry/inventoryHelpers.js';
export function dropoffSystem(world: World, _map: GameMap): void {
export function dropoffSystem(
world: World,
_map: GameMap,
tick: number,
onStockpileEvent?: (entry: StockpileLogEntry) => void,
): void {
const stockpileLog = world.getSingleton<StockpileLog>('stockpileLog');
for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) {
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
if (brain.currentGoal !== 'dropoff') continue;
@@ -22,8 +30,12 @@ export function dropoffSystem(world: World, _map: GameMap): void {
const sPos = world.getComponent<Position>(sEntity, 'position')!;
if (sPos.x !== pos.x || sPos.y !== pos.y) continue;
const npcName = world.getComponent<string>(entity, 'name') ?? 'Unknown';
for (const [itemId, qty] of inv) {
addItem(sData.inventory, itemId, qty);
const entry: StockpileLogEntry = { npcName, action: 'dropoff', itemId, quantity: qty, tick };
stockpileLog?.record(entry);
onStockpileEvent?.(entry);
}
inv.clear();
deposited = true;
+13 -16
View File
@@ -38,24 +38,21 @@ export function gatheringSystem(world: World, map: GameMap): void {
}
world.removeComponent(entity, 'gatheringState');
// Decide next action
if (needs.productivity >= PRODUCTIVITY_THRESHOLD) {
brain.currentGoal = 'wander';
} else {
// Check if a stockpile exists for drop-off
let hasStockpile = false;
for (const sEntity of world.query('structure')) {
const sData = world.getComponent<StructureData>(sEntity, 'structure');
if (sData && sData.type === 'stockpile' && sData.buildProgress === undefined) {
hasStockpile = true;
break;
}
// Decide next action: always drop off first if possible
let hasStockpile = false;
for (const sEntity of world.query('structure')) {
const sData = world.getComponent<StructureData>(sEntity, 'structure');
if (sData && sData.type === 'stockpile' && sData.buildProgress === undefined) {
hasStockpile = true;
break;
}
if (hasStockpile && inv && inv.size > 0) {
brain.currentGoal = 'dropoff';
}
// else brain stays on 'gather', npcBrain will pick new target next tick
}
if (hasStockpile && inv && inv.size > 0) {
brain.currentGoal = 'dropoff';
} else if (needs.productivity >= PRODUCTIVITY_THRESHOLD) {
brain.currentGoal = 'wander';
}
// else brain stays on 'gather', npcBrain will pick new target next tick
}
continue;
}
+42
View File
@@ -7,6 +7,7 @@ import type { GameMap } from '../map/GameMap.js';
import type { GatheringState } from './gatheringSystem.js';
import type { CraftingState } from './craftingSystem.js';
import type { BuildingState, StructureData } from './buildingSystem.js';
import type { PickupState } from './pickupSystem.js';
import { RecipeRegistry } from '../industry/recipeRegistry.js';
import { getEffectiveStat } from './statHelpers.js';
import { industryConfig } from '../config/industryConfig.js';
@@ -56,6 +57,7 @@ export function industrySystem(world: World, map: GameMap): void {
if (brain.currentGoal === 'gather' && world.getComponent<GatheringState>(entity, 'gatheringState')) continue;
if (brain.currentGoal === 'gather' || brain.currentGoal === 'eat' || brain.currentGoal === 'rest') continue;
if (brain.currentGoal === 'dropoff') continue;
if (brain.currentGoal === 'pickup') continue;
const inv = world.getComponent<Map<string, number>>(entity, 'inventory')!;
const pos = world.getComponent<Position>(entity, 'position')!;
@@ -114,6 +116,46 @@ export function industrySystem(world: World, map: GameMap): void {
}
if (didBuild) continue;
// Try pickup from stockpile for crafting
let didPickup = false;
const allCraftRecipes = registry.getAll().filter(r => !r.id.startsWith('build_'));
for (const recipe of allCraftRecipes) {
const neededItems: { itemId: string; quantity: number }[] = [];
for (const input of recipe.inputs) {
const haveInInv = inv.get(input.itemId) ?? 0;
const stillNeed = input.quantity - haveInInv;
if (stillNeed > 0) {
neededItems.push({ itemId: input.itemId, quantity: stillNeed });
}
}
if (neededItems.length === 0) continue; // NPC can already craft — handled above
// Find a stockpile that has all needed items
let stockpileEntity: number | null = null;
for (const sEntity of world.query('structure', 'position')) {
const sData = world.getComponent<StructureData>(sEntity, 'structure')!;
if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue;
const hasAll = neededItems.every(n => (sData.inventory.get(n.itemId) ?? 0) >= n.quantity);
if (hasAll) {
stockpileEntity = sEntity;
break;
}
}
if (stockpileEntity !== null) {
brain.currentGoal = 'pickup';
world.addComponent<PickupState>(entity, 'pickupState', {
stockpileEntityId: stockpileEntity,
items: neededItems,
});
didPickup = true;
break;
}
}
if (didPickup) continue;
// Fallback: gather
brain.currentGoal = 'gather';
}
+23 -1
View File
@@ -9,6 +9,7 @@ import type { EventMemoryService } from '../llm/eventMemoryService.js';
import type { CraftingState } from './craftingSystem.js';
import type { BuildingState, StructureData } from './buildingSystem.js';
import type { GatheringState } from './gatheringSystem.js';
import type { PickupState } from './pickupSystem.js';
function findNearestResource(map: GameMap, from: Position): Position | null {
const tiles = map.resourceTiles;
@@ -92,6 +93,27 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?:
brain.currentGoal = null;
}
// Preserve dropoff goal — NPC should finish delivering items before switching
if (brain.currentGoal === 'dropoff') continue;
// Preserve pickup goal — NPC should finish picking up items
if (brain.currentGoal === 'pickup') {
const ps = world.getComponent<PickupState>(entity, 'pickupState');
if (ps) {
const sPos = world.getComponent<Position>(ps.stockpileEntityId, 'position');
if (sPos) {
const path = findPath(map, pos, sPos);
if (path && path.length > 0) {
movement.state = 'walking';
movement.target = sPos;
movement.path = path;
movement.direction = directionTo(pos, path[0]);
}
}
}
continue;
}
// Determine new goal based on needs priority
const prevGoal = brain.currentGoal;
let goal: GoalType = 'wander';
@@ -103,7 +125,7 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?:
if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) {
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
const goalLabels: Record<string, string> = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items' };
const goalLabels: Record<string, string> = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items', pickup: 'picking up materials' };
eventMemoryService.record(entity, {
type: 'goal_change',
tick: 0,
+61
View File
@@ -0,0 +1,61 @@
import type { Movement, NPCBrain, Position, StockpileLogEntry } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { StructureData } from './buildingSystem.js';
import { addItem, removeItem } from '../industry/inventoryHelpers.js';
import type { StockpileLog } from '../industry/stockpileLog.js';
export interface PickupState {
stockpileEntityId: number;
items: { itemId: string; quantity: number }[];
}
export function pickupSystem(
world: World,
tick: number,
onStockpileEvent?: (entry: StockpileLogEntry) => void,
): void {
const stockpileLog = world.getSingleton<StockpileLog>('stockpileLog');
for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) {
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
if (brain.currentGoal !== 'pickup') continue;
const movement = world.getComponent<Movement>(entity, 'movement')!;
if (movement.state !== 'idle') continue;
const pickupState = world.getComponent<PickupState>(entity, 'pickupState');
if (!pickupState) {
brain.currentGoal = 'wander';
continue;
}
const pos = world.getComponent<Position>(entity, 'position')!;
const sPos = world.getComponent<Position>(pickupState.stockpileEntityId, 'position');
if (!sPos || sPos.x !== pos.x || sPos.y !== pos.y) continue;
const sData = world.getComponent<StructureData>(pickupState.stockpileEntityId, 'structure');
if (!sData) {
world.removeComponent(entity, 'pickupState');
brain.currentGoal = 'wander';
continue;
}
const inv = world.getComponent<Map<string, number>>(entity, 'inventory')!;
const npcName = world.getComponent<string>(entity, 'name') ?? 'Unknown';
for (const needed of pickupState.items) {
const available = sData.inventory.get(needed.itemId) ?? 0;
const take = Math.min(available, needed.quantity);
if (take > 0) {
removeItem(sData.inventory, needed.itemId, take);
addItem(inv, needed.itemId, take);
const entry: StockpileLogEntry = { npcName, action: 'pickup', itemId: needed.itemId, quantity: take, tick };
stockpileLog?.record(entry);
onStockpileEvent?.(entry);
}
}
world.removeComponent(entity, 'pickupState');
brain.currentGoal = 'craft';
}
}
+12 -1
View File
@@ -80,7 +80,7 @@ export interface StatModifiers {
}
export type MovementState = 'idle' | 'walking';
export type GoalType = 'wander' | 'eat' | 'rest' | 'gather' | 'craft' | 'build' | 'dropoff';
export type GoalType = 'wander' | 'eat' | 'rest' | 'gather' | 'craft' | 'build' | 'dropoff' | 'pickup';
export interface Movement {
state: MovementState;
@@ -227,6 +227,14 @@ export interface InventionSummary {
day: number;
}
export interface StockpileLogEntry {
npcName: string;
action: 'dropoff' | 'pickup';
itemId: string;
quantity: number;
tick: number;
}
// Server -> Client events
export interface ServerEvents {
'world-state': (data: WorldState) => void;
@@ -243,6 +251,9 @@ export interface ServerEvents {
'memory-history': (data: { entityId: EntityId; events: MemoryEvent[] }) => void;
'invention-event': (data: InventionSummary) => void;
'invention-history': (data: InventionSummary[]) => void;
'stockpile-event': (data: StockpileLogEntry) => void;
'stockpile-history': (data: StockpileLogEntry[]) => void;
'stockpile-summary': (data: Record<string, number>) => void;
}
// Client -> Server events