Files
lucashahmannandClaude Opus 5.5 e66f89f49e 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>
2026-10-13 09:33:25 +02:00

173 lines
7.5 KiB
TypeScript

import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
afterAll(closeTestPool);
const push = (...events: Parameters<typeof processEvents>[1]) => processEvents(pool, events, 'push', 'atm10');
const ms = (d: Date) => d.getTime();
describe('sessions', () => {
it('join then leave creates closed session with duration', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_leave', { ...ALICE, ts: T0 + 30 * MIN }));
const { rows } = await pool.query('SELECT started_at, ended_at, end_reason FROM sessions');
expect(rows).toHaveLength(1);
expect(ms(rows[0].ended_at) - ms(rows[0].started_at)).toBe(30 * MIN);
expect(rows[0].end_reason).toBe('leave');
});
it('closes dangling sessions on server_start', async () => {
await push(
ev('player_join', { ...ALICE, ts: T0 }),
ev('health', {
ts: T0 + 10 * MIN,
windowStart: T0 + 9 * MIN,
windowEnd: T0 + 10 * MIN,
msptAvg: 30,
msptP95: 40,
msptMax: 50,
tps: 20,
players: 1,
chunks: 100,
heapUsedMb: 1000,
heapMaxMb: 4000,
}),
);
await push(
ev('server_start', { ts: T0 + 60 * MIN, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21', mods: [] }),
);
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
expect(ms(rows[0].ended_at)).toBe(T0 + 10 * MIN);
expect(rows[0].end_reason).toBe('crash');
const info = await pool.query('SELECT mc_version FROM server_info');
expect(info.rows[0].mc_version).toBe('1.21.1');
});
it('server_stop closes open sessions', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('server_stop', { ts: T0 + 5 * MIN }));
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
expect(ms(rows[0].ended_at)).toBe(T0 + 5 * MIN);
expect(rows[0].end_reason).toBe('stop');
});
it('second join closes the previous open session as superseded', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_join', { ...ALICE, ts: T0 + 5 * MIN }));
const { rows } = await pool.query('SELECT end_reason FROM sessions ORDER BY started_at');
expect(rows.map((r) => r.end_reason)).toEqual(['superseded', null]);
});
});
describe('players', () => {
it('rename updates name and history', async () => {
await push(ev('player_join', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + MIN }));
const p = await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid]);
expect(p.rows[0].name).toBe('NewName');
const h = await pool.query('SELECT name FROM name_history WHERE uuid = $1 ORDER BY seen_at', [ALICE.uuid]);
expect(h.rows.map((r) => r.name)).toEqual(['OldName', 'NewName']);
});
});
describe('stats', () => {
const snap = (ts: number, value: number, source: 'push' | 'sftp' = 'push') =>
processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: ALICE.name, full: false, ts, stats: { 'minecraft:custom': { 'minecraft:deaths': value } } })],
source,
'atm10',
);
const deaths = async () =>
(await pool.query(`SELECT value FROM stat_current WHERE key = 'minecraft:deaths'`)).rows[0]?.value;
it('newer observation wins even if smaller', async () => {
await snap(T0 + 2 * MIN, 5);
await snap(T0 + 1 * MIN, 9, 'sftp');
expect(await deaths()).toBe(5);
await snap(T0 + 3 * MIN, 3);
expect(await deaths()).toBe(3);
});
it('full snapshot does not zero missing keys', async () => {
await processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 7 } } })],
'push',
'atm10',
);
await processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: true, ts: T0 + MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 1 } } })],
'sftp',
'atm10',
);
const { rows } = await pool.query(`SELECT key, value FROM stat_current ORDER BY key`);
expect(rows).toEqual([
{ key: 'minecraft:deaths', value: 1 },
{ key: 'minecraft:jump', value: 7 },
]);
});
it('creates unknown player without name and fills the name later', async () => {
await processEvents(
pool,
[ev('stats_snapshot', { uuid: BOB.uuid, name: null, full: true, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 1 } } })],
'sftp',
'atm10',
);
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBeNull();
await push(ev('player_join', { ...BOB, ts: T0 + MIN }));
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBe('Bob');
});
});
describe('advancements, combat, dimensions, health', () => {
it('advancement keeps earliest timestamp', async () => {
await push(ev('advancement', { ...ALICE, ts: T0 + MIN, advancementId: 'a:b', title: null }));
await push(ev('advancement', { ...ALICE, ts: T0, advancementId: 'a:b', title: 'Title' }));
const { rows } = await pool.query('SELECT achieved_at, title FROM advancements');
expect(ms(rows[0].achieved_at)).toBe(T0);
expect(rows[0].title).toBe('Title');
});
it('death and boss kill are stored', async () => {
await push(
ev('player_death', { ...ALICE, ts: T0, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
ev('entity_kill', { ...ALICE, ts: T0, dimension: 'minecraft:the_end', victimType: 'minecraft:ender_dragon', isBoss: true, weapon: null }),
);
expect((await pool.query('SELECT cause FROM deaths')).rows[0].cause).toBe('minecraft:lava');
expect((await pool.query('SELECT is_boss FROM kills')).rows[0].is_boss).toBe(true);
});
it('dimension visits keep first visit', async () => {
await push(ev('dimension_change', { ...ALICE, ts: T0, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
await push(ev('dimension_change', { ...ALICE, ts: T0 + MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
const { rows } = await pool.query('SELECT dimension, first_at FROM dimension_visits ORDER BY dimension');
expect(rows.map((r) => r.dimension)).toEqual(['minecraft:overworld', 'minecraft:the_nether']);
expect(ms(rows[1].first_at)).toBe(T0);
});
it('health stored per minute bucket', async () => {
const h = { msptAvg: 30, msptP95: 40, msptMax: 50, tps: 20, players: 1, chunks: 100, heapUsedMb: 1000, heapMaxMb: 4000 };
await push(ev('health', { ...h, ts: T0 + MIN, windowStart: T0 + 5_000, windowEnd: T0 + MIN }));
const { rows } = await pool.query('SELECT bucket, tps FROM server_health');
expect(ms(rows[0].bucket)).toBe(T0);
expect(rows[0].tps).toBe(20);
});
});
describe('review fixes', () => {
it('a late event with an older name does not revert a rename', async () => {
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + 10 * MIN }));
await push(ev('player_leave', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
expect((await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid])).rows[0].name).toBe('NewName');
});
});