Build Retur dead-letter sorting game

This commit is contained in:
Fredrik Johansson
2026-07-20 19:45:59 +02:00
commit b69131c540
18 changed files with 2270 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2
exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.local

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Retur
**Avdelningen för obeställbara försändelser.**
A short bureaucratic supernatural sorting game. You have five shifts at a municipal dead-letter office. Read each letter, consult an expanding and occasionally contradictory rulebook, then route it to delivery, sender, Archive 9, or quarantine.
Later regulations override earlier ones. The letters know this too.
## Stack
Vanilla TypeScript + Vite. No framework, backend, or runtime dependencies. State is kept in `localStorage`.
The visual language deliberately follows `djupet`: JetBrains Mono, near-black blue panels, restrained eldritch color, compact administrative labels, and pressure expressed through a screen-edge vignette. Retur replaces Djupet's Malmö map and idle resources with a paper desk, physical stamps, and a regulation sidebar.
## Development
```bash
npm install
npm run dev # http://localhost:5691
npm run typecheck
npm test
npm run build
```
## Rules
- Inspect the sender, recipient, address, postmark, letter text, and marks.
- Select one of four routes and stamp the decision.
- New regulations arrive each morning. Later and more specific rules take precedence.
- 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.
Keyboard: `1``4` selects a route, `Enter` stamps or continues, `?` opens help.
## Deployment
The production image is a static Vite build served by nginx.
```bash
docker compose up -d --build
```

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
retur:
build: .
image: retur:latest
container_name: retur
restart: unless-stopped
ports:
- "${HOST_PORT:-5694}:80"

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d0d1a" />
<meta name="description" content="Retur — ett byråkratiskt sorteringsspel om brev som inte borde finnas." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>RETUR — Avdelningen för obeställbara försändelser</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

16
nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|svg|webp|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

1507
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "retur",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5691",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview --port 4691",
"test": "vitest run"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^6.0.0",
"vitest": "^4.1.10"
}
}

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0d0d1a"/>
<rect x="6" y="9" width="20" height="14" rx="1" fill="none" stroke="#d95462" stroke-width="2" transform="rotate(-4 16 16)"/>
<path d="M7 10l9 7 9-7" fill="none" stroke="#d95462" stroke-width="2" stroke-linejoin="round" transform="rotate(-4 16 16)"/>
<text x="16" y="29" text-anchor="middle" fill="#aa66dd" font-size="5" font-family="monospace">RETUR</text>
</svg>

After

Width:  |  Height:  |  Size: 486 B

135
src/data/content.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { Letter, Regulation, Route } from '../types.ts';
export const ROUTES: Record<Route, { label: string; stamp: string; description: string }> = {
deliver: { label: 'VIDAREBEFORDRA', stamp: 'VIDARE', description: 'Adressen är giltig. Låt någon annan få problemet.' },
return: { label: 'ÅTERSÄND', stamp: 'RETUR', description: 'Avsändaren finns och bör få tillbaka sin försändelse.' },
archive: { label: 'ARKIV 9', stamp: 'ARKIV', description: 'Försändelser som gäller det förflutna. Eller kommer därifrån.' },
quarantine: { label: 'KARANTÄN', stamp: 'SPÄRRA', description: 'Öppna inte igen. Särskilt inte om brevet ber snällt.' },
};
export const REGULATIONS: Regulation[] = [
{ id: 'r1', fromDay: 1, number: '1.1', text: 'Fullständig gatuadress och nutida mottagare: vidarebefordra.' },
{ id: 'r2', fromDay: 1, number: '1.4', text: 'Saknad eller uppenbart ogiltig adress: återsänd om avsändare finns.' },
{ id: 'r3', fromDay: 1, number: '2.2', text: 'Poststämplar äldre än femtio år tillhör Arkiv 9.' },
{ id: 'r4', fromDay: 1, number: '3.1', text: 'Försändelser som uttryckligen ber att inte öppnas placeras i karantän.' },
{ id: 'r5', fromDay: 2, number: '4.3', text: 'Mottagare registrerade som avlidna behandlas som historiska ärenden: Arkiv 9.' },
{ id: 'r6', fromDay: 2, number: '4.7', text: 'Om den avlidne själv anges som avsändare gäller karantän.', note: 'Senare föreskrift har företräde.' },
{ id: 'r6b', fromDay: 2, number: '4.9', text: 'Försändelser som saknar både giltig avsändare och adress placeras i karantän.' },
{ id: 'r7', fromDay: 3, number: '5.2', text: 'Adresser med negativt gatunummer är ogiltiga och skall återsändas.' },
{ id: 'r8', fromDay: 3, number: '5.9', text: 'Internpost från Avdelning Noll får alltid vidarebefordras.', note: 'Även när adressen i övrigt är otillåten.' },
{ id: 'r9', fromDay: 4, number: '6.6', text: 'Brev vars text förändras mellan läsningar placeras i karantän.' },
{ id: 'r10', fromDay: 4, number: '6.8', text: 'Tre eller fler svarta stämplar innebär Arkiv 9, utom när §6.6 gäller.' },
{ id: 'r11', fromDay: 5, number: '7.1', text: 'Post från ett framtida datum skall återsändas till avsändaren.' },
{ id: 'r12', fromDay: 5, number: '7.4', text: 'Framtida post som beskriver den pågående arbetsdagen skall sättas i karantän.', note: 'Läs inte sista stycket.' },
];
export const LETTERS: Letter[] = [
{
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'],
correctRoute: 'deliver', explanation: 'Fullständig adress och inget annat anmärkningsvärt. Det är nästan misstänkt normalt.',
},
{
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'],
correctRoute: 'return', explanation: 'Adressen är ogiltig och avsändaren finns. Återsänd. Nyckeln nämns inte i föreskrifterna.',
},
{
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'],
correctRoute: 'archive', explanation: 'Poststämpeln är äldre än femtio år. Arkiv 9 hanterar resten.',
},
{
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'],
correctRoute: 'quarantine', explanation: 'Brevet säger uttryckligen att det inte skall öppnas. Det var generöst av det.',
},
{
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'],
correctRoute: 'archive', explanation: 'Mottagaren är registrerad som avliden. Historiskt ärende, trots kommunens optimism.',
},
{
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'],
correctRoute: 'quarantine', explanation: 'Den avlidne är avsändare. Den senare föreskriften §4.7 har företräde.',
},
{
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'],
correctRoute: 'deliver', explanation: 'Självkorrespondens är beklaglig men inte förbjuden.',
},
{
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'],
correctRoute: 'quarantine', explanation: 'Både giltig avsändare och adress saknas. §4.9 tar över returregeln.',
},
{
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'],
correctRoute: 'return', explanation: 'Negativa gatunummer är ogiltiga. Var trappan leder är inte ert ansvarsområde.',
},
{
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'],
correctRoute: 'deliver', explanation: 'Internpost från Avdelning Noll går alltid vidare, även till negativa adresser.',
},
{
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'],
correctRoute: 'return', explanation: 'Ogiltig adress, giltig avsändare. Kretsloppet är inte ert problem.',
},
{
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'],
correctRoute: 'deliver', explanation: '§5.9 säger alltid. Myndigheten har fetmarkerat ordet i originalet.',
},
{
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'],
correctRoute: 'quarantine', explanation: 'Föränderlig text omfattas av §6.6. Följ inte brevets egen sorteringsinstruktion.',
},
{
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'],
correctRoute: 'archive', explanation: 'Både ålder och tre svarta stämplar pekar mot Arkiv 9. För en gångs skull är reglerna överens.',
},
{
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'],
correctRoute: 'quarantine', explanation: '§6.6 undantar brevet från trestämpelregeln. Du vände dig väl inte om?',
},
{
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'],
correctRoute: 'deliver', explanation: 'Fullständig adress, nutida mottagare, vanlig svensk förvaltning.',
},
{
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'],
correctRoute: 'return', explanation: 'Framtida post återsänds. Familjens koftproblem får lösas kronologiskt.',
},
{
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'],
correctRoute: 'quarantine', explanation: 'Framtida post som beskriver den pågående arbetsdagen omfattas av §7.4.',
},
{
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'],
correctRoute: 'deliver', explanation: '§5.9 säger alltid. Avdelning Noll har företräde även framför framtiden.',
},
{
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'],
correctRoute: 'archive', explanation: 'Äldre än femtio år och tre svarta stämplar. Arkiv 9 tar hand om företrädaren. Förmodligen.',
},
];
export const DAY_BRIEFINGS = [
'Vanliga brev. Vanliga regler. Försök att inte vänja dig.',
'Personalregistret har uppdaterats. Några mottagare invände skriftligen.',
'Avdelning Noll har åter öppnat för internpost. Avdelning Noll finns inte.',
'Nattpersonalen rapporterar att vissa brev behöver läsas mer än en gång. Gör inte det.',
'Fredag. Framtiden har börjat svara. Stäng luckan 17:00, oavsett vem som knackar.',
];

35
src/engine.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { createState, currentLetter, lettersForDay, nextDay, routeLetter, startGame } from './engine.ts';
describe('sorting engine', () => {
it('starts on the first letter of day one', () => {
const state = startGame(createState());
expect(state.phase).toBe('playing');
expect(currentLetter(state)?.id).toBe('l01');
});
it('advances and records a correct route', () => {
const state = routeLetter(startGame(createState()), 'deliver');
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');
expect(state.decisions[0].correct).toBe(false);
expect(state.composure).toBe(2);
expect(state.suspicion).toBe(1);
});
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);
expect(state.phase).toBe('day-end');
state = nextDay(state);
expect(state.day).toBe(2);
expect(state.composure).toBe(3);
});
});

84
src/engine.ts Normal file
View File

@@ -0,0 +1,84 @@
import { LETTERS } from './data/content.ts';
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));
export function createState(): GameState {
return {
phase: 'intro', day: 1, letterIndex: 0, accuracy: 100,
composure: MAX_COMPOSURE, suspicion: 0, decisions: [], lastDecision: null,
};
}
export function lettersForDay(day: number): Letter[] {
return LETTERS.filter((letter) => letter.day === day);
}
export function currentLetter(state: GameState): Letter | null {
return lettersForDay(state.day)[state.letterIndex] ?? null;
}
export function startGame(state: GameState): GameState {
return state.phase === 'intro' ? { ...state, phase: 'playing' } : state;
}
export function routeLetter(state: GameState, route: Route): GameState {
if (state.phase !== 'playing') return state;
const letter = currentLetter(state);
if (!letter) return state;
const correct = letter.correctRoute === route;
const decisions = [...state.decisions, { letterId: letter.id, route, correct }];
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 dayLetters = lettersForDay(state.day);
const isLastLetter = state.letterIndex >= dayLetters.length - 1;
let phase: GameState['phase'] = 'playing';
if (composure <= 0 || suspicion >= MAX_SUSPICION) phase = 'lost';
else if (isLastLetter) phase = state.day >= LAST_DAY ? 'won' : 'day-end';
return {
...state,
phase,
letterIndex: isLastLetter ? state.letterIndex : state.letterIndex + 1,
accuracy,
composure,
suspicion,
decisions,
lastDecision: { letterId: letter.id, route, correct },
};
}
export function nextDay(state: GameState): GameState {
if (state.phase !== 'day-end') return state;
return {
...state,
phase: 'playing',
day: state.day + 1,
letterIndex: 0,
composure: Math.min(MAX_COMPOSURE, state.composure + 1),
lastDecision: null,
};
}
export function saveState(state: GameState): void {
localStorage.setItem('retur-save-v1', JSON.stringify(state));
}
export function loadState(): GameState | null {
try {
const value = localStorage.getItem('retur-save-v1');
return value ? JSON.parse(value) as GameState : null;
} catch {
return null;
}
}
export function clearState(): void {
localStorage.removeItem('retur-save-v1');
}

186
src/main.ts Normal file
View File

@@ -0,0 +1,186 @@
import './style.css';
import { DAY_BRIEFINGS, REGULATIONS, ROUTES } from './data/content.ts';
import { clearState, createState, currentLetter, lettersForDay, loadState, nextDay, routeLetter, saveState, startGame } from './engine.ts';
import type { Route } from './types.ts';
let state = loadState() ?? createState();
let selectedRoute: Route | null = null;
let showingResult = false;
const app = document.getElementById('app')!;
function escapeHtml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
function render(): void {
document.documentElement.style.setProperty('--suspicion', String(state.suspicion / 5));
if (state.phase === 'intro') return renderIntro();
if (state.phase === 'day-end') return renderDayEnd();
if (state.phase === 'won' || state.phase === 'lost') return renderEnding();
renderDesk();
}
function shell(content: string): void {
app.innerHTML = `
<header id="header">
<div><span class="brand">RETUR</span><span class="brand-sub">Avdelningen för obeställbara försändelser</span></div>
<button class="text-button" id="help-button" aria-label="Visa arbetsinstruktion">Arbetsinstruktion [?]</button>
</header>
${content}
<div id="danger-vignette"></div>
<dialog id="help-dialog">
<button class="dialog-close" aria-label="Stäng">×</button>
<div class="panel-title">Arbetsinstruktion 1-A</div>
<h2>Sortera. Stämpla. Fråga inte.</h2>
<p>Läs försändelsen och dagens föreskrifter. Välj rätt destination och tryck <b>STÄMPLA</b>. Senare föreskrifter har företräde när reglerna motsäger varandra.</p>
<ul><li>Tre fel och ditt lugn tar slut.</li><li>Misstanken följer med mellan arbetsdagarna.</li><li>En avslutad dag återställer ett lugn.</li><li>Tangent 14 väljer hög, Enter stämplar.</li></ul>
<p class="dim">Avdelningen ansvarar inte för skuggor, viskningar eller felaktigt placerade kroppsdelar.</p>
</dialog>`;
bindShell();
}
function renderIntro(): void {
shell(`<main class="intro-screen">
<div class="seal">R</div>
<div class="intro-copy">
<div class="panel-title">PERSONALPLACERING 9-B / EJ ÖVERKLAGBAR</div>
<h1>RETUR</h1>
<p class="intro-sub">Avdelningen för obeställbara försändelser</p>
<p>Din föregångare har gått hem. Det finns ingen uppgift om att hon kom hit.</p>
<p>Breven på ditt bord saknar giltig destination. Föreskrifterna säger vart de ska. Vanligtvis.</p>
<button class="primary" id="start-button">STÄMPLA IN</button>
</div>
</main>`);
document.getElementById('start-button')?.addEventListener('click', () => { state = startGame(state); saveState(state); render(); });
}
function renderDesk(): void {
const letter = currentLetter(state)!;
const dayLetters = lettersForDay(state.day);
const regulations = REGULATIONS.filter((rule) => rule.fromDay <= state.day);
const result = showingResult && state.lastDecision
? `<div class="result ${state.lastDecision.correct ? 'correct' : 'wrong'}">
<strong>${state.lastDecision.correct ? 'KORREKT SORTERAD' : 'ANMÄRKNING REGISTRERAD'}</strong>
<p>${escapeHtml(letter.explanation)}</p>
<button id="continue-button">NÄSTA FÖRSÄNDELSE →</button>
</div>` : '';
shell(`<main class="desk">
<aside class="panel status-panel">
<div class="panel-title">Arbetsdag</div>
<div class="day-number">0${state.day}</div>
<p class="briefing">${escapeHtml(DAY_BRIEFINGS[state.day - 1])}</p>
<div class="meter"><span>Lugn</span><b>${'●'.repeat(state.composure)}${'○'.repeat(3 - state.composure)}</b></div>
<div class="meter suspicion"><span>Misstanke</span><b>${state.suspicion} / 5</b><i style="width:${state.suspicion * 20}%"></i></div>
<div class="meter"><span>Noggrannhet</span><b>${state.accuracy}%</b></div>
<div class="panel-title regulations-title">Gällande föreskrifter</div>
<ol class="regulations">${regulations.map((rule) => `<li class="${rule.fromDay === state.day && state.day > 1 ? 'new-rule' : ''}"><b>§${rule.number}</b>${escapeHtml(rule.text)}${rule.note ? `<em>${escapeHtml(rule.note)}</em>` : ''}</li>`).join('')}</ol>
</aside>
<section class="work-area">
<div class="queue-meta"><span>INKOMMET ${state.letterIndex + 1}/${dayLetters.length}</span><span>DIARIENUMMER ${letter.id.toUpperCase()}-${state.day}26</span></div>
<article class="letter" aria-label="Aktuell försändelse">
<div class="postmark">${escapeHtml(letter.postmark)}</div>
<div class="letter-grid">
<div><span>Från</span><strong>${escapeHtml(letter.sender)}</strong></div>
<div><span>Till</span><strong>${escapeHtml(letter.recipient)}</strong></div>
<div class="address"><span>Adress</span><strong>${escapeHtml(letter.address || '— SAKNAS —')}</strong></div>
</div>
<div class="letter-body">${escapeHtml(letter.body)}</div>
<div class="marks">${letter.marks.map((mark) => `<span>${escapeHtml(mark)}</span>`).join('')}</div>
</article>
${result}
</section>
<aside class="panel route-panel">
<div class="panel-title">Sorteringsfack</div>
<div class="routes">${(Object.entries(ROUTES) as [Route, typeof ROUTES[Route]][]).map(([key, route], index) => `
<button class="route-button ${selectedRoute === key ? 'selected' : ''}" data-route="${key}" ${showingResult ? 'disabled' : ''}>
<span class="key">${index + 1}</span><strong>${route.label}</strong><small>${route.description}</small>
</button>`).join('')}</div>
<button class="stamp-button" id="stamp-button" ${!selectedRoute || showingResult ? 'disabled' : ''}>STÄMPLA</button>
<p class="desk-note">Alla beslut är slutgiltiga.<br />Felaktiga beslut är mer slutgiltiga.</p>
</aside>
</main>`);
document.querySelectorAll<HTMLButtonElement>('[data-route]').forEach((button) => button.addEventListener('click', () => {
selectedRoute = button.dataset.route as Route;
render();
}));
document.getElementById('stamp-button')?.addEventListener('click', stamp);
}
function stamp(): void {
if (!selectedRoute || showingResult) return;
const before = currentLetter(state);
state = routeLetter(state, selectedRoute);
saveState(state);
if (state.phase === 'playing' && before?.id !== currentLetter(state)?.id) {
// 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;
}
render();
}
function advanceAfterResult(): void {
if (!showingResult) return;
state = { ...state, letterIndex: state.letterIndex + 1 };
showingResult = false;
selectedRoute = null;
saveState(state);
render();
}
function renderDayEnd(): void {
const dayDecisions = state.decisions.slice(-lettersForDay(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>
<div class="audit-stamp">GODKÄND</div>
<h1>Luckan är stängd.</h1>
<p>${correct} av ${dayDecisions.length} försändelser sorterades enligt gällande verklighet.</p>
<p class="dim">Ett lugn återställs över natten. Misstanken gör det inte.</p>
<button class="primary" id="next-day-button">PÅBÖRJA DAG 0${state.day + 1}</button>
</div></main>`);
document.getElementById('next-day-button')?.addEventListener('click', () => { state = nextDay(state); saveState(state); render(); });
}
function renderEnding(): void {
const won = state.phase === 'won';
shell(`<main class="end-screen"><div class="end-card ${won ? 'victory' : 'failure'}">
<div class="panel-title">SLUTLIG PERSONALAKT</div>
<div class="audit-stamp">${won ? 'TILLSVIDARE' : 'OMPLACERAD'}</div>
<h1>${won ? 'Du får behålla bordet.' : 'Din stol behövs av någon annan.'}</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>
<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>
</div></main>`);
document.getElementById('restart-button')?.addEventListener('click', () => { clearState(); state = createState(); selectedRoute = null; showingResult = false; render(); });
}
function bindShell(): void {
const dialog = document.getElementById('help-dialog') as HTMLDialogElement | null;
document.getElementById('help-button')?.addEventListener('click', () => dialog?.showModal());
document.querySelector('.dialog-close')?.addEventListener('click', () => dialog?.close());
dialog?.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
}
window.addEventListener('keydown', (event) => {
if (event.key === '?') (document.getElementById('help-dialog') as HTMLDialogElement | null)?.showModal();
if (state.phase !== 'playing') return;
if (showingResult && event.key === 'Enter') { event.preventDefault(); advanceAfterResult(); return; }
const routes = Object.keys(ROUTES) as Route[];
const index = Number(event.key) - 1;
if (index >= 0 && index < routes.length && !showingResult) { selectedRoute = routes[index]; render(); }
if (event.key === 'Enter' && selectedRoute) { event.preventDefault(); stamp(); }
});
// Click continuation uses the same advancement path as Enter.
document.addEventListener('click', (event) => {
if ((event.target as HTMLElement).id === 'continue-button') advanceAfterResult();
});
render();

89
src/style.css Normal file
View File

@@ -0,0 +1,89 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Special+Elite&display=swap');
:root {
--bg: #0d0d1a; --panel: #12121f; --paper: #d8d0b8; --paper-ink: #25221d;
--border: #2a2a42; --text: #e2e2f0; --dim: #8888aa; --accent: #aa66dd;
--stamp: #d95462; --archive: #c49a45; --safe: #44bb66; --danger: #ee4444;
}
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--bg); }
body { margin: 0; min-height: 100vh; overflow-x: hidden; background: var(--bg); color: var(--text); font: 13px/1.55 'JetBrains Mono', monospace; }
button { font: inherit; }
#app { min-height: 100vh; max-width: 1280px; margin: auto; border-inline: 1px solid var(--border); position: relative; }
#header { min-height: 62px; padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; justify-content: space-between; gap: 18px; background: rgba(13,13,26,.94); }
.brand { color: var(--stamp); font-size: 22px; font-weight: 700; letter-spacing: 5px; text-shadow: 0 0 18px rgba(217,84,98,.35); }
.brand-sub { margin-left: 18px; color: var(--dim); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; }
.text-button { border: 0; background: none; color: var(--dim); cursor: pointer; font-size: 10px; text-transform: uppercase; }
.text-button:hover { color: var(--text); }
.panel { padding: 16px; overflow-y: auto; background: var(--panel); }
.panel-title { color: var(--dim); border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 12px; font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.desk { display: grid; grid-template-columns: 275px minmax(360px, 1fr) 275px; min-height: calc(100vh - 62px); }
.status-panel { border-right: 1px solid var(--border); }
.route-panel { border-left: 1px solid var(--border); }
.day-number { color: var(--accent); font-size: 48px; line-height: 1; font-weight: 700; }
.briefing { min-height: 76px; color: var(--dim); font-size: 11px; }
.meter { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 12px 0; color: var(--dim); font-size: 10px; text-transform: uppercase; position: relative; }
.meter b { color: var(--text); letter-spacing: 2px; }
.meter.suspicion { padding-bottom: 7px; }
.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; }
.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); }
.regulations em { display: block; color: var(--archive); font-style: normal; margin-top: 2px; }
.regulations .new-rule { padding-block: 5px; border-block: 1px dashed rgba(217,84,98,.35); }
.regulations .new-rule::after { content: 'NY'; position: absolute; right: 0; top: -7px; color: var(--stamp); font-size: 8px; }
.work-area { padding: clamp(18px, 4vw, 42px); background: radial-gradient(circle at 50% 38%, #1c1a27 0%, #0b0b15 72%); position: relative; overflow: hidden; }
.work-area::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .22; background-image: repeating-linear-gradient(93deg, transparent 0 47px, rgba(255,255,255,.015) 48px); }
.queue-meta { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: var(--dim); font-size: 9px; letter-spacing: .08em; }
.letter { min-height: 440px; padding: clamp(22px, 4vw, 42px); background-color: var(--paper); color: var(--paper-ink); box-shadow: 0 20px 60px rgba(0,0,0,.5), inset 0 0 80px rgba(87,63,31,.12); transform: rotate(-.35deg); position: relative; font-family: 'Special Elite', 'Courier New', monospace; }
.letter::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .28; background-image: repeating-linear-gradient(0deg, transparent 0 4px, rgba(72,56,34,.035) 5px); }
.postmark { position: absolute; top: 24px; right: 28px; padding: 8px 10px; border: 2px solid rgba(120,38,45,.65); border-radius: 50%; color: rgba(120,38,45,.78); transform: rotate(8deg); font-size: 10px; }
.letter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding-right: 100px; }
.letter-grid div { display: grid; gap: 3px; }
.letter-grid span { color: #766b58; font-size: 9px; text-transform: uppercase; }
.letter-grid strong { font-size: 14px; }
.letter-grid .address { grid-column: 1 / -1; }
.letter-body { max-width: 580px; margin: 52px auto 40px; font-size: clamp(15px, 2vw, 18px); line-height: 1.85; white-space: pre-line; }
.marks { display: flex; flex-wrap: wrap; gap: 9px; position: absolute; left: 28px; right: 28px; bottom: 25px; }
.marks span { padding: 5px 8px; border: 2px solid rgba(120,38,45,.55); color: rgba(120,38,45,.75); font-size: 9px; font-weight: 700; transform: rotate(-2deg); }
.routes { display: grid; gap: 9px; }
.route-button { width: 100%; padding: 10px; border: 1px solid var(--border); background: #0e0e1a; color: var(--text); text-align: left; cursor: pointer; position: relative; }
.route-button:hover:not(:disabled), .route-button.selected { border-color: var(--accent); }
.route-button.selected { box-shadow: inset 3px 0 var(--accent); }
.route-button .key { position: absolute; right: 7px; top: 6px; color: var(--dim); font-size: 9px; }
.route-button strong { display: block; color: var(--accent); font-size: 11px; }
.route-button small { display: block; margin-top: 3px; color: var(--dim); font-size: 9px; line-height: 1.45; }
.route-button:disabled { opacity: .45; cursor: default; }
.stamp-button, .primary { width: 100%; margin-top: 16px; padding: 13px; border: 2px solid var(--stamp); background: rgba(217,84,98,.08); color: var(--stamp); font-weight: 700; letter-spacing: .15em; cursor: pointer; }
.stamp-button:hover:not(:disabled), .primary:hover { background: var(--stamp); color: #fff; }
.stamp-button:disabled { opacity: .25; cursor: not-allowed; }
.desk-note { margin-top: 24px; color: #57576f; font-size: 9px; text-align: center; }
.result { position: absolute; inset: 0; z-index: 3; display: grid; place-content: center; padding: 30px; background: rgba(8,8,16,.93); text-align: center; }
.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); }
.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-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 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); }
.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); }
.final-stats dt { color: var(--dim); font-size: 9px; text-transform: uppercase; }.final-stats dd { margin: 4px 0 0; font-size: 20px; }
.dim { color: var(--dim); font-size: 11px; }
#danger-vignette { position: fixed; inset: 0; z-index: 10; pointer-events: none; background: radial-gradient(ellipse, transparent 55%, var(--danger) 150%); opacity: calc(var(--suspicion, 0) * .32); transition: opacity .5s; }
dialog { width: min(560px, calc(100% - 30px)); padding: 25px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font: 12px/1.7 'JetBrains Mono', monospace; }
dialog::backdrop { background: rgba(0,0,0,.8); }
.dialog-close { position: absolute; right: 12px; top: 8px; border: 0; background: none; color: var(--dim); font-size: 24px; cursor: pointer; }
@media (max-width: 980px) { .desk { grid-template-columns: 220px 1fr; }.route-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }.routes { grid-template-columns: repeat(4, 1fr); }.route-panel .stamp-button { max-width: 280px; }.desk-note { display: none; } }
@media (max-width: 700px) { #app { border: 0; }.brand-sub { display: block; margin: 2px 0 0; }.desk { display: block; }.status-panel { border-right: 0; border-bottom: 1px solid var(--border); }.regulations { max-height: 180px; overflow-y: auto; }.work-area { min-height: 560px; padding: 18px 12px; }.letter { min-height: 500px; padding: 24px 18px; }.letter-grid { display: grid; grid-template-columns: 1fr; gap: 10px; padding-right: 80px; }.letter-grid .address { grid-column: auto; }.letter-body { margin-top: 36px; }.routes { grid-template-columns: 1fr 1fr; }.intro-screen { grid-template-columns: 1fr; text-align: center; }.seal { width: 130px; margin: auto; }.final-stats { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }

39
src/types.ts Normal file
View File

@@ -0,0 +1,39 @@
export type Route = 'deliver' | 'return' | 'archive' | 'quarantine';
export interface Letter {
id: string;
day: number;
sender: string;
recipient: string;
address: string;
postmark: string;
body: string;
marks: string[];
correctRoute: Route;
explanation: string;
}
export interface Regulation {
id: string;
fromDay: number;
number: string;
text: string;
note?: string;
}
export interface Decision {
letterId: string;
route: Route;
correct: boolean;
}
export interface GameState {
phase: 'intro' | 'playing' | 'day-end' | 'won' | 'lost';
day: number;
letterIndex: number;
accuracy: number;
composure: number;
suspicion: number;
decisions: Decision[];
lastDecision: Decision | null;
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: { host: true },
});