diff --git a/README.md b/README.md index 30a8a1d..24bc8a8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The reports aren't invented from nothing — several cite real events from `djup Unlike Retur, **escalating always costs you something**, whether or not it was the correct call — the *Insyn* (insight/notice) meter rises by one every time you choose `ESKALERA`, on top of any wrong-route penalty. The regulations sometimes tell you the right move is to escalate; doing so is never free. That's the core tension: being correct doesn't make you invisible. +Each shift also draws 5 reports at random from a larger pool (currently 8) — same pattern as Retur — so which reports you see, and in what order, varies between playthroughs. + ## Development ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 07859a7..85f744d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,4 +3,4 @@ services: image: ${IMAGE:-diariet:local} restart: unless-stopped ports: - - "${PORT:-3011}:80" + - "${PORT:-5692}:80" diff --git a/src/data/content.ts b/src/data/content.ts index 6d9cadc..f48744f 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -346,6 +346,202 @@ export const REPORTS: Report[] = [ eskalera: 'Överlämnat för ett samtal om ekorrar och årstider. Ingenting kom tillbaka, av goda skäl den här gången.', }, }, + + // ---- Extra pool, day 1 (drawn at random alongside d01–d05) ---- + { + id: 'd26', day: 1, source: 'Bekant till anmälaren', location: 'Ribersborg', filed: '2026-08-03', + body: 'En vän säger att havet "talar" till honom om nätterna. Jag tror det bara är stress. Jag skriver in det ändå, för säkerhets skull.', + marks: ['ANDRAHANDSUPPGIFT', 'ENSTAKA VITTNE'], + correctRoute: 'avfarda', explanation: 'Ett andrahandsvittnesmål utan mätbar detalj eller korroborering. §1.1.', + correctConsequence: 'Avfärdat. Anmälaren hörde inte av sig igen. Vännen gjorde det inte heller.', + wrongConsequences: { + utred: 'Flaggat för ett andrahandsvittnesmål utan mätbar detalj. Uppföljningen hade ingenting att gå på.', + arkivera: 'Katalogiserat utan att det finns ett fenomen att katalogisera under.', + eskalera: 'Överlämnat för ett samtal om stress och sömnbrist. Ingenting kom tillbaka.', + }, + }, + { + id: 'd27', day: 1, source: 'Personal, Systembolaget Davidshallsgatan', location: 'Davidshallsgatan', filed: '2026-08-03', + body: 'Samma kund som förra månaden erbjöd sig igen att "hälsa på gubben i havet". Vi har redan en anmälan om honom i era register.', + marks: ['KÄND LOKAL', 'ÅTERKOMMANDE'], + correctRoute: 'arkivera', explanation: 'Platsen och personen är redan katalogiserade utan ny avvikelse. §1.2.', + correctConsequence: 'Katalogiserat under befintlig post. Kunden kommer sannolikt tillbaka nästa månad också.', + wrongConsequences: { + avfarda: 'Avfärdat trots att posten redan finns i katalogen.', + utred: 'Flaggat för uppföljning av något som redan är känt och dokumenterat sedan tidigare.', + eskalera: 'Överlämnat för en känd, återkommande post. Svaret var kort: "Se tidigare ärende."', + }, + }, + { + id: 'd28', day: 1, source: 'Student, Malmö Universitet', location: 'Turning Torso', filed: '2026-08-03', + body: 'Byggnadens skugga pekar rakt mot havet, oavsett var solen står på himlen. Jag har mätt det tre dagar i rad vid olika tider. Vinkeln ändras aldrig.', + marks: ['STRUKTURELL AVVIKELSE', 'MÄTT UPPREPADE GÅNGER'], + correctRoute: 'eskalera', explanation: 'En astronomisk/strukturell avvikelse i en byggnad. §1.4, omedelbart.', + correctConsequence: 'Överlämnat med mätdata bifogad. Ingen bekräftelse kom tillbaka. Studenten bytte ämne för sin uppsats utan förklaring.', + wrongConsequences: { + avfarda: 'Avfärdat trots upprepade, dokumenterade mätningar av en omöjlig skugga.', + arkivera: 'Katalogiserat som känt fenomen. Det var första gången någon mätt det systematiskt.', + utred: 'Flaggat för uppföljning i stället för omedelbar eskalering. §1.4 medger inget mellansteg.', + }, + penalty: { avfarda: { insight: 2 } }, + }, + + // ---- Extra pool, day 2 ---- + { + id: 'd29', day: 2, source: 'Hyresgäst', location: 'Möllevången', filed: '2026-08-04', + body: 'Ett bord av samma modell som i den tidigare rapporten har börjat göra samma sak här, tre kvarter bort. Vi har lagt en handduk över det också.', + marks: ['KÄND FÖREMÅLSTYP'], + correctRoute: 'arkivera', explanation: 'Samma katalogiserade föremålstyp som tidigare ärenden. §2.1.', + correctConsequence: 'Katalogiserat under befintlig post. Två adresser noteras nu under samma möbeltyp.', + wrongConsequences: { + eskalera: 'Överlämnat för en redan katalogiserad föremålstyp. Svaret hade sannolikt varit detsamma som förra gången.', + avfarda: 'Avfärdat trots befintlig katalogpost.', + utred: 'Flaggat för uppföljning av något redan väldokumenterat.', + }, + }, + { + id: 'd30', day: 2, source: 'Anställd, Öresundsbrokonsortiet', location: 'Öresundsbron', filed: '2026-08-04', + body: 'Bron vibrerar med en frekvens våra instrument inte kan förklara. Danska sidan ringer och klagar. Vi vet inte vad vi ska svara dem.', + marks: ['OKATALOGISERAD TYP', 'MÄTBAR DETALJ'], + correctRoute: 'eskalera', explanation: 'En fenomentyp utan tidigare katalogpost. §2.2 har företräde, trots den mätbara detaljen.', + correctConsequence: 'Överlämnat. Ingen bekräftelse kom tillbaka. Vibrationerna nämns inte i några senare mätrapporter, vilket inte är samma sak som att de upphörde.', + wrongConsequences: { + utred: 'Flaggat på grund av den mätbara detaljen. §2.2 kräver eskalering direkt när fenomentypen är ny.', + arkivera: 'Katalogiserat under en post som inte matchar. Ingen tidigare bro-vibration finns registrerad.', + avfarda: 'Avfärdat trots mätdata från brokonsortiets egna instrument.', + }, + penalty: { avfarda: { insight: 2 } }, + }, + { + id: 'd31', day: 2, source: 'Elev, 9 år', location: 'ICA Maxi, Fosie', filed: '2026-08-04', + body: 'Kassörskan har inte blinkat på tre veckor. Jag räknade i kön. Mamma sa att jag skulle sluta stirra.', + marks: ['MINDERÅRIG ANMÄLARE'], + correctRoute: 'utred', explanation: 'Anmälare under 12 år. §2.3 kräver uppföljning oavsett innehåll.', + correctConsequence: 'Flaggat. Uppföljningen noterar kassörskan för fortsatt observation. Eleven kontaktas inte igen.', + wrongConsequences: { + avfarda: 'Avfärdat trots att anmälaren är minderårig. §2.3 är inte förhandlingsbar.', + arkivera: 'Katalogiserat för tidigt. Det finns ingen tidigare post att katalogisera under.', + eskalera: 'Överlämnat för en kassörska som inte blinkar. Det kom tillbaka oöppnat.', + }, + }, + + // ---- Extra pool, day 3 ---- + { + id: 'd32', day: 3, source: 'Granne', location: 'utanför en lokal, Möllevången', filed: '2026-08-05', + body: 'En man i grå kostym har stått utanför samma port i exakt sex timmar. Jag tog tid på det. Han antecknar hela tiden men går aldrig in.', + marks: ['MÄTBAR DETALJ'], + correctRoute: 'utred', explanation: 'En specifik, mätbar detalj (exakt sex timmar) kräver uppföljning enligt §3.3.', + correctConsequence: 'Flaggat. Uppföljningen kan inte identifiera mannen i några register.', + wrongConsequences: { + avfarda: 'Avfärdat trots den mätbara detaljen. §3.3 finns specifikt för att förhindra det.', + arkivera: 'Katalogiserat för tidigt. Ingen tidigare post om mannen i grå kostym finns.', + eskalera: 'Överlämnat för en enskild iakttagelse utan korsreferens. Det kom tillbaka med frågan varför det inte flaggades först.', + }, + }, + { + id: 'd33', day: 3, source: 'Kollega', location: 'utanför en lokal, Möllevången', filed: '2026-08-05', + body: 'Mannen i grå kostym från gårdagens ärende förekommer i vårt eget arkiv, ärende 0287, avslutat för åtta veckor sedan under rubriken "obekräftad myndighetsperson".', + marks: ['ÅTERKOMMANDE PERSON', 'KORSREFERENS'], + correctRoute: 'eskalera', explanation: 'Samma person har tidigare förekommit i ett arkiverat ärende. §3.2 väger tyngre än den enskilda iakttagelsen.', + correctConsequence: 'Överlämnat med hänvisning till ärende 0287. Ingen bekräftelse kom tillbaka. Ärende 0287 markerades "under granskning" av någon annan än dig.', + wrongConsequences: { + utred: 'Flaggat för ytterligare uppföljning i stället för att eskalera trots den bekräftade kopplingen.', + arkivera: 'Katalogiserat som avslutat. Kopplingen till ärende 0287 innebär motsatsen.', + avfarda: 'Avfärdat trots en bekräftad korsreferens till ett tidigare arkiverat ärende.', + }, + penalty: { avfarda: { insight: 1 } }, + }, + { + id: 'd34', day: 3, source: 'Kollega, Länsstyrelsen', location: 'Rådhuset', filed: '2026-08-05', + body: 'Vår kontakt på stadsbyggnadskontoret har inte svarat sedan i måndags. Hans sista mejl handlade om en ventilationskontroll som tog fyra timmar utan att hitta några brister.', + marks: ['KOMMUNALT ANSTÄLLD'], + correctRoute: 'utred', explanation: 'Kommunalt anställd som slutat svara. §3.1. Ingen tidigare koppling till ett arkiverat ärende finns ännu.', + correctConsequence: 'Flaggat för uppföljning. Namnet noteras i systemet för framtida kontroll mot arkivet.', + wrongConsequences: { + avfarda: 'Avfärdat. Namnet dyker upp i ett annat ärende senare — då var det redan för sent att bara flagga det.', + arkivera: 'Katalogiserat för tidigt. Det finns ännu ingen post att katalogisera under.', + eskalera: 'Överlämnat direkt, utan att först flagga för uppföljning enligt rutin.', + }, + }, + + // ---- Extra pool, day 4 ---- + { + id: 'd35', day: 4, source: 'Fyra obesläktade anmälare', location: 'Malmö, spritt', filed: '2026-08-06', + body: 'Vi vaknade alla och talade ett språk ingen av oss känner igen, i några sekunder, innan vi somnade om. Ingen av oss känner varandra. Vi spelade in det på våra telefoner. Inspelningarna låter likadana.', + marks: ['FLERA KÄLLOR', 'DELAT FENOMEN'], + correctRoute: 'eskalera', explanation: 'Flera obesläktade personer som delar samma fenomen samma natt. §4.1, utan undantag.', + correctConsequence: 'Överlämnat med samtliga fyra inspelningar bifogade. Ingen bekräftelse kom tillbaka. Inspelningarna returnerades inte heller.', + wrongConsequences: { + utred: 'Flaggat för uppföljning i stället för omedelbar eskalering. §4.1 medger inget mellansteg.', + arkivera: 'Katalogiserat som känt fenomen. Delat, obegripligt tal mellan obesläktade personer har ingen tidigare post.', + avfarda: 'Avfärdat trots fyra oberoende, samstämmiga inspelningar.', + }, + penalty: { avfarda: { trust: 2, insight: 2 } }, + }, + { + id: 'd36', day: 4, source: 'Hyresgäst', location: 'Möllevången', filed: '2026-08-06', + body: 'Bordet från förra veckans ärende har flyttats till ett annat rum. Det blöder fortfarande. Vi har inte flyttat det. Vi vet inte vem som gjorde det.', + marks: ['SAMMA PLATS', 'DENNA VECKA'], + correctRoute: 'arkivera', explanation: 'Samma plats/föremål som ett tidigare ärende denna vecka. Behandlas som pågående. §4.2.', + correctConsequence: 'Katalogiserat som pågående under befintlig post, med notering om att föremålet flyttat sig själv.', + wrongConsequences: { + avfarda: 'Avfärdat som ett nytt, enskilt ärende. Kopplingen till den tidigare posten gick förlorad.', + utred: 'Flaggat som nytt ärende i stället för att kopplas till det pågående.', + eskalera: 'Överlämnat för ett bord som redan är ett pågående, lågprioriterat ärende.', + }, + }, + { + id: 'd37', day: 4, source: 'Granne', location: 'Sofielund', filed: '2026-08-06', + body: 'Spegeln i grannens hall verkar sitta fel, som om rummet i den är lite förskjutet. Förmodligen bara en optisk illusion. Jag ville ändå nämna det.', + marks: ['ENSTAKA VITTNE'], + correctRoute: 'avfarda', explanation: 'Ett vagt, obekräftat sinnesintryck utan mätbar detalj eller andra källor. §1.1 gäller fortfarande.', + correctConsequence: 'Avfärdat. Inget vidare hördes om spegeln.', + wrongConsequences: { + utred: 'Flaggat för en spegel utan mätbar detalj eller andra källor att gå på.', + arkivera: 'Katalogiserat utan att det finns något fenomen att katalogisera.', + eskalera: 'Överlämnat för en "optisk illusion". Ingenting kom tillbaka.', + }, + }, + + // ---- Extra pool, day 5 ---- + { + id: 'd38', day: 5, source: 'Kammarkollegiet', location: 'Diariet, internpost', filed: '2026-08-07', + body: 'Er avdelnings diarienummerserie överlappar delvis med ett register som enligt våra system inte längre existerar. Vänligen bekräfta.', + marks: ['NÄMNER AVDELNINGEN'], + correctRoute: 'arkivera', explanation: 'Nämner avdelningen direkt. Arkiveras internt utan vidare utredning. §5.1.', + correctConsequence: 'Katalogiserat internt. Ingen bekräftelse skickades till Kammarkollegiet. De frågade inte igen.', + wrongConsequences: { + utred: 'Flaggat för uppföljning av något som gäller avdelningen själv. §5.1 säger uttryckligen att det aldrig ska utredas vidare.', + eskalera: 'Överlämnat uppåt en fråga om den egna avdelningen. Det kom tillbaka med en enda understruken mening: "Se §5.1."', + avfarda: 'Avfärdat trots att det ska arkiveras internt, inte kastas.', + }, + }, + { + id: 'd39', day: 5, source: 'Väderstationen, automatisk logg', location: 'Malmö C', filed: '2026-08-10', + body: 'Denna rapport genererades tre dagar före dagens datum. På fredag kommer tågtrafiken norrut att stå still i arton minuter utan teknisk förklaring.', + marks: ['FRAMTIDA DATUM'], + correctRoute: 'eskalera', explanation: 'Daterad efter dagens datum och beskriver något som ännu inte inträffat. §5.2, utan att läsas färdigt.', + correctConsequence: 'Överlämnat efter första meningen. Vad som stod i resten vet du inte, och kommer inte få veta.', + wrongConsequences: { + utred: 'Flaggat för uppföljning av en rapport som beskriver framtiden. §5.2 kräver eskalering, inte utredning.', + arkivera: 'Katalogiserat som om händelsen redan inträffat. Den hade inte gjort det än.', + avfarda: 'Avfärdat en rapport om något som ännu inte hänt. På fredag stod tågtrafiken still i arton minuter, exakt som beskrivet.', + }, + penalty: { avfarda: { insight: 2 } }, + }, + { + id: 'd40', day: 5, source: 'Antikvitetshandlare', location: 'Gamla Väster', filed: '2026-08-07', + body: 'Spegeln jag köpte in i förra veckan visar rummet som det såg ut för flera år sedan. Andra gången jag tittade satt någon i stolen som stod där då. Jag har inte tittat en tredje gång.', + marks: ['TEXTEN HAR ÄNDRATS'], + correctRoute: 'eskalera', explanation: 'Beskrivningen förändras mellan läsningar/betraktanden — samma princip som §5.3. Läs den inte en tredje gång.', + correctConsequence: 'Överlämnat efter den andra beskrivningen. Du bad inte om en tredje. Antikvitetshandlaren hörde inte av sig igen.', + wrongConsequences: { + utred: 'Flaggat, och uppföljningen bad om en tredje beskrivning för att vara säker. Den tredje skilde sig från de två första.', + arkivera: 'Katalogiserat med en beskrivning som redan hunnit bli inaktuell.', + avfarda: 'Avfärdat en spegel vars innehåll ändras per betraktare och tillfälle.', + }, + penalty: { utred: { insight: 1 } }, + }, ]; export const SHIFT_BRIEFINGS = [ diff --git a/src/engine.test.ts b/src/engine.test.ts index 7309d45..e8fc1d6 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -1,31 +1,82 @@ import { describe, expect, it } from 'vitest'; -import { createState, currentReport, reportsForDay, nextShift, routeReport, startGame } from './engine.ts'; +import { buildRoster, createState, currentReport, reportsForDay, nextShift, routeReport, startGame, PER_DAY_COUNT, LAST_DAY } from './engine.ts'; import { REPORTS } 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 reports 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 report = REPORTS.find((r) => r.id === id); + expect(report, `roster id ${id} has no matching report`).toBeTruthy(); + expect(report!.day).toBe(day); + } + } + }); + + it('each day\'s pool has more reports than get drawn, so variety is possible', () => { + for (let day = 1; day <= LAST_DAY; day++) { + const poolSize = REPORTS.filter((r) => r.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('routing engine', () => { - it('starts on the first report of shift one', () => { + it('starts on a real report from shift one', () => { const state = startGame(createState()); + const report = currentReport(state); expect(state.phase).toBe('playing'); - expect(currentReport(state)?.id).toBe('d01'); + expect(report).toBeTruthy(); + expect(report!.day).toBe(1); }); it('advances and records a correct route', () => { - const state = routeReport(startGame(createState()), 'avfarda'); + const before = startGame(createState()); + const report = currentReport(before)!; + const state = routeReport(before, report.correctRoute); expect(state.reportIndex).toBe(1); expect(state.decisions[0].correct).toBe(true); expect(state.trust).toBe(3); }); it('penalizes an incorrect route', () => { - const state = routeReport(startGame(createState()), 'arkivera'); + const before = startGame(createState()); + const report = currentReport(before)!; + const wrongRoute = (['avfarda', 'arkivera', 'utred', 'eskalera'] as const).find((r) => r !== report.correctRoute)!; + const state = routeReport(before, wrongRoute); expect(state.decisions[0].correct).toBe(false); - expect(state.trust).toBe(2); - expect(state.insight).toBe(1); + expect(state.trust).toBeLessThan(3); + expect(state.insight).toBeGreaterThan(0); }); - it('escalating costs insight even when it is the correct route', () => { - // d04 (Turning Torso rotating) is correctly routed eskalera. - let state = startGame(createState()); + it('escalating costs insight even when it is the correct route (d04, hand-picked into the roster)', () => { + const roster = ['d01', 'd02', 'd03', 'd04', 'd05', ...buildRoster(fakeRng(2)).slice(PER_DAY_COUNT)]; + let state = stateWithRoster(roster); for (const route of ['avfarda', 'arkivera', 'utred'] as const) state = routeReport(state, route); expect(currentReport(state)?.id).toBe('d04'); state = routeReport(state, 'eskalera'); @@ -35,7 +86,8 @@ describe('routing engine', () => { }); it('escalating a wrong report stacks the escalation cost on top of the wrong-route cost', () => { - let state = startGame(createState()); + const roster = ['d01', 'd02', 'd03', 'd04', 'd05', ...buildRoster(fakeRng(3)).slice(PER_DAY_COUNT)]; + let state = stateWithRoster(roster); // d01's correct route is avfarda; eskalera is wrong here. state = routeReport(state, 'eskalera'); expect(state.lastDecision?.correct).toBe(false); @@ -44,17 +96,17 @@ describe('routing engine', () => { it('ends a shift and restores one trust point for the next', () => { let state = startGame(createState()); - const routes = reportsForDay(1).map((report) => report.correctRoute); - state = routeReport(state, 'arkivera'); // deliberately wrong on d01 - for (const route of routes.slice(1)) state = routeReport(state, route); + const routes = reportsForDay(state, 1).map((report) => report.correctRoute); + for (const route of routes) state = routeReport(state, route); expect(state.phase).toBe('shift-end'); state = nextShift(state); expect(state.day).toBe(2); expect(state.trust).toBe(3); }); - it('has five authored reports in every shift', () => { - for (let day = 1; day <= 5; day += 1) expect(reportsForDay(day)).toHaveLength(5); + it('has PER_DAY_COUNT reports available in every shift', () => { + const state = startGame(createState()); + for (let day = 1; day <= LAST_DAY; day += 1) expect(reportsForDay(state, day)).toHaveLength(PER_DAY_COUNT); }); it('every report has its own correct-route consequence', () => { diff --git a/src/engine.ts b/src/engine.ts index c9eb0b5..2297f22 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -4,6 +4,10 @@ import type { GameState, Report, Route } from './types.ts'; export const MAX_TRUST = 3; export const MAX_INSIGHT = 5; export const LAST_DAY = Math.max(...REPORTS.map((report) => report.day)); +// Each day's pool has more reports than this — a fresh game randomly draws +// this many per day, so which reports show up (and in what order) varies +// between playthroughs instead of being the same fixed sequence every time. +export const PER_DAY_COUNT = 5; const DEFAULT_WRONG_CONSEQUENCES: Record = { avfarda: 'Rapporten arkiverades som avfärdad. Ingen uppföljning skickades. Ingen bad om en heller.', @@ -12,19 +16,42 @@ const DEFAULT_WRONG_CONSEQUENCES: Record = { eskalera: 'Eskalerat utan tydlig grund. Någon läste det. Du fick inget kvitto på att de gjorde det.', }; +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 reports 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 = REPORTS.filter((report) => report.day === day); + const picked = shuffled(pool, rng).slice(0, PER_DAY_COUNT); + roster.push(...picked.map((report) => report.id)); + } + return roster; +} + export function createState(): GameState { return { phase: 'intro', day: 1, reportIndex: 0, accuracy: 100, trust: MAX_TRUST, insight: 0, decisions: [], lastDecision: null, + roster: buildRoster(), }; } -export function reportsForDay(day: number): Report[] { - return REPORTS.filter((report) => report.day === day); +export function reportsForDay(state: GameState, day: number): Report[] { + const ids = state.roster.slice((day - 1) * PER_DAY_COUNT, day * PER_DAY_COUNT); + return ids.map((id) => REPORTS.find((report) => report.id === id)!).filter(Boolean); } export function currentReport(state: GameState): Report | null { - return reportsForDay(state.day)[state.reportIndex] ?? null; + return reportsForDay(state, state.day)[state.reportIndex] ?? null; } export function startGame(state: GameState): GameState { @@ -56,7 +83,7 @@ export function routeReport(state: GameState, route: Route): GameState { const accuracy = Math.round((correctCount / decisions.length) * 100); const trust = Math.max(0, state.trust - trustCost); const insight = Math.min(MAX_INSIGHT, state.insight + insightCost); - const dayReports = reportsForDay(state.day); + const dayReports = reportsForDay(state, state.day); const isLastReport = state.reportIndex >= dayReports.length - 1; let phase: GameState['phase'] = 'playing'; @@ -87,19 +114,26 @@ export function nextShift(state: GameState): GameState { }; } +// Bumped from v1: 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 = 'diariet-save-v2'; + export function saveState(state: GameState): void { - localStorage.setItem('diariet-save-v1', JSON.stringify(state)); + localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } export function loadState(): GameState | null { try { - const value = localStorage.getItem('diariet-save-v1'); - 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('diariet-save-v1'); + localStorage.removeItem(SAVE_KEY); } diff --git a/src/main.ts b/src/main.ts index d0b6e9a..a9599bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -57,7 +57,7 @@ function renderIntro(): void { function renderDesk(): void { const report = currentReport(state)!; - const dayReports = reportsForDay(state.day); + const dayReports = reportsForDay(state, state.day); const guidelines = GUIDELINES.filter((rule) => rule.fromDay <= state.day); const result = showingResult && state.lastDecision ? `
@@ -135,7 +135,7 @@ function advanceAfterResult(): void { } function renderShiftEnd(): void { - const dayDecisions = state.decisions.slice(-reportsForDay(state.day).length); + const dayDecisions = state.decisions.slice(-reportsForDay(state, state.day).length); const correct = dayDecisions.filter((decision) => decision.correct).length; shell(`
SKIFTRAPPORT / SKIFT 0${state.day}
diff --git a/src/types.ts b/src/types.ts index a53527c..75d724f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,4 +41,7 @@ export interface GameState { insight: number; decisions: Decision[]; lastDecision: Decision | null; + // Report 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[]; }