import type { EventCast, EventCtx, GameState, Injury, LootEntry, ResourceId, Rng, Survivor, EnemyDef, LogEntry, } from './types'; import { generateSurvivor } from '../content/survivors'; import { A, aliveOf, applyMorale, addRel, hasTrait, } from './sim-core'; export interface CtxSession { lines: LogEntry[]; } /** Build an EventCtx bound to a state + rng. Narration accumulates in session.lines. */ export function makeCtx(s: GameState, rng: Rng, cast: EventCast, session: CtxSession): EventCtx { const push = (text: string, tone: LogEntry['kind'] = 'info') => { session.lines.push({ day: s.day, text, kind: tone }); s.log.push({ day: s.day, text, kind: tone }); }; const ctx: EventCtx = { s, rng, cast, say: (text, tone) => push(text, tone ?? 'info'), gain(res, n) { if (n <= 0) return; s.res[res] += n; }, lose(res, n) { if (n <= 0) return; s.res[res] = Math.max(0, s.res[res] - n); }, hasRes: (res, n) => s.res[res] >= n, morale(target, delta) { if (target === 'all') { for (const t of aliveOf(s)) applyMorale(s, t, delta); } else { applyMorale(s, target as Survivor, delta); } }, hp(target, delta) { target.hp = Math.max(1, Math.min(100, target.hp + delta)); }, hurt(target, kind, label, sev, days) { if (hasTrait(target, 'fragile')) days += 2; if (hasTrait(target, 'tough')) days = Math.max(1, days - 1); const inj: Injury = { kind, label, severity: sev, daysLeft: days }; target.injuries.push(inj); target.hp = Math.max(5, target.hp - sev * 8); push(`${target.name}: ${label}.`, 'bad'); }, healInjury(target) { if (!target.injuries.length) return false; target.injuries.sort((a, b) => b.severity - a.severity); const worst = target.injuries.shift()!; if (target.hp < 100) target.hp = Math.min(100, target.hp + 12); push(`${worst.label} — healed for ${target.name}.`, 'good'); return true; }, rel: (a, b, delta) => addRel(s, a, b, delta), memory(target, text, tone = 'neutral') { target.memories.push({ day: s.day, text, tone }); if (target.memories.length > 24) target.memories.shift(); }, flag(key, v = 1) { s.flags[key] = (s.flags[key] ?? 0) + v; if (s.flags[key] === 0 && v === 0) delete s.flags[key]; }, discoverRandom() { const hidden = s.locations.filter(l => !l.discovered); if (!hidden.length) return null; const loc = rng.pick(hidden); loc.discovered = true; s.log.push({ day: s.day, text: `New location discovered: ${loc.name}`, kind: 'good' }); return loc; }, queueEvent(eventId, inDays, payload) { s.queuedEvents.push({ eventId, dueDay: s.day + Math.max(0, inDays), payloadName: payload?.name, payloadId: payload?.id, }); }, joinGroup(opts) { const count = aliveOf(s).length; if (count >= 9) return null; const id = `s${s.nextSurvivorNum++}`; const ns = generateSurvivor(rng, id, { name: opts?.name, joinDay: s.day }); ns.morale = Math.min(ns.morale, 55); // newcomers are wary ns.hunger = Math.max(30, ns.hunger - 20); s.survivors.push(ns); for (const o of aliveOf(s)) { if (o.id !== ns.id) addRel(s, o.id, ns.id, rng.i(-6, 12)); } s.log.push({ day: s.day, text: `${ns.name} (${ns.occLabel}) joined the camp.`, kind: 'good' }); push(`${ns.name} joins the camp — ${ns.occLabel}, ${ns.age}.`, 'good'); return ns; }, leaveGroup(survivor, reason) { survivor.gone = true; survivor.goneDay = s.day; survivor.goneReason = reason; s.log.push({ day: s.day, text: `${survivor.name} left the camp — ${reason.toLowerCase()}.`, kind: 'bad' }); for (const o of aliveOf(s)) { const v = s.rel[relKeyOf(o.id, survivor.id)] ?? 0; applyMorale(s, o, v > 40 ? -8 : -3); } if (survivor.coupleWith) { const p = s.survivors.find(x => x.id === survivor.coupleWith); if (p && A(p)) { p.coupleWith = undefined; applyMorale(s, p, -15); p.memories.push({ day: s.day, text: `${survivor.name} left. Just left.`, tone: 'bad' }); } survivor.coupleWith = undefined; } }, kill(survivor, cause) { killAndGrief(s, survivor, cause, session); }, encounter(spec) { queueEncounter( s, { kind: 'event', name: spec.enemyName, strength: spec.strength, perception: spec.perception, speed: spec.speed, greed: spec.greed, loot: spec.loot, desc: spec.enemyDesc, }, spec.party, spec.intro, spec.context ?? 'event', ); }, logWorld: (text, tone) => { s.log.push({ day: s.day, text, kind: tone ?? 'info' }); }, }; return ctx; } import { relKey as relKeyOf } from './sim-core'; /* ---------------- death & grief ---------------- */ export function killAndGrief(s: GameState, victim: Survivor, cause: string, session?: CtxSession) { if (!victim.alive || victim.gone) return; victim.alive = false; victim.deathDay = s.day; victim.deathCause = cause; victim.hp = 0; s.stats.lost++; const line = `${victim.name} is dead — ${cause}.`; s.log.push({ day: s.day, text: line, kind: 'bad' }); session?.lines.push({ day: s.day, text: line, kind: 'bad' }); for (const o of aliveOf(s)) { const v = s.rel[relKeyOf(o.id, victim.id)] ?? 0; if (v >= 60) { applyMorale(s, o, -18); o.memories.push({ day: s.day, text: `${victim.name} died. ${victim.name} was family.`, tone: 'bad' }); s.flags[`grief_${o.id}`] = (s.flags[`grief_${o.id}`] ?? 0) + 1; s.strFlags[`grief_about_${o.id}`] = victim.name; } else if (v >= 20) { applyMorale(s, o, -10); o.memories.push({ day: s.day, text: `Lost ${victim.name}.`, tone: 'bad' }); } else { applyMorale(s, o, -6); } if (hasTrait(o, 'compassionate')) applyMorale(s, o, -3); } if (victim.coupleWith) { const p = s.survivors.find(x => x.id === victim.coupleWith); if (p && A(p)) { p.coupleWith = undefined; applyMorale(s, p, -20); p.memories.push({ day: s.day, text: `Buried ${victim.name}. The world got smaller.`, tone: 'bad' }); s.flags[`grief_${p.id}`] = (s.flags[`grief_${p.id}`] ?? 0) + 1; s.strFlags[`grief_about_${p.id}`] = victim.name; } victim.coupleWith = undefined; } // funeral event queued for next morning (dynamic event) s.queuedEvents.push({ eventId: `dyn:funeral:${encodeURIComponent(victim.name)}:${encodeURIComponent(cause)}:${victim.occLabel}`, dueDay: Math.min(100, s.day + 1), }); s.flags.hope = Math.max(-5, (s.flags.hope ?? 0) - 1); } /* ---------------- encounters ---------------- */ export function queueEncounter( s: GameState, enemyDef: EnemyDef, partyIds: string[], intro: string, context: import('./types').Encounter['context'], strengthScale = 1, ) { const ids = partyIds.filter(id => { const x = s.survivors.find(y => y.id === id); return x !== undefined && A(x); }); s.pending.push({ enemyName: enemyDef.name, enemyDesc: enemyDef.desc, strength: Math.round(enemyDef.strength * strengthScale), perception: enemyDef.perception, speed: enemyDef.speed, greed: enemyDef.greed, lootOnWin: enemyDef.loot, party: ids, intro, context, }); } export function rollLoot(rng: Rng, table: LootEntry[], mult = 1): Partial> { // Each entry is a probabilistic find; mult shifts both chance and plenty. const out: Partial> = {}; for (const e of table) { const p = Math.min(0.95, e.weight * 0.17 * Math.max(0.2, mult)); if (!rng.chance(p)) continue; let amt = rng.i(e.min, e.max); if (mult > 1.4 && rng.chance((mult - 1) * 0.5)) amt += rng.i(e.min, e.max); out[e.res] = (out[e.res] ?? 0) + amt; } return out; }