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

+138
View File
@@ -0,0 +1,138 @@
import { isCompound, parseSnbt, type SnbtValue } from './snbt.ts';
/** "308a693c2bd84d6b8790670aef5be601" → "308a693c-2bd8-4d6b-8790-670aef5be601" */
export function normalizeUuid(raw: string): string {
const hex = raw.replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`not a uuid: ${raw}`);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export interface QuestProgress {
teamId: string;
name: string | null;
/** IDs from FTB's `completed` map (quests and chapters) with completion time in ms. */
completed: { questId: string; at: number }[];
started: number;
}
/** Parses `world/ftbquests/<team>.snbt` (FTB Quests 2101.x team data). */
export function parseQuestProgress(text: string): QuestProgress {
const root = parseSnbt(text);
if (!isCompound(root) || typeof root.uuid !== 'string') throw new Error('quest progress: missing uuid');
const completedMap = isCompound(root.completed) ? root.completed : {};
const completed = Object.entries(completedMap)
.filter(([id, at]) => /^[0-9A-F]{16}$/i.test(id) && typeof at === 'number')
.map(([id, at]) => ({ questId: id.toUpperCase(), at: at as number }));
return {
teamId: normalizeUuid(root.uuid),
name: typeof root.name === 'string' ? root.name : null,
completed,
started: isCompound(root.started) ? Object.keys(root.started).length : 0,
};
}
export interface QuestDef {
chapterId: string;
chapterTitle: string | null;
questId: string;
title: string | null;
}
const str = (v: SnbtValue | undefined): string | null => (typeof v === 'string' && v.length > 0 ? v : null);
/**
* Removes Minecraft formatting codes ("&a", "&6&l", "§r") used in ATM10 quest texts. FTB writes a literal
* ampersand as "\&" (e.g. "Forbidden \& Arcanus"); that one is kept as "&".
*/
export function stripFormatting(s: string): string {
return s
.split('\\&')
.map((part) => part.replace(/[&§][0-9a-fk-or]/gi, ''))
.join('&')
.replace(/\s{2,}/g, ' ')
.trim();
}
const strip = (s: string | null) => (s ? stripFormatting(s) : null);
const EN_LANG = /\/lang\/en_us(\.snbt|\/.+\.snbt_merged)$/;
const CHAPTER_FILE = /\/chapters\/[^/]+\.snbt$/;
/**
* Quest config files worth transferring: chapter definitions and the English texts split per chapter by
* ftbquestslangsplitter (lang/en_us/**\/*.snbt_merged, verified on the ATM10 server). Skipped: other
* languages (~35 MB), reward tables and the combined lang/en_us.snbt (1.5 MB, same content as the split files).
*/
export function isQuestConfigFile(path: string): boolean {
return /^config\/ftbquests\/quests\/(chapters\/[^/]+\.snbt|lang\/en_us\/.+\.snbt_merged)$/.test(path);
}
/**
* Builds quest definitions from `config/ftbquests/quests/chapters/*.snbt`. Titles come from the English lang
* files (`chapter.<id>.title`, `quest.<id>.title`; split `lang/en_us/**\/*.snbt_merged` or a single
* `lang/en_us.snbt`), else from inline `title` fields; chapters fall back to their filename.
* Quests without a title key show their task item in game; that name is not available server-side (null).
*/
export function parseQuestDefs(files: { path: string; content: string }[]): QuestDef[] {
const lang = new Map<string, string>();
for (const f of files) {
if (!EN_LANG.test(f.path)) continue;
const root = parseSnbt(f.content);
if (!isCompound(root)) continue;
for (const [k, v] of Object.entries(root)) {
const raw = Array.isArray(v) ? v.filter((x) => typeof x === 'string').join(' ') : str(v);
const text = raw ? stripFormatting(raw) : null;
if (text) lang.set(k, text);
}
}
const defs: QuestDef[] = [];
const chapters = files.filter((f) => CHAPTER_FILE.test(f.path)).sort((a, b) => a.path.localeCompare(b.path));
for (const f of chapters) {
const root = parseSnbt(f.content);
if (!isCompound(root)) continue;
const chapterId = str(root.id);
if (!chapterId) continue;
const chapterTitle = lang.get(`chapter.${chapterId}.title`) ?? strip(str(root.title)) ?? str(root.filename);
for (const q of Array.isArray(root.quests) ? root.quests : []) {
if (!isCompound(q)) continue;
const questId = str(q.id);
if (!questId) continue;
defs.push({
chapterId,
chapterTitle,
questId: questId.toUpperCase(),
title: lang.get(`quest.${questId}.title`) ?? strip(str(q.title)),
});
}
}
return defs;
}
export interface TeamInfo {
teamId: string;
type: 'player' | 'party' | 'server' | string;
name: string | null;
/** players with rank member, officer or owner (not invited players or allies) */
members: string[];
}
const MEMBER_RANKS = new Set(['member', 'officer', 'owner']);
/**
* Parses `world/ftbteams/{player,party}/<id>.snbt` (FTB Teams 2101.x). Verified on the real server for player
* teams; party files use the same fields (`id`, `type`, `ranks`, `properties`).
*/
export function parseTeam(text: string): TeamInfo {
const root = parseSnbt(text);
if (!isCompound(root) || typeof root.id !== 'string') throw new Error('team: missing id');
const props = isCompound(root.properties) ? root.properties : {};
const ranks = isCompound(root.ranks) ? root.ranks : {};
return {
teamId: normalizeUuid(root.id),
type: typeof root.type === 'string' ? root.type : 'unknown',
name: str(props['ftbteams:display_name']) ?? str(root.player_name),
members: Object.entries(ranks)
.filter(([, rank]) => typeof rank === 'string' && MEMBER_RANKS.has(rank))
.map(([uuid]) => normalizeUuid(uuid)),
};
}
+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 };
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Tolerant SNBT parser for FTB files. FTB writes "pretty" SNBT: entries separated by newlines instead of commas,
* unquoted keys that may start with digits (team/quest IDs) or contain dots (lang keys).
* Numbers lose their type suffix (b, s, L, f, d) and become JS numbers.
*/
export type SnbtValue = string | number | boolean | SnbtValue[] | { [key: string]: SnbtValue };
export class SnbtError extends Error {
constructor(
message: string,
readonly line: number,
readonly column: number,
) {
super(`${message} at line ${line}, column ${column}`);
}
}
const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?[bBsSlLfFdD]?$/;
const BARE = /[A-Za-z0-9_\-.+]/;
export function parseSnbt(text: string): SnbtValue {
let pos = 0;
const error = (msg: string): never => {
const before = text.slice(0, pos);
const line = before.split('\n').length;
const column = pos - before.lastIndexOf('\n');
throw new SnbtError(msg, line, column);
};
/** Skips whitespace and commas (commas are optional separators in FTB SNBT). */
const skipSeparators = () => {
while (pos < text.length && /[\s,]/.test(text[pos]!)) pos++;
};
const skipWs = () => {
while (pos < text.length && /\s/.test(text[pos]!)) pos++;
};
const quoted = (): string => {
const q = text[pos++];
let out = '';
while (pos < text.length) {
const ch = text[pos++]!;
if (ch === '\\') {
if (pos >= text.length) break;
const next = text[pos++]!;
out += next === 'n' ? '\n' : next === 't' ? '\t' : next;
} else if (ch === q) {
return out;
} else {
out += ch;
}
}
return error('unterminated string');
};
const bare = (): string => {
const start = pos;
while (pos < text.length && BARE.test(text[pos]!)) pos++;
if (pos === start) error(`unexpected character '${text[pos] ?? 'EOF'}'`);
return text.slice(start, pos);
};
const key = (): string => (text[pos] === '"' || text[pos] === "'" ? quoted() : bare());
const compound = (): SnbtValue => {
pos++; // {
const obj: { [k: string]: SnbtValue } = {};
for (;;) {
skipSeparators();
if (pos >= text.length) error('unterminated compound');
if (text[pos] === '}') {
pos++;
return obj;
}
const k = key();
skipWs();
if (text[pos] !== ':') error(`expected ':' after key '${k}'`);
pos++;
obj[k] = value();
}
};
const list = (): SnbtValue => {
pos++; // [
skipWs();
// typed arrays: [I; 1, 2]
if (/[IBL]/.test(text[pos] ?? '') && text[pos + 1] === ';') pos += 2;
const arr: SnbtValue[] = [];
for (;;) {
skipSeparators();
if (pos >= text.length) error('unterminated list');
if (text[pos] === ']') {
pos++;
return arr;
}
arr.push(value());
}
};
const value = (): SnbtValue => {
skipWs();
const ch = text[pos];
if (ch === undefined) return error('unexpected end of input');
if (ch === '{') return compound();
if (ch === '[') return list();
if (ch === '"' || ch === "'") return quoted();
const word = bare();
if (word === 'true') return true;
if (word === 'false') return false;
if (NUMBER.test(word)) return Number(/[bBsSlLfFdD]$/.test(word) ? word.slice(0, -1) : word);
return word;
};
const result = value();
skipWs();
if (pos < text.length) error('trailing content');
return result;
}
export const isCompound = (v: SnbtValue | undefined): v is { [key: string]: SnbtValue } =>
typeof v === 'object' && v !== null && !Array.isArray(v);
+56
View File
@@ -0,0 +1,56 @@
import { createHash } from 'node:crypto';
import { deterministicId, type AtmEvent } from '@atm/shared';
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
/** `world/stats/<uuid>.json` → absolute stats_snapshot observed at the file's mtime. */
export function statsFileToEvent(uuid: string, json: string, mtimeMs: number): AtmEvent {
const root = JSON.parse(json) as { stats?: Record<string, Record<string, number>> };
const stats: Record<string, Record<string, number>> = {};
for (const [cat, entries] of Object.entries(root.stats ?? {})) {
const clean: Record<string, number> = {};
for (const [k, v] of Object.entries(entries)) if (Number.isInteger(v) && v >= 0) clean[k] = v;
stats[cat] = clean;
}
return {
id: deterministicId(`stats:${uuid}:${mtimeMs}:${sha256(json)}`),
type: 'stats_snapshot',
ts: mtimeMs,
uuid,
name: null,
full: true,
stats,
};
}
/** "2026-10-12 12:42:23 +0200" → epoch ms */
export function parseAdvancementTime(s: string): number {
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(s);
if (!m) return Number.NaN;
return Date.parse(`${m[1]}T${m[2]}${m[3]}${m[4]}:${m[5]}`);
}
/** `world/advancements/<uuid>.json` → one advancement event per completed, non-recipe advancement. */
export function advancementsFileToEvents(uuid: string, json: string): AtmEvent[] {
const root = JSON.parse(json) as Record<string, unknown>;
const events: AtmEvent[] = [];
for (const [id, entry] of Object.entries(root)) {
if (id === 'DataVersion' || id.includes(':recipes/')) continue;
const adv = entry as { done?: boolean; criteria?: Record<string, string> };
if (!adv?.done) continue;
const times = Object.values(adv.criteria ?? {})
.map(parseAdvancementTime)
.filter(Number.isFinite);
if (times.length === 0) continue;
events.push({
id: deterministicId(`adv:${uuid}:${id}`),
type: 'advancement',
ts: Math.min(...times),
uuid,
name: null,
advancementId: id,
title: null,
});
}
return events;
}