Add real replay variety: 15 new reports, random per-day roster
All checks were successful
Docker / build-and-push (push) Successful in 49s

Same fix as Retur (which this engine is descended from): every
playthrough showed the same fixed 25 reports in the same order, with
zero randomization anywhere. Added 15 new reports (3 per day,
d26-d40) citing more of Djupet's event pool (the bridge's impossible
vibration frequency, the investigator in the grey suit whose shadow
didn't follow, a mirror showing an older version of the room),
bringing each day's pool to 8, and changed the engine to draw
PER_DAY_COUNT (5) at random per game instead of a fixed set.

Same shape as Retur's fix: buildRoster() shuffles and slices each
day's pool, GameState carries a `roster` generated once at
createState() and persisted, reportsForDay/currentReport read through
it. Save key bumped to v2 for the same reason — no backfill for old
saves, just start fresh.

Tests rewritten the same way: buildRoster() property tests plus
engine tests that either check general correctness or construct an
explicit roster when a specific report's behavior needs locking in
(d04's escalation-even-when-correct case).

Verified: 12/12 vitest, tsc clean, real browser test confirming 4
distinct first-report sources across 5 fresh games.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-28 21:12:32 +02:00
parent 5faca60a12
commit 5e5ed2ec7e
7 changed files with 314 additions and 27 deletions

View File

@@ -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. 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 ## Development
```bash ```bash

View File

@@ -3,4 +3,4 @@ services:
image: ${IMAGE:-diariet:local} image: ${IMAGE:-diariet:local}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3011}:80" - "${PORT:-5692}:80"

View File

@@ -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.', 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 d01d05) ----
{
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 = [ export const SHIFT_BRIEFINGS = [

View File

@@ -1,31 +1,82 @@
import { describe, expect, it } from 'vitest'; 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'; 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', () => { 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 state = startGame(createState());
const report = currentReport(state);
expect(state.phase).toBe('playing'); 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', () => { 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.reportIndex).toBe(1);
expect(state.decisions[0].correct).toBe(true); expect(state.decisions[0].correct).toBe(true);
expect(state.trust).toBe(3); expect(state.trust).toBe(3);
}); });
it('penalizes an incorrect route', () => { 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.decisions[0].correct).toBe(false);
expect(state.trust).toBe(2); expect(state.trust).toBeLessThan(3);
expect(state.insight).toBe(1); expect(state.insight).toBeGreaterThan(0);
}); });
it('escalating costs insight even when it is the correct route', () => { it('escalating costs insight even when it is the correct route (d04, hand-picked into the roster)', () => {
// d04 (Turning Torso rotating) is correctly routed eskalera. const roster = ['d01', 'd02', 'd03', 'd04', 'd05', ...buildRoster(fakeRng(2)).slice(PER_DAY_COUNT)];
let state = startGame(createState()); let state = stateWithRoster(roster);
for (const route of ['avfarda', 'arkivera', 'utred'] as const) state = routeReport(state, route); for (const route of ['avfarda', 'arkivera', 'utred'] as const) state = routeReport(state, route);
expect(currentReport(state)?.id).toBe('d04'); expect(currentReport(state)?.id).toBe('d04');
state = routeReport(state, 'eskalera'); 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', () => { 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. // d01's correct route is avfarda; eskalera is wrong here.
state = routeReport(state, 'eskalera'); state = routeReport(state, 'eskalera');
expect(state.lastDecision?.correct).toBe(false); 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', () => { it('ends a shift and restores one trust point for the next', () => {
let state = startGame(createState()); let state = startGame(createState());
const routes = reportsForDay(1).map((report) => report.correctRoute); const routes = reportsForDay(state, 1).map((report) => report.correctRoute);
state = routeReport(state, 'arkivera'); // deliberately wrong on d01 for (const route of routes) state = routeReport(state, route);
for (const route of routes.slice(1)) state = routeReport(state, route);
expect(state.phase).toBe('shift-end'); expect(state.phase).toBe('shift-end');
state = nextShift(state); state = nextShift(state);
expect(state.day).toBe(2); expect(state.day).toBe(2);
expect(state.trust).toBe(3); expect(state.trust).toBe(3);
}); });
it('has five authored reports in every shift', () => { it('has PER_DAY_COUNT reports available in every shift', () => {
for (let day = 1; day <= 5; day += 1) expect(reportsForDay(day)).toHaveLength(5); 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', () => { it('every report has its own correct-route consequence', () => {

View File

@@ -4,6 +4,10 @@ import type { GameState, Report, Route } from './types.ts';
export const MAX_TRUST = 3; export const MAX_TRUST = 3;
export const MAX_INSIGHT = 5; export const MAX_INSIGHT = 5;
export const LAST_DAY = Math.max(...REPORTS.map((report) => report.day)); 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<Route, string> = { const DEFAULT_WRONG_CONSEQUENCES: Record<Route, string> = {
avfarda: 'Rapporten arkiverades som avfärdad. Ingen uppföljning skickades. Ingen bad om en heller.', avfarda: 'Rapporten arkiverades som avfärdad. Ingen uppföljning skickades. Ingen bad om en heller.',
@@ -12,19 +16,42 @@ const DEFAULT_WRONG_CONSEQUENCES: Record<Route, string> = {
eskalera: 'Eskalerat utan tydlig grund. Någon läste det. Du fick inget kvitto på att de gjorde det.', eskalera: 'Eskalerat utan tydlig grund. Någon läste det. Du fick inget kvitto på att de gjorde det.',
}; };
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 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 { export function createState(): GameState {
return { return {
phase: 'intro', day: 1, reportIndex: 0, accuracy: 100, phase: 'intro', day: 1, reportIndex: 0, accuracy: 100,
trust: MAX_TRUST, insight: 0, decisions: [], lastDecision: null, trust: MAX_TRUST, insight: 0, decisions: [], lastDecision: null,
roster: buildRoster(),
}; };
} }
export function reportsForDay(day: number): Report[] { export function reportsForDay(state: GameState, day: number): Report[] {
return REPORTS.filter((report) => report.day === day); 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 { 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 { 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 accuracy = Math.round((correctCount / decisions.length) * 100);
const trust = Math.max(0, state.trust - trustCost); const trust = Math.max(0, state.trust - trustCost);
const insight = Math.min(MAX_INSIGHT, state.insight + insightCost); 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; const isLastReport = state.reportIndex >= dayReports.length - 1;
let phase: GameState['phase'] = 'playing'; 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 { 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 { export function loadState(): GameState | null {
try { try {
const value = localStorage.getItem('diariet-save-v1'); 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('diariet-save-v1'); localStorage.removeItem(SAVE_KEY);
} }

View File

@@ -57,7 +57,7 @@ function renderIntro(): void {
function renderDesk(): void { function renderDesk(): void {
const report = currentReport(state)!; 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 guidelines = GUIDELINES.filter((rule) => rule.fromDay <= state.day);
const result = showingResult && state.lastDecision const result = showingResult && state.lastDecision
? `<div class="result-overlay"> ? `<div class="result-overlay">
@@ -135,7 +135,7 @@ function advanceAfterResult(): void {
} }
function renderShiftEnd(): 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; const correct = dayDecisions.filter((decision) => decision.correct).length;
shell(`<main class="shift-end-screen"><div class="shift-end-card"> shell(`<main class="shift-end-screen"><div class="shift-end-card">
<div class="panel-title">SKIFTRAPPORT / SKIFT 0${state.day}</div> <div class="panel-title">SKIFTRAPPORT / SKIFT 0${state.day}</div>

View File

@@ -41,4 +41,7 @@ export interface GameState {
insight: number; insight: number;
decisions: Decision[]; decisions: Decision[];
lastDecision: Decision | null; 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[];
} }