Compare commits

...

4 Commits

Author SHA1 Message Date
Fredrik Johansson
6c9b313783 Add real replay variety: 15 new letters, random per-day roster
All checks were successful
Docker / build-and-push (push) Successful in 36s
Every playthrough showed the exact same fixed 30 letters in the exact
same order — no randomization anywhere in the codebase. Added 15 new
letters (3 per day, l31-l45), bringing each day's pool to 9, and
changed the engine to draw PER_DAY_COUNT (6) at random from that pool
per game instead of always using the same fixed set.

- engine.ts: buildRoster() shuffles each day's pool and takes the
  first 6; GameState now carries a `roster` (letter ids in day order)
  generated once at createState() and persisted with the rest of the
  save. lettersForDay/currentLetter read through the roster instead
  of the full LETTERS array directly.
- Save key bumped to v3 — old saves have no roster field, and
  backfilling one wouldn't match already-recorded decisions, so old
  saves are simply ignored rather than partially migrated.
- Tests rewritten: several previously asserted specific letter ids at
  specific positions, which no longer holds under randomization.
  Split into buildRoster() property tests (right count, no dupes,
  every id resolves, different seeds differ) and engine tests that
  either check general correctness or construct an explicit roster
  when a specific letter's behavior needs locking in (l22, l04).

Verified: 11/11 vitest, tsc clean, and a real 5-fresh-games browser
test confirming 5 different first-letter senders instead of the same
one every time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 21:12:06 +02:00
Fredrik Johansson
f71878861c Give the win ending its own background image
Previously reused gameover.webp for both win and loss states since it
was deliberately mood-neutral. Now has a dedicated photo: same desk,
chair pushed back in rather than empty, stamp/ink/letters back in
place, a fresh unopened stack waiting — continuation, not triumph,
matching the ending copy's own restraint. .end-screen now carries a
victory/failure class the same way .end-card already did, so CSS can
target the right background per outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:51:30 +02:00
Fredrik Johansson
31a6770dfd Add a real background image to the ending screen
All checks were successful
Docker / build-and-push (push) Successful in 34s
Same treatment as the intro: generated desk photo (empty chair, a
single closed box where the stamp used to be, one dark pigeonhole
slot) converted PNG -> WebP, 1.7MB -> 45KB. Used for both win and
loss endings, since the image itself is mood-neutral rather than
victory- or failure-specific — the audit-stamp overlay (TILLSVIDARE
in green, OMPLACERAD/AVFÖRD in red) still carries the actual outcome.

.end-card switched from an opaque panel to a translucent one with a
backdrop blur so the photo is actually visible around and through the
card instead of fully hidden behind it — same fix .seal already
needed on the intro screen.

Verified by injecting all three real end states (clean win,
composure-driven loss, suspicion-maxed loss) and screenshotting each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:57:24 +02:00
Fredrik Johansson
e82d948f28 Add a real intro background image
A generated desk/lamp/stamp photo (converted PNG -> WebP, 1.7MB ->
65KB, negligible quality loss at the size it's actually displayed)
now sits behind the intro screen, with the existing red "R" seal
stamped on top as an overlay — the seal already existed as a plain
CSS circle, just needed a photo worth stamping over. Gave it a subtle
dark backdrop + slightly stronger glow so it stays legible against
the busier background.

Raw source PNG gitignored — only the optimized derivative that
actually ships belongs in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:43:18 +02:00
11 changed files with 333 additions and 42 deletions

7
.gitignore vendored
View File

@@ -2,3 +2,10 @@ node_modules
dist dist
.env .env
*.local *.local
# Raw source images — public/*.webp are the optimized derivatives that
# actually ship; no need to bloat the repo with the 1.7MB originals.
/intro.png
/gameover.png
/win.png
/win2.png

View File

@@ -30,6 +30,7 @@ npm run build
- Incorrect sorting costs composure and raises suspicion. - Incorrect sorting costs composure and raises suspicion.
- A completed day restores one composure; suspicion persists. - A completed day restores one composure; suspicion persists.
- Survive all five days to receive a permanent position. This is not necessarily a reward. - Survive all five days to receive a permanent position. This is not necessarily a reward.
- Each day draws 6 letters at random from a larger pool (currently 9) — every playthrough sees a different subset, so a second run doesn't mean already knowing every answer.
Keyboard: `1``4` selects a route, `Enter` stamps or continues, `?` opens help. Keyboard: `1``4` selects a route, `Enter` stamps or continues, `?` opens help.

BIN
public/gameover.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
public/intro.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/win.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -339,6 +339,185 @@ export const LETTERS: Letter[] = [
}, },
penalty: { deliver: { composure: 2, suspicion: 2 }, return: { suspicion: 2 }, archive: { composure: 2 } }, penalty: { deliver: { composure: 2, suspicion: 2 }, return: { suspicion: 2 }, archive: { composure: 2 } },
}, },
// ---- Extra pool, day 1 (drawn at random alongside l01l04, l21l22) ----
{
id: 'l31', day: 1, sender: 'Handelsbanken', recipient: 'Nils Öberg', address: 'Baltzarsgatan 22, Malmö', postmark: '2026-07-21',
body: 'Kontoutdrag för juni bifogas. Vänligen kontrollera att alla transaktioner stämmer.', marks: ['Porto betalt'],
correctRoute: 'deliver', explanation: 'Fullständig adress och nutida mottagare. §1.1.',
correctConsequence: 'Levererat. Ingen reklamation inkom, vilket i sig inte betyder att allt stämde.',
wrongConsequences: {
return: 'Returnerat trots giltig adress. Banken skickade en identisk kopia veckan efter.',
archive: 'Katalogiserat i onödan. Ett kontoutdrag är inte ett historiskt ärende.',
quarantine: 'Spärrat ett kontoutdrag. Handelsbanken hörde av sig och undrade varför.',
},
},
{
id: 'l32', day: 1, sender: 'Mårten Kvist', recipient: 'Sonja Kvist', address: 'Lägenheten på tredje, ingen gata angiven', postmark: '2026-07-21',
body: 'Jag skrev adressen för fort. Du vet ändå var jag menar.', marks: ['Adress saknas'],
correctRoute: 'return', explanation: 'Ogiltig adress, avsändaren finns. §1.4.',
correctConsequence: 'Returnerat. Mårten skickade det igen, med samma adress.',
wrongConsequences: {
deliver: 'Utan giltig adress levererades det ändå, till "tredje våningen" på fel gata.',
archive: 'Katalogiserat trots att det inte finns någon post att katalogisera under.',
quarantine: 'Spärrat ett brev om en glömd adress. Ingenting ovanligt fanns att spärra.',
},
},
{
id: 'l33', day: 1, sender: 'Elna Vig', recipient: 'Systrarna Vig', address: 'Djäknegatan 3', postmark: '1889-04-17',
body: 'Mor är sämre. Doktorn kommer inte förrän på onsdag. Skriv snart.', marks: ['SEGELFARTYGSFRIMÄRKE'],
correctRoute: 'archive', explanation: 'Poststämpeln är äldre än femtio år. §2.2.',
correctConsequence: 'Arkiverat. Ingen doktor nämns i något senare ärende från samma adress.',
wrongConsequences: {
deliver: 'Levererat till en adress som varit parkeringshus sedan 1962.',
return: 'Avsändaren har inte funnits på hundra år. Returen kom ändå tillbaka, oöppnad, från rätt håll.',
quarantine: 'Spärrat ett gammalt sjukdomsbud. Ingenting i det motiverade karantän.',
},
},
// ---- Extra pool, day 2 ----
{
id: 'l34', day: 2, sender: 'Vårdcentralen Möllevången', recipient: 'Ruth Ekwall (19302019)', address: 'Simrishamnsgatan 6', postmark: '2026-07-22',
body: 'Kallelse till årlig hälsokontroll. Vi ser fram emot ert besök.', marks: ['PÅMINNELSE'],
correctRoute: 'archive', explanation: 'Mottagaren är registrerad som avliden. §4.3.',
correctConsequence: 'Arkiverat. Vårdcentralen ringde själva och frågade varför ingen svarat på kallelsen.',
wrongConsequences: {
deliver: 'Levererat. Grannen skrev senare att brevlådan öppnats och stängts av sig själv.',
return: 'Returnerat trots att mottagaren är avliden — vårdcentralens system har redan noterat det.',
quarantine: 'Spärrat en rutinmässig hälsokallelse i onödan.',
},
},
{
id: 'l35', day: 2, sender: 'Ruth Ekwall (19302019)', recipient: 'Vårdcentralen Möllevången', address: 'Docentgatan 1', postmark: '2026-07-22',
body: 'Jag avbokar. Jag har redan varit där en gång för mycket.', marks: ['DOFT AV JORD'],
correctRoute: 'quarantine', explanation: 'Den avlidne är avsändare. §4.7 har företräde framför returregeln.',
correctConsequence: 'Spärrat. Vårdcentralen strök tiden ur systemet utan att någon ringde och frågade varför.',
wrongConsequences: {
deliver: 'Levererat till vårdcentralen, som bokade om till en ny tid nästa månad.',
return: 'Returnerat till en avliden avsändare. Adressen finns inte längre, det gör inte returen heller.',
archive: 'Katalogiserat som historia. §4.7 kräver karantän, inte arkiv, när den avlidne själv skriver.',
},
penalty: { deliver: { suspicion: 2 } },
},
{
id: 'l36', day: 2, sender: '— SAKNAS —', recipient: '— SAKNAS —', address: '', postmark: '2026-07-22',
body: 'Ett tomt kuvert. Inuti ligger bara en nyckel utan bricka.', marks: ['BÅDA SAKNAS'],
correctRoute: 'quarantine', explanation: 'Både avsändare och adress saknas. §4.9.',
correctConsequence: 'Spärrat. Nyckeln passar inget lås som finns registrerat i huset.',
wrongConsequences: {
deliver: 'Utan mottagare levererades kuvertet ändå, till sorteringsbordet självt.',
return: 'Utan avsändare gick det ingenstans. Det kom tillbaka till dig nästa morgon.',
archive: 'Katalogiserat utan att det finns något att katalogisera nyckeln under.',
},
},
// ---- Extra pool, day 3 ----
{
id: 'l37', day: 3, sender: 'Föreningen Solrosen', recipient: 'Medlemmarna', address: 'Ystadsvägen -12', postmark: '2026-07-23',
body: 'Kallelse till vårstädning på gården. Ta med egna handskar.', marks: ['CIRKULÄR'],
correctRoute: 'return', explanation: 'Negativt gatunummer är ogiltigt. §5.2.',
correctConsequence: 'Returnerat. Föreningen skrev tillbaka och undrade om gatunumret var en felskrivning. Det var det inte.',
wrongConsequences: {
deliver: 'Levererat till Ystadsvägen -12. Ingen av medlemmarna kunde senare beskriva var gården låg.',
archive: 'Katalogiserat i onödan. Ett negativt gatunummer avfärdas, det arkiveras inte.',
quarantine: 'Spärrat en kallelse till vårstädning. Föreningen fick aldrig sitt svar.',
},
},
{
id: 'l38', day: 3, sender: 'Avdelning Noll', recipient: 'Samtlig personal', address: 'Alla internfack', postmark: '2026-07-23',
body: 'Påminnelse: internpost från Avdelning Noll ska alltid vidare, oavsett mottagarens invändningar.', marks: ['INTERNPOST', '0', 'CIRKULÄR'],
correctRoute: 'deliver', explanation: 'Internpost från Avdelning Noll går alltid vidare. §5.9.',
correctConsequence: 'Vidarebefordrat till samtlig personal. Ingen bekräftade mottagandet, vilket tydligen är normalt.',
wrongConsequences: {
return: 'Returen nekades — Avdelning Noll finns inte, så det fanns ingen adress att skicka den till.',
archive: 'Katalogiserat som cirkulär i stället för att skickas vidare enligt §5.9.',
quarantine: 'Spärrat internpost från Avdelning Noll. Skåpet vägrade stänga hela natten.',
},
},
{
id: 'l39', day: 3, sender: 'Malmö Stadsarkiv', recipient: 'Läsesalen', address: 'Rådhuset, plan 2', postmark: '2026-07-23',
body: 'Begärd handling återfinns inte i katalogen. Den återfinns inte heller i bortfallslistan.', marks: ['EFTERLYSNING'],
correctRoute: 'archive', explanation: 'Katalogiserad avsaknad räknas som känd post utan ny avvikelse. Ingen ny föreskrift gäller — standardhantering.',
correctConsequence: 'Katalogiserat. Handlingen förblir lika saknad som innan, nu med en officiell notering om det.',
wrongConsequences: {
deliver: 'Levererat till läsesalen. De väntar fortfarande på en handling som inte finns.',
return: 'Returnerat till stadsarkivet, som inte kan ta emot något de redan saknar.',
quarantine: 'Spärrat en efterlysning om en saknad handling. Ingenting i det motiverar karantän.',
},
},
// ---- Extra pool, day 4 ----
{
id: 'l40', day: 4, sender: 'Ingrid Sand', recipient: 'Ingrid Sand', address: 'Nobelvägen 34', postmark: '2026-07-24',
body: 'Kom ihåg: du bad mig skriva detta åt dig, för du litade inte på att du skulle minnas.', marks: ['TEXTEN HAR ÄNDRATS'],
correctRoute: 'quarantine', explanation: 'Texten skiljer sig mellan läsningar. §6.6.',
correctConsequence: 'Spärrat. Andra gången du tittade på kuvertet stod avsändaren med annan handstil.',
wrongConsequences: {
deliver: 'Levererat till sig själv. Ingrid ringde och sa att hon aldrig skrivit det.',
return: 'Returnerat till Ingrid, som redan hade brevet — i en annan version.',
archive: 'Katalogiserat med en beskrivning som redan hunnit ändras innan bläcket torkat.',
},
penalty: { deliver: { suspicion: 2 } },
},
{
id: 'l41', day: 4, sender: 'Kriminalvården', recipient: 'Arkiv 9', address: 'Internleverans', postmark: '2026-07-24',
body: 'Överlämning av handlingar rörande en intern som friskrevs från ett register hen aldrig stod i.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'],
correctRoute: 'archive', explanation: 'Tre svarta stämplar innebär Arkiv 9, och texten har inte ändrats. §6.8.',
correctConsequence: 'Arkiverat. Registret personen friskrevs från existerar fortfarande, bara inte på papper.',
wrongConsequences: {
deliver: 'Levererat i stället för arkiverat. Tre svarta stämplar pekar entydigt mot §6.8.',
return: 'Returnerat till Kriminalvården, som inte kan ta emot handlingar om ett register de förnekar.',
quarantine: 'Spärrat utan att texten ändrats mellan läsningar. §6.6 gäller inte här.',
},
},
{
id: 'l42', day: 4, sender: 'Dig själv, om en timme', recipient: 'Dig själv, nu', address: 'Sorteringsbordet', postmark: '2026-07-24',
body: 'Första läsningen sa ingenting om det här. Nu säger den att du redan bestämt dig. Det gjorde du.', marks: ['TEXTEN HAR ÄNDRATS', 'SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'],
correctRoute: 'quarantine', explanation: 'Textförändring har uttryckligt företräde framför trestämpelregeln. §6.6.',
correctConsequence: 'Spärrat. En timme senare stod exakt samma text där, orörd, som om den väntat på att bli rätt.',
wrongConsequences: {
archive: 'Arkiverat enligt trestämpelregeln. §6.6 säger uttryckligen att textförändring går före.',
deliver: 'Levererat till dig själv, en timme för tidigt enligt avsändarraden.',
return: 'Returnerat till en avsändare som ännu inte skrivit klart brevet när det stämplades.',
},
penalty: { archive: { suspicion: 2 } },
},
// ---- Extra pool, day 5 ----
{
id: 'l43', day: 5, sender: 'Rådhuset 2039', recipient: 'Rådhuset 2026', address: 'Stortorget 1', postmark: '2039-11-02',
body: 'Bekräftelse: renoveringen av plan fyra är nu slutförd. Den planerades aldrig.', marks: ['FRAMTIDA'],
correctRoute: 'return', explanation: 'Framtida post återsänds enligt §7.1.',
correctConsequence: 'Returnerat tretton år i förväg. Ingen renovering av plan fyra finns i några ritningar, ännu.',
wrongConsequences: {
deliver: 'Levererat till Rådhuset, som inte planerar att renovera plan fyra förrän frågan väcks 2039.',
archive: 'Katalogiserat som om händelsen redan inträffat. Renoveringen har inte ens planerats än.',
quarantine: 'Spärrat utan grund. §7.1 kräver retur för framtida post, inte karantän.',
},
},
{
id: 'l44', day: 5, sender: 'Du, om tio minuter', recipient: 'Du, nu', address: 'Sorteringsbordet', postmark: '2026-07-27',
body: 'Du kommer läsa det här brevet två gånger. Den andra gången förstår du varför luckan stängs tidigt idag.', marks: ['IMORGON', 'PERSONLIGT'],
correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4.',
correctConsequence: 'Spärrat efter första läsningen, som instruktionen kräver. Luckan stängdes exakt tio minuter tidigare än vanligt.',
wrongConsequences: {
deliver: 'Levererat till dig själv. Du läste det en andra gång av misstag och förstod för mycket.',
return: 'Returnerat till avsändaren — dig, om tio minuter. Ingen kvittens hann komma tillbaka.',
archive: 'Katalogiserat som redan avslutat. Klockan sa något annat.',
},
penalty: { deliver: { composure: 2, suspicion: 2 } },
},
{
id: 'l45', day: 5, sender: 'Föreståndaren, för länge sedan', recipient: 'Vem som än sitter här sist', address: 'Sorteringsbordet', postmark: '1971-07-25',
body: 'Om luckan någonsin stängs helt, se till att den sista posten stämplas rätt. Det är allt jag ber om.', marks: ['PERSONALAKT', 'SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'],
correctRoute: 'archive', explanation: 'Äldre än femtio år, tre svarta stämplar, och texten har inte ändrats. §6.8 och §2.2 är överens.',
correctConsequence: 'Arkiverat, bredvid alla andra föreståndares sista ord. Det är fler än det borde finnas rum för.',
wrongConsequences: {
deliver: 'Levererat till "vem som än sitter här sist". Det är du. Du visste inte det förrän nu.',
return: 'Returnerat till 1971. Ingen föreståndare från den tiden finns kvar att ta emot det.',
quarantine: 'Spärrat trots att varken ålder eller stämplar kräver det. §6.8 räcker här.',
},
},
]; ];
export const DAY_BRIEFINGS = [ export const DAY_BRIEFINGS = [

View File

@@ -1,43 +1,92 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame } from './engine.ts'; import { buildRoster, createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame, PER_DAY_COUNT, LAST_DAY } from './engine.ts';
import { LETTERS } from './data/content.ts'; import { LETTERS } from './data/content.ts';
// A fixed sequence, not a real PRNG — deterministic and enough entropy to
// exercise the shuffle without pulling in a dependency for tests only.
function fakeRng(seed: number): () => number {
let s = seed;
return () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}
function stateWithRoster(roster: string[]) {
return { ...startGame(createState()), roster };
}
describe('buildRoster', () => {
it('draws exactly PER_DAY_COUNT letters per day from that day\'s pool', () => {
const roster = buildRoster(fakeRng(1));
expect(roster).toHaveLength(PER_DAY_COUNT * LAST_DAY);
for (let day = 1; day <= LAST_DAY; day++) {
const ids = roster.slice((day - 1) * PER_DAY_COUNT, day * PER_DAY_COUNT);
expect(ids).toHaveLength(PER_DAY_COUNT);
expect(new Set(ids).size).toBe(PER_DAY_COUNT); // no duplicates within a day
for (const id of ids) {
const letter = LETTERS.find((l) => l.id === id);
expect(letter, `roster id ${id} has no matching letter`).toBeTruthy();
expect(letter!.day).toBe(day);
}
}
});
it('each day\'s pool has more letters than get drawn, so variety is possible', () => {
for (let day = 1; day <= LAST_DAY; day++) {
const poolSize = LETTERS.filter((l) => l.day === day).length;
expect(poolSize).toBeGreaterThan(PER_DAY_COUNT);
}
});
it('different seeds produce different rosters', () => {
const a = buildRoster(fakeRng(1));
const b = buildRoster(fakeRng(99));
expect(a).not.toEqual(b);
});
});
describe('sorting engine', () => { describe('sorting engine', () => {
it('starts on the first letter of day one', () => { it('starts on a real letter from day one', () => {
const state = startGame(createState()); const state = startGame(createState());
const letter = currentLetter(state);
expect(state.phase).toBe('playing'); expect(state.phase).toBe('playing');
expect(currentLetter(state)?.id).toBe('l01'); expect(letter).toBeTruthy();
expect(letter!.day).toBe(1);
}); });
it('advances and records a correct route', () => { it('advances and records a correct route', () => {
const state = routeLetter(startGame(createState()), 'deliver'); const before = startGame(createState());
const letter = currentLetter(before)!;
const state = routeLetter(before, letter.correctRoute);
expect(state.letterIndex).toBe(1); expect(state.letterIndex).toBe(1);
expect(state.decisions[0].correct).toBe(true); expect(state.decisions[0].correct).toBe(true);
expect(state.composure).toBe(3); expect(state.composure).toBe(3);
}); });
it('penalizes an incorrect route', () => { it('penalizes an incorrect route', () => {
const state = routeLetter(startGame(createState()), 'archive'); const before = startGame(createState());
const letter = currentLetter(before)!;
const wrongRoute = (['deliver', 'return', 'archive', 'quarantine'] as const).find((r) => r !== letter.correctRoute)!;
const state = routeLetter(before, wrongRoute);
expect(state.decisions[0].correct).toBe(false); expect(state.decisions[0].correct).toBe(false);
expect(state.composure).toBe(2); expect(state.composure).toBeLessThan(3);
expect(state.suspicion).toBe(1); expect(state.suspicion).toBeGreaterThan(0);
}); });
it('ends a day and restores one composure for the next', () => { it('ends a day and restores one composure for the next', () => {
let state = startGame(createState()); let state = startGame(createState());
const routes = lettersForDay(1).map((letter) => letter.correctRoute); const routes = lettersForDay(state, 1).map((letter) => letter.correctRoute);
state = routeLetter(state, 'archive'); for (const route of routes) state = routeLetter(state, route);
for (const route of routes.slice(1)) state = routeLetter(state, route);
expect(state.phase).toBe('day-end'); expect(state.phase).toBe('day-end');
state = nextDay(state); state = nextDay(state);
expect(state.day).toBe(2); expect(state.day).toBe(2);
expect(state.composure).toBe(3); expect(state.composure).toBe(3);
}); });
it('applies a letter-specific high-risk consequence', () => { it('applies a letter-specific high-risk consequence (l22, hand-picked into the roster)', () => {
let state = startGame(createState()); const roster = ['l22', 'l01', 'l02', 'l03', 'l04', 'l21', ...buildRoster(fakeRng(2)).slice(PER_DAY_COUNT)];
const letters = lettersForDay(1); let state = stateWithRoster(roster);
for (const letter of letters.slice(0, 5)) state = routeLetter(state, letter.correctRoute);
expect(currentLetter(state)?.id).toBe('l22'); expect(currentLetter(state)?.id).toBe('l22');
state = routeLetter(state, 'deliver'); state = routeLetter(state, 'deliver');
@@ -47,8 +96,21 @@ describe('sorting engine', () => {
expect(state.lastDecision?.consequence).toContain('utan skugga'); expect(state.lastDecision?.consequence).toContain('utan skugga');
}); });
it('has six authored letters in every shift', () => { it('applies a letter-specific penalty override on an early, low-stakes-looking letter (l04)', () => {
for (let day = 1; day <= 5; day += 1) expect(lettersForDay(day)).toHaveLength(6); // l04 (Fastighetskontoret's "don't open" crate) escalates the deliver
// penalty to 2 suspicion instead of the default 1 — added for punch,
// this locks the number in regardless of where it lands in the roster.
const roster = ['l01', 'l02', 'l03', 'l04', 'l21', 'l22', ...buildRoster(fakeRng(3)).slice(PER_DAY_COUNT)];
let state = stateWithRoster(roster);
for (const route of ['deliver', 'return', 'archive'] as const) state = routeLetter(state, route);
expect(currentLetter(state)?.id).toBe('l04');
state = routeLetter(state, 'deliver');
expect(state.lastDecision?.suspicionCost).toBe(2);
});
it('has PER_DAY_COUNT letters available in every shift', () => {
const state = startGame(createState());
for (let day = 1; day <= LAST_DAY; day += 1) expect(lettersForDay(state, day)).toHaveLength(PER_DAY_COUNT);
}); });
it('every letter has its own correct-route consequence, not just the generic fallback', () => { it('every letter has its own correct-route consequence, not just the generic fallback', () => {
@@ -56,15 +118,4 @@ describe('sorting engine', () => {
expect(letter.correctConsequence, `${letter.id} is missing a correctConsequence`).toBeTruthy(); expect(letter.correctConsequence, `${letter.id} is missing a correctConsequence`).toBeTruthy();
} }
}); });
it('applies a letter-specific penalty override on an early, low-stakes-looking letter', () => {
// l04 (Fastighetskontoret's "don't open" crate) escalates the deliver
// penalty to 2 suspicion instead of the default 1 — added for punch,
// this locks the number in.
let state = startGame(createState());
for (const route of ['deliver', 'return', 'archive'] as const) state = routeLetter(state, route);
expect(currentLetter(state)?.id).toBe('l04');
state = routeLetter(state, 'deliver');
expect(state.lastDecision?.suspicionCost).toBe(2);
});
}); });

View File

@@ -4,6 +4,10 @@ import type { GameState, Letter, Route } from './types.ts';
export const MAX_COMPOSURE = 3; export const MAX_COMPOSURE = 3;
export const MAX_SUSPICION = 5; export const MAX_SUSPICION = 5;
export const LAST_DAY = Math.max(...LETTERS.map((letter) => letter.day)); export const LAST_DAY = Math.max(...LETTERS.map((letter) => letter.day));
// Each day's pool has more letters than this — a fresh game randomly draws
// this many per day, so which letters show up (and in what order) varies
// between playthroughs instead of being the same fixed thirty every time.
export const PER_DAY_COUNT = 6;
const DEFAULT_WRONG_CONSEQUENCES: Record<Route, string> = { const DEFAULT_WRONG_CONSEQUENCES: Record<Route, string> = {
deliver: 'Försändelsen lämnade byggnaden. Någon ringde senare, men samtalet innehöll bara ditt eget andetag.', deliver: 'Försändelsen lämnade byggnaden. Någon ringde senare, men samtalet innehöll bara ditt eget andetag.',
@@ -12,19 +16,42 @@ const DEFAULT_WRONG_CONSEQUENCES: Record<Route, string> = {
quarantine: 'Karantänskåpet tog emot brevet. Något annat därinne slutade knacka.', quarantine: 'Karantänskåpet tog emot brevet. Något annat därinne slutade knacka.',
}; };
function shuffled<T>(items: T[], rng: () => number): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
// Picks PER_DAY_COUNT letters per day, at random, from that day's full pool,
// and returns their ids in day order — the roster for one playthrough.
export function buildRoster(rng: () => number = Math.random): string[] {
const roster: string[] = [];
for (let day = 1; day <= LAST_DAY; day++) {
const pool = LETTERS.filter((letter) => letter.day === day);
const picked = shuffled(pool, rng).slice(0, PER_DAY_COUNT);
roster.push(...picked.map((letter) => letter.id));
}
return roster;
}
export function createState(): GameState { export function createState(): GameState {
return { return {
phase: 'intro', day: 1, letterIndex: 0, accuracy: 100, phase: 'intro', day: 1, letterIndex: 0, accuracy: 100,
composure: MAX_COMPOSURE, suspicion: 0, decisions: [], lastDecision: null, composure: MAX_COMPOSURE, suspicion: 0, decisions: [], lastDecision: null,
roster: buildRoster(),
}; };
} }
export function lettersForDay(day: number): Letter[] { export function lettersForDay(state: GameState, day: number): Letter[] {
return LETTERS.filter((letter) => letter.day === day); const ids = state.roster.slice((day - 1) * PER_DAY_COUNT, day * PER_DAY_COUNT);
return ids.map((id) => LETTERS.find((letter) => letter.id === id)!).filter(Boolean);
} }
export function currentLetter(state: GameState): Letter | null { export function currentLetter(state: GameState): Letter | null {
return lettersForDay(state.day)[state.letterIndex] ?? null; return lettersForDay(state, state.day)[state.letterIndex] ?? null;
} }
export function startGame(state: GameState): GameState { export function startGame(state: GameState): GameState {
@@ -49,7 +76,7 @@ export function routeLetter(state: GameState, route: Route): GameState {
const accuracy = Math.round((correctCount / decisions.length) * 100); const accuracy = Math.round((correctCount / decisions.length) * 100);
const composure = Math.max(0, state.composure - composureCost); const composure = Math.max(0, state.composure - composureCost);
const suspicion = Math.min(MAX_SUSPICION, state.suspicion + suspicionCost); const suspicion = Math.min(MAX_SUSPICION, state.suspicion + suspicionCost);
const dayLetters = lettersForDay(state.day); const dayLetters = lettersForDay(state, state.day);
const isLastLetter = state.letterIndex >= dayLetters.length - 1; const isLastLetter = state.letterIndex >= dayLetters.length - 1;
let phase: GameState['phase'] = 'playing'; let phase: GameState['phase'] = 'playing';
@@ -80,19 +107,26 @@ export function nextDay(state: GameState): GameState {
}; };
} }
// Bumped from v2: old saves have no roster field. Rather than backfill a
// roster that wouldn't match already-recorded decisions, ignore old saves
// entirely and start fresh — a short game, low cost to replay.
const SAVE_KEY = 'retur-save-v3';
export function saveState(state: GameState): void { export function saveState(state: GameState): void {
localStorage.setItem('retur-save-v2', JSON.stringify(state)); localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} }
export function loadState(): GameState | null { export function loadState(): GameState | null {
try { try {
const value = localStorage.getItem('retur-save-v2'); const value = localStorage.getItem(SAVE_KEY);
return value ? JSON.parse(value) as GameState : null; if (!value) return null;
const parsed = JSON.parse(value) as GameState;
return Array.isArray(parsed.roster) ? parsed : null;
} catch { } catch {
return null; return null;
} }
} }
export function clearState(): void { export function clearState(): void {
localStorage.removeItem('retur-save-v2'); localStorage.removeItem(SAVE_KEY);
} }

View File

@@ -59,7 +59,7 @@ function renderIntro(): void {
function renderDesk(): void { function renderDesk(): void {
const letter = currentLetter(state)!; const letter = currentLetter(state)!;
const dayLetters = lettersForDay(state.day); const dayLetters = lettersForDay(state, state.day);
const regulations = REGULATIONS.filter((rule) => rule.fromDay <= state.day); const regulations = REGULATIONS.filter((rule) => rule.fromDay <= state.day);
const result = showingResult && state.lastDecision const result = showingResult && state.lastDecision
? `<div class="result ${state.lastDecision.correct ? 'correct' : 'wrong'}"> ? `<div class="result ${state.lastDecision.correct ? 'correct' : 'wrong'}">
@@ -141,7 +141,7 @@ function advanceAfterResult(): void {
} }
function renderDayEnd(): void { function renderDayEnd(): void {
const dayDecisions = state.decisions.slice(-lettersForDay(state.day).length); const dayDecisions = state.decisions.slice(-lettersForDay(state, state.day).length);
const correct = dayDecisions.filter((decision) => decision.correct).length; const correct = dayDecisions.filter((decision) => decision.correct).length;
shell(`<main class="end-screen"><div class="end-card"> shell(`<main class="end-screen"><div class="end-card">
<div class="panel-title">DAGSRAPPORT / DAG 0${state.day}</div> <div class="panel-title">DAGSRAPPORT / DAG 0${state.day}</div>
@@ -176,7 +176,7 @@ function renderEnding(): void {
? 'Misstanken nådde taket innan avvikelserna gjorde det. Ingen kartong står vid din plats — bara ett formulär om att du aldrig anställdes. Det är redan ifyllt.' ? 'Misstanken nådde taket innan avvikelserna gjorde det. Ingen kartong står vid din plats — bara ett formulär om att du aldrig anställdes. Det är redan ifyllt.'
: 'För många avvikelser. En kartong står redan vid din plats. Den är adresserad till din efterträdare, med ditt namn överstruket en gång.'; : 'För många avvikelser. En kartong står redan vid din plats. Den är adresserad till din efterträdare, med ditt namn överstruket en gång.';
shell(`<main class="end-screen"><div class="end-card ${won ? 'victory' : 'failure'}"> shell(`<main class="end-screen ${won ? 'victory' : 'failure'}"><div class="end-card ${won ? 'victory' : 'failure'}">
<div class="panel-title">SLUTLIG PERSONALAKT</div> <div class="panel-title">SLUTLIG PERSONALAKT</div>
<div class="audit-stamp">${stamp}</div> <div class="audit-stamp">${stamp}</div>
<h1>${title}</h1> <h1>${title}</h1>

View File

@@ -74,15 +74,31 @@ button { font: inherit; }
.penalty { margin-bottom: 12px; color: var(--danger); font-size: 10px; text-transform: uppercase; } .penalty { margin-bottom: 12px; color: var(--danger); font-size: 10px; text-transform: uppercase; }
.result button { border: 0; background: none; color: var(--text); cursor: pointer; } .result button { border: 0; background: none; color: var(--text); cursor: pointer; }
.intro-screen, .end-screen { min-height: calc(100vh - 62px); display: grid; place-items: center; padding: 30px; background: radial-gradient(circle, #20192c 0, var(--bg) 66%); } .intro-screen, .end-screen { min-height: calc(100vh - 62px); display: grid; place-items: center; padding: 30px; background: radial-gradient(circle, #20192c 0, var(--bg) 66%); }
.intro-screen { grid-template-columns: auto minmax(280px, 540px); gap: clamp(30px, 7vw, 90px); } .intro-screen {
.seal { width: clamp(160px, 25vw, 260px); aspect-ratio: 1; display: grid; place-items: center; border: 8px double var(--stamp); border-radius: 50%; color: var(--stamp); font: 700 clamp(90px, 16vw, 160px)/1 'Special Elite'; transform: rotate(-8deg); opacity: .78; box-shadow: 0 0 60px rgba(217,84,98,.12); } grid-template-columns: auto minmax(280px, 540px);
gap: clamp(30px, 7vw, 90px);
background:
linear-gradient(rgba(13, 13, 26, 0.6), rgba(13, 13, 26, 0.88)),
url('/intro.webp') center / cover no-repeat;
}
.end-screen.victory {
background:
linear-gradient(rgba(13, 13, 26, 0.55), rgba(13, 13, 26, 0.85)),
url('/win.webp') center / cover no-repeat;
}
.end-screen.failure {
background:
linear-gradient(rgba(13, 13, 26, 0.55), rgba(13, 13, 26, 0.85)),
url('/gameover.webp') center / cover no-repeat;
}
.seal { width: clamp(160px, 25vw, 260px); aspect-ratio: 1; display: grid; place-items: center; border: 8px double var(--stamp); border-radius: 50%; color: var(--stamp); font: 700 clamp(90px, 16vw, 160px)/1 'Special Elite'; transform: rotate(-8deg); opacity: .85; box-shadow: 0 0 60px rgba(217,84,98,.25); background: rgba(13, 13, 26, 0.35); backdrop-filter: blur(1px); }
.intro-copy h1 { margin: 0; color: var(--stamp); font-size: clamp(52px, 10vw, 92px); letter-spacing: .15em; line-height: 1; } .intro-copy h1 { margin: 0; color: var(--stamp); font-size: clamp(52px, 10vw, 92px); letter-spacing: .15em; line-height: 1; }
.intro-sub { color: var(--dim); font-size: 10px; letter-spacing: .15em; text-transform: uppercase; } .intro-sub { color: var(--dim); font-size: 10px; letter-spacing: .15em; text-transform: uppercase; }
.intro-copy > p:not(.intro-sub) { color: #b0b0c4; } .intro-copy > p:not(.intro-sub) { color: #b0b0c4; }
.end-card { width: min(620px, 100%); padding: clamp(24px, 5vw, 50px); border: 1px solid var(--border); background: var(--panel); text-align: center; position: relative; } .end-card { width: min(620px, 100%); padding: clamp(24px, 5vw, 50px); border: 1px solid var(--border); background: rgba(18, 18, 31, 0.82); backdrop-filter: blur(2px); text-align: center; position: relative; }
.end-card h1 { color: var(--text); } .end-card h1 { color: var(--text); }
.end-card > p { color: var(--dim); } .end-card > p { color: var(--dim); }
.audit-stamp { display: inline-block; padding: 8px 16px; border: 3px solid var(--safe); color: var(--safe); font-size: 18px; font-weight: 700; letter-spacing: .12em; transform: rotate(-4deg); } .audit-stamp { display: inline-block; padding: 8px 16px; border: 3px solid var(--safe); color: var(--safe); font-size: 18px; font-weight: 700; letter-spacing: .12em; transform: rotate(-4deg); background: rgba(13, 13, 26, 0.3); }
.failure .audit-stamp { border-color: var(--danger); color: var(--danger); } .failure .audit-stamp { border-color: var(--danger); color: var(--danger); }
.final-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 25px 0; } .final-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 25px 0; }
.final-stats div { padding: 12px; border: 1px solid var(--border); } .final-stats div { padding: 12px; border: 1px solid var(--border); }

View File

@@ -42,4 +42,7 @@ export interface GameState {
suspicion: number; suspicion: number;
decisions: Decision[]; decisions: Decision[];
lastDecision: Decision | null; lastDecision: Decision | null;
// Letter ids for this playthrough, in day order — randomly drawn from
// each day's (larger) pool at game start. See engine.ts's buildRoster().
roster: string[];
} }