docs: add inventions panel implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 00:38:01 +00:00
parent 89ae5d065b
commit 827b38c2c7
@@ -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>('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<string, string> = {
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