#!/usr/bin/env node /** * Build content/index.json from individual mission files. * Usage: node content/build-index.mjs */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const missionsDir = join(__dirname, 'missions'); const outputPath = join(__dirname, 'index.json'); // Read all mission JSON files const files = readdirSync(missionsDir) .filter(f => f.endsWith('.json')) .sort(); // B1..B12, V1..V6 (lexicographic works for our IDs) console.log(`Found ${files.length} mission files.`); const missions = []; for (const file of files) { const filePath = join(missionsDir, file); const raw = readFileSync(filePath, 'utf-8'); const mission = JSON.parse(raw); missions.push(mission); console.log(` āœ“ ${mission.id}: ${mission.titleIt} (${mission.chunks.length} chunks, ${mission.dialogues.length} dialogues)`); } // Validate basic structure for (const m of missions) { if (!m.id || !m.trail || !m.cefr || !m.titleIt || !m.titlePt) { throw new Error(`Mission ${m.id || '?'} missing required fields`); } if (!Array.isArray(m.canDos) || m.canDos.length < 2) { throw new Error(`Mission ${m.id}: needs >=2 canDos`); } if (!Array.isArray(m.chunks) || m.chunks.length < 12) { throw new Error(`Mission ${m.id}: needs >=12 chunks, got ${m.chunks?.length}`); } if (!Array.isArray(m.dialogues) || m.dialogues.length !== 2) { throw new Error(`Mission ${m.id}: needs exactly 2 dialogues`); } for (let i = 0; i < m.dialogues.length; i++) { const d = m.dialogues[i]; if (d.length < 6 || d.length > 10) { throw new Error(`Mission ${m.id} dialogue ${i+1}: needs 6-10 lines, got ${d.length}`); } } if (!m.roleplay || !m.roleplay.settingIt || !m.roleplay.openerIt) { throw new Error(`Mission ${m.id}: incomplete roleplay`); } if (!Array.isArray(m.survivalChecklist) || m.survivalChecklist.length < 3) { throw new Error(`Mission ${m.id}: needs >=3 survivalChecklist items`); } // Check each chunk has audioKey for (const c of m.chunks) { if (!c.it || !c.pt || !c.audioKey) { throw new Error(`Mission ${m.id}: chunk missing it/pt/audioKey`); } } // Check each dialogue line has audioKey for (const d of m.dialogues) { for (const line of d) { if (!line.speaker || !line.it || !line.pt || !line.audioKey) { throw new Error(`Mission ${m.id}: dialogue line missing fields`); } } } } const bundle = { schemaVersion: 1, missions }; writeFileSync(outputPath, JSON.stringify(bundle, null, 2), 'utf-8'); console.log(`\nāœ… Wrote ${outputPath} with ${missions.length} missions.`); // Summary stats const totalChunks = missions.reduce((s, m) => s + m.chunks.length, 0); const totalDialogues = missions.reduce((s, m) => s + m.dialogues.length, 0); const totalLines = missions.reduce((s, m) => s + m.dialogues.reduce((a, d) => a + d.length, 0), 0); console.log(` Total chunks: ${totalChunks}`); console.log(` Total dialogues: ${totalDialogues}`); console.log(` Total dialogue lines: ${totalLines}`);