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>
94 lines
3.8 KiB
TypeScript
94 lines
3.8 KiB
TypeScript
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 };
|
|
}
|