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

+230
View File
@@ -0,0 +1,230 @@
// Ingest-Simulator: erzeugt plausible Ereignisse im echten Protokoll (gleiche Batches und Signatur wie die Mod),
// damit Backend und Frontend ohne Minecraft-Server getestet werden können.
//
// npm run sim -w tools/simulator -- --endpoint http://localhost:3001 --token <t> --secret <s> [--players 5] [--minutes 120] [--realtime]
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import { atmEventSchema, deterministicId, type AtmEvent } from '@atm/shared';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURES = join(here, '..', '..', '..', 'backend', 'test', 'fixtures');
function arg(name: string, fallback?: string): string {
const i = process.argv.indexOf(`--${name}`);
const v = i >= 0 ? process.argv[i + 1] : process.env[`SIM_${name.toUpperCase()}`];
if (v === undefined && fallback === undefined) throw new Error(`missing --${name}`);
return v ?? fallback!;
}
const endpoint = arg('endpoint', 'http://localhost:3001').replace(/\/+$/, '');
const token = arg('token');
const secret = arg('secret');
const playerCount = Number(arg('players', '5'));
const minutes = Number(arg('minutes', '120'));
const realtime = process.argv.includes('--realtime');
const NAMES = ['CDOGamer', 'Bob', 'Steve_Builder', 'Alexandra', 'RedstoneRita', 'MekaMax', 'ArsArne', 'QuestQueen', 'Ferro', 'Nyx'];
const DIMS = ['minecraft:overworld', 'minecraft:the_nether', 'minecraft:the_end', 'allthemodium:mining', 'allthemodium:the_other'];
const CAUSES = ['minecraft:fall', 'minecraft:lava', 'minecraft:mob_attack', 'minecraft:explosion', 'minecraft:drown', 'minecraft:magic'];
const KILLERS = ['minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:wither_skeleton', 'cataclysm:ignis'];
const BOSSES = ['minecraft:ender_dragon', 'minecraft:wither', 'cataclysm:ignis', 'cataclysm:netherite_monstrosity'];
const ADVANCEMENTS: [string, string][] = [
['minecraft:story/mine_stone', 'Stone Age'],
['minecraft:story/enter_the_nether', 'We Need to Go Deeper'],
['minecraft:nether/obtain_blaze_rod', 'Into Fire'],
['mekanism:root', 'Mekanism'],
['mysticalagriculture:infusion', 'Infusing Efficiency'],
['ars_nouveau:root', 'Ars Nouveau'],
['allthemodium:allthemodium', 'The Strongest Metal'],
];
const rnd = (n: number) => Math.floor(Math.random() * n);
const pick = <T>(xs: T[]) => xs[rnd(xs.length)]!;
type Stats = Record<string, Record<string, number>>;
interface Player {
uuid: string;
name: string;
stats: Stats;
}
/** Real counters from the fixture stats files as starting point, random fresh players for the rest. */
function loadPlayers(): Player[] {
const players: Player[] = [];
const files = readdirSync(join(FIXTURES, 'stats')).filter((f) => f.endsWith('.json'));
for (let i = 0; i < playerCount; i++) {
const file = files[i];
const stats: Stats = file ? JSON.parse(readFileSync(join(FIXTURES, 'stats', file), 'utf8')).stats : { 'minecraft:custom': {} };
const name = NAMES[i % NAMES.length]!;
// stable uuid per simulated name, so repeated runs update the same players
players.push({ uuid: file ? file.replace('.json', '') : deterministicId(`sim-player:${name}`), name, stats });
}
return players;
}
function grow(p: Player, minutesPlayed: number) {
const add = (cat: string, key: string, v: number) => {
p.stats[cat] ??= {};
p.stats[cat]![key] = (p.stats[cat]![key] ?? 0) + Math.max(0, Math.round(v));
};
const ticks = minutesPlayed * 1200;
add('minecraft:custom', 'minecraft:play_time', ticks);
add('minecraft:custom', 'minecraft:total_world_time', ticks);
add('minecraft:custom', 'minecraft:walk_one_cm', minutesPlayed * (2000 + rnd(8000)));
add('minecraft:custom', 'minecraft:sprint_one_cm', minutesPlayed * rnd(6000));
add('minecraft:custom', 'minecraft:fly_one_cm', minutesPlayed * rnd(12000));
add('minecraft:custom', 'minecraft:jump', minutesPlayed * rnd(30));
add('minecraft:custom', 'minecraft:mob_kills', rnd(minutesPlayed));
add('minecraft:custom', 'lootr:looted_stat', rnd(3));
add('minecraft:mined', pick(['minecraft:stone', 'minecraft:deepslate', 'alltheores:osmium_ore', 'minecraft:diamond_ore']), minutesPlayed * rnd(40));
add('minecraft:crafted', pick(['mekanism:steel_casing', 'powah:energy_cell_basic', 'ars_nouveau:source_gem', 'mysticalagriculture:inferium_essence']), rnd(20));
add('minecraft:used', pick(['mekanism:configurator', 'irons_spellbooks:scroll', 'minecraft:bread']), rnd(15));
add('minecraft:killed', pick(KILLERS), rnd(5));
}
// Representative excerpt of the ATM10 mod list (the real list arrives with server_start from the mod).
const MODS = [
['mekanism', '10.7.19', 'Mekanism'],
['powah', '6.2.10', 'Powah'],
['ars_nouveau', '5.13.1', 'Ars Nouveau'],
['mysticalagriculture', '8.0.28', 'Mystical Agriculture'],
['ftbquests', '2101.1.36', 'FTB Quests'],
['apotheosis', '8.8.0', 'Apotheosis'],
['irons_spellbooks', '3.16.3', "Iron's Spells 'n Spellbooks"],
['allthemodium', '3.0.1', 'Allthemodium'],
].map(([id, version, name]) => ({ id: id!, version: version!, name: name! }));
function timeline(players: Player[], now: number): AtmEvent[] {
const start = now - minutes * 60_000;
const out: AtmEvent[] = [];
const ev = (type: string, ts: number, payload: object) => out.push({ id: randomUUID(), type, ts: Math.round(ts), ...payload } as AtmEvent);
ev('server_start', start, { mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.12', mods: MODS });
for (let t = start; t < now; t += 60_000) {
const online = Math.max(1, Math.min(players.length, 2 + Math.round(Math.sin(t / 3.6e6) * 2)));
ev('health', t + 60_000, {
windowStart: t,
windowEnd: t + 60_000,
msptAvg: 25 + rnd(25),
msptP95: 45 + rnd(40),
msptMax: 60 + rnd(400),
tps: 20,
players: online,
chunks: 1500 + rnd(800),
heapUsedMb: 5000 + rnd(3000),
heapMaxMb: 10240,
});
}
for (const [i, p] of players.entries()) {
const who = { uuid: p.uuid, name: p.name };
let t = start + i * 7 * 60_000;
while (t < now) {
const join = t;
const length = (20 + rnd(90)) * 60_000;
const leave = join + length;
ev('player_join', join, who);
ev('stats_snapshot', join + 1000, { ...who, full: true, stats: structuredClone(p.stats) });
for (let s = join + 5 * 60_000; s < Math.min(leave, now); s += 5 * 60_000) {
grow(p, 5);
ev('stats_snapshot', s, { ...who, full: false, stats: structuredClone(p.stats) });
if (Math.random() < 0.15) {
ev('player_death', s + 1000, {
...who,
dimension: pick(DIMS),
cause: pick(CAUSES),
killerType: Math.random() < 0.5 ? pick(KILLERS) : null,
killerName: null,
});
}
if (Math.random() < 0.1) {
const [advancementId, title] = pick(ADVANCEMENTS);
ev('advancement', s + 2000, { ...who, advancementId, title });
}
if (Math.random() < 0.05) {
ev('entity_kill', s + 3000, { ...who, dimension: pick(DIMS), victimType: pick(BOSSES), isBoss: true, weapon: 'allthemodium:alloy_sword' });
}
if (Math.random() < 0.1) ev('dimension_change', s + 4000, { ...who, from: 'minecraft:overworld', to: pick(DIMS) });
}
// the last session of the first players stays open, so the portal shows players online
if (leave < now || i >= 3) ev('player_leave', Math.min(leave, now - 1000), who);
t = leave + (30 + rnd(120)) * 60_000;
}
}
// FTB quest progress of the first player (real fixture file)
const questFile = join(FIXTURES, 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt');
const content = readFileSync(questFile, 'utf8');
ev('file_sync', now - 1000, {
path: 'world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt',
sha256: createHash('sha256').update(content).digest('hex'),
content,
});
return out.sort((a, b) => a.ts - b.ts);
}
async function send(events: AtmEvent[]) {
for (const e of events) atmEventSchema.parse(e);
const body = gzipSync(Buffer.from(JSON.stringify({ protocol: 1, events })));
const ts = Date.now();
const sig = createHmac('sha256', secret)
.update(`${ts}.${createHash('sha256').update(body).digest('hex')}`)
.digest('hex');
const res = await fetch(`${endpoint}/api/ingest`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
'content-encoding': 'gzip',
'x-atm-server': 'simulator',
'x-atm-timestamp': String(ts),
'x-atm-signature': sig,
},
body,
});
const json = (await res.json()) as { accepted?: string[]; duplicate?: string[]; rejected?: unknown[]; error?: string };
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(json)}`);
return json;
}
async function main() {
const players = loadPlayers();
const events = timeline(players, Date.now());
let accepted = 0;
for (let i = 0; i < events.length; i += 500) {
const batch = events.slice(i, i + 500);
const r = await send(batch);
accepted += r.accepted?.length ?? 0;
if (r.rejected?.length) console.warn('rejected:', r.rejected);
}
console.log(`simuliert: ${players.length} Spieler, ${minutes} min, ${events.length} Ereignisse, ${accepted} neu angenommen`);
if (!realtime) return;
console.log('Echtzeit-Modus: sendet alle 10 s neue Ereignisse (Strg+C beendet)');
for (;;) {
await new Promise((r) => setTimeout(r, 10_000));
const p = pick(players);
grow(p, 1);
const live: AtmEvent[] = [
{ id: randomUUID(), type: 'stats_snapshot', ts: Date.now(), uuid: p.uuid, name: p.name, full: false, stats: structuredClone(p.stats) },
];
if (Math.random() < 0.3) {
live.push({ id: randomUUID(), type: 'player_death', ts: Date.now(), uuid: p.uuid, name: p.name, dimension: pick(DIMS), cause: pick(CAUSES), killerType: pick(KILLERS), killerName: null });
}
if (Math.random() < 0.3) {
const [advancementId, title] = pick(ADVANCEMENTS);
live.push({ id: randomUUID(), type: 'advancement', ts: Date.now(), uuid: p.uuid, name: p.name, advancementId: `${advancementId}_${rnd(1000)}`, title });
}
await send(live);
console.log(`live: ${live.map((e) => e.type).join(', ')} (${p.name})`);
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});