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:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user