Files
2026-10-13 01:32:14 +02:00

443 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ATM10 Stats Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Öffentliches Statistikportal für den ATM10-Server: serverseitige NeoForge-Mod (Push), SFTP-Pull-Worker, Fastify-API mit PostgreSQL, Next.js-Dashboard, Docker Compose hinter Traefik.
**Architecture:** Alle Datenquellen (Mod-Push und SFTP-Pull) erzeugen Ereignisse desselben Protokolls (v1) und laufen durch **eine** idempotente Pipeline `processEvents()` mit Projektionen in Tabellen. Lesende API und SSE bauen auf diesen Tabellen auf; das Frontend konsumiert nur die API. Die Mod enthält eine reine Java-Kernschicht (testbar ohne Minecraft) und eine dünne NeoForge-Glue-Schicht.
**Tech Stack:** Java 21, NeoForge 21.1.x, ModDevGradle, JUnit 5, Gson · Node ≥ 20 (Docker: 22), TypeScript 5, npm workspaces, Fastify 5, Zod, pg, node-pg-migrate, ssh2-sftp-client, argon2, pino, Vitest · Next.js 15 (App Router), Tailwind CSS, Recharts, Playwright · PostgreSQL 16 · Docker Compose, Traefik-Labels.
**Spec:** `docs/superpowers/specs/2026-10-13-atm10-stats-design.md`
## Global Constraints
- Minecraft 1.21.1, NeoForge 21.1.x, Java 21. Mod-ID `atmstats`, Package `de.hahmann.atmstats`, `displayTest = "IGNORE_ALL_VERSION"`, keine Registrierungen, keine Compile-Abhängigkeit auf andere Mods.
- Keine erfundenen Daten oder APIs: jede Kennzahl stammt aus einer in Spec §3.1 genannten Quelle.
- Server-Thread: nur O(1)-Arbeit (Objekt erzeugen, in Queue legen, Zählerkopie). Keine I/O, kein JSON auf dem Server-Thread.
- Protokoll v1: `POST /api/ingest`, Header `Authorization: Bearer`, `X-ATM-Server`, `X-ATM-Timestamp` (unix ms), `X-ATM-Signature` = hex(HMAC-SHA256(secret, `${timestamp}.${hex(sha256(body))}`)), Body gzip, Zeitfenster ±5 min, entpackt max. 2 MB.
- Batch max. 500 Events / 256 KB; Backoff 1 s → 5 min mit Jitter; Outbox max. 50 MB / 7 Tage.
- Zählerwerte sind absolut; pro `(uuid, category, key)` gewinnt die Beobachtung mit dem neuesten `observed_at`.
- Event-IDs: Push = UUIDv7; SFTP = UUIDv5 (Namespace-Konstante in `shared`) aus deterministischem Schlüssel.
- Intervalle: Stats-Snapshot 5 min, Quest-Scan 2 min, Health 60 s, SFTP 15 min, Leaderboard-Refresh ≤ 60 s, SSE-Heartbeat 25 s, Ringpuffer 500.
- Retention: Stunden-Snapshots 30 Tage, Tages-Snapshots unbegrenzt, Health-Minutenwerte 14 Tage → danach Stundenaggregate.
- Ausgeblendete Spieler (`players.hidden`) erscheinen in keiner öffentlichen Antwort und keinem SSE-Event.
- Keine Tokens, Secrets oder Spieler-IPs in Logs; `server-snapshot/` wird nie committet; Fixtures aus `latest.log` sind IP-bereinigt.
- UI-Sprache Deutsch; dunkles Design; responsiv; kein Übernehmen von craftattack-Assets.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. **Server-Absturz ohne `server_stop`** → beim nächsten `server_start` werden offene Sessions mit dem letzten bekannten Lebenszeichen geschlossen; Spielzeit wird nicht aufgebläht. Test in Task 4 (`closes dangling sessions on server_start`).
2. **Uhrabweichung des Gameservers** → Ingest antwortet `401 {"error":"timestamp_out_of_range"}`; die Mod loggt „clock skew“ statt still zu wiederholen. Tests in Task 3 und Task 13.
3. **Namensänderung eines Spielers** → `players.name` wird aktualisiert, alter Name in `name_history`; `/api/players/:idOrName` löst alte Namen auf. Tests in Task 4 und Task 8.
4. **Kleinerer Zählerwert aus neuerer Quelle / verspätete ältere Lieferung** → neuere Beobachtung gewinnt, ältere wird ignoriert, auch wenn größer. Test in Task 4 (`newer observation wins even if smaller`).
5. **Log-Rotation und unvollständige letzte Zeile** → nur Zeilen bis zum letzten `\n` werden geparst; Offset steht danach; kleinere Dateigröße setzt Offset auf 0. Test in Task 6 und Task 11.
---
## Dateistruktur
```
package.json npm workspaces: shared, backend, web, tools/simulator
tsconfig.base.json
shared/src/protocol.ts Zod-Schemas Protokoll v1 + Typen
shared/src/ids.ts SFTP_NAMESPACE, deterministicId()
shared/src/index.ts
shared/fixtures/protocol/*.json ein Beispiel-Event je Typ (auch von der Mod getestet)
backend/src/config.ts Env-Parsing (Zod)
backend/src/db/pool.ts pg Pool, withTx()
backend/migrations/*.sql Schema
backend/src/ingest/auth.ts Token-/Zeit-/HMAC-Prüfung
backend/src/ingest/route.ts /api/ingest, /api/ingest/ping
backend/src/pipeline/process.ts processEvents()
backend/src/pipeline/projections/*.ts je Eventtyp eine Projektion
backend/src/parsers/snbt.ts SNBT → JS
backend/src/parsers/ftbquests.ts Progress-/Defs-/Teams-Parser
backend/src/parsers/vanilla.ts stats/advancements JSON → Events
backend/src/parsers/log.ts latest.log → Events
backend/src/stats/metrics.ts Board-/Metrik-Definitionen
backend/src/stats/leaderboards.ts SQL-Abfragen Ranglisten
backend/src/stats/snapshots.ts Snapshot-/Retention-Jobs
backend/src/routes/public/*.ts öffentliche Endpunkte
backend/src/routes/live.ts SSE
backend/src/routes/admin.ts Admin
backend/src/auth/admin.ts argon2, Sessions, CSRF
backend/src/worker/sftp.ts SFTP-Sync
backend/src/worker/scheduler.ts Jobs mit Advisory-Locks
backend/src/app.ts buildApp()
backend/src/server.ts, worker.ts, cli.ts Entrypoints
backend/config/mod-categories.json
backend/test/** Vitest
mod/ NeoForge-MDK (Gradle-Wrapper)
mod/src/main/java/de/hahmann/atmstats/core/* reines Java
mod/src/main/java/de/hahmann/atmstats/mc/* NeoForge-Glue
mod/src/test/java/de/hahmann/atmstats/core/*
web/ Next.js
tools/simulator/ signierte Test-Batches
deploy/docker-compose.yml, docker-compose.test.yml, .env.example, backup.sh
docs/ARCHITECTURE.md, DATA-SOURCES.md, OPERATIONS.md, README.md
```
---
### Task 1: Monorepo und Protokoll-Schemas
**Files:**
- Create: `package.json`, `tsconfig.base.json`, `shared/package.json`, `shared/tsconfig.json`, `shared/src/protocol.ts`, `shared/src/ids.ts`, `shared/src/index.ts`, `shared/fixtures/protocol/<type>.json` (einer je Eventtyp), `shared/test/protocol.test.ts`
**Interfaces:**
- Produces (`@atm/shared`):
- `EventType = 'server_start'|'server_stop'|'player_join'|'player_leave'|'player_death'|'entity_kill'|'advancement'|'dimension_change'|'stats_snapshot'|'file_sync'|'health'`
- `AtmEvent` (discriminated union auf `type`), Basis `{ id: string(uuid); type; ts: number(ms) }`. Payloads:
- `server_start {mcVersion, neoforgeVersion, javaVersion, mods: {id,version,name}[]}`; `server_stop {}`
- `player_join|player_leave {uuid, name}`
- `player_death {uuid, name, dimension, cause, killerType: string|null, killerName: string|null}`
- `entity_kill {uuid, name, dimension, victimType, isBoss: boolean, weapon: string|null}` (nur Bosse und Spieler)
- `advancement {uuid, name, advancementId, title: string|null}`
- `dimension_change {uuid, name, from, to}`
- `stats_snapshot {uuid, name: string|null, stats: Record<category, Record<key, number>>, full: boolean}`
- `file_sync {path, sha256, content}` (path relativ zum Serverroot, `/`-getrennt)
- `health {windowStart, windowEnd, msptAvg, msptP95, msptMax, tps, players, chunks, heapUsedMb, heapMaxMb}`
- `IngestBatch {protocol: 1, events: AtmEvent[]}` (1–500 Events), `IngestResponse {accepted: string[], duplicate: string[], rejected: {id, reason}[]}`
- `ingestBatchSchema`, `atmEventSchema` (Zod)
- `SFTP_NAMESPACE = 'b3a1f0de-7c2e-4f43-9a51-6d2a1c0e9f10'`, `deterministicId(key: string): string` (UUIDv5)
- [ ] **Step 1:** Root `package.json` mit workspaces `["shared","backend","web","tools/simulator"]`, Scripts `test`, `lint`, `typecheck`; `tsconfig.base.json` (strict, ES2022, NodeNext). `npm install`.
- [ ] **Step 2: Failing tests** `shared/test/protocol.test.ts`:
- `every fixture validates`: jede Datei in `fixtures/protocol` parst mit `atmEventSchema`; Typ = Dateiname.
- `rejects unknown type` und `rejects batch with 501 events`.
- `deterministicId is stable`: `deterministicId('a') === deterministicId('a')`, ≠ `deterministicId('b')`, Format UUID v5.
- [ ] **Step 3:** Run `npm test -w shared` → FAIL.
- [ ] **Step 4:** Implementieren (Zod `discriminatedUnion`; uuid-Paket für v5).
- [ ] **Step 5:** Run `npm test -w shared` → PASS. Commit `feat(shared): Protokoll v1 Schemas und Fixtures`.
### Task 2: Backend-Grundgerüst, Konfiguration, Schema
**Files:**
- Create: `backend/package.json`, `backend/tsconfig.json`, `backend/vitest.config.ts`, `backend/src/config.ts`, `backend/src/db/pool.ts`, `backend/src/db/migrate.ts`, `backend/migrations/001_init.sql`, `deploy/docker-compose.test.yml`, `backend/test/helpers/db.ts`, `backend/test/db.test.ts`
**Interfaces:**
- Produces:
- `loadConfig(env = process.env): Config` mit `DATABASE_URL`, `PORT`(3001), `PUBLIC_ORIGIN`, `INGEST_HMAC_SECRET`, `SERVER_ID`, `SFTP_HOST|PORT|USER|PASSWORD|ROOT` (optional), `SFTP_INTERVAL_MIN`(15), `TRUST_PROXY`(true)
- `createPool(url): Pool`, `withTx<T>(pool, fn: (c: PoolClient) => Promise<T>): Promise<T>`
- `migrate(pool): Promise<void>` (node-pg-migrate programmatisch, unter `pg_advisory_lock(4711)`)
- Test-Helfer `getTestPool()`, `resetDb(pool)` (TRUNCATE aller Tabellen außer Migrations-Tabelle)
- Tabellen exakt nach Spec §6 plus `dimension_visits(uuid, dimension, first_at, PK(uuid,dimension))`, `admin_sessions(id text PK, user_id, csrf, expires_at)`; `events.event_id uuid PK`; Indizes `stat_current(category, key, value DESC)`, `sessions(uuid) WHERE ended_at IS NULL`, `events(ts)`.
- [ ] **Step 1:** `docker-compose.test.yml` mit `postgres:16-alpine` auf Port 54329, DB `atm_test`. `docker compose -f deploy/docker-compose.test.yml up -d`.
- [ ] **Step 2: Failing tests** `db.test.ts`: `migrate is idempotent` (zweimal ausführen, keine Fehler, Tabelle `events` existiert); `loadConfig rejects missing DATABASE_URL`.
- [ ] **Step 3:** Run `npm test -w backend` → FAIL.
- [ ] **Step 4:** Implementieren. `vitest.config.ts`: `fileParallelism: false`, `TEST_DATABASE_URL` default `postgres://postgres:postgres@localhost:54329/atm_test`.
- [ ] **Step 5:** Run → PASS. Commit `feat(backend): Grundgerüst, Konfiguration, DB-Schema`.
### Task 3: Ingest-Endpunkt mit Authentifizierung und Idempotenz
**Files:**
- Create: `backend/src/ingest/auth.ts`, `backend/src/ingest/route.ts`, `backend/src/ingest/tokens.ts`, `backend/src/pipeline/process.ts`, `backend/src/app.ts`, `backend/src/server.ts`, `backend/test/ingest.test.ts`, `backend/test/helpers/sign.ts`
**Interfaces:**
- Consumes: `ingestBatchSchema`, `withTx`, `migrate`.
- Produces:
- `verifyIngest(req: {headers, rawBody: Buffer}, opts: {secret, now: number, tokenValid: (token) => Promise<boolean>}): Promise<{ok: true} | {ok: false, status: 401, error: 'missing_auth'|'bad_token'|'timestamp_out_of_range'|'bad_signature'}>` – Signatur über den **komprimierten** Body, so wie er übertragen wird.
- `createIngestToken(pool): Promise<string>` (Klartext 32 Byte base64url, gespeichert als sha256), `isValidIngestToken(pool, token)`, `revokeAllIngestTokens(pool)`
- `processEvents(pool, events: AtmEvent[], source: 'push'|'sftp', serverId: string): Promise<IngestResponse>` – in einer Transaktion `INSERT … ON CONFLICT (event_id) DO NOTHING RETURNING event_id`; nur neu eingefügte Events an `project(client, event, source)`; nach Commit `pg_notify('atm_live', json)` je Event mit Live-Relevanz.
- `registerProjection(type, fn: (c: PoolClient, e, source) => Promise<void>)` – Registry; unbekannte Typen ohne Projektion werden nur gespeichert.
- `buildApp({pool, config}): FastifyInstance` (Content-Type `application/json`, gzip-Decoding für `/api/ingest`, Raw-Body verfügbar, Rate-Limit 60/min auf Ingest).
- Test-Helfer `signedRequest(body: object, opts?): {headers, payload: Buffer}`
- [ ] **Step 1: Failing tests** `ingest.test.ts` (über `app.inject`):
- `accepts valid batch` → 200, `accepted` enthält beide IDs, Tabelle `events` hat 2 Zeilen mit `source='push'`.
- `same batch twice is idempotent` → zweiter Aufruf: `accepted: []`, `duplicate` = beide IDs, weiterhin 2 Zeilen.
- `rejects wrong token` → 401 `bad_token`; `rejects bad signature` → 401 `bad_signature`.
- `rejects timestamp 6 minutes old` → 401 `{"error":"timestamp_out_of_range"}` (Review Focus 2).
- `rejects invalid event but accepts the rest` → ein Event ohne `uuid` landet in `rejected` mit Grund, gültiges in `accepted`.
- `rejects body over 2 MB unpacked` → 413.
- `ping` → `GET /api/ingest/ping` mit gültiger Signatur über leeren Body → 200 `{ok:true, serverTime}`.
- [ ] **Step 2:** Run `npx vitest run test/ingest.test.ts -w backend` → FAIL.
- [ ] **Step 3:** Implementieren. Batch-Validierung pro Event (`safeParse` je Event), damit ein fehlerhaftes Event nicht den ganzen Batch verwirft. Token-Vergleich über Hash-Lookup (keine Klartext-Vergleiche).
- [ ] **Step 4:** Run → PASS. Commit `feat(backend): idempotenter, signierter Ingest`.
### Task 4: Projektionen der Echtzeit-Ereignisse
**Files:**
- Create: `backend/src/pipeline/projections/players.ts`, `sessions.ts`, `combat.ts`, `advancements.ts`, `stats.ts`, `server.ts`, `index.ts`; Test `backend/test/projections.test.ts`
**Interfaces:**
- Consumes: `registerProjection`, `processEvents`.
- Produces (Tabellen-Semantik, auf die Read-API baut):
- `player_join`: upsert `players` (name, last_seen); Namensänderung → `name_history`; öffnet Session (schließt vorher evtl. offene Session desselben Spielers mit `end_reason='superseded'`).
- `player_leave`: schließt offene Session (`end_reason='leave'`).
- `server_start`: Insert `server_info`; schließt alle offenen Sessions mit `ended_at = max(letztes Event des Spielers, letztes health.windowEnd)` und `end_reason='crash'`.
- `server_stop`: schließt offene Sessions mit `ts`, `end_reason='stop'`.
- `player_death` → `deaths` (bewusst ohne Koordinaten: Datenschutz, Basen nicht verraten – Abweichung von Spec §3.1 „Position grob“); `entity_kill` → `kills`; `advancement` → upsert `advancements` (frühestes `achieved_at` gewinnt; Titel überschreibt NULL); `dimension_change` → upsert `dimension_visits`; `health` → `server_health` (Bucket = windowStart auf Minute).
- `stats_snapshot`: upsert je Schlüssel in `stat_current` mit `observed_at = ts` nur `WHERE stat_current.observed_at < EXCLUDED.observed_at`. Ein Snapshot mit `full: true` aus `sftp` setzt fehlende Schlüssel **nicht** auf 0. Aktualisiert `players.last_seen` nicht, aber legt unbekannte Spieler an (Name ggf. NULL → später aufgefüllt).
- [ ] **Step 1: Failing tests** `projections.test.ts`:
- `join then leave creates closed session with duration`.
- `closes dangling sessions on server_start` (Review Focus 1): join @t0, health windowEnd @t0+10min, server_start @t0+60min → Session endet @t0+10min, `end_reason='crash'`.
- `rename updates name and history` (Review Focus 3).
- `newer observation wins even if smaller` (Review Focus 4): snapshot @t2 value 5, danach snapshot @t1<t2 value 9 → 5 bleibt; snapshot @t3 value 3 → 3.
- `advancement keeps earliest timestamp`.
- `death and boss kill are stored`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implementieren. **Step 4:** Run → PASS.
- [ ] **Step 5:** Commit `feat(backend): Projektionen für Sessions, Kämpfe, Advancements, Stats, Health`.
### Task 5: SNBT- und FTB-Quests/Teams-Parser
**Files:**
- Create: `backend/migrations/002_files.sql` (`file_cache`), `backend/src/parsers/snbt.ts`, `backend/src/parsers/ftbquests.ts`, `backend/src/pipeline/projections/files.ts`; Fixtures `backend/test/fixtures/ftbquests/*.snbt` (Kopie aus `server-snapshot/world-ftbquests`), Test `backend/test/ftbquests.test.ts`, `backend/test/snbt.test.ts`
**Interfaces:**
- Produces:
- `parseSnbt(text: string): SnbtValue` – unterstützt Compounds `{}` mit Newline- **oder** Komma-Trennung, unquotierte und quotierte Keys, Strings `"…"` mit Escapes, Zahlen mit Suffix `b s L f d` (L → `bigint` nur wenn > 2^53, sonst number), `true/false`, Listen `[...]`, typisierte Arrays `[I; …]`, `[L; …]`, `[B; …]`. Fehler → `SnbtError` mit Zeile/Spalte.
- `parseQuestProgress(text): {teamId: string, name: string|null, completed: {questId: string, at: number}[], started: number}` – `uuid` ohne Bindestriche wird normalisiert zu UUID mit Bindestrichen.
- `parseQuestDefs(files: {path, content}[]): {chapterId, chapterTitle, questId, title}[]` – liest `chapters/*.snbt` (`id`, `title` bzw. `filename`, `quests[].id`, `quests[].title`), Titel aus `lang/en_us.snbt` falls vorhanden (`quest.<id>.title`, `chapter.<id>.title`); fehlt ein Titel → `null`.
- `parseTeam(text): {teamId, name: string|null, type: string|null, members: string[]}` – erst nach Verifikation am echten `world/ftbteams`-Inhalt aktivieren; bis dahin nur Projektion für Dateien, deren Struktur getestet ist.
- Projektion `file_sync`: Routing per Pfad: `world/ftbquests/*.snbt` → `quest_progress` (Upsert, gelöschte Quests bleiben); `config/ftbquests/quests/**` → sammelt alle zuletzt bekannten Dateien (Tabelle `file_cache(path PK, sha256, content, updated_at)`) und baut `quest_defs` neu; `world/ftbteams/**` → `teams`/`team_members`. Unbekannter Pfad → nur in `file_cache`.
- [ ] **Step 1: Failing tests:**
- `snbt.test.ts`: `parses newline-separated compound`, `parses comma-separated compound`, `parses suffixes 1L 2b 1.5f`, `parses typed arrays`, `reports line on error`.
- `ftbquests.test.ts`: `parses real progress file 308a…` → `teamId === '308a693c-2bd8-4d6b-8790-670aef5be601'`, `completed.length > 0`, jeder `questId` matcht `/^[0-9A-F]{16}$/`; `parseQuestDefs` an einem kleinen handgeschriebenen Kapitel-Fixture in dem im Spec genannten Format.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implementieren (handgeschriebener rekursiver Parser). **Step 4:** Run → PASS.
- [ ] **Step 5:** Vor dem Commit prüfen, ob `server-snapshot/config-ftbquests` bzw. `server-snapshot/world-ftbteams` existiert; falls ja, Fixtures daraus ergänzen und `parseQuestDefs`/`parseTeam` daran testen. Falls nein: `parseTeam`-Projektion deaktiviert lassen (Feature-Flag in `files.ts`), in `docs/DATA-SOURCES.md` vermerken. Commit `feat(backend): SNBT- und FTB-Quests-Parser`.
### Task 6: Vanilla-Dateien und Log als Ereignisquelle
**Files:**
- Create: `backend/src/parsers/vanilla.ts`, `backend/src/parsers/log.ts`, `tools/anonymize-log.mjs`; Fixtures `backend/test/fixtures/stats/*.json` (aus Snapshot), `backend/test/fixtures/latest.anon.log` (anonymisiert); Test `backend/test/vanilla.test.ts`, `backend/test/log.test.ts`
**Interfaces:**
- Produces:
- `statsFileToEvent(uuid: string, json: string, mtimeMs: number): AtmEvent` – `stats_snapshot` mit `full: true`, `ts = mtimeMs`, `id = deterministicId(\`stats:${uuid}:${mtimeMs}:${sha256(json)}\`)`.
- `advancementsFileToEvents(uuid, json): AtmEvent[]` – je Advancement mit `done: true` (bzw. allen Kriterien) und ohne `recipes/`-Präfix ein `advancement`-Event, `ts` = frühester Kriteriumszeitpunkt (Format `"2026-10-12 09:08:51 +0000"`), `title: null`.
- `parseLogChunk(text: string, baseOffset: number, dateHint: Date): {events: AtmEvent[], consumed: number}` – nur vollständige Zeilen (bis letztes `\n`); erkennt `UUID of player <name> is <uuid>` (Name→UUID-Map innerhalb des Chunks und aus vorherigem Zustand), `<name> joined the game`, `<name> left the game`, `<name> has made the advancement [<title>]`, `has reached the goal [..]`, `has completed the challenge [..]`; Zeitstempel `[12Oct2026 09:03:03.006]`. Event-IDs `deterministicId(\`log:${absoluteOffsetOfLine}:${sha256(line)}\`)`. Advancement aus Log: `advancementId = 'log:' + title`, nur als Fallback (siehe Task 11).
- `anonymize-log.mjs <in> <out>`: ersetzt `/\d+\.\d+\.\d+\.\d+:\d+` durch `/0.0.0.0:0`.
- [ ] **Step 1:** `node tools/anonymize-log.mjs server-snapshot/latest.log backend/test/fixtures/latest.anon.log`; prüfen `grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:' …` = 0 (außer 0.0.0.0).
- [ ] **Step 2: Failing tests:**
- `vanilla.test.ts`: `stats file maps play_time` → `stats['minecraft:custom']['minecraft:play_time'] === 1175736` für 308a…; `id stable for same content`.
- `log.test.ts`: `finds join of CDOGamer with uuid 308a…`; `finds advancement Infusing Efficiency`; `ignores incomplete last line` (Review Focus 5: Chunk ohne abschließendes `\n` → `consumed` endet am letzten `\n`, letzte Zeile nicht geparst); `same chunk twice yields same ids`.
- [ ] **Step 3:** Run → FAIL. **Step 4:** Implementieren. **Step 5:** Run → PASS. Commit `feat(backend): Parser für Stats-, Advancement- und Logdateien`.
### Task 7: Metriken, Ranglisten, Snapshots, Retention
**Files:**
- Create: `backend/config/mod-categories.json`, `backend/src/stats/metrics.ts`, `backend/src/stats/leaderboards.ts`, `backend/src/stats/snapshots.ts`, `backend/migrations/003_views.sql` (Views für abgeleitete Metriken, Materialized View `leaderboard_cache`, Tabelle `server_health_hourly`); Test `backend/test/leaderboards.test.ts`, `backend/test/snapshots.test.ts`
**Interfaces:**
- Produces:
- `mod-categories.json`: `{ "tech": [...], "magic": [...], "farming": [...] }` exakt mit den Namespaces aus Spec §7.
- `type MetricDef = {kind:'stat', category, key} | {kind:'sum', category, keySuffix?: string, namespaces?: string[], categories?: string[]} | {kind:'sumKeys', category, keys: string[]} | {kind:'quests'} | {kind:'advancements'}`
- `type Board = {id, title, description, unit: 'hours'|'count'|'km'|'blocks', metric: MetricDef}`; `BOARDS: Board[]` mit den 14 Boards aus Spec §7, IDs: `playtime, deaths, mobkills, mined, distance, flight, diamonds, tech, magic, farming, quests, advancements, loot, worldtier`. Umrechnung: Ticks/72000 → h, cm/100000 → km.
- `metricQuery(def: MetricDef): {sql: string}` – liefert `SELECT uuid, value` über nicht-ausgeblendete Spieler.
- `getLeaderboard(pool, boardId, period: 'all'|'30d'|'7d', limit = 10): Promise<{rank, uuid, name, value}[]>` – Zeitraum = aktueller Wert − Tages-Snapshot am/vor Periodenbeginn (fehlt er: frühester vorhandener; fehlt ganz: 0).
- `getPlayerRanks(pool, uuid): Promise<Record<boardId, {rank, value}>>`
- `takeSnapshots(pool, granularity: 'hour'|'day', at: Date)` – schreibt jede Board-Metrik + `play_time` je Spieler in `stat_snapshots` (metric = boardId), idempotent per PK `(uuid, metric, bucket, granularity)`.
- `applyRetention(pool, now: Date)` – löscht Stunden-Snapshots > 30 Tage; Health-Minutenwerte > 14 Tage werden zu Stundenaggregaten (`server_health_hourly`) zusammengefasst und gelöscht.
- Quests-Metrik: Spieler erhält die Quest-Anzahl seines Teams (`team_members`; ohne Teameintrag gilt Team-ID = Spieler-UUID).
- [ ] **Step 1: Failing tests:**
- `distance sums all *_one_cm keys in km`; `tech sums crafted+used of tech namespaces only`; `hidden player excluded`; `ties share rank order by name`; `7d period subtracts snapshot`; `quests uses team progress`.
- `snapshots.test.ts`: `takeSnapshots idempotent`; `retention deletes hourly older than 30 days but keeps daily`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implementieren. **Step 4:** Run → PASS. Commit `feat(backend): Ranglisten, Snapshots, Retention`.
### Task 8: Öffentliche Lese-API
**Files:**
- Create: `backend/src/routes/public/overview.ts`, `players.ts`, `leaderboards.ts`, `server.ts`, `mods.ts`, `quests.ts`, `sources.ts`, `avatar.ts`, `index.ts`; DTO-Typen in `shared/src/api.ts`; Test `backend/test/public-api.test.ts`
**Interfaces:**
- Consumes: Tabellen aus Task 4–7, `getLeaderboard`, `getPlayerRanks`, `BOARDS`.
- Produces (DTOs in `@atm/shared` `api.ts`):
- `GET /api/overview` → `{online: number, totalPlayers, totalPlayHours, totalDeaths, totalMobKills, questsCompleted, advancements, lastUpdate: number|null}`
- `GET /api/online` → `{players: {uuid, name, since: number, dimension: string|null}[]}`
- `GET /api/leaderboards` → `{boards: {id, title, description, unit, top: Entry[]}[]}` (Top 10 all); `GET /api/leaderboards/:id?period=` → `{board, period, entries: Entry[]}` (limit 100)
- `GET /api/players?search=&page=` → `{players: {uuid, name, lastSeen, playHours}[], total}`
- `GET /api/players/:idOrName` → `PlayerProfile {uuid, name, firstSeen, lastSeen, online, playHours, sessions: number, ranks, general: Record<key,number>, movement: Record<key,number>, deathsByCause, killsByType, bossKills, dimensions, advancements: {id, title, at}[], quests: {teamId, completed, total|null, chapters: {chapterTitle, completed, total}[]}, activityHeatmap: number[7][24]}` – alter Name löst über `name_history` auf (Review Focus 3); unbekannt/ausgeblendet → 404.
- `GET /api/players/:uuid/history?metric=<boardId>&range=7d|30d|all` → `{points: {t, v}[]}` (Stunden für 7d, Tage sonst)
- `GET /api/players/:uuid/items?category=mined|crafted|used|picked_up` → `{mods: {namespace, total, items: {key, value}[]}[]}` (Top 20 Items je Mod)
- `GET /api/mods` → `{server: ServerInfoDto|null, mods: {id, name, version, activity: number, category: string|null}[]}` (activity = Summe aller Stats im Namespace)
- `GET /api/quests` → `{chapters: {chapterId, title, total, completedByTeams: number}[], teams: {teamId, name, members, completed}[]}`
- `GET /api/server?range=24h|7d|30d` → `{info: ServerInfoDto|null, health: {t, msptAvg, msptP95, tps, players, heapUsedMb}[]}`
- `GET /api/sources` → `{push: {lastEventAt, lastServerStart, reachable: boolean}, sftp: {enabled, lastSyncAt, lastError: string|null}, limitations: string[]}` (Liste aus `DATA-SOURCES` als Konstante)
- `GET /api/avatar/:uuid` → PNG aus `https://mc-heads.net/avatar/<uuid>/64` mit 24-h-Cache (Datei- oder Speichercache, max. 500 Einträge), uuid strikt validiert; Fehler → eingebetteter Platzhalter-PNG.
- Alle Antworten: `Cache-Control: public, max-age=15` (Avatar 86400).
- [ ] **Step 1: Failing tests** `public-api.test.ts` (Seed über `processEvents`):
- `overview counts`; `online lists open sessions only`; `profile by old name resolves`; `hidden player is 404 and absent from leaderboards and online`; `items grouped by namespace`; `history returns daily points`; `avatar rejects invalid uuid with 400`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implementieren. **Step 4:** Run → PASS. Commit `feat(backend): öffentliche Lese-API`.
### Task 9: Live-Updates per SSE
**Files:**
- Create: `backend/src/routes/live.ts`, `backend/src/live/hub.ts`; Test `backend/test/live.test.ts`
**Interfaces:**
- Consumes: `pg_notify('atm_live', …)` aus `processEvents`.
- Produces:
- `LiveHub` mit `start(pool)` (eigener Client `LISTEN atm_live`, Reconnect mit Backoff), `subscribe(fn): unsubscribe`, Ringpuffer 500, fortlaufende numerische IDs.
- Notify-Payload: `{kind: 'online'|'death'|'advancement'|'quest'|'counters', data}` – `processEvents` mappt: join/leave → `online`, player_death → `death`, advancement (mit Titel) → `advancement`, quest_progress-Änderung → `quest`, stats_snapshot → `counters` (gedrosselt: max. 1 pro 10 s). Ausgeblendete Spieler werden nicht gesendet.
- `GET /api/live`: `text/event-stream`, `X-Accel-Buffering: no`, Heartbeat `: ping` alle 25 s, `Last-Event-ID` → Nachsenden aus Ringpuffer.
- [ ] **Step 1: Failing tests:** `delivers death event after ingest` (echter Server auf zufälligem Port, fetch-Stream lesen); `resumes from Last-Event-ID`; `hidden player not broadcast`.
- [ ] **Step 2:** FAIL. **Step 3:** Implementieren. **Step 4:** PASS. Commit `feat(backend): Live-Updates per SSE`.
### Task 10: Admin-Authentifizierung und Admin-API
**Files:**
- Create: `backend/src/auth/admin.ts`, `backend/src/routes/admin.ts`, `backend/src/cli.ts`; Test `backend/test/admin.test.ts`
**Interfaces:**
- Produces:
- `createAdmin(pool, username, password)` (argon2id), `login(pool, u, p): Promise<{sessionId, csrf} | null>`, `getSession(pool, sessionId)`, Session-TTL 12 h.
- `POST /api/admin/login {username, password}` → Cookie `atm_admin` (httpOnly, Secure außer `NODE_ENV=test`, SameSite=Strict, Path=/api/admin) + `{csrf}`; Rate-Limit 5/min/IP; `POST /api/admin/logout`; `GET /api/admin/session` → `{username, csrf}`.
- Mutierende Admin-Routen verlangen Header `x-csrf-token` = Session-CSRF.
- `GET /api/admin/status` → `{push: …, sftp: …, lastErrors: {at, source, message}[], outboxRejected: number}`; `POST /api/admin/sync` → setzt `sync_requests`-Zeile, die der Worker abarbeitet; `POST /api/admin/players/:uuid/hidden {hidden: boolean}`; `POST /api/admin/ingest-token/rotate` → `{token}` (einmalig, alte widerrufen).
- CLI: `npm run cli -w backend -- admin:create <user>` (Passwort per Prompt/`ADMIN_PASSWORD`), `token:create`, `migrate`.
- Tabellen `app_errors(at, source, message)`, `sync_requests(id, requested_at, done_at)` in `004_admin.sql`.
- [ ] **Step 1: Failing tests:** `login with wrong password 401`; `mutating route without csrf 403`; `hide player then profile 404`; `rotate token invalidates old token for ingest`.
- [ ] **Step 2:** FAIL. **Step 3:** Implementieren. **Step 4:** PASS. Commit `feat(backend): Admin-Login und Verwaltung`.
### Task 11: Worker (SFTP-Sync, Jobs)
**Files:**
- Create: `backend/src/worker/sftp.ts`, `backend/src/worker/fileSource.ts`, `backend/src/worker/scheduler.ts`, `backend/src/worker.ts`; Test `backend/test/worker.test.ts`
**Interfaces:**
- Consumes: Parser aus Task 5/6, `processEvents`, `takeSnapshots`, `applyRetention`.
- Produces:
- `interface FileSource { list(dir): Promise<{name, size, mtimeMs}[]>; read(path, start?: number): Promise<Buffer>; close() }`; `SftpFileSource(config)`; `LocalFileSource(root)` (für Tests und lokale Entwicklung).
- `syncOnce(pool, src: FileSource, now: Date): Promise<{files: number, events: number}>` – Pfade: `world/stats/*.json`, `world/advancements/*.json`, `world/ftbquests/*.snbt`, `world/ftbteams/**/*.snbt`, `config/ftbquests/quests/**/*.snbt`, `logs/latest.log`. Änderungserkennung über `sync_state(size, mtime)`; Log inkrementell ab `offset`, `size < offset` → Offset 0 (Review Focus 5). SNBT-Dateien werden als `file_sync`-Events eingespeist (gleiche Projektion wie Push). Log-Join/Leave-Events werden **nur** eingespeist, wenn das letzte Push-Event älter als 10 min ist; Log-Advancements nur, wenn kein Advancement mit gleichem Titel für den Spieler existiert.
- `runJob(pool, name, lockId, fn)` mit `pg_try_advisory_lock`; Fehler → `app_errors`.
- Scheduler: SFTP alle `SFTP_INTERVAL_MIN` + bei `sync_requests`; Stunden-Snapshot zur vollen Stunde, Tages-Snapshot 00:05, Retention täglich 03:00, `REFRESH MATERIALIZED VIEW CONCURRENTLY` höchstens alle 60 s wenn neue Events.
- [ ] **Step 1: Failing tests** mit `LocalFileSource` auf einem Temp-Verzeichnis, gefüllt mit Fixtures:
- `first sync imports stats, quests, log`; `second sync without changes reads nothing` (Spy auf `read`); `appended log lines are imported incrementally`; `rotated log resets offset`; `log joins skipped while push is fresh`.
- [ ] **Step 2:** FAIL. **Step 3:** Implementieren. **Step 4:** PASS. Commit `feat(backend): Worker mit SFTP-Sync und Jobs`.
### Task 12: Mod-Grundgerüst und Kernbausteine
**Files:**
- Create: `mod/` aus dem offiziellen NeoForge-MDK 1.21.1 (ModDevGradle) inkl. Gradle-Wrapper; `mod/gradle.properties` (`minecraft_version=1.21.1`, `neo_version` = aktuellste 21.1.x, `mod_id=atmstats`, `mod_group_id=de.hahmann.atmstats`, `loader_version_range=[1,)`, `neo_version_range=[21.1.0,)`); `core/UuidV7.java`, `core/Signer.java`, `core/Backoff.java`, `core/OutboxJournal.java`; Tests unter `mod/src/test/java/de/hahmann/atmstats/core/`
**Interfaces:**
- Produces:
- `UuidV7.next(): UUID` (monoton innerhalb einer ms)
- `Signer.sign(byte[] secret, long timestampMs, byte[] body): String` (hex; identisch zur Backend-Formel)
- `Backoff(long baseMs = 1000, long maxMs = 300000)`, `long delayMs(int attempt, Random rnd)` – `min(max, base·2^attempt)` mit Full-Jitter auf [delay/2, delay]
- `OutboxJournal(Path dir, long maxBytes, Duration maxAge)`: `append(List<String> jsonLines)`, `Optional<Segment> nextSendable()` (rotiert das offene Segment, wenn es nicht leer ist), `ack(Segment s)` (löscht), `reject(Segment s, String reason)` (verschiebt nach `rejected/`), `long totalBytes()`. Segmentdateien `seg-<20-stelliger seq>.jsonl`; Segmentgrenze 500 Zeilen oder 256 KB (Einzelzeile größer → eigenes Segment). `Segment` = `(Path file, List<String> lines)`. Beim Öffnen: unlesbare/abgeschnittene letzte Zeile wird verworfen. Grenzüberschreitung: zuerst Zeilen mit `"type":"stats_snapshot"` aus den ältesten Segmenten entfernen, dann älteste Segmente löschen; jeweils Rückgabe der Anzahl verworfener Zeilen fürs Log.
- [ ] **Step 1:** MDK herunterladen (`https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle` als ZIP), in `mod/` entpacken, Beispielinhalte entfernen; JDK 21 (Temurin) installieren; `./gradlew --version` → läuft mit Java 21.
- [ ] **Step 2: Failing tests:**
- `SignerTest.matchesBackendVector`: Vektor `secret="test-secret"`, `ts=1760000000000`, `body="hello"` → erwarteter Hex-Wert wird in Task 3 (`backend/test/helpers/sign.ts`) mit Node berechnet und hier als Konstante eingetragen.
- `UuidV7Test.isVersion7AndMonotonic` (1000 IDs sortiert = erzeugt).
- `BackoffTest.capsAtFiveMinutes` (attempt 30 → ≤ 300000), `BackoffTest.growsExponentially`.
- `OutboxJournalTest`: `survivesRestart` (append, neue Instanz, gleiche Zeilen sendbar), `dropsTruncatedLastLine`, `ackDeletesSegment`, `rotatesAt500Lines`, `evictsSnapshotsBeforeEvents` (maxBytes klein; nach Overflow sind alle Nicht-Snapshot-Zeilen noch vorhanden).
- [ ] **Step 3:** `./gradlew test` → FAIL. **Step 4:** Implementieren. **Step 5:** `./gradlew test` → PASS. Commit `feat(mod): Kernbausteine Journal, Signatur, Backoff`.
### Task 13: Mod – Ereignisse, Serialisierung, Versand
**Files:**
- Create: `core/Events.java`, `core/EventJson.java`, `core/IngestClient.java`, `core/HttpIngestClient.java`, `core/Shipper.java`; Tests `EventJsonTest.java`, `ShipperTest.java`
**Interfaces:**
- Consumes: Task 12, `shared/fixtures/protocol/*.json` (Test liest per relativem Pfad `../shared/fixtures/protocol`).
- Produces:
- `Events`: Java-Records je Eventtyp mit exakt den Feldern aus Task 1 (`record PlayerDeath(UUID id, long ts, String uuid, String name, String dimension, String cause, String killerType, String killerName)` usw.), gemeinsames Interface `AtmEvent { UUID id(); long ts(); String type(); }`.
- `EventJson.toJson(AtmEvent e): String` (Gson, `null`-Felder explizit serialisiert, Feldnamen camelCase wie Schema).
- `IngestClient { Response send(byte[] gzBody, long ts, String signature) throws IOException; }`, `record Response(int status, Set<String> accepted, Set<String> duplicate, Map<String,String> rejected, String error)`
- `HttpIngestClient(URI endpoint, String token, String serverId, Duration timeout)` (`java.net.http`, HTTP/2, TLS-Default-Verifikation), `ping(): String` (liefert „reachable“ oder Fehlergrund).
- `Shipper(Queue<AtmEvent> queue, OutboxJournal journal, IngestClient client, byte[] secret, Backoff backoff, Clock clock, Logger log)`; `void runOnce()` (Queue → Journal; ein Segment senden; bei 200: `ack`, abgelehnte Zeilen → `rejected/`; bei 401 `timestamp_out_of_range` → Log „clock skew: server clock differs from backend by more than 5 minutes“ und Backoff; bei 5xx/408/429/IOException → Backoff; bei sonstigen 4xx → `reject`); `void flushToJournal()` (für Shutdown).
- [ ] **Step 1: Failing tests:**
- `EventJsonTest.matchesSharedFixtures`: für jeden Fixture-Typ das Record mit denselben Werten bauen → `JsonParser.parseString(toJson(e))` equals Fixture-JSON.
- `ShipperTest`: `acksOnSuccess`, `retriesWithBackoffOn503` (Fake-Client, Fake-Clock; zweiter Versuch erst nach Delay), `rejectsOn400`, `logsClockSkewOn401` (Review Focus 2), `keepsEventsWhenClientThrows` (Journal danach unverändert).
- [ ] **Step 2:** FAIL. **Step 3:** Implementieren. **Step 4:** `./gradlew test` → PASS. Commit `feat(mod): Ereignisse, Serialisierung, zuverlässiger Versand`.
### Task 14: Mod – NeoForge-Anbindung
**Files:**
- Create: `mc/AtmStatsMod.java` (`@Mod("atmstats")`, nur `Dist.DEDICATED_SERVER`-Logik), `mc/ModConfig.java` (ModConfigSpec SERVER-unabhängig als `COMMON`-Datei `config/atmstats-server.toml`), `mc/GameHooks.java`, `mc/StatsCollector.java`, `mc/HealthMonitor.java`, `mc/FileWatcher.java`, `mc/ServerInfoCollector.java`, `mc/WorkerThread.java`; `src/main/resources/META-INF/neoforge.mods.toml`; Test `core/FileWatcherLogicTest.java` (Änderungserkennung als reine Logik in `core/FileChangeTracker.java`)
**Interfaces:**
- Consumes: Task 12/13.
- Produces / verwendete NeoForge-1.21.1-APIs (vor Implementierung im dekompilierten MDK-Klassenpfad verifizieren, keine Annahmen):
- `ServerStartedEvent` → `ServerInfoCollector` (`ModList.get().getMods()`, `SharedConstants.getCurrentVersion().getName()`, NeoForge-Version aus `ModList` Eintrag `neoforge`, `System.getProperty("java.version")`) → `server_start`; Ping-Selbsttest im Worker-Thread mit Log-Zeile `atmstats: backend reachable` / `atmstats: backend NOT reachable: <Grund>`.
- `ServerStoppingEvent` → `server_stop`, `player_leave` für alle Online, `Shipper.flushToJournal()` (max. 2 s), Worker stoppen.
- `PlayerEvent.PlayerLoggedInEvent/LoggedOutEvent`, `PlayerEvent.PlayerChangedDimensionEvent`, `AdvancementEvent.AdvancementEarnEvent` (nur mit `display().isPresent()`, Titel `display.getTitle().getString()`), `LivingDeathEvent` (Spieler stirbt → `player_death` mit `source.typeHolder().getRegisteredName()`; Killer = echter `ServerPlayer` und kein `FakePlayer` und Opfer ist Spieler oder in Tag `Tags.EntityTypes.BOSSES` → `entity_kill`).
- `ServerTickEvent.Pre/Post` → `HealthMonitor` (nanoTime-Differenzen, 60-s-Fenster, avg/p95/max, `tps = min(20, 1000/msptAvg)`, Spielerzahl, geladene Chunks über alle Level `getChunkSource().getLoadedChunksCount()`, Heap aus `Runtime`).
- `StatsCollector`: alle `statsSnapshotMinutes` Snapshot online Spieler, max. 2 Spieler pro Tick; liest `player.getStats()` (`ServerStatsCounter`) über alle `BuiltInRegistries.STAT_TYPE`-Einträge in eine Map; nur geänderte Werte seit letztem Snapshot (`full=false`), erster Snapshot je Spieler nach Start `full=true`.
- `FileWatcher` (Worker-Thread, alle `questScanMinutes`): `world/ftbquests/*.snbt`, `world/ftbteams/**/*.snbt`, `config/ftbquests/quests/**/*.snbt`; geändert (mtime/size, dann sha256) → `file_sync`; Datei > 1,5 MB → überspringen + Warnlog.
- `WorkerThread`: ein Daemon-Thread, Schleife `shipper.runOnce()` + FileWatcher-Tick, Sleep gemäß Backoff (min. 2 s).
- `neoforge.mods.toml`: `displayTest="IGNORE_ALL_VERSION"`, Abhängigkeit `neoforge` `[21.1.0,)` und `minecraft` `[1.21.1,1.21.2)`, `side="SERVER"`.
- [ ] **Step 1: Failing test** `FileChangeTrackerTest`: `detectsNewChangedAndUnchangedFiles` (gleiche mtime+size → nicht erneut; geänderte mtime aber gleicher Hash → nicht gesendet).
- [ ] **Step 2:** FAIL → implementieren → PASS.
- [ ] **Step 3:** Glue-Klassen implementieren; `./gradlew build` → `mod/build/libs/atmstats-<version>.jar`.
- [ ] **Step 4: Smoke-Test:** Backend lokal starten (`npm run dev -w backend`), Token per CLI erzeugen, `mod/run/config/atmstats-server.toml` setzen, `./gradlew runServer` (EULA im Run-Verzeichnis akzeptieren), warten bis „Done“, dann `stop`. Erwartet im Server-Log: `atmstats: backend reachable`; in DB: `server_start` mit `mc_version='1.21.1'` und `server_stop`. Tick-Kosten prüfen: keine `Can't keep up`-Meldung durch die Mod.
- [ ] **Step 5:** Commit `feat(mod): NeoForge-Anbindung, Hooks, Health, Dateiüberwachung`.
### Task 15: Simulator
**Files:**
- Create: `tools/simulator/package.json`, `tools/simulator/src/index.ts`, `tools/simulator/README.md`
**Interfaces:**
- Consumes: `@atm/shared`, Signaturformel.
- Produces: `npm run sim -w tools/simulator -- --endpoint http://localhost:3001 --token … --secret … [--players 5] [--minutes 60] [--realtime]` – erzeugt plausible Sessions, Tode, Advancements, Health und `stats_snapshot` (aus `backend/test/fixtures/stats/*.json` als Startwerte mit wachsenden Zählern) sowie `file_sync` mit den Quest-Fixtures; nutzt dieselben Batches/Signaturen wie die Mod.
- [ ] **Step 1:** Implementieren. **Step 2:** Gegen lokales Backend ausführen; `GET /api/overview` → `totalPlayers ≥ 2`, `online > 0`. Commit `feat(tools): Ingest-Simulator`.
### Task 16: Web – Grundgerüst, Designsystem, Übersicht, Live
**Files:**
- Create: `web/` (Next.js 15, TS, Tailwind), `web/src/lib/api.ts`, `web/src/lib/format.ts`, `web/src/lib/useLive.ts`, `web/src/components/{Header,Footer,StatTile,LeaderboardCard,PlayerHead,OnlineList,LiveTicker,EmptyState}.tsx`, `web/src/app/{layout,page}.tsx`, `web/next.config.ts`; Tests `web/test/format.test.ts`, `web/test/LeaderboardCard.test.tsx`
**Interfaces:**
- Consumes: DTOs aus `@atm/shared/api`.
- Produces:
- `api.get<T>(path, {revalidate?}): Promise<T>` – serverseitig `API_INTERNAL_URL` (Default `http://localhost:3001`), clientseitig relativ `/api`; Dev-Rewrite `/api/:path*` → Backend.
- `format.ts`: `formatHours(n)`, `formatKm(n)`, `formatCount(n)` (de-DE, z. B. `1.234`), `formatRelative(ms, now)` („vor 5 Min.“), `formatDuration(ms)`.
- `useLive(onEvent: (e: LiveEvent) => void)` – EventSource auf `/api/live`, Reconnect automatisch.
- Designsystem: Farb-Tokens als CSS-Variablen (dunkler Grundton, ein Akzent + Kategoriefarben tech/magic/farming), Schrift über `next/font` (eine Display-, eine Textschrift), Kartenstil, Fokuszustände, `prefers-reduced-motion`.
- `/`: Kennzahlen-Kacheln, „Gerade online“, Live-Ticker (Tode, Advancements), Raster aller Top-10-Karten; leere Zustände bei leerer DB.
- [ ] **Step 1: Failing tests:** `formatCount(1234) === '1.234'`, `formatHours(1175736/72000)` → `'16,3 h'`; `LeaderboardCard renders 10 rows and empty state`.
- [ ] **Step 2:** FAIL → implementieren → PASS (`npm test -w web`).
- [ ] **Step 3:** `npm run dev -w web` gegen Backend mit Simulator-Daten; Seite manuell im Browser prüfen (Desktop + 375 px Breite). Commit `feat(web): Grundgerüst, Designsystem, Übersicht mit Live-Daten`.
### Task 17: Web – Ranglisten und Spielerprofile
**Files:**
- Create: `web/src/app/leaderboards/page.tsx`, `web/src/app/leaderboards/[board]/page.tsx`, `web/src/app/players/page.tsx`, `web/src/app/players/[name]/page.tsx`, `web/src/components/{HistoryChart,ActivityHeatmap,ItemsByMod,RankBadges,PeriodTabs}.tsx`; Test `web/test/ActivityHeatmap.test.tsx`
**Interfaces:**
- Consumes: `/api/leaderboards*`, `/api/players*`, `/api/avatar/:uuid`.
- Produces: Rangliste mit Zeitraum-Tabs (all/30d/7d, Query-Param), Spielerliste mit Suche, Profil gemäß Spec §8 (Kopf, Kennzahlen, Ränge, Verlaufsdiagramm mit Metrik-Auswahl, Heatmap 7×24, Bewegung, Kampf, Items nach Mod mit Kategorie-Tabs, Advancements, Team-Quests). 404-Seite für unbekannte Spieler.
- [ ] **Step 1: Failing test:** `ActivityHeatmap renders 168 cells with aria labels`.
- [ ] **Step 2:** FAIL → implementieren → PASS; manuelle Prüfung. Commit `feat(web): Ranglisten und Spielerprofile`.
### Task 18: Web – ATM10-, Server-, Quellen- und Admin-Seiten
**Files:**
- Create: `web/src/app/atm10/page.tsx`, `web/src/app/server/page.tsx`, `web/src/app/sources/page.tsx`, `web/src/app/admin/page.tsx`, `web/src/app/admin/login/page.tsx`, `web/src/components/{ModActivityChart,HealthChart,ModTable,QuestChapters}.tsx`
**Interfaces:**
- Consumes: `/api/mods`, `/api/quests`, `/api/server`, `/api/sources`, `/api/admin/*`.
- Produces: ATM10 (Aktivität nach Kategorie/Mod, Kapitel-Fortschritt), Server (Versionen, TPS/MSPT/Spieler/Heap-Verlauf, durchsuchbare Modliste), Quellen (Status + Einschränkungen), Admin (Login-Formular, Status, Sync-Knopf, Spieler ein-/ausblenden, Token-Rotation mit einmaliger Anzeige; alle mutierenden Requests mit `x-csrf-token`).
- [ ] **Step 1:** Implementieren. **Step 2:** `npm run build -w web` ohne Fehler; manuelle Prüfung jeder Seite. Commit `feat(web): ATM10-, Server-, Quellen- und Admin-Seiten`.
### Task 19: Docker Compose, Traefik, Backup, E2E
**Files:**
- Create: `backend/Dockerfile`, `web/Dockerfile` (Next `output: 'standalone'`), `deploy/docker-compose.yml`, `deploy/.env.example`, `deploy/backup.sh`, `web/e2e/smoke.spec.ts`, `web/playwright.config.ts`
**Interfaces:**
- Produces:
- Services `db`, `api` (`node dist/server.js`), `worker` (`node dist/worker.js`), `web`, `backup` (postgres-Image, Cron-Schleife `pg_dump -Fc` täglich, 14 Tage Rotation). Netze: `internal` + externes `${TRAEFIK_NETWORK}` (nur api/web).
- Labels: Router `atm-api` `Host(\`${DOMAIN}\`) && PathPrefix(\`/api\`)` Priorität 100 → Port 3001; `atm-web` `Host(\`${DOMAIN}\`)` → Port 3000; `entrypoints=${TRAEFIK_ENTRYPOINT}`, `tls.certresolver=${TRAEFIK_CERTRESOLVER}`; optionaler Router `atm-ingest` für `PathPrefix(\`/api/ingest\`)` mit Middleware `ipallowlist.sourcerange=${INGEST_ALLOWED_IPS}`.
- Healthchecks: api `GET /api/health`, web `GET /`, db `pg_isready`. Container als Non-Root.
- `.env.example` mit allen Variablen aus `loadConfig` + `DOMAIN`, `TRAEFIK_*`, `POSTGRES_PASSWORD`, `API_INTERNAL_URL=http://api:3001`.
- [ ] **Step 1:** Lokal: `docker compose -f deploy/docker-compose.yml --env-file deploy/.env.local up -d --build` mit einem lokalen Override (`deploy/docker-compose.local.yml`, published Ports statt Traefik).
- [ ] **Step 2:** Simulator gegen den Stack laufen lassen. **Step 3:** Playwright-Smoke: Startseite zeigt Kennzahlen, Klick auf Spieler öffnet Profil, Ranglisten-Seite lädt, `/api/live` liefert Heartbeat. `npx playwright test` → PASS.
- [ ] **Step 4:** `deploy/backup.sh` einmal ausführen, Restore in Testdatenbank verifizieren. Commit `feat(deploy): Docker Compose mit Traefik, Backup, E2E-Smoke-Test`.
### Task 20: Dokumentation und Abschluss
**Files:**
- Create: `README.md`, `docs/ARCHITECTURE.md`, `docs/DATA-SOURCES.md`, `docs/OPERATIONS.md`, `mod/README.md`
**Interfaces:**
- Produces: `DATA-SOURCES.md` enthält Tabelle „Kennzahl → Quelle → Aktualität → Einschränkung“ für alle Boards, Profilbereiche und Serverwerte, Abschnitt „Nicht verfügbar“ (Spec §3.2) und „Hoster-Einschränkungen“ (unbekanntes ausgehendes HTTPS, Stats-Dateien nur beim Speichern aktualisiert, Log ohne Startabschnitt, Quest-Titel abhängig von Config-Kopie). `OPERATIONS.md`: Traefik-/OPNsense-Regeln, Installation der Mod per Mine-Hoster-SFTP (Server stoppen, JAR nach `mods/`, Config setzen, starten, Log auf `atmstats: backend reachable` prüfen), Token-Rotation, Backup/Restore, Neustartverhalten, Fehlerbilder (clock skew, not reachable, rejected-Ordner).
- [ ] **Step 1:** Dokumente schreiben, mit tatsächlichen Werten aus Code/Config abgleichen.
- [ ] **Step 2:** Vollständiger Lauf: `npm test` (alle Workspaces), `./gradlew test build`, `npm run build -w web`, Compose-E2E. Alles grün.
- [ ] **Step 3:** Commit `docs: Architektur, Datenquellen, Betrieb`.