From 35f3b4ea373209472063c3c3ffbfa0bda475160a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:45:44 +0000 Subject: [PATCH] docs: add implementation plan for stocks panel, NPC tabs, and pickup mechanic Co-Authored-By: Claude Opus 4.6 --- .../2026-03-09-stocks-panel-npc-tabs-impl.md | 1201 +++++++++++++++++ 1 file changed, 1201 insertions(+) create mode 100644 docs/plans/2026-03-09-stocks-panel-npc-tabs-impl.md diff --git a/docs/plans/2026-03-09-stocks-panel-npc-tabs-impl.md b/docs/plans/2026-03-09-stocks-panel-npc-tabs-impl.md new file mode 100644 index 0000000..0130b14 --- /dev/null +++ b/docs/plans/2026-03-09-stocks-panel-npc-tabs-impl.md @@ -0,0 +1,1201 @@ +# Stocks Panel, NPC Panel Tabs & Pickup Mechanic — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a Resources tab to the left panel showing stockpile totals + activity log, rework NPC panel tabs to vertical left-edge style (S/R/H/I), and implement stockpile pickup so NPCs can take materials from stockpiles to craft. + +**Architecture:** Three independent features that can be built in sequence. Server gets a StockpileLog singleton + pickup system. Client gets a StocksPanel component and NPC panel tab rework. Shared types bridge the socket events. + +**Tech Stack:** TypeScript, Socket.io, Phaser 3 (client), vitest (tests) + +--- + +### Task 1: Shared Types — StockpileLogEntry + Socket Events + +**Files:** +- Modify: `shared/src/types.ts:83` (GoalType), `shared/src/types.ts:231-246` (ServerEvents) + +**Step 1: Add 'pickup' to GoalType** + +In `shared/src/types.ts`, line 83, change: +```typescript +export type GoalType = 'wander' | 'eat' | 'rest' | 'gather' | 'craft' | 'build' | 'dropoff'; +``` +to: +```typescript +export type GoalType = 'wander' | 'eat' | 'rest' | 'gather' | 'craft' | 'build' | 'dropoff' | 'pickup'; +``` + +**Step 2: Add StockpileLogEntry and new ServerEvents** + +After the `InventionSummary` interface (line 228), add: +```typescript +export interface StockpileLogEntry { + npcName: string; + action: 'dropoff' | 'pickup'; + itemId: string; + quantity: number; + tick: number; +} +``` + +In `ServerEvents` (line 231-246), add three new events after `'invention-history'`: +```typescript + 'stockpile-event': (data: StockpileLogEntry) => void; + 'stockpile-history': (data: StockpileLogEntry[]) => void; + 'stockpile-summary': (data: Record) => void; +``` + +**Step 3: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean build, no errors. + +**Step 4: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat(shared): add StockpileLogEntry type, pickup goal, and stockpile socket events" +``` + +--- + +### Task 2: Server — StockpileLog Singleton + +**Files:** +- Create: `server/src/industry/stockpileLog.ts` + +**Step 1: Write the StockpileLog module** + +Model after `server/src/industry/inventionTimeline.ts`. Create `server/src/industry/stockpileLog.ts`: + +```typescript +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]; + }, + }; +} +``` + +**Step 2: Commit** + +```bash +git add server/src/industry/stockpileLog.ts +git commit -m "feat(server): add StockpileLog singleton for tracking stockpile activity" +``` + +--- + +### Task 3: Server — Wire StockpileLog into GameLoop + SocketServer + +**Files:** +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/network/SocketServer.ts` + +**Step 1: Add StockpileLog to GameLoop** + +In `server/src/game/GameLoop.ts`: + +Add import at top: +```typescript +import { createStockpileLog } from '../industry/stockpileLog.js'; +import type { StockpileLogEntry } from '@dflike/shared'; +``` + +Add callback and accessor after `onInventionCreated` (line 40): +```typescript + public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null; +``` + +In the constructor, after `this.world.setSingleton('inventionTimeline', ...)` (line 50), add: +```typescript + this.world.setSingleton('stockpileLog', createStockpileLog()); +``` + +**Step 2: Wire stockpile events in SocketServer** + +In `server/src/network/SocketServer.ts`: + +Add import: +```typescript +import type { StockpileLog } from '../industry/stockpileLog.js'; +``` + +In `setupBroadcast()`, after `this.gameLoop.onInventionCreated = ...` (line 52), add: +```typescript + this.gameLoop.onStockpileEvent = (entry) => { + this.io.emit('stockpile-event', entry); + // Also send updated summary + this.io.emit('stockpile-summary', this.computeStockpileSummary()); + }; +``` + +Add helper method to SocketServer class: +```typescript + 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; + } +``` + +Add import for StructureData: +```typescript +import type { StructureData } from '../systems/buildingSystem.js'; +``` + +In `setupConnections()`, after the invention-history emit (line 95), add: +```typescript + // 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()); +``` + +**Step 3: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/network/SocketServer.ts +git commit -m "feat(server): wire StockpileLog into GameLoop and SocketServer" +``` + +--- + +### Task 4: Server — Emit Stockpile Events from dropoffSystem + +**Files:** +- Modify: `server/src/systems/dropoffSystem.ts` + +**Step 1: Add stockpile log recording to dropoffSystem** + +The dropoff system needs access to the StockpileLog and the current tick. Modify the system signature and add logging. + +Replace the entire file: + +```typescript +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 { addItem } from '../industry/inventoryHelpers.js'; +import type { StockpileLog } from '../industry/stockpileLog.js'; + +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; + + const movement = world.getComponent(entity, 'movement')!; + if (movement.state !== 'idle') continue; + + const pos = world.getComponent(entity, 'position')!; + const inv = world.getComponent>(entity, 'inventory')!; + + let deposited = false; + for (const sEntity of world.query('structure', 'position')) { + const sData = world.getComponent(sEntity, 'structure')!; + if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue; + 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; + break; + } + + if (deposited) { + brain.currentGoal = 'wander'; + } + } +} +``` + +**Step 2: Update GameLoop call site** + +In `server/src/game/GameLoop.ts`, change the dropoffSystem call (line 163) from: +```typescript + dropoffSystem(this.world, this.map); +``` +to: +```typescript + dropoffSystem(this.world, this.map, this.tick, (entry) => this.onStockpileEvent?.(entry)); +``` + +**Step 3: Verify tests still pass** + +Run: `npm -w server run test` +Expected: All tests pass (dropoffSystem tests may need updating if they check the signature). + +**Step 4: Commit** + +```bash +git add server/src/systems/dropoffSystem.ts server/src/game/GameLoop.ts +git commit -m "feat(server): emit stockpile log events from dropoffSystem" +``` + +--- + +### Task 5: Server — Pickup System + +**Files:** +- Create: `server/src/systems/pickupSystem.ts` +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/systems/npcBrainSystem.ts` +- Modify: `server/src/systems/industrySystem.ts` + +**Step 1: Create pickupSystem** + +Create `server/src/systems/pickupSystem.ts`: + +```typescript +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'; + } +} +``` + +**Step 2: Add pickup to industrySystem** + +In `server/src/systems/industrySystem.ts`, add import: +```typescript +import type { PickupState } from './pickupSystem.js'; +``` + +Before the `// Fallback: gather` line (line 117), add logic to check stockpiles for craft materials: + +```typescript + // Try pickup from stockpile for crafting + const allCraftRecipes = registry.getAll().filter(r => !r.id.startsWith('build_')); + for (const recipe of allCraftRecipes) { + // Check if stockpile has the needed inputs + let stockpileEntity: number | null = null; + let canFulfill = true; + 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 this — industry handles it above + + // Find a stockpile that has all needed items + 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, + }); + didBuild = true; // reuse flag to skip gather + break; + } + } + if (didBuild) continue; +``` + +**Step 3: Add pickup goal to npcBrainSystem** + +In `server/src/systems/npcBrainSystem.ts`: + +Add import: +```typescript +import type { PickupState } from './pickupSystem.js'; +``` + +After the `dropoff` preserve line (line 96), add: +```typescript + // Preserve pickup goal + if (brain.currentGoal === 'pickup') continue; +``` + +In the `goalLabels` record (line 109), add: `pickup: 'picking up materials'`. + +Add pickup target pathfinding after the dropoff block (line 146), before the `if (!target)` fallback: +```typescript + if (goal === 'pickup' as GoalType) { + const ps = world.getComponent(entity, 'pickupState'); + if (ps) { + const sPos = world.getComponent(ps.stockpileEntityId, 'position'); + if (sPos) target = { x: sPos.x, y: sPos.y }; + } + if (!target) { + brain.currentGoal = 'wander'; + goal = 'wander'; + } + } +``` + +**Step 4: Add pickup to ACTIVITY_LABELS in NpcInfoPanel** + +In `client/src/ui/NpcInfoPanel.ts`, line 10, add to ACTIVITY_LABELS: +```typescript + pickup: 'Picking up', +``` + +**Step 5: Register pickupSystem in GameLoop** + +In `server/src/game/GameLoop.ts`: + +Add import: +```typescript +import { pickupSystem } from '../systems/pickupSystem.js'; +``` + +In the `update()` method, after `industrySystem(this.world, this.map)` (line 159) and before `gatheringSystem`: +```typescript + pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry)); +``` + +**Step 6: Verify tests pass** + +Run: `npm -w server run test` + +**Step 7: Commit** + +```bash +git add server/src/systems/pickupSystem.ts server/src/systems/industrySystem.ts server/src/systems/npcBrainSystem.ts server/src/game/GameLoop.ts client/src/ui/NpcInfoPanel.ts +git commit -m "feat(server): add pickup system for NPC stockpile material retrieval" +``` + +--- + +### Task 6: Client — SocketClient Stockpile Callbacks + +**Files:** +- Modify: `client/src/network/SocketClient.ts` + +**Step 1: Add stockpile callbacks and socket listeners** + +In `client/src/network/SocketClient.ts`: + +Add to imports (line 2): +```typescript +import type { ..., StockpileLogEntry } from '@dflike/shared'; +``` + +After `onInventionHistory` callback (line 24), add: +```typescript + onStockpileEvent: ((data: StockpileLogEntry) => void) | null = null; + onStockpileHistory: ((data: StockpileLogEntry[]) => void) | null = null; + onStockpileSummary: ((data: Record) => void) | null = null; +``` + +After the invention-history listener (line 60), add: +```typescript + 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)); +``` + +**Step 2: Commit** + +```bash +git add client/src/network/SocketClient.ts +git commit -m "feat(client): add stockpile socket callbacks to SocketClient" +``` + +--- + +### Task 7: Client — StocksPanel Component + +**Files:** +- Create: `client/src/ui/StocksPanel.ts` + +**Step 1: Create StocksPanel** + +Model after `client/src/ui/InventionsPanel.ts`. Create `client/src/ui/StocksPanel.ts`: + +```typescript +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 summary: Record = {}; + 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.summary = data; + this.renderSummary(); + } + + 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 renderSummary(): void { + this.summaryEl.innerHTML = ''; + const items = Object.entries(this.summary).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); + } + } + + 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; + } +} +``` + +**Step 2: Commit** + +```bash +git add client/src/ui/StocksPanel.ts +git commit -m "feat(client): add StocksPanel UI component" +``` + +--- + +### Task 8: Client — Wire StocksPanel into LeftPanel + GameScene + +**Files:** +- Modify: `client/src/ui/LeftPanel.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add R tab to LeftPanel** + +In `client/src/ui/LeftPanel.ts`: + +Update type unions. Change `activeTab` type (line 12): +```typescript + private activeTab: 'stats' | 'events' | 'inventions' | 'resources' | null = null; +``` + +Add private field (after `inventionsContent`, line 9): +```typescript + private resourcesTab: HTMLDivElement; + private resourcesContent: HTMLDivElement; +``` + +Update constructor signature (line 16) to accept 4th content: +```typescript + constructor(superlativesContent: HTMLDivElement, eventsFeedContent: HTMLDivElement, inventionsContent: HTMLDivElement, resourcesContent: HTMLDivElement) { +``` + +Add in constructor body after `this.inventionsContent = inventionsContent`: +```typescript + this.resourcesContent = resourcesContent; +``` + +After the inventions tab block (line 138), add resources tab: +```typescript + // Resources tab ("R") + this.resourcesTab = this.createTab('R'); + this.resourcesTab.addEventListener('click', () => { + if (this.activeTab === 'resources' && this.expanded) { + this.collapse(); + } else { + this.expand('resources'); + } + }); +``` + +Add to tabContainer (after line 142): +```typescript + tabContainer.appendChild(this.resourcesTab); +``` + +Update `updateTabs()` to include resources: +```typescript + this.resourcesTab.style.background = + this.activeTab === 'resources' && this.expanded ? '#2a2a4e' : '#1a1a2e'; + this.resourcesTab.dataset.active = + this.activeTab === 'resources' && this.expanded ? 'true' : 'false'; +``` + +Update `showContent()` to handle resources: +```typescript + } else if (tab === 'resources') { + this.headerEl.textContent = 'RESOURCES'; + this.contentArea.appendChild(this.resourcesContent); + } +``` + +Update `expand()` signature: +```typescript + expand(tab: 'stats' | 'events' | 'inventions' | 'resources'): void { +``` + +Update `getActiveTab()` return type: +```typescript + getActiveTab(): 'stats' | 'events' | 'inventions' | 'resources' | null { +``` + +**Step 2: Wire in GameScene** + +In `client/src/scenes/GameScene.ts`: + +Add import: +```typescript +import { StocksPanel } from '../ui/StocksPanel.js'; +``` + +Add field (after `inventionsPanel`, line 41): +```typescript + private stocksPanel!: StocksPanel; +``` + +In `create()`, after `this.inventionsPanel = new InventionsPanel()` (line 77): +```typescript + this.stocksPanel = new StocksPanel(); +``` + +Update LeftPanel constructor call (line 78-82): +```typescript + this.leftPanel = new LeftPanel( + this.superlativesPanel.getElement(), + this.eventsFeed.getElement(), + this.inventionsPanel.getElement(), + this.stocksPanel.getElement(), + ); +``` + +Add stocksPanel unseen handling after inventionsPanel callback (line 88): +```typescript + this.stocksPanel.onUnseenChanged = (hasUnseen) => { + if (hasUnseen && this.leftPanel.getActiveTab() !== 'resources') { + this.leftPanel.showResourcesNotificationDot(); + } + }; +``` + +In the `left-panel-opened` event handler (line 90), add: +```typescript + if (e.detail.tab === 'resources') { + this.stocksPanel.clearUnseen(); + } +``` + +Wire socket callbacks after invention callbacks (line 257): +```typescript + this.client.onStockpileEvent = (data) => { + this.stocksPanel.addLogEntry(data); + }; + + this.client.onStockpileHistory = (data) => { + this.stocksPanel.loadHistory(data); + }; + + this.client.onStockpileSummary = (data) => { + this.stocksPanel.updateSummary(data); + }; +``` + +**Step 3: Add resources notification dot to LeftPanel** + +In `client/src/ui/LeftPanel.ts`, add a second notification dot field: +```typescript + private resourcesNotificationDot: HTMLDivElement; +``` + +In constructor, after resources tab creation, add notification dot (same as inventions): +```typescript + 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); +``` + +In `expand()`, add: +```typescript + if (tab === 'resources') { + this.hideResourcesNotificationDot(); + } +``` + +Add methods: +```typescript + showResourcesNotificationDot(): void { + this.resourcesNotificationDot.style.display = ''; + } + + hideResourcesNotificationDot(): void { + this.resourcesNotificationDot.style.display = 'none'; + } +``` + +**Step 4: Commit** + +```bash +git add client/src/ui/LeftPanel.ts client/src/scenes/GameScene.ts +git commit -m "feat(client): wire StocksPanel into LeftPanel with R tab" +``` + +--- + +### Task 9: Client — NPC Panel Vertical Left-Edge Tabs + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` + +**Step 1: Restructure tab layout** + +This is the biggest UI change. Replace the horizontal tab bar with vertical left-edge tabs matching LeftPanel style. Add an Inventory tab content area. + +In `NpcInfoPanel.ts`: + +Add field for inventory tab: +```typescript + private inventoryTab!: HTMLDivElement; + private inventoryContent!: HTMLDivElement; +``` + +Update activeTab type (line 55): +```typescript + private activeTab: 'status' | 'relationships' | 'history' | 'inventory' = 'status'; +``` + +**Remove the horizontal tab bar** (lines 161-213). Replace with vertical left-edge tabs. + +The outerFrame layout needs to change from a single column to a row: `[tab buttons column | panel content]`. + +Replace the tab bar creation code. After the portrait well is appended to the container (line 159), instead of creating `tabBar`, create vertical tabs on the outerFrame's left edge: + +The approach: wrap the outerFrame contents in a flex row. Left column = tab buttons. Right column = existing container. + +In the constructor, restructure to: +```typescript + // Tab column (vertical, on left edge of panel) + 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); +``` + +The outerFrame needs to be a row layout with `tabColumn` on the left: +```typescript + 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); + `; +``` + +Move the border/shadow styling to the container (which is the main panel body): +```typescript + this.container.style.cssText = ` + width: 340px; + border-radius: 16px; + border: 3px solid ${EB.borderOuter}; + background: ${EB.borderGap}; + overflow: hidden; + display: flex; + flex-direction: column; + 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); + `; +``` + +The inner container replaces "container" and sits inside a border-gap frame: +```typescript + const innerContainer = document.createElement('div'); + innerContainer.style.cssText = ` + margin: 3px; + border-radius: 12px; + border: 2px solid ${EB.borderInner}; + background: ${EB.bgPanel}; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + `; +``` + +Add `createTab` helper to NpcInfoPanel (similar to LeftPanel's): +```typescript + 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; + } +``` + +Note the key difference from LeftPanel: `border-right: none` and `border-radius: 6px 0 0 6px` (tabs on left edge, so the open side faces right toward the panel). + +Append order: `outerFrame` gets `tabColumn` first, then `container`. + +**Step 2: Add inventory content area** + +Create inventory content div (alongside status/relationships/history): +```typescript + 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); +``` + +**Step 3: Remove inventory from status tab** + +Remove the `inventorySeparator` and `inventoryContainer` creation from statusContent (lines 342-364). Remove the `inventorySeparator` and `inventoryContainer` private fields. Remove the `updateInventory` call from `updateInfo()` (line 436). + +Move `updateInventory` to render into `inventoryContent` instead: +```typescript + private updateInventory(inventory?: Record): void { + this.inventoryContent.innerHTML = ''; + const items = inventory ? Object.entries(inventory).filter(([, qty]) => qty > 0) : []; + if (items.length === 0) { + 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; + } + + const grid = document.createElement('div'); + grid.style.cssText = ` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px 8px; + `; + + for (const [itemId, qty] of items) { + const el = document.createElement('div'); + el.style.cssText = ` + display: flex; + justify-content: space-between; + font-size: 14px; + `; + const nameEl = document.createElement('span'); + nameEl.style.cssText = `color: ${EB.textSecondary}; text-transform: capitalize;`; + nameEl.textContent = itemId.replace(/_/g, ' '); + const qtyEl = document.createElement('span'); + qtyEl.style.cssText = `color: ${EB.textPrimary}; text-shadow: 1px 1px 0 rgba(0,0,0,0.5);`; + qtyEl.textContent = String(qty); + el.appendChild(nameEl); + el.appendChild(qtyEl); + grid.appendChild(el); + } + this.inventoryContent.appendChild(grid); + } +``` + +**Step 4: Update switchTab** + +```typescript + 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.dataset.active = 'true'; + t.tabEl.style.background = '#2a2a4e'; + } else { + t.contentEl.style.display = 'none'; + t.tabEl.dataset.active = 'false'; + t.tabEl.style.background = '#1a1a2e'; + } + } + } +``` + +The old switchTab used borderBottom color for active state — new version uses dataset.active + background, matching LeftPanel pattern. + +**Step 5: Commit** + +```bash +git add client/src/ui/NpcInfoPanel.ts +git commit -m "feat(client): rework NPC panel tabs to vertical left-edge S/R/H/I style" +``` + +--- + +### Task 10: Verify + Fix Tests + +**Files:** +- Check: `server/src/systems/__tests__/` + +**Step 1: Run all tests** + +Run: `npm -w server run test` + +Fix any failures. The most likely breakages: +- `dropoffSystem` signature change (now takes `tick` and callback params) +- `GoalType` union expansion may affect test assertions +- Any tests importing types that changed + +**Step 2: Run client build** + +Run: `npm -w client run build` +Expected: Clean build, no TypeScript errors. + +**Step 3: Commit any test fixes** + +```bash +git add -A +git commit -m "fix: update tests for new system signatures and types" +``` + +--- + +### Task 11: Manual Smoke Test + +**Step 1: Start server and client** + +Run in separate terminals: +```bash +npm -w server run dev +npm -w client run dev +``` + +**Step 2: Verify left panel** + +- Click R tab — should open Resources panel +- Wait for NPCs to gather and drop off — activity log should populate +- Resource totals should update after dropoffs +- Notification dot should appear on R tab when new activity arrives + +**Step 3: Verify NPC panel** + +- Follow an NPC — panel should appear with S/R/H/I tabs on left edge +- Click each tab — content should switch correctly +- I tab should show NPC inventory +- S tab should NOT show inventory section + +**Step 4: Verify pickup mechanic** + +- Wait for NPCs to accumulate stockpile resources +- NPCs needing to craft should walk to stockpile, pick up items, then craft +- Pickup events should appear in the activity log ("Urist took 2 stone")