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

+93
View File
@@ -0,0 +1,93 @@
import { createHash } from 'node:crypto';
import { deterministicId, type AtmEvent } from '@atm/shared';
const MONTHS: Record<string, number> = {
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
};
function tzOffsetMs(utcMs: number, timeZone: string): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(new Date(utcMs));
const get = (t: string) => Number(parts.find((p) => p.type === t)!.value);
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
return asUtc - Math.floor(utcMs / 1000) * 1000;
}
/** "12Oct2026 09:03:03.006" in the server's local time zone → epoch ms. */
export function parseLogTimestamp(s: string, timeZone: string): number {
const m = /^(\d{2})([A-Z][a-z]{2})(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s);
if (!m || MONTHS[m[2]!] === undefined) return Number.NaN;
const local = Date.UTC(+m[3]!, MONTHS[m[2]!]!, +m[1]!, +m[4]!, +m[5]!, +m[6]!, +m[7]!);
let utc = local - tzOffsetMs(local, timeZone);
utc = local - tzOffsetMs(utc, timeZone); // second pass settles DST boundaries
return utc;
}
const LINE = /^\[([^\]]+)\] \[[^\]]+\] \[([^\]]+)\]: (.*)$/;
const NAME = '([A-Za-z0-9_]{1,16})';
const UUID_OF = new RegExp(`^UUID of player ${NAME} is ([0-9a-f-]{36})$`);
const JOINED = new RegExp(`^${NAME} joined the game$`);
const LEFT = new RegExp(`^${NAME} left the game$`);
const ADVANCEMENT = new RegExp(`^${NAME} has (?:made the advancement|reached the goal|completed the challenge) \\[(.+)\\]$`);
const SYSTEM_LOGGER = 'net.minecraft.server.MinecraftServer/';
/**
* Parses complete lines of `logs/latest.log`. Only bytes up to the last newline are consumed, so a line that is
* still being written is read again next time. `names` maps player name → uuid and is updated from
* "UUID of player" lines; lines for players without known uuid are skipped.
* Event IDs derive from the absolute byte offset and line content, so re-reading yields the same IDs.
*/
export function parseLogChunk(
chunk: Buffer,
baseOffset: number,
names: Map<string, string>,
timeZone: string,
): { events: AtmEvent[]; consumed: number } {
const end = chunk.lastIndexOf(0x0a);
if (end < 0) return { events: [], consumed: 0 };
const events: AtmEvent[] = [];
let offset = 0;
while (offset <= end) {
const nl = chunk.indexOf(0x0a, offset);
const line = chunk.toString('utf8', offset, nl).replace(/\r$/, '');
const lineOffset = baseOffset + offset;
offset = nl + 1;
const m = LINE.exec(line);
if (!m) continue;
const [, time, logger, msg] = m as unknown as [string, string, string, string];
const uuidOf = UUID_OF.exec(msg);
if (uuidOf) {
names.set(uuidOf[1]!, uuidOf[2]!);
continue;
}
if (logger !== SYSTEM_LOGGER) continue;
const id = () => deterministicId(`log:${lineOffset}:${createHash('sha256').update(line).digest('hex')}`);
const ts = parseLogTimestamp(time, timeZone);
if (!Number.isFinite(ts)) continue;
let hit: RegExpExecArray | null;
if ((hit = JOINED.exec(msg)) || (hit = LEFT.exec(msg))) {
const name = hit[1]!;
const uuid = names.get(name);
if (!uuid) continue;
events.push({ id: id(), type: JOINED.test(msg) ? 'player_join' : 'player_leave', ts, uuid, name });
} else if ((hit = ADVANCEMENT.exec(msg))) {
const name = hit[1]!;
const uuid = names.get(name);
if (!uuid) continue;
events.push({ id: id(), type: 'advancement', ts, uuid, name, advancementId: `log:${hit[2]}`, title: hit[2]! });
}
}
return { events, consumed: end + 1 };
}