docs: Implementierungsplan ATM10-Statistikportal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a28ab2cd4
commit
56d2078e6a
1 file changed
+442
@@ -0,0 +1,442 @@
|
|||||||
|
# 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`.
|
||||||
Reference in new issue
Block a user