Newer
Older
rpg / apps / game / tools / dialogue-editor / server.mjs
/**
 * Сервер визуального редактора диалогов (D8): node http без зависимостей.
 * Отдаёт статику (index.html/editor.js/editor.css), читает и пишет графы
 * в data/dialogues/*.json (атомарно, с mtime-гардом от конкурентной правки),
 * валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts —
 * правила одни (dialogueRules.ts), дублирования нет.
 *
 * Запуск: npm run dialogues [-- --port 5299]
 */
import http from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, writeFile, rename, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

/** Корень монорепо (server.mjs — на 4 уровня ниже корня). */
const ROOT = fileURLToPath(new URL('../../../..', import.meta.url));
const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues');
const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor');
const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node');
/** Порт редактора: --port N (иначе 5299; строка `??` после indexOf(-1) дала бы NaN). */
const portIdx = process.argv.indexOf('--port');
const PORT =
    portIdx !== -1 && Number.isFinite(Number(process.argv[portIdx + 1]))
        ? Number(process.argv[portIdx + 1])
        : 5299;

const JSON_SPACE = 4;

/** Спавн vite-node (TS-импорты из src): stdout — последняя строка (JSON). */
function viteNode(args, input) {
    return new Promise((resolve, reject) => {
        const p = spawn(VITE_NODE, args, { cwd: ROOT });
        let out = '';
        let err = '';
        if (input !== undefined) {
            p.stdin.write(input);
        }
        p.stdin.end();
        p.stdout.on('data', (c) => (out += c));
        p.stderr.on('data', (c) => (err += c));
        p.on('error', reject);
        p.on('close', (code) => {
            const line = out.trim().split('\n').filter(Boolean).pop() ?? '';
            try {
                resolve(JSON.parse(line));
            } catch {
                reject(new Error(`probe упал (code ${code}): ${err.slice(-500) || line.slice(-200)}`));
            }
        });
    });
}

/** Валидация графа (probe.ts): Invariants + анализ достижимости. */
function probe(graph) {
    return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'validate', graph }));
}

/** Dry-run графа на пресете состояния. */
function dryRun(graph, preset) {
    return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'dry-run', graph, preset }));
}

/** Список графов: id + mtime (для гарандов конкурентной правки). */
async function listGraphs() {
    const files = (await readdir(DIALOGUES_DIR)).filter((f) => f.endsWith('.json')).sort();
    const out = [];
    for (const f of files) {
        const id = f.replace(/\.json$/, '');
        out.push({ id, mtime: (await stat(path.join(DIALOGUES_DIR, f))).mtimeMs });
    }
    return out;
}

/** Канонический формат файла: 4 пробела + \\n в конце. */
function serialize(graph) {
    return JSON.stringify(graph, null, 4) + '\n';
}

const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };

/** Ответ хелпером: JSON / текст / буфер статики / ошибка. */
function send(res, code, body, type = 'application/json; charset=utf-8') {
    res.writeHead(code, { 'Content-Type': type });
    if (Buffer.isBuffer(body) || typeof body === 'string') res.end(body);
    else res.end(JSON.stringify(body));
}

/** Чтение тела запроса (JSON, лимит 2 МБ — графы маленькие). */
function readBody(req) {
    return new Promise((resolve, reject) => {
        let data = '';
        req.on('data', (c) => {
            data += c;
            if (data.length > 2 * 1024 * 1024) req.destroy();
        });
        req.on('end', () => {
            try {
                resolve(JSON.parse(data || '{}'));
            } catch (e) {
                reject(new Error(`тело не JSON: ${e.message}`));
            }
        });
    });
}

/** PUT графа: mtime-гард -> валидация -> атомарная запись. */
async function putGraph(id, body) {
    const file = path.join(DIALOGUES_DIR, `${id}.json`);
    let current;
    try {
        current = (await stat(file)).mtimeMs;
    } catch {
        return { code: 404, body: { message: `граф «${id}» не найден` } };
    }
    if (typeof body.mtime !== 'number' || body.mtime !== current) {
        // Конкурентная правка: отдаём свежий mtime — клиент предложит перечитать.
        return { code: 409, body: { message: 'файл изменён на диске', mtime: current } };
    }
    const probeOut = await probe(body.graph);
    if (probeOut.errors.length > 0) {
        return { code: 422, body: probeOut };
    }
    const tmp = `${file}.tmp`;
    await writeFile(tmp, serialize(body.graph));
    await rename(tmp, file);
    return { code: 200, body: { ok: true, mtime: (await stat(file)).mtimeMs } };
}

const server = http.createServer(async (req, res) => {
    const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
    try {
        if (url.pathname === '/api/graphs' && req.method === 'GET') {
            return send(res, 200, await listGraphs());
        }
        const graphMatch = url.pathname.match(/^\/api\/graph\/(\w+)$/);
        if (graphMatch) {
            const id = graphMatch[1];
            const file = path.join(DIALOGUES_DIR, `${id}.json`);
            if (req.method === 'GET') {
                const data = await readFile(file, 'utf8');
                return send(res, 200, { id, mtime: (await stat(file)).mtimeMs, graph: JSON.parse(data) });
            }
            if (req.method === 'PUT') {
                const { code, body } = await putGraph(id, await readBody(req));
                return send(res, code, body);
            }
        }
        if (url.pathname === '/api/dry-run' && req.method === 'POST') {
            const { graph, preset } = await readBody(req);
            return send(res, 200, await dryRun(graph, preset));
        }
        if (url.pathname === '/api/registries' && req.method === 'GET') {
            return send(res, 200, await viteNode(['apps/game/tools/dialogues/registries.ts'], ''));
        }
        // Статика редактора (нет файла — 404, не 500).
        const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
        if (/^[\w.-]+$/.test(name)) {
            try {
                const data = await readFile(path.join(STATIC_DIR, name));
                return send(res, 200, data, MIME[path.extname(name)] ?? 'application/octet-stream');
            } catch {
                return send(res, 404, { message: `нет файла «${name}»` });
            }
        }
        send(res, 404, { message: 'нет такого пути' });
    } catch (e) {
        send(res, 500, { message: String(e?.message ?? e) });
    }
});

server.listen(PORT, () => {
    console.log(`редактор диалогов: http://localhost:${PORT}`);
});