Add real replay variety: 15 new reports, random per-day roster
All checks were successful
Docker / build-and-push (push) Successful in 49s
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:
@@ -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
|
||||
|
||||
@@ -3,4 +3,4 @@ services:
|
||||
image: ${IMAGE:-diariet:local}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3011}:80"
|
||||
- "${PORT:-5692}:80"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Route, string> = {
|
||||
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.',
|
||||
};
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<div class="result-overlay">
|
||||
@@ -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(`<main class="shift-end-screen"><div class="shift-end-card">
|
||||
<div class="panel-title">SKIFTRAPPORT / SKIFT 0${state.day}</div>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user