diff --git a/src/data/content.ts b/src/data/content.ts index c885254..c6df9c1 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -124,6 +124,94 @@ export const LETTERS: Letter[] = [ 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.', }, + { + id: 'l21', day: 1, sender: 'MKB Fastighets AB', recipient: 'Boende, lgh 1302', address: 'Grönegatan 8, Malmö', postmark: '2026-07-21', + body: 'Planerat vattenavbrott torsdag 09:00–15:00. Knackningar i ledningarna under övrig tid ingår inte i felanmälan.', marks: ['SAMHÄLLSINFORMATION'], + correctRoute: 'deliver', explanation: 'Fullständig adress och nutida mottagare. Fastighetens rörsystem ligger utanför uppdraget.', + correctConsequence: 'Meddelandet gick vidare. Klockan 09:01 på torsdag blev hela huset tyst.', + }, + { + id: 'l22', day: 1, sender: 'Havet', recipient: 'Den som öppnade sist', address: '', postmark: '2026-07-21', + body: 'ÖPPNA INTE. Jag har redan kommit in genom kuvertets hörn.', marks: ['SALTFLÄCKAR', 'ÖPPNA EJ'], + correctRoute: 'quarantine', explanation: 'Brevet ber uttryckligen att inte öppnas. §3.1 gäller oavsett avsändarens geografiska omfång.', + correctConsequence: 'Karantänskåpets nedersta låda började droppa. Golvbrunnen tog hand om det mesta.', + wrongConsequences: { + deliver: 'Brevbäraren kom tillbaka genomblöt och utan skugga. Händelsen registrerades som arbetsrelaterad.', + return: 'Kustbevakningen hittade retursäcken flytande utanför Limhamn. Den var varm.', + archive: 'Arkiv 9 stängde för sanering. Din signatur fanns på insidan av samtliga kuvert.', + }, + penalty: { deliver: { composure: 2, suspicion: 2 }, return: { suspicion: 2 }, archive: { composure: 2 } }, + }, + { + id: 'l23', day: 2, sender: 'Folkbokföringen', recipient: 'Karl-Gustav Holm (1901–1974)', address: 'Rönneholmsvägen 44', postmark: '2026-07-22', + body: 'Bekräftelse av adressändring. Ny folkbokföringsadress: samma plats, cirka två meter längre ned.', marks: ['PERSONBEVIS'], + correctRoute: 'archive', explanation: 'Mottagaren är avliden. Formulärets exakta djup förändrar inte §4.3.', + correctConsequence: 'Arkiv 9 lade handlingen i en mapp märkt FAST BOENDE. Mappen var redan full.', + }, + { + id: 'l24', day: 2, sender: 'Karl-Gustav Holm (1901–1974)', recipient: 'Folkbokföringen', address: 'Kattsundsgatan 7', postmark: '2026-07-22', + body: 'Adressändringen godkänns inte. Det är trångt här nere och grannen spelar radio hela natten.', marks: ['LUKT AV FUKT', 'HANDSKRIVET'], + correctRoute: 'quarantine', explanation: 'En avliden person står som avsändare. §4.7 är den specifika och senare föreskriften.', + correctConsequence: 'Brevet låstes in. Radion hördes ändå svagt från ventilationsgallret resten av dagen.', + wrongConsequences: { + deliver: 'Folkbokföringen registrerade Karl-Gustav som levande. Systemet registrerade samtidigt dig som avliden.', + return: 'Kuvertet återkom med tillägget NEKAS: MOTTAGAREN EJ HEMMA, skrivet med din handstil.', + archive: 'Arkiv 9 vägrade ta emot avsändaren två gånger. Den tredje gången tog de emot budet i stället.', + }, + penalty: { deliver: { composure: 1, suspicion: 2 }, return: { composure: 2 }, archive: { suspicion: 2 } }, + }, + { + id: 'l25', day: 3, sender: 'Avdelning Noll', recipient: 'Vaktmästeriet', address: 'Källarplan -6', postmark: '2026-07-23', + body: 'Byt lampan i korridoren. Den nuvarande belyser sådant som inte längre är anställt.', marks: ['INTERNPOST', '0', 'BRÅDSKANDE'], + correctRoute: 'deliver', explanation: 'Avdelning Nolls internpost skall alltid vidare enligt §5.9, trots det negativa våningsplanet.', + correctConsequence: 'Vaktmästaren tog med en stege och kom tillbaka utan den. Lampan fungerar nu.', + }, + { + id: 'l26', day: 3, sender: 'Bostadsrättsföreningen Eken', recipient: 'Innehavaren', address: 'Östra Rönneholmsvägen -1', postmark: '2026-07-23', + body: 'Kallelse till extra föreningsstämma angående den nya lägenheten under tvättstugan.', marks: ['REKOMMENDERAT'], + correctRoute: 'return', explanation: 'Negativa gatunummer är ogiltiga enligt §5.2. Det gäller även bostadsrättsföreningar med expansionsplaner.', + correctConsequence: 'Föreningen strök lägenheten ur röstlängden. Avgiften drogs fortfarande via autogiro.', + wrongConsequences: { + deliver: 'En ny namnskylt dök upp under hissens nedersta knapp. Föreningen kräver nu att du betalar medlemsavgift.', + archive: 'Arkiv 9 registrerade fastigheten. Kommunens karta fick ett hål där kvarteret brukade ligga.', + quarantine: 'Karantänskåpet kallades till föreningsstämman och fick två röster.', + }, + penalty: { deliver: { suspicion: 2 }, archive: { composure: 2 }, quarantine: { suspicion: 2 } }, + }, + { + id: 'l27', day: 4, sender: 'Arkiv 9', recipient: 'Sorteringen', address: 'Internfack 9', postmark: '2026-07-24', + body: 'Den här försändelsen har tre svarta stämplar. Den här försändelsen har fyra svarta stämplar.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL', 'TEXTEN HAR ÄNDRATS'], + correctRoute: 'quarantine', explanation: 'Textförändring enligt §6.6 har uttryckligt företräde framför trestämpelregeln.', + correctConsequence: 'Skåpet svalde brevet utan att öppnas. En fjärde svart stämpel syntes kort på din handrygg.', + wrongConsequences: { + archive: 'Arkiv 9 skickade tillbaka brevet tillsammans med ett fotografi av dig när du fattade beslutet.', + }, + penalty: { archive: { composure: 2, suspicion: 2 } }, + }, + { + id: 'l28', day: 4, sender: 'Länsstyrelsen', recipient: 'Arkiv 9', address: 'Internleverans', postmark: '2026-07-24', + body: 'Slutrapport rörande tre försvunna brunnar, två felplacerade vittnen och ett godkänt avsteg.', marks: ['SVART STÄMPEL', 'SVART STÄMPEL', 'SVART STÄMPEL'], + correctRoute: 'archive', explanation: 'Tre svarta stämplar innebär Arkiv 9. Texten har inte förändrats, hur mycket man än önskar det.', + correctConsequence: 'Rapporten arkiverades. En av brunnarna ströks från bilagan och från verkligheten.', + }, + { + id: 'l29', day: 5, sender: 'Skatteverket 2041', recipient: 'Skatteverket 2026', address: 'Kungsgatan 15', postmark: '2041-03-18', + body: 'Förhandsbesked: dödsboet efter Staten Sverige saknar behörig firmatecknare.', marks: ['FRAMTIDA', 'TJÄNSTEPOST'], + correctRoute: 'return', explanation: 'Framtida post skall återsändas enligt §7.1. Innehållets skattemässiga betydelse är inte ert ansvar.', + correctConsequence: 'Returen accepterades femton år i förväg. Ett automatiskt dröjsmålsbrev skrevs ut omedelbart.', + }, + { + id: 'l30', day: 5, sender: 'Föreståndaren, måndag', recipient: 'Du, fredag', address: 'Ditt sorteringsbord', postmark: '2026-07-27', + body: 'Du läser det sista brevet nu. När du har stämplat hör du hissen stanna på plan fyra. Ta inte med nyckeln.', marks: ['NÄSTA VECKA', 'PERSONLIGT'], + correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4. Det räcker att beskrivningen är korrekt.', + correctConsequence: 'Brevet låstes in. Hissen stannade ändå på plan fyra. Någon därute provade din dörr tre gånger.', + wrongConsequences: { + deliver: 'Du vidarebefordrade brevet till dig själv. Hissen öppnades och nästa arbetsvecka började genast.', + return: 'Föreståndaren tog emot returen i måndags och skrev ditt namn på fredagens frånvarolista.', + archive: 'Arkiv 9 registrerade dagen som redan avslutad. Alla klockor i huset hoppade till 17:01.', + }, + penalty: { deliver: { composure: 2, suspicion: 2 }, return: { suspicion: 2 }, archive: { composure: 2 } }, + }, ]; export const DAY_BRIEFINGS = [ diff --git a/src/engine.test.ts b/src/engine.test.ts index 4f0983a..e2ab30c 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -32,4 +32,21 @@ describe('sorting engine', () => { 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); + expect(currentLetter(state)?.id).toBe('l22'); + + state = routeLetter(state, 'deliver'); + expect(state.lastDecision?.correct).toBe(false); + expect(state.lastDecision?.composureCost).toBe(2); + expect(state.lastDecision?.suspicionCost).toBe(2); + 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); + }); }); diff --git a/src/engine.ts b/src/engine.ts index c6a31a6..b7d15d9 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -5,6 +5,13 @@ export const MAX_COMPOSURE = 3; export const MAX_SUSPICION = 5; export const LAST_DAY = Math.max(...LETTERS.map((letter) => letter.day)); +const DEFAULT_WRONG_CONSEQUENCES: Record = { + deliver: 'Försändelsen lämnade byggnaden. Någon ringde senare, men samtalet innehöll bara ditt eget andetag.', + return: 'Returtransporten kom tillbaka tom. Chauffören ville inte säga var han hade varit.', + archive: 'Arkiv 9 kvitterade försändelsen och registrerade samtidigt en anmärkning i din personalakt.', + quarantine: 'Karantänskåpet tog emot brevet. Något annat därinne slutade knacka.', +}; + export function createState(): GameState { return { phase: 'intro', day: 1, letterIndex: 0, accuracy: 100, @@ -30,11 +37,18 @@ export function routeLetter(state: GameState, route: Route): GameState { if (!letter) return state; const correct = letter.correctRoute === route; - const decisions = [...state.decisions, { letterId: letter.id, route, correct }]; + const configuredPenalty = letter.penalty?.[route]; + const composureCost = correct ? 0 : configuredPenalty?.composure ?? 1; + const suspicionCost = correct ? 0 : configuredPenalty?.suspicion ?? 1; + const consequence = correct + ? letter.correctConsequence ?? 'Försändelsen försvann ned i rätt fack. Ingen protesterade högt nog för att räknas.' + : letter.wrongConsequences?.[route] ?? DEFAULT_WRONG_CONSEQUENCES[route]; + const decision = { letterId: letter.id, route, correct, consequence, composureCost, suspicionCost }; + const decisions = [...state.decisions, decision]; const correctCount = decisions.filter((decision) => decision.correct).length; const accuracy = Math.round((correctCount / decisions.length) * 100); - const composure = Math.max(0, state.composure - (correct ? 0 : 1)); - const suspicion = Math.min(MAX_SUSPICION, state.suspicion + (correct ? 0 : 1)); + const composure = Math.max(0, state.composure - composureCost); + const suspicion = Math.min(MAX_SUSPICION, state.suspicion + suspicionCost); const dayLetters = lettersForDay(state.day); const isLastLetter = state.letterIndex >= dayLetters.length - 1; @@ -50,7 +64,7 @@ export function routeLetter(state: GameState, route: Route): GameState { composure, suspicion, decisions, - lastDecision: { letterId: letter.id, route, correct }, + lastDecision: decision, }; } @@ -67,12 +81,12 @@ export function nextDay(state: GameState): GameState { } export function saveState(state: GameState): void { - localStorage.setItem('retur-save-v1', JSON.stringify(state)); + localStorage.setItem('retur-save-v2', JSON.stringify(state)); } export function loadState(): GameState | null { try { - const value = localStorage.getItem('retur-save-v1'); + const value = localStorage.getItem('retur-save-v2'); return value ? JSON.parse(value) as GameState : null; } catch { return null; @@ -80,5 +94,5 @@ export function loadState(): GameState | null { } export function clearState(): void { - localStorage.removeItem('retur-save-v1'); + localStorage.removeItem('retur-save-v2'); } diff --git a/src/main.ts b/src/main.ts index 9ba547f..22beed2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import type { Route } from './types.ts'; let state = loadState() ?? createState(); let selectedRoute: Route | null = null; let showingResult = false; +let resultAdvanced = false; const app = document.getElementById('app')!; @@ -16,6 +17,7 @@ function escapeHtml(value: string): string { function render(): void { document.documentElement.style.setProperty('--suspicion', String(state.suspicion / 5)); if (state.phase === 'intro') return renderIntro(); + if (showingResult) return renderDesk(); if (state.phase === 'day-end') return renderDayEnd(); if (state.phase === 'won' || state.phase === 'lost') return renderEnding(); renderDesk(); @@ -63,9 +65,13 @@ function renderDesk(): void { ? `
${state.lastDecision.correct ? 'KORREKT SORTERAD' : 'ANMÄRKNING REGISTRERAD'}

${escapeHtml(letter.explanation)}

+
FÖLJD${escapeHtml(state.lastDecision.consequence)}
+ ${state.lastDecision.correct ? '' : `
−${state.lastDecision.composureCost} lugn · +${state.lastDecision.suspicionCost} misstanke
`}
` : ''; + const recentDecisions = state.decisions.slice(-3).reverse(); + shell(`
@@ -114,21 +121,20 @@ function stamp(): void { const before = currentLetter(state); state = routeLetter(state, selectedRoute); saveState(state); - if (state.phase === 'playing' && before?.id !== currentLetter(state)?.id) { + resultAdvanced = before?.id !== currentLetter(state)?.id; + if (resultAdvanced) { // The engine has advanced; temporarily step the view back to show why the previous decision scored as it did. state = { ...state, letterIndex: state.letterIndex - 1 }; - showingResult = true; - } else if (state.phase === 'day-end' || state.phase === 'won' || state.phase === 'lost') { - render(); - return; } + showingResult = true; render(); } function advanceAfterResult(): void { if (!showingResult) return; - state = { ...state, letterIndex: state.letterIndex + 1 }; + if (resultAdvanced) state = { ...state, letterIndex: state.letterIndex + 1 }; showingResult = false; + resultAdvanced = false; selectedRoute = null; saveState(state); render(); @@ -158,7 +164,7 @@ function renderEnding(): void {
Noggrannhet
${state.accuracy}%
Misstanke
${state.suspicion}/5
Försändelser
${state.decisions.length}
`); - document.getElementById('restart-button')?.addEventListener('click', () => { clearState(); state = createState(); selectedRoute = null; showingResult = false; render(); }); + document.getElementById('restart-button')?.addEventListener('click', () => { clearState(); state = createState(); selectedRoute = null; showingResult = false; resultAdvanced = false; render(); }); } function bindShell(): void { diff --git a/src/style.css b/src/style.css index 2bb4422..9fbff8b 100644 --- a/src/style.css +++ b/src/style.css @@ -28,6 +28,11 @@ button { font: inherit; } .meter.suspicion::after, .meter.suspicion i { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--border); } .meter.suspicion i { right: auto; background: var(--danger); z-index: 1; transition: width .25s; } .regulations-title { margin-top: 24px; } +.incidents-title { margin-top: 24px; } +.incident-log { margin: 0; padding: 0; list-style: none; display: grid; gap: 7px; } +.incident-log li { padding-left: 10px; border-left: 2px solid var(--safe); color: var(--dim); font-size: 9px; line-height: 1.45; } +.incident-log li.bad { border-left-color: var(--danger); } +.incident-log b { display: block; color: var(--text); font-size: 8px; } .regulations { padding: 0; margin: 0; list-style: none; display: grid; gap: 9px; } .regulations li { padding-left: 36px; color: #aaaacc; font-size: 10px; position: relative; } .regulations b { position: absolute; left: 0; color: var(--accent); } @@ -64,6 +69,9 @@ button { font: inherit; } .result strong { font-size: clamp(22px, 4vw, 38px); letter-spacing: .12em; } .result.correct strong { color: var(--safe); } .result.wrong strong { color: var(--danger); } .result p { max-width: 540px; color: var(--dim); } +.consequence { max-width: 540px; margin: 10px auto 14px; padding: 12px; border: 1px solid var(--border); color: var(--text); font-size: 11px; } +.consequence span { display: block; margin-bottom: 4px; color: var(--archive); font-size: 8px; letter-spacing: .12em; } +.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); } diff --git a/src/types.ts b/src/types.ts index ba6b45d..816a74a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,9 @@ export interface Letter { marks: string[]; correctRoute: Route; explanation: string; + correctConsequence?: string; + wrongConsequences?: Partial>; + penalty?: Partial>; } export interface Regulation { @@ -25,6 +28,9 @@ export interface Decision { letterId: string; route: Route; correct: boolean; + consequence: string; + composureCost: number; + suspicionCost: number; } export interface GameState {