From 6c9b31378314d21c3c6e2bd132959513d52dbb15 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Tue, 28 Jul 2026 21:12:06 +0200 Subject: [PATCH] Add real replay variety: 15 new letters, random per-day roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 1 + src/data/content.ts | 179 ++++++++++++++++++++++++++++++++++++++++++++ src/engine.test.ts | 105 +++++++++++++++++++------- src/engine.ts | 50 +++++++++++-- src/main.ts | 4 +- src/types.ts | 3 + 6 files changed, 305 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d36a379..cbf8b37 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ npm run build - Incorrect sorting costs composure and raises suspicion. - A completed day restores one composure; suspicion persists. - 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. diff --git a/src/data/content.ts b/src/data/content.ts index 539dea9..6ebfa38 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -339,6 +339,185 @@ export const LETTERS: Letter[] = [ }, penalty: { deliver: { composure: 2, suspicion: 2 }, return: { suspicion: 2 }, archive: { composure: 2 } }, }, + + // ---- Extra pool, day 1 (drawn at random alongside l01–l04, l21–l22) ---- + { + 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 (1930–2019)', 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 (1930–2019)', 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 = [ diff --git a/src/engine.test.ts b/src/engine.test.ts index 6518429..bf6dc8e 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -1,43 +1,92 @@ 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'; +// 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', () => { - it('starts on the first letter of day one', () => { + it('starts on a real letter from day one', () => { const state = startGame(createState()); + const letter = currentLetter(state); 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', () => { - 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.decisions[0].correct).toBe(true); expect(state.composure).toBe(3); }); 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.composure).toBe(2); - expect(state.suspicion).toBe(1); + expect(state.composure).toBeLessThan(3); + expect(state.suspicion).toBeGreaterThan(0); }); it('ends a day and restores one composure for the next', () => { let state = startGame(createState()); - const routes = lettersForDay(1).map((letter) => letter.correctRoute); - state = routeLetter(state, 'archive'); - for (const route of routes.slice(1)) state = routeLetter(state, route); + const routes = lettersForDay(state, 1).map((letter) => letter.correctRoute); + for (const route of routes) state = routeLetter(state, route); expect(state.phase).toBe('day-end'); state = nextDay(state); expect(state.day).toBe(2); expect(state.composure).toBe(3); }); - it('applies a letter-specific high-risk consequence', () => { - let state = startGame(createState()); - const letters = lettersForDay(1); - for (const letter of letters.slice(0, 5)) state = routeLetter(state, letter.correctRoute); + it('applies a letter-specific high-risk consequence (l22, hand-picked into the roster)', () => { + const roster = ['l22', 'l01', 'l02', 'l03', 'l04', 'l21', ...buildRoster(fakeRng(2)).slice(PER_DAY_COUNT)]; + let state = stateWithRoster(roster); expect(currentLetter(state)?.id).toBe('l22'); state = routeLetter(state, 'deliver'); @@ -47,8 +96,21 @@ describe('sorting engine', () => { expect(state.lastDecision?.consequence).toContain('utan skugga'); }); - it('has six authored letters in every shift', () => { - for (let day = 1; day <= 5; day += 1) expect(lettersForDay(day)).toHaveLength(6); + it('applies a letter-specific penalty override on an early, low-stakes-looking letter (l04)', () => { + // 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', () => { @@ -56,15 +118,4 @@ describe('sorting engine', () => { 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); - }); }); diff --git a/src/engine.ts b/src/engine.ts index b7d15d9..56271ff 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -4,6 +4,10 @@ import type { GameState, Letter, Route } from './types.ts'; export const MAX_COMPOSURE = 3; export const MAX_SUSPICION = 5; 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 = { 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 = { quarantine: 'Karantänskåpet tog emot brevet. Något annat därinne slutade knacka.', }; +function shuffled(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 { return { phase: 'intro', day: 1, letterIndex: 0, accuracy: 100, composure: MAX_COMPOSURE, suspicion: 0, decisions: [], lastDecision: null, + roster: buildRoster(), }; } -export function lettersForDay(day: number): Letter[] { - return LETTERS.filter((letter) => letter.day === day); +export function lettersForDay(state: GameState, day: number): Letter[] { + 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 { - return lettersForDay(state.day)[state.letterIndex] ?? null; + return lettersForDay(state, state.day)[state.letterIndex] ?? null; } 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 composure = Math.max(0, state.composure - composureCost); 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; 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 { - localStorage.setItem('retur-save-v2', JSON.stringify(state)); + localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } export function loadState(): GameState | null { try { - const value = localStorage.getItem('retur-save-v2'); - return value ? JSON.parse(value) as GameState : null; + const value = localStorage.getItem(SAVE_KEY); + if (!value) return null; + const parsed = JSON.parse(value) as GameState; + return Array.isArray(parsed.roster) ? parsed : null; } catch { return null; } } export function clearState(): void { - localStorage.removeItem('retur-save-v2'); + localStorage.removeItem(SAVE_KEY); } diff --git a/src/main.ts b/src/main.ts index 597eeec..f7fcfaf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,7 @@ function renderIntro(): void { function renderDesk(): void { 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 result = showingResult && state.lastDecision ? `
@@ -141,7 +141,7 @@ function advanceAfterResult(): 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; shell(`
DAGSRAPPORT / DAG 0${state.day}
diff --git a/src/types.ts b/src/types.ts index 816a74a..09fca54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,4 +42,7 @@ export interface GameState { suspicion: number; decisions: Decision[]; 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[]; }