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'); }); });