diff --git a/server/src/systems/__tests__/statModifierSystem.test.ts b/server/src/systems/__tests__/statModifierSystem.test.ts new file mode 100644 index 0000000..dffef62 --- /dev/null +++ b/server/src/systems/__tests__/statModifierSystem.test.ts @@ -0,0 +1,108 @@ +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(); + const e = setupEntity(world, {}, undefined, [ + { stat: 'strength', value: 2, remaining: 10 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(e, 'statModifiers')!; + expect(mods.modifiers[0].remaining).toBe(9); + }); + + it('removes expired modifiers (remaining reaches 0)', () => { + const world = new World(); + const e = setupEntity(world, {}, undefined, [ + { stat: 'strength', value: 2, remaining: 1 }, + { stat: 'dexterity', value: -1, remaining: 5 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(e, '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(); + const e = setupEntity(world, {}, undefined, [ + { stat: 'intelligence', value: -2, remaining: -1 }, + ]); + statModifierSystem(world); + const mods = world.getComponent(e, '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); + }); + }); +}); diff --git a/server/src/systems/statModifierSystem.ts b/server/src/systems/statModifierSystem.ts new file mode 100644 index 0000000..5df4b14 --- /dev/null +++ b/server/src/systems/statModifierSystem.ts @@ -0,0 +1,39 @@ +import type { 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); + } + } +}