diff --git a/docs/plans/2026-03-07-npc-stats-impl.md b/docs/plans/2026-03-07-npc-stats-impl.md new file mode 100644 index 0000000..7f18cf4 --- /dev/null +++ b/docs/plans/2026-03-07-npc-stats-impl.md @@ -0,0 +1,1230 @@ +# NPC Statistics System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a two-tier stat system (Physical + Personality) with transient modifiers and baseline drift to NPCs, integrated into needs decay, brain, and social systems. + +**Architecture:** New `Stats` and `StatModifiers` components in the ECS. New `statModifierSystem` runs before needsDecay. A shared helper `getEffectiveStat()` lets all systems read computed values. Stats sent over wire as effective integers. NpcInfoPanel displays stats numerically. + +**Tech Stack:** TypeScript, shared types package, vitest, Phaser 3 HTML overlay + +--- + +### Task 1: Add Stats and StatModifiers types to shared + +**Files:** +- Modify: `shared/src/types.ts` + +**Step 1: Write the new types** + +Add to `shared/src/types.ts` after the `Needs` interface: + +```typescript +export interface Stats { + // Physical + strength: number; + dexterity: number; + constitution: number; + intelligence: number; + perception: number; + // Personality + sociability: number; + courage: number; + curiosity: number; + empathy: number; + temperament: number; +} + +export type StatName = keyof Stats; + +export interface StatModifier { + stat: StatName; + value: number; + remaining: number; // ticks remaining, -1 = permanent (needs-based, replaced each tick) +} + +export interface StatModifiers { + modifiers: StatModifier[]; +} +``` + +**Step 2: Add `stats` to `EntityState`** + +In the `EntityState` interface, add: + +```typescript +stats?: Stats; +``` + +**Step 3: Build shared package** + +Run: `npm -w shared run build` (or `npx -w shared tsc`) +Expected: Clean compile, no errors + +**Step 4: Commit** + +``` +feat: add Stats and StatModifiers types to shared +``` + +--- + +### Task 2: Create stat generation utility and tests + +**Files:** +- Create: `server/src/spawner/statGenerator.ts` +- Create: `server/src/spawner/__tests__/statGenerator.test.ts` + +**Step 1: Write the failing test** + +Create `server/src/spawner/__tests__/statGenerator.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { roll3d6, generateStats } from '../statGenerator.js'; +import type { Stats } from '@dflike/shared'; + +describe('roll3d6', () => { + it('returns a value between 3 and 18', () => { + for (let i = 0; i < 100; i++) { + const val = roll3d6(); + expect(val).toBeGreaterThanOrEqual(3); + expect(val).toBeLessThanOrEqual(18); + } + }); + + it('returns an integer', () => { + for (let i = 0; i < 20; i++) { + expect(Number.isInteger(roll3d6())).toBe(true); + } + }); +}); + +describe('generateStats', () => { + it('returns all 10 stats', () => { + const stats = generateStats(); + const keys: (keyof Stats)[] = [ + 'strength', 'dexterity', 'constitution', 'intelligence', 'perception', + 'sociability', 'courage', 'curiosity', 'empathy', 'temperament', + ]; + for (const key of keys) { + expect(stats[key]).toBeGreaterThanOrEqual(3); + expect(stats[key]).toBeLessThanOrEqual(18); + } + }); + + it('generates different stats across calls (not all identical)', () => { + const results = Array.from({ length: 10 }, () => generateStats()); + const strengths = results.map(s => s.strength); + const unique = new Set(strengths); + // With 10 rolls of 3d6, extremely unlikely all are the same + expect(unique.size).toBeGreaterThan(1); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/spawner/__tests__/statGenerator.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/spawner/statGenerator.ts`: + +```typescript +import type { Stats } from '@dflike/shared'; + +export function roll3d6(): number { + return ( + Math.floor(Math.random() * 6) + 1 + + Math.floor(Math.random() * 6) + 1 + + Math.floor(Math.random() * 6) + 1 + ); +} + +export function generateStats(): Stats { + return { + strength: roll3d6(), + dexterity: roll3d6(), + constitution: roll3d6(), + intelligence: roll3d6(), + perception: roll3d6(), + sociability: roll3d6(), + courage: roll3d6(), + curiosity: roll3d6(), + empathy: roll3d6(), + temperament: roll3d6(), + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/spawner/__tests__/statGenerator.test.ts` +Expected: PASS + +**Step 5: Commit** + +``` +feat: add 3d6 stat generation for NPCs +``` + +--- + +### Task 3: Create getEffectiveStat helper and tests + +**Files:** +- Create: `server/src/systems/statHelpers.ts` +- Create: `server/src/systems/__tests__/statHelpers.test.ts` + +**Step 1: Write the failing test** + +Create `server/src/systems/__tests__/statHelpers.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { getEffectiveStat } from '../statHelpers.js'; +import type { Stats, StatModifiers } from '@dflike/shared'; + +function addStats(world: World, entity: number, overrides: Partial = {}): void { + const base: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...overrides, + }; + world.addComponent(entity, 'stats', base); +} + +describe('getEffectiveStat', () => { + it('returns base stat when no modifiers', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { strength: 14 }); + expect(getEffectiveStat(world, e, 'strength')).toBe(14); + }); + + it('adds positive modifier', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { empathy: 12 }); + world.addComponent(e, 'statModifiers', { + modifiers: [{ stat: 'empathy', value: 2, remaining: 50 }], + }); + expect(getEffectiveStat(world, e, 'empathy')).toBe(14); + }); + + it('adds negative modifier', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { intelligence: 10 }); + world.addComponent(e, 'statModifiers', { + modifiers: [{ stat: 'intelligence', value: -2, remaining: 50 }], + }); + expect(getEffectiveStat(world, e, 'intelligence')).toBe(8); + }); + + it('clamps to minimum 3', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { dexterity: 4 }); + world.addComponent(e, 'statModifiers', { + modifiers: [{ stat: 'dexterity', value: -5, remaining: 50 }], + }); + expect(getEffectiveStat(world, e, 'dexterity')).toBe(3); + }); + + it('clamps to maximum 18', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { strength: 17 }); + world.addComponent(e, 'statModifiers', { + modifiers: [{ stat: 'strength', value: 5, remaining: 50 }], + }); + expect(getEffectiveStat(world, e, 'strength')).toBe(18); + }); + + it('sums multiple modifiers for same stat', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { sociability: 10 }); + world.addComponent(e, 'statModifiers', { + modifiers: [ + { stat: 'sociability', value: 2, remaining: 50 }, + { stat: 'sociability', value: -1, remaining: 30 }, + ], + }); + expect(getEffectiveStat(world, e, 'sociability')).toBe(11); + }); + + it('floors float base stats to integer', () => { + const world = new World(); + const e = world.createEntity(); + addStats(world, e, { courage: 10.7 }); + expect(getEffectiveStat(world, e, 'courage')).toBe(10); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/statHelpers.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/systems/statHelpers.ts`: + +```typescript +import type { Stats, StatName, StatModifiers } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; + +export function getEffectiveStat(world: World, entity: number, stat: StatName): number { + const base = world.getComponent(entity, 'stats'); + if (!base) return 10; // fallback for entities without stats + let value = base[stat]; + const mods = world.getComponent(entity, 'statModifiers'); + if (mods) { + for (const m of mods.modifiers) { + if (m.stat === stat) value += m.value; + } + } + return Math.max(3, Math.min(18, Math.floor(value))); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/systems/__tests__/statHelpers.test.ts` +Expected: PASS + +**Step 5: Commit** + +``` +feat: add getEffectiveStat helper for computed stat values +``` + +--- + +### Task 4: Create statModifierSystem and tests + +**Files:** +- Create: `server/src/systems/statModifierSystem.ts` +- Create: `server/src/systems/__tests__/statModifierSystem.test.ts` + +**Step 1: Write the failing test** + +Create `server/src/systems/__tests__/statModifierSystem.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { statModifierSystem } from '../statModifierSystem.js'; +import type { Stats, StatModifiers, Needs } from '@dflike/shared'; + +function setupEntity( + world: World, + statsOverrides: Partial = {}, + needs?: Partial, + modifiers: StatModifiers['modifiers'] = [], +): number { + const e = world.createEntity(); + const base: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...statsOverrides, + }; + world.addComponent(e, 'stats', base); + world.addComponent(e, 'statModifiers', { modifiers: [...modifiers] }); + if (needs) { + world.addComponent(e, 'needs', { hunger: 80, energy: 80, ...needs }); + } + return e; +} + +describe('statModifierSystem', () => { + describe('modifier decay', () => { + it('decrements remaining on each modifier', () => { + const world = new World(); + setupEntity(world, {}, undefined, [ + { stat: 'strength', value: 2, remaining: 10 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(1, 'statModifiers')!; + expect(mods.modifiers[0].remaining).toBe(9); + }); + + it('removes expired modifiers (remaining reaches 0)', () => { + const world = new World(); + setupEntity(world, {}, undefined, [ + { stat: 'strength', value: 2, remaining: 1 }, + { stat: 'dexterity', value: -1, remaining: 5 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(1, 'statModifiers')!; + expect(mods.modifiers).toHaveLength(1); + expect(mods.modifiers[0].stat).toBe('dexterity'); + }); + + it('does not decrement permanent modifiers (remaining = -1)', () => { + const world = new World(); + setupEntity(world, {}, undefined, [ + { stat: 'intelligence', value: -2, remaining: -1 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(1, 'statModifiers')!; + expect(mods.modifiers[0].remaining).toBe(-1); + }); + }); + + describe('needs-based modifiers', () => { + it('applies intelligence -2 and sociability -1 when hunger < 30', () => { + const world = new World(); + const e = setupEntity(world, {}, { hunger: 20, energy: 80 }); + statModifierSystem(world); + const mods = world.getComponent(e, 'statModifiers')!; + const intMod = mods.modifiers.find(m => m.stat === 'intelligence' && m.remaining === -1); + const socMod = mods.modifiers.find(m => m.stat === 'sociability' && m.remaining === -1); + expect(intMod?.value).toBe(-2); + expect(socMod?.value).toBe(-1); + }); + + it('applies dexterity -2 and temperament -2 when energy < 20', () => { + const world = new World(); + const e = setupEntity(world, {}, { hunger: 80, energy: 10 }); + statModifierSystem(world); + const mods = world.getComponent(e, 'statModifiers')!; + const dexMod = mods.modifiers.find(m => m.stat === 'dexterity' && m.remaining === -1); + const tempMod = mods.modifiers.find(m => m.stat === 'temperament' && m.remaining === -1); + expect(dexMod?.value).toBe(-2); + expect(tempMod?.value).toBe(-2); + }); + + it('removes needs-based modifiers when needs recover', () => { + const world = new World(); + const e = setupEntity(world, {}, { hunger: 20, energy: 80 }); + statModifierSystem(world); + // Now hunger recovers + const needs = world.getComponent(e, 'needs')!; + needs.hunger = 80; + statModifierSystem(world); + const mods = world.getComponent(e, 'statModifiers')!; + const needsMods = mods.modifiers.filter(m => m.remaining === -1); + expect(needsMods).toHaveLength(0); + }); + + it('does not stack needs-based modifiers across ticks', () => { + const world = new World(); + const e = setupEntity(world, {}, { hunger: 20, energy: 80 }); + statModifierSystem(world); + statModifierSystem(world); + statModifierSystem(world); + const mods = world.getComponent(e, 'statModifiers')!; + const intMods = mods.modifiers.filter(m => m.stat === 'intelligence' && m.remaining === -1); + expect(intMods).toHaveLength(1); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/statModifierSystem.test.ts` +Expected: FAIL + +**Step 3: Write the implementation** + +Create `server/src/systems/statModifierSystem.ts`: + +```typescript +import type { Stats, StatModifiers, Needs, StatModifier } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import { HUNGER_THRESHOLD, ENERGY_THRESHOLD } from '@dflike/shared'; + +function applyNeedsModifiers(needs: Needs, modifiers: StatModifier[]): StatModifier[] { + // Remove all existing permanent (needs-based) modifiers + const filtered = modifiers.filter(m => m.remaining !== -1); + + // Apply new needs-based modifiers + if (needs.hunger < HUNGER_THRESHOLD) { + filtered.push({ stat: 'intelligence', value: -2, remaining: -1 }); + filtered.push({ stat: 'sociability', value: -1, remaining: -1 }); + } + if (needs.energy < ENERGY_THRESHOLD) { + filtered.push({ stat: 'dexterity', value: -2, remaining: -1 }); + filtered.push({ stat: 'temperament', value: -2, remaining: -1 }); + } + + return filtered; +} + +export function statModifierSystem(world: World): void { + for (const entity of world.query('stats', 'statModifiers')) { + const mods = world.getComponent(entity, 'statModifiers')!; + + // Decay timed modifiers and remove expired + mods.modifiers = mods.modifiers.filter(m => { + if (m.remaining === -1) return true; // permanent, handled below + m.remaining--; + return m.remaining > 0; + }); + + // Apply needs-based modifiers if entity has needs + const needs = world.getComponent(entity, 'needs'); + if (needs) { + mods.modifiers = applyNeedsModifiers(needs, mods.modifiers); + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/systems/__tests__/statModifierSystem.test.ts` +Expected: PASS + +**Step 5: Commit** + +``` +feat: add statModifierSystem with needs-based modifiers +``` + +--- + +### Task 5: Wire stats into spawner and GameLoop + +**Files:** +- Modify: `server/src/game/spawner.ts` +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/game/__tests__/spawner.test.ts` + +**Step 1: Update spawner test to expect stats components** + +In `server/src/game/__tests__/spawner.test.ts`, add to the "creates all required components" test: + +```typescript +expect(world.getComponent(entity, 'stats')).toBeDefined(); +expect(world.getComponent(entity, 'statModifiers')).toBeDefined(); +``` + +And add a new test: + +```typescript +it('generates stats in 3-18 range', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map); + const stats = world.getComponent(entity, 'stats')!; + for (const key of Object.keys(stats) as (keyof Stats)[]) { + expect(stats[key]).toBeGreaterThanOrEqual(3); + expect(stats[key]).toBeLessThanOrEqual(18); + } +}); +``` + +Import `Stats` from `@dflike/shared` at the top. + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/game/__tests__/spawner.test.ts` +Expected: FAIL — stats/statModifiers not found on entity + +**Step 3: Update spawner to add stats components** + +In `server/src/game/spawner.ts`, add imports and two new `addComponent` calls: + +```typescript +import { generateStats } from '../spawner/statGenerator.js'; +import type { Stats, StatModifiers } from '@dflike/shared'; +``` + +After the `socialState` addComponent, add: + +```typescript +world.addComponent(entity, 'stats', generateStats()); +world.addComponent(entity, 'statModifiers', { modifiers: [] }); +``` + +**Step 4: Wire statModifierSystem into GameLoop** + +In `server/src/game/GameLoop.ts`: + +Add import: +```typescript +import { statModifierSystem } from '../systems/statModifierSystem.js'; +``` + +In the `update()` method, add `statModifierSystem(this.world);` as the FIRST system call (before `needsDecaySystem`). + +**Step 5: Run spawner test and full test suite** + +Run: `npm -w server run test -- --run src/game/__tests__/spawner.test.ts` +Expected: PASS + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 6: Commit** + +``` +feat: wire stats into NPC spawner and game loop +``` + +--- + +### Task 6: Integrate Constitution into needsDecaySystem + +**Files:** +- Modify: `server/src/systems/needsDecaySystem.ts` +- Modify: `server/src/systems/__tests__/systems.test.ts` + +**Step 1: Add failing test for constitution-scaled decay** + +In `server/src/systems/__tests__/systems.test.ts`, add a new test inside the `needsDecaySystem` describe block. First update the `createNPC` helper to optionally add stats: + +Add to imports: +```typescript +import type { Stats, StatModifiers } from '@dflike/shared'; +import { getEffectiveStat } from '../statHelpers.js'; +``` + +Add a helper function: +```typescript +function addStats(world: World, entity: number, overrides: Partial = {}): void { + const base: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...overrides, + }; + world.addComponent(entity, 'stats', base); + world.addComponent(entity, 'statModifiers', { modifiers: [] }); +} +``` + +Add tests: +```typescript +it('high constitution slows hunger decay', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + addStats(world, e, { constitution: 14 }); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + const expectedDecay = HUNGER_DECAY_PER_TICK * (1 - (14 - 10) * 0.03); + expect(needs.hunger).toBeCloseTo(50 - expectedDecay); +}); + +it('low constitution speeds hunger decay', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + addStats(world, e, { constitution: 6 }); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + const expectedDecay = HUNGER_DECAY_PER_TICK * (1 - (6 - 10) * 0.03); + expect(needs.hunger).toBeCloseTo(50 - expectedDecay); +}); + +it('uses default decay when entity has no stats', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + // No stats added — should use base decay + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: First two new tests FAIL (constitution not yet used) + +**Step 3: Update needsDecaySystem** + +Modify `server/src/systems/needsDecaySystem.ts`: + +```typescript +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, type Needs } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import { getEffectiveStat } from './statHelpers.js'; + +export function needsDecaySystem(world: World): void { + for (const entity of world.query('needs')) { + const needs = world.getComponent(entity, 'needs')!; + const con = getEffectiveStat(world, entity, 'constitution'); + const conMultiplier = 1 - (con - 10) * 0.03; + needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK * conMultiplier); + needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK * conMultiplier); + } +} +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: All pass (note: existing tests still pass because `getEffectiveStat` returns 10 as fallback when no stats component exists, giving multiplier 1.0) + +**Step 5: Commit** + +``` +feat: integrate constitution into needs decay rates +``` + +--- + +### Task 7: Integrate stats into socialSystem + +**Files:** +- Modify: `server/src/systems/socialSystem.ts` +- Modify: `server/src/systems/__tests__/socialSystem.test.ts` + +**Step 1: Add failing tests for perception-based awareness radius** + +In `server/src/systems/__tests__/socialSystem.test.ts`: + +Add imports: +```typescript +import type { Stats, StatModifiers } from '@dflike/shared'; +``` + +Add helper: +```typescript +function addStats(world: World, entity: number, overrides: Partial = {}): void { + const base: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...overrides, + }; + world.addComponent(entity, 'stats', base); + world.addComponent(entity, 'statModifiers', { modifiers: [] }); +} +``` + +Add new describe block: +```typescript +describe('stat integration', () => { + it('high perception extends awareness radius', () => { + const world = new World(); + // Place NPCs at distance 7 (outside default AWARENESS_RADIUS of 5) + const a = createNPC(world, 0, 0); + const b = createNPC(world, 7, 0); + addStats(world, a, { perception: 14 }); // +4 radius = 9 + addStats(world, b); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('facing'); + }); + + it('low perception shrinks awareness radius', () => { + const world = new World(); + // Place NPCs at distance 4 (inside default AWARENESS_RADIUS of 5) + const a = createNPC(world, 0, 0); + const b = createNPC(world, 4, 0); + addStats(world, a, { perception: 6 }); // -4 radius = 1 + addStats(world, b); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('none'); + }); + + it('high sociability reduces global cooldown', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + addStats(world, a, { sociability: 15 }); // multiplier: 1 - 5*0.04 = 0.8 + addStats(world, b); + // Set up emoting -> none transition to check cooldown + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + // 50 * 0.8 = 40 + expect(saAfter.globalCooldown).toBe(40); + }); + + it('high empathy biases positive outcome', () => { + const world = new World(); + // Run many trials with very high empathy + let positiveCount = 0; + const trials = 200; + for (let i = 0; i < trials; i++) { + const w = new World(); + const a = createNPC(w, 5, 5); + const b = createNPC(w, 8, 5); + addStats(w, a, { empathy: 18 }); // +0.24 bias = 0.74 chance + addStats(w, b, { empathy: 18 }); + const sa = w.getComponent(a, 'socialState')!; + const sb = w.getComponent(b, 'socialState')!; + sa.phase = 'pausing'; sa.partnerId = b; sa.phaseTimer = 1; + sb.phase = 'pausing'; sb.partnerId = a; sb.phaseTimer = 1; + socialSystem(w); + const saAfter = w.getComponent(a, 'socialState')!; + if (saAfter.outcome === 'positive') positiveCount++; + } + // With 74% expected positive rate over 200 trials, expect at least 100 + expect(positiveCount).toBeGreaterThan(100); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: New stat integration tests fail + +**Step 3: Update socialSystem** + +In `server/src/systems/socialSystem.ts`, add import: +```typescript +import { getEffectiveStat } from './statHelpers.js'; +``` + +Changes needed: + +1. **Awareness radius** — in the proximity detection loop, replace the hardcoded `AWARENESS_RADIUS` check. For each initiator `e`, compute: +```typescript +const perceptionA = getEffectiveStat(world, e, 'perception'); +const awarenessA = AWARENESS_RADIUS + (perceptionA - 10); +``` +Use `awarenessA` instead of `AWARENESS_RADIUS` in the distance check. + +2. **Global cooldown** — in the `emoting -> none` transition, compute sociability-scaled cooldown: +```typescript +const socA = getEffectiveStat(world, e, 'sociability'); +const cooldownA = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04)); +social.globalCooldown = cooldownA; +``` +Do the same for the partner: +```typescript +const socB = getEffectiveStat(world, partnerId!, 'sociability'); +const cooldownB = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04)); +partnerSocial.globalCooldown = cooldownB; +``` + +3. **Empathy outcome** — in the `pausing -> emoting` transition, replace `Math.random() < 0.5` with: +```typescript +const empA = getEffectiveStat(world, e, 'empathy'); +const positiveChanceA = 0.5 + (empA - 10) * 0.03; +const outcome = Math.random() < positiveChanceA ? 'positive' : 'negative'; +``` +Same for partner: +```typescript +const empB = getEffectiveStat(world, partnerId!, 'empathy'); +const positiveChanceB = 0.5 + (empB - 10) * 0.03; +partnerSocial.outcome = Math.random() < positiveChanceB ? 'positive' : 'negative'; +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: All pass + +**Step 5: Run full test suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 6: Commit** + +``` +feat: integrate perception, sociability, and empathy into social system +``` + +--- + +### Task 8: Add baseline drift to socialSystem + +**Files:** +- Modify: `server/src/systems/socialSystem.ts` +- Modify: `server/src/systems/__tests__/socialSystem.test.ts` + +**Step 1: Write failing test** + +Add to `socialSystem.test.ts` inside the `stat integration` describe block: + +```typescript +it('drifts sociability and empathy on positive social outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + addStats(world, a, { sociability: 10.0, empathy: 10.0 }); + addStats(world, b); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; sa.outcome = 'positive'; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; sb.outcome = 'positive'; + socialSystem(world); + const stats = world.getComponent(a, 'stats')!; + expect(stats.sociability).toBeCloseTo(10.05); + expect(stats.empathy).toBeCloseTo(10.05); +}); + +it('drifts temperament on negative social outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + addStats(world, a, { temperament: 10.0 }); + addStats(world, b); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; sa.outcome = 'negative'; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; sb.outcome = 'negative'; + socialSystem(world); + const stats = world.getComponent(a, 'stats')!; + expect(stats.temperament).toBeCloseTo(10.02); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: FAIL + +**Step 3: Add drift logic to socialSystem** + +In `server/src/systems/socialSystem.ts`, add import of `Stats`: +```typescript +import type { Stats } from '@dflike/shared'; +``` + +In the `emoting -> none` transition (after setting cooldowns, before resetting phase), add drift logic: + +```typescript +// Baseline drift based on outcome +const statsA = world.getComponent(e, 'stats'); +if (statsA) { + if (social.outcome === 'positive') { + statsA.sociability = Math.min(18, statsA.sociability + 0.05); + statsA.empathy = Math.min(18, statsA.empathy + 0.05); + } else { + statsA.temperament = Math.min(18, statsA.temperament + 0.02); + } +} +const statsB = world.getComponent(partnerId!, 'stats'); +if (statsB) { + if (partnerSocial.outcome === 'positive') { + statsB.sociability = Math.min(18, statsB.sociability + 0.05); + statsB.empathy = Math.min(18, statsB.empathy + 0.05); + } else { + statsB.temperament = Math.min(18, statsB.temperament + 0.02); + } +} +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: All pass + +**Step 5: Commit** + +``` +feat: add baseline stat drift on social interaction outcomes +``` + +--- + +### Task 9: Add event-based modifiers to socialSystem + +**Files:** +- Modify: `server/src/systems/socialSystem.ts` +- Modify: `server/src/systems/__tests__/socialSystem.test.ts` + +**Step 1: Write failing test** + +Add to `socialSystem.test.ts` inside the `stat integration` describe block: + +```typescript +it('adds positive sociability modifier on positive outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + addStats(world, a); + addStats(world, b); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; sa.outcome = 'positive'; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; sb.outcome = 'positive'; + socialSystem(world); + const mods = world.getComponent(a, 'statModifiers')!; + const socMod = mods.modifiers.find(m => m.stat === 'sociability' && m.remaining === 100); + expect(socMod).toBeDefined(); + expect(socMod!.value).toBe(1); +}); + +it('adds negative sociability and empathy modifiers on negative outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + addStats(world, a); + addStats(world, b); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; sa.outcome = 'negative'; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; sb.outcome = 'negative'; + socialSystem(world); + const mods = world.getComponent(a, 'statModifiers')!; + const socMod = mods.modifiers.find(m => m.stat === 'sociability' && m.remaining === 100); + const empMod = mods.modifiers.find(m => m.stat === 'empathy' && m.remaining === 100); + expect(socMod?.value).toBe(-1); + expect(empMod?.value).toBe(-1); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: FAIL + +**Step 3: Add event modifier logic** + +In the `emoting -> none` transition of `socialSystem.ts`, after the baseline drift block, add: + +```typescript +import type { StatModifiers } from '@dflike/shared'; +``` + +(Add to existing imports at top of file.) + +Then in the transition: + +```typescript +// Event-based transient modifiers +const modsA = world.getComponent(e, 'statModifiers'); +if (modsA) { + if (social.outcome === 'positive') { + modsA.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 }); + } else { + modsA.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 }); + modsA.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 }); + } +} +const modsB = world.getComponent(partnerId!, 'statModifiers'); +if (modsB) { + if (partnerSocial.outcome === 'positive') { + modsB.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 }); + } else { + modsB.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 }); + modsB.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 }); + } +} +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/socialSystem.test.ts` +Expected: All pass + +**Step 5: Run full suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 6: Commit** + +``` +feat: add event-based transient stat modifiers on social outcomes +``` + +--- + +### Task 10: Serialize stats over the wire + +**Files:** +- Modify: `server/src/network/stateSerializer.ts` + +**Step 1: Update serializer to include effective stats** + +In `server/src/network/stateSerializer.ts`, add imports: + +```typescript +import type { Stats } from '@dflike/shared'; +import { getEffectiveStat } from '../systems/statHelpers.js'; +``` + +In the `serializeEntity` function, compute effective stats and add to the returned object: + +```typescript +const statsComponent = world.getComponent(entityId, 'stats'); +const stats = statsComponent ? { + strength: getEffectiveStat(world, entityId, 'strength'), + dexterity: getEffectiveStat(world, entityId, 'dexterity'), + constitution: getEffectiveStat(world, entityId, 'constitution'), + intelligence: getEffectiveStat(world, entityId, 'intelligence'), + perception: getEffectiveStat(world, entityId, 'perception'), + sociability: getEffectiveStat(world, entityId, 'sociability'), + courage: getEffectiveStat(world, entityId, 'courage'), + curiosity: getEffectiveStat(world, entityId, 'curiosity'), + empathy: getEffectiveStat(world, entityId, 'empathy'), + temperament: getEffectiveStat(world, entityId, 'temperament'), +} : undefined; +``` + +Add `stats` to the returned `EntityState` object. + +**Step 2: Build shared and run full suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 3: Commit** + +``` +feat: serialize effective stats in entity state broadcast +``` + +--- + +### Task 11: Display stats in NpcInfoPanel + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` + +**Step 1: Add stats display section to the panel** + +In `NpcInfoPanel.ts`, after the needs bars container in the constructor, add a stats section: + +- Add a second separator (diamond pattern) +- Add a `statsContainer` div with two columns (Physical / Personality) +- Each stat shown as a label + numeric value in the EarthBound style + +Add a private field: +```typescript +private statsContainer: HTMLDivElement; +private statElements: Map = new Map(); +``` + +In the constructor, after `this.needsBarsContainer` is appended to `infoSection`: + +```typescript +// Stats separator +const statsSeparator = document.createElement('div'); +statsSeparator.style.cssText = ` + text-align: center; + font-size: 5px; + color: ${EB.textMuted}; + padding: 4px 0; + letter-spacing: 6px; + user-select: none; +`; +statsSeparator.textContent = '\u25C6\u25C6\u25C6'; +infoSection.appendChild(statsSeparator); + +// Stats container - two columns +this.statsContainer = document.createElement('div'); +this.statsContainer.style.cssText = ` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px 8px; + font-family: 'Press Start 2P', monospace; +`; +infoSection.appendChild(this.statsContainer); +``` + +Add a method to create/update stat display: + +```typescript +private setStatDisplay(label: string, value: number): void { + let el = this.statElements.get(label); + if (!el) { + el = document.createElement('div'); + el.style.cssText = ` + display: flex; + justify-content: space-between; + font-size: 6px; + `; + const labelEl = document.createElement('span'); + labelEl.style.cssText = `color: ${EB.textSecondary}; text-transform: uppercase; letter-spacing: 0.5px;`; + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.style.cssText = `color: ${EB.textPrimary}; text-shadow: 1px 1px 0 rgba(0,0,0,0.5);`; + valueEl.dataset.role = 'value'; + el.appendChild(labelEl); + el.appendChild(valueEl); + this.statsContainer.appendChild(el); + this.statElements.set(label, el); + } + const valueEl = el.querySelector('[data-role="value"]') as HTMLSpanElement; + valueEl.textContent = String(value); +} +``` + +In `updateInfo()`, after the needs update, add: + +```typescript +if ((entity as any).stats) { + this.updateStats((entity as any).stats); +} +``` + +Add `updateStats` method: + +```typescript +updateStats(stats: Record): void { + const physicalKeys = ['STR', 'DEX', 'CON', 'INT', 'PER']; + const personalityKeys = ['SOC', 'COU', 'CUR', 'EMP', 'TMP']; + const mapping: Record = { + STR: 'strength', DEX: 'dexterity', CON: 'constitution', INT: 'intelligence', PER: 'perception', + SOC: 'sociability', COU: 'courage', CUR: 'curiosity', EMP: 'empathy', TMP: 'temperament', + }; + for (const key of [...physicalKeys, ...personalityKeys]) { + const value = stats[mapping[key]]; + if (value !== undefined) { + this.setStatDisplay(key, value); + } + } +} +``` + +Use the proper `EntityState` type for the stats check — since we added `stats?: Stats` to the shared type, update the import and use `entity.stats` directly instead of `(entity as any).stats`. + +**Step 2: Build client and verify visually** + +Run: `npm -w client run build` +Expected: Clean compile + +**Step 3: Commit** + +``` +feat: display NPC stats in info panel with EarthBound styling +``` + +--- + +### Task 12: Final integration test + +**Files:** +- No new files + +**Step 1: Run full server test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 2: Build shared and client** + +Run: `npm -w shared run build && npm -w client run build` +Expected: Clean compile for both + +**Step 3: Manual smoke test** + +Run server and client: +``` +npm -w server run dev & +npm -w client run dev +``` + +Verify: +- NPCs spawn with visible stats in follow mode info panel +- Stats show 3-18 range values +- Needs bars still work +- Social interactions still trigger +- No console errors + +**Step 4: Commit (if any final tweaks needed)** + +``` +chore: final integration verification for NPC stats system +```