Compare commits

..
5 Commits
Author SHA1 Message Date
Fredrik JohanssonandClaude Sonnet 5 61c56ce2b2 Add Kodex: unlockable lore fragments about places, things, and people
Docker / build-and-push (push) Successful in 1m54s
Ported the pattern from vildhjarta/Thön's own Kodex -- same shared
.help-modal/.help-section CSS both projects already have, same
LoreEntry{id,title,body,unlock} shape, same "N fragment kvarstår
olästa" locked-count footer.

Twelve entries pulled from LORE-BIBLE.md's established canon (the
basement, Den Sovande, Skriften, the ducks, the number 13, Turning
Torso, Värnhemstorget), gated by dread/ritual thresholds, upgrade
ownership, and specific events having fired (coc_investigator_dream,
k_bengt_ake, k_oresund_rises, nine_chairs_full, grand_ritual_prep) --
reusing hooks already in the engine rather than adding new ones.
Deliberately doesn't resolve anything LORE-BIBLE.md lists under "what
is deliberately never explained": these entries deepen the atmosphere
around the warm chair, the sunken city, and Den Sovande's true shape,
they don't answer them.

Verified live via injected save state: at dread=25 with the_manuscript
bought, exactly the 4 expected entries unlock (out of 12), the locked-
count footer reads "8 fragment kvarstår olästa," and Escape correctly
closes the modal. 21/21 tests pass, typecheck and build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 22:37:42 +02:00
Fredrik JohanssonandClaude Sonnet 5 e449cba288 Content pass: give inflytande a purpose, branch the endings, deepen troende/Möllan/Universitet
Docker / build-and-push (push) Successful in 1m23s
Addresses the gaps flagged after surveying the existing game against the
new LORE-BIBLE.md:

- inflytande had exactly one spending sink in the whole game. Added
  favor_network (unlocks a bigger repeatable "call in a favor" upgrade)
  and neutralize_investigation, which pays off the previously-dead-end
  Kommissarie Fors thread from coc_investigator_dream -- she stops
  investigating, but why she was dreaming about the cult in the first
  place stays exactly as unexplained as the lore bible says it should.
- Lindqvist's and Hassan's event-chain flags (listened/silenced,
  empowered/sidelined) previously only rerouted flavor text on the way
  to identical win/lose copy. showOverlay() now picks a variant based on
  which flags are set, most-specific match first, so a run where both
  arcs resolved gets its own combined ending instead of falling through
  to whichever flag happened to be checked first.
- troende past nine was flavor-only even though the hard cap was removed
  in 0.3.0. Added troende_overflow (event) and bigger_venue (upgrade,
  gated behind nine_chairs_full + troende>=10) so outgrowing the
  basement has a real mechanical consequence, not just a stat that keeps
  climbing quietly.
- Möllan and Universitet (added in 0.6.0) had zero upgrade or event ties
  compared to Rosengård/Värnhem/Torso. Added mollan_regular +
  mollan_favor and universitet_seminar as a first pass -- lighter-touch
  than full area-gating (which would mean reshaping State.areas across
  engine/map/upgrades), but real content where there was none.
- Kaos events skewed entirely comic relief against standard events'
  near-total dread. Added four kaos entries in the heavier register
  (thirteen chimes, the mirror room, permanent lights on the Öresund
  seabed, the warm chair finally occupied) as a tonal counterweight,
  lower-weighted so they don't crowd out the existing jokes.

Added engine tests for all four new upgrades' gating and cost scaling.
21/21 tests pass, typecheck clean, build clean. Verified all six ending
variants (win base/hassan/lindqvist/combo/fors, lose base/hassan/
lindqvist) render correctly against a real build via injected save state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 21:04:23 +02:00
Fredrik JohanssonandClaude Sonnet 5 a420032594 Add LORE-BIBLE.md, reconstructed from shipped content
No lore reference doc existed -- everything was embedded in event/upgrade/
character text with no single place tying it together. Documents the
established canon (Den Sovande, Skriften, the three tonal registers, place
and character indices, recurring motifs) and explicitly flags open threads
that were seeded in existing events but never paid off: the sunken city in
Öresund, Kommissarie Annika Fors, the Hässleholm rival cult, the warm
ninth chair, Rolf. Meant as a real constraint for future content, not just
a writeup -- new events/upgrades/characters should agree with this rather
than drift from it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:55:45 +02:00
Fredrik JohanssonandClaude Sonnet 5 2fd6c5e854 Soften offline hemlighet drain: grace period + per-session cap
Docker / build-and-push (push) Successful in 1m39s
Per feedback that unattended-tab game-overs felt bad specifically
because there was no chance to react — unlike a loss during active
play, which you can usually see coming.

- The first 5 minutes away accrue rewards (andakt, dread, etc.) as
  before, but cost zero hemlighet. A quick tab-out is never punished.
- A single absence, however long, can never cost more than 35
  hemlighet total. Applied as a clamp on the *combined* passive-drain
  and flavor-event effects — an earlier version of this only capped
  the passive-drain component, so a run of bad luck on the random
  away-time events could still blow past the intended cap.

Doesn't change anything about live play — only applyOfflineProgress.
A cult that was already critically exposed before you left can still
be found while you're gone; the cap bounds the damage one absence can
do, it doesn't grant immunity.

Added two tests (grace window is a true no-op below threshold and
still accrues rewards; a 24h absence can't drop hemlighet by more
than the cap regardless of which random events fire) and verified
live in a real browser: a 4-minute absence left hemlighet unchanged
by the offline step, and a 30-hour absence starting at full hemlighet
landed at 64/65 instead of the uncapped 58 from before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:01:17 +02:00
Fredrik JohanssonandClaude Sonnet 5 c23bd369f1 Actually fix click-outside-to-close (previous fix only worked by luck)
Root cause: #app carries a CSS filter for the dread color effect, and
a filter on an ancestor creates a new containing block for any
position:fixed descendant. Every modal (and the overlay) lived inside
#app, so they were clipping to #app's own box — max-width: 1200px,
centered — instead of the real viewport. On screens wider than that,
clicking in the resulting side margins did nothing, because the click
never actually landed inside the fixed element's clipped box.

The earlier "fix" only added Escape support; click-outside code was
already correct and simply never got exercised at the actual edges of
a wide window, which is exactly where a real "click outside" gesture
lands.

Real fix: overlay, help-modal, characters-modal, event-modal, and the
lightbox are now appended directly to <body>, as siblings of #app,
so they're never affected by #app's filter regardless of viewport
width. Everything else (game grid, danger-vignette) stays inside
#app as before.

Verified via headless browser at the exact point that failed before
the fix (top-left corner of a 1300px-wide viewport, well outside
#app's 1200px content area): help, characters, and the lightbox all
now close correctly. Also confirmed no regression — normal gameplay
screen and the game-over overlay both render identically, and the
overlay now correctly covers the full viewport instead of leaving a
gap on wide screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:57:21 +02:00
8 changed files with 744 additions and 63 deletions
+239
View File
@@ -0,0 +1,239 @@
# Djupet — Lore Bible
No such document existed before this one — everything below is reconstructed
from what's actually in `src/events.ts`, `src/upgrades.ts`, `src/characters.ts`,
and `src/map.ts`, plus a few threads that were seeded in event text and never
paid off. This file is the canon going forward: new events, upgrades,
characters, and endings should agree with it rather than contradict it. Where
something below is marked **(open thread)**, it's a hook that exists in
shipped content but hasn't been resolved yet — a deliberate invitation, not
an oversight.
## Premise, one line
A Malmö cult meets in a Rosengård basement under cover of a Nordic
crime-fiction reading circle, working toward completing a ritual tied to
something asleep beneath Öresund — and the game is about managing how much
of that anyone else gets to notice.
## Tone
Three registers, deliberately blended, and every new piece of content should
know which one (or which mix) it's writing in:
- **Anders Fager** — vardagssvenska possession-by-mundane-detail. Skatteverket,
ICA, SJ, Pressbyrån, Systembolaget. The horror arrives disguised as a
Tuesday.
- **Kult** — reality-as-prison, the flesh under the surface of things. Rooms
that aren't on the blueprints, walls that are warm, doors that stop
existing.
- **Call of Cthulhu** — investigators, sanity, the Mythos with a Skåne accent.
Things half-glimpsed, things half-translated, things that are worse once
understood than before.
The house style is **deadpan understatement**. Nobody screams. Things are
noted, dryly, and then the cult moves on. A sentence structure that recurs
constantly in existing text: state something uncanny in one short sentence,
then immediately undercut or normalize it in the next ("X. Ingen pratar om
det." / "X. Det är Malmö."). New content should reach for that rhythm before
reaching for anything more explicit.
## The entity: Den Sovande
**Never given a proper name beyond the epithet** ("The Sleeping One") — this
is deliberate and should stay deliberate. It sleeps in or beneath Öresund.
The ritual's endpoint is not clearly "waking" it so much as *completing* it —
event and upgrade text consistently frames the goal as finishing something
unfinished, not rousing something dormant. "Ofärdig" (unfinished) is the word
used in the abandon-game log line, and it's the right word to keep reaching
for.
Established physical/architectural connections:
- **Turning Torso** is not a metaphor for a twisting spine — per
`torso_beacon`'s upgrade text, that's literal: "Det är korrekt. Det är inte
en metafor." The building has always been listening. The architect is
fictionalized as **Calatrava-Santiago**.
- **Värnhemstorget** — a chalk circle that regrows overnight no matter how
often the city washes it off. Doesn't advance the ritual as directly as
Torso, but every visit and every varnhem-gated event ties dread/ritual
together here.
- **Öresund itself** — water level anomalies, a "sunken city" glimpsed on the
bottom in the deepest kaos-tier event (`k_oresund_rises`) that is
explicitly **not** an old city — the buildings are modern, one has
lighting. This is the single most unresolved, most promising lore thread
in the game **(open thread)**: what is the sunken city, and is it Den
Sovande's, a reflection of Malmö itself, or something arriving rather than
waking?
## The artifact: Skriften (The Scripture)
Found immured behind 1923 tile in a wall on Davidshallsgatan. Properties
established across several events and the `the_manuscript` upgrade:
- Self-writing — new lines appear overnight, "inte på något känt språk...
inte på något okänt språk heller egentligen."
- Moves if you look away (`torso_windows`' cousin-text; also implied by the
manuscript upgrade's "Texten rör sig om man tittar bort").
- Was translated by a linguistics grad student who wasn't trying to (she was
chasing "ett ovanligt baltiskt dialektdrag" for unrelated academic work) —
she later asks to join the cult (`k_researcher_joins`) specifically because
she "just wants to see how it ends." She is the **Doktoranden** character.
- The last page wrote itself the night the grand ritual prep upgrade
triggers, and per that upgrade's text, it's "ett recept" — a recipe. This
is worth remembering: the endgame isn't a spell in the abstract, it's
*instructions*, plural steps, something with a procedure. Good hook for
more mid-ritual content (see Depth ideas below).
## Place index
Real Malmö geography throughout — never invent a fictional location when a
real one will do; that's load-bearing for the tone.
**Core ritual sites** (area-gated, upgrade-unlocked):
- **Rosengård** — the founding basement. Nominal cover: "en läsecirkel för
nordisk kriminallitteratur." Nine chairs; one is always a little too warm.
- **Värnhemstorget** — the chalk circle.
- **Turning Torso** — the beacon/altar.
**Free-roam locations** (visitable from turn one, currently flavor-only, no
upgrade or event-chain ties of their own — see Depth ideas):
- **Öresundsbron** — the bridge to Denmark; the Danes conspicuously don't
notice anything, which several events flag as more unsettling than if they
did.
- **Ribersborgsstranden** — kallbadhus, and ducks that face Turning Torso in
an "almost worshipful" state. Recurring motif, used at least three times
across events/kaos (`ducks_torso`, `k_duck_army`, location flavor text).
The ducks know more than the cult does — never contradict that.
**(open thread — the ducks have never been explained even in-joke terms)**
- **Davidshallsgatan** — where Skriften was found. A neighbor sings something
"almost, but not quite, a psalm" through the wall.
- **Stortorget** — the old town square; clocks around it rarely agree.
- **Möllevångstorget ("Möllan")** — market, fifty kitchens' worth of smell at
once, three speakers never playing quite the same song. Added later
(v0.6.0) alongside Universitet and still has no upgrade/NPC tie of its own.
- **Malmö universitet (Niagara building)** — library, lecture halls, a room
for rarely-requested collections that always has whatever the cult's code
needs. This is where the Doktoranden's translation happened.
**Flavor-only mentions** (referenced in event text, never visitable, don't
need to become visitable — they're texture, not real estate): Malmö C,
Kockumskranen, Folkets Park, Triangeln, Limhamns kalkbrott, Hyllie,
Augustenborg, Lund Observatory/university, Västra Hamnen, S:t Johannes
kyrka, Gustav Adolfs Torg.
## Character index
**Founders** (always known):
- **Broder Lindqvist** — found the basement. Dreams of the sea every night,
increasingly literally as the ritual progresses. Draws the same map,
always the same angle toward Öresund. Sleeping less and less; says he no
longer needs to. His 3-step event chain (`choice_lindqvist_dream` →
`lindqvist_chain_2a/2b` → `lindqvist_chain_3`) ends with him walking into
Öresund at night and coming back changed, dry, no longer talking about
dreams — regardless of which branch (listened/silenced) the player took to
get there. **The two branches currently only reroute flavor text on the
way to the same outcome — see branching-endings work below.**
- **Syster Hassan** — handles paperwork, writes 47-page replies to
Skatteverket and wins. Practical and unshakeable until she starts
proposing changes to the ritual's order with a confidence nobody recalls
her having before. Her 3-step chain (`hassan_ambition` → `hassan_chain_2a/2b`
→ `hassan_chain_3`) ends with her either performing a solo ritual at
Turning Torso (empowered branch) or being told no (sidelined branch) — same
structural shape as Lindqvist's arc, same current problem: doesn't touch
the ending.
**Met along the way** (unlocked by specific events, portraits exist for all):
- **Göran** — added to the cult's Discord by mistake, mistook the chat for a
board game about the sea, gave "constructive" feedback anyway. Later
promoted to "Väktare av det Tredje Sigillet" (Guardian of the Third Seal),
which he interprets as treasurer. Roughly correct.
- **Bengt-Åke från Husie** — buys a nameless newspaper from Pressbyrån daily.
Turns out to already belong to a rival sect (active since 1987), whose own
lineage goes back to the 1840s. The coordination meeting between the two
groups "went well" — **(open thread — this rival-but-friendly lineage has
never been used again; ripe for a recurring faction rather than a one-off
joke)**.
- **Rolf från Limhamn** — attended for three weeks, then stopped. Shoes found
outside, neatly placed, toes toward the sea. A non-smoker's tobacco left
beside them. Never resolved, never explained, never brought back — this
reads as intentionally left open rather than abandoned; treat him as
"taken," not "written out."
- **Systern Anneli-Karin** — talks in her sleep in an unrecognized language;
played backward, still incomprehensible, "just differently incomprehensible."
- **Den gamle sjömannen** — retired sailor, unidentifiable tattoos, locked
himself in the Turning Torso tower and sang; rescue services couldn't get
in; the singing genuinely helped the ritual.
- **Doktoranden** — the linguistics grad student who unwittingly translated
Skriften, later asked to join specifically to see how it ends.
- **Journalisten från Sydsvenskan** — wrote an award-winning puff piece about
"Malmö's growing alternative spirituality" (the jury can't recall why they
gave it the prize); came back later for a follow-up, found more than she
was looking for; the follow-up was never published; her notes were kept by
the cult instead.
**Named but never made full characters** — usable as-is for new event text,
or promotable to full characters if a future content pass wants them:
**Kommissarie Annika Fors** (a Grova Brott detective who dream-attends
meetings — the cult has set out a chair for her that she sits in without
knowing it; this is the single clearest seeded-antagonist thread in the game
and currently dead-ends after one event, `coc_investigator_dream`
**(open thread)**), the unnamed **Hässleholm rival cult** (tried to summon a
different entity, summoned IKEA customer service instead — currently played
as a pure joke in `k_rival_cult`, but Bengt-Åke's backstory proves the game
already has appetite for rival-sect lore played straight).
## Recurring motifs — reuse these before inventing new ones
- **Ducks at Ribersborg**, facing Turning Torso.
- **The number 13** — 13-minute intervals, 13-meter circles, 13 chimes at
midnight, a train departing every 13 minutes forever.
- **Circles and rings** — the Värnhemstorget chalk circle, fish swimming in
a shrinking circle, pigeons flying an unclosing circle.
- **Doubling / mirroring** — a second illusory Turning Torso, a mirror
reflecting a different-era version of the same room, two sets of measurements
that never quite agree with each other.
- **A chorus/choir**, heard underground or underwater, never seen.
- **Time glitches** — missing hours nobody misses (which is what's
unsettling about it, not the hour itself), trains stopped in tunnels that
aren't on any map, clocks agreeing with each other but not with reality.
- **Bureaucracy as a delivery mechanism for dread** — Skatteverket,
Länsstyrelsen, a rådman, a fastighetsmäklare, the Statistiska centralbyrån
survey call that gets uncomfortably specific. The mundane administrative
state is *already* a little bit compromised, and needs surprisingly little
push to become an active participant. `inflytande`'s whole design premise
hangs off this — favors flow both ways.
## The number nine
Nine chairs from the very first log line ("Nio stolar. En av dem är alltid
lite för varm."). The mechanical troende cap was removed in v0.3.0, but the
*flavor* still treats nine as symbolically complete
(`nine_chairs_full`, `troende_schism`'s "Nio troende är fler än vi någonsin
haft"). Do not resolve this tension by explaining it away — the correct move
is to keep nine as the meaningful, "designed" number, and treat anything
past it as the cult outgrowing its own architecture. The warm chair is
occupied only once flavor-confirmed (`nine_chairs_full`: "Ingen pratar om vem
som sitter i den"), and nobody has ever been told who's in it. **Keep it
that way.**
## What is deliberately never explained
A short list, because knowing what to leave alone matters as much as knowing
what to build out:
- Who or what sits in the ninth (warm) chair.
- What happened to Rolf.
- What the sunken city at the bottom of Öresund actually is.
- Why the ducks know.
- Den Sovande's actual name, shape, or intentions beyond "unfinished."
## Using this document
New events, upgrades, and characters should:
1. Pick one of the three registers (or a blend) deliberately, the way the
existing source comments already tag event blocks by register.
2. Reuse a real Malmö place before inventing a new one, and reuse a
recurring motif before inventing a new image.
3. Prefer resolving or advancing an **(open thread)** listed above over
starting an entirely new dangling one — the game already has more setup
than payoff.
4. Keep the deadpan two-sentence rhythm: state the uncanny thing, undercut
or normalize it immediately after.
+29 -1
View File
@@ -1,4 +1,4 @@
export const VERSION = '0.7.0';
export const VERSION = '0.9.0';
export interface ChangelogEntry {
version: string;
@@ -7,6 +7,34 @@ export interface ChangelogEntry {
}
export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.9.0',
date: '2026-08-08',
changes: [
'Nytt: Kodex — tolv olåsbara textfragment om platser, saker och folk, upplåsta allteftersom dread/ritual/troende stiger eller specifika händelser inträffar. Samma mönster som Thön/Vildhjartas egen Kodex.',
],
},
{
version: '0.8.0',
date: '2026-08-08',
changes: [
'Nytt: LORE-BIBLE.md dokumenterar allt som redan var etablerat i spelet, inklusive trådar som aldrig knutits ihop',
'Inflytande har äntligen fler användningsområden: ett nätverk av tjänster, en repeterbar "kalla in en tjänst"-uppgradering, och ett sätt att neutralisera Kommissarie Fors utredning permanent',
'Fler än nio troende har nu en egen konsekvens: källaren blir för trång, och en ny uppgradering flyttar er till en större lokal',
'Möllevångstorget och Malmö universitet har fått egna händelser och en uppgradering, istället för att bara vara besöksbara utan vidare koppling',
'Fyra nya kaos-händelser i ett tyngre register — inte allt i kaos-poolen behöver vara ett skämt',
'Segrar och nederlag har nu olika slutord beroende på om Lindqvist lyssnade, Hassan fick ansvar, eller utredningen neutraliserades — tidigare påverkade de valen aldrig själva slutet',
],
},
{
version: '0.7.1',
date: '2026-08-03',
changes: [
'Alla modaler (hjälp, sällskapet, bildvisning) går nu faktiskt att stänga genom att klicka utanför dem, inte bara med X-knappen — tidigare fungerade det bara av misstag i mitten av skärmen',
'Mjukat till hur mycket en frånvaro kan kosta: de första fem minuterna borta dränerar ingen hemlighet alls, och en enskild frånvaro kan aldrig kosta mer än 35 hemlighet totalt, hur länge den än varar',
'Fixat: Malmö universitets kartnål satt vid Hyllie, inte vid campus',
],
},
{
version: '0.7.0',
date: '2026-08-01',
+94
View File
@@ -189,6 +189,83 @@ describe('exert_influence', () => {
});
});
describe('favor_network / call_in_favor', () => {
it('call_in_favor is unavailable until favor_network is bought, then restores hemlighet with rising cost per use', () => {
let s = createState();
s.troende = 3;
s.inflytande = 1000;
s.hemlighet = 50;
const blocked = buyUpgrade(s, 'call_in_favor');
expect(blocked.hemlighet).toBe(50); // no favor_network yet — refused
s = buyUpgrade(s, 'favor_network');
expect(s.upgrades.has('favor_network')).toBe(true);
const callInFavor = UPGRADES.find(u => u.id === 'call_in_favor')!;
let lastCost = 0;
for (let i = 0; i < 4; i++) {
s.hemlighet = 50; // reset below the cap each round so the restore is actually observable
const cost = callInFavor.cost(s).inflytande!;
expect(cost).toBeGreaterThan(lastCost);
lastCost = cost;
s = buyUpgrade(s, 'call_in_favor');
expect(s.hemlighet).toBeGreaterThan(50);
expect(s.favorsCalled).toBe(i + 1);
}
});
});
describe('neutralize_investigation', () => {
it('requires favor_network and having actually met Kommissarie Fors, then sets fors_neutralized', () => {
let s = createState();
s.troende = 3;
s.inflytande = 1000;
s.andakt = 1000;
const tooEarly = buyUpgrade(s, 'neutralize_investigation');
expect(tooEarly.flags.has('fors_neutralized')).toBe(false);
s = buyUpgrade(s, 'favor_network');
s.usedEventIds.add('coc_investigator_dream');
s = buyUpgrade(s, 'neutralize_investigation');
expect(s.flags.has('fors_neutralized')).toBe(true);
expect(s.upgrades.has('neutralize_investigation')).toBe(true);
});
});
describe('bigger_venue (troende past nine)', () => {
it('is only available once nine_chairs_full is bought and troende reaches ten', () => {
let s = createState();
s.troende = 10;
s.andakt = 10000;
s.dread = 10000;
const tooEarly = buyUpgrade(s, 'bigger_venue');
expect(tooEarly.upgrades.has('bigger_venue')).toBe(false);
s.upgrades.add('nine_chairs_full');
const rateBefore = s.andaktRate;
s = buyUpgrade(s, 'bigger_venue');
expect(s.upgrades.has('bigger_venue')).toBe(true);
expect(s.andaktRate).toBeGreaterThan(rateBefore);
});
});
describe('mollan_favor', () => {
it('requires the mollan_regular event to have actually fired first', () => {
let s = createState();
s.andakt = 10000;
const tooEarly = buyUpgrade(s, 'mollan_favor');
expect(tooEarly.upgrades.has('mollan_favor')).toBe(false);
s.usedEventIds.add('mollan_regular');
s = buyUpgrade(s, 'mollan_favor');
expect(s.upgrades.has('mollan_favor')).toBe(true);
});
});
describe('buyUpgrade immutability', () => {
it('does not mutate the input state', () => {
const s = createState();
@@ -246,6 +323,23 @@ describe('applyOfflineProgress', () => {
expect(after.phase).toBe('lost');
});
it('never drains hemlighet at all within the grace window', () => {
let s = createState();
s.troende = 50; // would otherwise drain fast
s.hemlighet = 50;
const after = applyOfflineProgress(s, 4 * 60 * 1000); // 4 minutes, under the 5-minute grace
expect(after.hemlighet).toBe(50);
expect(after.andakt).toBeGreaterThan(s.andakt); // rewards still accrue during grace
});
it('caps how much hemlighet a single absence can cost, no matter how long', () => {
let s = createState();
s.troende = 50; // heavy secrecy drain
s.hemlighet = 100;
const after = applyOfflineProgress(s, 24 * 3600 * 1000); // the full 24h cap
expect(after.hemlighet).toBeGreaterThanOrEqual(100 - 35);
});
it('never mutates the input state', () => {
const s = createState();
const frozenAndakt = s.andakt;
+21 -1
View File
@@ -21,6 +21,7 @@ export interface State {
ritual: number; // 0-100, win at 100
ritualRate: number; // per second
ritualUnlocked: boolean;
favorsCalled: number; // times "Kalla in en tjänst" bought — drives its own cost scaling
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
upgrades: Set<string>;
log: LogEntry[];
@@ -68,6 +69,7 @@ export function createState(): State {
ritual: 0,
ritualRate: 0,
ritualUnlocked: false,
favorsCalled: 0,
areas: { rosengard: false, varnhem: false, torso: false },
upgrades: new Set(),
log: [{ tick: 0, text: 'Mötet började i en fuktig källare i Rosengård. Nio stolar. En av dem är alltid lite för varm.', kind: 'system' }],
@@ -211,6 +213,8 @@ export function tick(state: State): State {
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
const OFFLINE_GRACE_SECONDS = 5 * 60; // the first 5 minutes away accrue rewards but risk no hemlighet at all
const MAX_OFFLINE_HEMLIGHET_DROP = 35; // one absence, however long, can't cost more than this much secrecy
// Approximates what `tick()` would have done, second-by-second, without
// actually running potentially hundreds of thousands of frames synchronously
@@ -222,6 +226,7 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
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) };
const startingHemlighet = s.hemlighet;
s.andakt += s.andaktRate * s.troende * awaySec;
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec;
@@ -233,10 +238,16 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
const kaosReleased = s.kaos + kaosGain >= 100;
s.kaos = Math.min(100, s.kaos + kaosGain);
// A quick tab-out shouldn't be punished at all, and even a long one has
// a floor on how much it can cost — losing the game entirely to a
// stretch you weren't watching, with no chance to react, felt like a
// different (worse) kind of loss than one you saw coming during play.
const drainSec = Math.max(0, awaySec - OFFLINE_GRACE_SECONDS);
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);
const rawDrop = (troendeDrain + dreadDrain + ritualDrain) * drainSec;
s.hemlighet = Math.max(0, s.hemlighet - Math.min(MAX_OFFLINE_HEMLIGHET_DROP, rawDrop));
// 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
@@ -252,6 +263,14 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
s.kaos = Math.max(0, s.kaos - 80);
}
// Re-apply the session cap after events — those can also carry a
// hemlighet penalty, and the cap is meant to bound the *total* damage
// an absence can do, not just the passive-drain portion of it.
const totalDrop = startingHemlighet - s.hemlighet;
if (totalDrop > MAX_OFFLINE_HEMLIGHET_DROP) {
s.hemlighet = Math.max(0, startingHemlighet - MAX_OFFLINE_HEMLIGHET_DROP);
}
const awayLabel =
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
@@ -466,6 +485,7 @@ export function loadState(): State | null {
usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
logSeq: d.logSeq ?? 0,
favorsCalled: d.favorsCalled ?? 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.
+64
View File
@@ -458,6 +458,40 @@ export const STANDARD_EVENTS: GameEvent[] = [
requires: { minDread: 30 },
},
// ── Kommissarie Fors, escalating ──
{
id: 'fors_closing_in',
text: 'Kommissarie Fors har börjat fråga kollegor om källaren i Rosengård, vaken den här gången. Hon minns nu vad hon drömt. Hon skriver rapporter om det, officiellt, i vanlig text, med sitt eget namn under.',
effect: { hemlighet: -14, dread: 10 },
weight: 5,
requires: { completedEventId: 'coc_investigator_dream', minDread: 45 },
},
// ── Nio blir fler ──
{
id: 'troende_overflow',
text: 'Källaren rymmer inte fler stolar. Ni har börjat stå längs väggarna. Ingen klagar. Ingen erbjuder heller sin plats till den som kom sist.',
effect: { kaos: 8, hemlighet: -6 },
weight: 6,
requires: { minTroende: 10 },
},
// ── Möllan och Universitet, fördjupade ──
{
id: 'mollan_regular',
text: 'Marknadshandlaren på Möllan har börjat spara undan varor åt er innan ni ens kommer dit. Hon kallar det stamkundsrabatt. Ingen av er minns att ni presenterat er för henne.',
effect: { andakt: 10, kaos: 4 },
weight: 7,
requires: { minTroende: 3 },
},
{
id: 'universitet_seminar',
text: 'En seminarieserie om "marina folktraditioner i Öresundsregionen" har startat på universitetet, utan att någon minns vem som initierade den. Föreläsaren citerar källor som inte existerar utanför Skriften.',
effect: { dread: 12, ritual: 2 },
weight: 6,
requires: { minRitual: 5 },
},
// ── Choice events ───────────────────────────────────────────────────────────
{
id: 'choice_lindqvist_dream',
@@ -1070,4 +1104,34 @@ export const KAOS_EVENTS: GameEvent[] = [
weight: 4,
requires: { minDread: 90 },
},
// ── Tyngre kaos-register: inte allt behöver vara ett skämt ──
{
id: 'k_thirteen_chimes',
text: 'Klockan i S:t Petri slår tretton slag mitt på dagen. Ingen på torget verkar höra det utom er. Ni räknar tillsammans efteråt, var för sig, utan att jämföra. Alla fick samma tal.',
effect: { kaos: -50, dread: 35 },
weight: 6,
requires: { minDread: 40 },
},
{
id: 'k_mirror_room_return',
text: 'Spegeln i förrummet visar återigen det gamla rummet, men den här gången känner ni igen en av personerna som sitter där. Det är en av er. Ingen säger vem. Ingen frågar heller.',
effect: { kaos: -55, dread: 45 },
weight: 5,
requires: { minDread: 60 },
},
{
id: 'k_sunken_lights',
text: 'Ett ljus syns nu permanent på botten av Öresund, på samma plats varje kväll. Det är inte fartygstrafik. Dykare skickas aldrig ner för att kolla. Ingen har föreslagit det. Ingen behöver föreslå det.',
effect: { kaos: -60, dread: 50, ritual: 4 },
weight: 4,
requires: { minDread: 90 },
},
{
id: 'k_warm_chair_used',
text: 'Den varma stolen i källaren var upptagen i kväll, för första gången någonsin. Ingen av er såg vem som satt där. Alla av er såg att stolen var upptagen.',
effect: { kaos: -65, dread: 55, hemlighet: -10 },
weight: 3,
requires: { minTroende: 8, minDread: 70 },
},
];
+89
View File
@@ -0,0 +1,89 @@
import type { State } from './engine.ts';
export interface LoreEntry {
id: string;
title: string;
body: string;
unlock: (s: State) => boolean;
}
// Unlockable fragments describing places, things, and people from the
// established canon in LORE-BIBLE.md — same pattern as vildhjarta/Thön's
// Kodex. Deliberately does not resolve anything LORE-BIBLE.md lists under
// "what is deliberately never explained" (the warm chair's occupant, the
// sunken city itself, Rolf, Den Sovande's true shape) — these entries
// deepen the atmosphere around those mysteries, they don't answer them.
export const LORE: LoreEntry[] = [
{
id: 'kallaren',
title: 'Källaren i Rosengård',
body: 'Ett källarförråd som blev något annat. Ingen av er minns exakt när. Hyresvärden vet fortfarande inte om det. Nio stolar står där nu. Ingen av er köpte den nionde.',
unlock: () => true,
},
{
id: 'den_sovande',
title: 'Den Sovande',
body: 'Inget dokument ni hittat ger den ett riktigt namn. "Den Sovande" är vad ni kallar den, för att ni måste kalla den något. Sover är förresten fel ord också — det närmaste ni kommit är "ofärdig". Vad den väntar på att bli färdig med säger ingen källa. Kanske vet ingen källa.',
unlock: s => s.dread >= 20,
},
{
id: 'skriften',
title: 'Skriften',
body: 'Hittades inmurad bakom kakel från 1923 på Davidshallsgatan. Den skriver sig själv om nätterna. Rör sig om man tittar bort. En doktorand i lingvistik översatte tre rader av misstag, medan hon letade efter något helt annat. Hon vet fortfarande inte vad hon hittade. Det gör ni.',
unlock: s => s.upgrades.has('the_manuscript'),
},
{
id: 'anderna',
title: 'Änderna vid Ribersborg',
body: 'De vänder sig mot Turning Torso i ett tillstånd som liknar tillbedjan mer än väder borde förklara. Ingen ornitolog har velat sätta sitt namn på en förklaring. Änderna vet mer än ni gör. Det är inte ett skämt.',
unlock: s => s.dread >= 15,
},
{
id: 'varnhemstorget_cirkeln',
title: 'Cirkeln på Värnhemstorget',
body: 'Ritad med krita — det måste vara krita, ingen vet varför, alla känner det. Kommunen tvättar bort den varje morgon. Den är tillbaka varje kväll, och varje gång lite större än ni ritade den.',
unlock: s => s.areas.varnhem,
},
{
id: 'turning_torso',
title: 'Turning Torso',
body: 'Calatrava-Santiago ritade byggnaden som en vridande ryggrad. Det är inte en metafor. Den har alltid lyssnat. Nu ber ni den svara, och den gör det — ett ljud under hörselgränsen som ni känner i tänderna snarare än öronen.',
unlock: s => s.areas.torso,
},
{
id: 'talet_13',
title: 'Om talet 13',
body: 'Tretton minuter mellan avgångar som inte finns på någon tidtabell. Tretton meter i diameter, krympande. Tretton slag klockan tolv. Ingen av er valde talet. Det bara börjar dyka upp, och när ni väl sett det slutar ni kunna sluta se det.',
unlock: s => s.dread >= 40,
},
{
id: 'kommissarie_fors',
title: 'Kommissarie Fors',
body: 'Grova brott. Hon drömmer om era möten utan att veta att hon gör det — ni har satt ut en stol åt henne, och hon sitter i den, i drömmen, varje gång. Varför just hon, varför just era möten, är en fråga ingen av er ställt högt än.',
unlock: s => s.usedEventIds.has('coc_investigator_dream'),
},
{
id: 'bengt_ake_sekten',
title: 'Bengt-Åke och det äldre arvet',
body: 'Han köper en namnlös tidning på Pressbyrån varje dag och ser nöjd ut. Visade sig tillhöra en helt annan sekt sedan 1987 — vars egen linje går tillbaka till 1840-talet. Samordningsmötet gick förvånansvärt bra. Ni har inte frågat vad de faktiskt tillber. Det känns oartigt.',
unlock: s => s.usedEventIds.has('k_bengt_ake'),
},
{
id: 'staden_pa_botten',
title: 'Staden på botten',
body: 'Vattnet i Öresund fortsätter sjunka, och på botten syns nu en stad. Det är inte en gammal stad — byggnaderna är moderna. En av dem har belysning. Ingen av er har föreslagit att undersöka den närmare. Ingen behöver föreslå det.',
unlock: s => s.usedEventIds.has('k_oresund_rises'),
},
{
id: 'den_varma_stolen',
title: 'Den varma stolen',
body: 'Källarens nionde stol har alltid varit lite för varm, från första mötet. Ni har äntligen alla nio platser fyllda. Ingen pratar om vem som sitter i den varma. Ni har alla sett att den är upptagen. Ingen av er har sett vem.',
unlock: s => s.upgrades.has('nine_chairs_full'),
},
{
id: 'skriften_receptet',
title: 'Ett recept, inte en besvärjelse',
body: 'Skriftens sista sida skrev sig själv på en enda natt. Ni hade väntat er en besvärjelse, ord som gör något om de sägs rätt. Det ni fick var mer praktiskt än så — steg, i ordning, med mått. Det gör det varken mindre eller mer verkligt. Bara mer skrämmande på ett sätt ni inte väntat er.',
unlock: s => s.upgrades.has('grand_ritual_prep'),
},
];
+158 -61
View File
@@ -2,6 +2,7 @@ import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts';
import { CHARACTERS } from './characters.ts';
import { LORE } from './lore.ts';
export function initUI(
app: HTMLElement,
@@ -19,6 +20,7 @@ export function initUI(
<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="lore-btn" class="help-btn" title="Kodex">Kodex</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>
</div>
@@ -37,63 +39,6 @@ export function initUI(
</div>
</div>
<div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">DJUPET — Hjälp &amp; Ändringslogg</span>
<button id="help-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<section class="help-section">
<div class="help-section-title">Hur man spelar</div>
<ul class="help-list">
<li><strong>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</li>
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
<li>Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.</li>
</ul>
</section>
<section class="help-section">
<div class="help-section-title">Om spelet</div>
<p class="help-flavor">
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
Den Sovande är inte vaken ännu. Jobba på det.
</p>
</section>
<section class="help-section">
<div class="help-section-title">Ändringslogg</div>
${CHANGELOG.map(e => `
<div class="help-changelog-entry">
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
</div>
`).join('')}
</section>
</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-title">Resurser</div>
@@ -168,6 +113,18 @@ export function initUI(
</div>
</div>
<div id="danger-vignette"></div>
`;
// Overlays live as siblings of #app, appended directly to <body>, not
// inside it. #app carries a `filter` (for the dread color effect),
// and CSS filter on an ancestor creates a new containing block for any
// `position: fixed` descendant — so these would clip to #app's own box
// (max-width: 1200px, centered) instead of the real viewport. On wider
// screens that left a dead margin on each side where "click outside to
// close" silently did nothing, because the click never actually landed
// inside the fixed element's clipped box.
document.body.insertAdjacentHTML('beforeend', `
<div id="overlay">
<div class="overlay-scrim">
<div class="overlay-title" id="overlay-title"></div>
@@ -176,7 +133,75 @@ export function initUI(
</div>
</div>
<div id="danger-vignette"></div>
<div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">DJUPET — Hjälp &amp; Ändringslogg</span>
<button id="help-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<section class="help-section">
<div class="help-section-title">Hur man spelar</div>
<ul class="help-list">
<li><strong>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</li>
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
<li>Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.</li>
</ul>
</section>
<section class="help-section">
<div class="help-section-title">Om spelet</div>
<p class="help-flavor">
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
Den Sovande är inte vaken ännu. Jobba på det.
</p>
</section>
<section class="help-section">
<div class="help-section-title">Ändringslogg</div>
${CHANGELOG.map(e => `
<div class="help-changelog-entry">
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
</div>
`).join('')}
</section>
</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 id="lore-modal" class="help-modal">
<div class="help-modal-inner" id="lore-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Kodex</span>
<button id="lore-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<p class="help-flavor" style="margin-bottom:14px;">Fragment ni samlat på er — platser, saker, folk. Fylls i allteftersom ritualen fördjupas.</p>
<div id="lore-list"></div>
</div>
</div>
</div>
<div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner">
@@ -193,7 +218,7 @@ export function initUI(
<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('start-btn')!.addEventListener('click', onStart);
@@ -235,6 +260,15 @@ export function initUI(
});
});
const loreModal = document.getElementById('lore-modal')!;
const loreInner = document.getElementById('lore-modal-inner')!;
document.getElementById('lore-btn')!.addEventListener('click', () => {
renderLoreList(latestState);
loreModal.classList.toggle('visible');
});
document.getElementById('lore-close')!.addEventListener('click', () => loreModal.classList.remove('visible'));
loreModal.addEventListener('click', (e) => { if (!loreInner.contains(e.target as Node)) loreModal.classList.remove('visible'); });
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
@@ -260,6 +294,7 @@ export function initUI(
if (e.key !== 'Escape') return;
if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; }
if (charactersModal.classList.contains('visible')) { charactersModal.classList.remove('visible'); return; }
if (loreModal.classList.contains('visible')) { loreModal.classList.remove('visible'); return; }
if (helpModal.classList.contains('visible')) { helpModal.classList.remove('visible'); return; }
});
@@ -479,6 +514,21 @@ function renderLog(state: State): void {
}
}
function renderLoreList(state: State | null): void {
const list = document.getElementById('lore-list')!;
if (!list) return;
const unlocked = state ? LORE.filter(l => l.unlock(state)) : [];
const lockedCount = LORE.length - unlocked.length;
list.innerHTML = unlocked.map(l => `
<div class="help-section" style="margin-bottom:16px;">
<div class="help-section-title" style="color:var(--accent);border-bottom:none;padding-bottom:0;">${l.title}</div>
<p class="help-flavor" style="font-style:normal;color:var(--text);">${l.body}</p>
</div>
`).join('') + (lockedCount > 0
? `<p class="help-flavor">…${lockedCount} fragment kvarstår olästa. Fördjupa ritualen för att hitta fler.</p>`
: '');
}
function renderCharacterGrid(state: State | null): void {
const grid = document.getElementById('characters-grid')!;
const met = new Set(state?.usedEventIds ?? []);
@@ -517,6 +567,53 @@ function renderCharacterGrid(state: State | null): void {
}
}
// Both NPC arcs (Lindqvist listened/silenced, Hassan empowered/sidelined)
// previously only rerouted flavor text on the way to the same win/lose
// copy — the choices never actually reached the ending. Picking the most
// specific match first (both arcs resolved the same direction) means a
// player who leaned into both gets a variant that acknowledges both,
// rather than whichever flag happened to be checked first.
function pickVariant(flags: Set<string>, variants: { when: string[]; text: string }[], fallback: string): string {
const scored = variants
.filter(v => v.when.every(f => flags.has(f)))
.sort((a, b) => b.when.length - a.when.length);
return scored[0]?.text ?? fallback;
}
const WIN_BASE = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.';
const WIN_VARIANTS = [
{
when: ['hassan_empowered', 'lindqvist_listened'],
text: 'Det är gjort. Hassan står i cirkelns mitt och talar orden i en ordning ni aldrig lärde henne. Lindqvist står vid vattnet, torr, och nickar i takt med henne utan att höra. Turning Torso pekar rakt ned. Ni undrar, för första gången, om ni fortfarande behövs.',
},
{
when: ['hassan_empowered'],
text: 'Det är gjort, men det var Hassans röst som avslutade det, inte er. Cirkeln följde henne hela vägen. Turning Torso pekar rakt ned. Havet är stilla. Ni klappar henne på axeln. Hon ler och tittar förbi er, mot vattnet.',
},
{
when: ['lindqvist_listened'],
text: 'Det är gjort. Lindqvist stod vid vattnet när det hände, precis som han sagt att han skulle. Han kom tillbaka torr och log på ett sätt ni inte känner igen längre. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla för att vara bara hav.',
},
{
when: ['fors_neutralized'],
text: 'Det är gjort. Ingen kom för att stoppa er — ni såg till det i god tid. Turning Torso pekar rakt ned. Havet är stilla. Kommissarie Fors sover, äntligen, utan drömmar. Ni undrar om det är en tjänst ni gjorde henne, eller tog ifrån henne.',
},
];
const LOST_BASE_FALLBACK = 'Utredarna kom.';
const LOST_VARIANTS = [
{
when: ['hassan_empowered'],
text: 'Utredarna slog till vid gryningen. Hassan var den enda som inte sprang. Hon satt kvar i cirkeln och log mot dem tills de förde bort henne. Källaren är tom nu. Mattan ligger kvar. Under mattan finns fortfarande bordet.',
},
{
when: ['lindqvist_listened'],
text: 'Utredarna slog till vid gryningen. Lindqvist var inte där — han hade gått ner till vattnet igen, som han gjort så många nätter. Ingen vet om han någonsin fick veta att det var över. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.',
},
];
function showOverlay(state: State): void {
const ov = document.getElementById('overlay')!;
ov.classList.add('visible');
@@ -526,7 +623,7 @@ function showOverlay(state: State): void {
ov.style.backgroundImage = "url('/img/win.webp')";
title.textContent = 'FULLBORDAT';
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 = pickVariant(state.flags, WIN_VARIANTS, WIN_BASE);
} else if (state.phase === 'abandoned') {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'ÖVERGIVET';
@@ -536,7 +633,7 @@ function showOverlay(state: State): void {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'AVSLÖJAD';
title.className = 'overlay-title lost';
body.textContent = state.lostReason ?? 'Utredarna kom.';
body.textContent = pickVariant(state.flags, LOST_VARIANTS, state.lostReason ?? LOST_BASE_FALLBACK);
}
}
+50
View File
@@ -119,6 +119,56 @@ export const UPGRADES: Upgrade[] = [
available: s => s.upgrades.has('grand_ritual_prep'),
once: false,
},
{
id: 'favor_network',
name: 'Nätverket av tjänster',
desc: 'Kontakterna ni samlat på er är fler än ni insett. En tjänst ger en annan. Nätverket börjar arbeta för sin egen skull nu, inte bara för er räkning. +inflytande/s, och en ny, större sorts tjänst blir möjlig att kalla in.',
cost: () => ({ inflytande: 60 }),
effect: s => { s.inflytandeRate += 0.5; },
available: s => s.troende >= 3 && !s.upgrades.has('favor_network'),
once: true,
},
{
id: 'call_in_favor',
name: 'Kalla in en tjänst',
desc: 'Ring den som är skyldig er mest. Det är sällan gratis att bli påmind om vad man är skyldig — men det fungerar. Stor återhämtning av hemlighet, mot lite ökad kaos. Dyrare för varje gång ni ber om samma sak.',
cost: s => ({ inflytande: Math.round(50 * Math.pow(1.4, s.favorsCalled)) }),
effect: s => {
s.hemlighet = Math.min(100, s.hemlighet + 20);
s.kaos = Math.min(100, s.kaos + 6);
s.dread = Math.max(0, s.dread - 5);
s.favorsCalled += 1;
},
available: s => s.upgrades.has('favor_network'),
once: false,
},
{
id: 'neutralize_investigation',
name: 'Neutralisera utredningen',
desc: 'Kommissarie Fors har en chef. Chefen har en tjänst att göra gentjänst för. Utredningen läggs ner, officiellt av resursskäl. Fors fortsätter drömma om er ändå — hon skriver bara inte ner det längre.',
cost: () => ({ inflytande: 150, andakt: 300 }),
effect: s => { s.hemlighet = Math.min(100, s.hemlighet + 25); s.flags.add('fors_neutralized'); },
available: s => s.upgrades.has('favor_network') && s.usedEventIds.has('coc_investigator_dream') && !s.upgrades.has('neutralize_investigation'),
once: true,
},
{
id: 'bigger_venue',
name: 'Källaren är för liten',
desc: 'Ni flyttar till en större lokal, officiellt "för läsecirkeln." Fler rum, fler dörrar, fler saker att förklara för grannarna. Den varma stolen flyttar med. Den är fortfarande varm.',
cost: () => ({ andakt: 1200, dread: 150 }),
effect: s => { s.andaktRate += 2; s.kaosRate += 0.25; },
available: s => s.troende >= 10 && s.upgrades.has('nine_chairs_full') && !s.upgrades.has('bigger_venue'),
once: true,
},
{
id: 'mollan_favor',
name: 'Stamkund på Möllan',
desc: 'Handlaren ger er mer än rabatt nu. Hon ber inte om förklaringar, bara om att få vara med "när det händer." Ni har inte sagt ja. Ni har inte sagt nej heller.',
cost: () => ({ andakt: 250 }),
effect: s => { s.andaktRate += 0.4; },
available: s => s.usedEventIds.has('mollan_regular') && !s.upgrades.has('mollan_favor'),
once: true,
},
{
id: 'whisper_guidance',
name: 'Viskningen leder er',