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,172 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user