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,16 @@
|
||||
#!/usr/bin/env node
|
||||
// Entfernt Spieler-IP-Adressen aus einem Minecraft-Log, damit es als Test-Fixture versioniert werden kann.
|
||||
// Aufruf: node tools/anonymize-log.mjs <eingabe> <ausgabe>
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const [input, output] = process.argv.slice(2);
|
||||
if (!input || !output) {
|
||||
console.error('usage: node tools/anonymize-log.mjs <in> <out>');
|
||||
process.exit(1);
|
||||
}
|
||||
const text = readFileSync(input, 'utf8')
|
||||
.replace(/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+/g, '/0.0.0.0:0')
|
||||
.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '0.0.0.0')
|
||||
// login positions ("logged in with entity id … at (x, y, z)") would reveal where players build
|
||||
.replace(/ at \(-?[\d.]+, -?[\d.]+, -?[\d.]+\)/g, ' at (0.0, 0.0, 0.0)');
|
||||
writeFileSync(output, text);
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimaler RCON-Client für lokale Tests: node tools/rcon.mjs <host> <port> <passwort> <befehl...>
|
||||
import net from 'node:net';
|
||||
|
||||
const [host, port, password, ...cmd] = process.argv.slice(2);
|
||||
if (!host || !port || !password || cmd.length === 0) {
|
||||
console.error('usage: node tools/rcon.mjs <host> <port> <password> <command...>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packet = (id, type, body) => {
|
||||
const payload = Buffer.from(body, 'utf8');
|
||||
const buf = Buffer.alloc(14 + payload.length);
|
||||
buf.writeInt32LE(10 + payload.length, 0);
|
||||
buf.writeInt32LE(id, 4);
|
||||
buf.writeInt32LE(type, 8);
|
||||
payload.copy(buf, 12);
|
||||
return buf;
|
||||
};
|
||||
|
||||
const socket = net.connect(Number(port), host, () => socket.write(packet(1, 3, password)));
|
||||
let buf = Buffer.alloc(0);
|
||||
let authed = false;
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (buf.length >= 4 && buf.length >= buf.readInt32LE(0) + 4) {
|
||||
const len = buf.readInt32LE(0);
|
||||
const id = buf.readInt32LE(4);
|
||||
const body = buf.toString('utf8', 12, 4 + len - 2);
|
||||
buf = buf.subarray(4 + len);
|
||||
if (!authed) {
|
||||
if (id === -1) {
|
||||
console.error('rcon: authentication failed');
|
||||
process.exit(2);
|
||||
}
|
||||
authed = true;
|
||||
socket.write(packet(2, 2, cmd.join(' ')));
|
||||
} else {
|
||||
if (body) console.log(body);
|
||||
socket.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('error', (e) => {
|
||||
console.error(`rcon: ${e.message}`);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
# Ingest-Simulator
|
||||
|
||||
Erzeugt plausible Ereignisse im echten Protokoll v1 (gleiche Batches, gzip und HMAC-Signatur wie die Mod),
|
||||
damit Backend und Frontend ohne Minecraft-Server ausprobiert werden können.
|
||||
|
||||
```bash
|
||||
npm run sim -w tools/simulator -- \
|
||||
--endpoint http://127.0.0.1:3001 \
|
||||
--token <Ingest-Token> \
|
||||
--secret <INGEST_HMAC_SECRET> \
|
||||
--players 5 --minutes 240 [--realtime]
|
||||
```
|
||||
|
||||
- Startwerte der Zähler stammen aus `backend/test/fixtures/stats/*.json` (echte Serverdaten), weitere Spieler beginnen bei 0.
|
||||
- Erzeugt Sessions, Health-Werte im Minutenraster, Stats-Snapshots alle 5 Minuten, Tode, Advancements, Boss-Kills,
|
||||
Dimensionswechsel und den FTB-Quest-Fortschritt aus dem Fixture.
|
||||
- `--realtime` sendet danach alle 10 Sekunden neue Ereignisse (für die Live-Ansicht).
|
||||
|
||||
Die simulierten Werte sind **Testdaten** und gehören nicht in die Produktionsdatenbank.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@atm/simulator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sim": "tsx src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atm/shared": "*"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
|
||||
Reference in new issue
Block a user