47 KiB
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, Packagede.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, HeaderAuthorization: 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 neuestenobserved_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 auslatest.logsind 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
- Server-Absturz ohne
server_stop→ beim nächstenserver_startwerden offene Sessions mit dem letzten bekannten Lebenszeichen geschlossen; Spielzeit wird nicht aufgebläht. Test in Task 4 (closes dangling sessions on server_start). - 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. - Namensänderung eines Spielers →
players.namewird aktualisiert, alter Name inname_history;/api/players/:idOrNamelöst alte Namen auf. Tests in Task 4 und Task 8. - 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). - Log-Rotation und unvollständige letzte Zeile → nur Zeilen bis zum letzten
\nwerden 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 auftype), 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.jsonmit workspaces["shared","backend","web","tools/simulator"], Scriptstest,lint,typecheck;tsconfig.base.json(strict, ES2022, NodeNext).npm install. -
Step 2: Failing tests
shared/test/protocol.test.ts:every fixture validates: jede Datei infixtures/protocolparst mitatmEventSchema; Typ = Dateiname.rejects unknown typeundrejects 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. Commitfeat(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): ConfigmitDATABASE_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, unterpg_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; Indizesstat_current(category, key, value DESC),sessions(uuid) WHERE ended_at IS NULL,events(ts). -
Step 1:
docker-compose.test.ymlmitpostgres:16-alpineauf Port 54329, DBatm_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, Tabelleeventsexistiert);loadConfig rejects missing DATABASE_URL. -
Step 3: Run
npm test -w backend→ FAIL. -
Step 4: Implementieren.
vitest.config.ts:fileParallelism: false,TEST_DATABASE_URLdefaultpostgres://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 TransaktionINSERT … ON CONFLICT (event_id) DO NOTHING RETURNING event_id; nur neu eingefügte Events anproject(client, event, source); nach Commitpg_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-Typeapplication/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(überapp.inject):accepts valid batch→ 200,acceptedenthält beide IDs, Tabelleeventshat 2 Zeilen mitsource='push'.same batch twice is idempotent→ zweiter Aufruf:accepted: [],duplicate= beide IDs, weiterhin 2 Zeilen.rejects wrong token→ 401bad_token;rejects bad signature→ 401bad_signature.rejects timestamp 6 minutes old→ 401{"error":"timestamp_out_of_range"}(Review Focus 2).rejects invalid event but accepts the rest→ ein Event ohneuuidlandet inrejectedmit Grund, gültiges inaccepted.rejects body over 2 MB unpacked→ 413.ping→GET /api/ingest/pingmit 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 (
safeParseje 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; Testbackend/test/projections.test.ts
Interfaces:
- Consumes:
registerProjection,processEvents. - Produces (Tabellen-Semantik, auf die Read-API baut):
player_join: upsertplayers(name, last_seen); Namensänderung →name_history; öffnet Session (schließt vorher evtl. offene Session desselben Spielers mitend_reason='superseded').player_leave: schließt offene Session (end_reason='leave').server_start: Insertserver_info; schließt alle offenen Sessions mitended_at = max(letztes Event des Spielers, letztes health.windowEnd)undend_reason='crash'.server_stop: schließt offene Sessions mitts,end_reason='stop'.player_death→deaths(bewusst ohne Koordinaten: Datenschutz, Basen nicht verraten – Abweichung von Spec §3.1 „Position grob“);entity_kill→kills;advancement→ upsertadvancements(frühestesachieved_atgewinnt; Titel überschreibt NULL);dimension_change→ upsertdimension_visits;health→server_health(Bucket = windowStart auf Minute).stats_snapshot: upsert je Schlüssel instat_currentmitobserved_at = tsnurWHERE stat_current.observed_at < EXCLUDED.observed_at. Ein Snapshot mitfull: trueaussftpsetzt fehlende Schlüssel nicht auf 0. Aktualisiertplayers.last_seennicht, 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; Fixturesbackend/test/fixtures/ftbquests/*.snbt(Kopie ausserver-snapshot/world-ftbquests), Testbackend/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 Suffixb s L f d(L →bigintnur wenn > 2^53, sonst number),true/false, Listen[...], typisierte Arrays[I; …],[L; …],[B; …]. Fehler →SnbtErrormit Zeile/Spalte.parseQuestProgress(text): {teamId: string, name: string|null, completed: {questId: string, at: number}[], started: number}–uuidohne Bindestriche wird normalisiert zu UUID mit Bindestrichen.parseQuestDefs(files: {path, content}[]): {chapterId, chapterTitle, questId, title}[]– liestchapters/*.snbt(id,titlebzw.filename,quests[].id,quests[].title), Titel auslang/en_us.snbtfalls 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 echtenworld/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 (Tabellefile_cache(path PK, sha256, content, updated_at)) und bautquest_defsneu;world/ftbteams/**→teams/team_members. Unbekannter Pfad → nur infile_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, jederquestIdmatcht/^[0-9A-F]{16}$/;parseQuestDefsan 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-ftbquestsbzw.server-snapshot/world-ftbteamsexistiert; falls ja, Fixtures daraus ergänzen undparseQuestDefs/parseTeamdaran testen. Falls nein:parseTeam-Projektion deaktiviert lassen (Feature-Flag infiles.ts), indocs/DATA-SOURCES.mdvermerken. Commitfeat(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; Fixturesbackend/test/fixtures/stats/*.json(aus Snapshot),backend/test/fixtures/latest.anon.log(anonymisiert); Testbackend/test/vanilla.test.ts,backend/test/log.test.ts
Interfaces:
- Produces:
statsFileToEvent(uuid: string, json: string, mtimeMs: number): AtmEvent–stats_snapshotmitfull: true,ts = mtimeMs,id = deterministicId(\stats:${uuid}:${mtimeMs}:${sha256(json)}`)`.advancementsFileToEvents(uuid, json): AtmEvent[]– je Advancement mitdone: true(bzw. allen Kriterien) und ohnerecipes/-Präfix einadvancement-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); erkenntUUID 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-IDsdeterministicId(\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üfengrep -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'] === 1175736fü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→consumedendet 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 Viewleaderboard_cache, Tabelleserver_health_hourly); Testbackend/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}– liefertSELECT 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_timeje Spieler instat_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 inshared/src/api.ts; Testbackend/test/public-api.test.ts
Interfaces:
- Consumes: Tabellen aus Task 4–7,
getLeaderboard,getPlayerRanks,BOARDS. - Produces (DTOs in
@atm/sharedapi.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 übername_historyauf (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 ausDATA-SOURCESals Konstante)GET /api/avatar/:uuid→ PNG aushttps://mc-heads.net/avatar/<uuid>/64mit 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 überprocessEvents):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; Testbackend/test/live.test.ts
Interfaces:
- Consumes:
pg_notify('atm_live', …)ausprocessEvents. - Produces:
LiveHubmitstart(pool)(eigener ClientLISTEN atm_live, Reconnect mit Backoff),subscribe(fn): unsubscribe, Ringpuffer 500, fortlaufende numerische IDs.- Notify-Payload:
{kind: 'online'|'death'|'advancement'|'quest'|'counters', data}–processEventsmappt: 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: pingalle 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; Testbackend/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}→ Cookieatm_admin(httpOnly, Secure außerNODE_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→ setztsync_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)in004_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; Testbackend/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 übersync_state(size, mtime); Log inkrementell aboffset,size < offset→ Offset 0 (Review Focus 5). SNBT-Dateien werden alsfile_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)mitpg_try_advisory_lock; Fehler →app_errors.- Scheduler: SFTP alle
SFTP_INTERVAL_MIN+ beisync_requests; Stunden-Snapshot zur vollen Stunde, Tages-Snapshot 00:05, Retention täglich 03:00,REFRESH MATERIALIZED VIEW CONCURRENTLYhöchstens alle 60 s wenn neue Events.
- Step 1: Failing tests mit
LocalFileSourceauf einem Temp-Verzeichnis, gefüllt mit Fixtures:first sync imports stats, quests, log;second sync without changes reads nothing(Spy aufread);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 untermod/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 nachrejected/),long totalBytes(). Segmentdateienseg-<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-ModDevGradleals ZIP), inmod/entpacken, Beispielinhalte entfernen; JDK 21 (Temurin) installieren;./gradlew --version→ läuft mit Java 21. - Step 2: Failing tests:
SignerTest.matchesBackendVector: Vektorsecret="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. Commitfeat(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; TestsEventJsonTest.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 InterfaceAtmEvent { 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 401timestamp_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. Commitfeat(mod): Ereignisse, Serialisierung, zuverlässiger Versand.
Task 14: Mod – NeoForge-Anbindung
Files:
- Create:
mc/AtmStatsMod.java(@Mod("atmstats"), nurDist.DEDICATED_SERVER-Logik),mc/ModConfig.java(ModConfigSpec SERVER-unabhängig alsCOMMON-Dateiconfig/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; Testcore/FileWatcherLogicTest.java(Änderungserkennung als reine Logik incore/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 ausModListEintragneoforge,System.getProperty("java.version")) →server_start; Ping-Selbsttest im Worker-Thread mit Log-Zeileatmstats: backend reachable/atmstats: backend NOT reachable: <Grund>.ServerStoppingEvent→server_stop,player_leavefür alle Online,Shipper.flushToJournal()(max. 2 s), Worker stoppen.PlayerEvent.PlayerLoggedInEvent/LoggedOutEvent,PlayerEvent.PlayerChangedDimensionEvent,AdvancementEvent.AdvancementEarnEvent(nur mitdisplay().isPresent(), Titeldisplay.getTitle().getString()),LivingDeathEvent(Spieler stirbt →player_deathmitsource.typeHolder().getRegisteredName(); Killer = echterServerPlayerund keinFakePlayerund Opfer ist Spieler oder in TagTags.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 LevelgetChunkSource().getLoadedChunksCount(), Heap ausRuntime).StatsCollector: allestatsSnapshotMinutesSnapshot online Spieler, max. 2 Spieler pro Tick; liestplayer.getStats()(ServerStatsCounter) über alleBuiltInRegistries.STAT_TYPE-Einträge in eine Map; nur geänderte Werte seit letztem Snapshot (full=false), erster Snapshot je Spieler nach Startfull=true.FileWatcher(Worker-Thread, allequestScanMinutes):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, Schleifeshipper.runOnce()+ FileWatcher-Tick, Sleep gemäß Backoff (min. 2 s).neoforge.mods.toml:displayTest="IGNORE_ALL_VERSION", Abhängigkeitneoforge[21.1.0,)undminecraft[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.tomlsetzen,./gradlew runServer(EULA im Run-Verzeichnis akzeptieren), warten bis „Done“, dannstop. Erwartet im Server-Log:atmstats: backend reachable; in DB:server_startmitmc_version='1.21.1'undserver_stop. Tick-Kosten prüfen: keineCan'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 undstats_snapshot(ausbackend/test/fixtures/stats/*.jsonals Startwerte mit wachsenden Zählern) sowiefile_syncmit 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. Commitfeat(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; Testsweb/test/format.test.ts,web/test/LeaderboardCard.test.tsx
Interfaces:
- Consumes: DTOs aus
@atm/shared/api. - Produces:
api.get<T>(path, {revalidate?}): Promise<T>– serverseitigAPI_INTERNAL_URL(Defaulthttp://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 webgegen Backend mit Simulator-Daten; Seite manuell im Browser prüfen (Desktop + 375 px Breite). Commitfeat(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; Testweb/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 webohne Fehler; manuelle Prüfung jeder Seite. Commitfeat(web): ATM10-, Server-, Quellen- und Admin-Seiten.
Task 19: Docker Compose, Traefik, Backup, E2E
Files:
- Create:
backend/Dockerfile,web/Dockerfile(Nextoutput: '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-Schleifepg_dump -Fctäglich, 14 Tage Rotation). Netze:internal+ externes${TRAEFIK_NETWORK}(nur api/web). - Labels: Router
atm-apiHost(\${DOMAIN}`) && PathPrefix(`/api`)Priorität 100 → Port 3001;atm-webHost(`${DOMAIN}`)→ Port 3000;entrypoints=${TRAEFIK_ENTRYPOINT},tls.certresolver=${TRAEFIK_CERTRESOLVER}; optionaler Routeratm-ingestfürPathPrefix(`/api/ingest`)mit Middlewareipallowlist.sourcerange=${INGEST_ALLOWED_IPS}`. - Healthchecks: api
GET /api/health, webGET /, dbpg_isready. Container als Non-Root. .env.examplemit allen Variablen ausloadConfig+DOMAIN,TRAEFIK_*,POSTGRES_PASSWORD,API_INTERNAL_URL=http://api:3001.
- Services
- Step 1: Lokal:
docker compose -f deploy/docker-compose.yml --env-file deploy/.env.local up -d --buildmit 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/liveliefert Heartbeat.npx playwright test→ PASS. - Step 4:
deploy/backup.sheinmal ausführen, Restore in Testdatenbank verifizieren. Commitfeat(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.mdenthä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 nachmods/, Config setzen, starten, Log aufatmstats: backend reachableprü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.