Files
dflike/docs/plans/2026-03-10-admin-log-panel-impl.md
T
2026-03-10 02:13:37 +00:00

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:

  1. Add new fields:
private logTab: HTMLDivElement;
private adminTab: HTMLDivElement;
private logContent: HTMLDivElement;
private adminContent: HTMLDivElement;
  1. Update activeTab type:
private activeTab: 'stats' | 'events' | 'inventions' | 'resources' | 'log' | 'admin' | null = null;
  1. Expand constructor to accept and wire logContent + adminContent (6th and 7th params).

  2. Create "L" and "A" tabs using this.createTab(), wire click handlers same pattern.

  3. Add to tabContainer: tabContainer.appendChild(this.logTab) and tabContainer.appendChild(this.adminTab).

  4. Add tooltips: attachTooltip(this.logTab, 'Server Log', 'right') and attachTooltip(this.adminTab, 'Admin', 'right').

  5. 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);
}
  1. Update updateTabs() to handle log and admin tab active states.

  2. Update expand() type signature and getActiveTab() return type.

Step 2: Update GameScene to create and wire new panels

In GameScene.ts:

  1. Add imports:
import { LogPanel } from '../ui/LogPanel.js';
import { AdminPanel } from '../ui/AdminPanel.js';
  1. Add fields:
private logPanel!: LogPanel;
private adminPanel!: AdminPanel;
  1. 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(),
);
  1. 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();
};
  1. 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);
};
  1. 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:

  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:

// 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