diff --git a/docs/plans/2026-03-10-admin-log-panel-impl.md b/docs/plans/2026-03-10-admin-log-panel-impl.md new file mode 100644 index 0000000..6a598e9 --- /dev/null +++ b/docs/plans/2026-03-10-admin-log-panel-impl.md @@ -0,0 +1,1340 @@ +# Admin & Log Panel Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Admin (password-gated constant editor) and Log (streaming server logs) tabs to the left panel. + +**Architecture:** Server-side LogService (ring buffer + subscriber push) and RuntimeConstants (mutable gameplay config). Socket.io events for auth, constant updates, and log streaming. Two new client panels integrated into existing LeftPanel tab system. + +**Tech Stack:** TypeScript, Socket.io, existing EarthBound-styled HTML/CSS panels. + +--- + +### Task 1: Shared Types — TunableConstants + Socket Events + +**Files:** +- Modify: `shared/src/types.ts:270-298` +- Modify: `shared/src/constants.ts` (add TUNABLE_KEYS export) + +**Step 1: Add types to shared/src/types.ts** + +Add before the `ServerEvents` interface (line 270): + +```typescript +// Admin panel types +export interface TunableConstants { + TICK_RATE: number; + BROADCAST_EVERY_N_TICKS: number; + HUNGER_DECAY_PER_TICK: number; + ENERGY_DECAY_PER_TICK: number; + HUNGER_THRESHOLD: number; + ENERGY_THRESHOLD: number; + NEED_RECOVERY_RATE: number; + SLEEP_ENERGY_RECOVERY_PER_TICK: number; + SLEEP_HUNGER_DECAY_MULTIPLIER: number; + SLEEP_WAKE_THRESHOLD: number; + SLEEP_VOLUNTARY_ENERGY_THRESHOLD: number; + PRODUCTIVITY_DECAY_PER_TICK: number; + PRODUCTIVITY_THRESHOLD: number; + PRODUCTIVITY_RECOVERY_RATE: number; + DAY_NIGHT_RATIO: number; + DAY_HOURS: number; + SUNSET_DURATION_HOURS: number; + SUNRISE_DURATION_HOURS: number; + NIGHT_DARKNESS: number; + AWARENESS_RADIUS: number; + FACING_DURATION: number; + PAUSING_DURATION: number; + EMOTING_DURATION: number; + SOCIAL_GLOBAL_COOLDOWN: number; + SOCIAL_PAIR_COOLDOWN: number; + PROPOSAL_EMOTING_DURATION: number; + MAX_NPC_COUNT: number; + MOVE_SPEED: number; +} + +export type TunableKey = keyof TunableConstants; + +// Log panel types +export type LogSeverity = 'error' | 'warning' | 'info'; +export type LogCategory = 'LLM' | 'Save' | 'Network' | 'Performance' | 'Game'; + +export interface LogEntry { + timestamp: number; + severity: LogSeverity; + category: LogCategory; + message: string; +} +``` + +**Step 2: Add admin + log events to ServerEvents** + +```typescript +// Add to ServerEvents interface: +'admin-auth-result': (data: { success: boolean; constants?: TunableConstants }) => void; +'admin-constants-updated': (data: TunableConstants) => void; +'log-history': (data: LogEntry[]) => void; +'log-entry': (data: LogEntry) => void; +``` + +**Step 3: Add admin + log events to ClientEvents** + +```typescript +// Add to ClientEvents interface: +'admin-auth': (data: { password: string }) => void; +'admin-update-constant': (data: { key: TunableKey; value: number }) => void; +'admin-reset-defaults': () => void; +'log-subscribe': () => void; +'log-unsubscribe': () => void; +``` + +**Step 4: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compile + +**Step 5: Commit** + +``` +feat: add TunableConstants, LogEntry types and socket events +``` + +--- + +### Task 2: Server — RuntimeConstants Service + +**Files:** +- Create: `server/src/config/runtimeConstants.ts` +- Test: `server/src/config/__tests__/runtimeConstants.test.ts` + +**Step 1: Write failing tests** + +Create `server/src/config/__tests__/runtimeConstants.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRuntimeConstants } from '../runtimeConstants.js'; + +describe('RuntimeConstants', () => { + let rc: ReturnType; + + beforeEach(() => { + rc = createRuntimeConstants(); + }); + + it('initializes with default values from shared constants', () => { + const constants = rc.getAll(); + expect(constants.TICK_RATE).toBe(10); + expect(constants.HUNGER_DECAY_PER_TICK).toBe(0.05); + expect(constants.AWARENESS_RADIUS).toBe(5); + }); + + it('updates a single constant', () => { + rc.update('TICK_RATE', 20); + expect(rc.getAll().TICK_RATE).toBe(20); + }); + + it('rejects unknown keys gracefully', () => { + const result = rc.update('NOT_A_KEY' as any, 5); + expect(result).toBe(false); + }); + + it('resets all to defaults', () => { + rc.update('TICK_RATE', 20); + rc.update('HUNGER_DECAY_PER_TICK', 1.0); + rc.resetDefaults(); + const constants = rc.getAll(); + expect(constants.TICK_RATE).toBe(10); + expect(constants.HUNGER_DECAY_PER_TICK).toBe(0.05); + }); + + it('get() returns individual constant value', () => { + expect(rc.get('MOVE_SPEED')).toBe(0.75); + rc.update('MOVE_SPEED', 1.5); + expect(rc.get('MOVE_SPEED')).toBe(1.5); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/config/__tests__/runtimeConstants.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement runtimeConstants.ts** + +Create `server/src/config/runtimeConstants.ts`: + +```typescript +import type { TunableConstants, TunableKey } from '@dflike/shared'; +import { + TICK_RATE, BROADCAST_EVERY_N_TICKS, + HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, NEED_RECOVERY_RATE, + SLEEP_ENERGY_RECOVERY_PER_TICK, SLEEP_HUNGER_DECAY_MULTIPLIER, SLEEP_WAKE_THRESHOLD, SLEEP_VOLUNTARY_ENERGY_THRESHOLD, + PRODUCTIVITY_DECAY_PER_TICK, PRODUCTIVITY_THRESHOLD, PRODUCTIVITY_RECOVERY_RATE, + DAY_NIGHT_RATIO, DAY_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS, + AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, + SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, PROPOSAL_EMOTING_DURATION, + MAX_NPC_COUNT, MOVE_SPEED, +} from '@dflike/shared'; + +const DEFAULTS: TunableConstants = { + TICK_RATE, BROADCAST_EVERY_N_TICKS, + HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, NEED_RECOVERY_RATE, + SLEEP_ENERGY_RECOVERY_PER_TICK, SLEEP_HUNGER_DECAY_MULTIPLIER, SLEEP_WAKE_THRESHOLD, SLEEP_VOLUNTARY_ENERGY_THRESHOLD, + PRODUCTIVITY_DECAY_PER_TICK, PRODUCTIVITY_THRESHOLD, PRODUCTIVITY_RECOVERY_RATE, + DAY_NIGHT_RATIO, DAY_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS, + AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, + SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, PROPOSAL_EMOTING_DURATION, + MAX_NPC_COUNT, MOVE_SPEED, +}; + +export function createRuntimeConstants() { + const current: TunableConstants = { ...DEFAULTS }; + + return { + get(key: K): TunableConstants[K] { + return current[key]; + }, + + getAll(): TunableConstants { + return { ...current }; + }, + + update(key: TunableKey, value: number): boolean { + if (!(key in DEFAULTS)) return false; + current[key] = value; + return true; + }, + + resetDefaults(): void { + Object.assign(current, DEFAULTS); + }, + + getDefaults(): TunableConstants { + return { ...DEFAULTS }; + }, + }; +} + +export type RuntimeConstants = ReturnType; +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/config/__tests__/runtimeConstants.test.ts` +Expected: All 5 tests PASS + +**Step 5: Commit** + +``` +feat: add RuntimeConstants service with get/update/reset +``` + +--- + +### Task 3: Server — LogService + +**Files:** +- Create: `server/src/services/logService.ts` +- Test: `server/src/services/__tests__/logService.test.ts` + +**Step 1: Write failing tests** + +Create `server/src/services/__tests__/logService.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { createLogService } from '../logService.js'; + +describe('LogService', () => { + let logService: ReturnType; + + beforeEach(() => { + logService = createLogService(); + }); + + it('logs entries and returns them via getRecent', () => { + logService.log('info', 'Game', 'Server started'); + const entries = logService.getRecent(); + expect(entries).toHaveLength(1); + expect(entries[0].severity).toBe('info'); + expect(entries[0].category).toBe('Game'); + expect(entries[0].message).toBe('Server started'); + expect(entries[0].timestamp).toBeGreaterThan(0); + }); + + it('enforces ring buffer limit of 200', () => { + for (let i = 0; i < 210; i++) { + logService.log('info', 'Game', `Entry ${i}`); + } + const entries = logService.getRecent(); + expect(entries).toHaveLength(200); + expect(entries[0].message).toBe('Entry 10'); + }); + + it('notifies subscribers on new entry', () => { + const received: any[] = []; + const unsub = logService.subscribe((entry) => received.push(entry)); + logService.log('error', 'LLM', 'Timeout'); + expect(received).toHaveLength(1); + expect(received[0].severity).toBe('error'); + unsub(); + logService.log('info', 'Game', 'Ignored'); + expect(received).toHaveLength(1); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/services/__tests__/logService.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement logService.ts** + +Create `server/src/services/logService.ts`: + +```typescript +import type { LogEntry, LogSeverity, LogCategory } from '@dflike/shared'; + +const MAX_ENTRIES = 200; + +export function createLogService() { + const entries: LogEntry[] = []; + const subscribers = new Set<(entry: LogEntry) => void>(); + + return { + log(severity: LogSeverity, category: LogCategory, message: string): void { + const entry: LogEntry = { + timestamp: Date.now(), + severity, + category, + message, + }; + entries.push(entry); + if (entries.length > MAX_ENTRIES) { + entries.shift(); + } + for (const cb of subscribers) { + cb(entry); + } + }, + + getRecent(): LogEntry[] { + return [...entries]; + }, + + subscribe(cb: (entry: LogEntry) => void): () => void { + subscribers.add(cb); + return () => { subscribers.delete(cb); }; + }, + }; +} + +export type LogService = ReturnType; +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/services/__tests__/logService.test.ts` +Expected: All 3 tests PASS + +**Step 5: Commit** + +``` +feat: add LogService with ring buffer and subscriber push +``` + +--- + +### Task 4: Server — Wire LogService + RuntimeConstants into GameLoop + +**Files:** +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Add LogService and RuntimeConstants to GameLoop** + +In `GameLoop.ts`, add imports and create instances in constructor: + +```typescript +// Add imports: +import { createLogService, type LogService } from '../services/logService.js'; +import { createRuntimeConstants, type RuntimeConstants } from '../config/runtimeConstants.js'; + +// Add to class fields (public so SocketServer can access): +readonly logService: LogService; +readonly runtimeConstants: RuntimeConstants; + +// In constructor, before other init: +this.logService = createLogService(); +this.runtimeConstants = createRuntimeConstants(); +``` + +**Step 2: Replace direct constant imports in GameLoop with runtimeConstants** + +In `GameLoop.ts`, the `update()` method uses `BROADCAST_EVERY_N_TICKS`, `start()` uses `TICK_RATE`, and `getGameTime()` uses `ENERGY_DECAY_PER_TICK` and `DAY_NIGHT_RATIO`. + +Change the import line to only import types, and update usages: + +```typescript +// Change line 1 from: +import { TICK_RATE, BROADCAST_EVERY_N_TICKS, ENERGY_DECAY_PER_TICK, DAY_NIGHT_RATIO } from '@dflike/shared'; +// To (remove constant imports, keep type imports via other lines): +// (Remove this import line entirely - constants come from runtimeConstants now) + +// In start(): +const tickInterval = 1000 / this.runtimeConstants.get('TICK_RATE'); + +// In update(): +if (this.tick % this.runtimeConstants.get('BROADCAST_EVERY_N_TICKS') === 0 && this.onBroadcast) { + +// In getGameTime(): +const dayTicks = 100 / this.runtimeConstants.get('ENERGY_DECAY_PER_TICK'); +const nightTicks = dayTicks / this.runtimeConstants.get('DAY_NIGHT_RATIO'); +``` + +**Step 3: Add tick overrun detection** + +In `update()`, wrap the tick body with timing: + +```typescript +private update(): void { + const start = performance.now(); + this.tick++; + // ... existing system calls ... + const elapsed = performance.now() - start; + const tickBudget = 1000 / this.runtimeConstants.get('TICK_RATE'); + if (elapsed > tickBudget) { + this.logService.log('warning', 'Performance', `Tick ${this.tick} overrun: ${elapsed.toFixed(1)}ms (budget: ${tickBudget.toFixed(1)}ms)`); + } +} +``` + +**Step 4: Add logging to save operations** + +In `start()` autosave callback, wrap with try/catch: + +```typescript +this.saveInterval = setInterval(() => { + if (this.saveManager) { + try { + this.saveManager.saveEntityState(this.world, this.tick); + this.logService.log('info', 'Save', `Autosaved at tick ${this.tick}`); + } catch (err) { + this.logService.log('error', 'Save', `Autosave failed: ${(err as Error).message}`); + } + } +}, 30_000); +``` + +**Step 5: Run existing tests to verify no regressions** + +Run: `npm -w server run test -- --run` +Expected: All 408 tests pass (some tests import constants directly from shared — that's fine, only GameLoop uses runtimeConstants now) + +**Step 6: Commit** + +``` +feat: wire LogService + RuntimeConstants into GameLoop +``` + +--- + +### Task 5: Server — Wire LogService into LLM Layer + +**Files:** +- Modify: `server/src/llm/llmService.ts` — accept LogService, log failures/fallbacks/milestones +- Modify: `server/src/llm/openRouterClient.ts` — accept LogService, log HTTP errors and timeouts +- Modify: `server/src/llm/backstoryGenerator.ts` — log JSON parse failures +- Modify: `server/src/game/GameLoop.ts` — pass logService to LLM layer + +**Step 1: Update openRouterClient to accept and use logService** + +In `openRouterClient.ts`, change `createOpenRouterClient` signature: + +```typescript +import type { LogService } from '../services/logService.js'; + +export function createOpenRouterClient(config: LlmConfig, logService?: LogService): OpenRouterClient { +``` + +Replace the two `console.warn` calls: + +```typescript +// Line 74 (HTTP error): +logService?.log('error', 'LLM', `API error: ${response.status} ${response.statusText}`); + +// Line 90 (request failed catch): +logService?.log('error', 'LLM', `Request failed: ${(error as Error).message}`); +``` + +Keep `console.warn` as well for local terminal visibility, or remove it (recommend keeping both for now). + +**Step 2: Update llmService to accept and use logService** + +In `llmService.ts`, change `createLlmService` signature: + +```typescript +import type { LogService } from '../services/logService.js'; + +export function createLlmService(logService?: LogService): LlmService { +``` + +Pass logService to `createOpenRouterClient`: + +```typescript +const client = createOpenRouterClient(config, logService); +``` + +Add logging for: +- Fallback switch (line 58): `logService?.log('warning', 'LLM', 'Switched to fallback model: ...');` +- Switch back (line 67): `logService?.log('info', 'LLM', 'Switched back to primary model: ...');` +- Rate limited (line 84-87): `logService?.log('warning', 'LLM', 'Rate limited, switching to fallback');` +- Token milestones (line 96-98): `logService?.log('info', 'LLM', counters.getSummary());` + +**Step 3: Update backstoryGenerator to log parse failures** + +In `backstoryGenerator.ts`, find existing `console.warn` calls for JSON parse failures and add logService logging. The function `generateBackstoryAndDesires` needs a logService parameter (or access it via the llmService). Simplest approach: pass logService as optional param. + +Check the backstoryGenerator signature and add logService. Log when JSON parsing fails for desires. + +**Step 4: Update GameLoop to pass logService** + +In `GameLoop.ts` constructor: + +```typescript +this.llmService = createLlmService(this.logService); +``` + +**Step 5: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass (logService params are optional) + +**Step 6: Commit** + +``` +feat: wire LogService into LLM layer for error/warning logging +``` + +--- + +### Task 6: Server — Wire LogService into Network + Spawner + +**Files:** +- Modify: `server/src/network/SocketServer.ts` — log connect/disconnect/errors +- Modify: `server/src/game/spawner.ts` — log spawn failures (if applicable) + +**Step 1: Add network logging to SocketServer** + +In `setupConnections()`, add logService logging: + +```typescript +// Access via this.gameLoop.logService +const logService = this.gameLoop.logService; + +// On connection (line 75): +logService.log('info', 'Network', `Player connected: ${playerId}`); + +// On disconnect (line 169): +logService.log('info', 'Network', `Player disconnected: ${playerId}`); +``` + +Replace existing `console.log` calls with logService (keep console.log too for terminal). + +**Step 2: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 3: Commit** + +``` +feat: wire LogService into network layer +``` + +--- + +### Task 7: Server — Admin + Log Socket Handlers + +**Files:** +- Modify: `server/src/network/SocketServer.ts` + +**Step 1: Add admin auth handler** + +In `setupConnections()`, inside the `connection` callback, add: + +```typescript +const ADMIN_PASSWORD = 'dwarf'; + +socket.on('admin-auth', (data: { password: string }) => { + if (data.password === ADMIN_PASSWORD) { + socket.emit('admin-auth-result', { + success: true, + constants: this.gameLoop.runtimeConstants.getAll(), + }); + } else { + socket.emit('admin-auth-result', { success: false }); + } +}); + +socket.on('admin-update-constant', (data: { key: any; value: number }) => { + const success = this.gameLoop.runtimeConstants.update(data.key, data.value); + if (success) { + // Broadcast updated constants to all connected sockets + this.io.emit('admin-constants-updated', this.gameLoop.runtimeConstants.getAll()); + this.gameLoop.logService.log('info', 'Game', `Admin updated ${data.key} to ${data.value}`); + } +}); + +socket.on('admin-reset-defaults', () => { + this.gameLoop.runtimeConstants.resetDefaults(); + this.io.emit('admin-constants-updated', this.gameLoop.runtimeConstants.getAll()); + this.gameLoop.logService.log('info', 'Game', 'Admin reset all constants to defaults'); +}); +``` + +**Step 2: Add log subscribe/unsubscribe handlers** + +Add a `logSubscribers` Set to the class (like `superlativesSubscribers`): + +```typescript +private logSubscribers: Map void> = new Map(); +``` + +In the connection handler: + +```typescript +socket.on('log-subscribe', () => { + socket.emit('log-history', this.gameLoop.logService.getRecent()); + const unsub = this.gameLoop.logService.subscribe((entry) => { + socket.emit('log-entry', entry); + }); + this.logSubscribers.set(socket.id, unsub); +}); + +socket.on('log-unsubscribe', () => { + const unsub = this.logSubscribers.get(socket.id); + if (unsub) { + unsub(); + this.logSubscribers.delete(socket.id); + } +}); +``` + +In disconnect handler, clean up log subscription: + +```typescript +const logUnsub = this.logSubscribers.get(socket.id); +if (logUnsub) { + logUnsub(); + this.logSubscribers.delete(socket.id); +} +``` + +**Step 3: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 4: Commit** + +``` +feat: add admin auth and log streaming socket handlers +``` + +--- + +### Task 8: Client — SocketClient Extensions + +**Files:** +- Modify: `client/src/network/SocketClient.ts` + +**Step 1: Add new callbacks and methods** + +Add callback properties: + +```typescript +// Admin callbacks +onAdminAuthResult: ((data: { success: boolean; constants?: TunableConstants }) => void) | null = null; +onAdminConstantsUpdated: ((data: TunableConstants) => void) | null = null; + +// Log callbacks +onLogHistory: ((data: LogEntry[]) => void) | null = null; +onLogEntry: ((data: LogEntry) => void) | null = null; +``` + +Add socket listeners in constructor: + +```typescript +this.socket.on('admin-auth-result', (data) => this.onAdminAuthResult?.(data)); +this.socket.on('admin-constants-updated', (data) => this.onAdminConstantsUpdated?.(data)); +this.socket.on('log-history', (data) => this.onLogHistory?.(data)); +this.socket.on('log-entry', (data) => this.onLogEntry?.(data)); +``` + +Add emit methods: + +```typescript +adminAuth(password: string): void { + this.socket.emit('admin-auth', { password }); +} + +adminUpdateConstant(key: TunableKey, value: number): void { + this.socket.emit('admin-update-constant', { key, value }); +} + +adminResetDefaults(): void { + this.socket.emit('admin-reset-defaults'); +} + +subscribeLogs(): void { + this.socket.emit('log-subscribe'); +} + +unsubscribeLogs(): void { + this.socket.emit('log-unsubscribe'); +} +``` + +Add necessary imports from `@dflike/shared` (TunableConstants, TunableKey, LogEntry). + +**Step 2: Rebuild shared (if not done) and verify client compiles** + +Run: `npx -w shared tsc && npm -w client run build` +Expected: Clean build + +**Step 3: Commit** + +``` +feat: add admin and log socket methods to SocketClient +``` + +--- + +### Task 9: Client — LogPanel UI + +**Files:** +- Create: `client/src/ui/LogPanel.ts` + +**Step 1: Create LogPanel** + +Create `client/src/ui/LogPanel.ts` following the StocksPanel pattern: + +```typescript +import type { LogEntry } from '@dflike/shared'; + +const MAX_ENTRIES = 200; + +const SEVERITY_COLORS: Record = { + error: '#ff6666', + warning: '#f0d060', + info: '#6699cc', +}; + +const SEVERITY_ICONS: Record = { + error: '\u25cf', // filled circle + warning: '\u25b2', // triangle + info: '\u25cb', // open circle +}; + +export class LogPanel { + private container: HTMLDivElement; + private logEl: HTMLDivElement; + private emptyEl: HTMLDivElement; + + constructor() { + this.container = document.createElement('div'); + this.container.style.cssText = ` + display: flex; + flex-direction: column; + height: 100%; + gap: 4px; + `; + + this.emptyEl = document.createElement('div'); + this.emptyEl.style.cssText = ` + color: #8878a8; + font-size: 10px; + text-align: center; + padding: 20px 0; + `; + this.emptyEl.textContent = 'No log entries yet...'; + this.container.appendChild(this.emptyEl); + + this.logEl = document.createElement('div'); + this.logEl.style.cssText = ` + display: flex; + flex-direction: column; + gap: 1px; + overflow-y: auto; + flex: 1; + `; + this.container.appendChild(this.logEl); + } + + getElement(): HTMLDivElement { + return this.container; + } + + addEntry(entry: LogEntry): void { + this.emptyEl.style.display = 'none'; + const el = this.createLogLine(entry); + this.logEl.insertBefore(el, this.logEl.firstChild); + while (this.logEl.children.length > MAX_ENTRIES) { + this.logEl.removeChild(this.logEl.lastChild!); + } + } + + loadHistory(entries: LogEntry[]): void { + this.logEl.innerHTML = ''; + if (entries.length === 0) { + this.emptyEl.style.display = ''; + return; + } + this.emptyEl.style.display = 'none'; + for (let i = entries.length - 1; i >= 0; i--) { + this.logEl.appendChild(this.createLogLine(entries[i])); + } + } + + private createLogLine(entry: LogEntry): HTMLDivElement { + const el = document.createElement('div'); + el.style.cssText = ` + font-size: 9px; + padding: 2px 0; + border-bottom: 1px solid #1c1450; + line-height: 1.5; + display: flex; + gap: 4px; + align-items: baseline; + `; + + const timeStr = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false }); + const color = SEVERITY_COLORS[entry.severity] ?? '#a0a0c0'; + const icon = SEVERITY_ICONS[entry.severity] ?? ''; + + const timeEl = document.createElement('span'); + timeEl.style.cssText = 'color: #666; flex-shrink: 0;'; + timeEl.textContent = timeStr; + + const iconEl = document.createElement('span'); + iconEl.style.cssText = `color: ${color}; flex-shrink: 0;`; + iconEl.textContent = icon; + + const catEl = document.createElement('span'); + catEl.style.cssText = 'color: #8878a8; flex-shrink: 0;'; + catEl.textContent = `[${entry.category}]`; + + const msgEl = document.createElement('span'); + msgEl.style.cssText = `color: ${color};`; + msgEl.textContent = entry.message; + + el.appendChild(timeEl); + el.appendChild(iconEl); + el.appendChild(catEl); + el.appendChild(msgEl); + + return el; + } +} +``` + +**Step 2: Commit** + +``` +feat: add LogPanel UI component +``` + +--- + +### Task 10: Client — AdminPanel UI + +**Files:** +- Create: `client/src/ui/AdminPanel.ts` + +**Step 1: Create AdminPanel** + +Create `client/src/ui/AdminPanel.ts`: + +```typescript +import type { TunableConstants, TunableKey } from '@dflike/shared'; + +const LABELS: Record = { + TICK_RATE: 'Tick Rate (ticks/sec)', + BROADCAST_EVERY_N_TICKS: 'Broadcast Interval (ticks)', + HUNGER_DECAY_PER_TICK: 'Hunger Decay / Tick', + ENERGY_DECAY_PER_TICK: 'Energy Decay / Tick', + HUNGER_THRESHOLD: 'Hunger Threshold', + ENERGY_THRESHOLD: 'Energy Threshold', + NEED_RECOVERY_RATE: 'Need Recovery Rate', + SLEEP_ENERGY_RECOVERY_PER_TICK: 'Sleep Energy Recovery / Tick', + SLEEP_HUNGER_DECAY_MULTIPLIER: 'Sleep Hunger Decay Mult', + SLEEP_WAKE_THRESHOLD: 'Sleep Wake Threshold', + SLEEP_VOLUNTARY_ENERGY_THRESHOLD: 'Voluntary Sleep Threshold', + PRODUCTIVITY_DECAY_PER_TICK: 'Productivity Decay / Tick', + PRODUCTIVITY_THRESHOLD: 'Productivity Threshold', + PRODUCTIVITY_RECOVERY_RATE: 'Productivity Recovery Rate', + DAY_NIGHT_RATIO: 'Day/Night Ratio', + DAY_HOURS: 'Day Hours', + SUNSET_DURATION_HOURS: 'Sunset Duration (hrs)', + SUNRISE_DURATION_HOURS: 'Sunrise Duration (hrs)', + NIGHT_DARKNESS: 'Night Darkness (0-1)', + AWARENESS_RADIUS: 'Awareness Radius (tiles)', + FACING_DURATION: 'Facing Duration (ticks)', + PAUSING_DURATION: 'Pausing Duration (ticks)', + EMOTING_DURATION: 'Emoting Duration (ticks)', + SOCIAL_GLOBAL_COOLDOWN: 'Social Global Cooldown (ticks)', + SOCIAL_PAIR_COOLDOWN: 'Social Pair Cooldown (ticks)', + PROPOSAL_EMOTING_DURATION: 'Proposal Emote Duration (ticks)', + MAX_NPC_COUNT: 'Max NPC Count', + MOVE_SPEED: 'NPC Move Speed (tiles/tick)', +}; + +export class AdminPanel { + private container: HTMLDivElement; + private passwordView: HTMLDivElement; + private editorView: HTMLDivElement; + private authenticated = false; + private inputs: Map = new Map(); + private defaults: TunableConstants | null = null; + + // Callbacks set by GameScene + onAuth: ((password: string) => void) | null = null; + onUpdate: ((key: TunableKey, value: number) => void) | null = null; + onReset: (() => void) | null = null; + + constructor() { + this.container = document.createElement('div'); + this.container.style.cssText = ` + display: flex; + flex-direction: column; + height: 100%; + `; + + // Password view + this.passwordView = document.createElement('div'); + this.passwordView.style.cssText = ` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px 0; + `; + + const label = document.createElement('div'); + label.style.cssText = 'color: #8878a8; font-size: 10px;'; + label.textContent = 'Admin Password:'; + + const input = document.createElement('input'); + input.type = 'password'; + input.style.cssText = ` + background: #0a0a1e; + border: 1px solid #8878a8; + color: #e0d0b0; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + padding: 4px 8px; + width: 120px; + text-align: center; + `; + + const errorEl = document.createElement('div'); + errorEl.style.cssText = 'color: #ff6666; font-size: 9px; display: none;'; + errorEl.textContent = 'Wrong password'; + + const submit = document.createElement('button'); + submit.style.cssText = ` + background: #2a2a4e; + border: 1px solid #8878a8; + color: #e0d0b0; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + padding: 4px 12px; + cursor: pointer; + `; + submit.textContent = 'Enter'; + + const doAuth = () => { + this.onAuth?.(input.value); + input.value = ''; + }; + + submit.addEventListener('click', doAuth); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doAuth(); + e.stopPropagation(); // Prevent game input + }); + input.addEventListener('keyup', (e) => e.stopPropagation()); + + this.passwordView.appendChild(label); + this.passwordView.appendChild(input); + this.passwordView.appendChild(errorEl); + this.passwordView.appendChild(submit); + + // Editor view (hidden until authenticated) + this.editorView = document.createElement('div'); + this.editorView.style.cssText = ` + display: none; + flex-direction: column; + gap: 4px; + overflow-y: auto; + flex: 1; + `; + + this.container.appendChild(this.passwordView); + this.container.appendChild(this.editorView); + } + + getElement(): HTMLDivElement { + return this.container; + } + + showAuthError(): void { + const errorEl = this.passwordView.querySelector('div:nth-child(3)') as HTMLDivElement; + if (errorEl) errorEl.style.display = ''; + } + + setAuthenticated(constants: TunableConstants): void { + this.authenticated = true; + this.defaults = { ...constants }; + this.passwordView.style.display = 'none'; + this.editorView.style.display = 'flex'; + this.buildEditor(constants); + } + + updateConstants(constants: TunableConstants): void { + for (const [key, input] of this.inputs) { + if (document.activeElement !== input) { + input.value = String(constants[key]); + } + } + } + + private buildEditor(constants: TunableConstants): void { + this.editorView.innerHTML = ''; + this.inputs.clear(); + + let debounceTimer: ReturnType | null = null; + + for (const key of Object.keys(LABELS) as TunableKey[]) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; + flex-direction: column; + gap: 1px; + padding: 3px 0; + border-bottom: 1px solid #1c1450; + `; + + const labelRow = document.createElement('div'); + labelRow.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: baseline; + `; + + const nameEl = document.createElement('span'); + nameEl.style.cssText = 'color: #a0a0c0; font-size: 8px;'; + nameEl.textContent = LABELS[key]; + + const defaultEl = document.createElement('span'); + defaultEl.style.cssText = 'color: #555; font-size: 7px;'; + defaultEl.textContent = `default: ${constants[key]}`; + + labelRow.appendChild(nameEl); + labelRow.appendChild(defaultEl); + + const input = document.createElement('input'); + input.type = 'number'; + input.step = 'any'; + input.value = String(constants[key]); + input.style.cssText = ` + background: #0a0a1e; + border: 1px solid #8878a8; + color: #e0d0b0; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + padding: 2px 4px; + width: 100%; + box-sizing: border-box; + `; + + input.addEventListener('input', () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const val = parseFloat(input.value); + if (!isNaN(val)) { + this.onUpdate?.(key, val); + } + }, 300); + }); + input.addEventListener('keydown', (e) => e.stopPropagation()); + input.addEventListener('keyup', (e) => e.stopPropagation()); + + this.inputs.set(key, input); + + row.appendChild(labelRow); + row.appendChild(input); + this.editorView.appendChild(row); + } + + // Reset button + const resetBtn = document.createElement('button'); + resetBtn.style.cssText = ` + background: #3a1a1a; + border: 1px solid #8878a8; + color: #ff6666; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + padding: 6px 12px; + cursor: pointer; + margin-top: 8px; + flex-shrink: 0; + `; + resetBtn.textContent = 'Reset to Defaults'; + resetBtn.addEventListener('click', () => { + this.onReset?.(); + }); + this.editorView.appendChild(resetBtn); + } +} +``` + +**Step 2: Commit** + +``` +feat: add AdminPanel UI component with password gate and constant editor +``` + +--- + +### Task 11: Client — Integrate Panels into LeftPanel + GameScene + +**Files:** +- Modify: `client/src/ui/LeftPanel.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Update LeftPanel to accept 6 content panels** + +In `LeftPanel.ts`: + +1. Add new fields: +```typescript +private logTab: HTMLDivElement; +private adminTab: HTMLDivElement; +private logContent: HTMLDivElement; +private adminContent: HTMLDivElement; +``` + +2. Update `activeTab` type: +```typescript +private activeTab: 'stats' | 'events' | 'inventions' | 'resources' | 'log' | 'admin' | null = null; +``` + +3. Expand constructor to accept and wire logContent + adminContent (6th and 7th params). + +4. Create "L" and "A" tabs using `this.createTab()`, wire click handlers same pattern. + +5. Add to tabContainer: `tabContainer.appendChild(this.logTab)` and `tabContainer.appendChild(this.adminTab)`. + +6. Add tooltips: `attachTooltip(this.logTab, 'Server Log', 'right')` and `attachTooltip(this.adminTab, 'Admin', 'right')`. + +7. Update `showContent()` to handle `'log'` and `'admin'` cases: +```typescript +} else if (tab === 'log') { + this.headerEl.textContent = 'SERVER LOG'; + this.contentArea.appendChild(this.logContent); +} else if (tab === 'admin') { + this.headerEl.textContent = 'ADMIN'; + this.contentArea.appendChild(this.adminContent); +} +``` + +8. Update `updateTabs()` to handle log and admin tab active states. + +9. Update `expand()` type signature and `getActiveTab()` return type. + +**Step 2: Update GameScene to create and wire new panels** + +In `GameScene.ts`: + +1. Add imports: +```typescript +import { LogPanel } from '../ui/LogPanel.js'; +import { AdminPanel } from '../ui/AdminPanel.js'; +``` + +2. Add fields: +```typescript +private logPanel!: LogPanel; +private adminPanel!: AdminPanel; +``` + +3. In `create()`, after stocksPanel creation: +```typescript +this.logPanel = new LogPanel(); +this.adminPanel = new AdminPanel(); +this.leftPanel = new LeftPanel( + this.superlativesPanel.getElement(), + this.eventsFeed.getElement(), + this.inventionsPanel.getElement(), + this.stocksPanel.getElement(), + this.logPanel.getElement(), + this.adminPanel.getElement(), +); +``` + +4. Wire admin callbacks: +```typescript +this.adminPanel.onAuth = (password) => { + this.client.adminAuth(password); +}; +this.adminPanel.onUpdate = (key, value) => { + this.client.adminUpdateConstant(key, value); +}; +this.adminPanel.onReset = () => { + this.client.adminResetDefaults(); +}; +``` + +5. Wire socket callbacks: +```typescript +this.client.onAdminAuthResult = (data) => { + if (data.success && data.constants) { + this.adminPanel.setAuthenticated(data.constants); + } else { + this.adminPanel.showAuthError(); + } +}; + +this.client.onAdminConstantsUpdated = (data) => { + this.adminPanel.updateConstants(data); +}; + +this.client.onLogHistory = (data) => { + this.logPanel.loadHistory(data); +}; + +this.client.onLogEntry = (data) => { + this.logPanel.addEntry(data); +}; +``` + +6. Wire log subscribe/unsubscribe in the left-panel-opened/closed event handlers: +```typescript +// In left-panel-opened handler: +if (e.detail.tab === 'log') { + this.client.subscribeLogs(); +} + +// In left-panel-closed handler (existing): +this.client.unsubscribeSuperlatives(); +this.client.unsubscribeLogs(); +``` + +**Step 3: Build and verify** + +Run: `npm -w client run build` +Expected: Clean build + +**Step 4: Commit** + +``` +feat: integrate Admin and Log panels into LeftPanel and GameScene +``` + +--- + +### Task 12: Server — Wire Remaining Systems to RuntimeConstants + +**Files:** +- Modify: `server/src/systems/needsDecaySystem.ts` +- Modify: `server/src/systems/socialSystem.ts` +- Modify: `server/src/systems/statModifierSystem.ts` +- Modify: `server/src/systems/movementSystem.ts` +- Modify: `server/src/network/SocketServer.ts` (MAX_NPC_COUNT) + +This is a larger task. For each system that imports gameplay constants from `@dflike/shared`: + +1. Change the system function signature to accept `RuntimeConstants` as parameter +2. Replace constant references with `rc.get('CONSTANT_NAME')` calls +3. Update the call site in `GameLoop.update()` to pass `this.runtimeConstants` + +**Systems to update:** + +- `needsDecaySystem.ts`: Uses HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, SLEEP_ENERGY_RECOVERY_PER_TICK, SLEEP_HUNGER_DECAY_MULTIPLIER, NEED_RECOVERY_RATE +- `socialSystem.ts`: Uses AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, PROPOSAL_EMOTING_DURATION +- `statModifierSystem.ts`: Uses HUNGER_THRESHOLD, ENERGY_THRESHOLD +- `movementSystem.ts`: Uses MOVE_SPEED +- `SocketServer.ts`: Uses MAX_NPC_COUNT + +**Pattern for each system:** + +```typescript +// Before: +import { SOME_CONSTANT } from '@dflike/shared'; +export function someSystem(world: World): void { + // uses SOME_CONSTANT +} + +// After: +import type { RuntimeConstants } from '../config/runtimeConstants.js'; +export function someSystem(world: World, rc: RuntimeConstants): void { + // uses rc.get('SOME_CONSTANT') +} +``` + +**Update GameLoop.update() calls:** + +```typescript +statModifierSystem(this.world, this.runtimeConstants); +needsDecaySystem(this.world, this.eventMemoryService, this.runtimeConstants); +// ... etc for each updated system +``` + +**Note:** Tests will continue importing constants from `@dflike/shared` directly — they test the system logic with known constant values, which is fine. Tests that call system functions will need the `runtimeConstants` parameter added. Create a test helper like `const rc = createRuntimeConstants()` in tests that need it. + +**Step 1: Update each system file (5 files)** +**Step 2: Update GameLoop.update() call sites** +**Step 3: Update SocketServer spawn-npc handler to use runtimeConstants** +**Step 4: Run all tests, fix any signature mismatches in test files** + +Run: `npm -w server run test -- --run` +Expected: All tests pass (may need to update ~10 test files with runtimeConstants param) + +**Step 5: Commit** + +``` +feat: wire all gameplay systems to RuntimeConstants +``` + +--- + +### Task 13: End-to-End Manual Testing + +**Step 1: Start server and client** + +Run: `npm -w server run dev -- --new-world` (terminal 1) +Run: `npm -w client run dev` (terminal 2) + +**Step 2: Test Log tab** +- Click "L" tab on left panel — should show "No log entries yet..." initially +- Entries should populate as server logs come in (player connect, etc.) +- Close tab and reopen — should show history + +**Step 3: Test Admin tab** +- Click "A" tab — should show password input +- Enter wrong password — should show error +- Enter "dwarf" — should show constant editor +- Change a value (e.g., HUNGER_DECAY_PER_TICK to 0.5) — should apply immediately +- Click "Reset to Defaults" — should revert all values +- Refresh page — admin should require re-authentication + +**Step 4: Commit final** + +``` +feat: Admin & Log panels - complete implementation +```