Files
100-days-after/src/engine/eventctx.ts
T
deepseek 486201655b 100 Days After — complete playable survival strategy game
- Seeded procedural runs (survivors, locations, weather, events)
- 100-day campaign with 5 phases, scripted story beats, Day-100 finale
- 8 endings incl. hidden 'First Light Again'
- Data-driven events (people/world/story packs) with callback chains
- Relationships, memories, grief, karma/hope systems
- Tactical encounters (fight/hide/run/negotiate) with honest odds
- 8 upgradeable camp buildings, 13 location templates
- Canvas parallax scenes, procedural SVG portraits, weather FX
- Synthesized adaptive audio (WebAudio, no assets)
- Save slots + autosave (localStorage), shareable seeds
- Tests: headless multi-seed simulator + browser E2E (playwright)
2026-08-23 06:58:47 +00:00

245 lines
7.9 KiB
TypeScript

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<Record<ResourceId, number>> {
// Each entry is a probabilistic find; mult shifts both chance and plenty.
const out: Partial<Record<ResourceId, number>> = {};
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;
}