feat: ATM10-Statistikportal

Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseLogChunk, parseLogTimestamp } from '../src/parsers/log.ts';
const log = readFileSync(join(import.meta.dirname, 'fixtures', 'latest.anon.log'));
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
const TZ = 'Europe/Berlin';
describe('parseLogTimestamp', () => {
it('interprets log time in the server time zone', () => {
// 12 Oct 2026 is CEST (UTC+2)
expect(parseLogTimestamp('12Oct2026 09:03:03.006', TZ)).toBe(Date.parse('2026-10-12T07:03:03.006Z'));
// 1 Dec 2026 is CET (UTC+1)
expect(parseLogTimestamp('01Dec2026 00:00:00.000', TZ)).toBe(Date.parse('2026-11-30T23:00:00.000Z'));
});
});
describe('parseLogChunk', () => {
it('finds join of CDOGamer with uuid 308a…', () => {
const { events } = parseLogChunk(log, 0, new Map(), TZ);
const join = events.find((e) => e.type === 'player_join');
expect(join).toMatchObject({ type: 'player_join', uuid: ALICE, name: 'CDOGamer' });
expect(join!.ts).toBe(parseLogTimestamp('12Oct2026 09:03:03.006', TZ));
expect(events.filter((e) => e.type === 'player_leave').length).toBeGreaterThan(0);
});
it('finds advancement Infusing Efficiency and goals', () => {
const { events } = parseLogChunk(log, 0, new Map(), TZ);
const titles = events.flatMap((e) => (e.type === 'advancement' ? [e.title] : []));
expect(titles).toContain('Infusing Efficiency');
expect(titles).toContain('The Factory Must Grow!');
const adv = events.find((e) => e.type === 'advancement')!;
expect(adv).toMatchObject({ uuid: ALICE, advancementId: 'log:Infusing Efficiency' });
});
it('resolves names known from earlier chunks', () => {
const line = Buffer.from('[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\r\n');
const known = new Map([['Bob', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']]);
expect(parseLogChunk(line, 0, known, TZ).events[0]).toMatchObject({ type: 'player_leave', name: 'Bob' });
expect(parseLogChunk(line, 0, new Map(), TZ).events).toEqual([]);
});
it('ignores chat lines that imitate system messages', () => {
const line = Buffer.from(
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <CDOGamer> Bob joined the game\n',
);
expect(parseLogChunk(line, 0, new Map([['Bob', ALICE]]), TZ).events).toEqual([]);
});
it('ignores incomplete last line', () => {
const text = Buffer.from(
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\n' +
'[12Oct2026 10:00:01.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob joined the ga',
);
const r = parseLogChunk(text, 100, new Map([['Bob', ALICE]]), TZ);
expect(r.events).toHaveLength(1);
expect(r.consumed).toBe(text.indexOf('\n') + 1);
});
it('same chunk twice yields same ids; ids depend on absolute offset', () => {
const a = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
const b = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
const c = parseLogChunk(log, 10, new Map(), TZ).events.map((e) => e.id);
expect(a).toEqual(b);
expect(a[0]).not.toBe(c[0]);
});
});