2026-07-07 23:05:22 +02:00
|
|
|
import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts';
|
2026-07-12 23:17:48 +02:00
|
|
|
|
|
|
|
|
export interface PendingEvent {
|
|
|
|
|
event: GameEvent;
|
|
|
|
|
kind: 'event' | 'kaos';
|
|
|
|
|
}
|
2026-07-07 23:05:22 +02:00
|
|
|
import { UPGRADES } from './upgrades.ts';
|
|
|
|
|
|
|
|
|
|
export interface State {
|
|
|
|
|
tick: number;
|
|
|
|
|
troende: number;
|
|
|
|
|
andakt: number;
|
|
|
|
|
andaktRate: number; // per second
|
2026-07-08 17:28:32 +02:00
|
|
|
inflytande: number;
|
|
|
|
|
inflytandeRate: number; // multiplier on the base network-effect curve
|
2026-07-07 23:05:22 +02:00
|
|
|
dread: number;
|
|
|
|
|
dreadRate: number; // per second
|
|
|
|
|
hemlighet: number; // 0-100, lose at 0
|
|
|
|
|
kaos: number; // 0-100, triggers events at 100
|
|
|
|
|
kaosRate: number; // per second
|
|
|
|
|
ritual: number; // 0-100, win at 100
|
|
|
|
|
ritualRate: number; // per second
|
|
|
|
|
ritualUnlocked: boolean;
|
|
|
|
|
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
|
|
|
|
|
upgrades: Set<string>;
|
|
|
|
|
log: LogEntry[];
|
|
|
|
|
phase: 'playing' | 'won' | 'lost';
|
|
|
|
|
lostReason?: string;
|
|
|
|
|
usedEventIds: Set<string>;
|
2026-07-12 23:53:43 +02:00
|
|
|
flags: Set<string>;
|
2026-07-07 23:05:22 +02:00
|
|
|
nextEventTick: number;
|
|
|
|
|
nextKaosTick: number;
|
2026-07-12 23:17:48 +02:00
|
|
|
pendingEvent: PendingEvent | null;
|
2026-07-07 23:05:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LogEntry {
|
|
|
|
|
tick: number;
|
|
|
|
|
text: string;
|
|
|
|
|
kind: 'event' | 'kaos' | 'system' | 'upgrade';
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-07 23:25:44 +02:00
|
|
|
// Bump this when State shape changes to invalidate old saves
|
2026-07-08 17:28:32 +02:00
|
|
|
const SAVE_VERSION = '002';
|
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>
2026-07-07 23:25:44 +02:00
|
|
|
const SAVE_KEY = `djupet_v${SAVE_VERSION}`;
|
|
|
|
|
|
2026-07-07 23:05:22 +02:00
|
|
|
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
|
2026-07-08 17:28:32 +02:00
|
|
|
const DREAD_HEMLIGHET_DRAIN = 0.35; // per second, saturating with dread — the deeper it goes, the more it's noticed
|
|
|
|
|
const RITUAL_HEMLIGHET_DRAIN = 0.25; // per second at ritual=100 — an active ritual is hard to hide
|
|
|
|
|
const INFLYTANDE_BASE_RATE = 0.15; // per second, scales with troende^1.2 — a network effect
|
2026-07-07 23:05:22 +02:00
|
|
|
|
|
|
|
|
export function createState(): State {
|
|
|
|
|
return {
|
|
|
|
|
tick: 0,
|
|
|
|
|
troende: 1,
|
|
|
|
|
andakt: 10,
|
|
|
|
|
andaktRate: 0.3,
|
2026-07-08 17:28:32 +02:00
|
|
|
inflytande: 0,
|
|
|
|
|
inflytandeRate: 1,
|
2026-07-07 23:05:22 +02:00
|
|
|
dread: 0,
|
|
|
|
|
dreadRate: 0,
|
|
|
|
|
hemlighet: 100,
|
|
|
|
|
kaos: 0,
|
|
|
|
|
kaosRate: 0.05,
|
|
|
|
|
ritual: 0,
|
|
|
|
|
ritualRate: 0,
|
|
|
|
|
ritualUnlocked: false,
|
|
|
|
|
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' }],
|
|
|
|
|
phase: 'playing',
|
|
|
|
|
usedEventIds: new Set(),
|
2026-07-12 23:53:43 +02:00
|
|
|
flags: new Set(),
|
2026-07-07 23:05:22 +02:00
|
|
|
nextEventTick: EVENT_INTERVAL_TICKS,
|
|
|
|
|
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
|
2026-07-12 23:17:48 +02:00
|
|
|
pendingEvent: null,
|
2026-07-07 23:05:22 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
|
|
|
|
|
const available = pool.filter(e => {
|
|
|
|
|
if (state.usedEventIds.has(e.id)) return false;
|
|
|
|
|
const r = e.requires;
|
|
|
|
|
if (!r) return true;
|
|
|
|
|
if (r.minTroende && state.troende < r.minTroende) return false;
|
|
|
|
|
if (r.minDread && state.dread < r.minDread) return false;
|
|
|
|
|
if (r.minRitual && state.ritual < r.minRitual) return false;
|
|
|
|
|
if (r.unlockedArea && !state.areas[r.unlockedArea]) return false;
|
2026-07-12 23:53:43 +02:00
|
|
|
if (r.flag && !state.flags.has(r.flag)) return false;
|
|
|
|
|
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) return false;
|
2026-07-07 23:05:22 +02:00
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
if (!available.length) return null;
|
|
|
|
|
const total = available.reduce((s, e) => s + e.weight, 0);
|
|
|
|
|
let roll = Math.random() * total;
|
|
|
|
|
for (const e of available) {
|
|
|
|
|
roll -= e.weight;
|
|
|
|
|
if (roll <= 0) return e;
|
|
|
|
|
}
|
|
|
|
|
return available[available.length - 1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyEffect(state: State, effect: GameEvent['effect']): void {
|
|
|
|
|
if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt);
|
|
|
|
|
if (effect.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet));
|
|
|
|
|
if (effect.kaos) state.kaos = Math.max(0, Math.min(100, state.kaos + effect.kaos));
|
|
|
|
|
if (effect.dread) state.dread = Math.max(0, state.dread + effect.dread);
|
|
|
|
|
if (effect.troende) state.troende = Math.max(1, state.troende + effect.troende);
|
|
|
|
|
if (effect.ritual && state.ritualUnlocked) state.ritual = Math.min(100, state.ritual + effect.ritual);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DT = 1 / 30; // 30fps
|
|
|
|
|
|
|
|
|
|
export function tick(state: State): State {
|
|
|
|
|
if (state.phase !== 'playing') return state;
|
|
|
|
|
|
2026-07-12 23:53:43 +02:00
|
|
|
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
2026-07-07 23:05:22 +02:00
|
|
|
s.tick += 1;
|
|
|
|
|
|
2026-07-12 23:17:48 +02:00
|
|
|
// A choice event is awaiting the player's answer — freeze new events, keep resources flowing.
|
|
|
|
|
if (s.pendingEvent) {
|
|
|
|
|
s.nextEventTick += 1;
|
|
|
|
|
s.nextKaosTick += 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 23:05:22 +02:00
|
|
|
// Resource accumulation
|
|
|
|
|
s.andakt += s.andaktRate * s.troende * DT;
|
2026-07-08 17:28:32 +02:00
|
|
|
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * DT;
|
2026-07-07 23:05:22 +02:00
|
|
|
s.dread += s.dreadRate * DT;
|
|
|
|
|
if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * DT);
|
|
|
|
|
|
|
|
|
|
// Kaos accumulation — faster with more troende and more dread
|
|
|
|
|
const kaosScale = 1 + (s.dread / 200);
|
|
|
|
|
s.kaos = Math.min(100, s.kaos + s.kaosRate * kaosScale * DT);
|
|
|
|
|
|
2026-07-08 17:28:32 +02:00
|
|
|
// Hemlighet drain — more believers, more dread, and a deepening ritual all draw attention
|
|
|
|
|
const troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0;
|
|
|
|
|
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
|
|
|
|
|
const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0;
|
|
|
|
|
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * DT);
|
2026-07-07 23:05:22 +02:00
|
|
|
|
|
|
|
|
// Standard event
|
2026-07-12 23:17:48 +02:00
|
|
|
if (!s.pendingEvent && s.tick >= s.nextEventTick) {
|
2026-07-07 23:05:22 +02:00
|
|
|
const ev = pickEvent(STANDARD_EVENTS, s);
|
|
|
|
|
if (ev) {
|
|
|
|
|
s.usedEventIds.add(ev.id);
|
2026-07-12 23:17:48 +02:00
|
|
|
if (ev.choices?.length) {
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
|
|
|
|
|
s.pendingEvent = { event: ev, kind: 'event' };
|
|
|
|
|
} else {
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
|
|
|
|
|
applyEffect(s, ev.effect);
|
|
|
|
|
}
|
2026-07-07 23:05:22 +02:00
|
|
|
}
|
|
|
|
|
s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60);
|
|
|
|
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kaos event
|
2026-07-12 23:17:48 +02:00
|
|
|
if (!s.pendingEvent && (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick)) {
|
2026-07-07 23:05:22 +02:00
|
|
|
const ev = pickEvent(KAOS_EVENTS, s);
|
|
|
|
|
if (ev) {
|
|
|
|
|
s.usedEventIds.add(ev.id);
|
2026-07-12 23:17:48 +02:00
|
|
|
if (ev.choices?.length) {
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
|
|
|
|
s.pendingEvent = { event: ev, kind: 'kaos' };
|
|
|
|
|
} else {
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: `⚡ ${ev.text}`, kind: 'kaos' });
|
|
|
|
|
applyEffect(s, ev.effect);
|
|
|
|
|
}
|
2026-07-07 23:05:22 +02:00
|
|
|
}
|
|
|
|
|
s.kaos = Math.max(0, s.kaos - 80);
|
|
|
|
|
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
|
|
|
|
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Win / lose checks
|
|
|
|
|
if (s.ritual >= 100) {
|
|
|
|
|
s.phase = 'won';
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
|
|
|
|
} else if (s.hemlighet <= 0) {
|
|
|
|
|
s.phase = 'lost';
|
|
|
|
|
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 23:17:48 +02:00
|
|
|
export function resolveEventChoice(state: State, choiceIndex: number): State {
|
|
|
|
|
if (!state.pendingEvent) return state;
|
|
|
|
|
const choice = state.pendingEvent.event.choices?.[choiceIndex];
|
|
|
|
|
if (!choice) return state;
|
|
|
|
|
|
2026-07-12 23:53:43 +02:00
|
|
|
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
2026-07-12 23:17:48 +02:00
|
|
|
applyEffect(s, choice.effect);
|
2026-07-12 23:53:43 +02:00
|
|
|
if (choice.flag) s.flags.add(choice.flag);
|
2026-07-12 23:17:48 +02:00
|
|
|
s.log.unshift({ tick: s.tick, text: `↳ ${choice.resultText}`, kind: s.pendingEvent!.kind });
|
|
|
|
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
|
|
|
|
s.pendingEvent = null;
|
|
|
|
|
|
|
|
|
|
if (s.hemlighet <= 0) {
|
|
|
|
|
s.phase = 'lost';
|
|
|
|
|
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' });
|
|
|
|
|
} else if (s.ritual >= 100) {
|
|
|
|
|
s.phase = 'won';
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
interface LocationVisit {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: string[];
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
|
|
|
|
|
requiresArea?: keyof State['areas'];
|
|
|
|
|
lockedText: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LOCATION_VISITS: Record<string, LocationVisit> = {
|
|
|
|
|
rosengard: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
|
|
|
|
|
'Någon har städat källaren medan ingen var där. Ingen av er erkänner sig skyldig. +andakt',
|
|
|
|
|
'Den varma stolen är varm igen. Ingen har suttit i den. +andakt',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { andakt: 15 },
|
|
|
|
|
requiresArea: 'rosengard',
|
|
|
|
|
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
|
|
|
|
|
},
|
|
|
|
|
varnhem: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
|
|
|
|
|
'Kritan på asfalten har inte suddats trots regnet i natt. +dread +ritual',
|
|
|
|
|
'Torget är tomt förutom en duva som går i exakt cirkelns mönster. +dread +ritual',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { dread: 8, ritual: 2 },
|
|
|
|
|
requiresArea: 'varnhem',
|
|
|
|
|
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
|
|
|
|
|
},
|
|
|
|
|
torso: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
|
|
|
|
|
'Ett fönster högt uppe är tänt som det aldrig varit förut. Ingen bor där. Ingen har någonsin bott där. +dread +ritual',
|
|
|
|
|
'Torso vrider sig en grad till i vinden som inte finns. +dread +ritual',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { dread: 15, ritual: 5 },
|
|
|
|
|
requiresArea: 'torso',
|
|
|
|
|
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
|
|
|
|
|
},
|
|
|
|
|
oresund: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
|
|
|
|
|
'En färja passerar utan ljud. Ingen ombord vinkar. Alla ombord tittar rakt på dig. +dread',
|
|
|
|
|
'Bron surrar på en frekvens du känner i tänderna snarare än öronen. +dread',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { dread: 5 },
|
|
|
|
|
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
|
|
|
|
|
},
|
|
|
|
|
ribersborg: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
|
|
|
|
|
'Alla änderna vänder sig samtidigt mot Turning Torso när du kommer. +andakt',
|
|
|
|
|
'Sanden är kall trots solen. Ingen annan verkar märka det. +andakt',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { andakt: 8 },
|
|
|
|
|
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
|
|
|
|
|
},
|
|
|
|
|
davidshall: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
|
|
|
|
|
'Kaklet från 1923 är kallt att röra vid, oavsett årstid. +andakt',
|
|
|
|
|
'Grannen genom väggen sjunger något som nästan, men inte riktigt, är en psalm. +andakt',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { andakt: 10 },
|
|
|
|
|
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
|
|
|
|
|
},
|
|
|
|
|
stortorget: {
|
2026-07-13 00:01:51 +02:00
|
|
|
texts: [
|
|
|
|
|
'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
|
|
|
|
|
'Statyn på torget har flyttat en centimeter sedan i förra veckan. Du är den enda som mätt. +kaos',
|
|
|
|
|
'Alla klockor på torget visar olika tid. Ingen av dem är fel. +kaos',
|
|
|
|
|
],
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
effect: { kaos: 8 },
|
|
|
|
|
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps
|
|
|
|
|
|
|
|
|
|
export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
|
|
|
|
|
const visit = LOCATION_VISITS[id];
|
|
|
|
|
if (!visit) return { state, cooldowns };
|
|
|
|
|
|
|
|
|
|
const now = state.tick;
|
|
|
|
|
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
|
|
|
|
|
|
2026-07-12 23:53:43 +02:00
|
|
|
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
|
|
|
|
|
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
|
2026-07-13 00:01:51 +02:00
|
|
|
const text = areaLocked ? visit.lockedText : visit.texts[Math.floor(Math.random() * visit.texts.length)];
|
Add help modal, changelog, map visits, Dockerfile, CI
- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
Unlocked cult areas give resources + log entries. Locked locations show
lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:31:37 +02:00
|
|
|
|
|
|
|
|
if (!areaLocked) {
|
|
|
|
|
if (visit.effect.andakt) s.andakt += visit.effect.andakt;
|
|
|
|
|
if (visit.effect.dread) s.dread += visit.effect.dread;
|
|
|
|
|
if (visit.effect.kaos) s.kaos = Math.min(100, s.kaos + visit.effect.kaos);
|
|
|
|
|
if (visit.effect.ritual && s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + visit.effect.ritual);
|
|
|
|
|
if (visit.effect.hemlighet) s.hemlighet = Math.max(0, Math.min(100, s.hemlighet + visit.effect.hemlighet));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.log.unshift({ tick: now, text: `⬡ ${text}`, kind: 'system' });
|
|
|
|
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
|
|
|
|
|
|
|
|
|
const newCooldowns = new Map(cooldowns);
|
|
|
|
|
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
|
|
|
|
|
|
|
|
|
|
return { state: s, cooldowns: newCooldowns };
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-07 23:25:44 +02:00
|
|
|
export function saveState(state: State): void {
|
|
|
|
|
try {
|
|
|
|
|
const serial = {
|
|
|
|
|
...state,
|
|
|
|
|
upgrades: [...state.upgrades],
|
|
|
|
|
usedEventIds: [...state.usedEventIds],
|
2026-07-12 23:53:43 +02:00
|
|
|
flags: [...state.flags],
|
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>
2026-07-07 23:25:44 +02:00
|
|
|
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 ?? []),
|
2026-07-12 23:53:43 +02:00
|
|
|
flags: new Set<string>(d.flags ?? []),
|
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>
2026-07-07 23:25:44 +02:00
|
|
|
} as State;
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearSave(): void {
|
|
|
|
|
localStorage.removeItem(SAVE_KEY);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 23:05:22 +02:00
|
|
|
export function buyUpgrade(state: State, id: string): State {
|
|
|
|
|
const upgrade = UPGRADES.find(u => u.id === id);
|
|
|
|
|
if (!upgrade) return state;
|
|
|
|
|
if (!upgrade.available(state)) return state;
|
2026-07-08 17:28:32 +02:00
|
|
|
const cost = upgrade.cost(state);
|
|
|
|
|
if (cost.andakt && state.andakt < cost.andakt) return state;
|
|
|
|
|
if (cost.dread && state.dread < cost.dread) return state;
|
|
|
|
|
if (cost.inflytande && state.inflytande < cost.inflytande) return state;
|
2026-07-07 23:05:22 +02:00
|
|
|
|
2026-07-12 23:53:43 +02:00
|
|
|
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
|
2026-07-08 17:28:32 +02:00
|
|
|
if (cost.andakt) s.andakt -= cost.andakt;
|
|
|
|
|
if (cost.dread) s.dread -= cost.dread;
|
|
|
|
|
if (cost.inflytande) s.inflytande -= cost.inflytande;
|
2026-07-07 23:05:22 +02:00
|
|
|
upgrade.effect(s);
|
|
|
|
|
if (upgrade.once) s.upgrades.add(id);
|
|
|
|
|
s.log.unshift({ tick: s.tick, text: `↳ ${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
|
|
|
|
|
if (s.log.length > 40) s.log = s.log.slice(0, 40);
|
|
|
|
|
return s;
|
|
|
|
|
}
|