Files

225 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
async function load(name) {
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/player/${name}.ts`, import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
}
const { TomieFunctions } = await load("tomie-functions");
const { NaniRuntime } = await load("nani-runtime");
const { parseNani } = await load("nani-parser");
const { TomieScene } = await load("tomie-scene");
const { TomieMinigame } = await load("tomie-minigame");
const command = (op, args = {}) => ({ op, args });
test("Tomie functions preserve source formulas, string lists and saved RNG", () => {
let store = {};
const helpers = new TomieFunctions({ store: () => store, tables: { names: ["A", "B"] }, hasLabel: () => false, hasVideo: () => false, link() {} });
const f = helpers.functions;
assert.equal(f.WillpowerDamage(100, 50, 20, 0, 100), 83);
assert.equal(f.RoundValue(2.5), 2); assert.equal(f.RoundValue(3.5), 4);
assert.equal(f.RoundValue(-2.5), -2); assert.equal(f.RoundValue(-3.5), -4);
assert.equal(f.AddListString2("A@B", "B"), "A@B");
assert.equal(f.AddListString2("A", "B"), "A@B");
f.NewRandList(); f.AddRandList("A", 2); f.AddRandList("B");
assert.equal(f.MergeRandList(), "A&A&B");
helpers.state.rng = 12345;
const saved = structuredClone(store);
const first = [f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()];
store = saved;
assert.deepEqual([f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()], first);
});
test("runtime parser supports the recovered wrapped strings and lazy If", () => {
assert.deepEqual(parseNani('\\"hello\\"', new Set()), ["const", "hello"]);
assert.equal(parseNani('If(true, 1, 2)', new Set())[0], "if");
for (const invalid of ['globalThis.alert(1)', 'constructor()', 'a[0]', 'x; 2']) assert.throws(() => parseNani(invalid, new Set()));
});
function fixture() {
return { start: { script: "Main", label: "Start" }, defaults: { points: 1 }, function_data: {},
scripts: { Main: { labels: { Start: 0, End: 5 }, commands: [
command("SetCustomVariable", { Expression: "points+=2" }),
command("Gosub", { Path: "Sub" }),
command("AddChoice", { ChoiceSummary: "Spend", OnSelected: ["@removeItem coin amount:1", "@set points=points+3"], GotoPath: ".End" }),
command("Stop"), command("PrintText", { Text: "unreachable" }),
command("PrintText", { Text: { template: "Score {points}" } }), command("ExitToTitle") ] },
Sub: { labels: {}, commands: [command("PrintText", { Text: "Subroutine" }), command("Return")] } }, initial_inventory: { coin: 1 } };
}
test("Naninovel call frames, choice continuations and inventory survive a save", () => {
const events = [];
const vm = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
vm.start(); assert.equal(vm.waiting, "text"); assert.equal(vm.stack.length, 1);
const saved = vm.snapshot();
const restored = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
assert.equal(restored.restore(saved), true);
restored.advance(); assert.equal(restored.waiting, "choice");
const atChoice = restored.snapshot();
assert.equal(restored.restore(atChoice), true);
restored.choose(0);
assert.equal(restored.variables.points, 6);
assert.equal(restored.evaluator.state.inventory.coin, 0);
assert.equal(restored.waiting, "text");
assert.equal(events.findLast((event) => event.kind === "text").data.text, "Score 6");
restored.advance(); assert.equal(restored.waiting, "complete");
assert.equal(restored.restore({ ...saved, cursor: 9999 }), false);
});
test("native conditional seeking handles gotos across block boundaries and EndIf no-ops", () => {
const data = fixture();
data.scripts.Main = { labels: { Start: 0, Outside: 5 }, commands: [
command("BeginIf", { Expression: "true" }), command("Goto", { Path: ".Outside" }),
command("EndIf"), command("PrintText", { Text: "unreachable" }), command("EndIf"),
command("EndIf"), command("BeginIf", { Expression: "false" }), command("BeginIf", { Expression: "true" }),
command("PrintText", { Text: "nested unreachable" }), command("EndIf"), command("ElseIf", { Expression: "true" }),
command("PrintText", { Text: "selected" }), command("Else"), command("PrintText", { Text: "wrong else" }),
command("EndIf"), command("PrintText", { Text: "done" }) ] };
const texts = [], vm = new NaniRuntime(data, (kind, value) => { if (kind === "text") texts.push(value.text); });
vm.start(); assert.deepEqual(texts, ["selected"]); vm.advance(); assert.deepEqual(texts, ["selected", "done"]);
});
test("Tomie message triggers preserve anonymous threads and remove by identity", () => {
const vm = new NaniRuntime(fixture(), () => {});
vm.variables = { messageString: "Anonymous@hello@id1^Other@old@id2" };
vm.changed("newMessage", "Anonymous@new@id3");
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Anonymous@hello@id1^Other@old@id2");
vm.changed("messageChanged", "id1#remove");
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Other@old@id2");
});
test("dynamic OnSelected templates are resolved before parsing and preserve the chosen value", () => {
const data = fixture();
data.defaults.answer = "Tomie";
data.scripts.Main.commands = [command("AddChoice", { ChoiceSummary: "Choose", OnSelected: { template: '@set result="{answer}"' } }),
command("Stop"), command("PrintText", { Text: { template: "Selected {result}" } })];
const vm = new NaniRuntime(data, () => {});
vm.start();
assert.equal(vm.choiceSupported(vm.choices[0]), true);
vm.choose(0);
assert.equal(vm.waiting, "text");
assert.equal(vm.variables.result, "Tomie");
});
test("Tomie scene snapshots retain actor placement, media and effects without replaying the VM", () => {
const media = { backgrounds: { city: "bg" }, characters: { "tomie/home": { file: "actor", size: [700, 1080] } },
audio: { song: "music" }, videos: { clip: "video" }, textures: {}, fonts: {} };
const stage = new TomieScene(media);
stage.apply("ModifyBackground", { AppearanceAndTransition: { name: "city" } });
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie.home", ScenePosition: [33, 0, 1], Scale: [-1, 1] });
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie", ScenePosition: [60, 0] });
stage.apply("PlayBgm", { BgmPath: "Song", Loop: 1 });
stage.apply("variable_changed", { name: "videoBack", value: "Clip@0@half@right" });
stage.apply("Spawn", { Path: "Rain" });
stage.apply("text", { text: "Hello", author: "Tomie", reset: true });
const saved = stage.snapshot();
stage.apply("HideAllCharacters", {});
stage.apply("StopBgm", {});
const restored = new TomieScene(media);
restored.restore(saved);
assert.deepEqual(restored.state.actors.Tomie.scale, [-1, 1]);
assert.deepEqual(restored.state.actors.Tomie.position, [60, 0]);
assert.equal(restored.state.actors.Tomie.visible, true);
assert.equal(restored.state.bgm.file, "music");
assert.equal(restored.state.video.file, "video");
assert.ok(Object.hasOwn(restored.state.effects, "Rain"));
assert.equal(restored.state.text, "Hello");
assert.throws(() => restored.apply("ModifyCharacter", { IdAndAppearance: "Tomie.missing" }), /не найден образ/);
assert.throws(() => restored.apply("PlaySfx", { SfxPath: "missing" }), /не найден ресурс/);
assert.equal(restored.apply("NativePlugin", {}), false);
});
test("native minigame pauses, saves mid-round and takes both original result paths", () => {
const game = new TomieMinigame(() => 0.5);
game.start();
for (let step = 0; step < 120; step++) game.update(1 / 60);
const saved = game.snapshot();
game.update(0.5, true);
assert.deepEqual(game.snapshot(), saved);
assert.ok(saved.particles.length > 0);
const resumed = new TomieMinigame(() => 0.5);
resumed.restore(saved);
for (let step = 0; step < 1200; step++) {
resumed.pulse(); resumed.update(1 / 60);
game.pulse(); game.update(1 / 60);
}
assert.equal(resumed.state.result, "won");
assert.deepEqual(resumed.snapshot(), game.snapshot());
const lost = new TomieMinigame(() => 0.5);
lost.start();
for (let step = 0; step < 1200 && lost.state.result === "running"; step++) lost.update(1 / 60);
assert.equal(lost.state.result, "lost");
});
test("the original Unity extraction runs character creation through to the board", { skip: !process.env.PGAMES_TOMIE_RUNTIME }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_RUNTIME, "utf8"));
const faults = [];
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") faults.push(value.message); });
vm.start(129);
for (let step = 0; step < 10000 && !vm.onBoard && !["error", "complete"].includes(vm.waiting); step++) {
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
else if (vm.waiting === "input") vm.submitInput(String(vm.resolve(vm.last.args.VariableName)), "Player");
else if (vm.waiting === "choice") {
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
assert.ok(index >= 0, `No available choices in ${vm.script}:${vm.last.line}`); vm.choose(index);
} else throw new Error(`Unexpected wait ${vm.waiting} in ${vm.script}:${vm.last.line}`);
}
assert.deepEqual(faults, []);
assert.equal(vm.onBoard, true);
});
test("real recovered Tomie opening reaches the board without an expression error", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
let fault;
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") fault = value.message; });
vm.start();
for (let step = 0; step < 10000 && !["board", "error", "complete"].includes(vm.waiting); step++) {
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
else if (vm.waiting === "choice") {
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
assert.ok(index >= 0); vm.choose(index);
} else throw new Error("Unexpected wait: " + vm.waiting);
}
assert.equal(fault, undefined);
assert.equal(vm.waiting, "board");
});
test("real Tomie runs seven original day/night chains for three seeds", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
data.start = data.board_start;
const isBoard = (vm) => vm.waiting === "board" || (vm.waiting === "choice" && vm.script === "AppScreen" && vm.last.line === 97);
function settle(vm) {
const seen = new Map();
for (let step = 0; step < 3000; step++) {
if (isBoard(vm) || vm.waiting === "complete") return;
assert.notEqual(vm.waiting, "error", vm.lastFault);
if (["text", "timer"].includes(vm.waiting)) vm.advance();
else if (["choice", "timed_choice"].includes(vm.waiting)) {
const key = vm.script + ":" + vm.last.line, count = seen.get(key) ?? 0;
seen.set(key, count + 1);
const enabled = vm.choices.map((choice, i) => ({ choice, i })).filter(({ choice }) => vm.choiceSupported(choice));
assert.ok(enabled.length > 0, "All choices disabled: " + key);
const exit = vm.script === "HomeActivity" || count > 1 ? enabled.find(({ choice }) =>
["do nothing", "return", "leave", "end", "stop", "i got it", "decline", "refuse"].some((word) => choice.ChoiceSummary.toLowerCase().includes(word))) : undefined;
vm.choose((exit ?? enabled[count % enabled.length]).i);
} else if (vm.waiting === "input") vm.submitInput(String(vm.last.args.VariableName ?? "cheatCode"), "");
else throw new Error("Unexpected wait " + vm.waiting + " in " + vm.script);
}
throw new Error("Route did not settle: " + vm.script + ":" + vm.last.line);
}
for (const seed of [1, 7, 42]) {
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") vm.lastFault = value.message; });
vm.fastWaits = true; vm.start(seed); settle(vm);
for (let day = 0; day < 7; day++) {
vm.runExternal("DicePlay", "DayEnd"); settle(vm);
assert.ok(isBoard(vm));
}
}
});