Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
38 KiB
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):
// 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
// 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
// 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:
import { describe, it, expect, beforeEach } from 'vitest';
import { createRuntimeConstants } from '../runtimeConstants.js';
describe('RuntimeConstants', () => {
let rc: ReturnType<typeof createRuntimeConstants>;
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:
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<K extends TunableKey>(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<typeof createRuntimeConstants>;
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:
import { describe, it, expect, beforeEach } from 'vitest';
import { createLogService } from '../logService.js';
describe('LogService', () => {
let logService: ReturnType<typeof createLogService>;
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:
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<typeof createLogService>;
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:
// 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:
// 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:
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:
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:
import type { LogService } from '../services/logService.js';
export function createOpenRouterClient(config: LlmConfig, logService?: LogService): OpenRouterClient {
Replace the two console.warn calls:
// 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:
import type { LogService } from '../services/logService.js';
export function createLlmService(logService?: LogService): LlmService {
Pass logService to createOpenRouterClient:
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:
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:
// 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:
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):
private logSubscribers: Map<string, () => void> = new Map();
In the connection handler:
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:
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:
// 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:
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:
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:
import type { LogEntry } from '@dflike/shared';
const MAX_ENTRIES = 200;
const SEVERITY_COLORS: Record<string, string> = {
error: '#ff6666',
warning: '#f0d060',
info: '#6699cc',
};
const SEVERITY_ICONS: Record<string, string> = {
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:
import type { TunableConstants, TunableKey } from '@dflike/shared';
const LABELS: Record<TunableKey, string> = {
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<TunableKey, HTMLInputElement> = 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<typeof setTimeout> | 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:
- Add new fields:
private logTab: HTMLDivElement;
private adminTab: HTMLDivElement;
private logContent: HTMLDivElement;
private adminContent: HTMLDivElement;
- Update
activeTabtype:
private activeTab: 'stats' | 'events' | 'inventions' | 'resources' | 'log' | 'admin' | null = null;
-
Expand constructor to accept and wire logContent + adminContent (6th and 7th params).
-
Create "L" and "A" tabs using
this.createTab(), wire click handlers same pattern. -
Add to tabContainer:
tabContainer.appendChild(this.logTab)andtabContainer.appendChild(this.adminTab). -
Add tooltips:
attachTooltip(this.logTab, 'Server Log', 'right')andattachTooltip(this.adminTab, 'Admin', 'right'). -
Update
showContent()to handle'log'and'admin'cases:
} 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);
}
-
Update
updateTabs()to handle log and admin tab active states. -
Update
expand()type signature andgetActiveTab()return type.
Step 2: Update GameScene to create and wire new panels
In GameScene.ts:
- Add imports:
import { LogPanel } from '../ui/LogPanel.js';
import { AdminPanel } from '../ui/AdminPanel.js';
- Add fields:
private logPanel!: LogPanel;
private adminPanel!: AdminPanel;
- In
create(), after stocksPanel creation:
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(),
);
- Wire admin callbacks:
this.adminPanel.onAuth = (password) => {
this.client.adminAuth(password);
};
this.adminPanel.onUpdate = (key, value) => {
this.client.adminUpdateConstant(key, value);
};
this.adminPanel.onReset = () => {
this.client.adminResetDefaults();
};
- Wire socket callbacks:
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);
};
- Wire log subscribe/unsubscribe in the left-panel-opened/closed event handlers:
// 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:
- Change the system function signature to accept
RuntimeConstantsas parameter - Replace constant references with
rc.get('CONSTANT_NAME')calls - Update the call site in
GameLoop.update()to passthis.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_RATEsocialSystem.ts: Uses AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, PROPOSAL_EMOTING_DURATIONstatModifierSystem.ts: Uses HUNGER_THRESHOLD, ENERGY_THRESHOLDmovementSystem.ts: Uses MOVE_SPEEDSocketServer.ts: Uses MAX_NPC_COUNT
Pattern for each system:
// 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:
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