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,227 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import '../src/pipeline/projections/index.ts';
|
||||
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam, stripFormatting } from '../src/parsers/ftbquests.ts';
|
||||
import { processEvents } from '../src/pipeline/process.ts';
|
||||
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
|
||||
|
||||
const dir = join(import.meta.dirname, 'fixtures', 'ftbquests');
|
||||
const read = (p: string) => readFileSync(join(dir, p), 'utf8');
|
||||
const ALICE_FILE = '308a693c-2bd8-4d6b-8790-670aef5be601.snbt';
|
||||
|
||||
describe('parseQuestProgress', () => {
|
||||
it('parses real progress file 308a…', () => {
|
||||
const p = parseQuestProgress(read(ALICE_FILE));
|
||||
expect(p.teamId).toBe('308a693c-2bd8-4d6b-8790-670aef5be601');
|
||||
expect(p.name).toBe('CDOGamer#308a693c');
|
||||
expect(p.completed.length).toBeGreaterThan(0);
|
||||
for (const q of p.completed) {
|
||||
expect(q.questId).toMatch(/^[0-9A-F]{16}$/);
|
||||
expect(q.at).toBeGreaterThan(1.7e12);
|
||||
}
|
||||
expect(p.completed.find((q) => q.questId === '3BC0A50886A3222B')?.at).toBe(1791559740996);
|
||||
expect(p.started).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('parses the second real file', () => {
|
||||
expect(parseQuestProgress(read('c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt')).teamId).toBe(
|
||||
'c25d9ff0-dba3-48f0-b414-fad2b63473a9',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const CHAPTER = { path: 'config/ftbquests/quests/chapters/getting_started.snbt', content: read('config/chapters/getting_started.snbt') };
|
||||
const LANG_CHAPTERS = { path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged', content: read('config/lang/en_us/chapter.snbt_merged') };
|
||||
const LANG_QUESTS = {
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: read('config/lang/en_us/chapters/getting_started.snbt_merged'),
|
||||
};
|
||||
|
||||
describe('parseQuestDefs', () => {
|
||||
it('reads chapters with titles from the split en_us lang files (ftbquestslangsplitter layout)', () => {
|
||||
const defs = parseQuestDefs([CHAPTER, LANG_CHAPTERS, LANG_QUESTS]);
|
||||
expect(defs).toEqual([
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '3BC0A50886A3222B', title: 'Punch a Tree' },
|
||||
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '33218EFDD498E9DB', title: 'From Lang' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('also reads a single en_us.snbt and ignores other languages', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{ path: 'config/ftbquests/quests/lang/en_us.snbt', content: '{ quest.3BC0A50886A3222B.title: "Single File" }' },
|
||||
{ path: 'config/ftbquests/quests/lang/de_de/chapters/getting_started.snbt_merged', content: '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }' },
|
||||
]);
|
||||
expect(defs[0]!.title).toBe('Single File');
|
||||
});
|
||||
|
||||
it('falls back to inline title and filename when no lang file exists', () => {
|
||||
const defs = parseQuestDefs([CHAPTER]);
|
||||
expect(defs.map((d) => [d.chapterTitle, d.title])).toEqual([
|
||||
['getting_started', null],
|
||||
['getting_started', 'Inline Title'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuestConfigFile', () => {
|
||||
it('keeps chapters and English texts, skips other languages, reward tables and the big combined file', () => {
|
||||
const wanted = [
|
||||
'config/ftbquests/quests/chapters/aether.snbt',
|
||||
'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us/chapters/aether.snbt_merged',
|
||||
];
|
||||
const skipped = [
|
||||
'config/ftbquests/quests/lang/de_de/chapters/aether.snbt_merged',
|
||||
'config/ftbquests/quests/lang/en_us.snbt',
|
||||
'config/ftbquests/quests/reward_tables/loot.snbt',
|
||||
'config/ftbquests/quests/data.snbt',
|
||||
];
|
||||
expect(wanted.filter(isQuestConfigFile)).toEqual(wanted);
|
||||
expect(skipped.filter(isQuestConfigFile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTeam', () => {
|
||||
const team = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', p), 'utf8');
|
||||
|
||||
it('reads a player team (real structure)', () => {
|
||||
expect(parseTeam(team('player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt'))).toEqual({
|
||||
teamId: '308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
type: 'player',
|
||||
name: 'CDOGamer',
|
||||
members: ['308a693c-2bd8-4d6b-8790-670aef5be601'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a party team; invited players and allies are not members', () => {
|
||||
const t = parseTeam(team('party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt'));
|
||||
expect(t.type).toBe('party');
|
||||
expect(t.name).toBe('Die Techniker');
|
||||
expect(t.members.sort()).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file_sync projection', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
afterAll(closeTestPool);
|
||||
|
||||
const fileEvent = (path: string, content: string) => ({
|
||||
id: randomUUID(),
|
||||
type: 'file_sync' as const,
|
||||
ts: Date.now(),
|
||||
path,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
content,
|
||||
});
|
||||
|
||||
it('stores quest progress for the team', async () => {
|
||||
await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, read(ALICE_FILE))], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM quest_progress WHERE team_id = $1`, [
|
||||
'308a693c-2bd8-4d6b-8790-670aef5be601',
|
||||
]);
|
||||
expect(rows[0].n).toBe(parseQuestProgress(read(ALICE_FILE)).completed.length);
|
||||
});
|
||||
|
||||
it('rebuilds quest_defs from all cached config files, also across batches', async () => {
|
||||
await processEvents(pool, [fileEvent(CHAPTER.path, CHAPTER.content)], 'push', 'atm10');
|
||||
await processEvents(pool, [fileEvent(LANG_CHAPTERS.path, LANG_CHAPTERS.content), fileEvent(LANG_QUESTS.path, LANG_QUESTS.content)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT quest_id, title, chapter_title FROM quest_defs ORDER BY quest_id`);
|
||||
expect(rows).toEqual([
|
||||
{ quest_id: '33218EFDD498E9DB', title: 'From Lang', chapter_title: 'Getting Started' },
|
||||
{ quest_id: '3BC0A50886A3222B', title: 'Punch a Tree', chapter_title: 'Getting Started' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('party membership decides whose quest progress a player gets', async () => {
|
||||
const party = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'party', '5e0c1a2b-0000-4000-8000-00000000abcd.snbt'), 'utf8');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', party)], 'push', 'atm10');
|
||||
const { rows } = await pool.query(`SELECT t.name, t.type, array_agg(m.uuid::text ORDER BY m.uuid) AS members
|
||||
FROM teams t JOIN team_members m USING (team_id) GROUP BY t.name, t.type`);
|
||||
expect(rows).toEqual([
|
||||
{ name: 'Die Techniker', type: 'party', members: ['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9'] },
|
||||
]);
|
||||
// leaving the party: a newer file without Bob
|
||||
const smaller = party.replace(/\t\tc25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"\n/, '');
|
||||
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', smaller)], 'push', 'atm10');
|
||||
const after = await pool.query(`SELECT uuid::text FROM team_members`);
|
||||
expect(after.rows.map((r) => r.uuid)).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601']);
|
||||
});
|
||||
|
||||
it('player team files set the team name but no party membership', async () => {
|
||||
const player = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'player', `${ALICE_FILE}`), 'utf8');
|
||||
await processEvents(pool, [fileEvent(`world/ftbteams/player/${ALICE_FILE}`, player)], 'push', 'atm10');
|
||||
expect((await pool.query(`SELECT name, type FROM teams`)).rows).toEqual([{ name: 'CDOGamer', type: 'player' }]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM team_members`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it('a broken file is cached but does not fail the batch', async () => {
|
||||
const res = await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, '{ broken')], 'push', 'atm10');
|
||||
expect(res.accepted).toHaveLength(1);
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM app_errors`);
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quest reset', () => {
|
||||
let pool: Pool;
|
||||
beforeEach(async () => {
|
||||
pool = await getTestPool();
|
||||
await resetDb(pool);
|
||||
});
|
||||
|
||||
it('quests missing from a newer progress file are no longer completed', async () => {
|
||||
const file = (completed: string) =>
|
||||
`{\n\tversion: 1\n\tuuid: "308a693c2bd84d6b8790670aef5be601"\n\tname: "T"\n\tcompleted: {\n${completed}\n\t}\n}\n`;
|
||||
const send = (content: string) =>
|
||||
processEvents(
|
||||
pool,
|
||||
[{ id: randomUUID(), type: 'file_sync', ts: Date.now(), path: `world/ftbquests/${ALICE_FILE}`, sha256: createHash('sha256').update(content).digest('hex'), content }],
|
||||
'push',
|
||||
'atm10',
|
||||
);
|
||||
await send(file('\t\tAAAAAAAAAAAAAAAA: 1L\n\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
await send(file('\t\tBBBBBBBBBBBBBBBB: 2L'));
|
||||
const { rows } = await pool.query(`SELECT quest_id FROM quest_progress`);
|
||||
expect(rows.map((r) => r.quest_id)).toEqual(['BBBBBBBBBBBBBBBB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting codes', () => {
|
||||
it('strips Minecraft colour and style codes from chapter and quest titles', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "&aChapter 4&r: &dCreative" \n quest.3BC0A50886A3222B.title: "&6&lATM Star§r" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]).toMatchObject({ chapterTitle: 'Chapter 4: Creative', title: 'ATM Star' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped ampersand', () => {
|
||||
it('keeps a literal "&" that FTB escapes as backslash-ampersand', () => {
|
||||
// real lang file: "Forbidden \\& Arcanus" in SNBT, i.e. the parsed string contains a backslash before "&"
|
||||
expect(stripFormatting('Forbidden \\& Arcanus')).toBe('Forbidden & Arcanus');
|
||||
expect(stripFormatting('&6Gold \\&b &lBold')).toBe('Gold &b Bold');
|
||||
});
|
||||
|
||||
it('decodes the escape through the real SNBT path', () => {
|
||||
const defs = parseQuestDefs([
|
||||
CHAPTER,
|
||||
{
|
||||
path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
|
||||
content: '{ chapter.1A2B3C4D5E6F7081.title: "Forbidden \\\\& Arcanus" }',
|
||||
},
|
||||
]);
|
||||
expect(defs[0]!.chapterTitle).toBe('Forbidden & Arcanus');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user