Compare commits

...

5 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:53:26 +02:00
28 changed files with 939 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

86
src/characters.ts Normal file
View File

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

View File

@@ -1,4 +1,4 @@
export const VERSION = '0.4.0';
export const VERSION = '0.7.0';
export interface ChangelogEntry {
version: string;
@@ -7,6 +7,38 @@ export interface ChangelogEntry {
}
export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.7.0',
date: '2026-08-01',
changes: [
'Klicka på en plats på kartan för att se dess bild i fullstorlek, tillsammans med en fast beskrivning av platsen',
'Alla 9 platser har nu konst på kartan',
],
},
{
version: '0.6.0',
date: '2026-08-01',
changes: [
'Alla porträtt i Sällskapet är nu på plats',
'Nytt läge i Sällskapet: Fullständigt arkiv — se alla porträtt oavsett hur långt ni kommit, klicka för fullstorlek',
'Platser på kartan visar nu en liten bild i förhandsvisningen (när konst finns för platsen)',
'Två nya platser: Möllevångstorget och Malmö universitet, båda besökbara direkt från start',
'Fler händelser tillagda, inklusive ett fotografi från 1930-talet som inte borde finnas',
],
},
{
version: '0.5.0',
date: '2026-08-01',
changes: [
'Källaren fortsätter utan er nu — stäng fliken eller gå därifrån, och nästa gång ni öppnar spelet har andakt, dread och kaos fortsatt sin gång (inom rimliga gränser)',
'Fliken viskar när ni tittar bort: titeln växlar till korta rader ur ritualen så länge spelet inte är i förgrunden',
'Sen uppgradering: Viskningen leder er — kulten besöker platser på egen hand ibland, utan att någon av er fattat beslutet',
'Loggens senaste rad skrivs nu ut bokstav för bokstav, och tecken byts stundtals mot ockulta symboler ju djupare dread ligger — äldre rader förblir lästa som de var',
'Ny flik: Sällskapet — porträtt och korta biografier över alla ni mött, tomma tills ni faktiskt mött dem',
'Fixat: Göran och Bengt-Åke kunde tidigare dyka upp i loggen som redan bekanta innan ni faktiskt träffat dem',
'Nya händelser och platsvarianter tillagda över hela spelet',
],
},
{
version: '0.4.0',
date: '2026-07-13',

View File

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

View File

@@ -32,6 +32,7 @@ export interface State {
nextKaosTick: number;
pendingEvent: PendingEvent | null;
logSeq: number; // increments on every log push — log.length alone can't detect changes once capped at 40
lastRealTime: number; // epoch ms, refreshed on save — used to compute offline progress on load
}
export interface LogEntry {
@@ -77,6 +78,7 @@ export function createState(): State {
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
pendingEvent: null,
logSeq: 0,
lastRealTime: Date.now(),
};
}
@@ -207,6 +209,72 @@ export function tick(state: State): State {
return s;
}
const MAX_OFFLINE_SECONDS = 24 * 3600; // cap the catch-up so a week-long absence doesn't insta-win or insta-lose
const MIN_OFFLINE_SECONDS = 60; // don't bother with a log line for a quick tab-switch
// Approximates what `tick()` would have done, second-by-second, without
// actually running potentially hundreds of thousands of frames synchronously
// on load. Uses current rates as a flat average over the away period — close
// enough for a narrative idle game, not meant to be exact.
export function applyOfflineProgress(state: State, awayMs: number): State {
if (state.phase !== 'playing') return state;
const awaySec = Math.min(MAX_OFFLINE_SECONDS, awayMs / 1000);
if (awaySec < MIN_OFFLINE_SECONDS) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
s.andakt += s.andaktRate * s.troende * awaySec;
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec;
s.dread += s.dreadRate * awaySec;
if (s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + s.ritualRate * awaySec);
const kaosScale = 1 + (s.dread / 200);
const kaosGain = s.kaosRate * kaosScale * awaySec;
const kaosReleased = s.kaos + kaosGain >= 100;
s.kaos = Math.min(100, s.kaos + kaosGain);
const troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0;
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0;
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * awaySec);
// A little flavor for the time away — non-choice events only, since nobody
// was there to answer. Roughly one per two hours away, capped low so the
// log doesn't get swamped after a long absence.
const eventRolls = Math.min(3, Math.floor(awaySec / 7200));
for (let i = 0; i < eventRolls; i++) {
const ev = pickEvent(STANDARD_EVENTS.filter(e => !e.choices?.length), s);
if (!ev) break;
s.usedEventIds.add(ev.id);
applyEffect(s, ev.effect);
}
if (kaosReleased) {
s.kaos = Math.max(0, s.kaos - 80);
}
const awayLabel =
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
'länge — för länge för att räkna';
const summary =
awaySec < 3600 ? `Ni var borta i ${awayLabel}. Källaren väntade, otålig.` :
awaySec < 6 * 3600 ? `Ni var borta i ${awayLabel}. Andakten samlades utan er.` :
awaySec < MAX_OFFLINE_SECONDS ? `Ni var borta i ${awayLabel}. Dread steg tyst i källaren medan ni sov.` :
`Ni var borta ${awayLabel}. Källaren har fortsatt utan er. Det är oklart hur.`;
pushLog(s, { tick: s.tick, text: summary, kind: 'system' });
if (s.hemlighet <= 0) {
s.phase = 'lost';
s.lostReason = 'Utredarna slog till medan ni var borta. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
} else if (s.ritual >= 100) {
s.phase = 'won';
pushLog(s, { tick: s.tick, text: 'Det är gjort — utan er i rummet. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
}
return s;
}
export function resolveEventChoice(state: State, choiceIndex: number): State {
if (!state.pendingEvent) return state;
const choice = state.pendingEvent.event.choices?.[choiceIndex];
@@ -243,6 +311,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
'Någon har städat källaren medan ingen var där. Ingen av er erkänner sig skyldig. +andakt',
'Den varma stolen är varm igen. Ingen har suttit i den. +andakt',
'Hyresvärden har inte hört av sig på tre månader. Hyran dras ändå, varje månad, på utsatt dag. +andakt',
'Det droppar från taket på exakt samma ställe som förra veckan. Golvet under är torrt. +andakt',
],
effect: { andakt: 15 },
requiresArea: 'rosengard',
@@ -253,6 +323,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
'Kritan på asfalten har inte suddats trots regnet i natt. +dread +ritual',
'Torget är tomt förutom en duva som går i exakt cirkelns mönster. +dread +ritual',
'Spårvagnen bromsar mjukt precis vid cirkelns kant, varje gång, utan att föraren verkar märka det. +dread +ritual',
'Någon har lagt blommor mitt i cirkeln. Det står inget namn på kortet. Datumet är nästa vecka. +dread +ritual',
],
effect: { dread: 8, ritual: 2 },
requiresArea: 'varnhem',
@@ -263,6 +335,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
'Ett fönster högt uppe är tänt som det aldrig varit förut. Ingen bor där. Ingen har någonsin bott där. +dread +ritual',
'Torso vrider sig en grad till i vinden som inte finns. +dread +ritual',
'Hissarna stannar på en våning som inte finns i receptionens skylt. Dörrarna öppnas ändå. Ingen går ut. +dread +ritual',
'Fasadens glas reflekterar en himmel som inte matchar den du står under. +dread +ritual',
],
effect: { dread: 15, ritual: 5 },
requiresArea: 'torso',
@@ -273,6 +347,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
'En färja passerar utan ljud. Ingen ombord vinkar. Alla ombord tittar rakt på dig. +dread',
'Bron surrar på en frekvens du känner i tänderna snarare än öronen. +dread',
'Fiskarna vid stranden ligger stilla i vattenbrynet, andas fortfarande, väntar på något. +dread',
'Tullstationens skärmar visar samma bil passera om och om igen, med olika registreringsskyltar. +dread',
],
effect: { dread: 5 },
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
@@ -282,6 +358,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
'Alla änderna vänder sig samtidigt mot Turning Torso när du kommer. +andakt',
'Sanden är kall trots solen. Ingen annan verkar märka det. +andakt',
'Kallbadhusets bastu är på trots att ingen bokat den. Dörren är varm att röra vid, ingen sitter inne. +andakt',
'En strandpromenerare frågar om vägen till en adress som inte finns i Malmö. Du visar honom ändå. +andakt',
],
effect: { andakt: 8 },
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
@@ -291,6 +369,8 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
'Kaklet från 1923 är kallt att röra vid, oavsett årstid. +andakt',
'Grannen genom väggen sjunger något som nästan, men inte riktigt, är en psalm. +andakt',
'Elmätaren i trapphuset räknar baklänges. Elbolaget har inte hört av sig om det. +andakt',
'En tidigare hyresgästs post kommer fortfarande. Adressaten flyttade ut 1971. +andakt',
],
effect: { andakt: 10 },
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
@@ -300,12 +380,38 @@ const LOCATION_VISITS: Record<string, LocationVisit> = {
'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
'Statyn på torget har flyttat en centimeter sedan i förra veckan. Du är den enda som mätt. +kaos',
'Alla klockor på torget visar olika tid. Ingen av dem är fel. +kaos',
'Ett gatumusikkorband spelar samma stycke de spelade förra gången du var här. De säger att de aldrig spelat det förut. +kaos',
'Duvorna flyger i en cirkel över torget som inte sluter sig, om och om igen, tills du slutar titta. +kaos',
],
effect: { kaos: 8 },
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
},
mollan: {
texts: [
'Marknadsstånden på Möllan säljer samma fisk som alltid, men ingen minns vilket hav den kom från. Den smakar rätt ändå. +kaos',
'Ett gäng ungdomar sjunger något på ett språk som låter som tre olika språk samtidigt, i perfekt harmoni. +kaos',
'Fontänen på torget rinner uppåt i en sekund innan den rättar till sig. Ingen annan såg det. Du är säker på att du såg det. +kaos',
'En gatuförsäljare erbjuder en frukt du inte känner igen, från ett land ingen kan namnge. Den är söt. Den är för söt. +kaos',
'Graffitin på muren mot innergården ändras varje gång du vänder ryggen till, alltid samma motiv, aldrig samma vinkel. +kaos',
],
effect: { kaos: 10 },
lockedText: 'Möllevångstorget är fullt av liv, musik, dofter från femtio kök samtidigt. Det är svårt att höra sig själv tänka här. Det är kanske poängen.',
},
universitet: {
texts: [
'Biblioteket har ett rum för sällan efterfrågade samlingar. Din kod fungerar där ingen minns att du fått en. +andakt +dread',
'En föreläsningssal står tom men whiteboarden är full av ekvationer ingen lärare skrivit. Den sista raden är inte matematik. +andakt +dread',
'Doktoranderna på plan tre pratar lägre än vanligt när du går förbi. En av dem tittar upp för länge. +andakt +dread',
'Bibliotekets sökkatalog listar en avhandling om Skriften, publicerad om tre år. Ingen kan checka ut den förrän dess. +andakt +dread',
'Studenthälsan har en anmärkningsvärt hög andel patienter med sömnproblem i just den här korridoren. Ingen har satt det i samband med något. +andakt +dread',
],
effect: { andakt: 12, dread: 6 },
lockedText: 'Malmö universitet vid Niagara. Glas och stål och lysrör som surrar på en frekvens du inte borde kunna höra genom väggarna.',
},
};
export const VISITABLE_LOCATION_IDS = Object.keys(LOCATION_VISITS);
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps
export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
@@ -360,6 +466,10 @@ export function loadState(): State | null {
usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
logSeq: d.logSeq ?? 0,
// Saves from before offline progress existed won't have this — treat
// them as "just now" so an old save doesn't suddenly claim you were
// away since the Unix epoch.
lastRealTime: d.lastRealTime ?? Date.now(),
} as State;
} catch (_) {
return null;

View File

@@ -691,6 +691,123 @@ export const STANDARD_EVENTS: GameEvent[] = [
},
],
},
{
id: 'library_card',
text: 'Stadsbiblioteket har skickat en påminnelse om en bok ingen av er lånat. Titeln går inte att läsa högt utan att stamma. Förfallodatumet var för tre år sedan.',
effect: { dread: 6, kaos: 4 },
weight: 7,
},
{
id: 'malmo_taxi',
text: 'En taxichaufför känner igen er utan att ni sagt vart ni ska och kör direkt till källaren. Han tar ingen betalning. Han ber om något annat istället, artigt, som om det vore normalt.',
effect: { dread: 10, andakt: 10 },
weight: 6,
requires: { minTroende: 3 },
},
{
id: 'weather_wrong',
text: 'SMHI:s prognos för Malmö visar regn. Solen skiner. Appen uppdateras inte, oavsett hur många gånger ni drar ner den.',
effect: { kaos: 10 },
weight: 8,
},
{
id: 'apartment_silence',
text: 'Grannarna ovanför källaren har inte hörts på en vecka. Posten samlas i brevlådan. Ingen av er går och knackar.',
effect: { dread: 8, hemlighet: -4 },
weight: 6,
requires: { minDread: 20 },
},
{
id: 'choice_lost_child',
text: 'Ett barn kommer in i källaren genom en dörr ingen av er lämnat olåst och frågar efter sin mamma. Barnet verkar inte rädd. Det verkar snarare besviken över att träffa er istället.',
effect: {},
weight: 5,
requires: { minDread: 60 },
choices: [
{
text: 'Följ barnet ut och leta efter mamman',
effect: { hemlighet: 5, kaos: -10 },
resultText: 'Ni går ut tillsammans. Gatan är tom. Barnet släpper er hand utan ett ord och går åt ett håll ni inte följer.',
},
{
text: 'Fråga vad hon heter',
effect: { dread: 12, ritual: 3 },
resultText: 'Barnet säger ett namn ingen av er känner igen, men alla skriver ner exakt likadant utan att jämföra.',
},
],
},
{
id: 'malmo_arena_concert',
text: 'En utsåld konsert på Malmö Arena tystnar mitt i en låt, i exakt tolv sekunder, samtidigt som ert möte. Ingen i publiken verkar märka det. Klippet på nätet visar tystnaden. Ljudet finns kvar i videon om man sänker volymen till max.',
effect: { kaos: 12, dread: 8 },
weight: 5,
requires: { minTroende: 4 },
},
{
id: 'mollan_market_stall',
text: 'En marknadshandlare på Möllevångstorget säger att hon känner igen er, fast ingen av er har handlat hos henne förut. Hon lägger till en extra påse i kassen utan att fråga. Innehållet är inte på svenska.',
effect: { andakt: 12, kaos: 6 },
weight: 6,
requires: { unlockedArea: 'rosengard' },
},
{
id: 'university_manuscript_hit',
text: 'En sökning på universitetsbiblioteket ger en enda träff på en fras ur Skriften, i en avhandling om maritim folktro från 1958. Författaren dog samma år. Boken är utlånad. Den har alltid varit utlånad.',
effect: { dread: 14, ritual: 3 },
weight: 5,
requires: { minRitual: 15 },
},
{
id: 'choice_survey_call',
text: 'Statistiska centralbyrån ringer och frågar om er upplevda livskvalitet, på en skala ett till tio. Frågorna blir successivt mer specifika. Till slut frågar hen om ni "hör röster som inte är era egna, och om ni lyssnar."',
effect: {},
weight: 5,
requires: { minDread: 25 },
choices: [
{
text: 'Svara ärligt',
effect: { hemlighet: -15, dread: 5 },
resultText: 'Hen tackar för svaret utan att låta förvånad. Samtalet avslutas normalt. Ni är inte säkra på vem ni just pratade med.',
},
{
text: 'Ljug och lägg på',
effect: { hemlighet: 5, kaos: 8 },
resultText: 'Ni lägger på mitt i en mening. Telefonen ringer igen tre sekunder senare. Ni svarar inte.',
},
],
},
{
id: 'bus_2_driver',
text: 'Föraren på linje 2 hälsar er vid namn när ni kliver på, trots att ingen av er sagt det. Bussen kör en rutt som inte finns i tidtabellen och släpper av er exakt utanför källaren, tio minuter tidigare än ni räknat med.',
effect: { dread: 9, andakt: 6 },
weight: 6,
requires: { minDread: 15 },
},
{
id: 'malmo_live_reflection',
text: 'Glasfasaden på Malmö Live speglar en himmel med fel antal moln jämfört med den riktiga. Ni räknar två gånger. Talen stämmer inte överens med varandra heller.',
effect: { kaos: 9, dread: 6 },
weight: 6,
},
{
id: 'choice_old_photograph',
text: 'En gammal svartvit fotografi dyker upp i källaren, ingen minns var den kom ifrån. Den föreställer nio personer runt ett bord i exakt samma rum. Kläderna är från 1930-talet. En av personerna ser ut som Broder Lindqvist.',
effect: {},
weight: 4,
requires: { minDread: 70, minRitual: 1 },
choices: [
{
text: 'Visa fotografiet för Lindqvist',
effect: { dread: 15, andakt: 10 },
resultText: 'Lindqvist tittar länge. Han säger att han känner igen rummet men inte människorna. Han lägger fotografiet i sin ficka istället för att lämna tillbaka det.',
},
{
text: 'Göm fotografiet',
effect: { kaos: 12, hemlighet: -5 },
resultText: 'Ni lägger det längst ner i en låda. Nästa vecka ligger det på bordet igen, överst.',
},
],
},
];
// ── Kaos events (Henderson-tier) ─────────────────────────────────────────────
@@ -701,6 +818,7 @@ export const KAOS_EVENTS: GameEvent[] = [
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantandet. Tvärtom. Bamse leder nu det andra kvartalets böner.',
effect: { kaos: -40, andakt: 20, troende: 1 },
weight: 10,
requires: { completedEventId: 'discord_goran' },
},
{
id: 'k_influencer',
@@ -765,6 +883,7 @@ export const KAOS_EVENTS: GameEvent[] = [
text: 'Göran har frågat om han kan "ta lite mer ansvar" i föreningen. Vi har utnämnt honom till Väktare av det Tredje Sigillet. Han tolkar det som kassör. Det är ungefär rätt.',
effect: { kaos: -30, andakt: 30, hemlighet: 5 },
weight: 8,
requires: { completedEventId: 'discord_goran' },
},
{
id: 'k_no_survivors',
@@ -778,6 +897,7 @@ export const KAOS_EVENTS: GameEvent[] = [
text: 'Bengt-Åke (tidningsmannen från Husie) visade sig vara med i en helt annan sekt sedan 1987. Hans sekt visade sig vara med i en ännu äldre sekt sedan 1840-talet. Vi håller ett samordningsmöte. Det är produktivt.',
effect: { kaos: -45, andakt: 35, troende: 2 },
weight: 7,
requires: { completedEventId: 'pressbyraan' },
},
{
id: 'k_skinwalker_bus',
@@ -798,7 +918,7 @@ export const KAOS_EVENTS: GameEvent[] = [
text: 'Journalisten från Sydsvenskan som skrev det prisbelönta reportaget har rest till Malmö för en uppföljningsartikel. Han hittade mer än han letade efter. Artikeln publiceras aldrig. Vi sparar hans anteckningar.',
effect: { kaos: -40, hemlighet: 20, ritual: 5 },
weight: 6,
requires: { minDread: 45 },
requires: { minDread: 45, completedEventId: 'k_journalist' },
},
{
id: 'k_kult_archon',
@@ -895,4 +1015,59 @@ export const KAOS_EVENTS: GameEvent[] = [
weight: 3,
requires: { minDread: 220 },
},
{
id: 'k_mall_muzak',
text: 'Bakgrundsmusiken i Triangeln har bytts ut mot en enda ackordföljd som upprepas i sex timmar utan paus. Vaktbolaget säger att de inte kan hitta strömbrytaren. Kunderna handlar mer än vanligt.',
effect: { kaos: -40, andakt: 25 },
weight: 8,
},
{
id: 'k_ferry_returns',
text: 'En färja som togs ur trafik 2019 lade till vid hamnen i natt, i tid och enligt en tidtabell som inte längre existerar. Ingen gick av. Den lade ut igen innan personalen hann kontrollera den.',
effect: { kaos: -55, dread: 40 },
weight: 5,
requires: { minDread: 50 },
},
{
id: 'k_choir_practice',
text: 'Malmö Kammarkör har börjat repetera ett stycke ingen av dem minns att de fått noter till. Dirigenten säger att det "kändes rätt att göra." Konserten är redan fullbokad.',
effect: { kaos: -35, ritual: 4, andakt: 15 },
weight: 7,
requires: { minRitual: 10 },
},
{
id: 'k_construction_halt',
text: 'Ett byggprojekt vid Västra Hamnen har stannat av utan förklaring. Arbetarna säger att marken "kändes fel" på ett sätt ingen av dem kunde beskriva för facket. Platsen står orörd sedan dess.',
effect: { kaos: -40, dread: 30, hemlighet: 10 },
weight: 6,
requires: { minDread: 35 },
},
{
id: 'k_mollan_food_truck',
text: 'En food truck som ingen beställt eller godkänt har parkerat på Möllevångstorget och serverar en rätt som inte står på skylten till alla som frågar. Kommunen kan inte hitta några tillståndspapper. Truckens ägare finns inte i något register.',
effect: { kaos: -40, troende: 2, andakt: 20 },
weight: 7,
requires: { minTroende: 3 },
},
{
id: 'k_university_thesis_defense',
text: 'En doktorandförsvar vid universitetet avbryts när opponenten ställer en fråga ingen i publiken hör men alla ser avhandlingsförfattaren svara på. Betyget blir godkänt med beröm. Ingen av professorerna minns frågan efteråt.',
effect: { kaos: -45, ritual: 6, dread: 20 },
weight: 5,
requires: { minRitual: 10 },
},
{
id: 'k_all_clocks_torso',
text: 'Alla digitala klockor inom synhåll från Turning Torso har visat samma sekund i över en timme. Analoga klockor går som vanligt. Ingen teknisk avdelning kan förklara det, och ingen verkar särskilt intresserad av att försöka.',
effect: { kaos: -60, dread: 50, ritual: 8 },
weight: 4,
requires: { unlockedArea: 'torso', minDread: 70 },
},
{
id: 'k_ribersborg_tide',
text: 'Tidvattnet vid Ribersborg går ut längre än någonsin uppmätt, i tio minuter, och avslöjar stenformationer på havsbotten som inte finns på någon sjökarta. Sedan kommer vattnet tillbaka som om inget hänt. Fotografierna på nätet försvinner inom en timme.',
effect: { kaos: -55, dread: 45, ritual: 6 },
weight: 4,
requires: { minDread: 90 },
},
];

View File

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

View File

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

View File

@@ -161,12 +161,26 @@ body {
color: var(--text) !important;
font-family: inherit !important;
font-size: 11px !important;
padding: 3px 8px !important;
padding: 4px !important;
border-radius: 2px !important;
box-shadow: none !important;
}
.map-tooltip::before { display: none !important; }
.map-tooltip-inner {
display: flex;
align-items: center;
gap: 6px;
}
.map-tooltip-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border: 1px solid var(--border);
flex-shrink: 0;
}
.map-tooltip-inner span { padding-right: 2px; }
/* ── Upgrades ── */
.upgrade-btn {
display: block;
@@ -494,6 +508,82 @@ body {
font-style: italic;
}
#characters-modal-inner { width: min(860px, 94vw); }
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.character-card {
background: var(--bg);
border: 1px solid var(--border);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.character-portrait {
width: 100%;
aspect-ratio: 4 / 5;
background: #08080f;
border: 1px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.character-portrait img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(0.15) contrast(1.05);
}
.character-portrait-fallback,
.locked-portrait {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--dim);
letter-spacing: 0.05em;
}
.character-card.locked .character-portrait.locked-portrait {
background: repeating-linear-gradient(135deg, #08080f, #08080f 6px, #0d0d1a 6px, #0d0d1a 12px);
border-style: dashed;
}
.character-card.locked .character-name,
.character-card.locked .character-role {
color: var(--dim);
}
.character-name {
font-size: 12px;
font-weight: 700;
color: var(--accent);
}
.character-role {
font-size: 10px;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.character-bio {
font-size: 11px;
line-height: 1.6;
color: var(--text);
}
.help-changelog-entry {
margin-bottom: 14px;
}
@@ -511,6 +601,78 @@ body {
margin-left: 6px;
}
.archive-toggle {
display: flex;
gap: 0;
margin-bottom: 12px;
border: 1px solid var(--border);
width: fit-content;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.archive-toggle-btn {
appearance: none;
border: none;
background: transparent;
color: var(--dim);
padding: 7px 12px;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
}
.archive-toggle-btn + .archive-toggle-btn { border-left: 1px solid var(--border); }
.archive-toggle-btn.active { background: var(--accent); color: #0d0d1a; }
.character-card:not(.locked) .character-portrait { cursor: pointer; }
.character-card:not(.locked) .character-portrait:hover img { filter: grayscale(0) contrast(1.1) brightness(1.08); }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(4, 4, 10, 0.94);
z-index: 1100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
padding: 40px;
}
.lightbox.visible { display: flex; }
.lightbox img {
max-width: min(90vw, 720px);
max-height: 78vh;
object-fit: contain;
border: 1px solid var(--border);
}
.lightbox-caption {
font-size: 12px;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.lightbox-blurb {
font-size: 12px;
color: var(--dim);
line-height: 1.7;
max-width: 56ch;
text-align: center;
font-style: italic;
}
.lightbox-blurb:empty { display: none; }
.lightbox-close {
position: absolute;
top: 20px;
right: 24px;
background: none;
border: none;
color: var(--dim);
font-size: 20px;
cursor: pointer;
}
.lightbox-close:hover { color: var(--text); }
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; }
.panel { border-right: none; border-bottom: 1px solid var(--border); }

174
src/ui.ts
View File

@@ -1,6 +1,7 @@
import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts';
import { CHARACTERS } from './characters.ts';
export function initUI(
app: HTMLElement,
@@ -17,6 +18,7 @@ export function initUI(
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
<div class="header-actions">
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
<button id="characters-btn" class="help-btn" title="Sällskapet">Sällskapet</button>
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div>
@@ -75,6 +77,23 @@ export function initUI(
</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>
@@ -167,6 +186,13 @@ export function initUI(
</div>
</div>
</div>
<div id="lightbox" class="lightbox">
<button id="lightbox-close" class="lightbox-close" aria-label="Stäng">✕</button>
<img id="lightbox-img" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
<p id="lightbox-blurb" class="lightbox-blurb"></p>
</div>
`;
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
@@ -188,6 +214,46 @@ export function initUI(
helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) helpModal.classList.toggle('visible'); });
const charactersModal = document.getElementById('characters-modal')!;
const charactersInner = document.getElementById('characters-modal-inner')!;
document.getElementById('characters-btn')!.addEventListener('click', () => {
renderCharacterGrid(latestState);
charactersModal.classList.toggle('visible');
});
document.getElementById('characters-close')!.addEventListener('click', () => charactersModal.classList.remove('visible'));
charactersModal.addEventListener('click', (e) => { if (!charactersInner.contains(e.target as Node)) charactersModal.classList.remove('visible'); });
document.querySelectorAll<HTMLButtonElement>('.archive-toggle-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.archive-toggle-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
archiveMode = btn.dataset['archive'] === 'full';
document.getElementById('characters-hint')!.textContent = archiveMode
? 'Fullständigt arkiv — alla porträtt, oavsett hur långt ni kommit. Klicka för att se dem i fullstorlek.'
: 'De som suttit vid bordet. Vissa längre än andra.';
renderCharacterGrid(latestState);
});
});
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
document.getElementById('characters-grid')!.addEventListener('click', (e) => {
const portrait = (e.target as Element).closest('.character-card:not(.locked) .character-portrait');
if (!portrait) return;
const card = portrait.closest<HTMLElement>('[data-char]');
const img = portrait.querySelector('img');
if (!card || !img?.src) return;
lightboxImg.src = img.src;
lightboxCaption.textContent = card.querySelector('.character-name')?.textContent ?? '';
lightboxBlurb.textContent = '';
lightbox.classList.add('visible');
});
document.getElementById('lightbox-close')!.addEventListener('click', () => lightbox.classList.remove('visible'));
lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.classList.remove('visible'); });
// Split the map container: map on top, log below
const mapContainer = document.getElementById('map-container')!;
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';
@@ -203,7 +269,11 @@ export function initUI(
});
}
let latestState: State | null = null;
let archiveMode = false;
export function renderState(state: State): void {
latestState = state;
setText('r-troende', String(state.troende));
setText('r-andakt', fmt(state.andakt));
setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`);
@@ -333,17 +403,55 @@ function renderUpgrades(state: State): void {
}
let lastLogSeq = -1;
let typewriterTimer: number | undefined;
// Occult stand-ins for the typewriter effect on the newest log line — a
// deeper ritual means a shakier grip on plain Swedish. Never applied
// retroactively to older entries, so history stays legible.
const GLITCH_CHARS = ['☿', '☠', 'ψ', 'Ψ', '⛧', '҂'];
function glitchChar(dreadIntensity: number): string | null {
const chance = Math.min(0.18, dreadIntensity * 0.22);
if (Math.random() < chance) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]!;
return null;
}
function renderLog(state: State): void {
const log = document.getElementById('log')!;
if (state.logSeq === lastLogSeq) return;
const isFirstRender = lastLogSeq === -1;
lastLogSeq = state.logSeq;
// state.log is newest-first (unshift); render as-is, scroll to top
log.innerHTML = state.log.map(e =>
`<div class="log-entry ${e.kind}">${e.text}</div>`
).join('');
// state.log is newest-first (unshift). The newest line gets its own
// element so it can type itself out; everything else renders plainly.
const [newest, ...rest] = state.log;
log.innerHTML = rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).join('')
+ (newest ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '');
log.scrollTop = 0;
if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; }
if (newest) {
const el = document.getElementById('log-newest')!;
if (isFirstRender || state.phase !== 'playing') {
// Don't animate the save you just loaded, or the ending screen's line.
el.textContent = newest.text;
} else {
const dreadIntensity = Math.min(1, state.dread / 150);
const text = newest.text;
const stepMs = text.length > 120 ? 8 : 18;
let i = 0;
typewriterTimer = window.setInterval(() => {
i += 1;
el.textContent = text.slice(0, i).split('').map(ch => (ch === ' ' ? ch : glitchChar(dreadIntensity) ?? ch)).join('');
if (i >= text.length) {
window.clearInterval(typewriterTimer);
typewriterTimer = undefined;
}
}, stepMs);
}
}
if (state.log[0]?.kind === 'kaos') {
const app = document.getElementById('app')!;
app.classList.remove('kaos-glitch');
@@ -352,6 +460,44 @@ function renderLog(state: State): void {
}
}
function renderCharacterGrid(state: State | null): void {
const grid = document.getElementById('characters-grid')!;
const met = new Set(state?.usedEventIds ?? []);
grid.innerHTML = CHARACTERS.map(c => {
const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId);
if (!known) {
return `
<div class="character-card locked">
<div class="character-portrait locked-portrait">?</div>
<div class="character-name">???</div>
<div class="character-role">Inte mött än.</div>
</div>`;
}
return `
<div class="character-card" data-char="${c.id}">
<div class="character-portrait"><img alt="${c.name}" loading="lazy"></div>
<div class="character-name">${c.name}</div>
<div class="character-role">${c.role}</div>
<div class="character-bio">${c.bio}</div>
</div>`;
}).join('');
// Portraits are optional — art may not exist yet for every character.
// Fall back to an initial-letter placeholder rather than a broken image.
for (const c of CHARACTERS) {
const card = grid.querySelector(`[data-char="${c.id}"]`);
const img = card?.querySelector('img');
if (!img) continue;
img.addEventListener('error', () => {
const fallback = document.createElement('div');
fallback.className = 'character-portrait-fallback';
fallback.textContent = c.name.charAt(0);
img.replaceWith(fallback);
}, { once: true });
img.src = c.img;
}
}
function showOverlay(state: State): void {
const ov = document.getElementById('overlay')!;
ov.classList.add('visible');
@@ -375,6 +521,26 @@ function showOverlay(state: State): void {
}
}
// Called on every map marker click, alongside the actual visit — a way to
// see the location's art at a readable size instead of the 40px tooltip
// thumbnail. Probes the image first so a location with no art yet doesn't
// pop an empty modal; the visit itself always happens regardless.
export function showLocationArt(id: string, label: string, blurb: string): void {
const src = `/img/locations/${id}.png`;
const probe = new Image();
probe.onload = () => {
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
lightboxImg.src = src;
lightboxCaption.textContent = label;
lightboxBlurb.textContent = blurb;
lightbox.classList.add('visible');
};
probe.src = src;
}
export function setPausedUI(paused: boolean): void {
const btn = document.getElementById('pause-btn')!;
btn.textContent = paused ? 'Fortsätt' : 'Pausa';

View File

@@ -119,6 +119,15 @@ export const UPGRADES: Upgrade[] = [
available: s => s.upgrades.has('grand_ritual_prep'),
once: false,
},
{
id: 'whisper_guidance',
name: 'Viskningen leder er',
desc: 'Ni behöver inte längre besluta vart ni går. Fötterna vet vägen innan huvudet gör det. Bekvämt. Det är också första gången ingen av er är helt säker på vem som bestämmer längre.',
cost: () => ({ andakt: 600, dread: 80 }),
effect: () => { /* handled by main.ts's tick loop — see WHISPER_INTERVAL_TICKS */ },
available: s => s.upgrades.has('torso_beacon') && !s.upgrades.has('whisper_guidance'),
once: true,
},
];
function ritualDeepenings(s: State): number {