Compare commits
5
Commits
666b64e748
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c56ce2b2 | ||
|
|
e449cba288 | ||
|
|
a420032594 | ||
|
|
2fd6c5e854 | ||
|
|
c23bd369f1 |
+239
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
export const VERSION = '0.7.0';
|
export const VERSION = '0.9.0';
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -7,6 +7,34 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
version: '0.7.0',
|
||||||
date: '2026-08-01',
|
date: '2026-08-01',
|
||||||
|
|||||||
@@ -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', () => {
|
describe('buyUpgrade immutability', () => {
|
||||||
it('does not mutate the input state', () => {
|
it('does not mutate the input state', () => {
|
||||||
const s = createState();
|
const s = createState();
|
||||||
@@ -246,6 +323,23 @@ describe('applyOfflineProgress', () => {
|
|||||||
expect(after.phase).toBe('lost');
|
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', () => {
|
it('never mutates the input state', () => {
|
||||||
const s = createState();
|
const s = createState();
|
||||||
const frozenAndakt = s.andakt;
|
const frozenAndakt = s.andakt;
|
||||||
|
|||||||
+21
-1
@@ -21,6 +21,7 @@ export interface State {
|
|||||||
ritual: number; // 0-100, win at 100
|
ritual: number; // 0-100, win at 100
|
||||||
ritualRate: number; // per second
|
ritualRate: number; // per second
|
||||||
ritualUnlocked: boolean;
|
ritualUnlocked: boolean;
|
||||||
|
favorsCalled: number; // times "Kalla in en tjänst" bought — drives its own cost scaling
|
||||||
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
|
areas: { rosengard: boolean; varnhem: boolean; torso: boolean };
|
||||||
upgrades: Set<string>;
|
upgrades: Set<string>;
|
||||||
log: LogEntry[];
|
log: LogEntry[];
|
||||||
@@ -68,6 +69,7 @@ export function createState(): State {
|
|||||||
ritual: 0,
|
ritual: 0,
|
||||||
ritualRate: 0,
|
ritualRate: 0,
|
||||||
ritualUnlocked: false,
|
ritualUnlocked: false,
|
||||||
|
favorsCalled: 0,
|
||||||
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' }],
|
||||||
@@ -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 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 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
|
// Approximates what `tick()` would have done, second-by-second, without
|
||||||
// actually running potentially hundreds of thousands of frames synchronously
|
// 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;
|
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 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.andakt += s.andaktRate * s.troende * awaySec;
|
||||||
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * 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;
|
const kaosReleased = s.kaos + kaosGain >= 100;
|
||||||
s.kaos = Math.min(100, s.kaos + kaosGain);
|
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 troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0;
|
||||||
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
|
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
|
||||||
const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0;
|
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
|
// 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
|
// 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);
|
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 =
|
const awayLabel =
|
||||||
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
|
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
|
||||||
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
|
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
|
||||||
@@ -466,6 +485,7 @@ export function loadState(): State | null {
|
|||||||
usedEventIds: new Set<string>(d.usedEventIds ?? []),
|
usedEventIds: new Set<string>(d.usedEventIds ?? []),
|
||||||
flags: new Set<string>(d.flags ?? []),
|
flags: new Set<string>(d.flags ?? []),
|
||||||
logSeq: d.logSeq ?? 0,
|
logSeq: d.logSeq ?? 0,
|
||||||
|
favorsCalled: d.favorsCalled ?? 0,
|
||||||
// Saves from before offline progress existed won't have this — treat
|
// 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
|
// them as "just now" so an old save doesn't suddenly claim you were
|
||||||
// away since the Unix epoch.
|
// away since the Unix epoch.
|
||||||
|
|||||||
@@ -458,6 +458,40 @@ export const STANDARD_EVENTS: GameEvent[] = [
|
|||||||
requires: { minDread: 30 },
|
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 ───────────────────────────────────────────────────────────
|
// ── Choice events ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'choice_lindqvist_dream',
|
id: 'choice_lindqvist_dream',
|
||||||
@@ -1070,4 +1104,34 @@ export const KAOS_EVENTS: GameEvent[] = [
|
|||||||
weight: 4,
|
weight: 4,
|
||||||
requires: { minDread: 90 },
|
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
@@ -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'),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@ 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';
|
import { CHARACTERS } from './characters.ts';
|
||||||
|
import { LORE } from './lore.ts';
|
||||||
|
|
||||||
export function initUI(
|
export function initUI(
|
||||||
app: HTMLElement,
|
app: HTMLElement,
|
||||||
@@ -19,6 +20,7 @@ export function initUI(
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
|
<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="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="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
|
||||||
<button id="help-btn" class="help-btn" title="Hjälp & Ändringslogg">? v${VERSION}</button>
|
<button id="help-btn" class="help-btn" title="Hjälp & Ändringslogg">? v${VERSION}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,63 +39,6 @@ export function initUI(
|
|||||||
</div>
|
</div>
|
||||||
</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 & Ä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" id="panel-left">
|
||||||
<div class="panel-title">Resurser</div>
|
<div class="panel-title">Resurser</div>
|
||||||
|
|
||||||
@@ -168,6 +113,18 @@ export function initUI(
|
|||||||
</div>
|
</div>
|
||||||
</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 id="overlay">
|
||||||
<div class="overlay-scrim">
|
<div class="overlay-scrim">
|
||||||
<div class="overlay-title" id="overlay-title"></div>
|
<div class="overlay-title" id="overlay-title"></div>
|
||||||
@@ -176,7 +133,75 @@ export function initUI(
|
|||||||
</div>
|
</div>
|
||||||
</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 & Ä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 id="event-modal" class="help-modal">
|
||||||
<div class="help-modal-inner" id="event-modal-inner">
|
<div class="help-modal-inner" id="event-modal-inner">
|
||||||
@@ -193,7 +218,7 @@ export function initUI(
|
|||||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||||
<p id="lightbox-blurb" class="lightbox-blurb"></p>
|
<p id="lightbox-blurb" class="lightbox-blurb"></p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
|
|
||||||
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
|
||||||
document.getElementById('start-btn')!.addEventListener('click', onStart);
|
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 lightbox = document.getElementById('lightbox')!;
|
||||||
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
|
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
|
||||||
const lightboxCaption = document.getElementById('lightbox-caption')!;
|
const lightboxCaption = document.getElementById('lightbox-caption')!;
|
||||||
@@ -260,6 +294,7 @@ export function initUI(
|
|||||||
if (e.key !== 'Escape') return;
|
if (e.key !== 'Escape') return;
|
||||||
if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; }
|
if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; }
|
||||||
if (charactersModal.classList.contains('visible')) { charactersModal.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; }
|
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 {
|
function renderCharacterGrid(state: State | null): void {
|
||||||
const grid = document.getElementById('characters-grid')!;
|
const grid = document.getElementById('characters-grid')!;
|
||||||
const met = new Set(state?.usedEventIds ?? []);
|
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 {
|
function showOverlay(state: State): void {
|
||||||
const ov = document.getElementById('overlay')!;
|
const ov = document.getElementById('overlay')!;
|
||||||
ov.classList.add('visible');
|
ov.classList.add('visible');
|
||||||
@@ -526,7 +623,7 @@ function showOverlay(state: State): void {
|
|||||||
ov.style.backgroundImage = "url('/img/win.webp')";
|
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 = pickVariant(state.flags, WIN_VARIANTS, WIN_BASE);
|
||||||
} else if (state.phase === 'abandoned') {
|
} else if (state.phase === 'abandoned') {
|
||||||
ov.style.backgroundImage = "url('/img/fail.webp')";
|
ov.style.backgroundImage = "url('/img/fail.webp')";
|
||||||
title.textContent = 'ÖVERGIVET';
|
title.textContent = 'ÖVERGIVET';
|
||||||
@@ -536,7 +633,7 @@ function showOverlay(state: State): void {
|
|||||||
ov.style.backgroundImage = "url('/img/fail.webp')";
|
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 = pickVariant(state.flags, LOST_VARIANTS, state.lostReason ?? LOST_BASE_FALLBACK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,56 @@ export const UPGRADES: Upgrade[] = [
|
|||||||
available: s => s.upgrades.has('grand_ritual_prep'),
|
available: s => s.upgrades.has('grand_ritual_prep'),
|
||||||
once: false,
|
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',
|
id: 'whisper_guidance',
|
||||||
name: 'Viskningen leder er',
|
name: 'Viskningen leder er',
|
||||||
|
|||||||
Reference in New Issue
Block a user