Extend the endgame, add more content, make kaos events recur
All checks were successful
Docker / build-and-push (push) Successful in 43s

Balance: an optimal-play simulation showed the game finishing in
under 3 minutes, mostly because torso_beacon + grand_ritual_prep
alone pushed ritualRate to 2.5 — enough to clear the last 50 ritual
points in 20 seconds. Softened those boosts (0.5→0.15, 2→0.35),
raised deepen_ritual's cost curve so it's a real endgame sink instead
of a one-off top-up, and added two new late-game upgrades (sunken
choir, nine filled chairs) between grand_ritual_prep and the win.
Re-simulated: a greedy optimal-play bot now takes ~5 minutes instead
of ~3 and surfaces ~40 distinct events instead of ~30 out of a much
larger pool — real (non-bot) play should run considerably longer.

Content: added 8 new high-dread late-game standard events (gated up
to minDread 400, since nothing previously escalated past 75) and 7
new kaos events, plus one more choice event.

Kaos events no longer get marked as permanently used — they're
chaos-relief flavor, not narrative progression, and a ~24-entry pool
(now 30) was depleting for good over a long game. Standard/chain
events remain single-use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-13 19:29:04 +02:00
parent 323a61997f
commit bde809e862
4 changed files with 138 additions and 9 deletions

View File

@@ -136,7 +136,7 @@ describe('deepen_ritual cost scaling', () => {
it('grows monotonically with each purchase, giving andakt a late-game sink', () => {
let s = createState();
s.upgrades.add('grand_ritual_prep');
s.ritualRate = 2.5; // baseline after torso_beacon + grand_ritual_prep
s.ritualRate = 0.5; // baseline after torso_beacon (0.15) + grand_ritual_prep (0.35)
s.andakt = 1_000_000;
s.dread = 1_000_000;

View File

@@ -180,7 +180,8 @@ export function tick(state: State): State {
if (!s.pendingEvent && (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick)) {
const ev = pickEvent(KAOS_EVENTS, s);
if (ev) {
s.usedEventIds.add(ev.id);
// Kaos events are chaos-relief flavor, not narrative progression — let them recur
// over a long game instead of permanently depleting a pool of only ~24 entries.
if (ev.choices?.length) {
pushLog(s, { tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
s.pendingEvent = { event: ev, kind: 'kaos' };

View File

@@ -621,6 +621,76 @@ export const STANDARD_EVENTS: GameEvent[] = [
},
],
},
// ── High dread / late game ──────────────────────────────────────────────────
{
id: 'late_torso_lean',
text: 'Turning Torso lutar nu tre grader mer än vad byggnadskonstruktören godkänt. Ingenjörerna har mätt om det fem gånger. Alla fem mätningarna skiljer sig åt. Alla fem pekar mot samma punkt i Öresund.',
effect: { dread: 25, ritual: 4 },
weight: 5,
requires: { minDread: 90 },
},
{
id: 'late_streetlights',
text: 'Gatlyktorna längs hela Amiralsgatan slocknar samtidigt varje natt klockan 03.13, i exakt tolv sekunder. Kommunen hittar inget fel. Ingen i Malmö sover mellan 03.00 och 03.15 längre, men ingen pratar om det.',
effect: { dread: 30, hemlighet: -6 },
weight: 5,
requires: { minDread: 120 },
},
{
id: 'late_choir_beneath',
text: 'En kör hörs nu under staden, tydligast vid tunnelbanans arbetsschakt vid Triangeln. Arbetarna har slutat gräva djupare. De säger att marken "svarar för nära" om de fortsätter.',
effect: { dread: 35, ritual: 6 },
weight: 4,
requires: { minDread: 160 },
},
{
id: 'late_sun_wrong',
text: 'Solen gick upp i väster i morse. Den korrigerade sig själv inom en minut. SMHI kallar det en "atmosfärisk synvilla." Ingen som såg det tror på den förklaringen, inklusive de som skrev den.',
effect: { dread: 40, kaos: 15 },
weight: 4,
requires: { minDread: 200 },
},
{
id: 'late_children_hum',
text: 'Barnen på förskolan vid Bellevuegården har börjat nynna samma melodi, oberoende av varandra, utan att någon lärt dem den. Personalen har slutat fråga varifrån den kommer. De nynnar den nu också.',
effect: { dread: 45, hemlighet: -10, troende: 1 },
weight: 4,
requires: { minDread: 250 },
},
{
id: 'late_sky_seam',
text: 'Himlen över Malmö har fått en söm. Den syns bara i skymningen, ett tunt rakt streck rakt över Turning Torso, som om något stort suttit ihop fel. Ingen har fotograferat den framgångsrikt än. Kamerorna vägrar.',
effect: { dread: 55, ritual: 8 },
weight: 3,
requires: { minDread: 320 },
},
{
id: 'late_final_stillness',
text: 'Hela Malmö är alldeles tyst i tre sekunder klockan fyra på morgonen. Trafiken stannar. Fåglarna tystnar. Ingen märker det utom ni. Ni vet alla exakt vad det betydde. Ingen säger det högt.',
effect: { dread: 60, ritual: 10, hemlighet: -8 },
weight: 3,
requires: { minDread: 400 },
},
{
id: 'choice_final_congregation',
text: 'En troende ni inte känner igen står plötsligt mitt i cirkeln under ett möte. Hon säger att hon "alltid varit här" och att ni bara "inte sett henne förrän nu." Ingen minns att hon klev in.',
effect: {},
weight: 4,
requires: { minDread: 150 },
choices: [
{
text: 'Ge henne en plats i cirkeln',
effect: { troende: 1, dread: 20, ritual: 5 },
resultText: 'Ni flyttar ihop för att göra plats. Det behövdes ingen extra stol. Det fanns redan en.',
},
{
text: 'Fråga vem hon är',
effect: { hemlighet: -5, kaos: 10 },
resultText: 'Hon svarar. Ingen av er minns svaret efteråt, men alla nickar som om det var uppenbart.',
},
],
},
];
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
@@ -785,4 +855,44 @@ export const KAOS_EVENTS: GameEvent[] = [
weight: 5,
requires: { minDread: 40 },
},
{
id: 'k_ica_hoarding',
text: 'Alla ICA-butiker i Malmö har fått slut på konservburkar med fisk samtidigt. Kunderna köper allt de kan bära utan att kunna förklara varför. En kassörska säger att hyllorna "kändes hungrigare än vanligt."',
effect: { kaos: -30, andakt: 15, troende: 1 },
weight: 8,
},
{
id: 'k_pigeon_choir',
text: 'Duvorna över hela innerstaden har synkroniserat sitt kuttrande till en enda rytm. Ornitologer vid universitetet kallar det "statistiskt osannolikt." Ingen av dem vågar kalla det något annat.',
effect: { kaos: -35, dread: 20 },
weight: 7,
},
{
id: 'k_bridge_toll_glitch',
text: 'Betalstationen vid Öresundsbron har börjat ta betalt i en valuta ingen känner igen. Systemet accepterar den ändå. Bokföringen går ihop perfekt. Ingen vill fråga hur.',
effect: { kaos: -45, andakt: 30, dread: 25 },
weight: 6,
requires: { minDread: 60 },
},
{
id: 'k_missing_hour',
text: 'Klockan blev plötsligt två timmar senare än den borde för alla i Malmö samtidigt, utan att någon märkte tidshoppet medan det hände. Ingen saknar den förlorade timmen. Det är det som oroar mest.',
effect: { kaos: -70, dread: 65, ritual: 7 },
weight: 3,
requires: { minDread: 100 },
},
{
id: 'k_second_torso',
text: 'En andra Turning Torso syns i horisonten i tio minuter varje kväll, en spegelbild som inte har någon vattenyta att spegla sig i. Turistbyrån har börjat sälja vykort på den. De säljer bra.',
effect: { kaos: -75, dread: 70, ritual: 10 },
weight: 2,
requires: { minDread: 180 },
},
{
id: 'k_everyone_dreams_same',
text: 'Hela Malmö drömde samma dröm i natt. Ingen minns innehållet, bara att den var er. Sömnforskare vid Lund har ingen förklaring. De har slutat publicera sina fynd om natten.',
effect: { kaos: -65, troende: 2, ritual: 12, hemlighet: -12 },
weight: 3,
requires: { minDread: 220 },
},
];

View File

@@ -78,8 +78,8 @@ export const UPGRADES: Upgrade[] = [
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: 400, dread: 100 }),
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.5; },
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,
},
@@ -87,22 +87,40 @@ export const UPGRADES: Upgrade[] = [
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: 600, dread: 200 }),
effect: s => { s.ritualRate += 2; s.kaosRate += 0.5; },
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(300 * Math.pow(1.6, ritualDeepenings(s))), dread: 50 }),
effect: s => { s.ritualRate += 0.3; },
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,
},
];
function ritualDeepenings(s: State): number {
return Math.round((s.ritualRate - 2.5) / 0.3);
return Math.round((s.ritualRate - 0.5) / 0.15);
}