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>
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|