From 9e02cd973e64b345f65f64be712f3f783f7b62b9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:47:26 +0000 Subject: [PATCH 01/10] feat(shared): add StockpileLogEntry type, pickup goal, and stockpile socket events Co-Authored-By: Claude Opus 4.6 --- shared/src/types.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/shared/src/types.ts b/shared/src/types.ts index 02c60e5..954d61c 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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) => void; } // Client -> Server events From 9194a568480831662e003d14518d04ff85e0859d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:47:35 +0000 Subject: [PATCH 02/10] feat(server): add StockpileLog singleton for tracking stockpile activity Co-Authored-By: Claude Opus 4.6 --- server/src/industry/stockpileLog.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 server/src/industry/stockpileLog.ts diff --git a/server/src/industry/stockpileLog.ts b/server/src/industry/stockpileLog.ts new file mode 100644 index 0000000..0427731 --- /dev/null +++ b/server/src/industry/stockpileLog.ts @@ -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]; + }, + }; +} From 188a9157321403dc2802ced67d416b77f1ec8eed Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:48:23 +0000 Subject: [PATCH 03/10] feat(server): wire StockpileLog into GameLoop and SocketServer Co-Authored-By: Claude Opus 4.6 --- server/src/game/GameLoop.ts | 41 +++++++++++++++++++++++++++++- server/src/network/SocketServer.ts | 26 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 0d45eff..6d15305 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -21,11 +21,14 @@ 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 +40,7 @@ export class GameLoop { readonly inventionSystem: InventionSystem; public followedEntityIds: Set = new Set(); public onInventionCreated: ((summary: InventionSummary) => void) | null = null; + public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null; private tick = 0; private interval: ReturnType | null = null; private onBroadcast: (() => void) | null = null; @@ -47,6 +51,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 +64,7 @@ export class GameLoop { (summary) => this.onInventionCreated?.(summary), ); this.setupMap(); + this.spawnDefaultStockpiles(); this.spawnInitialNPCs(8); } @@ -83,6 +89,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(entity, 'position', positions[i]); + this.world.addComponent(entity, 'structure', { + type: 'stockpile', + subtype: subtypes[i], + inventory: new Map(), + }); + } + } + generateNpcBackstory(entityId: number): void { generateBackstory(this.world, entityId, this.llmService); } diff --git a/server/src/network/SocketServer.ts b/server/src/network/SocketServer.ts index b153dd7..694771c 100644 --- a/server/src/network/SocketServer.ts +++ b/server/src/network/SocketServer.ts @@ -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; @@ -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'); + 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 { + const summary: Record = {}; + for (const entity of this.gameLoop.world.query('structure')) { + const s = this.gameLoop.world.getComponent(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'); From 60c6d7808d2e88c09e3fe4f8abce0d3838f97186 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:49:24 +0000 Subject: [PATCH 04/10] feat(server): emit stockpile log events from dropoffSystem Co-Authored-By: Claude Opus 4.6 --- server/src/game/GameLoop.ts | 2 +- server/src/systems/dropoffSystem.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 6d15305..8c949a1 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -164,7 +164,7 @@ export class GameLoop { 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); diff --git a/server/src/systems/dropoffSystem.ts b/server/src/systems/dropoffSystem.ts index faf07d1..79eeb84 100644 --- a/server/src/systems/dropoffSystem.ts +++ b/server/src/systems/dropoffSystem.ts @@ -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'); + for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) { const brain = world.getComponent(entity, 'npcBrain')!; if (brain.currentGoal !== 'dropoff') continue; @@ -22,8 +30,12 @@ export function dropoffSystem(world: World, _map: GameMap): void { const sPos = world.getComponent(sEntity, 'position')!; if (sPos.x !== pos.x || sPos.y !== pos.y) continue; + const npcName = world.getComponent(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; From 9f77a84d6f575401df9399ed6b5dae19d31c320f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:52:37 +0000 Subject: [PATCH 05/10] feat(server): add pickup system for NPC stockpile material retrieval NPCs can now retrieve crafting materials from stockpiles instead of always gathering raw resources. The industry system checks if a stockpile has needed materials before falling back to gathering. Co-Authored-By: Claude Opus 4.6 --- client/src/ui/NpcInfoPanel.ts | 1 + server/src/game/GameLoop.ts | 2 + server/src/systems/industrySystem.ts | 42 +++++++++++++++++++ server/src/systems/npcBrainSystem.ts | 24 ++++++++++- server/src/systems/pickupSystem.ts | 61 ++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 server/src/systems/pickupSystem.ts diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 6fe1c21..dc9159b 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -8,6 +8,7 @@ const ACTIVITY_LABELS: Record = { craft: 'Crafting', build: 'Building', dropoff: 'Dropping off', + pickup: 'Picking up', }; // EarthBound-inspired color palette diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 8c949a1..748c583 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -15,6 +15,7 @@ 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'; @@ -161,6 +162,7 @@ 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); diff --git a/server/src/systems/industrySystem.ts b/server/src/systems/industrySystem.ts index c9c062a..44f2086 100644 --- a/server/src/systems/industrySystem.ts +++ b/server/src/systems/industrySystem.ts @@ -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(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>(entity, 'inventory')!; const pos = world.getComponent(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(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(entity, 'pickupState', { + stockpileEntityId: stockpileEntity, + items: neededItems, + }); + didPickup = true; + break; + } + } + if (didPickup) continue; + // Fallback: gather brain.currentGoal = 'gather'; } diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts index 5a87fd0..81ae51e 100644 --- a/server/src/systems/npcBrainSystem.ts +++ b/server/src/systems/npcBrainSystem.ts @@ -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(entity, 'pickupState'); + if (ps) { + const sPos = world.getComponent(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(entity, 'name') ?? 'Unknown'; - const goalLabels: Record = { 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 = { 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, diff --git a/server/src/systems/pickupSystem.ts b/server/src/systems/pickupSystem.ts new file mode 100644 index 0000000..eb1abe2 --- /dev/null +++ b/server/src/systems/pickupSystem.ts @@ -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'); + + for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'pickup') continue; + + const movement = world.getComponent(entity, 'movement')!; + if (movement.state !== 'idle') continue; + + const pickupState = world.getComponent(entity, 'pickupState'); + if (!pickupState) { + brain.currentGoal = 'wander'; + continue; + } + + const pos = world.getComponent(entity, 'position')!; + const sPos = world.getComponent(pickupState.stockpileEntityId, 'position'); + if (!sPos || sPos.x !== pos.x || sPos.y !== pos.y) continue; + + const sData = world.getComponent(pickupState.stockpileEntityId, 'structure'); + if (!sData) { + world.removeComponent(entity, 'pickupState'); + brain.currentGoal = 'wander'; + continue; + } + + const inv = world.getComponent>(entity, 'inventory')!; + const npcName = world.getComponent(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'; + } +} From 803e6c9b8c0bd5f6009652beab465ee603624549 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:55:44 +0000 Subject: [PATCH 06/10] feat(client): add stockpile socket callbacks to SocketClient Co-Authored-By: Claude Opus 4.6 --- client/src/network/SocketClient.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/network/SocketClient.ts b/client/src/network/SocketClient.ts index cf797fa..f2d8e37 100644 --- a/client/src/network/SocketClient.ts +++ b/client/src/network/SocketClient.ts @@ -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; @@ -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) => 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; } From 3dbf5b707b58df0f3fa2bc83a2b114acb0dada56 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:55:47 +0000 Subject: [PATCH 07/10] feat(client): add StocksPanel UI component Co-Authored-By: Claude Opus 4.6 --- client/src/ui/StocksPanel.ts | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 client/src/ui/StocksPanel.ts diff --git a/client/src/ui/StocksPanel.ts b/client/src/ui/StocksPanel.ts new file mode 100644 index 0000000..e091628 --- /dev/null +++ b/client/src/ui/StocksPanel.ts @@ -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): 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; + } +} From 797ea2d41098a299f3c8e610bdf733313c9d8803 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:55:52 +0000 Subject: [PATCH 08/10] feat(client): wire StocksPanel into LeftPanel with R tab Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 25 +++++++++++++++ client/src/ui/LeftPanel.ts | 57 +++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index eb06b1a..11b3ad8 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -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(); diff --git a/client/src/ui/LeftPanel.ts b/client/src/ui/LeftPanel.ts index 3d7e863..bd32a95 100644 --- a/client/src/ui/LeftPanel.ts +++ b/client/src/ui/LeftPanel.ts @@ -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(); } From 067976f9a227ae931a1e1e61d0337863abe7a124 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:59:22 +0000 Subject: [PATCH 09/10] feat(client): rework NPC panel tabs to vertical left-edge S/R/H/I style Replace horizontal Status/Relations/History tabs with vertical single-letter tabs (S/R/H/I) on the left edge of the panel. Add dedicated Inventory tab, removing inventory from the Status tab. Panel structure reworked to flex row with tab column + panel body, matching the LeftPanel's tab styling but mirrored. Co-Authored-By: Claude Opus 4.6 --- client/src/ui/NpcInfoPanel.ts | 240 +++++++++++++++++----------------- 1 file changed, 121 insertions(+), 119 deletions(-) diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index dc9159b..6ef8f53 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -32,7 +32,6 @@ const EB = { }; export class NpcInfoPanel { - private container: HTMLDivElement; private outerFrame: HTMLDivElement; private portraitImg: HTMLImageElement; private nameEl: HTMLDivElement; @@ -45,46 +44,81 @@ export class NpcInfoPanel { private backstoryEl: HTMLDivElement; private statsContainer: HTMLDivElement; private statElements: Map = 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 = 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}; @@ -111,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'); @@ -157,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; @@ -340,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'); @@ -382,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); @@ -443,29 +450,22 @@ export class NpcInfoPanel { } private updateInventory(inventory?: Record): 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'); @@ -482,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 { @@ -668,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'; } } } @@ -718,7 +720,7 @@ export class NpcInfoPanel { const tierIcons: Record = { '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) { From 9a3e9d4757aff6cd11eafb28d45b24d0e0ae415e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 03:00:55 +0000 Subject: [PATCH 10/10] fix(server): prioritize stockpile dropoff over wandering after gathering Co-Authored-By: Claude Opus 4.6 --- server/src/systems/gatheringSystem.ts | 29 ++++++++++++--------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/server/src/systems/gatheringSystem.ts b/server/src/systems/gatheringSystem.ts index 0e24541..ba26717 100644 --- a/server/src/systems/gatheringSystem.ts +++ b/server/src/systems/gatheringSystem.ts @@ -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(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(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; }