# 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 `. ## 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/.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>, 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(pool, fn: (c: PoolClient) => Promise): Promise` - `migrate(pool): Promise` (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}): 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` (Klartext 32 Byte base64url, gespeichert als sha256), `isValidIngestToken(pool, token)`, `revokeAllIngestTokens(pool)` - `processEvents(pool, events: AtmEvent[], source: 'push'|'sftp', serverId: string): Promise` – 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)` – 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 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..title`, `chapter..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 is ` (Name→UUID-Map innerhalb des Chunks und aus vorherigem Zustand), ` joined the game`, ` left the game`, ` has made the advancement []`, `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`.