Compare commits
4 Commits
7224027a77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9b313783 | ||
|
|
f71878861c | ||
|
|
31a6770dfd | ||
|
|
e82d948f28 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,3 +2,10 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
*.local
|
||||
|
||||
# Raw source images — public/*.webp are the optimized derivatives that
|
||||
# actually ship; no need to bloat the repo with the 1.7MB originals.
|
||||
/intro.png
|
||||
/gameover.png
|
||||
/win.png
|
||||
/win2.png
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
BIN
public/gameover.webp
Normal file
BIN
public/gameover.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
public/intro.webp
Normal file
BIN
public/intro.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
public/win.webp
Normal file
BIN
public/win.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Route, string> = {
|
||||
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<Route, string> = {
|
||||
quarantine: 'Karantänskåpet tog emot brevet. Något annat därinne slutade knacka.',
|
||||
};
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<div class="result ${state.lastDecision.correct ? 'correct' : 'wrong'}">
|
||||
@@ -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(`<main class="end-screen"><div class="end-card">
|
||||
<div class="panel-title">DAGSRAPPORT / DAG 0${state.day}</div>
|
||||
@@ -176,7 +176,7 @@ function renderEnding(): void {
|
||||
? 'Misstanken nådde taket innan avvikelserna gjorde det. Ingen kartong står vid din plats — bara ett formulär om att du aldrig anställdes. Det är redan ifyllt.'
|
||||
: 'För många avvikelser. En kartong står redan vid din plats. Den är adresserad till din efterträdare, med ditt namn överstruket en gång.';
|
||||
|
||||
shell(`<main class="end-screen"><div class="end-card ${won ? 'victory' : 'failure'}">
|
||||
shell(`<main class="end-screen ${won ? 'victory' : 'failure'}"><div class="end-card ${won ? 'victory' : 'failure'}">
|
||||
<div class="panel-title">SLUTLIG PERSONALAKT</div>
|
||||
<div class="audit-stamp">${stamp}</div>
|
||||
<h1>${title}</h1>
|
||||
|
||||
@@ -74,15 +74,31 @@ button { font: inherit; }
|
||||
.penalty { margin-bottom: 12px; color: var(--danger); font-size: 10px; text-transform: uppercase; }
|
||||
.result button { border: 0; background: none; color: var(--text); cursor: pointer; }
|
||||
.intro-screen, .end-screen { min-height: calc(100vh - 62px); display: grid; place-items: center; padding: 30px; background: radial-gradient(circle, #20192c 0, var(--bg) 66%); }
|
||||
.intro-screen { grid-template-columns: auto minmax(280px, 540px); gap: clamp(30px, 7vw, 90px); }
|
||||
.seal { width: clamp(160px, 25vw, 260px); aspect-ratio: 1; display: grid; place-items: center; border: 8px double var(--stamp); border-radius: 50%; color: var(--stamp); font: 700 clamp(90px, 16vw, 160px)/1 'Special Elite'; transform: rotate(-8deg); opacity: .78; box-shadow: 0 0 60px rgba(217,84,98,.12); }
|
||||
.intro-screen {
|
||||
grid-template-columns: auto minmax(280px, 540px);
|
||||
gap: clamp(30px, 7vw, 90px);
|
||||
background:
|
||||
linear-gradient(rgba(13, 13, 26, 0.6), rgba(13, 13, 26, 0.88)),
|
||||
url('/intro.webp') center / cover no-repeat;
|
||||
}
|
||||
.end-screen.victory {
|
||||
background:
|
||||
linear-gradient(rgba(13, 13, 26, 0.55), rgba(13, 13, 26, 0.85)),
|
||||
url('/win.webp') center / cover no-repeat;
|
||||
}
|
||||
.end-screen.failure {
|
||||
background:
|
||||
linear-gradient(rgba(13, 13, 26, 0.55), rgba(13, 13, 26, 0.85)),
|
||||
url('/gameover.webp') center / cover no-repeat;
|
||||
}
|
||||
.seal { width: clamp(160px, 25vw, 260px); aspect-ratio: 1; display: grid; place-items: center; border: 8px double var(--stamp); border-radius: 50%; color: var(--stamp); font: 700 clamp(90px, 16vw, 160px)/1 'Special Elite'; transform: rotate(-8deg); opacity: .85; box-shadow: 0 0 60px rgba(217,84,98,.25); background: rgba(13, 13, 26, 0.35); backdrop-filter: blur(1px); }
|
||||
.intro-copy h1 { margin: 0; color: var(--stamp); font-size: clamp(52px, 10vw, 92px); letter-spacing: .15em; line-height: 1; }
|
||||
.intro-sub { color: var(--dim); font-size: 10px; letter-spacing: .15em; text-transform: uppercase; }
|
||||
.intro-copy > p:not(.intro-sub) { color: #b0b0c4; }
|
||||
.end-card { width: min(620px, 100%); padding: clamp(24px, 5vw, 50px); border: 1px solid var(--border); background: var(--panel); text-align: center; position: relative; }
|
||||
.end-card { width: min(620px, 100%); padding: clamp(24px, 5vw, 50px); border: 1px solid var(--border); background: rgba(18, 18, 31, 0.82); backdrop-filter: blur(2px); text-align: center; position: relative; }
|
||||
.end-card h1 { color: var(--text); }
|
||||
.end-card > p { color: var(--dim); }
|
||||
.audit-stamp { display: inline-block; padding: 8px 16px; border: 3px solid var(--safe); color: var(--safe); font-size: 18px; font-weight: 700; letter-spacing: .12em; transform: rotate(-4deg); }
|
||||
.audit-stamp { display: inline-block; padding: 8px 16px; border: 3px solid var(--safe); color: var(--safe); font-size: 18px; font-weight: 700; letter-spacing: .12em; transform: rotate(-4deg); background: rgba(13, 13, 26, 0.3); }
|
||||
.failure .audit-stamp { border-color: var(--danger); color: var(--danger); }
|
||||
.final-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 25px 0; }
|
||||
.final-stats div { padding: 12px; border: 1px solid var(--border); }
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user