diff --git a/src/data/version.ts b/src/data/version.ts index 6073a35..fef08f9 100644 --- a/src/data/version.ts +++ b/src/data/version.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.7.1'; +export const VERSION = '0.8.0'; export interface ChangelogEntry { version: string; @@ -7,6 +7,18 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: '0.8.0', + date: '2026-08-08', + changes: [ + 'Nytt: LORE-BIBLE.md dokumenterar allt som redan var etablerat i spelet, inklusive trådar som aldrig knutits ihop', + 'Inflytande har äntligen fler användningsområden: ett nätverk av tjänster, en repeterbar "kalla in en tjänst"-uppgradering, och ett sätt att neutralisera Kommissarie Fors utredning permanent', + 'Fler än nio troende har nu en egen konsekvens: källaren blir för trång, och en ny uppgradering flyttar er till en större lokal', + 'Möllevångstorget och Malmö universitet har fått egna händelser och en uppgradering, istället för att bara vara besöksbara utan vidare koppling', + 'Fyra nya kaos-händelser i ett tyngre register — inte allt i kaos-poolen behöver vara ett skämt', + 'Segrar och nederlag har nu olika slutord beroende på om Lindqvist lyssnade, Hassan fick ansvar, eller utredningen neutraliserades — tidigare påverkade de valen aldrig själva slutet', + ], + }, { version: '0.7.1', date: '2026-08-03', diff --git a/src/engine.test.ts b/src/engine.test.ts index 7593d2d..1a80dba 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -189,6 +189,83 @@ describe('exert_influence', () => { }); }); +describe('favor_network / call_in_favor', () => { + it('call_in_favor is unavailable until favor_network is bought, then restores hemlighet with rising cost per use', () => { + let s = createState(); + s.troende = 3; + s.inflytande = 1000; + s.hemlighet = 50; + + const blocked = buyUpgrade(s, 'call_in_favor'); + expect(blocked.hemlighet).toBe(50); // no favor_network yet — refused + + s = buyUpgrade(s, 'favor_network'); + expect(s.upgrades.has('favor_network')).toBe(true); + + const callInFavor = UPGRADES.find(u => u.id === 'call_in_favor')!; + let lastCost = 0; + for (let i = 0; i < 4; i++) { + s.hemlighet = 50; // reset below the cap each round so the restore is actually observable + const cost = callInFavor.cost(s).inflytande!; + expect(cost).toBeGreaterThan(lastCost); + lastCost = cost; + s = buyUpgrade(s, 'call_in_favor'); + expect(s.hemlighet).toBeGreaterThan(50); + expect(s.favorsCalled).toBe(i + 1); + } + }); +}); + +describe('neutralize_investigation', () => { + it('requires favor_network and having actually met Kommissarie Fors, then sets fors_neutralized', () => { + let s = createState(); + s.troende = 3; + s.inflytande = 1000; + s.andakt = 1000; + + const tooEarly = buyUpgrade(s, 'neutralize_investigation'); + expect(tooEarly.flags.has('fors_neutralized')).toBe(false); + + s = buyUpgrade(s, 'favor_network'); + s.usedEventIds.add('coc_investigator_dream'); + s = buyUpgrade(s, 'neutralize_investigation'); + expect(s.flags.has('fors_neutralized')).toBe(true); + expect(s.upgrades.has('neutralize_investigation')).toBe(true); + }); +}); + +describe('bigger_venue (troende past nine)', () => { + it('is only available once nine_chairs_full is bought and troende reaches ten', () => { + let s = createState(); + s.troende = 10; + s.andakt = 10000; + s.dread = 10000; + + const tooEarly = buyUpgrade(s, 'bigger_venue'); + expect(tooEarly.upgrades.has('bigger_venue')).toBe(false); + + s.upgrades.add('nine_chairs_full'); + const rateBefore = s.andaktRate; + s = buyUpgrade(s, 'bigger_venue'); + expect(s.upgrades.has('bigger_venue')).toBe(true); + expect(s.andaktRate).toBeGreaterThan(rateBefore); + }); +}); + +describe('mollan_favor', () => { + it('requires the mollan_regular event to have actually fired first', () => { + let s = createState(); + s.andakt = 10000; + + const tooEarly = buyUpgrade(s, 'mollan_favor'); + expect(tooEarly.upgrades.has('mollan_favor')).toBe(false); + + s.usedEventIds.add('mollan_regular'); + s = buyUpgrade(s, 'mollan_favor'); + expect(s.upgrades.has('mollan_favor')).toBe(true); + }); +}); + describe('buyUpgrade immutability', () => { it('does not mutate the input state', () => { const s = createState(); diff --git a/src/engine.ts b/src/engine.ts index 95b474f..a425d55 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -21,6 +21,7 @@ export interface State { ritual: number; // 0-100, win at 100 ritualRate: number; // per second ritualUnlocked: boolean; + favorsCalled: number; // times "Kalla in en tjänst" bought — drives its own cost scaling areas: { rosengard: boolean; varnhem: boolean; torso: boolean }; upgrades: Set; log: LogEntry[]; @@ -68,6 +69,7 @@ export function createState(): State { ritual: 0, ritualRate: 0, ritualUnlocked: false, + favorsCalled: 0, 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' }], @@ -483,6 +485,7 @@ export function loadState(): State | null { usedEventIds: new Set(d.usedEventIds ?? []), flags: new Set(d.flags ?? []), logSeq: d.logSeq ?? 0, + favorsCalled: d.favorsCalled ?? 0, // Saves from before offline progress existed won't have this — treat // them as "just now" so an old save doesn't suddenly claim you were // away since the Unix epoch. diff --git a/src/events.ts b/src/events.ts index 96680dc..e6630cb 100644 --- a/src/events.ts +++ b/src/events.ts @@ -458,6 +458,40 @@ export const STANDARD_EVENTS: GameEvent[] = [ requires: { minDread: 30 }, }, + // ── Kommissarie Fors, escalating ── + { + id: 'fors_closing_in', + text: 'Kommissarie Fors har börjat fråga kollegor om källaren i Rosengård, vaken den här gången. Hon minns nu vad hon drömt. Hon skriver rapporter om det, officiellt, i vanlig text, med sitt eget namn under.', + effect: { hemlighet: -14, dread: 10 }, + weight: 5, + requires: { completedEventId: 'coc_investigator_dream', minDread: 45 }, + }, + + // ── Nio blir fler ── + { + id: 'troende_overflow', + text: 'Källaren rymmer inte fler stolar. Ni har börjat stå längs väggarna. Ingen klagar. Ingen erbjuder heller sin plats till den som kom sist.', + effect: { kaos: 8, hemlighet: -6 }, + weight: 6, + requires: { minTroende: 10 }, + }, + + // ── Möllan och Universitet, fördjupade ── + { + id: 'mollan_regular', + text: 'Marknadshandlaren på Möllan har börjat spara undan varor åt er innan ni ens kommer dit. Hon kallar det stamkundsrabatt. Ingen av er minns att ni presenterat er för henne.', + effect: { andakt: 10, kaos: 4 }, + weight: 7, + requires: { minTroende: 3 }, + }, + { + id: 'universitet_seminar', + text: 'En seminarieserie om "marina folktraditioner i Öresundsregionen" har startat på universitetet, utan att någon minns vem som initierade den. Föreläsaren citerar källor som inte existerar utanför Skriften.', + effect: { dread: 12, ritual: 2 }, + weight: 6, + requires: { minRitual: 5 }, + }, + // ── Choice events ─────────────────────────────────────────────────────────── { id: 'choice_lindqvist_dream', @@ -1070,4 +1104,34 @@ export const KAOS_EVENTS: GameEvent[] = [ weight: 4, requires: { minDread: 90 }, }, + + // ── Tyngre kaos-register: inte allt behöver vara ett skämt ── + { + id: 'k_thirteen_chimes', + text: 'Klockan i S:t Petri slår tretton slag mitt på dagen. Ingen på torget verkar höra det utom er. Ni räknar tillsammans efteråt, var för sig, utan att jämföra. Alla fick samma tal.', + effect: { kaos: -50, dread: 35 }, + weight: 6, + requires: { minDread: 40 }, + }, + { + id: 'k_mirror_room_return', + text: 'Spegeln i förrummet visar återigen det gamla rummet, men den här gången känner ni igen en av personerna som sitter där. Det är en av er. Ingen säger vem. Ingen frågar heller.', + effect: { kaos: -55, dread: 45 }, + weight: 5, + requires: { minDread: 60 }, + }, + { + id: 'k_sunken_lights', + text: 'Ett ljus syns nu permanent på botten av Öresund, på samma plats varje kväll. Det är inte fartygstrafik. Dykare skickas aldrig ner för att kolla. Ingen har föreslagit det. Ingen behöver föreslå det.', + effect: { kaos: -60, dread: 50, ritual: 4 }, + weight: 4, + requires: { minDread: 90 }, + }, + { + id: 'k_warm_chair_used', + text: 'Den varma stolen i källaren var upptagen i kväll, för första gången någonsin. Ingen av er såg vem som satt där. Alla av er såg att stolen var upptagen.', + effect: { kaos: -65, dread: 55, hemlighet: -10 }, + weight: 3, + requires: { minTroende: 8, minDread: 70 }, + }, ]; diff --git a/src/ui.ts b/src/ui.ts index fb5c84c..b3fe6a9 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -527,6 +527,53 @@ function renderCharacterGrid(state: State | null): void { } } +// Both NPC arcs (Lindqvist listened/silenced, Hassan empowered/sidelined) +// previously only rerouted flavor text on the way to the same win/lose +// copy — the choices never actually reached the ending. Picking the most +// specific match first (both arcs resolved the same direction) means a +// player who leaned into both gets a variant that acknowledges both, +// rather than whichever flag happened to be checked first. +function pickVariant(flags: Set, variants: { when: string[]; text: string }[], fallback: string): string { + const scored = variants + .filter(v => v.when.every(f => flags.has(f))) + .sort((a, b) => b.when.length - a.when.length); + return scored[0]?.text ?? fallback; +} + +const WIN_BASE = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.'; + +const WIN_VARIANTS = [ + { + when: ['hassan_empowered', 'lindqvist_listened'], + text: 'Det är gjort. Hassan står i cirkelns mitt och talar orden i en ordning ni aldrig lärde henne. Lindqvist står vid vattnet, torr, och nickar i takt med henne utan att höra. Turning Torso pekar rakt ned. Ni undrar, för första gången, om ni fortfarande behövs.', + }, + { + when: ['hassan_empowered'], + text: 'Det är gjort, men det var Hassans röst som avslutade det, inte er. Cirkeln följde henne hela vägen. Turning Torso pekar rakt ned. Havet är stilla. Ni klappar henne på axeln. Hon ler och tittar förbi er, mot vattnet.', + }, + { + when: ['lindqvist_listened'], + text: 'Det är gjort. Lindqvist stod vid vattnet när det hände, precis som han sagt att han skulle. Han kom tillbaka torr och log på ett sätt ni inte känner igen längre. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla för att vara bara hav.', + }, + { + when: ['fors_neutralized'], + text: 'Det är gjort. Ingen kom för att stoppa er — ni såg till det i god tid. Turning Torso pekar rakt ned. Havet är stilla. Kommissarie Fors sover, äntligen, utan drömmar. Ni undrar om det är en tjänst ni gjorde henne, eller tog ifrån henne.', + }, +]; + +const LOST_BASE_FALLBACK = 'Utredarna kom.'; + +const LOST_VARIANTS = [ + { + when: ['hassan_empowered'], + text: 'Utredarna slog till vid gryningen. Hassan var den enda som inte sprang. Hon satt kvar i cirkeln och log mot dem tills de förde bort henne. Källaren är tom nu. Mattan ligger kvar. Under mattan finns fortfarande bordet.', + }, + { + when: ['lindqvist_listened'], + text: 'Utredarna slog till vid gryningen. Lindqvist var inte där — han hade gått ner till vattnet igen, som han gjort så många nätter. Ingen vet om han någonsin fick veta att det var över. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.', + }, +]; + function showOverlay(state: State): void { const ov = document.getElementById('overlay')!; ov.classList.add('visible'); @@ -536,7 +583,7 @@ function showOverlay(state: State): void { ov.style.backgroundImage = "url('/img/win.webp')"; title.textContent = 'FULLBORDAT'; title.className = 'overlay-title won'; - body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.'; + body.textContent = pickVariant(state.flags, WIN_VARIANTS, WIN_BASE); } else if (state.phase === 'abandoned') { ov.style.backgroundImage = "url('/img/fail.webp')"; title.textContent = 'ÖVERGIVET'; @@ -546,7 +593,7 @@ function showOverlay(state: State): void { ov.style.backgroundImage = "url('/img/fail.webp')"; title.textContent = 'AVSLÖJAD'; title.className = 'overlay-title lost'; - body.textContent = state.lostReason ?? 'Utredarna kom.'; + body.textContent = pickVariant(state.flags, LOST_VARIANTS, state.lostReason ?? LOST_BASE_FALLBACK); } } diff --git a/src/upgrades.ts b/src/upgrades.ts index 2e383b8..0e35941 100644 --- a/src/upgrades.ts +++ b/src/upgrades.ts @@ -119,6 +119,56 @@ export const UPGRADES: Upgrade[] = [ available: s => s.upgrades.has('grand_ritual_prep'), once: false, }, + { + id: 'favor_network', + name: 'Nätverket av tjänster', + desc: 'Kontakterna ni samlat på er är fler än ni insett. En tjänst ger en annan. Nätverket börjar arbeta för sin egen skull nu, inte bara för er räkning. +inflytande/s, och en ny, större sorts tjänst blir möjlig att kalla in.', + cost: () => ({ inflytande: 60 }), + effect: s => { s.inflytandeRate += 0.5; }, + available: s => s.troende >= 3 && !s.upgrades.has('favor_network'), + once: true, + }, + { + id: 'call_in_favor', + name: 'Kalla in en tjänst', + desc: 'Ring den som är skyldig er mest. Det är sällan gratis att bli påmind om vad man är skyldig — men det fungerar. Stor återhämtning av hemlighet, mot lite ökad kaos. Dyrare för varje gång ni ber om samma sak.', + cost: s => ({ inflytande: Math.round(50 * Math.pow(1.4, s.favorsCalled)) }), + effect: s => { + s.hemlighet = Math.min(100, s.hemlighet + 20); + s.kaos = Math.min(100, s.kaos + 6); + s.dread = Math.max(0, s.dread - 5); + s.favorsCalled += 1; + }, + available: s => s.upgrades.has('favor_network'), + once: false, + }, + { + id: 'neutralize_investigation', + name: 'Neutralisera utredningen', + desc: 'Kommissarie Fors har en chef. Chefen har en tjänst att göra gentjänst för. Utredningen läggs ner, officiellt av resursskäl. Fors fortsätter drömma om er ändå — hon skriver bara inte ner det längre.', + cost: () => ({ inflytande: 150, andakt: 300 }), + effect: s => { s.hemlighet = Math.min(100, s.hemlighet + 25); s.flags.add('fors_neutralized'); }, + available: s => s.upgrades.has('favor_network') && s.usedEventIds.has('coc_investigator_dream') && !s.upgrades.has('neutralize_investigation'), + once: true, + }, + { + id: 'bigger_venue', + name: 'Källaren är för liten', + desc: 'Ni flyttar till en större lokal, officiellt "för läsecirkeln." Fler rum, fler dörrar, fler saker att förklara för grannarna. Den varma stolen flyttar med. Den är fortfarande varm.', + cost: () => ({ andakt: 1200, dread: 150 }), + effect: s => { s.andaktRate += 2; s.kaosRate += 0.25; }, + available: s => s.troende >= 10 && s.upgrades.has('nine_chairs_full') && !s.upgrades.has('bigger_venue'), + once: true, + }, + { + id: 'mollan_favor', + name: 'Stamkund på Möllan', + desc: 'Handlaren ger er mer än rabatt nu. Hon ber inte om förklaringar, bara om att få vara med "när det händer." Ni har inte sagt ja. Ni har inte sagt nej heller.', + cost: () => ({ andakt: 250 }), + effect: s => { s.andaktRate += 0.4; }, + available: s => s.usedEventIds.has('mollan_regular') && !s.upgrades.has('mollan_favor'), + once: true, + }, { id: 'whisper_guidance', name: 'Viskningen leder er',