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