diff --git a/docs/plans/2026-03-09-inventions-panel-implementation.md b/docs/plans/2026-03-09-inventions-panel-implementation.md new file mode 100644 index 0000000..b26cc84 --- /dev/null +++ b/docs/plans/2026-03-09-inventions-panel-implementation.md @@ -0,0 +1,869 @@ +# Inventions Panel Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an "I" (Inventions) tab to the left-side panel showing a scrollable log of all inventions with name, category, materials, requirements, inventor, and day. + +**Architecture:** Extend shared types with `InventionSummary` and two new socket events. Extend `InventionTimeline` to store full recipe data. Add `InventionsPanel` UI class and wire it through `SocketClient`, `SocketServer`, and `GameScene`. Add notification dot to the tab. + +**Tech Stack:** TypeScript, Socket.io, Phaser 3, HTML/CSS DOM overlays + +--- + +### Task 1: Add InventionSummary type and socket events to shared types + +**Files:** +- Modify: `shared/src/types.ts` + +**Step 1: Add the InventionSummary interface and socket events** + +In `shared/src/types.ts`, add after the `SuperlativesData` interface (around line 217): + +```typescript +export interface InventionSummary { + itemId: string; + name: string; + category: 'resource' | 'tool' | 'material' | 'structure'; + inputs: { itemId: string; quantity: number }[]; + workshopType: string | null; + toolRequired: string | null; + inventorName: string; + day: number; +} +``` + +In the `ServerEvents` interface, add two new events after the `'memory-history'` line: + +```typescript +'invention-event': (data: InventionSummary) => void; +'invention-history': (data: InventionSummary[]) => void; +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compile, no errors + +**Step 3: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat(shared): add InventionSummary type and socket events" +``` + +--- + +### Task 2: Extend InventionTimeline to store full invention data + +**Files:** +- Modify: `server/src/industry/inventionTimeline.ts` +- Test: `server/src/industry/__tests__/inventionTimeline.test.ts` + +**Step 1: Write the failing test** + +Add to `inventionTimeline.test.ts`: + +```typescript +it('stores and returns full invention summary data', () => { + const timeline = createInventionTimeline(); + timeline.record({ + itemId: 'rope', + inventorEntityId: 1, + inventorName: 'Gwen', + tick: 500, + day: 2, + name: 'Rope', + category: 'material', + inputs: [{ itemId: 'plant_fiber', quantity: 3 }], + workshopType: null, + toolRequired: null, + }); + const summaries = timeline.getSummaries(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toEqual({ + itemId: 'rope', + name: 'Rope', + category: 'material', + inputs: [{ itemId: 'plant_fiber', quantity: 3 }], + workshopType: null, + toolRequired: null, + inventorName: 'Gwen', + day: 2, + }); +}); + +it('getSummaries returns entries in insertion order', () => { + const timeline = createInventionTimeline(); + timeline.record({ + itemId: 'a', inventorEntityId: 1, inventorName: 'X', tick: 300, day: 1, + name: 'A Item', category: 'tool', inputs: [], workshopType: null, toolRequired: null, + }); + timeline.record({ + itemId: 'b', inventorEntityId: 2, inventorName: 'Y', tick: 100, day: 1, + name: 'B Item', category: 'resource', inputs: [], workshopType: null, toolRequired: null, + }); + const summaries = timeline.getSummaries(); + expect(summaries[0].itemId).toBe('a'); + expect(summaries[1].itemId).toBe('b'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --reporter=verbose src/industry/__tests__/inventionTimeline.test.ts` +Expected: FAIL — `name`, `category`, `inputs`, `workshopType`, `toolRequired` not part of `InventionEntry`; `getSummaries` does not exist + +**Step 3: Update InventionEntry and add getSummaries** + +Replace the contents of `server/src/industry/inventionTimeline.ts`: + +```typescript +import type { EntityId, InventionSummary } from '@dflike/shared'; + +export interface InventionEntry { + itemId: string; + inventorEntityId: EntityId; + inventorName: string; + tick: number; + day: number; + name: string; + category: 'resource' | 'tool' | 'material' | 'structure'; + inputs: { itemId: string; quantity: number }[]; + workshopType: string | null; + toolRequired: string | null; +} + +export interface InventionTimeline { + record(entry: InventionEntry): void; + getAll(): InventionEntry[]; + getSummaries(): InventionSummary[]; + countByEntity(entityId: EntityId): number; +} + +export function createInventionTimeline(): InventionTimeline { + const entries: InventionEntry[] = []; + + return { + record(entry: InventionEntry): void { + entries.push(entry); + }, + + getAll(): InventionEntry[] { + return [...entries]; + }, + + getSummaries(): InventionSummary[] { + return entries.map(e => ({ + itemId: e.itemId, + name: e.name, + category: e.category, + inputs: e.inputs, + workshopType: e.workshopType, + toolRequired: e.toolRequired, + inventorName: e.inventorName, + day: e.day, + })); + }, + + countByEntity(entityId: EntityId): number { + return entries.filter(e => e.inventorEntityId === entityId).length; + }, + }; +} +``` + +**Step 4: Update existing tests to include new required fields** + +The existing tests pass minimal `InventionEntry` objects. Update each `timeline.record(...)` call in `inventionTimeline.test.ts` to include the new fields with defaults. Add these fields to every existing record call: + +```typescript +name: 'Item', category: 'resource' as const, inputs: [], workshopType: null, toolRequired: null, +``` + +For example, the first test becomes: +```typescript +timeline.record({ + itemId: 'rope', + inventorEntityId: 1, + inventorName: 'Gwen', + tick: 500, + day: 2, + name: 'Rope', + category: 'material', + inputs: [], + workshopType: null, + toolRequired: null, +}); +``` + +**Step 5: Run tests to verify they pass** + +Run: `npm -w server run test -- --reporter=verbose src/industry/__tests__/inventionTimeline.test.ts` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add server/src/industry/inventionTimeline.ts server/src/industry/__tests__/inventionTimeline.test.ts +git commit -m "feat(server): extend InventionTimeline with full invention data and getSummaries" +``` + +--- + +### Task 3: Update inventionRegistrar to pass full data to timeline + +**Files:** +- Modify: `server/src/industry/inventionRegistrar.ts` +- Test: `server/src/industry/__tests__/inventionRegistrar.test.ts` + +**Step 1: Update the timeline.record call in registerInvention** + +In `server/src/industry/inventionRegistrar.ts`, change line 45 from: + +```typescript +timeline.record({ itemId, inventorEntityId, inventorName, tick, day }); +``` + +to: + +```typescript +timeline.record({ + itemId, + inventorEntityId, + inventorName, + tick, + day, + name: raw.name, + category: raw.category as 'resource' | 'tool' | 'material' | 'structure', + inputs: raw.inputs, + workshopType: raw.workshopType ?? null, + toolRequired: raw.toolRequired ?? null, +}); +``` + +**Step 2: Run existing registrar tests to verify nothing broke** + +Run: `npm -w server run test -- --reporter=verbose src/industry/__tests__/inventionRegistrar.test.ts` +Expected: All tests PASS (the test mocks the timeline, so the extra fields are just passed through) + +**Step 3: Commit** + +```bash +git add server/src/industry/inventionRegistrar.ts +git commit -m "feat(server): pass full invention data to timeline on registration" +``` + +--- + +### Task 4: Add invention callback and socket events to SocketServer + +**Files:** +- Modify: `server/src/systems/inventionSystem.ts` +- Modify: `server/src/network/SocketServer.ts` + +**Step 1: Add onInventionCreated callback to inventionSystem** + +In `server/src/systems/inventionSystem.ts`, after the `registerInvention(...)` call (around line 107), add: + +```typescript +// Emit invention summary for UI +const summary: InventionSummary = { + itemId: validation.itemId!, + name: parsed.name, + category: parsed.category as 'resource' | 'tool' | 'material' | 'structure', + inputs: parsed.inputs, + workshopType: parsed.workshopType ?? null, + toolRequired: parsed.toolRequired ?? null, + inventorName: name, + day, +}; +onInventionCreated?.(summary); +``` + +Add `InventionSummary` to the import from `@dflike/shared`. Add `onInventionCreated` as a parameter to `createInventionSystem`: + +```typescript +export function createInventionSystem( + llmService: LlmService, + narrationService: NarrationService, + eventMemoryService: EventMemoryService, + onInventionCreated?: (summary: InventionSummary) => void, +): InventionSystem { +``` + +**Step 2: Wire up in SocketServer** + +In `server/src/network/SocketServer.ts`, in the `setupBroadcast` method, after the existing narration/thought/memory hooks, add: + +```typescript +this.gameLoop.onInventionCreated = (summary) => { + this.io.emit('invention-event', summary); +}; +``` + +In the `setupConnections` method, after `socket.emit('narration-history', ...)` (line 85), add: + +```typescript +// Send invention history +const inventionTimeline = this.gameLoop.world.getSingleton('inventionTimeline'); +if (inventionTimeline) { + socket.emit('invention-history', inventionTimeline.getSummaries()); +} +``` + +**Step 3: Wire the callback in GameLoop** + +The `onInventionCreated` callback needs to be exposed on `GameLoop`. Check how `GameLoop` creates the invention system and add the callback parameter. In `GameLoop`, add a public property: + +```typescript +public onInventionCreated: ((summary: InventionSummary) => void) | null = null; +``` + +Pass it when creating the invention system (or pass a closure that delegates to it). + +**Step 4: Run server tests to verify nothing broke** + +Run: `npm -w server run test -- --reporter=verbose` +Expected: All 338 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/inventionSystem.ts server/src/network/SocketServer.ts server/src/game/GameLoop.ts +git commit -m "feat(server): emit invention-event and invention-history socket events" +``` + +--- + +### Task 5: Add invention callbacks to SocketClient + +**Files:** +- Modify: `client/src/network/SocketClient.ts` + +**Step 1: Add the callbacks and listeners** + +In `client/src/network/SocketClient.ts`, add to the imports: + +```typescript +import type { ..., InventionSummary } from '@dflike/shared'; +``` + +Add two new callback properties after `onMemoryHistory`: + +```typescript +onInventionEvent: ((data: InventionSummary) => void) | null = null; +onInventionHistory: ((data: InventionSummary[]) => void) | null = null; +``` + +Add two new socket listeners in the constructor, after the `memory-history` listener: + +```typescript +this.socket.on('invention-event', (data) => this.onInventionEvent?.(data)); +this.socket.on('invention-history', (data) => this.onInventionHistory?.(data)); +``` + +**Step 2: Commit** + +```bash +git add client/src/network/SocketClient.ts +git commit -m "feat(client): add invention event/history callbacks to SocketClient" +``` + +--- + +### Task 6: Create InventionsPanel UI component + +**Files:** +- Create: `client/src/ui/InventionsPanel.ts` + +**Step 1: Create the panel** + +Create `client/src/ui/InventionsPanel.ts`: + +```typescript +import type { InventionSummary } from '@dflike/shared'; + +const CATEGORY_COLORS: Record = { + resource: '#6bc46b', + tool: '#d0a050', + material: '#8090d0', + structure: '#c07070', +}; + +export class InventionsPanel { + private container: HTMLDivElement; + private listEl: HTMLDivElement; + private emptyEl: HTMLDivElement; + private inventions: InventionSummary[] = []; + 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%; + `; + + this.emptyEl = document.createElement('div'); + this.emptyEl.style.cssText = ` + color: #8878a8; + font-size: 10px; + text-align: center; + padding: 20px 0; + `; + this.emptyEl.textContent = 'No inventions yet...'; + + this.listEl = document.createElement('div'); + this.listEl.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + flex: 1; + `; + + this.container.appendChild(this.emptyEl); + this.container.appendChild(this.listEl); + } + + getElement(): HTMLDivElement { + return this.container; + } + + get hasUnseen(): boolean { + return this._hasUnseen; + } + + clearUnseen(): void { + this._hasUnseen = false; + this.onUnseenChanged?.(false); + } + + addInvention(data: InventionSummary): void { + this.inventions.push(data); + this.emptyEl.style.display = 'none'; + // Prepend (newest first) + const card = this.createCard(data); + this.listEl.insertBefore(card, this.listEl.firstChild); + this._hasUnseen = true; + this.onUnseenChanged?.(true); + } + + loadHistory(data: InventionSummary[]): void { + this.inventions = [...data]; + this.listEl.innerHTML = ''; + if (data.length === 0) { + this.emptyEl.style.display = ''; + return; + } + this.emptyEl.style.display = 'none'; + // Show newest first + for (let i = data.length - 1; i >= 0; i--) { + this.listEl.appendChild(this.createCard(data[i])); + } + } + + private createCard(data: InventionSummary): HTMLDivElement { + const card = document.createElement('div'); + card.style.cssText = ` + background: #22223a; + border: 1px solid #8878a8; + padding: 6px 8px; + font-size: 9px; + line-height: 1.5; + `; + + // Name + category tag + const header = document.createElement('div'); + header.style.cssText = ` + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; + `; + + const nameEl = document.createElement('span'); + nameEl.style.cssText = `color: #e0d0b0; font-size: 10px;`; + nameEl.textContent = data.name; + + const tagEl = document.createElement('span'); + const tagColor = CATEGORY_COLORS[data.category] ?? '#888'; + tagEl.style.cssText = ` + color: ${tagColor}; + font-size: 8px; + border: 1px solid ${tagColor}; + padding: 0 3px; + border-radius: 2px; + `; + tagEl.textContent = data.category; + + header.appendChild(nameEl); + header.appendChild(tagEl); + card.appendChild(header); + + // Materials + if (data.inputs.length > 0) { + const materialsEl = document.createElement('div'); + materialsEl.style.cssText = `color: #a0a0c0; font-size: 8px;`; + materialsEl.textContent = data.inputs + .map(i => `${i.quantity}x ${i.itemId.replace(/_/g, ' ')}`) + .join(', '); + card.appendChild(materialsEl); + } + + // Requirements + const reqs: string[] = []; + if (data.workshopType) reqs.push(data.workshopType.replace(/_/g, ' ')); + if (data.toolRequired) reqs.push(data.toolRequired.replace(/_/g, ' ')); + if (reqs.length > 0) { + const reqEl = document.createElement('div'); + reqEl.style.cssText = `color: #c09060; font-size: 8px;`; + reqEl.textContent = `Requires: ${reqs.join(', ')}`; + card.appendChild(reqEl); + } + + // Footer: inventor + day + const footer = document.createElement('div'); + footer.style.cssText = ` + color: #8878a8; + font-size: 8px; + margin-top: 3px; + display: flex; + justify-content: space-between; + `; + + const inventorEl = document.createElement('span'); + inventorEl.textContent = data.inventorName; + + const dayEl = document.createElement('span'); + dayEl.textContent = `Day ${data.day}`; + + footer.appendChild(inventorEl); + footer.appendChild(dayEl); + card.appendChild(footer); + + return card; + } +} +``` + +**Step 2: Commit** + +```bash +git add client/src/ui/InventionsPanel.ts +git commit -m "feat(client): add InventionsPanel UI component" +``` + +--- + +### Task 7: Add inventions tab to LeftPanel with notification dot + +**Files:** +- Modify: `client/src/ui/LeftPanel.ts` + +**Step 1: Extend LeftPanel constructor to accept inventions content** + +Update the constructor signature to accept a third parameter: + +```typescript +constructor( + superlativesContent: HTMLDivElement, + eventsFeedContent: HTMLDivElement, + inventionsContent: HTMLDivElement, +) +``` + +Add a new field: +```typescript +private inventionsContent: HTMLDivElement; +private inventionsTab: HTMLDivElement; +private notificationDot: HTMLDivElement; +``` + +Store `inventionsContent` in constructor. Update `activeTab` type: +```typescript +private activeTab: 'stats' | 'events' | 'inventions' | null = null; +``` + +**Step 2: Add the inventions tab with notification dot** + +Create the "I" tab after the "E" tab in the constructor: + +```typescript +this.inventionsTab = this.createTab('I'); +this.inventionsTab.style.position = 'relative'; +this.notificationDot = document.createElement('div'); +this.notificationDot.style.cssText = ` + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background: #f0d060; + border-radius: 50%; + display: none; +`; +this.inventionsTab.appendChild(this.notificationDot); +this.inventionsTab.addEventListener('click', () => { + if (this.activeTab === 'inventions' && this.expanded) { + this.collapse(); + } else { + this.expand('inventions'); + } +}); + +tabContainer.appendChild(this.inventionsTab); +``` + +**Step 3: Update showContent to handle 'inventions'** + +Add the inventions case to `showContent`: + +```typescript +} else if (tab === 'inventions') { + this.headerEl.textContent = 'INVENTIONS'; + this.contentArea.appendChild(this.inventionsContent); +} +``` + +**Step 4: Update updateTabs to handle inventions tab** + +Add to `updateTabs`: + +```typescript +this.inventionsTab.style.background = + this.activeTab === 'inventions' && this.expanded ? '#2a2a4e' : '#1a1a2e'; +this.inventionsTab.dataset.active = + this.activeTab === 'inventions' && this.expanded ? 'true' : 'false'; +``` + +**Step 5: Update expand to accept 'inventions' tab type** + +Update the `expand` method signature: +```typescript +expand(tab: 'stats' | 'events' | 'inventions'): void { +``` + +Also update `showContent` signature: +```typescript +private showContent(tab: 'stats' | 'events' | 'inventions'): void { +``` + +**Step 6: Add notification dot methods** + +```typescript +showNotificationDot(): void { + this.notificationDot.style.display = ''; +} + +hideNotificationDot(): void { + this.notificationDot.style.display = 'none'; +} +``` + +Also update `expand` — when opening the inventions tab, hide the dot: + +```typescript +expand(tab: 'stats' | 'events' | 'inventions'): void { + this.activeTab = tab; + this.expanded = true; + this.showContent(tab); + this.updateTabs(); + this.container.style.transform = 'translateY(-50%) translateX(0)'; + if (tab === 'inventions') { + this.hideNotificationDot(); + } + document.dispatchEvent( + new CustomEvent('left-panel-opened', { detail: { tab } }), + ); +} +``` + +**Step 7: Commit** + +```bash +git add client/src/ui/LeftPanel.ts +git commit -m "feat(client): add inventions tab with notification dot to LeftPanel" +``` + +--- + +### Task 8: Wire everything together in GameScene + +**Files:** +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Import InventionsPanel and wire it up** + +Add import: +```typescript +import { InventionsPanel } from '../ui/InventionsPanel.js'; +``` + +Add field: +```typescript +private inventionsPanel!: InventionsPanel; +``` + +In `create()`, replace the LeftPanel construction block (around lines 73-78): + +```typescript +this.superlativesPanel = new SuperlativesPanel(); +this.eventsFeed = new EventsFeed(); +this.inventionsPanel = new InventionsPanel(); +this.leftPanel = new LeftPanel( + this.superlativesPanel.getElement(), + this.eventsFeed.getElement(), + this.inventionsPanel.getElement(), +); + +this.inventionsPanel.onUnseenChanged = (hasUnseen) => { + if (hasUnseen && this.leftPanel.getActiveTab() !== 'inventions') { + this.leftPanel.showNotificationDot(); + } +}; +``` + +Update the `left-panel-opened` event listener to handle the inventions tab clearing unseen state: + +```typescript +document.addEventListener('left-panel-opened', ((e: CustomEvent) => { + if (e.detail.tab === 'stats') { + this.client.subscribeSuperlatives(); + } + if (e.detail.tab === 'inventions') { + this.inventionsPanel.clearUnseen(); + } +}) as EventListener); +``` + +Wire the invention socket callbacks after the memory callbacks: + +```typescript +this.client.onInventionEvent = (data) => { + this.inventionsPanel.addInvention(data); +}; + +this.client.onInventionHistory = (data) => { + this.inventionsPanel.loadHistory(data); +}; +``` + +**Step 2: Build client to verify compilation** + +Run: `npm -w client run build` +Expected: Clean build, no errors + +**Step 3: Commit** + +```bash +git add client/src/scenes/GameScene.ts +git commit -m "feat(client): wire InventionsPanel into GameScene" +``` + +--- + +### Task 9: Update GameLoop to pass invention callback + +**Files:** +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Check how GameLoop creates the invention system** + +Read `server/src/game/GameLoop.ts` to find where `createInventionSystem` is called. Add the `onInventionCreated` callback as the fourth parameter. + +Add a public property to GameLoop: +```typescript +public onInventionCreated: ((summary: InventionSummary) => void) | null = null; +``` + +When creating the invention system, pass a closure: +```typescript +const inventionSystem = createInventionSystem( + this.llmService, + this.narrationService, + this.eventMemoryService, + (summary) => this.onInventionCreated?.(summary), +); +``` + +Import `InventionSummary` from `@dflike/shared`. + +**Step 2: Run all server tests** + +Run: `npm -w server run test -- --reporter=verbose` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/game/GameLoop.ts +git commit -m "feat(server): expose onInventionCreated callback from GameLoop" +``` + +--- + +### Task 10: Fix any remaining test failures from InventionEntry changes + +**Files:** +- Modify: Any test files that create `InventionEntry` objects directly + +**Step 1: Search for all test files that use `InventionEntry` or `timeline.record`** + +Search for `timeline.record` and `InventionEntry` across all test files. Files likely affected: +- `server/src/industry/__tests__/inventionRegistrar.test.ts` +- `server/src/systems/__tests__/inventionSystem.test.ts` +- `server/src/systems/__tests__/inventionIntegration.test.ts` + +**Step 2: Update each test to include the new required fields** + +Add the new fields (`name`, `category`, `inputs`, `workshopType`, `toolRequired`) wherever `InventionEntry` objects are constructed. Use sensible defaults like: + +```typescript +name: 'Test Item', +category: 'resource' as const, +inputs: [], +workshopType: null, +toolRequired: null, +``` + +**Step 3: Run all server tests** + +Run: `npm -w server run test -- --reporter=verbose` +Expected: All tests PASS + +**Step 4: Commit** + +```bash +git add -A +git commit -m "test(server): update tests for extended InventionEntry fields" +``` + +--- + +### Task 11: End-to-end verification + +**Step 1: Build shared types** + +Run: `npx -w shared tsc` +Expected: Clean compile + +**Step 2: Run all server tests** + +Run: `npm -w server run test` +Expected: All tests PASS + +**Step 3: Build client** + +Run: `npm -w client run build` +Expected: Clean build + +**Step 4: Manual smoke test (optional)** + +Run server and client, verify: +- "I" tab appears on the left panel +- Tab shows notification dot when inventions occur +- Clicking "I" shows the inventions list +- Each entry shows name, category tag, materials, requirements, inventor, day