225 lines
13 KiB
JavaScript
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));
|
|
}
|
|
}
|
|
});
|