- 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)
245 lines
7.9 KiB
TypeScript
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;
|
|
}
|