More punch: consequence text for every letter, tiered endings
All checks were successful
Docker / build-and-push (push) Successful in 34s

Letters l01-l20 only ever hit the generic default consequence lines
(engine.ts's DEFAULT_WRONG_CONSEQUENCES / a generic correct-route
line) — l21-l30 already had the good stuff. Gave all 20 their own
correctConsequence and wrongConsequences, matching the dry,
institutional dread already established, tied to each letter's own
specific details (the cardigan, the wrong key, the room removed from
the blueprint, Avdelning Noll's negative floors, the future self
warning about 16:42). Bumped a few penalty overrides (l04, l06, l08,
l09, l13, l15, l18) where the wrong choice is narratively worse than
the default 1/1 cost, same pattern already used on l22/l26.

Also split the single win/loss ending into four tiers based on final
suspicion: a clean TILLSVIDARE vs. a "TILLSVIDARE, VILLKORLIGT" win,
and an OMPLACERAD (composure-driven) vs. AVFÖRD (suspicion-maxed)
loss — same stamp-and-personnel-file frame, different outcome text,
so a second playthrough has something new to see even on the same
ending type.

Verified: tsc clean, 8/8 vitest (added a data-integrity check that
every letter has a correctConsequence, plus a test locking in l04's
new penalty), a real browser playthrough of l01's new consequence
text, and all four ending variants rendered by injecting finished
game states directly rather than scripting a fragile 30-letter
keyboard sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-26 19:34:17 +02:00
parent 6b06e5a6ce
commit 7224027a77
3 changed files with 169 additions and 4 deletions

View File

@@ -28,101 +28,228 @@ export const LETTERS: Letter[] = [
id: 'l01', day: 1, sender: 'Britta Nilsson', recipient: 'Ture Andersson', address: 'Bokgatan 14, Malmö', postmark: '2026-07-21', id: 'l01', day: 1, sender: 'Britta Nilsson', recipient: 'Ture Andersson', address: 'Bokgatan 14, Malmö', postmark: '2026-07-21',
body: 'Ture, din kofta ligger fortfarande här. Jag skickar den inte igen. Hämta den innan torsdag.', marks: ['Porto betalt'], body: 'Ture, din kofta ligger fortfarande här. Jag skickar den inte igen. Hämta den innan torsdag.', marks: ['Porto betalt'],
correctRoute: 'deliver', explanation: 'Fullständig adress och inget annat anmärkningsvärt. Det är nästan misstänkt normalt.', correctRoute: 'deliver', explanation: 'Fullständig adress och inget annat anmärkningsvärt. Det är nästan misstänkt normalt.',
correctConsequence: 'Koftan levererades. Ture svarade inte, men tvättstugan bokades ändå i hans namn till torsdag.',
wrongConsequences: {
return: 'Koftan återsändes till Britta. Den luktade svagt av någon annans tvättmedel.',
archive: 'Arkiv 9 noterade den som "personligt föremål, obestämd ålder" och behöll den.',
quarantine: 'Karantänskåpet vägrade stänga helt. En ärm stack ut hela natten.',
},
}, },
{ {
id: 'l02', day: 1, sender: 'K. Lund', recipient: 'Den som bor ovanför vinden', address: 'Huset med den gröna dörren', postmark: '2026-07-20', id: 'l02', day: 1, sender: 'K. Lund', recipient: 'Den som bor ovanför vinden', address: 'Huset med den gröna dörren', postmark: '2026-07-20',
body: 'Ni har fått fel nyckel. Använd den inte i källardörren.', marks: ['Adress saknas'], body: 'Ni har fått fel nyckel. Använd den inte i källardörren.', marks: ['Adress saknas'],
correctRoute: 'return', explanation: 'Adressen är ogiltig och avsändaren finns. Återsänd. Nyckeln nämns inte i föreskrifterna.', correctRoute: 'return', explanation: 'Adressen är ogiltig och avsändaren finns. Återsänd. Nyckeln nämns inte i föreskrifterna.',
correctConsequence: 'Nyckeln återsändes. K. Lund svarade samma dag: "Fel nyckel igen. Sluta försöka."',
wrongConsequences: {
deliver: 'Nyckeln levererades till vinden. Källardörren rapporterades olåst nästa morgon.',
archive: 'Arkiv 9 tog inte emot föremål utan bekräftad ålder. Den kom tillbaka i ett brunt kuvert utan avsändare — ställt till dig.',
quarantine: 'Nyckeln låstes in. Källardörren gick upp av sig själv strax efter midnatt.',
},
}, },
{ {
id: 'l03', day: 1, sender: 'Ester Malm', recipient: 'Oskar Malm', address: 'Södra Förstadsgatan 81', postmark: '1912-11-03', id: 'l03', day: 1, sender: 'Ester Malm', recipient: 'Oskar Malm', address: 'Södra Förstadsgatan 81', postmark: '1912-11-03',
body: 'Käre Oskar. De knackar under golvet igen. Far säger att huset sätter sig.', marks: ['BLEKINGE BATALJON', 'Öppnat av censuren'], body: 'Käre Oskar. De knackar under golvet igen. Far säger att huset sätter sig.', marks: ['BLEKINGE BATALJON', 'Öppnat av censuren'],
correctRoute: 'archive', explanation: 'Poststämpeln är äldre än femtio år. Arkiv 9 hanterar resten.', correctRoute: 'archive', explanation: 'Poststämpeln är äldre än femtio år. Arkiv 9 hanterar resten.',
correctConsequence: 'Arkiverat utan incident. Ett knackande hördes kort från arkivkorridoren, sedan tystnad.',
wrongConsequences: {
deliver: 'Levererat till en adress som revs 1974. Posten kom fram ändå.',
return: 'Avsändaren fanns inte längre. Brevet kom tillbaka öppnat, med källarens lukt kvar i papperet.',
quarantine: 'Skåpet knackade tillbaka, tre gånger, i samma rytm som brevet beskrev.',
},
}, },
{ {
id: 'l04', day: 1, sender: 'Fastighetskontoret', recipient: 'Nattvakt 3', address: 'Förvaltningen, internfack 12', postmark: '2026-07-21', id: 'l04', day: 1, sender: 'Fastighetskontoret', recipient: 'Nattvakt 3', address: 'Förvaltningen, internfack 12', postmark: '2026-07-21',
body: 'ÖPPNA INTE I BELYST RUM. Förvaras svalt. Om innehållet börjar nynna, kontakta ingen.', marks: ['ÖPPNA EJ'], body: 'ÖPPNA INTE I BELYST RUM. Förvaras svalt. Om innehållet börjar nynna, kontakta ingen.', marks: ['ÖPPNA EJ'],
correctRoute: 'quarantine', explanation: 'Brevet säger uttryckligen att det inte skall öppnas. Det var generöst av det.', correctRoute: 'quarantine', explanation: 'Brevet säger uttryckligen att det inte skall öppnas. Det var generöst av det.',
correctConsequence: 'Kartongen låstes in. Ingen nynnade mer den natten, vilket kändes värre än om den gjort det.',
wrongConsequences: {
deliver: 'Nattvakt 3 tackade för leveransen över internradion. Nattvakt 3 har inte hörts av sedan.',
return: 'Fastighetskontoret nekade retur. De hävdade att de aldrig skickat något.',
archive: 'Arkiv 9 öppnade kartongen för registrering. Nynnandet hörs fortfarande därifrån.',
},
penalty: { deliver: { suspicion: 2 } },
}, },
{ {
id: 'l05', day: 2, sender: 'Malmö Stad', recipient: 'Signe Persson (18881961)', address: 'Föreningsgatan 32', postmark: '2026-07-22', id: 'l05', day: 2, sender: 'Malmö Stad', recipient: 'Signe Persson (18881961)', address: 'Föreningsgatan 32', postmark: '2026-07-22',
body: 'Påminnelse om utebliven närvaro vid den årliga medborgardialogen.', marks: ['ANDRA PÅMINNELSEN'], body: 'Påminnelse om utebliven närvaro vid den årliga medborgardialogen.', marks: ['ANDRA PÅMINNELSEN'],
correctRoute: 'archive', explanation: 'Mottagaren är registrerad som avliden. Historiskt ärende, trots kommunens optimism.', correctRoute: 'archive', explanation: 'Mottagaren är registrerad som avliden. Historiskt ärende, trots kommunens optimism.',
correctConsequence: 'Arkiverat som avslutat ärende. Kommunen skickade en tredje påminnelse veckan efter.',
wrongConsequences: {
deliver: 'Levererat enligt adress. Grannarna hörde något svara genom brevlådan samma kväll.',
return: 'Malmö Stad nekade retur. "Adressaten bor kvar," svarade handläggaren, utan att tveka.',
quarantine: 'Skåpet fylldes i onödan. Det var bara en påminnelse — trodde du.',
},
}, },
{ {
id: 'l06', day: 2, sender: 'Signe Persson (18881961)', recipient: 'Malmö Stad', address: 'August Palms plats 1', postmark: '2026-07-22', id: 'l06', day: 2, sender: 'Signe Persson (18881961)', recipient: 'Malmö Stad', address: 'August Palms plats 1', postmark: '2026-07-22',
body: 'Jag har fått era båda påminnelser. Jag kommer inte. Vänligen uppdatera era register.', marks: ['JORD UNDER FRIMÄRKET'], body: 'Jag har fått era båda påminnelser. Jag kommer inte. Vänligen uppdatera era register.', marks: ['JORD UNDER FRIMÄRKET'],
correctRoute: 'quarantine', explanation: 'Den avlidne är avsändare. Den senare föreskriften §4.7 har företräde.', correctRoute: 'quarantine', explanation: 'Den avlidne är avsändare. Den senare föreskriften §4.7 har företräde.',
correctConsequence: 'Spärrat. Malmö Stad fick aldrig sitt svar och skickade en fjärde påminnelse ändå.',
wrongConsequences: {
deliver: 'Levererat till Malmö Stad. Registret uppdaterades: mottagaren "ej längre aktuell, se bilaga".',
return: 'Returnerat till Föreningsgatan 32. Brevbäraren la det i en brevlåda som inte längre satt där.',
archive: 'Arkiv 9 tog emot det som historia. Jorden under frimärket smulade sig över hela disken.',
},
penalty: { deliver: { suspicion: 2 } },
}, },
{ {
id: 'l07', day: 2, sender: 'Lars Dahl', recipient: 'Lars Dahl', address: 'Regementsgatan 19', postmark: '2026-07-22', id: 'l07', day: 2, sender: 'Lars Dahl', recipient: 'Lars Dahl', address: 'Regementsgatan 19', postmark: '2026-07-22',
body: 'Till mig själv: glöm inte att köpa mjölk. Och svara inte när telefonen ringer 03:14.', marks: ['EGENPOST'], body: 'Till mig själv: glöm inte att köpa mjölk. Och svara inte när telefonen ringer 03:14.', marks: ['EGENPOST'],
correctRoute: 'deliver', explanation: 'Självkorrespondens är beklaglig men inte förbjuden.', correctRoute: 'deliver', explanation: 'Självkorrespondens är beklaglig men inte förbjuden.',
correctConsequence: 'Levererat till sig själv. Telefonen ringde 03:14 ändå. Ingen vet om han svarade.',
wrongConsequences: {
return: 'Returnerat till Lars Dahl, som redan hade brevet. Han skickade det igen samma eftermiddag.',
archive: 'Arkiv 9 vägrade arkivera pågående korrespondens. Formuläret kom tillbaka med en handskriven anteckning: "Fråga honom själv."',
quarantine: 'Egenpost är beklaglig men inte förbjuden, påminde tillsynen skriftligt. Skåpet öppnades igen inom timmen.',
},
}, },
{ {
id: 'l08', day: 2, sender: '— SAKNAS —', recipient: 'Föreståndaren', address: '', postmark: '2026-07-22', id: 'l08', day: 2, sender: '— SAKNAS —', recipient: 'Föreståndaren', address: '', postmark: '2026-07-22',
body: 'Det finns inget returkontor på plan fyra. Sluta skicka personal dit.', marks: ['AVSÄNDARE SAKNAS'], body: 'Det finns inget returkontor på plan fyra. Sluta skicka personal dit.', marks: ['AVSÄNDARE SAKNAS'],
correctRoute: 'quarantine', explanation: 'Både giltig avsändare och adress saknas. §4.9 tar över returregeln.', correctRoute: 'quarantine', explanation: 'Både giltig avsändare och adress saknas. §4.9 tar över returregeln.',
correctConsequence: '§4.9 tillämpades. Ingen personal skickades till plan fyra igen den veckan.',
wrongConsequences: {
deliver: 'Utan adress levererades brevet ändå, till plan fyra. Den som bar det dit kom inte tillbaka i tid för lunch.',
return: 'Utan avsändare gick returen ingenstans. Den låg kvar på bordet till morgonen efter, nu adresserad till dig.',
archive: 'Arkiv 9 accepterade det trots avsaknaden, och frågade sedan varför plan fyra inte fanns på deras karta heller.',
},
penalty: { deliver: { composure: 2, suspicion: 1 } },
}, },
{ {
id: 'l09', day: 3, sender: 'Asta Berg', recipient: 'Hjalmar Berg', address: 'Kyrkogatan -4, Malmö', postmark: '2026-07-23', id: 'l09', day: 3, sender: 'Asta Berg', recipient: 'Hjalmar Berg', address: 'Kyrkogatan -4, Malmö', postmark: '2026-07-23',
body: 'Trappan fortsätter fyra våningar nedanför källaren. Ta med lampan den här gången.', marks: ['FELVÄNT FRIMÄRKE'], body: 'Trappan fortsätter fyra våningar nedanför källaren. Ta med lampan den här gången.', marks: ['FELVÄNT FRIMÄRKE'],
correctRoute: 'return', explanation: 'Negativa gatunummer är ogiltiga. Var trappan leder är inte ert ansvarsområde.', correctRoute: 'return', explanation: 'Negativa gatunummer är ogiltiga. Var trappan leder är inte ert ansvarsområde.',
correctConsequence: 'Returnerat till Kyrkogatan. Asta bekräftade mottagandet med en enda rad: "Han tog med lampan."',
wrongConsequences: {
deliver: 'Levererat till Kyrkogatan -4. Hjalmar har inte återvänt uppför trapporna sedan.',
archive: 'Arkiv 9 accepterade adressen som historisk, trots dagens datum. Källartrappan förlängdes med ett våningsplan till.',
quarantine: 'Skåpet vägrade stänga över trappans riktning. Ljudet av steg fortsatte nedåt hela natten.',
},
penalty: { deliver: { composure: 2 } },
}, },
{ {
id: 'l10', day: 3, sender: 'Avdelning Noll', recipient: 'Arkivarien', address: 'Korridor -2, dörr 0', postmark: '2026-07-23', id: 'l10', day: 3, sender: 'Avdelning Noll', recipient: 'Arkivarien', address: 'Korridor -2, dörr 0', postmark: '2026-07-23',
body: 'Inventeringen är slutförd. Vi saknar fortfarande en anställd. Namnet är ditt.', marks: ['INTERNPOST', '0'], body: 'Inventeringen är slutförd. Vi saknar fortfarande en anställd. Namnet är ditt.', marks: ['INTERNPOST', '0'],
correctRoute: 'deliver', explanation: 'Internpost från Avdelning Noll går alltid vidare, även till negativa adresser.', correctRoute: 'deliver', explanation: 'Internpost från Avdelning Noll går alltid vidare, även till negativa adresser.',
correctConsequence: 'Vidarebefordrat enligt §5.9. Arkivarien läste ditt namn högt en gång och sa inget mer om saken.',
wrongConsequences: {
return: 'Avdelning Noll accepterar inga returer. Brevet kom tillbaka från en korridor som inte finns på ritningen.',
archive: 'Arkiv 9 registrerade inventeringen. Din personalakt fick ett nytt, odaterat tillägg.',
quarantine: 'Skåpet vägrade stänga om internpost från Avdelning Noll. Det stod olåst till morgonen.',
},
}, },
{ {
id: 'l11', day: 3, sender: 'PostNord Kundservice', recipient: 'PostNord Kundservice', address: 'Ej utdelningsbart', postmark: '2026-07-23', id: 'l11', day: 3, sender: 'PostNord Kundservice', recipient: 'PostNord Kundservice', address: 'Ej utdelningsbart', postmark: '2026-07-23',
body: 'Vi beklagar att ditt brev inte kunde levereras. Ditt ärende är viktigt för oss.', marks: ['AUTOMATISKT SVAR'], body: 'Vi beklagar att ditt brev inte kunde levereras. Ditt ärende är viktigt för oss.', marks: ['AUTOMATISKT SVAR'],
correctRoute: 'return', explanation: 'Ogiltig adress, giltig avsändare. Kretsloppet är inte ert problem.', correctRoute: 'return', explanation: 'Ogiltig adress, giltig avsändare. Kretsloppet är inte ert problem.',
correctConsequence: 'Returnerat enligt rutin. Ett automatiskt kvitto bekräftade mottagandet av kvittot.',
wrongConsequences: {
deliver: 'Levererat till PostNord Kundservice, som svarade med ett identiskt brev, adresserat till dig.',
archive: 'Arkiv 9 accepterade inte ett autosvar som historia. Det kom tillbaka med tre nya stämplar och ingen förklaring.',
quarantine: 'Skåpet fylldes med ett brev utan fara, bara artighetsfraser i oändlig loop.',
},
}, },
{ {
id: 'l12', day: 3, sender: 'Avdelning Noll', recipient: 'Ingen särskild', address: '', postmark: '2026-07-23', id: 'l12', day: 3, sender: 'Avdelning Noll', recipient: 'Ingen särskild', address: '', postmark: '2026-07-23',
body: 'Vidarebefordra detta. Det behöver inte komma fram.', marks: ['INTERNPOST', '0'], body: 'Vidarebefordra detta. Det behöver inte komma fram.', marks: ['INTERNPOST', '0'],
correctRoute: 'deliver', explanation: '§5.9 säger alltid. Myndigheten har fetmarkerat ordet i originalet.', correctRoute: 'deliver', explanation: '§5.9 säger alltid. Myndigheten har fetmarkerat ordet i originalet.',
correctConsequence: '§5.9 tillämpades igen. Brevet levererades till ingen särskild, precis som instruerat.',
wrongConsequences: {
return: 'Returen nekades. Avdelning Noll finns inte, så det fanns ingen adress att skicka den till.',
archive: 'Arkiv 9 tog emot det ändå. Fetstilen i originalet blev bara tjockare av registreringen.',
quarantine: 'Skåpet accepterade inte post som inte behövde komma fram. Det försvann därifrån ändå, till okänt mål.',
},
}, },
{ {
id: 'l13', day: 4, sender: 'M. Ek', recipient: 'M. Ek', address: 'Amiralsgatan 7', postmark: '2026-07-24', id: 'l13', day: 4, sender: 'M. Ek', recipient: 'M. Ek', address: 'Amiralsgatan 7', postmark: '2026-07-24',
body: 'Första läsningen: Lägg brevet i Arkiv 9. Andra läsningen: Du gjorde inte det.', marks: ['TEXTEN HAR ÄNDRATS'], body: 'Första läsningen: Lägg brevet i Arkiv 9. Andra läsningen: Du gjorde inte det.', marks: ['TEXTEN HAR ÄNDRATS'],
correctRoute: 'quarantine', explanation: 'Föränderlig text omfattas av §6.6. Följ inte brevets egen sorteringsinstruktion.', correctRoute: 'quarantine', explanation: 'Föränderlig text omfattas av §6.6. Följ inte brevets egen sorteringsinstruktion.',
correctConsequence: 'Texten slutade ändra sig efter att brevet spärrades. Den sista versionen löd: "Bra val."',
wrongConsequences: {
deliver: 'Levererat enligt första läsningen. Andra läsningen visade att det redan låg i Arkiv 9 ändå.',
return: 'Returnerat till M. Ek, som skickade tillbaka samma brev med en tredje, ännu oläst mening.',
archive: 'Arkiv 9 tog emot brevet enligt dess första instruktion. Den andra läsningen bekräftade att det var fel val, för sent.',
},
penalty: { archive: { suspicion: 2 } },
}, },
{ {
id: 'l14', day: 4, sender: 'Riksarkivet', recipient: 'Lokalkontoret', address: 'Arkivgatan 9', postmark: '1948-02-09', id: 'l14', day: 4, sender: 'Riksarkivet', recipient: 'Lokalkontoret', address: 'Arkivgatan 9', postmark: '1948-02-09',
body: 'Överföring av handlingar rörande det rum som avlägsnades ur ritningen.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'], body: 'Överföring av handlingar rörande det rum som avlägsnades ur ritningen.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'],
correctRoute: 'archive', explanation: 'Både ålder och tre svarta stämplar pekar mot Arkiv 9. För en gångs skull är reglerna överens.', correctRoute: 'archive', explanation: 'Både ålder och tre svarta stämplar pekar mot Arkiv 9. För en gångs skull är reglerna överens.',
correctConsequence: 'Arkiverat utan incident. Rummet som avlägsnades ur ritningen finns kvar i registret, om inte på plan fyra.',
wrongConsequences: {
deliver: 'Levererat till lokalkontoret, som inte kunde hitta ritningen brevet syftade på — eller kontoret.',
return: 'Returnerat till Riksarkivet 1948. Det kom tillbaka 2026, poststämplat samma dag som avsändes.',
quarantine: 'Skåpet accepterade det trots åldern. Det knackade en gång, i samma takt som rummet det beskrev.',
},
}, },
{ {
id: 'l15', day: 4, sender: 'Någon bakom dig', recipient: 'Du', address: 'Sorteringsbordet', postmark: '2026-07-24', id: 'l15', day: 4, sender: 'Någon bakom dig', recipient: 'Du', address: 'Sorteringsbordet', postmark: '2026-07-24',
body: 'Vänd dig inte om. Texten kommer vara annorlunda när du tittar tillbaka.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL', 'TEXTEN HAR ÄNDRATS'], body: 'Vänd dig inte om. Texten kommer vara annorlunda när du tittar tillbaka.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL', 'TEXTEN HAR ÄNDRATS'],
correctRoute: 'quarantine', explanation: '§6.6 undantar brevet från trestämpelregeln. Du vände dig väl inte om?', correctRoute: 'quarantine', explanation: '§6.6 undantar brevet från trestämpelregeln. Du vände dig väl inte om?',
correctConsequence: '§6.6 tillämpades utan att du vände dig om. Bordet bakom dig var tomt när du till slut tittade.',
wrongConsequences: {
deliver: 'Levererat till "Du". Det kom tillbaka samma eftermiddag, adresserat till någon annan, i din handstil.',
return: 'Returnerat till avsändaren, som stod bakom dig hela tiden. Ingen retur behövdes egentligen.',
archive: 'Arkiv 9 tog emot det som historiskt, trots dagens datum. Tre nya svarta stämplar syntes redan på kuvertet.',
},
penalty: { deliver: { composure: 2, suspicion: 2 } },
}, },
{ {
id: 'l16', day: 4, sender: 'Kammarkollegiet', recipient: 'Vederbörande', address: 'Stora Nygatan 1', postmark: '2026-07-24', id: 'l16', day: 4, sender: 'Kammarkollegiet', recipient: 'Vederbörande', address: 'Stora Nygatan 1', postmark: '2026-07-24',
body: 'Härmed översändes blankett 14-C. Blanketten bekräftar att blankett 14-C saknas.', marks: ['DIARIEFÖRD'], body: 'Härmed översändes blankett 14-C. Blanketten bekräftar att blankett 14-C saknas.', marks: ['DIARIEFÖRD'],
correctRoute: 'deliver', explanation: 'Fullständig adress, nutida mottagare, vanlig svensk förvaltning.', correctRoute: 'deliver', explanation: 'Fullständig adress, nutida mottagare, vanlig svensk förvaltning.',
correctConsequence: 'Levererat enligt vanlig förvaltning. Blankett 14-C bekräftades fortfarande saknas, nu officiellt.',
wrongConsequences: {
return: 'Returnerat i onödan. Kammarkollegiet skickade tillbaka samma blankett med en tredje bekräftelse av dess frånvaro.',
archive: 'Arkiv 9 arkiverade ett pågående ärende. Det räknas nu som avslutat, vilket det inte var.',
quarantine: 'Spärrat utan anledning. Blankett 14-C existerar fortfarande inte, nu i karantän också.',
},
}, },
{ {
id: 'l17', day: 5, sender: 'Ture Andersson', recipient: 'Britta Nilsson', address: 'Bokgatan 12, Malmö', postmark: '2037-07-25', id: 'l17', day: 5, sender: 'Ture Andersson', recipient: 'Britta Nilsson', address: 'Bokgatan 12, Malmö', postmark: '2037-07-25',
body: 'Skicka inte koftan. Det är så den hittar hit.', marks: ['PORTO GÄLLER FRÅN 2037'], body: 'Skicka inte koftan. Det är så den hittar hit.', marks: ['PORTO GÄLLER FRÅN 2037'],
correctRoute: 'return', explanation: 'Framtida post återsänds. Familjens koftproblem får lösas kronologiskt.', correctRoute: 'return', explanation: 'Framtida post återsänds. Familjens koftproblem får lösas kronologiskt.',
correctConsequence: 'Returnerat till 2037. Britta har elva år kvar att inte skicka koftan i.',
wrongConsequences: {
deliver: 'Levererat till Bokgatan 12, en adress som inte finns än. Huset byggs först 2029.',
archive: 'Arkiv 9 vägrade acceptera framtida post som historia. Det kom tillbaka poststämplat två gånger.',
quarantine: 'Spärrat utan grund. Koftan hittade ändå vägen dit den brukar, elva år för tidigt.',
},
}, },
{ {
id: 'l18', day: 5, sender: 'Du', recipient: 'Du', address: 'Avdelningen för obeställbara försändelser', postmark: '2026-07-26', id: 'l18', day: 5, sender: 'Du', recipient: 'Du', address: 'Avdelningen för obeställbara försändelser', postmark: '2026-07-26',
body: 'Klockan är 16:42. Du har två brev kvar. Lägg inte detta i returhögen. Det är där handen finns.', marks: ['IMORGON', 'TEXTEN HAR ÄNDRATS'], body: 'Klockan är 16:42. Du har två brev kvar. Lägg inte detta i returhögen. Det är där handen finns.', marks: ['IMORGON', 'TEXTEN HAR ÄNDRATS'],
correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4.', correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4.',
correctConsequence: '§7.4 tillämpades. Klockan blev 16:42 utan att hissen rörde sig. Två brev återstod, precis som brevet sa.',
wrongConsequences: {
deliver: 'Levererat till dig själv. Du läste det en andra gång innan du förstod vad du just hade gjort.',
return: 'Returnerat till avsändaren — dig, imorgon. Ingen kvittens kom tillbaka.',
archive: 'Arkiv 9 tog emot dagens post som redan avslutad. Klockan visade fortfarande 16:42 efteråt.',
},
penalty: { return: { suspicion: 2 } },
}, },
{ {
id: 'l19', day: 5, sender: 'Avdelning Noll', recipient: 'Föreståndaren', address: 'Kontor 4', postmark: '2031-01-01', id: 'l19', day: 5, sender: 'Avdelning Noll', recipient: 'Föreståndaren', address: 'Kontor 4', postmark: '2031-01-01',
body: 'Gratulerar till fem års prickfri tjänst. Beklagar resten.', marks: ['INTERNPOST', '0', 'FRAMTIDA'], body: 'Gratulerar till fem års prickfri tjänst. Beklagar resten.', marks: ['INTERNPOST', '0', 'FRAMTIDA'],
correctRoute: 'deliver', explanation: '§5.9 säger alltid. Avdelning Noll har företräde även framför framtiden.', correctRoute: 'deliver', explanation: '§5.9 säger alltid. Avdelning Noll har företräde även framför framtiden.',
correctConsequence: '§5.9 gäller före framtiden också. Gratulationen levererades femton år för tidigt, till rätt person.',
wrongConsequences: {
return: 'Avdelning Noll accepterar inga returer, inte ens från framtiden. Brevet kom tillbaka från ett kontor som inte finns förrän 2031.',
archive: 'Arkiv 9 arkiverade en gratulation som ännu inte inträffat. Fem år drogs av från någons tjänstetid, ospecificerat vems.',
quarantine: 'Spärrat i onödan. Firandet skedde ändå, tyst, i ett rum utan fönster.',
},
}, },
{ {
id: 'l20', day: 5, sender: 'Föreståndaren', recipient: 'Ny handläggare', address: 'Sorteringsbordet', postmark: '1956-07-25', id: 'l20', day: 5, sender: 'Föreståndaren', recipient: 'Ny handläggare', address: 'Sorteringsbordet', postmark: '1956-07-25',
body: 'Om du läser detta har jag fått gå hem. Det händer sällan här. Bordet behöver någon.', marks: ['PERSONALAKT', 'SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'], body: 'Om du läser detta har jag fått gå hem. Det händer sällan här. Bordet behöver någon.', marks: ['PERSONALAKT', 'SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'],
correctRoute: 'archive', explanation: 'Äldre än femtio år och tre svarta stämplar. Arkiv 9 tar hand om företrädaren. Förmodligen.', correctRoute: 'archive', explanation: 'Äldre än femtio år och tre svarta stämplar. Arkiv 9 tar hand om företrädaren. Förmodligen.',
correctConsequence: 'Arkiverat. Bordet fick sin nya handläggare samma dag brevet skrevs, sjuttio år tidigare.',
wrongConsequences: {
deliver: 'Levererat till "Ny handläggare" — dig. Du la brevet i din egen ficka utan att minnas att du gjort det.',
return: 'Returnerat till Föreståndaren, 1956. Ingen sådan person svarade, då eller nu.',
quarantine: 'Spärrat trots åldern. Personalakten öppnades ändå, av någon som redan gått hem för dagen.',
},
}, },
{ {
id: 'l21', day: 1, sender: 'MKB Fastighets AB', recipient: 'Boende, lgh 1302', address: 'Grönegatan 8, Malmö', postmark: '2026-07-21', id: 'l21', day: 1, sender: 'MKB Fastighets AB', recipient: 'Boende, lgh 1302', address: 'Grönegatan 8, Malmö', postmark: '2026-07-21',

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame } from './engine.ts'; import { createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame } from './engine.ts';
import { LETTERS } from './data/content.ts';
describe('sorting engine', () => { describe('sorting engine', () => {
it('starts on the first letter of day one', () => { it('starts on the first letter of day one', () => {
@@ -49,4 +50,21 @@ describe('sorting engine', () => {
it('has six authored letters in every shift', () => { it('has six authored letters in every shift', () => {
for (let day = 1; day <= 5; day += 1) expect(lettersForDay(day)).toHaveLength(6); for (let day = 1; day <= 5; day += 1) expect(lettersForDay(day)).toHaveLength(6);
}); });
it('every letter has its own correct-route consequence, not just the generic fallback', () => {
for (const letter of LETTERS) {
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);
});
}); });

View File

@@ -1,6 +1,6 @@
import './style.css'; import './style.css';
import { DAY_BRIEFINGS, REGULATIONS, ROUTES } from './data/content.ts'; import { DAY_BRIEFINGS, REGULATIONS, ROUTES } from './data/content.ts';
import { clearState, createState, currentLetter, lettersForDay, loadState, nextDay, routeLetter, saveState, startGame } from './engine.ts'; import { MAX_SUSPICION, clearState, createState, currentLetter, lettersForDay, loadState, nextDay, routeLetter, saveState, startGame } from './engine.ts';
import type { Route } from './types.ts'; import type { Route } from './types.ts';
let state = loadState() ?? createState(); let state = loadState() ?? createState();
@@ -156,11 +156,31 @@ function renderDayEnd(): void {
function renderEnding(): void { function renderEnding(): void {
const won = state.phase === 'won'; const won = state.phase === 'won';
const firedBySuspicion = !won && state.suspicion >= MAX_SUSPICION;
const stamp = won ? (state.suspicion <= 1 ? 'TILLSVIDARE' : 'TILLSVIDARE, VILLKORLIGT') : firedBySuspicion ? 'AVFÖRD' : 'OMPLACERAD';
const title = won
? state.suspicion <= 1
? 'Du får behålla bordet.'
: 'Du får behålla bordet. För tillfället.'
: firedBySuspicion
? 'Man slutade fråga vad du gjorde där nere.'
: 'Din stol behövs av någon annan.';
const body = won
? state.suspicion <= 1
? 'Fem arbetsdagar avslutade utan en enda anmärkning. Föreståndaren lämnar en nyckel till plan fyra utan att kommentera den. Hissen har fortfarande bara tre knappar.'
: 'Fem arbetsdagar avslutade, men personalakten är tjockare än den borde vara. Föreståndaren lämnar en nyckel till plan fyra och tittar på dig en sekund för länge innan hen går.'
: firedBySuspicion
? '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"><div class="end-card ${won ? 'victory' : 'failure'}">
<div class="panel-title">SLUTLIG PERSONALAKT</div> <div class="panel-title">SLUTLIG PERSONALAKT</div>
<div class="audit-stamp">${won ? 'TILLSVIDARE' : 'OMPLACERAD'}</div> <div class="audit-stamp">${stamp}</div>
<h1>${won ? 'Du får behålla bordet.' : 'Din stol behövs av någon annan.'}</h1> <h1>${title}</h1>
<p>${won ? 'Fem arbetsdagar avslutade. Föreståndaren lämnar en nyckel till plan fyra. Hissen har fortfarande bara tre knappar.' : 'För många avvikelser. En kartong står redan vid din plats. Den är adresserad till din efterträdare.'}</p> <p>${body}</p>
<dl class="final-stats"><div><dt>Noggrannhet</dt><dd>${state.accuracy}%</dd></div><div><dt>Misstanke</dt><dd>${state.suspicion}/5</dd></div><div><dt>Försändelser</dt><dd>${state.decisions.length}</dd></div></dl> <dl class="final-stats"><div><dt>Noggrannhet</dt><dd>${state.accuracy}%</dd></div><div><dt>Misstanke</dt><dd>${state.suspicion}/5</dd></div><div><dt>Försändelser</dt><dd>${state.decisions.length}</dd></div></dl>
<button class="primary" id="restart-button">NY PERSONALAKT</button> <button class="primary" id="restart-button">NY PERSONALAKT</button>
</div></main>`); </div></main>`);