Add lore depth, fix recruit click bug, add localStorage save

- 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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-07 23:25:44 +02:00
parent d091e7382a
commit f5776f269b
5 changed files with 245 additions and 13 deletions

View File

@@ -30,6 +30,10 @@ export interface LogEntry {
kind: 'event' | 'kaos' | 'system' | 'upgrade'; 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 EVENT_INTERVAL_TICKS = 180; // ~6s at 30fps
const KAOS_THRESHOLD = 100; const KAOS_THRESHOLD = 100;
const HEMLIGHET_DRAIN_PER_TROENDE = 0.002; // per second, per troende above 4 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; 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<string>(d.upgrades ?? []),
usedEventIds: new Set<string>(d.usedEventIds ?? []),
} as State;
} catch (_) {
return null;
}
}
export function clearSave(): void {
localStorage.removeItem(SAVE_KEY);
}
export function buyUpgrade(state: State, id: string): State { export function buyUpgrade(state: State, id: string): State {
const upgrade = UPGRADES.find(u => u.id === id); const upgrade = UPGRADES.find(u => u.id === id);
if (!upgrade) return state; if (!upgrade) return state;

View File

@@ -153,6 +153,135 @@ export const STANDARD_EVENTS: GameEvent[] = [
weight: 3, weight: 3,
requires: { minRitual: 60 }, 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) ───────────────────────────────────────────── // ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
@@ -235,4 +364,45 @@ export const KAOS_EVENTS: GameEvent[] = [
weight: 3, weight: 3,
requires: { minDread: 60, unlockedArea: 'varnhem' }, 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 },
},
]; ];

View File

@@ -1,19 +1,22 @@
import './style.css'; 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 { initUI, renderState } from './ui.ts';
import { initMap, updateMap } from './map.ts'; import { initMap, updateMap } from './map.ts';
let state = createState(); let state = loadState() ?? createState();
let lastTime = 0; let lastTime = 0;
const TARGET_FPS = 30; const TARGET_FPS = 30;
const FRAME_MS = 1000 / TARGET_FPS; const FRAME_MS = 1000 / TARGET_FPS;
const SAVE_INTERVAL_MS = 10_000;
let lastSaveTime = 0;
function start(): void { function start(): void {
const app = document.getElementById('app')!; const app = document.getElementById('app')!;
initUI(app, initUI(app,
(id) => { state = buyUpgrade(state, id); renderState(state); }, (id) => { state = buyUpgrade(state, id); renderState(state); saveState(state); },
() => { () => {
clearSave();
state = createState(); state = createState();
document.getElementById('overlay')!.classList.remove('visible'); document.getElementById('overlay')!.classList.remove('visible');
renderState(state); renderState(state);
@@ -34,6 +37,10 @@ function loop(now: number): void {
renderState(state); renderState(state);
updateMap(state.dread, state.areas); updateMap(state.dread, state.areas);
} }
if (now - lastSaveTime >= SAVE_INTERVAL_MS) {
lastSaveTime = now;
saveState(state);
}
} }
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }

View File

@@ -79,11 +79,11 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
// Split the map container: map on top, log below // Split the map container: map on top, log below
const mapContainer = document.getElementById('map-container')!; 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')!; 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')!; 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 // Upgrade click delegation
document.getElementById('upgrades')!.addEventListener('click', e => { document.getElementById('upgrades')!.addEventListener('click', e => {
@@ -145,10 +145,21 @@ export function renderState(state: State): void {
if (state.phase !== 'playing') showOverlay(state); if (state.phase !== 'playing') showOverlay(state);
} }
let lastUpgradeKey = '';
function renderUpgrades(state: State): void { function renderUpgrades(state: State): void {
const container = document.getElementById('upgrades')!; const container = document.getElementById('upgrades')!;
const available = UPGRADES.filter(u => u.available(state) && (u.once ? !state.upgrades.has(u.id) : true)); 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) { if (!available.length) {
container.innerHTML = '<div style="font-size:11px;color:var(--dim);">Inga tillgängliga åtgärder.</div>'; container.innerHTML = '<div style="font-size:11px;color:var(--dim);">Inga tillgängliga åtgärder.</div>';
return; return;

View File

@@ -16,14 +16,14 @@ export const UPGRADES: Upgrade[] = [
name: 'Rekrytera en troende', name: 'Rekrytera en troende',
desc: '+1 Troende. De undrade varför källarlamporna flimrade. Nu vet de.', desc: '+1 Troende. De undrade varför källarlamporna flimrade. Nu vet de.',
cost: { andakt: 20 }, 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, available: s => s.troende < 12,
once: false, once: false,
}, },
{ {
id: 'first_meeting', id: 'first_meeting',
name: 'Källarmöte i Rosengård', 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 }, cost: { andakt: 30 },
effect: s => { s.andaktRate += 1; s.areas.rosengard = true; }, effect: s => { s.andaktRate += 1; s.areas.rosengard = true; },
available: s => s.troende >= 2 && !s.upgrades.has('first_meeting'), available: s => s.troende >= 2 && !s.upgrades.has('first_meeting'),
@@ -32,16 +32,25 @@ export const UPGRADES: Upgrade[] = [
{ {
id: 'the_manuscript', id: 'the_manuscript',
name: 'Översätt Skriften', 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 }, cost: { andakt: 80 },
effect: s => { s.dreadRate += 0.5; s.ritualUnlocked = true; }, effect: s => { s.dreadRate += 0.5; s.ritualUnlocked = true; },
available: s => s.troende >= 3 && s.upgrades.has('first_meeting') && !s.upgrades.has('the_manuscript'), available: s => s.troende >= 3 && s.upgrades.has('first_meeting') && !s.upgrades.has('the_manuscript'),
once: true, 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', id: 'idol_under_bridge',
name: 'Idolen under bron', 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 }, cost: { andakt: 120, dread: 20 },
effect: s => { s.dreadRate += 1; s.andaktRate += 0.5; }, effect: s => { s.dreadRate += 1; s.andaktRate += 0.5; },
available: s => s.upgrades.has('the_manuscript') && !s.upgrades.has('idol_under_bridge'), available: s => s.upgrades.has('the_manuscript') && !s.upgrades.has('idol_under_bridge'),
@@ -50,7 +59,7 @@ export const UPGRADES: Upgrade[] = [
{ {
id: 'varnhem_circle', id: 'varnhem_circle',
name: 'Cirkeln på Värnhemstorget', 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 }, cost: { andakt: 200, dread: 40 },
effect: s => { s.areas.varnhem = true; s.dreadRate += 1.5; s.kaosRate += 0.2; }, 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'), 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', id: 'torso_beacon',
name: 'Altaret vid Turning Torso', 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 }, cost: { andakt: 400, dread: 100 },
effect: s => { s.areas.torso = true; s.dreadRate += 3; s.ritualRate += 0.5; }, 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'), 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', id: 'grand_ritual_prep',
name: 'Förberedelser för Den Stora Ritualen', 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 }, cost: { andakt: 600, dread: 200 },
effect: s => { s.ritualRate += 2; s.kaosRate += 0.5; }, 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'), available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),