import { clamp } from './deterministic.js'; import { emitWorldEvent, findRequired, rebuildRoomOccupants, trimHistory } from './state-utils.js'; import type { CommandResult, EntityId, MemoryRecord, NpcState, UniverseState, WorldEventState } from './types.js'; export class PopulationSystem { constructor(private state: UniverseState) {} replaceState(state: UniverseState): void { this.state = state; } getNpc(npcId: EntityId): NpcState { return findRequired(this.state.npcs, npcId, 'NPC'); } remember(npcId: EntityId, subjectId: EntityId, summary: string, emotionalWeight: number, tags: string[] = []): MemoryRecord { const npc = this.getNpc(npcId); const memory: MemoryRecord = { id: `memory-${npc.id}-${this.state.tick}-${npc.memories.length + 1}`, tick: this.state.tick, subjectId, summary, emotionalWeight: clamp(emotionalWeight, -1, 1), confidence: 1, tags: [...tags], }; npc.memories.push(memory); trimHistory(npc.memories, 64); return memory; } recordPlayerAction(npcId: EntityId, action: string, trustDelta: number, actionCategory?: string): CommandResult { const npc = this.getNpc(npcId); if (!npc.alive) return { ok: false, message: `${npc.name} is dead.`, eventIds: [] }; // Apply personality-based reaction weight if a category is specified let effectiveDelta = trustDelta; if (actionCategory && npc.reactionWeights) { const weight = (npc.reactionWeights as unknown as Record)[actionCategory]; if (typeof weight === 'number') effectiveDelta = trustDelta * (1 + weight); } const existing = npc.trustValues[this.state.player.id] ?? 0; npc.trustValues[this.state.player.id] = clamp(existing + effectiveDelta, -100, 100); npc.playerReaction = this.reactionForTrust(npc.trustValues[this.state.player.id] as number); this.remember(npc.id, this.state.player.id, action, clamp(effectiveDelta / 20, -1, 1), ['player-action']); this.sealSocialEvent(npc, actionCategory ?? 'player-action', this.state.player.id, action, clamp(effectiveDelta / 20, -1, 1)); npc.behavior.immediateReaction = effectiveDelta < -10 ? 'seek safety and notify security' : effectiveDelta > 10 ? 'offer immediate assistance' : 'update social assessment'; const event = emitWorldEvent(this.state, 'npc-player-reaction', npc.id, this.state.player.id, `${npc.name} remembers: ${action}`, { severity: Math.abs(effectiveDelta) / 100, data: { trustDelta: effectiveDelta, reaction: npc.playerReaction } }); return { ok: true, message: event.summary, eventIds: [event.id] }; } setAlive(npcId: EntityId, alive: boolean, cause: string): void { const npc = this.getNpc(npcId); if (npc.alive === alive) return; npc.alive = alive; npc.health = alive ? Math.max(1, npc.health) : 0; npc.taskQueue = alive ? npc.taskQueue : []; emitWorldEvent(this.state, alive ? 'npc-revived' : 'npc-died', npc.id, null, `${npc.name} ${alive ? 'returned to active life' : `died: ${cause}`}.`, { severity: alive ? 0.5 : 1, persistent: true, data: { cause } }); rebuildRoomOccupants(this.state); } tick(deltaMs: number): void { const elapsedHours = deltaMs / 3_600_000; const worldMinute = Math.floor((this.state.elapsedMs / 60_000) % 1_440); const recentEvents = this.state.events.filter((event) => !event.resolved && event.tick >= this.state.tick - 100); for (const npc of this.state.npcs) { if (!npc.alive) continue; this.updateNeeds(npc, elapsedHours); this.updateScheduleAndPlan(npc, worldMinute); this.updateLayeredBehavior(npc, recentEvents); this.advanceTask(npc, elapsedHours); npc.lastUpdatedTick = this.state.tick; } rebuildRoomOccupants(this.state); } sealSocialEvent(npc: NpcState, kind: string, withId: string, summary: string, weight: number): void { const ts = this.state.tick; const content = `${kind}:${withId}:${summary.slice(0, 64)}:${weight.toFixed(3)}:${ts}`; const combined = npc.wormHead + content; // Deterministic hex digest using a simple djb2-derived fold (no async crypto in simulation tick) let h = 5381; for (let i = 0; i < combined.length; i += 1) h = ((h << 5) + h) ^ (combined.codePointAt(i) ?? 0); const seal = (h >>> 0).toString(16).padStart(8, '0').repeat(4).slice(0, 16).toUpperCase(); npc.wormHead = seal; npc.memories.push({ id: `memory-${npc.id}-${ts}-${kind}`, tick: ts, subjectId: withId, summary, emotionalWeight: clamp(weight, -1, 1), confidence: 1, tags: [kind, 'social', 'worm-sealed'], }); trimHistory(npc.memories, 64); } private updateNeeds(npc: NpcState, elapsedHours: number): void { const dm = npc.personality?.needDecayMod; const restDecay = dm ? 2.8 * dm.rest : 2.8; const nutritionDecay = dm ? 4.2 * dm.nutrition : 4.2; const belongingDecay = dm ? 0.6 * dm.belonging : 0.6; npc.needs.rest = clamp(npc.needs.rest - elapsedHours * restDecay, 0, 100); npc.needs.nutrition = clamp(npc.needs.nutrition - elapsedHours * nutritionDecay, 0, 100); npc.needs.belonging = clamp(npc.needs.belonging - elapsedHours * belongingDecay, 0, 100); const room = this.state.rooms.find((candidate) => candidate.id === npc.currentLocationId); const safetyDelta = room?.damage || !room?.environment.breathable ? -8 : 0.8; npc.needs.safety = clamp(npc.needs.safety + elapsedHours * safetyDelta, 0, 100); if (npc.currentLocationId === npc.homeLocationId) npc.needs.rest = clamp(npc.needs.rest + elapsedHours * 10, 0, 100); if (npc.currentLocationId === 'room-galley') npc.needs.nutrition = clamp(npc.needs.nutrition + elapsedHours * 18, 0, 100); if (npc.health < 40 && npc.currentLocationId !== 'room-medical') { npc.taskQueue.unshift({ id: `task-${npc.id}-medical-${this.state.tick}`, kind: 'seek-medical-care', targetId: 'interaction-medical', locationId: 'room-medical', priority: 100, progress: 0, status: 'queued' }); } } private updateScheduleAndPlan(npc: NpcState, worldMinute: number): void { const scheduled = npc.schedule.find((entry) => worldMinute >= entry.startMinute && worldMinute < entry.endMinute) ?? npc.schedule[0]; if (!scheduled) return; const urgent = npc.taskQueue.find((task) => task.status === 'active' || task.priority > scheduled.priority); if (!urgent) { const scheduledTask = npc.taskQueue.find((task) => task.kind === scheduled.activity && task.status !== 'completed'); if (!scheduledTask) { npc.taskQueue.push({ id: `task-${npc.id}-${this.state.tick}`, kind: scheduled.activity, targetId: null, locationId: scheduled.locationId, priority: scheduled.priority, progress: 0, status: 'queued' }); } } npc.taskQueue.sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)); const active = npc.taskQueue.find((task) => task.status === 'active') ?? npc.taskQueue.find((task) => task.status === 'queued'); if (active && active.status === 'queued') { active.status = 'active'; npc.currentLocationId = active.locationId; } npc.behavior.shortTermTaskId = active?.id ?? null; } private updateLayeredBehavior(npc: NpcState, events: WorldEventState[]): void { const relevant = events .filter((event) => event.targetId === npc.id || event.targetId === npc.currentLocationId || event.targetId === null) .sort((left, right) => right.severity - left.severity || right.tick - left.tick)[0]; npc.behavior.immediateReaction = relevant && relevant.severity >= 0.7 ? 'react to immediate hazard' : 'continue local task'; npc.behavior.worldEventResponse = relevant ? `respond to ${relevant.type}` : 'monitor local alerts'; const faction = this.state.factions.find((candidate) => candidate.id === npc.factionId); npc.behavior.factionObligation = faction?.activeConflictIds.length ? 'support faction emergency posture' : `advance ${faction?.goals[0] ?? 'assigned duty'}`; const playerMemories = npc.memories.filter((memory) => memory.subjectId === this.state.player.id); npc.behavior.memoryBias = playerMemories.length === 0 ? 0 : playerMemories.reduce((sum, memory) => sum + memory.emotionalWeight * memory.confidence, 0) / playerMemories.length; const goal = npc.goals.find((candidate) => candidate.id === npc.behavior.longTermGoalId); if (goal && npc.currentLocationId === npc.workLocationId) goal.progress = clamp(goal.progress + 0.0005, 0, 1); if (goal?.progress === 1) goal.status = 'completed'; } private advanceTask(npc: NpcState, elapsedHours: number): void { const active = npc.taskQueue.find((task) => task.status === 'active'); if (!active) return; active.progress = clamp(active.progress + Math.max(0.01, elapsedHours * 2), 0, 1); if (active.progress < 1) return; active.status = 'completed'; if (active.kind.includes('rest')) npc.needs.rest = clamp(npc.needs.rest + 12, 0, 100); if (active.kind.includes('breakfast')) npc.needs.nutrition = clamp(npc.needs.nutrition + 18, 0, 100); if (active.kind.includes('social') || active.kind.includes('chat')) { npc.needs.belonging = clamp(npc.needs.belonging + 8, 0, 100); this.sealSocialEvent(npc, 'social-task', active.locationId, `Completed social task: ${active.kind}`, 0.1); } npc.needs.purpose = clamp(npc.needs.purpose + 0.5, 0, 100); npc.taskQueue = npc.taskQueue.filter((task) => task.status !== 'completed' || task.id === active.id).slice(-12); } private reactionForTrust(trust: number): NpcState['playerReaction'] { if (trust <= -60) return 'hostile'; if (trust <= -20) return 'wary'; if (trust >= 70) return 'loyal'; if (trust >= 25) return 'friendly'; return 'neutral'; } }