import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts'; import { UPGRADES } from './upgrades.ts'; export interface State { tick: number; troende: number; andakt: number; andaktRate: number; // per second dread: number; dreadRate: number; // per second hemlighet: number; // 0-100, lose at 0 kaos: number; // 0-100, triggers events at 100 kaosRate: number; // per second ritual: number; // 0-100, win at 100 ritualRate: number; // per second ritualUnlocked: boolean; areas: { rosengard: boolean; varnhem: boolean; torso: boolean }; upgrades: Set; log: LogEntry[]; phase: 'playing' | 'won' | 'lost'; lostReason?: string; usedEventIds: Set; nextEventTick: number; nextKaosTick: number; } export interface LogEntry { tick: number; text: string; kind: 'event' | 'kaos' | 'system' | 'upgrade'; } const EVENT_INTERVAL_TICKS = 180; // ~6s at 30fps const KAOS_THRESHOLD = 100; const HEMLIGHET_DRAIN_PER_TROENDE = 0.002; // per second, per troende above 4 export function createState(): State { return { tick: 0, troende: 1, andakt: 10, andaktRate: 0.3, dread: 0, dreadRate: 0, hemlighet: 100, kaos: 0, kaosRate: 0.05, ritual: 0, ritualRate: 0, ritualUnlocked: false, areas: { rosengard: false, varnhem: false, torso: false }, upgrades: new Set(), log: [{ tick: 0, text: 'Mötet började i en fuktig källare i Rosengård. Nio stolar. En av dem är alltid lite för varm.', kind: 'system' }], phase: 'playing', usedEventIds: new Set(), nextEventTick: EVENT_INTERVAL_TICKS, nextKaosTick: EVENT_INTERVAL_TICKS * 3, }; } function pickEvent(pool: GameEvent[], state: State): GameEvent | null { const available = pool.filter(e => { if (state.usedEventIds.has(e.id)) return false; const r = e.requires; if (!r) return true; if (r.minTroende && state.troende < r.minTroende) return false; if (r.minDread && state.dread < r.minDread) return false; if (r.minRitual && state.ritual < r.minRitual) return false; if (r.unlockedArea && !state.areas[r.unlockedArea]) return false; return true; }); if (!available.length) return null; const total = available.reduce((s, e) => s + e.weight, 0); let roll = Math.random() * total; for (const e of available) { roll -= e.weight; if (roll <= 0) return e; } return available[available.length - 1]; } function applyEffect(state: State, effect: GameEvent['effect']): void { if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt); if (effect.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet)); if (effect.kaos) state.kaos = Math.max(0, Math.min(100, state.kaos + effect.kaos)); if (effect.dread) state.dread = Math.max(0, state.dread + effect.dread); if (effect.troende) state.troende = Math.max(1, state.troende + effect.troende); if (effect.ritual && state.ritualUnlocked) state.ritual = Math.min(100, state.ritual + effect.ritual); } const DT = 1 / 30; // 30fps export function tick(state: State): State { if (state.phase !== 'playing') return state; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; s.tick += 1; // Resource accumulation s.andakt += s.andaktRate * s.troende * DT; s.dread += s.dreadRate * DT; if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * DT); // Kaos accumulation — faster with more troende and more dread const kaosScale = 1 + (s.dread / 200); s.kaos = Math.min(100, s.kaos + s.kaosRate * kaosScale * DT); // Hemlighet drain — more believers = harder to stay hidden if (s.troende > 4) { s.hemlighet = Math.max(0, s.hemlighet - HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) * DT); } // Standard event if (s.tick >= s.nextEventTick) { const ev = pickEvent(STANDARD_EVENTS, s); if (ev) { s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' }); applyEffect(s, ev.effect); s.usedEventIds.add(ev.id); } s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60); if (s.log.length > 40) s.log = s.log.slice(0, 40); } // Kaos event if (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick) { const ev = pickEvent(KAOS_EVENTS, s); if (ev) { s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' }); applyEffect(s, ev.effect); s.usedEventIds.add(ev.id); } s.kaos = Math.max(0, s.kaos - 80); s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120); if (s.log.length > 40) s.log = s.log.slice(0, 40); } // Win / lose checks if (s.ritual >= 100) { s.phase = 'won'; s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' }); } else if (s.hemlighet <= 0) { s.phase = 'lost'; s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.'; s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' }); } return s; } export function buyUpgrade(state: State, id: string): State { const upgrade = UPGRADES.find(u => u.id === id); if (!upgrade) return state; if (!upgrade.available(state)) return state; if (upgrade.cost.andakt && state.andakt < upgrade.cost.andakt) return state; if (upgrade.cost.dread && state.dread < upgrade.cost.dread) return state; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; if (upgrade.cost.andakt) s.andakt -= upgrade.cost.andakt; if (upgrade.cost.dread) s.dread -= upgrade.cost.dread; upgrade.effect(s); if (upgrade.once) s.upgrades.add(id); s.log.unshift({ tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' }); if (s.log.length > 40) s.log = s.log.slice(0, 40); return s; }