// CorruptionDirector.ts: single source of truth for all state + A/V params. // Holds happiness/awareness/day/task flags/clue set/erosion stage. // Emits events for scenes to react. import { audio } from './AudioEngine'; import { HAPPY_START, AWARE_START, HAPPY_MAX, AWARE_MAX, OVERFLOW_THRESHOLD, EROSION_MATRIX, AWARE_CLUE_GAIN, AWARE_SAD_GAIN, HAPPY_SAD_LOSS, HAPPY_SMALL_DAILY_CAP, DEFAULT_NAME, LOW_HAPPY_THRESHOLD, LOW_HAPPY_DURATION_MS, FORCE_FESTIVAL_HAPPY, } from '../data/gamedata'; export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4'; type Handler = (payload?: any) => void; class CorruptionDirector { happiness = HAPPY_START; awareness = AWARE_START; day = 1; stage: Stage = 'S1'; playerName = DEFAULT_NAME; startTime = Date.now(); // clue set clues = new Set(); // task completion tasksDone = new Set(); // sadness: small-interaction daily counters per kind smallInteractCounts: Record = {}; // key: kind@day // npc talk counts per day (for 纠缠 detection) npcTalkCounts: Record = {}; // rejection records (Day 5) rejectedTasks = new Set(); // escaped forced festivals count (E4) escapedFestivals = 0; // happiness-low tracker private lowHappySince = 0; // overflow pool private overflowPool = 0; // last negative flash time private lastFlashAt = 0; // gaze tracking (well / fog) once per day gazedWell = false; gazedFog = false; // whether forced festival is active forcedFestivalActive = false; private listeners: Record = {}; on(ev: string, h: Handler) { (this.listeners[ev] ||= []).push(h); return () => this.off(ev, h); } off(ev: string, h: Handler) { const arr = this.listeners[ev]; if (!arr) return; const i = arr.indexOf(h); if (i >= 0) arr.splice(i, 1); } emit(ev: string, payload?: any) { (this.listeners[ev] || []).slice().forEach(h => { try { h(payload); } catch (e) { console.error(e); } }); } reset() { this.happiness = HAPPY_START; this.awareness = AWARE_START; this.day = 1; this.stage = 'S1'; this.playerName = DEFAULT_NAME; this.startTime = Date.now(); this.clues.clear(); this.tasksDone.clear(); this.smallInteractCounts = {}; this.npcTalkCounts = {}; this.rejectedTasks.clear(); this.escapedFestivals = 0; this.lowHappySince = 0; this.overflowPool = 0; this.gazedWell = false; this.gazedFog = false; this.forcedFestivalActive = false; this.recomputeStage(); this.emit('change'); } recomputeStage() { const h = this.happiness; let s: Stage = 'S0'; for (const row of EROSION_MATRIX) { if (h >= row.happinessRange[0] && h <= row.happinessRange[1]) { s = row.stage; break; } } if (s !== this.stage) { this.stage = s; audio.setErosion(s); this.emit('stage', s); } this.emit('change'); } setHappiness(v: number, opts: { silent?: boolean } = {}) { const prev = this.happiness; let nv = Math.max(0, Math.min(HAPPY_MAX, Math.round(v))); // forced maintenance at 100: small deductions accumulate in overflow pool if (prev >= HAPPY_MAX && nv < prev) { const diff = prev - nv; this.overflowPool += diff; nv = HAPPY_MAX; // stays at 100 if (this.overflowPool >= OVERFLOW_THRESHOLD) { this.overflowPool = 0; this.emit('negativeFlash'); this.stage = 'S4'; audio.setErosion('S4'); this.emit('stage', 'S4'); } } this.happiness = nv; if (!opts.silent) this.recomputeStage(); this.checkLowHappy(); } addHappiness(delta: number) { if (delta > 0 && this.happiness >= HAPPY_MAX) { // overflow pool accumulation (PRD: 小额扣分不再生效而是累积进隐藏溢出池) — for increases beyond 100, ignore return; } this.setHappiness(this.happiness + delta); } addAwareness(delta: number) { this.awareness = Math.max(0, Math.min(AWARE_MAX, this.awareness + delta)); this.emit('change'); } // §6.2 clue discovery discoverClue(id: string) { if (this.clues.has(id)) return false; this.clues.add(id); this.addAwareness(AWARE_CLUE_GAIN); this.emit('clue', id); this.emit('change'); return true; } // §6.1 small interaction (water/feed/pet) +1, daily cap 3 per kind smallInteraction(kind: string) { const key = `${kind}@${this.day}`; const count = this.smallInteractCounts[key] || 0; if (count >= HAPPY_SMALL_DAILY_CAP) return false; this.smallInteractCounts[key] = count + 1; this.addHappiness(1); return true; } // §6.3 sadness behavior: -5 happy, +3 aware, nearest NPC turns sadness(behavior: string, nearestNpc?: string) { this.addHappiness(-HAPPY_SAD_LOSS); this.addAwareness(AWARE_SAD_GAIN); this.emit('sadness', { behavior, nearestNpc }); this.emit('change'); } // gaze well/fog (§6.2) gaze(target: 'well' | 'fog') { if (target === 'well' && !this.gazedWell) { this.gazedWell = true; this.addAwareness(3); } if (target === 'fog' && !this.gazedFog) { this.gazedFog = true; this.addAwareness(3); } } // NPC talk counter for 纠缠 (>=5/day) noteNpcTalk(npcKey: string): boolean { const key = `${npcKey}@${this.day}`; this.npcTalkCounts[key] = (this.npcTalkCounts[key] || 0) + 1; return this.npcTalkCounts[key] >= 5; } private checkLowHappy() { if (this.happiness < LOW_HAPPY_THRESHOLD) { if (this.lowHappySince === 0) this.lowHappySince = Date.now(); else if (Date.now() - this.lowHappySince >= LOW_HAPPY_DURATION_MS && !this.forcedFestivalActive) { this.triggerForcedFestival(); } } else { this.lowHappySince = 0; } } // §6.1 forced festival triggerForcedFestival() { this.forcedFestivalActive = true; this.emit('forcedFestivalStart'); // raise happiness to 50 this.setHappiness(FORCE_FESTIVAL_HAPPY); } escapeFestival() { if (!this.forcedFestivalActive) return; this.forcedFestivalActive = false; this.escapedFestivals++; this.emit('forcedFestivalEnd'); this.emit('change'); } advanceDay() { this.day = Math.min(7, this.day + 1); this.gazedWell = false; this.gazedFog = false; this.emit('day', this.day); this.emit('change'); } // ending determination per §9 priority determineEnding(): 'E4'|'E3'|'E2'|'E1' { if (this.day >= 5 && this.happiness <= 10 && this.escapedFestivals >= 3) return 'E4'; if (this.awareness >= 70 && this.clues.size >= 6) return 'E3'; if (this.awareness >= 30) return 'E2'; if (this.awareness <= 29 && this.happiness >= 90) return 'E1'; // fallback if (this.awareness <= 29) return 'E1'; return 'E2'; } playTimeSeconds(): number { return Math.floor((Date.now() - this.startTime) / 1000); } formatPlayTime(): string { const s = this.playTimeSeconds(); const m = Math.floor(s / 60); const sec = s % 60; return `${m}分${sec}秒`; } // debug setters debugSetHappiness(v: number) { this.happiness = Math.max(0,Math.min(HAPPY_MAX,v)); this.recomputeStage(); } debugSetAwareness(v: number) { this.awareness = Math.max(0,Math.min(AWARE_MAX,v)); this.emit('change'); } debugSetDay(d: number) { this.day = Math.max(1,Math.min(7,d)); this.emit('day',this.day); this.emit('change'); } debugAllClues() { ['C1','C2','C3','C4','C5','C6','C7','C8','C9'].forEach(c=>this.clues.add(c)); this.addAwareness(0); this.emit('change'); } } export const director = new CorruptionDirector();