Add flag-gated NPC event chains for Broder Lindqvist and Syster Hassan
Events can now depend on a previously-set flag and a completed event id, letting a choice made early in the game (e.g. whether Lindqvist's whisper was heeded) shape which follow-up events surface later. Adds two 3-step chains as a first example of persistent NPC arcs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ export interface State {
|
||||
phase: 'playing' | 'won' | 'lost';
|
||||
lostReason?: string;
|
||||
usedEventIds: Set<string>;
|
||||
flags: Set<string>;
|
||||
nextEventTick: number;
|
||||
nextKaosTick: number;
|
||||
pendingEvent: PendingEvent | null;
|
||||
@@ -70,6 +71,7 @@ export function createState(): State {
|
||||
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(),
|
||||
flags: new Set(),
|
||||
nextEventTick: EVENT_INTERVAL_TICKS,
|
||||
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
||||
pendingEvent: null,
|
||||
@@ -85,6 +87,8 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
||||
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;
|
||||
if (r.flag && !state.flags.has(r.flag)) return false;
|
||||
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!available.length) return null;
|
||||
@@ -111,7 +115,7 @@ 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) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
s.tick += 1;
|
||||
|
||||
// A choice event is awaiting the player's answer — freeze new events, keep resources flowing.
|
||||
@@ -189,8 +193,9 @@ export function resolveEventChoice(state: State, choiceIndex: number): State {
|
||||
const choice = state.pendingEvent.event.choices?.[choiceIndex];
|
||||
if (!choice) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
applyEffect(s, choice.effect);
|
||||
if (choice.flag) s.flags.add(choice.flag);
|
||||
s.log.unshift({ tick: s.tick, text: `↳ ${choice.resultText}`, kind: s.pendingEvent!.kind });
|
||||
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
||||
s.pendingEvent = null;
|
||||
@@ -264,7 +269,7 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
|
||||
const now = state.tick;
|
||||
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
|
||||
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
|
||||
const text = areaLocked ? visit.lockedText : visit.text;
|
||||
@@ -292,6 +297,7 @@ export function saveState(state: State): void {
|
||||
...state,
|
||||
upgrades: [...state.upgrades],
|
||||
usedEventIds: [...state.usedEventIds],
|
||||
flags: [...state.flags],
|
||||
log: state.log.slice(0, 20),
|
||||
};
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(serial));
|
||||
@@ -307,6 +313,7 @@ export function loadState(): State | null {
|
||||
...d,
|
||||
upgrades: new Set<string>(d.upgrades ?? []),
|
||||
usedEventIds: new Set<string>(d.usedEventIds ?? []),
|
||||
flags: new Set<string>(d.flags ?? []),
|
||||
} as State;
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -326,7 +333,7 @@ export function buyUpgrade(state: State, id: string): State {
|
||||
if (cost.dread && state.dread < cost.dread) return state;
|
||||
if (cost.inflytande && state.inflytande < cost.inflytande) return state;
|
||||
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
|
||||
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
||||
if (cost.andakt) s.andakt -= cost.andakt;
|
||||
if (cost.dread) s.dread -= cost.dread;
|
||||
if (cost.inflytande) s.inflytande -= cost.inflytande;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface EventChoice {
|
||||
text: string;
|
||||
effect: EventEffect;
|
||||
resultText: string;
|
||||
flag?: string;
|
||||
}
|
||||
|
||||
export interface GameEvent {
|
||||
@@ -18,7 +19,14 @@ export interface GameEvent {
|
||||
text: string;
|
||||
effect: EventEffect;
|
||||
weight: number;
|
||||
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>;
|
||||
requires?: Partial<{
|
||||
minTroende: number;
|
||||
minDread: number;
|
||||
minRitual: number;
|
||||
unlockedArea: Area;
|
||||
flag: string;
|
||||
completedEventId: string;
|
||||
}>;
|
||||
choices?: EventChoice[];
|
||||
}
|
||||
|
||||
@@ -461,11 +469,46 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
text: 'Lyssna på viskningen',
|
||||
effect: { dread: 12, hemlighet: -4, andakt: 6 },
|
||||
resultText: 'Ni lyssnar. Det ni hör går inte att glömma. Ni vet nu var Den Sovande vänder blicken härnäst.',
|
||||
flag: 'lindqvist_listened',
|
||||
},
|
||||
{
|
||||
text: 'Be honom tiga',
|
||||
effect: { kaos: 6 },
|
||||
resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.',
|
||||
flag: 'lindqvist_silenced',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_2a',
|
||||
text: 'Broder Lindqvist har inte sovit på fyra dagar. Han säger att han inte längre behöver det. Han ritar samma karta om och om igen, alltid samma vinkel mot Öresund.',
|
||||
effect: { dread: 10, andakt: 5 },
|
||||
weight: 9,
|
||||
requires: { flag: 'lindqvist_listened', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_2b',
|
||||
text: 'Broder Lindqvist har blivit tystlåten sedan ni bad honom tiga. Han deltar fortfarande, men han skriver nu ner sina drömmar istället för att dela dem. Ingen har läst anteckningsboken. Ingen vågar fråga.',
|
||||
effect: { hemlighet: -6, kaos: 4 },
|
||||
weight: 9,
|
||||
requires: { flag: 'lindqvist_silenced', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
|
||||
},
|
||||
{
|
||||
id: 'lindqvist_chain_3',
|
||||
text: 'Lindqvist ber att få gå ner till Öresund ensam, på natten, "för att svara ordentligt denna gång." Han har redan sina kläder på sig. Han väntar bara på ert tillstånd.',
|
||||
effect: {},
|
||||
weight: 8,
|
||||
requires: { minDread: 45, completedEventId: 'choice_lindqvist_dream' },
|
||||
choices: [
|
||||
{
|
||||
text: 'Låt honom gå',
|
||||
effect: { dread: 20, ritual: 4, hemlighet: -8 },
|
||||
resultText: 'Han går ner mot vattnet och försvinner in i dimman. Han kommer tillbaka i gryningen, torr, leende, och pratar inte längre om drömmar. Han pratar inte om något alls.',
|
||||
},
|
||||
{
|
||||
text: 'Håll fast honom',
|
||||
effect: { kaos: 15, andakt: 10 },
|
||||
resultText: 'Ni håller kvar honom till gryningen. Han skriker att ni har gjort ett misstag. Han slutar skrika när solen går upp. Han säger aldrig varför.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -487,6 +530,60 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hassan_ambition',
|
||||
text: 'Syster Hassan har börjat föreslå ändringar i ritualens ordning. Hon säger att hon sett en bättre väg i en dröm. Hon säger det med en säkerhet ni inte kommer ihåg att hon haft förut.',
|
||||
effect: {},
|
||||
weight: 8,
|
||||
requires: { minDread: 15 },
|
||||
choices: [
|
||||
{
|
||||
text: 'Ge henne ansvar',
|
||||
effect: { andakt: 12, dread: 6 },
|
||||
resultText: 'Ni låter henne leda nästa moment. Cirkeln följer henne lättare än den någonsin följt er. Det känns bra. Det känns också som en förlust ni inte kan namnge.',
|
||||
flag: 'hassan_empowered',
|
||||
},
|
||||
{
|
||||
text: 'Behåll ordningen',
|
||||
effect: { kaos: 5 },
|
||||
resultText: 'Ni tackar för förslaget och fortsätter som förut. Hassan nickar och säger inget mer. Hon skriver fortfarande i sin anteckningsbok efteråt.',
|
||||
flag: 'hassan_sidelined',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_2a',
|
||||
text: 'Syster Hassan leder nu halva mötena. Hon har egna ord för Den Sovande, ord ingen av er lärt henne. Troende lyssnar på henne som de en gång lyssnade på er.',
|
||||
effect: { andakt: 15, kaos: 5 },
|
||||
weight: 8,
|
||||
requires: { flag: 'hassan_empowered', completedEventId: 'hassan_ambition', minDread: 35 },
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_2b',
|
||||
text: 'Syster Hassans anteckningsbok är full nu. Hon slutar be om lov att tala. Det märks inte i mötena — hon sitter tyst och riktig — men ni ser henne titta på dörren mot Värnhemstorget på ett nytt sätt.',
|
||||
effect: { hemlighet: -5, dread: 8 },
|
||||
weight: 8,
|
||||
requires: { flag: 'hassan_sidelined', completedEventId: 'hassan_ambition', minDread: 35 },
|
||||
},
|
||||
{
|
||||
id: 'hassan_chain_3',
|
||||
text: 'Syster Hassan ber om att få genomföra en egen ritual, separat från er, på Turning Torso. Hon säger att Den Sovande bett om henne specifikt. Hon säger det utan att blinka.',
|
||||
effect: {},
|
||||
weight: 7,
|
||||
requires: { minDread: 55, completedEventId: 'hassan_ambition', unlockedArea: 'torso' },
|
||||
choices: [
|
||||
{
|
||||
text: 'Låt henne göra det',
|
||||
effect: { dread: 22, ritual: 6, troende: -1 },
|
||||
resultText: 'Hon går upp ensam i mörkret. Ni ser ljus i fönstren på våningar som inte har fönster. Hon kommer tillbaka annorlunda — mer er, mindre henne, i en riktning ingen kan sätta ord på.',
|
||||
},
|
||||
{
|
||||
text: 'Neka henne',
|
||||
effect: { kaos: 18, hemlighet: -4 },
|
||||
resultText: 'Ni säger nej. Hon accepterar det med ett leende som inte når hennes ögon och går tillbaka till sin plats i cirkeln. Ni undrar länge efteråt om ni gjorde rätt val, eller bara det säkra.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'choice_new_recruit',
|
||||
text: 'En ung kvinna har suttit utanför källaren tre kvällar i rad. Hon säger att hon hörde sången genom väggarna och att hon inte kan sluta tänka på den.',
|
||||
|
||||
Reference in New Issue
Block a user