Compare commits

...

19 Commits

Author SHA1 Message Date
Fredrik Johansson
3a741a7bfc Fix log entries appearing to lag one action behind
All checks were successful
Docker / build-and-push (push) Successful in 1m16s
Real bug, two parts:

1. renderLog appended the newest entry LAST in the DOM (so it could
   get its own element for the typewriter effect), but log.scrollTop
   = 0 scrolls to the TOP, where the older entries now sat. The
   newest line rendered below the fold, invisible until enough later
   entries pushed it into view — exactly "nothing appears until the
   next event." Fixed by putting the newest entry first.

2. Separately, the typewriter itself was applying to every log kind,
   including the player's own actions (upgrades, visits). Those took
   1-2s to fully print, so even after fixing the ordering, taking a
   second action while the first was still typing would silently
   snap the first to full text right as the second started — still
   reads as a one-action lag. Now only narrative kinds (event, kaos)
   animate; upgrades and visits confirm instantly. Also sped up the
   animation itself (4-8ms/char, was 8-18ms) so even narrative beats
   don't drag.

Also fixed Malmö universitet's map marker — it was placed near
Hyllie, nowhere near the actual campus at Universitetsholmen by the
harbor.

Verified via headless browser: a kaos event and a free location
visit both now appear at the top of the log immediately, not after
a subsequent action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:14:06 +02:00
Fredrik Johansson
b610d27542 Replace Göran's portrait with a tighter, more intimate framing
All checks were successful
Docker / build-and-push (push) Successful in 1m17s
The original wide shot split focus between him and the full ritual
circle in the background, which is the game's set dressing more than
his — and put the dog on its own blanket further back, reading as
"a dog happens to be in this room" rather than his dog. New version
is a close single-candle framing: just him, the table, the mug, and
the dog curled at his feet. The warm, cozy light against rough
concrete does more comedic work too — he's the one person here who
hasn't noticed anything's wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:54:24 +02:00
Fredrik Johansson
1aa9eb2ce8 Click a map marker to see its art at full size
Reuses the lightbox already built for the character gallery. Clicking
any location still triggers the visit as before, but now also probes
for that location's art and, if it exists, pops it up full-size with
a caption — no modal for locations that don't have art yet, the visit
still happens either way.

Also added a standing "about this place" blurb per location (map.ts's
new LOCATION_INFO), shown under the art. This is distinct from
LOCATION_VISITS' randomized per-visit outcome text in engine.ts, which
still changes every time — the blurb is a fixed description of the
place itself, not what just happened there.

Verified via headless browser: clicking Turning Torso's marker pops
the lightbox with its real art, label, and blurb, no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:42:28 +02:00
Fredrik Johansson
d2a8b71ffc Add all 9 location thumbnails
All map markers now show real art in their tooltip preview instead
of falling back to text-only. Fixed two filename mismatches against
what map.ts expects (id-based, e.g. "universitet.png" not the
Swedish definite form "universitetet.png"). Kept the stronger of two
Ribersborg takes — the one with Turning Torso on the skyline ties it
back to the game's central landmark, and the ducks facing camera
reads eerier than facing away.

Verified via headless browser: all 9 markers present, thumbnails
load without error, tooltip renders image + label together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:27:54 +02:00
Fredrik Johansson
899ab72c80 Location thumbnails on the map, full-art archive mode, more content
- Map markers now show a tiny thumbnail preview in their tooltip when
  art exists for that location (same missing-asset-is-fine fallback
  as character portraits — onerror just removes the broken img).
- Two new locations, both free-roam from the start: Möllevångstorget
  and Malmö universitet (the latter ties into the researcher
  character's arc). VISITABLE_LOCATION_IDS picks them up automatically
  since it's derived from the data, not a separate list.
- Sällskapet gets a second mode: "Fullständigt arkiv" shows every
  character's real portrait and bio regardless of story progress
  (bypassing the usedEventIds lock), and clicking any known portrait
  opens a lightbox at full size instead of the cropped card thumbnail.
- All 9 character portraits are now in and wired (fixed two more
  filename mismatches along the way: anneli-karin and the rest use
  hyphens, not underscores, matching what was actually generated).
- More content: 2 new location-visit variants each for the two new
  areas, 6 new Standard events (including a found 1930s photograph
  that seems to show Lindqvist in a room nobody recognizes), 4 new
  Kaos events tied to the new locations and existing ones.
- Caught and fixed a bug in my own new content before commit: an
  event's requires.completedEventId pointed at an upgrade id, which
  can never appear in usedEventIds — switched to a minRitual check
  that actually gates on the right precondition.

Verified: 15/15 tests, clean typecheck and build, and a real
headless-browser pass — all 9 map markers present, full-archive mode
correctly bypassing locks, and the lightbox opening at full size with
a caption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:09:43 +02:00
Fredrik Johansson
aeedf0abb5 Add offline progress, tab haunting, character gallery, more flavor
Real depth additions, not a reskin of another idle game:

- applyOfflineProgress: the cult continues while the tab is closed or
  backgrounded (rAF throttles hard when hidden, so real time drifts
  well past simulated time). Approximates tick()'s per-second rates
  over the elapsed window, capped at 24h, with a narrative "while you
  were away" log line scaled to how long that was. Can end the game
  in a win or loss if the numbers would have gotten there. Re-checked
  on every tab-visibility return too, not just page load.
- Tab-title haunting: document.title cycles short eerie lines
  ("Han väntar…", "Havet kallar…") while the tab is hidden, restores
  on return.
- New late-game upgrade "Viskningen leder er" — once bought, the cult
  visits map locations on its own occasionally without a click. A
  small loss-of-agency horror beat, not just a QoL toggle.
- Log's newest line now types itself out, with characters
  occasionally swapped for occult symbols (☿☠ψ) at a rate scaled to
  current dread. Older lines stay static and legible — this never
  applies retroactively.
- New "Sällskapet" character gallery: cards for every named recurring
  character, locked to a silhouette + "???" until you've actually met
  them (checked against usedEventIds). Portraits are optional — missing
  art falls back to an initial-letter placeholder, so this works
  incrementally as art gets added. First five real portraits included.
- Real bug fix: several Kaos events referenced characters (Göran,
  Bengt-Åke, the Sydsvenskan journalist) as if already introduced,
  but had no ordering dependency on the Standard event that actually
  introduces them — so a fresh cultist could show up in the log
  already "familiar" before you'd ever met them. Gated with
  requires.completedEventId.
- More flavor text throughout: two new variants per location visit,
  6 new Standard events, 4 new Kaos events.

Evaluated three other ideas from the same source and declined them:
a generic milestone-triggered log (Djupet's log already reacts to
real state, not arbitrary point totals), an investigator combat
minigame (hemlighet draining to zero already does this job, and
tower-defense clicking would fight the game's actual tone), and a
node-sequencing ritual minigame (real scope for a twitch mechanic
that doesn't match the rest of the game's pacing).

Verified: 15/15 tests passing, clean typecheck and production build,
and a real headless-browser pass — offline catch-up after a
simulated 5-hour gap, tab-title cycling and restore, and the
character gallery rendering real portraits alongside locked cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:53:26 +02:00
Fredrik Johansson
1eb9c43625 Add favicon
All checks were successful
Docker / build-and-push (push) Successful in 43s
Purple downward-chevron mark matching the site's eldritch-purple accent, in the same rounded-square favicon style used across the other self-hosted projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:49:28 +02:00
Fredrik Johansson
ee60a9f4d2 Add .dockerignore to shrink the build context sent to the daemon
All checks were successful
Docker / build-and-push (push) Successful in 51s
node_modules/.git/dist were being sent on every build with no
.dockerignore at all — same class of fix as goonk's recent deploy-speed
pass. Verified by building with the new file in place (adventure and
new spot-checked with a full container run + health check; the rest
share the same multi-stage Dockerfile pattern).
2026-07-16 18:48:35 +02:00
Fredrik Johansson
bde809e862 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>
2026-07-13 19:29:04 +02:00
Fredrik Johansson
323a61997f Lighten win/loss overlay scrim to match the intro screen's touch
All checks were successful
Docker / build-and-push (push) Successful in 43s
The overlay's radial gradient was much darker/broader than the intro
scrim, crushing the win/loss artwork (the inverted Torso, the ritual
circle) that the intro screen showed off fine. Dropped the opacity
stops so the same restrained look applies everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 16:54:03 +02:00
Fredrik Johansson
b7c052a11f Fix silently-dropped log entries past the 40-line cap, add pause, clarify locations panel
All checks were successful
Docker / build-and-push (push) Successful in 42s
- The log view only re-rendered when state.log.length changed, but the
  log is capped at 40 entries — so once a run had logged 40+ things
  (easy in a normal session), every entry after that point, including
  choice-event outcomes, upgrade purchases, and location visits,
  stopped appearing even though the underlying state updated. Fixed
  by tracking a monotonic logSeq counter that increments on every
  push regardless of the cap, and rendering off that instead of
  array length. Consolidated the repeated unshift+cap logic into one
  pushLog() helper so future call sites can't reintroduce the bug.
- Added a pause button (and "PAUSAD" indicator) that freezes the tick
  loop without touching game state, so a hectic run can be paused to
  think without it counting as a save-scummy state mutation. Manual
  actions (buying upgrades, visiting locations) still work while
  paused, since that's usually the point of pausing.
- The "Aktiva områden" sidebar only ever listed the 3 story-gated
  core areas, while the map has 7 clickable locations — making it
  look like 4 of the map pins didn't really exist. Split the panel
  into "Ritualens kärnplatser" (the 3 gated ones, unlock status shown
  as before) and "Övriga platser" (the 4 always-open flavor
  locations), so the sidebar and map agree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 16:49:37 +02:00
Fredrik Johansson
4b4e5c14aa Merge origin/main
All checks were successful
Docker / build-and-push (push) Successful in 42s
2026-07-13 13:30:30 +02:00
Fredrik Johansson
efc19bac1d Bump version to 0.4.0, update changelog, fix Swedish typos
Covers the choice events, NPC chains, location text variety, ambient
visual reactivity, intro screen, win/loss art, and the abandon option
added across the last few changes. Also fixes a handful of spelling
and word-choice mistakes found in a sweep of the event text
("blinkmat"→"blinkat", a "summera"/summon false-friend, a stray
English "return" in "Returndatumet", and one word that didn't fit).

Intro and win/loss screens now use a constrained gradient scrim
around the text instead of a flat overlay covering the whole image,
so the artwork stays visible instead of being crushed to black.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 13:21:23 +02:00
Fredrik Johansson
d039dfb019 Bump version to 0.4.0, update changelog, fix Swedish typos
All checks were successful
Docker / build-and-push (push) Successful in 42s
Covers the choice events, NPC chains, location text variety, ambient
visual reactivity, intro screen, win/loss art, and the abandon option
added across the last few changes. Also fixes a handful of spelling
and word-choice mistakes found in a sweep of the event text
("blinkmat"→"blinkat", a "summera"/summon false-friend, a stray
English "return" in "Returndatumet", and one word that didn't fit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:10:40 +02:00
Fredrik Johansson
4d47409e03 Add intro screen, win/loss art, and a way to abandon the ritual
- Adds a full-screen intro/title screen (new 'intro' game phase) with
  a moody Malmö/Turning Torso background and a "Börja ritualen"
  button; the game no longer starts ticking until the player begins.
- Win and loss overlays now show matching generated artwork behind
  the existing text, instead of a flat scrim.
- New "Avbryt" header button lets the player abandon a run mid-game
  (with a confirm prompt) via a new 'abandoned' phase, reusing the
  existing overlay/restart flow.
- Source images converted from PNG (~2.1MB each) to WebP (~120KB
  each) and placed under public/img/ so Vite serves them as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:54:46 +02:00
Fredrik Johansson
10c7999701 Merge feature/interactive-events: choice events, NPC chains, visual reactivity, location variety
All checks were successful
Docker / build-and-push (push) Successful in 42s
2026-07-13 00:02:58 +02:00
Fredrik Johansson
6ed9d2dac3 Add ambient visual reactivity and location visit variety
- #app's filter (hue-rotate/saturate) now tracks dread continuously,
  and a fixed vignette bleeds red as hemlighet drops below 40 — no
  new assets, just CSS driven by existing state.
- A kaos log entry now triggers a brief screen-glitch shake.
- Location visits pick from 3 flavor-text variants per site instead
  of a single fixed line, so repeat visits don't repeat verbatim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 00:01:51 +02:00
Fredrik Johansson
80fdebf102 Add flag-gated NPC event chains for Broder Lindqvist and Syster Hassan
Events can now depend on a previously-set flag and a completed event
id, letting a choice made early in the game (e.g. whether Lindqvist's
whisper was heeded) shape which follow-up events surface later. Adds
two 3-step chains as a first example of persistent NPC arcs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:53:43 +02:00
Fredrik Johansson
b9561b05a5 Add choice-based events with a resolution modal
Standard/kaos events can now present 2 options instead of a fixed
effect, pausing new event triggers until the player answers. Adds
4 choice events as a first batch; resources keep accruing while a
choice is pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:48 +02:00
34 changed files with 1689 additions and 86 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
**/node_modules
dist
**/dist
.git
.env
.env.example
*.log
data

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Djupet</title> <title>Djupet</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head> </head>

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0d0d1a"/>
<path d="M9 10 L16 24 L23 10" fill="none" stroke="#aa66dd" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="24" r="1.6" fill="#aa66dd"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
public/img/fail.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/img/intro.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
public/img/win.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

86
src/characters.ts Normal file
View File

@@ -0,0 +1,86 @@
export interface Character {
id: string;
name: string;
role: string;
bio: string;
// The event whose id, once in usedEventIds, means you've actually met
// this person — until then the card stays a silhouette. Undefined means
// always known (the two founders, present from the first log line).
unlockEventId?: string;
img: string;
}
// Order roughly matches how the game introduces people: the two founders
// first, then everyone met along the way.
export const CHARACTERS: Character[] = [
{
id: 'lindqvist',
name: 'Broder Lindqvist',
role: 'Grundare. Den som hittade källaren.',
bio: 'Drömmer om havet varje natt, mer och mer bokstavligt ju längre ritualen går. Ritar samma karta om och om igen, alltid samma vinkel mot Öresund. Sover allt mindre. Säger att han inte längre behöver det.',
img: '/img/characters/lindqvist.png',
},
{
id: 'hassan',
name: 'Syster Hassan',
role: 'Grundare. Sköter pappersarbetet.',
bio: 'Skriver 47-sidiga svar till Skatteverket och vinner. Praktisk, obekymrad, orubblig — tills hon börjar föreslå ändringar i ritualens ordning med en säkerhet ingen minns att hon haft förut.',
img: '/img/characters/hassan.png',
},
{
id: 'goran',
name: 'Göran',
role: 'Kom av misstag. Stannade av eget val.',
bio: 'Blev tillagd i fel Discord-server och trodde ni höll på med ett brädspel om havet. Feedbacken var konstruktiv nog att han fick stanna. Numera Väktare av det Tredje Sigillet, vilket han tolkar som kassör.',
unlockEventId: 'discord_goran',
img: '/img/characters/goran.png',
},
{
id: 'bengt_ake',
name: 'Bengt-Åke från Husie',
role: 'Köper en tidning om dagen. Vet mer än han säger.',
bio: 'Köper en namnlös tidning på Pressbyrån varje dag och verkar nöjd med den. Visade sig senare vara med i en helt annan sekt sedan 1987 — vars egen sekt går tillbaka till 1840-talet. Samordningsmötet gick bra.',
unlockEventId: 'pressbyraan',
img: '/img/characters/bengt-ake.png',
},
{
id: 'rolf',
name: 'Rolf från Limhamn',
role: 'Var med i tre veckor.',
bio: 'Slutade komma. Skorna hittades utanför lokalen, ordentligt placerade, tårna mot havet. Rökarens tobak låg bredvid. Rolf rökte inte.',
unlockEventId: 'rolf_disappears',
img: '/img/characters/rolf.png',
},
{
id: 'anneli_karin',
name: 'Systern Anneli-Karin',
role: 'Talar i sömnen. Inte på svenska.',
bio: 'Talar i sömnen på ett språk ingen känner igen. Inspelningen, uppspelad baklänges, var lika obegriplig — fast på ett annat sätt.',
unlockEventId: 'anneli_dream',
img: '/img/characters/anneli-karin.png',
},
{
id: 'sailor',
name: 'Den gamle sjömannen',
role: 'Vid Turning Torso. Sjunger.',
bio: 'Pensionerad, med tatueringar ingen kan identifiera. Låste in sig i tornet vid Turning Torso och började sjunga. Räddningstjänsten kunde inte ta sig in. Sången hjälpte faktiskt ritualen.',
unlockEventId: 'k_old_man',
img: '/img/characters/sailor.png',
},
{
id: 'researcher',
name: 'Doktoranden',
role: 'Översatte tre rader. Ångrade sig inte.',
bio: 'Råkade översätta tre rader av Skriften för ett annat projekt. Sökte senare medlemskap och sa att hon "bara vill se hur det slutar." Ni tror henne inte. Ni släpper in henne ändå.',
unlockEventId: 'k_researcher_joins',
img: '/img/characters/researcher.png',
},
{
id: 'journalist',
name: 'Journalisten från Sydsvenskan',
role: 'Skrev ett prisbelönt reportage. Kom tillbaka för mer.',
bio: 'Skrev ett positivt reportage om "Malmös växande alternativa andlighet" som vann pris — juryn minns inte varför. Reste senare tillbaka för en uppföljning. Artikeln publicerades aldrig. Anteckningarna sparades.',
unlockEventId: 'k_journalist',
img: '/img/characters/journalist.png',
},
];

View File

@@ -1,4 +1,4 @@
export const VERSION = '0.3.0'; export const VERSION = '0.7.0';
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string;
@@ -7,6 +7,51 @@ export interface ChangelogEntry {
} }
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.7.0',
date: '2026-08-01',
changes: [
'Klicka på en plats på kartan för att se dess bild i fullstorlek, tillsammans med en fast beskrivning av platsen',
'Alla 9 platser har nu konst på kartan',
],
},
{
version: '0.6.0',
date: '2026-08-01',
changes: [
'Alla porträtt i Sällskapet är nu på plats',
'Nytt läge i Sällskapet: Fullständigt arkiv — se alla porträtt oavsett hur långt ni kommit, klicka för fullstorlek',
'Platser på kartan visar nu en liten bild i förhandsvisningen (när konst finns för platsen)',
'Två nya platser: Möllevångstorget och Malmö universitet, båda besökbara direkt från start',
'Fler händelser tillagda, inklusive ett fotografi från 1930-talet som inte borde finnas',
],
},
{
version: '0.5.0',
date: '2026-08-01',
changes: [
'Källaren fortsätter utan er nu — stäng fliken eller gå därifrån, och nästa gång ni öppnar spelet har andakt, dread och kaos fortsatt sin gång (inom rimliga gränser)',
'Fliken viskar när ni tittar bort: titeln växlar till korta rader ur ritualen så länge spelet inte är i förgrunden',
'Sen uppgradering: Viskningen leder er — kulten besöker platser på egen hand ibland, utan att någon av er fattat beslutet',
'Loggens senaste rad skrivs nu ut bokstav för bokstav, och tecken byts stundtals mot ockulta symboler ju djupare dread ligger — äldre rader förblir lästa som de var',
'Ny flik: Sällskapet — porträtt och korta biografier över alla ni mött, tomma tills ni faktiskt mött dem',
'Fixat: Göran och Bengt-Åke kunde tidigare dyka upp i loggen som redan bekanta innan ni faktiskt träffat dem',
'Nya händelser och platsvarianter tillagda över hela spelet',
],
},
{
version: '0.4.0',
date: '2026-07-13',
changes: [
'Vissa händelser ställer nu ett val istället för att bara hända — vad ni svarar Broder Lindqvist spelar roll',
'Syster Hassan och Broder Lindqvist har fått egna, flerstegs historielinjer som reagerar på tidigare val',
'Nya platsbesök: varje plats har nu flera varianter av vad som möter er, inte bara en fast rad text',
'Skärmen reagerar nu på tillståndet i källaren — högre dread färgar allt, låg hemlighet blöder rött i kanterna, kaos-händelser skakar bilden',
'Ny introskärm innan ritualen börjar, med egen bakgrund',
'Seger och nederlag har nu egen bakgrundskonst istället för bara text på svart',
'Ny knapp: Avbryt — överge kulten i förtid om ni ångrar er',
],
},
{ {
version: '0.3.0', version: '0.3.0',
date: '2026-07-08', date: '2026-07-08',

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { createState, tick, buyUpgrade, type State } from './engine.ts'; import { createState as createIntroState, startGame, tick, buyUpgrade, applyOfflineProgress, type State } from './engine.ts';
function createState(): State {
return startGame(createIntroState());
}
import { UPGRADES } from './upgrades.ts'; import { UPGRADES } from './upgrades.ts';
// Deterministic PRNG so simulation tests aren't flaky, but still exercise // Deterministic PRNG so simulation tests aren't flaky, but still exercise
@@ -132,7 +136,7 @@ describe('deepen_ritual cost scaling', () => {
it('grows monotonically with each purchase, giving andakt a late-game sink', () => { it('grows monotonically with each purchase, giving andakt a late-game sink', () => {
let s = createState(); let s = createState();
s.upgrades.add('grand_ritual_prep'); 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.andakt = 1_000_000;
s.dread = 1_000_000; s.dread = 1_000_000;
@@ -197,3 +201,61 @@ describe('buyUpgrade immutability', () => {
expect(s.andakt).toBe(frozenAndakt); expect(s.andakt).toBe(frozenAndakt);
}); });
}); });
describe('applyOfflineProgress', () => {
it('is a no-op under the one-minute threshold', () => {
const s = createState();
const after = applyOfflineProgress(s, 30_000);
expect(after.andakt).toBe(s.andakt);
expect(after.log.length).toBe(s.log.length);
});
it('accrues andakt, dread, and inflytande roughly matching the per-second rates', () => {
let s = createState();
s.andaktRate = 1;
s.troende = 2;
s.dreadRate = 0.5;
const before = { andakt: s.andakt, dread: s.dread };
const after = applyOfflineProgress(s, 60 * 60 * 1000); // 1 hour
expect(after.andakt).toBeCloseTo(before.andakt + 1 * 2 * 3600, 0);
expect(after.dread).toBeCloseTo(before.dread + 0.5 * 3600, 0);
});
it('caps the catch-up window so a long-forgotten tab does not simulate forever', () => {
let s = createState();
s.andaktRate = 1;
s.troende = 1;
// Seed identically before each call so both draw the same flavor-event
// sequence — otherwise unseeded event selection alone (not the cap)
// could account for a small difference in the two totals.
seedRandom(11);
const cappedAt24h = applyOfflineProgress(s, 24 * 3600 * 1000);
seedRandom(11);
const overAWeek = applyOfflineProgress(s, 30 * 24 * 3600 * 1000);
expect(overAWeek.andakt).toBeCloseTo(cappedAt24h.andakt, 0);
});
it('can end the game in a loss if hemlighet would have run out while away', () => {
let s = createState();
s.troende = 50; // heavy secrecy drain
s.hemlighet = 5;
// Kept under 2h so no flavor events roll — a lucky +hemlighet event
// rescuing the cult is legitimate (it mirrors tick()'s own ordering),
// just not what this test is trying to isolate.
const after = applyOfflineProgress(s, 3600 * 1000);
expect(after.phase).toBe('lost');
});
it('never mutates the input state', () => {
const s = createState();
const frozenAndakt = s.andakt;
applyOfflineProgress(s, 2 * 3600 * 1000);
expect(s.andakt).toBe(frozenAndakt);
});
it('does nothing outside the "playing" phase', () => {
const s = createIntroState(); // phase: 'intro'
const after = applyOfflineProgress(s, 6 * 3600 * 1000);
expect(after).toBe(s);
});
});

View File

@@ -1,4 +1,9 @@
import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts'; import { STANDARD_EVENTS, KAOS_EVENTS, type GameEvent } from './events.ts';
export interface PendingEvent {
event: GameEvent;
kind: 'event' | 'kaos';
}
import { UPGRADES } from './upgrades.ts'; import { UPGRADES } from './upgrades.ts';
export interface State { export interface State {
@@ -19,11 +24,15 @@ export interface State {
areas: { rosengard: boolean; varnhem: boolean; torso: boolean }; areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
upgrades: Set<string>; upgrades: Set<string>;
log: LogEntry[]; log: LogEntry[];
phase: 'playing' | 'won' | 'lost'; phase: 'intro' | 'playing' | 'won' | 'lost' | 'abandoned';
lostReason?: string; lostReason?: string;
usedEventIds: Set<string>; usedEventIds: Set<string>;
flags: Set<string>;
nextEventTick: number; nextEventTick: number;
nextKaosTick: number; nextKaosTick: number;
pendingEvent: PendingEvent | null;
logSeq: number; // increments on every log push — log.length alone can't detect changes once capped at 40
lastRealTime: number; // epoch ms, refreshed on save — used to compute offline progress on load
} }
export interface LogEntry { export interface LogEntry {
@@ -62,13 +71,29 @@ export function createState(): State {
areas: { rosengard: false, varnhem: false, torso: false }, areas: { rosengard: false, varnhem: false, torso: false },
upgrades: new Set(), 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' }], 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', phase: 'intro',
usedEventIds: new Set(), usedEventIds: new Set(),
flags: new Set(),
nextEventTick: EVENT_INTERVAL_TICKS, nextEventTick: EVENT_INTERVAL_TICKS,
nextKaosTick: EVENT_INTERVAL_TICKS * 3, nextKaosTick: EVENT_INTERVAL_TICKS * 3,
pendingEvent: null,
logSeq: 0,
lastRealTime: Date.now(),
}; };
} }
export function startGame(state: State): State {
if (state.phase !== 'intro') return state;
return { ...state, phase: 'playing' };
}
export function abandonGame(state: State): State {
const s = { ...state, log: [...state.log] };
s.phase = 'abandoned';
pushLog(s, { tick: s.tick, text: 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.', kind: 'system' });
return s;
}
function pickEvent(pool: GameEvent[], state: State): GameEvent | null { function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
const available = pool.filter(e => { const available = pool.filter(e => {
if (state.usedEventIds.has(e.id)) return false; if (state.usedEventIds.has(e.id)) return false;
@@ -78,6 +103,8 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
if (r.minDread && state.dread < r.minDread) return false; if (r.minDread && state.dread < r.minDread) return false;
if (r.minRitual && state.ritual < r.minRitual) return false; if (r.minRitual && state.ritual < r.minRitual) return false;
if (r.unlockedArea && !state.areas[r.unlockedArea]) return false; if (r.unlockedArea && !state.areas[r.unlockedArea]) return false;
if (r.flag && !state.flags.has(r.flag)) return false;
if (r.completedEventId && !state.usedEventIds.has(r.completedEventId)) return false;
return true; return true;
}); });
if (!available.length) return null; if (!available.length) return null;
@@ -90,6 +117,12 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
return available[available.length - 1]; return available[available.length - 1];
} }
function pushLog(state: State, entry: LogEntry): void {
state.log.unshift(entry);
state.logSeq += 1;
if (state.log.length > 40) state.log = state.log.slice(0, 40);
}
function applyEffect(state: State, effect: GameEvent['effect']): void { function applyEffect(state: State, effect: GameEvent['effect']): void {
if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt); 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.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet));
@@ -104,9 +137,15 @@ const DT = 1 / 30; // 30fps
export function tick(state: State): State { export function tick(state: State): State {
if (state.phase !== 'playing') return state; if (state.phase !== 'playing') return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
s.tick += 1; s.tick += 1;
// A choice event is awaiting the player's answer — freeze new events, keep resources flowing.
if (s.pendingEvent) {
s.nextEventTick += 1;
s.nextKaosTick += 1;
}
// Resource accumulation // Resource accumulation
s.andakt += s.andaktRate * s.troende * DT; s.andakt += s.andaktRate * s.troende * DT;
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * DT; s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * DT;
@@ -124,45 +163,143 @@ export function tick(state: State): State {
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * DT); s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * DT);
// Standard event // Standard event
if (s.tick >= s.nextEventTick) { if (!s.pendingEvent && s.tick >= s.nextEventTick) {
const ev = pickEvent(STANDARD_EVENTS, s); const ev = pickEvent(STANDARD_EVENTS, s);
if (ev) { if (ev) {
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
applyEffect(s, ev.effect);
s.usedEventIds.add(ev.id); s.usedEventIds.add(ev.id);
if (ev.choices?.length) {
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
s.pendingEvent = { event: ev, kind: 'event' };
} else {
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
applyEffect(s, ev.effect);
}
} }
s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60); 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 // Kaos event
if (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick) { if (!s.pendingEvent && (s.kaos >= KAOS_THRESHOLD || s.tick >= s.nextKaosTick)) {
const ev = pickEvent(KAOS_EVENTS, s); const ev = pickEvent(KAOS_EVENTS, s);
if (ev) { if (ev) {
s.log.unshift({ tick: s.tick, text: `${ev.text}`, kind: 'kaos' }); // 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' };
} else {
pushLog(s, { tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
applyEffect(s, ev.effect); applyEffect(s, ev.effect);
s.usedEventIds.add(ev.id); }
} }
s.kaos = Math.max(0, s.kaos - 80); s.kaos = Math.max(0, s.kaos - 80);
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120); 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 // Win / lose checks
if (s.ritual >= 100) { if (s.ritual >= 100) {
s.phase = 'won'; 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' }); pushLog(s, { 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) { } else if (s.hemlighet <= 0) {
s.phase = 'lost'; s.phase = 'lost';
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.'; 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' }); pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
}
return s;
}
const MAX_OFFLINE_SECONDS = 24 * 3600; // cap the catch-up so a week-long absence doesn't insta-win or insta-lose
const MIN_OFFLINE_SECONDS = 60; // don't bother with a log line for a quick tab-switch
// Approximates what `tick()` would have done, second-by-second, without
// actually running potentially hundreds of thousands of frames synchronously
// on load. Uses current rates as a flat average over the away period — close
// enough for a narrative idle game, not meant to be exact.
export function applyOfflineProgress(state: State, awayMs: number): State {
if (state.phase !== 'playing') return state;
const awaySec = Math.min(MAX_OFFLINE_SECONDS, awayMs / 1000);
if (awaySec < MIN_OFFLINE_SECONDS) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
s.andakt += s.andaktRate * s.troende * awaySec;
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec;
s.dread += s.dreadRate * awaySec;
if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * awaySec);
const kaosScale = 1 + (s.dread / 200);
const kaosGain = s.kaosRate * kaosScale * awaySec;
const kaosReleased = s.kaos + kaosGain >= 100;
s.kaos = Math.min(100, s.kaos + kaosGain);
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) * awaySec);
// A little flavor for the time away — non-choice events only, since nobody
// was there to answer. Roughly one per two hours away, capped low so the
// log doesn't get swamped after a long absence.
const eventRolls = Math.min(3, Math.floor(awaySec / 7200));
for (let i = 0; i < eventRolls; i++) {
const ev = pickEvent(STANDARD_EVENTS.filter(e => !e.choices?.length), s);
if (!ev) break;
s.usedEventIds.add(ev.id);
applyEffect(s, ev.effect);
}
if (kaosReleased) {
s.kaos = Math.max(0, s.kaos - 80);
}
const awayLabel =
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
'länge — för länge för att räkna';
const summary =
awaySec < 3600 ? `Ni var borta i ${awayLabel}. Källaren väntade, otålig.` :
awaySec < 6 * 3600 ? `Ni var borta i ${awayLabel}. Andakten samlades utan er.` :
awaySec < MAX_OFFLINE_SECONDS ? `Ni var borta i ${awayLabel}. Dread steg tyst i källaren medan ni sov.` :
`Ni var borta ${awayLabel}. Källaren har fortsatt utan er. Det är oklart hur.`;
pushLog(s, { tick: s.tick, text: summary, kind: 'system' });
if (s.hemlighet <= 0) {
s.phase = 'lost';
s.lostReason = 'Utredarna slog till medan ni var borta. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
} else if (s.ritual >= 100) {
s.phase = 'won';
pushLog(s, { tick: s.tick, text: 'Det är gjort — utan er i rummet. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
}
return s;
}
export function resolveEventChoice(state: State, choiceIndex: number): State {
if (!state.pendingEvent) return state;
const choice = state.pendingEvent.event.choices?.[choiceIndex];
if (!choice) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
applyEffect(s, choice.effect);
if (choice.flag) s.flags.add(choice.flag);
pushLog(s, { tick: s.tick, text: `${choice.resultText}`, kind: s.pendingEvent!.kind });
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.';
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
} else if (s.ritual >= 100) {
s.phase = 'won';
pushLog(s, { tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
} }
return s; return s;
} }
interface LocationVisit { interface LocationVisit {
text: string; texts: string[];
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>; effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
requiresArea?: keyof State['areas']; requiresArea?: keyof State['areas'];
lockedText: string; lockedText: string;
@@ -170,45 +307,111 @@ interface LocationVisit {
const LOCATION_VISITS: Record<string, LocationVisit> = { const LOCATION_VISITS: Record<string, LocationVisit> = {
rosengard: { rosengard: {
text: 'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt', 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',
'Hyresvärden har inte hört av sig på tre månader. Hyran dras ändå, varje månad, på utsatt dag. +andakt',
'Det droppar från taket på exakt samma ställe som förra veckan. Golvet under är torrt. +andakt',
],
effect: { andakt: 15 }, effect: { andakt: 15 },
requiresArea: 'rosengard', requiresArea: 'rosengard',
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.', lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
}, },
varnhem: { varnhem: {
text: 'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual', 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',
'Spårvagnen bromsar mjukt precis vid cirkelns kant, varje gång, utan att föraren verkar märka det. +dread +ritual',
'Någon har lagt blommor mitt i cirkeln. Det står inget namn på kortet. Datumet är nästa vecka. +dread +ritual',
],
effect: { dread: 8, ritual: 2 }, effect: { dread: 8, ritual: 2 },
requiresArea: 'varnhem', requiresArea: 'varnhem',
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.', lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
}, },
torso: { torso: {
text: 'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual', 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',
'Hissarna stannar på en våning som inte finns i receptionens skylt. Dörrarna öppnas ändå. Ingen går ut. +dread +ritual',
'Fasadens glas reflekterar en himmel som inte matchar den du står under. +dread +ritual',
],
effect: { dread: 15, ritual: 5 }, effect: { dread: 15, ritual: 5 },
requiresArea: 'torso', requiresArea: 'torso',
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.', lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
}, },
oresund: { oresund: {
text: 'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread', 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',
'Fiskarna vid stranden ligger stilla i vattenbrynet, andas fortfarande, väntar på något. +dread',
'Tullstationens skärmar visar samma bil passera om och om igen, med olika registreringsskyltar. +dread',
],
effect: { dread: 5 }, effect: { dread: 5 },
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.', lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
}, },
ribersborg: { ribersborg: {
text: 'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt', 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',
'Kallbadhusets bastu är på trots att ingen bokat den. Dörren är varm att röra vid, ingen sitter inne. +andakt',
'En strandpromenerare frågar om vägen till en adress som inte finns i Malmö. Du visar honom ändå. +andakt',
],
effect: { andakt: 8 }, effect: { andakt: 8 },
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.', lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
}, },
davidshall: { davidshall: {
text: 'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt', 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',
'Elmätaren i trapphuset räknar baklänges. Elbolaget har inte hört av sig om det. +andakt',
'En tidigare hyresgästs post kommer fortfarande. Adressaten flyttade ut 1971. +andakt',
],
effect: { andakt: 10 }, effect: { andakt: 10 },
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.', lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
}, },
stortorget: { stortorget: {
text: 'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos', 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',
'Ett gatumusikkorband spelar samma stycke de spelade förra gången du var här. De säger att de aldrig spelat det förut. +kaos',
'Duvorna flyger i en cirkel över torget som inte sluter sig, om och om igen, tills du slutar titta. +kaos',
],
effect: { kaos: 8 }, effect: { kaos: 8 },
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.', lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
}, },
mollan: {
texts: [
'Marknadsstånden på Möllan säljer samma fisk som alltid, men ingen minns vilket hav den kom från. Den smakar rätt ändå. +kaos',
'Ett gäng ungdomar sjunger något på ett språk som låter som tre olika språk samtidigt, i perfekt harmoni. +kaos',
'Fontänen på torget rinner uppåt i en sekund innan den rättar till sig. Ingen annan såg det. Du är säker på att du såg det. +kaos',
'En gatuförsäljare erbjuder en frukt du inte känner igen, från ett land ingen kan namnge. Den är söt. Den är för söt. +kaos',
'Graffitin på muren mot innergården ändras varje gång du vänder ryggen till, alltid samma motiv, aldrig samma vinkel. +kaos',
],
effect: { kaos: 10 },
lockedText: 'Möllevångstorget är fullt av liv, musik, dofter från femtio kök samtidigt. Det är svårt att höra sig själv tänka här. Det är kanske poängen.',
},
universitet: {
texts: [
'Biblioteket har ett rum för sällan efterfrågade samlingar. Din kod fungerar där ingen minns att du fått en. +andakt +dread',
'En föreläsningssal står tom men whiteboarden är full av ekvationer ingen lärare skrivit. Den sista raden är inte matematik. +andakt +dread',
'Doktoranderna på plan tre pratar lägre än vanligt när du går förbi. En av dem tittar upp för länge. +andakt +dread',
'Bibliotekets sökkatalog listar en avhandling om Skriften, publicerad om tre år. Ingen kan checka ut den förrän dess. +andakt +dread',
'Studenthälsan har en anmärkningsvärt hög andel patienter med sömnproblem i just den här korridoren. Ingen har satt det i samband med något. +andakt +dread',
],
effect: { andakt: 12, dread: 6 },
lockedText: 'Malmö universitet vid Niagara. Glas och stål och lysrör som surrar på en frekvens du inte borde kunna höra genom väggarna.',
},
}; };
export const VISITABLE_LOCATION_IDS = Object.keys(LOCATION_VISITS);
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps 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> } { export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
@@ -218,10 +421,10 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
const now = state.tick; const now = state.tick;
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns }; if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea]; const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
const text = areaLocked ? visit.lockedText : visit.text; const text = areaLocked ? visit.lockedText : visit.texts[Math.floor(Math.random() * visit.texts.length)];
if (!areaLocked) { if (!areaLocked) {
if (visit.effect.andakt) s.andakt += visit.effect.andakt; if (visit.effect.andakt) s.andakt += visit.effect.andakt;
@@ -231,8 +434,7 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
if (visit.effect.hemlighet) s.hemlighet = Math.max(0, Math.min(100, s.hemlighet + visit.effect.hemlighet)); 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' }); pushLog(s, { tick: now, text: `${text}`, kind: 'system' });
if (s.log.length > 40) s.log = s.log.slice(0, 40);
const newCooldowns = new Map(cooldowns); const newCooldowns = new Map(cooldowns);
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS); newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
@@ -246,6 +448,7 @@ export function saveState(state: State): void {
...state, ...state,
upgrades: [...state.upgrades], upgrades: [...state.upgrades],
usedEventIds: [...state.usedEventIds], usedEventIds: [...state.usedEventIds],
flags: [...state.flags],
log: state.log.slice(0, 20), log: state.log.slice(0, 20),
}; };
localStorage.setItem(SAVE_KEY, JSON.stringify(serial)); localStorage.setItem(SAVE_KEY, JSON.stringify(serial));
@@ -261,6 +464,12 @@ export function loadState(): State | null {
...d, ...d,
upgrades: new Set<string>(d.upgrades ?? []), upgrades: new Set<string>(d.upgrades ?? []),
usedEventIds: new Set<string>(d.usedEventIds ?? []), usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
logSeq: d.logSeq ?? 0,
// Saves from before offline progress existed won't have this — treat
// them as "just now" so an old save doesn't suddenly claim you were
// away since the Unix epoch.
lastRealTime: d.lastRealTime ?? Date.now(),
} as State; } as State;
} catch (_) { } catch (_) {
return null; return null;
@@ -280,13 +489,12 @@ export function buyUpgrade(state: State, id: string): State {
if (cost.dread && state.dread < cost.dread) return state; if (cost.dread && state.dread < cost.dread) return state;
if (cost.inflytande && state.inflytande < cost.inflytande) return state; if (cost.inflytande && state.inflytande < cost.inflytande) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) }; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
if (cost.andakt) s.andakt -= cost.andakt; if (cost.andakt) s.andakt -= cost.andakt;
if (cost.dread) s.dread -= cost.dread; if (cost.dread) s.dread -= cost.dread;
if (cost.inflytande) s.inflytande -= cost.inflytande; if (cost.inflytande) s.inflytande -= cost.inflytande;
upgrade.effect(s); upgrade.effect(s);
if (upgrade.once) s.upgrades.add(id); if (upgrade.once) s.upgrades.add(id);
s.log.unshift({ tick: s.tick, text: `${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' }); pushLog(s, { tick: s.tick, text: `${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
if (s.log.length > 40) s.log = s.log.slice(0, 40);
return s; return s;
} }

View File

@@ -7,12 +7,27 @@ export type EventEffect = Partial<{
ritual: number; ritual: number;
}>; }>;
export interface EventChoice {
text: string;
effect: EventEffect;
resultText: string;
flag?: string;
}
export interface GameEvent { export interface GameEvent {
id: string; id: string;
text: string; text: string;
effect: EventEffect; effect: EventEffect;
weight: number; weight: number;
requires?: Partial<{ minTroende: number; minDread: number; minRitual: number; unlockedArea: Area }>; requires?: Partial<{
minTroende: number;
minDread: number;
minRitual: number;
unlockedArea: Area;
flag: string;
completedEventId: string;
}>;
choices?: EventChoice[];
} }
export type Area = 'rosengard' | 'varnhem' | 'torso'; export type Area = 'rosengard' | 'varnhem' | 'torso';
@@ -157,7 +172,7 @@ export const STANDARD_EVENTS: GameEvent[] = [
// ── Anders Fager-register: vardagssvenska möter det ofattbara ── // ── Anders Fager-register: vardagssvenska möter det ofattbara ──
{ {
id: 'ica_maxi', 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ö.', text: 'Kassörskan på ICA Maxi vid Fosie har inte blinkat på tre veckor. Kunden bakom i kön räknade. Ingen anmälde det. Det är Malmö.',
effect: { dread: 7, kaos: 4 }, effect: { dread: 7, kaos: 4 },
weight: 9, weight: 9,
}, },
@@ -442,6 +457,357 @@ export const STANDARD_EVENTS: GameEvent[] = [
weight: 6, weight: 6,
requires: { minDread: 30 }, requires: { minDread: 30 },
}, },
// ── Choice events ───────────────────────────────────────────────────────────
{
id: 'choice_lindqvist_dream',
text: 'Broder Lindqvist vaknar mitt i mötet och säger att fisken har gett honom en instruktion. Han vägrar upprepa den högt. Han vill viska den till en av er, en och en.',
effect: {},
weight: 9,
choices: [
{
text: 'Lyssna på viskningen',
effect: { dread: 12, hemlighet: -4, andakt: 6 },
resultText: 'Ni lyssnar. Det ni hör går inte att glömma. Ni vet nu var Den Sovande vänder blicken härnäst.',
flag: 'lindqvist_listened',
},
{
text: 'Be honom tiga',
effect: { kaos: 6 },
resultText: 'Ni tystar honom. Han ler ändå, som om han redan sagt det som behövdes.',
flag: 'lindqvist_silenced',
},
],
},
{
id: 'lindqvist_chain_2a',
text: 'Broder Lindqvist har inte sovit på fyra dagar. Han säger att han inte längre behöver det. Han ritar samma karta om och om igen, alltid samma vinkel mot Öresund.',
effect: { dread: 10, andakt: 5 },
weight: 9,
requires: { flag: 'lindqvist_listened', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
},
{
id: 'lindqvist_chain_2b',
text: 'Broder Lindqvist har blivit tystlåten sedan ni bad honom tiga. Han deltar fortfarande, men han skriver nu ner sina drömmar istället för att dela dem. Ingen har läst anteckningsboken. Ingen vågar fråga.',
effect: { hemlighet: -6, kaos: 4 },
weight: 9,
requires: { flag: 'lindqvist_silenced', completedEventId: 'choice_lindqvist_dream', minDread: 25 },
},
{
id: 'lindqvist_chain_3',
text: 'Lindqvist ber att få gå ner till Öresund ensam, på natten, "för att svara ordentligt denna gång." Han har redan sina kläder på sig. Han väntar bara på ert tillstånd.',
effect: {},
weight: 8,
requires: { minDread: 45, completedEventId: 'choice_lindqvist_dream' },
choices: [
{
text: 'Låt honom gå',
effect: { dread: 20, ritual: 4, hemlighet: -8 },
resultText: 'Han går ner mot vattnet och försvinner in i dimman. Han kommer tillbaka i gryningen, torr, leende, och pratar inte längre om drömmar. Han pratar inte om något alls.',
},
{
text: 'Håll fast honom',
effect: { kaos: 15, andakt: 10 },
resultText: 'Ni håller kvar honom till gryningen. Han skriker att ni har gjort ett misstag. Han slutar skrika när solen går upp. Han säger aldrig varför.',
},
],
},
{
id: 'choice_skatteverket_audit',
text: 'Skatteverket har utökat sin utredning. En handläggare vid namn Persson vill besöka källaren personligen "för att förstå verksamhetens karaktär."',
effect: {},
weight: 8,
choices: [
{
text: 'Bjud in honom',
effect: { hemlighet: -15, andakt: 25 },
resultText: 'Persson dricker teet ni bjuder på. Han går därifrån med ett leende och ett obegripligt formulär ifyllt till er fördel. Han minns inte riktigt vägen hem.',
},
{
text: 'Avböj artigt',
effect: { hemlighet: 5, andakt: -5 },
resultText: 'Ni skickar ett artigt nej. Persson accepterar det, men skriver något i sina anteckningar som ni inte får se.',
},
],
},
{
id: 'hassan_ambition',
text: 'Syster Hassan har börjat föreslå ändringar i ritualens ordning. Hon säger att hon sett en bättre väg i en dröm. Hon säger det med en säkerhet ni inte kommer ihåg att hon haft förut.',
effect: {},
weight: 8,
requires: { minDread: 15 },
choices: [
{
text: 'Ge henne ansvar',
effect: { andakt: 12, dread: 6 },
resultText: 'Ni låter henne leda nästa moment. Cirkeln följer henne lättare än den någonsin följt er. Det känns bra. Det känns också som en förlust ni inte kan namnge.',
flag: 'hassan_empowered',
},
{
text: 'Behåll ordningen',
effect: { kaos: 5 },
resultText: 'Ni tackar för förslaget och fortsätter som förut. Hassan nickar och säger inget mer. Hon skriver fortfarande i sin anteckningsbok efteråt.',
flag: 'hassan_sidelined',
},
],
},
{
id: 'hassan_chain_2a',
text: 'Syster Hassan leder nu halva mötena. Hon har egna ord för Den Sovande, ord ingen av er lärt henne. Troende lyssnar på henne som de en gång lyssnade på er.',
effect: { andakt: 15, kaos: 5 },
weight: 8,
requires: { flag: 'hassan_empowered', completedEventId: 'hassan_ambition', minDread: 35 },
},
{
id: 'hassan_chain_2b',
text: 'Syster Hassans anteckningsbok är full nu. Hon slutar be om lov att tala. Det märks inte i mötena — hon sitter tyst och rak — men ni ser henne titta på dörren mot Värnhemstorget på ett nytt sätt.',
effect: { hemlighet: -5, dread: 8 },
weight: 8,
requires: { flag: 'hassan_sidelined', completedEventId: 'hassan_ambition', minDread: 35 },
},
{
id: 'hassan_chain_3',
text: 'Syster Hassan ber om att få genomföra en egen ritual, separat från er, på Turning Torso. Hon säger att Den Sovande bett om henne specifikt. Hon säger det utan att blinka.',
effect: {},
weight: 7,
requires: { minDread: 55, completedEventId: 'hassan_ambition', unlockedArea: 'torso' },
choices: [
{
text: 'Låt henne göra det',
effect: { dread: 22, ritual: 6, troende: -1 },
resultText: 'Hon går upp ensam i mörkret. Ni ser ljus i fönstren på våningar som inte har fönster. Hon kommer tillbaka annorlunda — mer er, mindre henne, i en riktning ingen kan sätta ord på.',
},
{
text: 'Neka henne',
effect: { kaos: 18, hemlighet: -4 },
resultText: 'Ni säger nej. Hon accepterar det med ett leende som inte når hennes ögon och går tillbaka till sin plats i cirkeln. Ni undrar länge efteråt om ni gjorde rätt val, eller bara det säkra.',
},
],
},
{
id: 'choice_new_recruit',
text: 'En ung kvinna har suttit utanför källaren tre kvällar i rad. Hon säger att hon hörde sången genom väggarna och att hon inte kan sluta tänka på den.',
effect: {},
weight: 9,
choices: [
{
text: 'Släpp in henne',
effect: { troende: 1, kaos: 8, hemlighet: -3 },
resultText: 'Hon kliver in och sätter sig utan att bli tillsagd var. Cirkeln känns mindre ensam. Den känns också mer sedd.',
},
{
text: 'Skicka bort henne',
effect: { hemlighet: 3, dread: 4 },
resultText: 'Ni ber henne gå. Hon nickar och försvinner in i mörkret. Ni hör sången fortsätta, en röst kort.',
},
],
},
{
id: 'choice_ikea_table_ritual',
text: 'Det blödande LACK-bordet har slutat blöda. Istället vibrerar det svagt när någon lägger sin hand på det, som om det väntar på en fråga.',
effect: {},
weight: 7,
requires: { minDread: 15 },
choices: [
{
text: 'Ställ en fråga',
effect: { dread: 14, ritual: 3, hemlighet: -2 },
resultText: 'Bordet svarar. Ingen av er pratar om svaret efteråt, men ni ritar alla samma symbol på varsitt papper, oberoende av varandra.',
},
{
text: 'Kasta ut det',
effect: { kaos: 10, andakt: 8 },
resultText: 'Ni bär ut bordet till återvinningen. Det står kvar på återvinningsgården i tre dagar innan någon vågar ta det. Ni vill inte veta vem.',
},
],
},
// ── 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.',
},
],
},
{
id: 'library_card',
text: 'Stadsbiblioteket har skickat en påminnelse om en bok ingen av er lånat. Titeln går inte att läsa högt utan att stamma. Förfallodatumet var för tre år sedan.',
effect: { dread: 6, kaos: 4 },
weight: 7,
},
{
id: 'malmo_taxi',
text: 'En taxichaufför känner igen er utan att ni sagt vart ni ska och kör direkt till källaren. Han tar ingen betalning. Han ber om något annat istället, artigt, som om det vore normalt.',
effect: { dread: 10, andakt: 10 },
weight: 6,
requires: { minTroende: 3 },
},
{
id: 'weather_wrong',
text: 'SMHI:s prognos för Malmö visar regn. Solen skiner. Appen uppdateras inte, oavsett hur många gånger ni drar ner den.',
effect: { kaos: 10 },
weight: 8,
},
{
id: 'apartment_silence',
text: 'Grannarna ovanför källaren har inte hörts på en vecka. Posten samlas i brevlådan. Ingen av er går och knackar.',
effect: { dread: 8, hemlighet: -4 },
weight: 6,
requires: { minDread: 20 },
},
{
id: 'choice_lost_child',
text: 'Ett barn kommer in i källaren genom en dörr ingen av er lämnat olåst och frågar efter sin mamma. Barnet verkar inte rädd. Det verkar snarare besviken över att träffa er istället.',
effect: {},
weight: 5,
requires: { minDread: 60 },
choices: [
{
text: 'Följ barnet ut och leta efter mamman',
effect: { hemlighet: 5, kaos: -10 },
resultText: 'Ni går ut tillsammans. Gatan är tom. Barnet släpper er hand utan ett ord och går åt ett håll ni inte följer.',
},
{
text: 'Fråga vad hon heter',
effect: { dread: 12, ritual: 3 },
resultText: 'Barnet säger ett namn ingen av er känner igen, men alla skriver ner exakt likadant utan att jämföra.',
},
],
},
{
id: 'malmo_arena_concert',
text: 'En utsåld konsert på Malmö Arena tystnar mitt i en låt, i exakt tolv sekunder, samtidigt som ert möte. Ingen i publiken verkar märka det. Klippet på nätet visar tystnaden. Ljudet finns kvar i videon om man sänker volymen till max.',
effect: { kaos: 12, dread: 8 },
weight: 5,
requires: { minTroende: 4 },
},
{
id: 'mollan_market_stall',
text: 'En marknadshandlare på Möllevångstorget säger att hon känner igen er, fast ingen av er har handlat hos henne förut. Hon lägger till en extra påse i kassen utan att fråga. Innehållet är inte på svenska.',
effect: { andakt: 12, kaos: 6 },
weight: 6,
requires: { unlockedArea: 'rosengard' },
},
{
id: 'university_manuscript_hit',
text: 'En sökning på universitetsbiblioteket ger en enda träff på en fras ur Skriften, i en avhandling om maritim folktro från 1958. Författaren dog samma år. Boken är utlånad. Den har alltid varit utlånad.',
effect: { dread: 14, ritual: 3 },
weight: 5,
requires: { minRitual: 15 },
},
{
id: 'choice_survey_call',
text: 'Statistiska centralbyrån ringer och frågar om er upplevda livskvalitet, på en skala ett till tio. Frågorna blir successivt mer specifika. Till slut frågar hen om ni "hör röster som inte är era egna, och om ni lyssnar."',
effect: {},
weight: 5,
requires: { minDread: 25 },
choices: [
{
text: 'Svara ärligt',
effect: { hemlighet: -15, dread: 5 },
resultText: 'Hen tackar för svaret utan att låta förvånad. Samtalet avslutas normalt. Ni är inte säkra på vem ni just pratade med.',
},
{
text: 'Ljug och lägg på',
effect: { hemlighet: 5, kaos: 8 },
resultText: 'Ni lägger på mitt i en mening. Telefonen ringer igen tre sekunder senare. Ni svarar inte.',
},
],
},
{
id: 'bus_2_driver',
text: 'Föraren på linje 2 hälsar er vid namn när ni kliver på, trots att ingen av er sagt det. Bussen kör en rutt som inte finns i tidtabellen och släpper av er exakt utanför källaren, tio minuter tidigare än ni räknat med.',
effect: { dread: 9, andakt: 6 },
weight: 6,
requires: { minDread: 15 },
},
{
id: 'malmo_live_reflection',
text: 'Glasfasaden på Malmö Live speglar en himmel med fel antal moln jämfört med den riktiga. Ni räknar två gånger. Talen stämmer inte överens med varandra heller.',
effect: { kaos: 9, dread: 6 },
weight: 6,
},
{
id: 'choice_old_photograph',
text: 'En gammal svartvit fotografi dyker upp i källaren, ingen minns var den kom ifrån. Den föreställer nio personer runt ett bord i exakt samma rum. Kläderna är från 1930-talet. En av personerna ser ut som Broder Lindqvist.',
effect: {},
weight: 4,
requires: { minDread: 70, minRitual: 1 },
choices: [
{
text: 'Visa fotografiet för Lindqvist',
effect: { dread: 15, andakt: 10 },
resultText: 'Lindqvist tittar länge. Han säger att han känner igen rummet men inte människorna. Han lägger fotografiet i sin ficka istället för att lämna tillbaka det.',
},
{
text: 'Göm fotografiet',
effect: { kaos: 12, hemlighet: -5 },
resultText: 'Ni lägger det längst ner i en låda. Nästa vecka ligger det på bordet igen, överst.',
},
],
},
]; ];
// ── Kaos events (Henderson-tier) ───────────────────────────────────────────── // ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
@@ -449,9 +815,10 @@ export const STANDARD_EVENTS: GameEvent[] = [
export const KAOS_EVENTS: GameEvent[] = [ export const KAOS_EVENTS: GameEvent[] = [
{ {
id: 'k_goran_dog', id: 'k_goran_dog',
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantingen. Tvärtom. Bamse leder nu det andra kvartalets böner.', text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantandet. Tvärtom. Bamse leder nu det andra kvartalets böner.',
effect: { kaos: -40, andakt: 20, troende: 1 }, effect: { kaos: -40, andakt: 20, troende: 1 },
weight: 10, weight: 10,
requires: { completedEventId: 'discord_goran' },
}, },
{ {
id: 'k_influencer', id: 'k_influencer',
@@ -479,7 +846,7 @@ export const KAOS_EVENTS: GameEvent[] = [
}, },
{ {
id: 'k_rival_cult', id: 'k_rival_cult',
text: 'En rivaliserande sekt med bas i Hässleholm försöker summera en annan entitet. De summerar fel. De summerar IKEA:s kundtjänst. Det är värre.', text: 'En rivaliserande sekt med bas i Hässleholm försöker mana fram en annan entitet. De manar fel. De manar fram IKEA:s kundtjänst. Det är värre.',
effect: { kaos: -45, dread: 50, hemlighet: 10 }, effect: { kaos: -45, dread: 50, hemlighet: 10 },
weight: 7, weight: 7,
}, },
@@ -516,6 +883,7 @@ export const KAOS_EVENTS: GameEvent[] = [
text: 'Göran har frågat om han kan "ta lite mer ansvar" i föreningen. Vi har utnämnt honom till Väktare av det Tredje Sigillet. Han tolkar det som kassör. Det är ungefär rätt.', text: 'Göran har frågat om han kan "ta lite mer ansvar" i föreningen. Vi har utnämnt honom till Väktare av det Tredje Sigillet. Han tolkar det som kassör. Det är ungefär rätt.',
effect: { kaos: -30, andakt: 30, hemlighet: 5 }, effect: { kaos: -30, andakt: 30, hemlighet: 5 },
weight: 8, weight: 8,
requires: { completedEventId: 'discord_goran' },
}, },
{ {
id: 'k_no_survivors', id: 'k_no_survivors',
@@ -529,6 +897,7 @@ export const KAOS_EVENTS: GameEvent[] = [
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.', 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 }, effect: { kaos: -45, andakt: 35, troende: 2 },
weight: 7, weight: 7,
requires: { completedEventId: 'pressbyraan' },
}, },
{ {
id: 'k_skinwalker_bus', id: 'k_skinwalker_bus',
@@ -539,7 +908,7 @@ export const KAOS_EVENTS: GameEvent[] = [
}, },
{ {
id: 'k_fager_library', 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.', 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. Returdatumet är omöjligt.',
effect: { kaos: -55, ritual: 12, dread: 20 }, effect: { kaos: -55, ritual: 12, dread: 20 },
weight: 5, weight: 5,
requires: { minDread: 30 }, requires: { minDread: 30 },
@@ -549,7 +918,7 @@ export const KAOS_EVENTS: GameEvent[] = [
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.', 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 }, effect: { kaos: -40, hemlighet: 20, ritual: 5 },
weight: 6, weight: 6,
requires: { minDread: 45 }, requires: { minDread: 45, completedEventId: 'k_journalist' },
}, },
{ {
id: 'k_kult_archon', id: 'k_kult_archon',
@@ -567,7 +936,7 @@ export const KAOS_EVENTS: GameEvent[] = [
}, },
{ {
id: 'k_malmofestivalen', id: 'k_malmofestivalen',
text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantingen tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.', text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantandet tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.',
effect: { kaos: -35, andakt: 40, troende: 2, hemlighet: -12 }, effect: { kaos: -35, andakt: 40, troende: 2, hemlighet: -12 },
weight: 7, weight: 7,
}, },
@@ -606,4 +975,99 @@ export const KAOS_EVENTS: GameEvent[] = [
weight: 5, weight: 5,
requires: { minDread: 40 }, 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 },
},
{
id: 'k_mall_muzak',
text: 'Bakgrundsmusiken i Triangeln har bytts ut mot en enda ackordföljd som upprepas i sex timmar utan paus. Vaktbolaget säger att de inte kan hitta strömbrytaren. Kunderna handlar mer än vanligt.',
effect: { kaos: -40, andakt: 25 },
weight: 8,
},
{
id: 'k_ferry_returns',
text: 'En färja som togs ur trafik 2019 lade till vid hamnen i natt, i tid och enligt en tidtabell som inte längre existerar. Ingen gick av. Den lade ut igen innan personalen hann kontrollera den.',
effect: { kaos: -55, dread: 40 },
weight: 5,
requires: { minDread: 50 },
},
{
id: 'k_choir_practice',
text: 'Malmö Kammarkör har börjat repetera ett stycke ingen av dem minns att de fått noter till. Dirigenten säger att det "kändes rätt att göra." Konserten är redan fullbokad.',
effect: { kaos: -35, ritual: 4, andakt: 15 },
weight: 7,
requires: { minRitual: 10 },
},
{
id: 'k_construction_halt',
text: 'Ett byggprojekt vid Västra Hamnen har stannat av utan förklaring. Arbetarna säger att marken "kändes fel" på ett sätt ingen av dem kunde beskriva för facket. Platsen står orörd sedan dess.',
effect: { kaos: -40, dread: 30, hemlighet: 10 },
weight: 6,
requires: { minDread: 35 },
},
{
id: 'k_mollan_food_truck',
text: 'En food truck som ingen beställt eller godkänt har parkerat på Möllevångstorget och serverar en rätt som inte står på skylten till alla som frågar. Kommunen kan inte hitta några tillståndspapper. Truckens ägare finns inte i något register.',
effect: { kaos: -40, troende: 2, andakt: 20 },
weight: 7,
requires: { minTroende: 3 },
},
{
id: 'k_university_thesis_defense',
text: 'En doktorandförsvar vid universitetet avbryts när opponenten ställer en fråga ingen i publiken hör men alla ser avhandlingsförfattaren svara på. Betyget blir godkänt med beröm. Ingen av professorerna minns frågan efteråt.',
effect: { kaos: -45, ritual: 6, dread: 20 },
weight: 5,
requires: { minRitual: 10 },
},
{
id: 'k_all_clocks_torso',
text: 'Alla digitala klockor inom synhåll från Turning Torso har visat samma sekund i över en timme. Analoga klockor går som vanligt. Ingen teknisk avdelning kan förklara det, och ingen verkar särskilt intresserad av att försöka.',
effect: { kaos: -60, dread: 50, ritual: 8 },
weight: 4,
requires: { unlockedArea: 'torso', minDread: 70 },
},
{
id: 'k_ribersborg_tide',
text: 'Tidvattnet vid Ribersborg går ut längre än någonsin uppmätt, i tio minuter, och avslöjar stenformationer på havsbotten som inte finns på någon sjökarta. Sedan kommer vattnet tillbaka som om inget hänt. Fotografierna på nätet försvinner inom en timme.',
effect: { kaos: -55, dread: 45, ritual: 6 },
weight: 4,
requires: { minDread: 90 },
},
]; ];

View File

@@ -1,16 +1,56 @@
import './style.css'; import './style.css';
import { createState, tick, buyUpgrade, visitLocation, saveState, loadState, clearSave } from './engine.ts'; import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave, applyOfflineProgress, VISITABLE_LOCATION_IDS } from './engine.ts';
import { initUI, renderState } from './ui.ts'; import { initUI, renderState, setPausedUI, showLocationArt } from './ui.ts';
import { initMap, updateMap } from './map.ts'; import { initMap, updateMap, LOCATION_INFO } from './map.ts';
let state = loadState() ?? createState(); let state = loadState() ?? createState();
// The cult doesn't pause just because the tab isn't in front of you —
// catch up on what happened while you were gone, once, on load.
if (state.phase === 'playing') {
state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now()));
}
let visitCooldowns = new Map<string, number>(); let visitCooldowns = new Map<string, number>();
let paused = false;
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; const SAVE_INTERVAL_MS = 10_000;
let lastSaveTime = 0; let lastSaveTime = 0;
const WHISPER_INTERVAL_TICKS = 30 * 45; // 45s at 30fps
const WHISPER_RETRY_TICKS = 30 * 5; // retry sooner if every location was on cooldown
let nextWhisperTick = 0;
const BASE_TITLE = document.title;
const HAUNT_MESSAGES = ['Han väntar…', 'Vakna honom…', 'Havet kallar…', 'Kom tillbaka…', '☿ Källaren minns ☿'];
let hauntTimer: number | undefined;
let hauntIndex = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (state.phase !== 'playing') return;
hauntIndex = 0;
hauntTimer = window.setInterval(() => {
document.title = HAUNT_MESSAGES[hauntIndex % HAUNT_MESSAGES.length]!;
hauntIndex += 1;
}, 2500);
} else {
if (hauntTimer !== undefined) { window.clearInterval(hauntTimer); hauntTimer = undefined; }
document.title = BASE_TITLE;
// Coming back to the tab is itself a kind of "return" — re-check for
// offline progress in case the tab was merely backgrounded, not
// reloaded (requestAnimationFrame throttles hard in hidden tabs, so
// real time can drift a long way ahead of simulated game time).
if (state.phase === 'playing') {
state = applyOfflineProgress(state, Date.now() - (state.lastRealTime ?? Date.now()));
state = { ...state, lastRealTime: Date.now() };
renderState(state);
}
}
});
function start(): void { function start(): void {
const app = document.getElementById('app')!; const app = document.getElementById('app')!;
@@ -22,7 +62,11 @@ function start(): void {
visitCooldowns = new Map(); visitCooldowns = new Map();
document.getElementById('overlay')!.classList.remove('visible'); document.getElementById('overlay')!.classList.remove('visible');
renderState(state); renderState(state);
} },
(index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); },
() => { state = startGame(state); renderState(state); saveState(state); },
() => { state = abandonGame(state); renderState(state); saveState(state); },
() => { paused = !paused; setPausedUI(paused); }
); );
initMap('map', (locationId) => { initMap('map', (locationId) => {
@@ -30,6 +74,9 @@ function start(): void {
state = result.state; state = result.state;
visitCooldowns = result.cooldowns; visitCooldowns = result.cooldowns;
renderState(state); renderState(state);
const info = LOCATION_INFO[locationId];
if (info) showLocationArt(locationId, info.label, info.blurb);
}); });
renderState(state); renderState(state);
@@ -39,13 +86,32 @@ function start(): void {
function loop(now: number): void { function loop(now: number): void {
if (now - lastTime >= FRAME_MS) { if (now - lastTime >= FRAME_MS) {
lastTime = now; lastTime = now;
if (state.phase === 'playing') { if (state.phase === 'playing' && !paused) {
state = tick(state); state = tick(state);
// "Viskningen leder er" — once purchased, the cult moves on its own
// occasionally without a click. Skip a location currently on
// cooldown rather than burning the whole interval on a no-op visit.
if (state.upgrades.has('whisper_guidance') && state.tick >= nextWhisperTick) {
const tickNow = state.tick;
const available = VISITABLE_LOCATION_IDS.filter(id => (visitCooldowns.get(id) ?? 0) <= tickNow);
if (available.length) {
const id = available[Math.floor(Math.random() * available.length)]!;
const result = visitLocation(state, id, visitCooldowns);
state = result.state;
visitCooldowns = result.cooldowns;
nextWhisperTick = state.tick + WHISPER_INTERVAL_TICKS;
} else {
nextWhisperTick = state.tick + WHISPER_RETRY_TICKS;
}
}
renderState(state); renderState(state);
updateMap(state.dread, state.areas); updateMap(state.dread, state.areas);
} }
if (now - lastSaveTime >= SAVE_INTERVAL_MS) { if (now - lastSaveTime >= SAVE_INTERVAL_MS) {
lastSaveTime = now; lastSaveTime = now;
state = { ...state, lastRealTime: Date.now() };
saveState(state); saveState(state);
} }
} }

View File

@@ -10,15 +10,62 @@ export interface MapMarker {
dreadLevel: number; // 0-3 dreadLevel: number; // 0-3
} }
const LOCATIONS: Omit<MapMarker, 'dreadLevel'>[] = [ interface LocationInfo {
{ id: 'rosengard', lat: 55.5894, lng: 13.0414, label: 'Källaren i Rosengård' }, lat: number;
{ id: 'varnhem', lat: 55.5982, lng: 13.0058, label: 'Värnhemstorget' }, lng: number;
{ id: 'torso', lat: 55.6130, lng: 12.9758, label: 'Turning Torso' }, label: string;
{ id: 'oresund', lat: 55.5789, lng: 12.9200, label: 'Øresundsbron' }, // A standing description of the place itself, shown in the art modal —
{ id: 'ribersborg',lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden' }, // distinct from LOCATION_VISITS' randomized per-visit outcome text in
{ id: 'davidshall',lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan' }, // engine.ts, which changes every time. This doesn't.
{ id: 'stortorget',lat: 55.6047, lng: 13.0038, label: 'Stortorget' }, blurb: string;
]; }
const LOCATIONS: Record<string, LocationInfo> = {
rosengard: {
lat: 55.5894, lng: 13.0414, label: 'Källaren i Rosengård',
blurb: 'Ett källarförråd som blev något annat. Ingen av er minns exakt när. Hyresvärden vet fortfarande inte om det.',
},
varnhem: {
lat: 55.5982, lng: 13.0058, label: 'Värnhemstorget',
blurb: 'Ett vanligt torg med spårvagnshållplats och krita på asfalten som inte suddas, hur mycket det än regnar.',
},
torso: {
lat: 55.6130, lng: 12.9758, label: 'Turning Torso',
blurb: 'Calatrava-Santiago ritade byggnaden som en vridande ryggrad. Det visade sig inte vara en metafor.',
},
oresund: {
lat: 55.5789, lng: 12.9200, label: 'Øresundsbron',
blurb: 'Bron till Danmark. Vattnet under den är stillare än det borde vara, oftare än det borde vara.',
},
ribersborg: {
lat: 55.6063, lng: 12.9652, label: 'Ribersborgsstranden',
blurb: 'En strand med kallbadhus och änder som står stilla i vattenbrynet, vända mot samma håll, oavsett väder.',
},
davidshall: {
lat: 55.5963, lng: 13.0021, label: 'Davidshallsgatan',
blurb: 'Ett bostadshus med kakel från 1923 i trapphuset. Kallt att röra vid, oavsett årstid.',
},
stortorget: {
lat: 55.6047, lng: 13.0038, label: 'Stortorget',
blurb: 'Stadens gamla torg, statyn i mitten, klockor på fasaderna runtom som sällan visar exakt samma tid.',
},
mollan: {
lat: 55.5904, lng: 13.0203, label: 'Möllevångstorget',
blurb: 'Marknad, dofter från femtio kök samtidigt, musik från tre högtalare som aldrig riktigt spelar samma låt.',
},
universitet: {
lat: 55.6083, lng: 12.9974, label: 'Malmö universitet',
blurb: 'Bibliotek och föreläsningssalar. Ett rum för sällan efterfrågade samlingar som er kod alltid fungerar till.',
},
};
export const LOCATION_INFO: Record<string, { label: string; blurb: string }> =
Object.fromEntries(Object.entries(LOCATIONS).map(([id, l]) => [id, { label: l.label, blurb: l.blurb }]));
// Optional tiny preview shown in each location's tooltip — same
// missing-asset-is-fine convention as the character portraits. Path is
// tried once per marker; a 404 just means the tooltip falls back to text.
const THUMB = (id: string): string => `/img/locations/${id}.png`;
type LeafletMap = ReturnType<typeof L.map>; type LeafletMap = ReturnType<typeof L.map>;
type LeafletMarker = ReturnType<typeof L.circleMarker>; type LeafletMarker = ReturnType<typeof L.circleMarker>;
@@ -47,16 +94,23 @@ export function initMap(containerId: string, onClick: (id: string) => void): voi
maxZoom: 18, maxZoom: 18,
}).addTo(map); }).addTo(map);
for (const loc of LOCATIONS) { for (const [id, loc] of Object.entries(LOCATIONS)) {
const m = L.circleMarker([loc.lat, loc.lng], { const m = L.circleMarker([loc.lat, loc.lng], {
radius: 6, radius: 6,
color: '#1a1a2e', color: '#1a1a2e',
fillColor: '#1a1a2e', fillColor: '#1a1a2e',
fillOpacity: 0.3, fillOpacity: 0.3,
weight: 1, weight: 1,
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false }); }).addTo(map).bindTooltip(
m.on('click', () => onClick(loc.id)); `<div class="map-tooltip-inner">
markers.set(loc.id, m); <img class="map-tooltip-thumb" src="${THUMB(id)}" alt=""
onerror="this.remove()">
<span>${loc.label}</span>
</div>`,
{ className: 'map-tooltip', permanent: false },
);
m.on('click', () => onClick(id));
markers.set(id, m);
} }
} }
@@ -71,6 +125,8 @@ export function updateMap(dread: number, areas: { rosengard: boolean; varnhem: b
ribersborg: Math.min(2, Math.floor(dread / 45)), ribersborg: Math.min(2, Math.floor(dread / 45)),
davidshall: Math.min(1, Math.floor(dread / 70)), davidshall: Math.min(1, Math.floor(dread / 70)),
stortorget: Math.min(2, Math.floor(dread / 55)), stortorget: Math.min(2, Math.floor(dread / 55)),
mollan: Math.min(2, Math.floor(dread / 48)),
universitet: Math.min(2, Math.floor(dread / 65)),
}; };
for (const [id, marker] of markers) { for (const [id, marker] of markers) {

View File

@@ -36,6 +36,29 @@ body {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
gap: 0; gap: 0;
filter: hue-rotate(calc(var(--dread-intensity, 0) * -35deg)) saturate(calc(1 + var(--dread-intensity, 0) * 0.7));
transition: filter 2s ease;
}
#app.kaos-glitch { animation: kaos-glitch-shake 0.4s steps(2, end) 2; }
@keyframes kaos-glitch-shake {
0% { transform: translate(0, 0); }
20% { transform: translate(-2px, 1px); filter: hue-rotate(20deg); }
40% { transform: translate(2px, -1px); filter: hue-rotate(-15deg); }
60% { transform: translate(-1px, 2px); filter: hue-rotate(10deg); }
80% { transform: translate(1px, -2px); }
100% { transform: translate(0, 0); }
}
#danger-vignette {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999; /* below modals (1000/1001), above map/log */
background: radial-gradient(ellipse at center, transparent 55%, var(--danger) 150%);
opacity: var(--hemlighet-danger, 0);
transition: opacity 1.5s ease;
} }
/* ── Header ── */ /* ── Header ── */
@@ -138,12 +161,26 @@ body {
color: var(--text) !important; color: var(--text) !important;
font-family: inherit !important; font-family: inherit !important;
font-size: 11px !important; font-size: 11px !important;
padding: 3px 8px !important; padding: 4px !important;
border-radius: 2px !important; border-radius: 2px !important;
box-shadow: none !important; box-shadow: none !important;
} }
.map-tooltip::before { display: none !important; } .map-tooltip::before { display: none !important; }
.map-tooltip-inner {
display: flex;
align-items: center;
gap: 6px;
}
.map-tooltip-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border: 1px solid var(--border);
flex-shrink: 0;
}
.map-tooltip-inner span { padding-right: 2px; }
/* ── Upgrades ── */ /* ── Upgrades ── */
.upgrade-btn { .upgrade-btn {
display: block; display: block;
@@ -207,22 +244,85 @@ body {
.log-entry.system { color: var(--dim); font-style: italic; } .log-entry.system { color: var(--dim); font-style: italic; }
.log-entry.upgrade { color: var(--accent); } .log-entry.upgrade { color: var(--accent); }
/* ── Intro screen ── */
#intro-screen {
display: none;
position: fixed;
inset: 0;
z-index: 1002; /* above help modal and overlay */
background-size: cover;
background-position: center;
background-color: var(--bg);
align-items: center;
justify-content: flex-end;
}
#intro-screen.visible { display: flex; }
.intro-scrim {
background: linear-gradient(180deg, rgba(6,6,14,0) 0%, rgba(6,6,14,0.75) 35%, rgba(6,6,14,0.97) 100%);
width: 100%;
height: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 18vh 20px 8vh;
text-align: center;
gap: 12px;
}
.intro-title {
font-size: 40px;
font-weight: 700;
letter-spacing: 8px;
color: var(--dread);
text-shadow: 0 0 30px rgba(204, 68, 204, 0.5);
}
.intro-sub {
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--dim);
}
.intro-flavor {
max-width: 480px;
color: var(--text);
font-size: 12px;
line-height: 1.9;
margin: 8px 20px 4px;
}
/* ── Overlays ── */ /* ── Overlays ── */
#overlay { #overlay {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(6, 6, 14, 0.92); background-color: var(--bg);
background-size: cover;
background-position: center;
z-index: 1000; z-index: 1000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column;
gap: 16px;
text-align: center;
} }
#overlay.visible { display: flex; } #overlay.visible { display: flex; }
.overlay-scrim {
background: radial-gradient(ellipse 55% 42% at center, rgba(6,6,14,0.8) 0%, rgba(6,6,14,0.5) 55%, rgba(6,6,14,0.12) 80%, transparent 100%);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
text-align: center;
padding: 20px;
}
.overlay-title { .overlay-title {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
@@ -269,9 +369,30 @@ body {
.phase-tag.mid { color: var(--accent); border-color: var(--accent); } .phase-tag.mid { color: var(--accent); border-color: var(--accent); }
.phase-tag.late { color: var(--dread); border-color: var(--dread); } .phase-tag.late { color: var(--dread); border-color: var(--dread); }
/* ── Help button ── */ .pause-tag {
.help-btn { margin-left: 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--kaos);
}
/* Dim the world a touch while paused, so it reads as "held" rather than "broken" */
#app.paused #map-container,
#app.paused #panel-left,
#app.paused #panel-right {
filter: grayscale(0.5) brightness(0.75);
transition: filter 0.3s ease;
}
/* ── Help / header buttons ── */
.header-actions {
margin-left: auto; margin-left: auto;
display: flex;
gap: 8px;
}
.help-btn {
background: none; background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--dim); color: var(--dim);
@@ -283,6 +404,7 @@ body {
transition: border-color 0.15s, color 0.15s; transition: border-color 0.15s, color 0.15s;
} }
.help-btn:hover { border-color: var(--accent); color: var(--accent); } .help-btn:hover { border-color: var(--accent); color: var(--accent); }
#abandon-btn:hover { border-color: var(--danger); color: var(--danger); }
/* ── Help modal ── */ /* ── Help modal ── */
.help-modal { .help-modal {
@@ -343,6 +465,18 @@ body {
gap: 20px; gap: 20px;
} }
.event-modal-text {
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
.event-modal-choices {
display: flex;
flex-direction: column;
gap: 8px;
}
.help-section { display: flex; flex-direction: column; gap: 8px; } .help-section { display: flex; flex-direction: column; gap: 8px; }
.help-section-title { .help-section-title {
@@ -374,6 +508,82 @@ body {
font-style: italic; font-style: italic;
} }
#characters-modal-inner { width: min(860px, 94vw); }
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.character-card {
background: var(--bg);
border: 1px solid var(--border);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.character-portrait {
width: 100%;
aspect-ratio: 4 / 5;
background: #08080f;
border: 1px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.character-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(0.15) contrast(1.05);
}
.character-portrait-fallback,
.locked-portrait {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--dim);
letter-spacing: 0.05em;
}
.character-card.locked .character-portrait.locked-portrait {
background: repeating-linear-gradient(135deg, #08080f, #08080f 6px, #0d0d1a 6px, #0d0d1a 12px);
border-style: dashed;
}
.character-card.locked .character-name,
.character-card.locked .character-role {
color: var(--dim);
}
.character-name {
font-size: 12px;
font-weight: 700;
color: var(--accent);
}
.character-role {
font-size: 10px;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.character-bio {
font-size: 11px;
line-height: 1.6;
color: var(--text);
}
.help-changelog-entry { .help-changelog-entry {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -391,6 +601,78 @@ body {
margin-left: 6px; margin-left: 6px;
} }
.archive-toggle {
display: flex;
gap: 0;
margin-bottom: 12px;
border: 1px solid var(--border);
width: fit-content;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.archive-toggle-btn {
appearance: none;
border: none;
background: transparent;
color: var(--dim);
padding: 7px 12px;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
}
.archive-toggle-btn + .archive-toggle-btn { border-left: 1px solid var(--border); }
.archive-toggle-btn.active { background: var(--accent); color: #0d0d1a; }
.character-card:not(.locked) .character-portrait { cursor: pointer; }
.character-card:not(.locked) .character-portrait:hover img { filter: grayscale(0) contrast(1.1) brightness(1.08); }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(4, 4, 10, 0.94);
z-index: 1100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
padding: 40px;
}
.lightbox.visible { display: flex; }
.lightbox img {
max-width: min(90vw, 720px);
max-height: 78vh;
object-fit: contain;
border: 1px solid var(--border);
}
.lightbox-caption {
font-size: 12px;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.lightbox-blurb {
font-size: 12px;
color: var(--dim);
line-height: 1.7;
max-width: 56ch;
text-align: center;
font-style: italic;
}
.lightbox-blurb:empty { display: none; }
.lightbox-close {
position: absolute;
top: 20px;
right: 24px;
background: none;
border: none;
color: var(--dim);
font-size: 20px;
cursor: pointer;
}
.lightbox-close:hover { color: var(--text); }
@media (max-width: 900px) { @media (max-width: 900px) {
#app { grid-template-columns: 1fr; } #app { grid-template-columns: 1fr; }
.panel { border-right: none; border-bottom: 1px solid var(--border); } .panel { border-right: none; border-bottom: 1px solid var(--border); }

314
src/ui.ts
View File

@@ -1,14 +1,41 @@
import type { State } from './engine.ts'; import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts'; import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts'; import { VERSION, CHANGELOG } from './data/version.ts';
import { CHARACTERS } from './characters.ts';
export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRestart: () => void): void { export function initUI(
app: HTMLElement,
onUpgrade: (id: string) => void,
onRestart: () => void,
onEventChoice: (index: number) => void,
onStart: () => void,
onAbandon: () => void,
onTogglePause: () => void,
): void {
app.innerHTML = ` app.innerHTML = `
<div id="header"> <div id="header">
<div class="brand">DJUPET</div> <div class="brand">DJUPET</div>
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span></div> <div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
<div class="header-actions">
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
<button id="characters-btn" class="help-btn" title="Sällskapet">Sällskapet</button>
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button> <button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div> </div>
</div>
<div id="intro-screen" style="background-image:url('/img/intro.webp')">
<div class="intro-scrim">
<div class="intro-title">DJUPET</div>
<div class="intro-sub">Malmö, Sverige</div>
<p class="intro-flavor">
Nio stolar i en fuktig källare i Rosengård. En av dem är alltid lite för varm.<br>
Ni har hört sången i väggarna. Den Sovande har inte hört er ännu.<br>
Det är dags att ändra på det.
</p>
<button class="overlay-btn" id="start-btn">Börja ritualen</button>
</div>
</div>
<div id="help-modal" class="help-modal"> <div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner"> <div class="help-modal-inner" id="help-modal-inner">
@@ -50,6 +77,23 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
</div> </div>
</div> </div>
<div id="characters-modal" class="help-modal">
<div class="help-modal-inner" id="characters-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Sällskapet</span>
<button id="characters-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<div class="archive-toggle" role="group" aria-label="Visningsläge">
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
</div>
<p class="help-flavor" id="characters-hint" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
<div id="characters-grid" class="characters-grid"></div>
</div>
</div>
</div>
<div class="panel" id="panel-left"> <div class="panel" id="panel-left">
<div class="panel-title">Resurser</div> <div class="panel-title">Resurser</div>
@@ -103,12 +147,21 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
</div> </div>
<div class="panel" id="panel-right"> <div class="panel" id="panel-right">
<div class="panel-title">Aktiva områden</div> <div class="panel-title">Ritualens kärnplatser</div>
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;"> <div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
<div id="area-rosengard">○ Rosengård</div> <div id="area-rosengard">○ Rosengård</div>
<div id="area-varnhem">○ Värnhemstorget</div> <div id="area-varnhem">○ Värnhemstorget</div>
<div id="area-torso">○ Turning Torso</div> <div id="area-torso">○ Turning Torso</div>
</div> </div>
<div style="margin-top:16px;">
<div class="panel-title">Övriga platser</div>
<div style="font-size:11px;color:var(--dim);line-height:2;">
<div>Øresundsbron</div>
<div>Ribersborgsstranden</div>
<div>Davidshallsgatan</div>
<div>Stortorget</div>
</div>
</div>
<div style="margin-top:20px;" id="investigator-section"> <div style="margin-top:20px;" id="investigator-section">
<div class="panel-title">Utredarna</div> <div class="panel-title">Utredarna</div>
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div> <div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
@@ -116,13 +169,43 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
</div> </div>
<div id="overlay"> <div id="overlay">
<div class="overlay-scrim">
<div class="overlay-title" id="overlay-title"></div> <div class="overlay-title" id="overlay-title"></div>
<div class="overlay-body" id="overlay-body"></div> <div class="overlay-body" id="overlay-body"></div>
<button class="overlay-btn" id="overlay-btn">Börja om</button> <button class="overlay-btn" id="overlay-btn">Börja om</button>
</div> </div>
</div>
<div id="danger-vignette"></div>
<div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner">
<div class="help-modal-body">
<div class="event-modal-text" id="event-modal-text"></div>
<div class="event-modal-choices" id="event-modal-choices"></div>
</div>
</div>
</div>
<div id="lightbox" class="lightbox">
<button id="lightbox-close" class="lightbox-close" aria-label="Stäng">✕</button>
<img id="lightbox-img" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
<p id="lightbox-blurb" class="lightbox-blurb"></p>
</div>
`; `;
document.getElementById('overlay-btn')!.addEventListener('click', onRestart); document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
document.getElementById('start-btn')!.addEventListener('click', onStart);
document.getElementById('abandon-btn')!.addEventListener('click', () => {
if (window.confirm('Överge kulten? Cirkeln bryts och ni går skilda vägar.')) onAbandon();
});
document.getElementById('pause-btn')!.addEventListener('click', onTogglePause);
document.getElementById('event-modal-choices')!.addEventListener('click', e => {
const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null;
if (btn?.dataset['choice'] !== undefined) onEventChoice(Number(btn.dataset['choice']));
});
const helpModal = document.getElementById('help-modal')!; const helpModal = document.getElementById('help-modal')!;
const helpInner = document.getElementById('help-modal-inner')!; const helpInner = document.getElementById('help-modal-inner')!;
@@ -131,6 +214,46 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); }); helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) helpModal.classList.toggle('visible'); }); document.addEventListener('keydown', (e) => { if (e.key === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) helpModal.classList.toggle('visible'); });
const charactersModal = document.getElementById('characters-modal')!;
const charactersInner = document.getElementById('characters-modal-inner')!;
document.getElementById('characters-btn')!.addEventListener('click', () => {
renderCharacterGrid(latestState);
charactersModal.classList.toggle('visible');
});
document.getElementById('characters-close')!.addEventListener('click', () => charactersModal.classList.remove('visible'));
charactersModal.addEventListener('click', (e) => { if (!charactersInner.contains(e.target as Node)) charactersModal.classList.remove('visible'); });
document.querySelectorAll<HTMLButtonElement>('.archive-toggle-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.archive-toggle-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
archiveMode = btn.dataset['archive'] === 'full';
document.getElementById('characters-hint')!.textContent = archiveMode
? 'Fullständigt arkiv — alla porträtt, oavsett hur långt ni kommit. Klicka för att se dem i fullstorlek.'
: 'De som suttit vid bordet. Vissa längre än andra.';
renderCharacterGrid(latestState);
});
});
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
document.getElementById('characters-grid')!.addEventListener('click', (e) => {
const portrait = (e.target as Element).closest('.character-card:not(.locked) .character-portrait');
if (!portrait) return;
const card = portrait.closest<HTMLElement>('[data-char]');
const img = portrait.querySelector('img');
if (!card || !img?.src) return;
lightboxImg.src = img.src;
lightboxCaption.textContent = card.querySelector('.character-name')?.textContent ?? '';
lightboxBlurb.textContent = '';
lightbox.classList.add('visible');
});
document.getElementById('lightbox-close')!.addEventListener('click', () => lightbox.classList.remove('visible'));
lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.classList.remove('visible'); });
// 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;height:100%;'; mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';
@@ -146,7 +269,11 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
}); });
} }
let latestState: State | null = null;
let archiveMode = false;
export function renderState(state: State): void { export function renderState(state: State): void {
latestState = state;
setText('r-troende', String(state.troende)); setText('r-troende', String(state.troende));
setText('r-andakt', fmt(state.andakt)); setText('r-andakt', fmt(state.andakt));
setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`); setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`);
@@ -158,6 +285,11 @@ export function renderState(state: State): void {
setBar('bar-hemlighet', state.hemlighet); setBar('bar-hemlighet', state.hemlighet);
setBar('bar-kaos', state.kaos); setBar('bar-kaos', state.kaos);
// Ambient visual reactivity — the deeper the dread, the more the world tilts; low hemlighet bleeds red at the edges.
const root = document.documentElement.style;
root.setProperty('--dread-intensity', Math.min(1, state.dread / 150).toFixed(3));
root.setProperty('--hemlighet-danger', (state.hemlighet < 40 ? (1 - state.hemlighet / 40) * 0.35 : 0).toFixed(3));
const ritualSection = document.getElementById('ritual-section')!; const ritualSection = document.getElementById('ritual-section')!;
if (state.ritualUnlocked) { if (state.ritualUnlocked) {
ritualSection.style.display = ''; ritualSection.style.display = '';
@@ -196,8 +328,41 @@ export function renderState(state: State): void {
// Log // Log
renderLog(state); renderLog(state);
// Intro screen
document.getElementById('intro-screen')!.classList.toggle('visible', state.phase === 'intro');
// Overlay // Overlay
if (state.phase !== 'playing') showOverlay(state); const overlay = document.getElementById('overlay')!;
if (state.phase === 'won' || state.phase === 'lost' || state.phase === 'abandoned') {
showOverlay(state);
} else {
overlay.classList.remove('visible');
}
// Event choice modal
renderEventModal(state);
}
let lastPendingEventId: string | null = null;
function renderEventModal(state: State): void {
const modal = document.getElementById('event-modal')!;
const pending = state.pendingEvent;
const id = pending?.event.id ?? null;
if (id === lastPendingEventId) return;
lastPendingEventId = id;
if (!pending) {
modal.classList.remove('visible');
return;
}
document.getElementById('event-modal-text')!.textContent = pending.event.text;
const choices = document.getElementById('event-modal-choices')!;
choices.innerHTML = (pending.event.choices ?? []).map((c, i) =>
`<button class="upgrade-btn" data-choice="${i}"><span class="up-name">${c.text}</span></button>`
).join('');
modal.classList.add('visible');
} }
let lastUpgradeKey = ''; let lastUpgradeKey = '';
@@ -237,17 +402,110 @@ function renderUpgrades(state: State): void {
}).join(''); }).join('');
} }
let lastLogLength = 0; let lastLogSeq = -1;
let typewriterTimer: number | undefined;
// Occult stand-ins for the typewriter effect on the newest log line — a
// deeper ritual means a shakier grip on plain Swedish. Never applied
// retroactively to older entries, so history stays legible.
const GLITCH_CHARS = ['☿', '☠', 'ψ', 'Ψ', '⛧', '҂'];
function glitchChar(dreadIntensity: number): string | null {
const chance = Math.min(0.18, dreadIntensity * 0.22);
if (Math.random() < chance) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]!;
return null;
}
function renderLog(state: State): void { function renderLog(state: State): void {
const log = document.getElementById('log')!; const log = document.getElementById('log')!;
if (state.log.length === lastLogLength) return; if (state.logSeq === lastLogSeq) return;
lastLogLength = state.log.length; const isFirstRender = lastLogSeq === -1;
// state.log is newest-first (unshift); render as-is, scroll to top lastLogSeq = state.logSeq;
log.innerHTML = state.log.map(e =>
`<div class="log-entry ${e.kind}">${e.text}</div>` // state.log is newest-first (unshift). The newest line gets its own
).join(''); // element so it can type itself out; everything else renders plainly.
// It has to come FIRST in the DOM to match scrollTop = 0 below — it
// previously came last, which put the newest entry below the fold,
// scrolled out of view until enough later entries pushed it up.
const [newest, ...rest] = state.log;
log.innerHTML = (newest ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '')
+ rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).join('');
log.scrollTop = 0; log.scrollTop = 0;
if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; }
if (newest) {
const el = document.getElementById('log-newest')!;
// Only narrative beats (story events, kaos) get the typewriter — the
// player's own actions (upgrades, visits) confirm instantly. Animating
// those too made the log feel like it was lagging a full action behind:
// the reveal took 1-2s, and if you acted again before it finished, the
// old line would silently snap to full text right as the new one
// started typing, which read exactly like "nothing happened until the
// next event."
const animate = !isFirstRender && state.phase === 'playing' && (newest.kind === 'event' || newest.kind === 'kaos');
if (!animate) {
el.textContent = newest.text;
} else {
const dreadIntensity = Math.min(1, state.dread / 150);
const text = newest.text;
const stepMs = text.length > 90 ? 4 : 8;
let i = 0;
typewriterTimer = window.setInterval(() => {
i += 1;
el.textContent = text.slice(0, i).split('').map(ch => (ch === ' ' ? ch : glitchChar(dreadIntensity) ?? ch)).join('');
if (i >= text.length) {
window.clearInterval(typewriterTimer);
typewriterTimer = undefined;
}
}, stepMs);
}
}
if (state.log[0]?.kind === 'kaos') {
const app = document.getElementById('app')!;
app.classList.remove('kaos-glitch');
void app.offsetWidth; // restart animation
app.classList.add('kaos-glitch');
}
}
function renderCharacterGrid(state: State | null): void {
const grid = document.getElementById('characters-grid')!;
const met = new Set(state?.usedEventIds ?? []);
grid.innerHTML = CHARACTERS.map(c => {
const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId);
if (!known) {
return `
<div class="character-card locked">
<div class="character-portrait locked-portrait">?</div>
<div class="character-name">???</div>
<div class="character-role">Inte mött än.</div>
</div>`;
}
return `
<div class="character-card" data-char="${c.id}">
<div class="character-portrait"><img alt="${c.name}" loading="lazy"></div>
<div class="character-name">${c.name}</div>
<div class="character-role">${c.role}</div>
<div class="character-bio">${c.bio}</div>
</div>`;
}).join('');
// Portraits are optional — art may not exist yet for every character.
// Fall back to an initial-letter placeholder rather than a broken image.
for (const c of CHARACTERS) {
const card = grid.querySelector(`[data-char="${c.id}"]`);
const img = card?.querySelector('img');
if (!img) continue;
img.addEventListener('error', () => {
const fallback = document.createElement('div');
fallback.className = 'character-portrait-fallback';
fallback.textContent = c.name.charAt(0);
img.replaceWith(fallback);
}, { once: true });
img.src = c.img;
}
} }
function showOverlay(state: State): void { function showOverlay(state: State): void {
@@ -256,16 +514,50 @@ function showOverlay(state: State): void {
const title = document.getElementById('overlay-title')!; const title = document.getElementById('overlay-title')!;
const body = document.getElementById('overlay-body')!; const body = document.getElementById('overlay-body')!;
if (state.phase === 'won') { if (state.phase === 'won') {
ov.style.backgroundImage = "url('/img/win.webp')";
title.textContent = 'FULLBORDAT'; title.textContent = 'FULLBORDAT';
title.className = 'overlay-title won'; title.className = 'overlay-title won';
body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.'; body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.';
} else if (state.phase === 'abandoned') {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'ÖVERGIVET';
title.className = 'overlay-title lost';
body.textContent = 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.';
} else { } else {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'AVSLÖJAD'; title.textContent = 'AVSLÖJAD';
title.className = 'overlay-title lost'; title.className = 'overlay-title lost';
body.textContent = state.lostReason ?? 'Utredarna kom.'; body.textContent = state.lostReason ?? 'Utredarna kom.';
} }
} }
// Called on every map marker click, alongside the actual visit — a way to
// see the location's art at a readable size instead of the 40px tooltip
// thumbnail. Probes the image first so a location with no art yet doesn't
// pop an empty modal; the visit itself always happens regardless.
export function showLocationArt(id: string, label: string, blurb: string): void {
const src = `/img/locations/${id}.png`;
const probe = new Image();
probe.onload = () => {
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
lightboxImg.src = src;
lightboxCaption.textContent = label;
lightboxBlurb.textContent = blurb;
lightbox.classList.add('visible');
};
probe.src = src;
}
export function setPausedUI(paused: boolean): void {
const btn = document.getElementById('pause-btn')!;
btn.textContent = paused ? 'Fortsätt' : 'Pausa';
document.getElementById('app')!.classList.toggle('paused', paused);
setText('pause-tag', paused ? '⏸ Pausad' : '');
}
function setText(id: string, val: string): void { function setText(id: string, val: string): void {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = val; if (el) el.textContent = val;

View File

@@ -78,8 +78,8 @@ export const UPGRADES: Upgrade[] = [
id: 'torso_beacon', id: 'torso_beacon',
name: 'Altaret vid Turning Torso', 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.', 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: 500, 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.15; },
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'),
once: true, once: true,
}, },
@@ -87,22 +87,49 @@ 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 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.', 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: 900, dread: 250 }),
effect: s => { s.ritualRate += 2; s.kaosRate += 0.5; }, 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'), available: s => s.upgrades.has('torso_beacon') && s.ritual >= 50 && !s.upgrades.has('grand_ritual_prep'),
once: true, 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', id: 'deepen_ritual',
name: 'Fördjupa ritualen', 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.', 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 }), cost: s => ({ andakt: Math.round(350 * Math.pow(1.55, ritualDeepenings(s))), dread: 60 }),
effect: s => { s.ritualRate += 0.3; }, effect: s => { s.ritualRate += 0.15; },
available: s => s.upgrades.has('grand_ritual_prep'), available: s => s.upgrades.has('grand_ritual_prep'),
once: false, once: false,
}, },
{
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 { function ritualDeepenings(s: State): number {
return Math.round((s.ritualRate - 2.5) / 0.3); return Math.round((s.ritualRate - 0.5) / 0.15);
} }