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,182 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { makeApp } from './helpers/app.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
|
||||
|
||||
let pool: Pool;
|
||||
let app: FastifyInstance;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await app?.close();
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
({ app } = await makeApp(pool));
|
||||
const now = Date.now();
|
||||
await processEvents(
|
||||
pool,
|
||||
[
|
||||
ev('player_join', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY }),
|
||||
ev('player_leave', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY + 30 * MIN }),
|
||||
ev('player_join', { ...ALICE, ts: now - 10 * MIN }),
|
||||
ev('dimension_change', { ...ALICE, ts: now - 5 * MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }),
|
||||
ev('player_join', { ...BOB, ts: now - 2 * MIN }),
|
||||
ev('stats_snapshot', {
|
||||
...ALICE,
|
||||
full: false,
|
||||
ts: now - MIN,
|
||||
stats: {
|
||||
'minecraft:custom': { 'minecraft:play_time': 144000, 'minecraft:deaths': 2, 'minecraft:mob_kills': 5, 'minecraft:walk_one_cm': 1000 },
|
||||
'minecraft:mined': { 'mekanism:osmium_ore': 30, 'mekanism:steel_casing': 1, 'minecraft:stone': 99 },
|
||||
'minecraft:killed': { 'minecraft:zombie': 4 },
|
||||
},
|
||||
}),
|
||||
ev('stats_snapshot', { ...BOB, full: false, ts: now - MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 7 } } }),
|
||||
ev('player_death', { ...ALICE, ts: now - 4 * MIN, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
|
||||
ev('advancement', { ...ALICE, ts: now - 3 * MIN, advancementId: 'minecraft:story/enter_the_nether', title: 'We Need to Go Deeper' }),
|
||||
],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await closeTestPool();
|
||||
});
|
||||
|
||||
const get = async (url: string) => {
|
||||
const res = await app.inject({ method: 'GET', url });
|
||||
return { status: res.statusCode, body: res.statusCode < 500 ? res.json() : res.body, headers: res.headers };
|
||||
};
|
||||
|
||||
describe('public api', () => {
|
||||
it('overview counts', async () => {
|
||||
const { status, body, headers } = await get('/api/overview');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ online: 2, totalPlayers: 2, totalPlayHours: 2, totalDeaths: 9, totalMobKills: 5, advancements: 1 });
|
||||
expect(headers['cache-control']).toBe('public, max-age=15');
|
||||
});
|
||||
|
||||
it('online lists open sessions only, with current dimension', async () => {
|
||||
const { body } = await get('/api/online');
|
||||
expect(body.players.map((p: { name: string }) => p.name).sort()).toEqual(['Bob', 'CDOGamer']);
|
||||
expect(body.players.find((p: { name: string }) => p.name === 'CDOGamer').dimension).toBe('minecraft:the_nether');
|
||||
});
|
||||
|
||||
it('profile by uuid, current name and old name', async () => {
|
||||
for (const key of [ALICE.uuid, 'cdogamer', 'OldAlice']) {
|
||||
const { status, body } = await get(`/api/players/${key}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body.uuid).toBe(ALICE.uuid);
|
||||
}
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2, sessions: 2 });
|
||||
expect(body.movement).toEqual({ 'minecraft:walk_one_cm': 1000 });
|
||||
expect(body.general['minecraft:deaths']).toBe(2);
|
||||
expect(body.deathsByCause).toEqual([{ cause: 'minecraft:lava', count: 1 }]);
|
||||
expect(body.killsByType).toEqual([{ entity: 'minecraft:zombie', count: 4 }]);
|
||||
expect(body.advancements[0].title).toBe('We Need to Go Deeper');
|
||||
expect(body.ranks.deaths).toEqual({ rank: 2, value: 2 });
|
||||
expect(body.activityHeatmap).toHaveLength(7);
|
||||
expect(body.activityHeatmap[0]).toHaveLength(24);
|
||||
expect(body.activityHeatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBeGreaterThanOrEqual(39);
|
||||
});
|
||||
|
||||
it('unknown player is 404', async () => {
|
||||
expect((await get('/api/players/Nobody')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('hidden player is 404 and absent from leaderboards, online and overview', async () => {
|
||||
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
|
||||
expect((await get(`/api/players/${BOB.uuid}`)).status).toBe(404);
|
||||
expect((await get('/api/online')).body.players.map((p: { uuid: string }) => p.uuid)).toEqual([ALICE.uuid]);
|
||||
const lb = (await get('/api/leaderboards/deaths')).body;
|
||||
expect(lb.entries.map((e: { uuid: string }) => e.uuid)).toEqual([ALICE.uuid]);
|
||||
expect((await get('/api/overview')).body).toMatchObject({ online: 1, totalPlayers: 1, totalDeaths: 2 });
|
||||
expect((await get('/api/players?search=bo')).body.players).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaderboards list has every board with top entries', async () => {
|
||||
const { body } = await get('/api/leaderboards');
|
||||
expect(body.boards).toHaveLength(14);
|
||||
expect(body.boards.find((b: { id: string }) => b.id === 'deaths').top[0].name).toBe('Bob');
|
||||
expect((await get('/api/leaderboards/nope')).status).toBe(404);
|
||||
expect((await get('/api/leaderboards/deaths?period=1y')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('player search and paging', async () => {
|
||||
const { body } = await get('/api/players?search=cdo');
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.players[0]).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2 });
|
||||
});
|
||||
|
||||
it('items grouped by namespace', async () => {
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/items?category=mined`);
|
||||
expect(body.mods.map((m: { namespace: string; total: number }) => [m.namespace, m.total])).toEqual([
|
||||
['minecraft', 99],
|
||||
['mekanism', 31],
|
||||
]);
|
||||
expect((await get(`/api/players/${ALICE.uuid}/items?category=bogus`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('history returns daily points and the current value', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value) VALUES ($1, 'deaths', date_trunc('day', now() - interval '2 days'), 'day', 1)`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}/history?metric=deaths&range=30d`);
|
||||
expect(body.unit).toBe('count');
|
||||
expect(body.points.map((p: { v: number }) => p.v)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('server and mods expose versions from server_start', async () => {
|
||||
await processEvents(
|
||||
pool,
|
||||
[ev('server_start', { ts: T0, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.5', mods: [{ id: 'mekanism', version: '10.7.19', name: 'Mekanism' }] })],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
const mods = (await get('/api/mods')).body;
|
||||
expect(mods.server).toMatchObject({ mcVersion: '1.21.1', neoforgeVersion: '21.1.219', modCount: 1 });
|
||||
expect(mods.mods[0]).toMatchObject({ id: 'mekanism', activity: 31, category: 'tech' });
|
||||
expect((await get('/api/server?range=24h')).body.info.javaVersion).toBe('21.0.5');
|
||||
});
|
||||
|
||||
it('quests and sources respond', async () => {
|
||||
expect((await get('/api/quests')).body).toMatchObject({ defsKnown: false, chapters: [] });
|
||||
const src = (await get('/api/sources')).body;
|
||||
expect(src.push.reachable).toBe(true);
|
||||
expect(src.sftp.enabled).toBe(false);
|
||||
expect(src.limitations.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('avatar rejects invalid uuid with 400', async () => {
|
||||
expect((await get('/api/avatar/not-a-uuid')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest titles in the profile', () => {
|
||||
it('lists recently completed quests with titles, newest first, only known quests', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title) VALUES
|
||||
('AAAAAAAAAAAAAAAA', 'C1', 'Getting Started', 'Punch a Tree'),
|
||||
('BBBBBBBBBBBBBBBB', 'C1', 'Getting Started', NULL),
|
||||
('CCCCCCCCCCCCCCCC', 'C1', 'Getting Started', 'Craft a Table')`,
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES
|
||||
($1, 'AAAAAAAAAAAAAAAA', now() - interval '2 hours'),
|
||||
($1, 'BBBBBBBBBBBBBBBB', now() - interval '1 hour'),
|
||||
($1, 'CCCCCCCCCCCCCCCC', now() - interval '30 minutes'),
|
||||
($1, 'DDDDDDDDDDDDDDDD', now())`,
|
||||
[ALICE.uuid],
|
||||
);
|
||||
const { body } = await get(`/api/players/${ALICE.uuid}`);
|
||||
expect(body.quests.completed).toBe(3);
|
||||
expect(body.quests.recent.map((q: { title: string }) => q.title)).toEqual(['Craft a Table', 'Punch a Tree']);
|
||||
expect(body.quests.recent[0]).toMatchObject({ chapterTitle: 'Getting Started' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user