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>
This commit is contained in:
Fredrik Johansson
2026-07-28 21:12:06 +02:00
parent f71878861c
commit 6c9b313783
6 changed files with 305 additions and 37 deletions

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.

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>

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[];
} }