Docker / build-and-push (push) Successful in 1m23s
Addresses the gaps flagged after surveying the existing game against the new LORE-BIBLE.md: - inflytande had exactly one spending sink in the whole game. Added favor_network (unlocks a bigger repeatable "call in a favor" upgrade) and neutralize_investigation, which pays off the previously-dead-end Kommissarie Fors thread from coc_investigator_dream -- she stops investigating, but why she was dreaming about the cult in the first place stays exactly as unexplained as the lore bible says it should. - Lindqvist's and Hassan's event-chain flags (listened/silenced, empowered/sidelined) previously only rerouted flavor text on the way to identical win/lose copy. showOverlay() now picks a variant based on which flags are set, most-specific match first, so a run where both arcs resolved gets its own combined ending instead of falling through to whichever flag happened to be checked first. - troende past nine was flavor-only even though the hard cap was removed in 0.3.0. Added troende_overflow (event) and bigger_venue (upgrade, gated behind nine_chairs_full + troende>=10) so outgrowing the basement has a real mechanical consequence, not just a stat that keeps climbing quietly. - Möllan and Universitet (added in 0.6.0) had zero upgrade or event ties compared to Rosengård/Värnhem/Torso. Added mollan_regular + mollan_favor and universitet_seminar as a first pass -- lighter-touch than full area-gating (which would mean reshaping State.areas across engine/map/upgrades), but real content where there was none. - Kaos events skewed entirely comic relief against standard events' near-total dread. Added four kaos entries in the heavier register (thirteen chimes, the mirror room, permanent lights on the Öresund seabed, the warm chair finally occupied) as a tonal counterweight, lower-weighted so they don't crowd out the existing jokes. Added engine tests for all four new upgrades' gating and cost scaling. 21/21 tests pass, typecheck clean, build clean. Verified all six ending variants (win base/hassan/lindqvist/combo/fors, lose base/hassan/ lindqvist) render correctly against a real build via injected save state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
9.3 KiB
TypeScript
186 lines
9.3 KiB
TypeScript
import type { State } from './engine.ts';
|
|
|
|
export interface Upgrade {
|
|
id: string;
|
|
name: string;
|
|
desc: string;
|
|
cost: (s: State) => Partial<{ andakt: number; dread: number; inflytande: number }>;
|
|
effect: (s: State) => void;
|
|
available: (s: State) => boolean;
|
|
once: boolean;
|
|
}
|
|
|
|
export const UPGRADES: Upgrade[] = [
|
|
{
|
|
id: 'recruit_neighbor',
|
|
name: 'Rekrytera en troende',
|
|
desc: '+1 Troende. De undrade varför källarlamporna flimrade. Nu vet de. Fler munnar att mätta — priset stiger för varje ny medlem.',
|
|
cost: s => ({ andakt: Math.round(20 * Math.pow(1.35, s.troende)) }),
|
|
effect: s => { s.troende += 1; },
|
|
available: () => true,
|
|
once: false,
|
|
},
|
|
{
|
|
id: 'exert_influence',
|
|
name: 'Utöva inflytande',
|
|
desc: 'Ring några kontakter. En tjänsteman blir plötsligt mycket samarbetsvillig. En journalist glömmer varför hon ringde. +hemlighet, men ryktet sprids ändå lite. +kaos',
|
|
cost: () => ({ inflytande: 30 }),
|
|
effect: s => { s.hemlighet = Math.min(100, s.hemlighet + 8); s.kaos = Math.min(100, s.kaos + 3); },
|
|
available: s => s.troende >= 2,
|
|
once: false,
|
|
},
|
|
{
|
|
id: 'first_meeting',
|
|
name: 'Källarmöte i Rosengård',
|
|
desc: 'Broder Lindqvist vet en källarlokal. Den ska egentligen vara ett förråd. Hyresvärden vet inte om den. Det är okänt varför han inte vet om den. +1 andakt/s.',
|
|
cost: () => ({ andakt: 30 }),
|
|
effect: s => { s.andaktRate += 1; s.areas.rosengard = true; },
|
|
available: s => s.troende >= 2 && !s.upgrades.has('first_meeting'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'the_manuscript',
|
|
name: 'Översätt Skriften',
|
|
desc: 'Dokumentet hittades inmurad i en vägg vid Davidshallsgatan, bakom kakel från 1923. Texten rör sig om man tittar bort. Lingvisten vi anlitade satt tyst i tre timmar och gick sedan hem. Han skickade översättningen nästa morgon utan kommentar. Vi läser den inte högt.',
|
|
cost: () => ({ andakt: 80 }),
|
|
effect: s => { s.dreadRate += 0.5; s.ritualUnlocked = true; },
|
|
available: s => s.troende >= 3 && s.upgrades.has('first_meeting') && !s.upgrades.has('the_manuscript'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'blood_pact',
|
|
name: 'Paktens Förnyelse',
|
|
desc: 'Det är en gammal pakt. Någon tecknade den för länge sedan. Vi vet inte vem. Vi förnyar den ändå — det verkar artigare. Skriften vet om det innan vi är klara.',
|
|
cost: () => ({ andakt: 150, dread: 30 }),
|
|
effect: s => { s.andaktRate += 1.5; s.hemlighet = Math.max(0, s.hemlighet - 10); },
|
|
available: s => s.upgrades.has('the_manuscript') && !s.upgrades.has('blood_pact'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'idol_under_bridge',
|
|
name: 'Idolen under bron',
|
|
desc: 'Vi fann den redan nergrävd under Øresundsbron. Vi behövde bara gräva ner den djupare. Materialet är okänt. Det är varmt mot handen. Danskarna la märke till ingenting. Det är värre.',
|
|
cost: () => ({ andakt: 120, dread: 20 }),
|
|
effect: s => { s.dreadRate += 1; s.andaktRate += 0.5; },
|
|
available: s => s.upgrades.has('the_manuscript') && !s.upgrades.has('idol_under_bridge'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'varnhem_circle',
|
|
name: 'Cirkeln på Värnhemstorget',
|
|
desc: 'Rita cirkeln. Använd krita — det är viktigt att det är krita, vi vet inte varför men vi känner det. Kommunen tvättar bort den varje morgon. Den är tillbaka varje kväll. Nu är den tillbaka lite större.',
|
|
cost: () => ({ andakt: 200, dread: 40 }),
|
|
effect: s => { s.areas.varnhem = true; s.dreadRate += 1.5; s.kaosRate += 0.2; },
|
|
available: s => s.upgrades.has('idol_under_bridge') && s.dread >= 50 && !s.upgrades.has('varnhem_circle'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'torso_beacon',
|
|
name: 'Altaret vid Turning Torso',
|
|
desc: 'Calatrava-Santiago ritade byggnaden som en vridande ryggrad. Det är korrekt. Det är inte en metafor. Byggnaden har alltid lyssnat. Nu ber vi den svara. Den gör det. Svaret är ett ljud under hörselgränsen som vi känner i tänderna.',
|
|
cost: () => ({ andakt: 500, dread: 100 }),
|
|
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.15; },
|
|
available: s => s.upgrades.has('varnhem_circle') && s.dread >= 100 && !s.upgrades.has('torso_beacon'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'grand_ritual_prep',
|
|
name: 'Förberedelser för Den Stora Ritualen',
|
|
desc: 'Allt har lett hit. Turning Torso pekar. Värnhemstorget svarar. Öresund drar sig tillbaka som om det vet vad som kommer. Skriften är nu fullständig. Den sista sidan skrevs av sig själv i natt. Det är ett recept.',
|
|
cost: () => ({ andakt: 900, dread: 250 }),
|
|
effect: s => { s.ritualRate += 0.35; s.kaosRate += 0.5; },
|
|
available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'sunken_choir',
|
|
name: 'Den sjunkna kören',
|
|
desc: 'Rösterna under Öresund har börjat svara i takt med era. Ni behöver bara lyssna tillbaka. Det underlättar. Det är också svårt att sluta göra.',
|
|
cost: () => ({ andakt: 1400, dread: 300 }),
|
|
effect: s => { s.ritualRate += 0.25; s.andaktRate += 1; },
|
|
available: s => s.upgrades.has('grand_ritual_prep') && s.dread >= 300 && !s.upgrades.has('sunken_choir'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'nine_chairs_full',
|
|
name: 'Alla nio stolarna fyllda',
|
|
desc: 'Källarens nio stolar har äntligen alla sina rätta innehavare. Den varma stolen är också upptagen nu. Ingen pratar om vem som sitter i den.',
|
|
cost: () => ({ andakt: 2200, dread: 350 }),
|
|
effect: s => { s.ritualRate += 0.25; s.kaosRate += 0.3; },
|
|
available: s => s.upgrades.has('sunken_choir') && s.troende >= 9 && !s.upgrades.has('nine_chairs_full'),
|
|
once: true,
|
|
},
|
|
{
|
|
id: 'deepen_ritual',
|
|
name: 'Fördjupa ritualen',
|
|
desc: 'Ännu ett lager av mening under det förra. Andakt är inget värt om det bara samlas i en burk. Varje fördjupning kostar mer än den förra — Den Sovande blir kräsnare ju närmre ni kommer.',
|
|
cost: s => ({ andakt: Math.round(350 * Math.pow(1.55, ritualDeepenings(s))), dread: 60 }),
|
|
effect: s => { s.ritualRate += 0.15; },
|
|
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',
|
|
desc: 'Ni behöver inte längre besluta vart ni går. Fötterna vet vägen innan huvudet gör det. Bekvämt. Det är också första gången ingen av er är helt säker på vem som bestämmer längre.',
|
|
cost: () => ({ andakt: 600, dread: 80 }),
|
|
effect: () => { /* handled by main.ts's tick loop — see WHISPER_INTERVAL_TICKS */ },
|
|
available: s => s.upgrades.has('torso_beacon') && !s.upgrades.has('whisper_guidance'),
|
|
once: true,
|
|
},
|
|
];
|
|
|
|
function ritualDeepenings(s: State): number {
|
|
return Math.round((s.ritualRate - 0.5) / 0.15);
|
|
}
|