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,111 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
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 { getLeaderboard, getPlayerRanks } from '../src/stats/leaderboards.ts';
|
||||
import { BOARDS } from '../src/stats/metrics.ts';
|
||||
import { takeSnapshots } from '../src/stats/snapshots.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function stats(who: { uuid: string; name: string }, stats: Record<string, Record<string, number>>, ts = T0) {
|
||||
await processEvents(pool, [ev('stats_snapshot', { uuid: who.uuid, name: who.name, full: false, ts, stats })], 'push', 'atm10');
|
||||
}
|
||||
|
||||
describe('boards', () => {
|
||||
it('defines the 14 boards from the spec', () => {
|
||||
expect(BOARDS.map((b) => b.id)).toEqual([
|
||||
'playtime', 'deaths', 'mobkills', 'mined', 'distance', 'flight', 'diamonds',
|
||||
'tech', 'magic', 'farming', 'quests', 'advancements', 'loot', 'worldtier',
|
||||
]);
|
||||
});
|
||||
|
||||
it('playtime converts ticks to hours', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:play_time': 1175736 } });
|
||||
const [top] = await getLeaderboard(pool, 'playtime', 'all');
|
||||
expect(top).toMatchObject({ rank: 1, uuid: ALICE.uuid, name: ALICE.name });
|
||||
expect(top!.value).toBeCloseTo(16.33, 2);
|
||||
});
|
||||
|
||||
it('distance sums all *_one_cm keys except falling, in km', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:custom': {
|
||||
'minecraft:walk_one_cm': 150_000,
|
||||
'minecraft:fly_one_cm': 50_000,
|
||||
'minecraft:fall_one_cm': 999_999,
|
||||
'minecraft:jump': 5,
|
||||
},
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'distance', 'all');
|
||||
expect(top!.value).toBe(2);
|
||||
});
|
||||
|
||||
it('tech sums crafted+used of tech namespaces only', async () => {
|
||||
await stats(ALICE, {
|
||||
'minecraft:crafted': { 'mekanism:steel_casing': 3, 'minecraft:stick': 100, 'ars_nouveau:source_gem': 9 },
|
||||
'minecraft:used': { 'powah:energy_cell_basic': 2 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 50 },
|
||||
});
|
||||
const [top] = await getLeaderboard(pool, 'tech', 'all');
|
||||
expect(top!.value).toBe(5);
|
||||
});
|
||||
|
||||
it('hidden player excluded', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
const rows = await getLeaderboard(pool, 'deaths', 'all');
|
||||
expect(rows.map((r) => r.uuid)).toEqual([ALICE.uuid]);
|
||||
});
|
||||
|
||||
it('ties share rank, ordered by name; zero values omitted', async () => {
|
||||
const carl = { uuid: randomUUID(), name: 'Carl' };
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
|
||||
await stats(carl, { 'minecraft:custom': { 'minecraft:mob_kills': 0 } });
|
||||
const rows = await getLeaderboard(pool, 'mobkills', 'all');
|
||||
expect(rows.map((r) => [r.rank, r.name])).toEqual([
|
||||
[1, 'Bob'],
|
||||
[1, 'CDOGamer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('7d period subtracts the daily snapshot at the period start', async () => {
|
||||
const now = new Date(T0 + 10 * DAY);
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 4 } }, T0);
|
||||
await takeSnapshots(pool, 'day', new Date(T0 + 2 * DAY));
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 10 } }, T0 + 9 * DAY);
|
||||
const [top] = await getLeaderboard(pool, 'deaths', '7d', 10, now);
|
||||
expect(top!.value).toBe(6);
|
||||
});
|
||||
|
||||
it('quests uses team progress and counts only known quests when defs exist', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:jump': 1 } });
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES ($1, 'AAAAAAAAAAAAAAAA', now()), ($1, 'BBBBBBBBBBBBBBBB', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(2);
|
||||
await pool.query(`INSERT INTO quest_defs(quest_id, chapter_id) VALUES ('AAAAAAAAAAAAAAAA', 'C')`);
|
||||
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(1);
|
||||
});
|
||||
|
||||
it('player ranks cover every board the player appears on', async () => {
|
||||
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3, 'minecraft:play_time': 72000 } });
|
||||
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
|
||||
const ranks = await getPlayerRanks(pool, ALICE.uuid);
|
||||
expect(ranks.deaths).toEqual({ rank: 2, value: 3 });
|
||||
expect(ranks.playtime).toEqual({ rank: 1, value: 1 });
|
||||
expect(ranks.magic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user