From f5776f269baf69655e7ebf1839789f107bea519f Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Tue, 7 Jul 2026 23:25:44 +0200 Subject: [PATCH] Add lore depth, fix recruit click bug, add localStorage save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 20+ new events drawing from Anders Fager, Kult, and Call of Cthulhu: ICA cashiers who never blink, SJ trains stopping in non-existent tunnels, Kult lictors as city officials, death servants finishing lectures, Deep Ones, investigators dreaming about us, stars being right over Lund Observatory - Rewrite upgrade descriptions to match the register: mundane detail, matter-of-fact horror, no melodrama - Add a new mid-game upgrade (Paktens Förnyelse) to smooth the progression - Fix recruit button: was destroying DOM every 30fps tick, killing mousedown→click events; now only rebuilds when availability/affordability actually changes - Fix double andakt deduction in recruit effect - Fix map growing over log (proper flex constraints) - Add localStorage save/load with versioned key (SAVE_VERSION bump invalidates old saves); autosaves every 10s and on upgrade purchase, clears on restart Co-Authored-By: Claude Sonnet 4.6 --- src/engine.ts | 35 ++++++++++ src/events.ts | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 13 +++- src/ui.ts | 17 ++++- src/upgrades.ts | 23 +++++-- 5 files changed, 245 insertions(+), 13 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 97f8598..6223eff 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -30,6 +30,10 @@ export interface LogEntry { kind: 'event' | 'kaos' | 'system' | 'upgrade'; } +// Bump this when State shape changes to invalidate old saves +const SAVE_VERSION = '001'; +const SAVE_KEY = `djupet_v${SAVE_VERSION}`; + const EVENT_INTERVAL_TICKS = 180; // ~6s at 30fps const KAOS_THRESHOLD = 100; const HEMLIGHET_DRAIN_PER_TROENDE = 0.002; // per second, per troende above 4 @@ -148,6 +152,37 @@ export function tick(state: State): State { return s; } +export function saveState(state: State): void { + try { + const serial = { + ...state, + upgrades: [...state.upgrades], + usedEventIds: [...state.usedEventIds], + log: state.log.slice(0, 20), + }; + localStorage.setItem(SAVE_KEY, JSON.stringify(serial)); + } catch (_) { /* storage full or unavailable */ } +} + +export function loadState(): State | null { + try { + const raw = localStorage.getItem(SAVE_KEY); + if (!raw) return null; + const d = JSON.parse(raw); + return { + ...d, + upgrades: new Set(d.upgrades ?? []), + usedEventIds: new Set(d.usedEventIds ?? []), + } as State; + } catch (_) { + return null; + } +} + +export function clearSave(): void { + localStorage.removeItem(SAVE_KEY); +} + export function buyUpgrade(state: State, id: string): State { const upgrade = UPGRADES.find(u => u.id === id); if (!upgrade) return state; diff --git a/src/events.ts b/src/events.ts index cde716a..84a7272 100644 --- a/src/events.ts +++ b/src/events.ts @@ -153,6 +153,135 @@ export const STANDARD_EVENTS: GameEvent[] = [ weight: 3, requires: { minRitual: 60 }, }, + + // ── Anders Fager-register: vardagssvenska möter det ofattbara ── + { + id: 'ica_maxi', + text: 'Kassörskan på ICA Maxi vid Fosie har inte blinkmat på tre veckor. Kunden bakom i kön räknade. Ingen anmälde det. Det är Malmö.', + effect: { dread: 7, kaos: 4 }, + weight: 9, + }, + { + id: 'sj_train', + text: 'SJ-tåget mot Köpenhamn stannade i tolv minuter i en tunnel som inte finns på kartan. Passagerarna anlände i tid. Ingen av dem kan minnas att de steg på tåget.', + effect: { dread: 14, kaos: 8 }, + weight: 7, + requires: { minDread: 15 }, + }, + { + id: 'pressbyraan', + text: 'Pressbyrån på centralstationen säljer sedan fredag en tidning utan namn. Texten byter innehåll beroende på vem som läser. Bengt-Åke från Husie köper ett ex om dagen och verkar nöjd.', + effect: { dread: 10, kaos: 6, andakt: 5 }, + weight: 8, + }, + { + id: 'child_sees', + text: 'Syster Halléns dotter, 7 år, har börjat rita samma bild varje dag. En stad under vatten. Byggnaderna är rätt. Malmö kan inte ha legat under vatten. Inte på den här sidan av verkligheten.', + effect: { dread: 18, ritual: 2 }, + weight: 7, + requires: { minDread: 10 }, + }, + { + id: 'rolf_disappears', + text: 'Rolf från Limhamn var med oss i tre veckor. Han slutade komma. Vi hittade hans skor utanför lokalen ordentligt placerade med tårna mot havet. Rökarens tobak låg bredvid. Rolf rökte inte.', + effect: { hemlighet: -8, dread: 12, kaos: 5 }, + weight: 7, + }, + { + id: 'mirror_problem', + text: 'Spegeln i förrummet återspeglar lokalen som den såg ut för ett okänt antal år sedan. Möblerna är annorlunda. Det sitter folk i dem. De ser glada ut.', + effect: { dread: 16, kaos: 9 }, + weight: 6, + requires: { minDread: 20 }, + }, + { + id: 'kult_lictor', + text: 'En tjänsteman från stadsbyggnadskontoret kom för att kontrollera ventilationen. Han hittade inga brister. Han stannade i fyra timmar. Han antecknade ingenting men han tittade på allt. Dörren han gick ut genom finns inte längre.', + effect: { hemlighet: -20, dread: 15 }, + weight: 5, + requires: { minDread: 30 }, + }, + { + id: 'anneli_dream', + text: 'Systern Anneli-Karin har börjat tala i sömnen på ett språk vi inte känner igen. Vi spelade in det och spelade upp det baklänges. Det var också obegripligt. Fast annorlunda obegripligt.', + effect: { dread: 12, ritual: 3, andakt: 8 }, + weight: 7, + requires: { minDread: 25 }, + }, + { + id: 'hospital_report', + text: 'UMAS rapporterar en ökning av patienter med "orientationssvårigheter." De vet vem de är. De vet var de är. De vet bara inte när de är. Siffran stämmer inte med någon känd tidpunkt.', + effect: { dread: 20, kaos: 12 }, + weight: 5, + requires: { minDread: 40 }, + }, + + // ── Kult-register: verkligheten är ett fängelse och murarna spricker ── + { + id: 'kult_space_between', + text: 'Det finns ett rum i källaren som inte är med i ritningarna. Vi har mätt tre gånger. Arkitektkontoret mätte en gång. De mäter inte mer.', + effect: { dread: 22, kaos: 10 }, + weight: 6, + requires: { minDread: 35 }, + }, + { + id: 'kult_flesh', + text: 'Väggarna i Värnhemslokalens källare har börjat kännas varma. Vi kontrollerade rören. Det är inte rören. Vi kontrollerade inte mer.', + effect: { dread: 28, ritual: 4 }, + weight: 4, + requires: { unlockedArea: 'varnhem', minDread: 50 }, + }, + { + id: 'kult_death_servant', + text: 'Broder Mattsson dog i hjärtattack under onsdagsmötet. Han reste sig upp igen och färdigförklarade sin tolkning av det tredje kapitlet. Vi applåderade. Det var den bästa föreläsningen hittills.', + effect: { dread: 35, ritual: 6, kaos: 18 }, + weight: 3, + requires: { minDread: 60 }, + }, + { + id: 'kult_awakening', + text: 'Syster Thordis minns plötsligt något. Inte från ett tidigare liv — det är fel metafor. Snarare minns hon något som aldrig upphört. Hon gråter. Hon är glad. Skillnaden är svår att se.', + effect: { dread: 25, ritual: 8, andakt: 15 }, + weight: 4, + requires: { minRitual: 20 }, + }, + + // ── CoC-register: utredare, förlorat förstånd, Mythos med Malmö-accent ── + { + id: 'coc_deep_ones', + text: 'Fiskarna vid Ribersborg uppför sig konstigt. De simmar i cirklar. Cirkeln är exakt 13 meter i diameter. Varje dag krymper den med en halv meter. Vi vet vad det betyder. Vi skriver inte ner det här.', + effect: { dread: 18, ritual: 2, kaos: 7 }, + weight: 6, + requires: { minDread: 20 }, + }, + { + id: 'coc_investigator_dream', + text: 'Kommissarie Annika Fors från grova brott drömmer om oss. Vi vet det för att hon har börjat dröm-närvara på mötena. Vi har satt ut en stol åt henne. Hon sitter i den utan att veta om det.', + effect: { hemlighet: -10, kaos: 15, dread: 12 }, + weight: 5, + requires: { minDread: 35 }, + }, + { + id: 'coc_old_book', + text: 'En antikvariat vid Lilla Torg fick in ett parti böcker från ett dödsbo i Hyllie. En av dem börjar om du öppnar den. Antikvariaten har läst sista sidan. Hon behöver inte läsa fler böcker.', + effect: { dread: 20, ritual: 5, kaos: 8 }, + weight: 5, + requires: { minDread: 25 }, + }, + { + id: 'coc_sanity', + text: 'Broder Erik fick ett panikattackliknande tillstånd efter gårdagens session. Inte för att det var läskigt. För att det inte var det längre. "Jag tänkte det borde vara det," sa han. "Och det var det inte."', + effect: { dread: 15, kaos: 10, andakt: 10 }, + weight: 7, + requires: { minDread: 30 }, + }, + { + id: 'coc_stars_right', + text: 'Astronomerna vid Lund Observatory har lagt ut en pressrelease om "ovanlig stjärnkonfiguration." De drog tillbaka den inom en timme. Konfigurationen kvarstår. Stjärnorna vet.', + effect: { dread: 30, ritual: 7, kaos: 12 }, + weight: 4, + requires: { minRitual: 30, minDread: 50 }, + }, ]; // ── Kaos events (Henderson-tier) ───────────────────────────────────────────── @@ -235,4 +364,45 @@ export const KAOS_EVENTS: GameEvent[] = [ weight: 3, requires: { minDread: 60, unlockedArea: 'varnhem' }, }, + { + id: 'k_bengt_ake', + text: 'Bengt-Åke (tidningsmannen från Husie) visade sig vara med i en helt annan sekt sedan 1987. Hans sekt visade sig vara med i en ännu äldre sekt sedan 1840-talet. Vi håller ett samordningsmöte. Det är produktivt.', + effect: { kaos: -45, andakt: 35, troende: 2 }, + weight: 7, + }, + { + id: 'k_skinwalker_bus', + text: 'Något som ser ut som en bussförare har kört linje 2 sedan tisdags morse. Tidtabellen är perfekt. Kunderna är nöjda. Skånetrafiken vet inte vem det är. Det spelar ingen roll, säger de. Det kör ju.', + effect: { kaos: -50, dread: 45 }, + weight: 6, + requires: { minDread: 40 }, + }, + { + id: 'k_fager_library', + text: 'Stadsbiblioteket på Kung Oscars väg har ett rum i källaren som bara öppnas under kaos. Vi var sist dit. Samlingen är imponerande. Returndatumet är omöjligt.', + effect: { kaos: -55, ritual: 12, dread: 20 }, + weight: 5, + requires: { minDread: 30 }, + }, + { + id: 'k_reporter_vanishes', + text: 'Journalisten från Sydsvenskan som skrev det prisbelönta reportaget har rest till Malmö för en uppföljningsartikel. Han hittade mer än han letade efter. Artikeln publiceras aldrig. Vi sparar hans anteckningar.', + effect: { kaos: -40, hemlighet: 20, ritual: 5 }, + weight: 6, + requires: { minDread: 45 }, + }, + { + id: 'k_kult_archon', + text: 'Något som påminner om en bank-direktör besökte ritualen utan att vi bjöd in det. Det tittade på oss med ett intresse vi inte kan beskriva. Det lämnade ett visitkort. Det finns inget namn på det. Bara en adress. Vi har inte åkt dit än.', + effect: { kaos: -60, dread: 60, hemlighet: -25 }, + weight: 4, + requires: { minDread: 55 }, + }, + { + id: 'k_oresund_rises', + text: 'Vattnet i Öresund sjunker alltjämt. På botten syns nu en stad. Det är inte en gammal stad. Byggnaderna är moderna. En av dem har belysning.', + effect: { kaos: -80, dread: 90, ritual: 15 }, + weight: 2, + requires: { minDread: 75, minRitual: 50 }, + }, ]; diff --git a/src/main.ts b/src/main.ts index defe67b..4dc0aed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,22 @@ import './style.css'; -import { createState, tick, buyUpgrade } from './engine.ts'; +import { createState, tick, buyUpgrade, saveState, loadState, clearSave } from './engine.ts'; import { initUI, renderState } from './ui.ts'; import { initMap, updateMap } from './map.ts'; -let state = createState(); +let state = loadState() ?? createState(); let lastTime = 0; const TARGET_FPS = 30; const FRAME_MS = 1000 / TARGET_FPS; +const SAVE_INTERVAL_MS = 10_000; +let lastSaveTime = 0; function start(): void { const app = document.getElementById('app')!; initUI(app, - (id) => { state = buyUpgrade(state, id); renderState(state); }, + (id) => { state = buyUpgrade(state, id); renderState(state); saveState(state); }, () => { + clearSave(); state = createState(); document.getElementById('overlay')!.classList.remove('visible'); renderState(state); @@ -34,6 +37,10 @@ function loop(now: number): void { renderState(state); updateMap(state.dread, state.areas); } + if (now - lastSaveTime >= SAVE_INTERVAL_MS) { + lastSaveTime = now; + saveState(state); + } } requestAnimationFrame(loop); } diff --git a/src/ui.ts b/src/ui.ts index b7c7efb..28e0229 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -79,11 +79,11 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest // Split the map container: map on top, log below const mapContainer = document.getElementById('map-container')!; - mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;'; + mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;'; const mapEl = document.getElementById('map')!; - mapEl.style.cssText = 'flex:0 0 45%;min-height:0;'; + mapEl.style.cssText = 'flex:0 0 45%;min-height:0;max-height:45%;'; const logEl = document.getElementById('log')!; - logEl.style.cssText = 'flex:1;overflow-y:auto;border-top:1px solid var(--border);'; + logEl.style.cssText = 'flex:1 1 0;overflow-y:auto;border-top:1px solid var(--border);min-height:0;'; // Upgrade click delegation document.getElementById('upgrades')!.addEventListener('click', e => { @@ -145,10 +145,21 @@ export function renderState(state: State): void { if (state.phase !== 'playing') showOverlay(state); } +let lastUpgradeKey = ''; + function renderUpgrades(state: State): void { const container = document.getElementById('upgrades')!; const available = UPGRADES.filter(u => u.available(state) && (u.once ? !state.upgrades.has(u.id) : true)); + // Only rebuild DOM when availability or affordability changes + const key = available.map(u => { + const canAfford = (!u.cost.andakt || state.andakt >= u.cost.andakt) && (!u.cost.dread || state.dread >= u.cost.dread); + return `${u.id}:${canAfford}`; + }).join('|'); + + if (key === lastUpgradeKey) return; + lastUpgradeKey = key; + if (!available.length) { container.innerHTML = '
Inga tillgängliga åtgärder.
'; return; diff --git a/src/upgrades.ts b/src/upgrades.ts index b180beb..24b84ff 100644 --- a/src/upgrades.ts +++ b/src/upgrades.ts @@ -16,14 +16,14 @@ export const UPGRADES: Upgrade[] = [ name: 'Rekrytera en troende', desc: '+1 Troende. De undrade varför källarlamporna flimrade. Nu vet de.', cost: { andakt: 20 }, - effect: s => { s.troende += 1; s.andakt = Math.max(0, s.andakt - 20 * Math.max(1, s.troende - 2)); }, + effect: s => { s.troende += 1; }, available: s => s.troende < 12, once: false, }, { id: 'first_meeting', name: 'Källarmöte i Rosengård', - desc: 'Formalisera mötena. +1 andakt/s. Det luktar lite konstigt men ingen säger något.', + 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'), @@ -32,16 +32,25 @@ export const UPGRADES: Upgrade[] = [ { id: 'the_manuscript', name: 'Översätt Skriften', - desc: 'Någon hittade ett dokument i en källarlokal vid Davidshallsgatan. Texten översätts bäst som "snälla sluta." Vi fortsätter.', + 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 placerade en idol under Øresundsbron vid nattens mörkaste timme. Danskarna la märke till det. De säger ingenting.', + 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'), @@ -50,7 +59,7 @@ export const UPGRADES: Upgrade[] = [ { id: 'varnhem_circle', name: 'Cirkeln på Värnhemstorget', - desc: 'Rita cirkeln. Använd krita. Det är viktigt att det är krita. Kommunen tvättar bort den varje morgon. Den är tillbaka varje kväll.', + 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'), @@ -59,7 +68,7 @@ export const UPGRADES: Upgrade[] = [ { id: 'torso_beacon', name: 'Altaret vid Turning Torso', - desc: 'Byggnaden har alltid lyssnat. Nu ber vi den svara. Den gör det.', + 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; }, available: s => s.upgrades.has('varnhem_circle') && s.dread >= 100 && !s.upgrades.has('torso_beacon'), @@ -68,7 +77,7 @@ 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 väntar. Öresund drar sig tillbaka.', + 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; }, available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),