import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ScenarioModel } from '../model/scenario-model.js'; import { DEFAULT_QUEST_CONFIG, getTypePrefix, serializeQuestConfig } from '../model/component-types.js'; import { writeIni } from '../io/ini-writer.js'; import { buildPackage } from '../io/package-builder.js'; import { parseLocalization } from '../io/localization-io.js'; import { getSharedCatalog } from '../catalogs/catalog-store.js'; import { parseRefList } from '../model/component-types.js'; const DATA_FILES = ['events.ini', 'tiles.ini', 'tokens.ini', 'spawns.ini', 'items.ini', 'ui.ini', 'other.ini'] as const; /** * Get the Valkyrie editor directory for MoM scenarios. * * Platform paths (mirrors Game.DefaultAppData() + "/MoM/Editor" in Valkyrie C#): * macOS/Linux: ~/.config/Valkyrie/MoM/Editor * Windows: %APPDATA%/Valkyrie/MoM/Editor */ export function getEditorDir(): string { const platform = os.platform(); let appData: string; if (platform === 'win32') { appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); } else { appData = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'); } return path.join(appData, 'Valkyrie', 'MoM', 'Editor'); } export interface ScenarioSummary { name: string; dir: string; questName: string; } /** List all scenarios in the Valkyrie editor directory */ export function listScenarios(editorDir?: string): ScenarioSummary[] { const dir = editorDir ?? getEditorDir(); if (!fs.existsSync(dir)) return []; const entries = fs.readdirSync(dir, { withFileTypes: true }); const results: ScenarioSummary[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const scenarioDir = path.join(dir, entry.name); const questPath = path.join(scenarioDir, 'quest.ini'); if (!fs.existsSync(questPath)) continue; // Try to read quest name from localization let questName = entry.name; const locPath = path.join(scenarioDir, 'Localization.English.txt'); if (fs.existsSync(locPath)) { try { const parsed = parseLocalization(fs.readFileSync(locPath, 'utf-8')); questName = parsed.entries.get('quest.name') ?? entry.name; } catch { /* use folder name */ } } results.push({ name: entry.name, dir: scenarioDir, questName }); } return results; } export interface ScenarioState { totalComponents: number; componentCounts: Record; localizationKeys: number; } /** Creates new scenario directory with quest.ini + empty data files */ export async function createScenario( name: string, options?: { dir?: string }, ): Promise<{ model: ScenarioModel; dir: string }> { const dir = options?.dir ?? name; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const model = new ScenarioModel(undefined, dir); // Write quest.ini const questSections: Record> = { Quest: serializeQuestConfig(DEFAULT_QUEST_CONFIG), }; // New scenarios start empty — no data files listed or written. // They get populated on the first saveScenario() call. const bareKeySections: Record = { QuestText: ['Localization.English.txt'], QuestData: [], }; fs.writeFileSync(path.join(dir, 'quest.ini'), writeIni(questSections, bareKeySections)); // Write localization file with common keys const commonKeys: Record = { CONTINUE: 'Continue', PASS: 'Pass', FAIL: 'Fail' }; for (const [k, v] of Object.entries(commonKeys)) { model.localization.set(k, v); } fs.writeFileSync(path.join(dir, 'Localization.English.txt'), model.localization.toCSV()); return { model, dir }; } /** Loads existing scenario from directory */ export async function loadScenario(dir: string): Promise { const questIni = fs.readFileSync(path.join(dir, 'quest.ini'), 'utf-8'); const dataFiles: Record = {}; for (const f of DATA_FILES) { const filePath = path.join(dir, f); if (fs.existsSync(filePath)) { dataFiles[f] = fs.readFileSync(filePath, 'utf-8'); } } // Find English localization file let locContent: string | undefined; const locPath = path.join(dir, 'Localization.English.txt'); if (fs.existsSync(locPath)) { locContent = fs.readFileSync(locPath, 'utf-8'); } const model = ScenarioModel.loadFromData(questIni, dataFiles, locContent); model.scenarioDir = dir; return model; } /** Returns current scenario state summary */ export function getScenarioState(model: ScenarioModel): ScenarioState { const all = model.getAll(); const counts: Record = {}; for (const comp of all) { const prefix = getTypePrefix(comp.name); counts[prefix] = (counts[prefix] ?? 0) + 1; } return { totalComponents: all.length, componentCounts: counts, localizationKeys: model.localization.size, }; } /** Saves model to disk */ export async function saveScenario(model: ScenarioModel): Promise { const dir = model.scenarioDir; if (!dir) throw new Error('Model has no scenarioDir set'); // Serialize components by file const iniData = model.serializeToIniData(); // Only write data files that have components; delete stale empty files const populatedFiles: string[] = []; for (const f of DATA_FILES) { const sections = iniData[f]; const filePath = path.join(dir, f); if (sections && Object.keys(sections).length > 0) { fs.writeFileSync(filePath, writeIni(sections)); populatedFiles.push(f); } else { // Remove stale empty file from a prior save if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } } // Auto-compute required packs from tile sides and monsters const catalog = getSharedCatalog(); const requiredPacks = new Set(); for (const comp of model.getByType('Tile')) { const side = comp.data.side; if (!side) continue; const pack = catalog.getPackForTileSide(side); if (pack && pack !== 'MoMBase') requiredPacks.add(pack); } for (const comp of model.getByType('Spawn')) { const monsterField = comp.data.monster; if (!monsterField) continue; for (const name of parseRefList(monsterField)) { const pack = catalog.getPackForMonster(name); if (pack && pack !== 'MoMBase') requiredPacks.add(pack); } } if (requiredPacks.size > 0) { model.questConfig.packs = [...requiredPacks].sort().join(' '); } // Write quest.ini — only list populated data files const questSections: Record> = { Quest: serializeQuestConfig(model.questConfig), }; const bareKeySections: Record = { QuestText: ['Localization.English.txt'], QuestData: populatedFiles, }; fs.writeFileSync(path.join(dir, 'quest.ini'), writeIni(questSections, bareKeySections)); // Write localization const locContent = model.localization.toCSV(); fs.writeFileSync(path.join(dir, 'Localization.English.txt'), locContent); } /** Builds .valkyrie package */ export async function buildScenario(model: ScenarioModel, outputPath: string): Promise { const dir = model.scenarioDir; if (!dir) throw new Error('Model has no scenarioDir set'); await buildPackage(dir, outputPath); }