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

47 KiB
Raw Permalink Blame History

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-ingestfürPathPrefix(`/api/ingest`)mit Middlewareipallowlist.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.