Files
org_web/tests/two-rivers/game.test.mjs
gitea-actions 08516fda14
All checks were successful
Build Org Website / build (push) Successful in 45s
Serve Two Rivers modules with production MIME type
2026-08-11 13:40:14 +01:00

240 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import {fileURLToPath} from "node:url";
import {
applyEffects, createStore, freshState, normalizeState, relationshipStage,
resolveEnding, requirementMet, RELATIONSHIPS
} from "../../assets/scripts/games/two-rivers/core/state.js";
import {validateContent} from "../../assets/scripts/games/two-rivers/core/content.js";
import {createSaveService, SAVE_KEY} from "../../assets/scripts/games/two-rivers/core/save.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const json = (file) => JSON.parse(read(file));
const content = {
assets: json("assets/games/two-rivers/data/assets.json"),
characters: json("assets/games/two-rivers/data/characters.json"),
locations: json("assets/games/two-rivers/data/locations.json"),
items: json("assets/games/two-rivers/data/items.json"),
quests: json("assets/games/two-rivers/data/quests.json"),
achievements: json("assets/games/two-rivers/data/achievements.json"),
endings: json("assets/games/two-rivers/data/endings.json"),
dialogue: {
...json("assets/games/two-rivers/data/dialogue/story.json"),
...json("assets/games/two-rivers/data/dialogue/scenes.json"),
...json("assets/games/two-rivers/data/dialogue/chapters.json")
}
};
test("content contracts, asset paths, and dialogue graphs are complete", () => {
assert.deepEqual(validateContent(content), []);
assert.equal(Object.keys(content.locations).length, 5);
assert.equal(Object.keys(content.endings).length, 4);
assert.equal(Object.keys(content.achievements).length, 12);
assert.equal(content.assets.images.filter((asset) => asset.type === "npc").length, 6);
assert.equal(content.assets.images.filter((asset) => asset.type === "item").length, 18);
assert.equal(new Set(content.assets.images.map((asset) => asset.id)).size, content.assets.images.length);
content.assets.images.forEach((asset) => assert.ok(fs.existsSync(path.join(root, asset.url))));
content.assets.images.forEach((asset) => {
const promptFile = asset.prompt?.split("#")[0];
assert.ok(promptFile && fs.existsSync(path.join(root, "assets/games/two-rivers", promptFile)), `missing prompt for ${asset.id}`);
});
content.assets.audio.forEach((asset) => {
assert.deepEqual(asset.urls.map((url) => path.extname(url)), [".webm", ".mp3"]);
asset.urls.forEach((url) => assert.ok(fs.existsSync(path.join(root, url))));
assert.ok(asset.source && asset.licence);
});
Object.values(content.dialogue).forEach((dialogue) => {
const reachable = new Set();
const visit = (id) => {
if (!id || reachable.has(id)) return;
reachable.add(id);
const node = dialogue.nodes[id];
visit(node.next);
node.choices?.forEach((choice) => visit(choice.next));
};
visit(dialogue.start);
assert.deepEqual([...reachable].sort(), Object.keys(dialogue.nodes).sort());
});
const authoredWords = Object.values(content.dialogue).flatMap((dialogue) => Object.values(dialogue.nodes))
.reduce((total, node) => total + (node.text.match(/\b[\w'-]+\b/g) || []).length +
(node.choices || []).reduce((choiceTotal, choice) => choiceTotal + (choice.text.match(/\b[\w'-]+\b/g) || []).length, 0), 0);
assert.ok(authoredWords >= 5000, `expected a feature-length script, found ${authoredWords} words`);
});
test("seven relationship values are independent, clamped, and qualitative", () => {
const store = createStore(freshState());
const before = store.get().relationships;
store.dispatch({type: "relationship", key: "trust", amount: 200});
assert.equal(store.get().relationships.trust, 100);
RELATIONSHIPS.filter((key) => key !== "trust").forEach((key) => assert.equal(store.get().relationships[key], before[key]));
assert.deepEqual([0, 25, 50, 75].map(relationshipStage), ["New", "Growing", "Warm", "Deep"]);
});
test("quest rewards are idempotent and requirements unlock deterministically", () => {
const store = createStore(freshState());
const quest = content.quests.garland;
content.items.flowers.forEach((item) => store.dispatch({
type: "questProgress", id: "garland", item, target: quest.target, reward: quest.reward
}));
const complete = store.get();
assert.equal(complete.quests.garland.status, "complete");
assert.ok(complete.unlockedCGs.includes("garland"));
const approval = complete.relationships.familyApproval;
store.dispatch({type: "questProgress", id: "garland", item: "jasmine", target: quest.target, reward: quest.reward});
assert.equal(store.get().relationships.familyApproval, approval);
store.dispatch({type: "puzzle", id: "cipher"});
assert.equal(requirementMet(store.get(), "flag:cipher_solved"), true);
});
test("all four guaranteed-romance endings are reachable", () => {
const profiles = {
scholar: {respect: 100, trust: 100, comfort: 90},
laughter: {humour: 100, comfort: 100, adventure: 80},
road: {adventure: 100, trust: 95, humour: 90},
families: {familyApproval: 100, respect: 90, comfort: 90}
};
Object.entries(profiles).forEach(([expected, values]) => {
const state = freshState();
Object.assign(state.relationships, {
trust: 5, respect: 5, comfort: 5, humour: 5,
adventure: 5, romance: 90, familyApproval: 5, ...values
});
state.endingBias[expected] = 12;
assert.equal(resolveEnding(state, content.endings), expected);
});
});
test("save slots validate and reject corruption", () => {
class MemoryStorage {
constructor() { this.values = new Map(); }
getItem(key) { return this.values.has(key) ? this.values.get(key) : null; }
setItem(key, value) { this.values.set(key, String(value)); }
removeItem(key) { this.values.delete(key); }
}
const storage = new MemoryStorage();
const saves = createSaveService(storage);
const state = freshState();
state.location = "garden";
saves.save("auto", state);
assert.equal(saves.load("auto").location, "garden");
saves.save("slot1", state);
assert.ok(saves.list().slot1);
const envelope = JSON.parse(storage.getItem(SAVE_KEY));
envelope.slots.auto.state.location = "unknown";
storage.setItem(SAVE_KEY, JSON.stringify(envelope));
assert.equal(saves.load("auto"), null);
});
test("page uses local pinned libraries and production source is clean", () => {
const page = read("play/two-rivers.org");
assert.match(page, /phaser-3\.90\.0\.min\.js/);
assert.match(page, /gsap-3\.13\.0\.min\.js/);
assert.match(page, /howler-2\.2\.4\.min\.js/);
assert.match(page, /games\/two-rivers\/gate\.js\?v=1\.0\.1/);
assert.match(page, /games\/two-rivers\/main\.js\?v=1\.0\.2/);
assert.doesNotMatch(page, /https?:\/\//);
const source = [
page,
read("assets/scripts/games/two-rivers/main.js"),
read("assets/scripts/games/two-rivers/gate.js"),
read("assets/scripts/games/two-rivers/core/state.js"),
read("assets/scripts/games/two-rivers/ui/interface.js"),
read("assets/scripts/games/two-rivers/scenes/game-scenes.js")
].join("\n");
assert.doesNotMatch(source, /\bTODO\b|placeholder implementation|lorem ipsum/i);
assert.doesNotMatch(source, /\bReact\b|\bVue\b|\bAngular\b|\bjQuery\b/);
assert.doesNotMatch(source, /\.mjs(?:["'?#]|$)/, "production runtime must use the server's JavaScript MIME mapping");
assert.ok(read("assets/styles/pages/two-rivers.css").length > 15000);
});
test("normalization rejects hostile IDs and caps collections", () => {
const state = freshState();
state.inventory = ["valid_item", "<script>", ...Array.from({length: 100}, (_, index) => `item_${index}`)];
const normalized = normalizeState(state);
assert.equal(normalized.inventory.includes("<script>"), false);
assert.ok(normalized.inventory.length <= 80);
});
test("minigame results improve but story rewards cannot be ground repeatedly", () => {
const store = createStore(freshState());
store.dispatch({type: "minigame", id: "archery", score: 35});
applyEffects(store, [{type: "relationship", key: "adventure", amount: 4}]);
const rewarded = store.get().relationships.adventure;
store.dispatch({type: "minigame", id: "archery", score: 82});
assert.equal(store.get().minigames.archery.score, 82);
assert.equal(store.get().relationships.adventure, rewarded);
assert.equal(store.get().minigames.archery.attempts, 2);
});
test("a deterministic complete route reaches a guaranteed romantic ending", () => {
const store = createStore(freshState());
const finishDialogue = (id, choiceIndex = 0) => {
const graph = content.dialogue[id];
let nodeId = graph.start;
const visited = new Set();
while (nodeId) {
assert.ok(!visited.has(nodeId), `cycle in ${id}/${nodeId}`);
visited.add(nodeId);
const node = graph.nodes[nodeId];
applyEffects(store, node.effects);
if (node.choices?.length) {
const choice = node.choices[Math.min(choiceIndex, node.choices.length - 1)];
applyEffects(store, choice.effects);
store.dispatch({type: "choice", id: `${id}:${nodeId}:${choice.next}`});
nodeId = choice.next;
} else nodeId = node.next;
}
store.dispatch({type: "flag", id: `dialogue_${id}`});
};
const exitReady = (location, hotspot) => {
const definition = content.locations[location].hotspots.find((spot) => spot.id === hotspot);
assert.ok(definition.requires.every((requirement) => requirementMet(store.get(), requirement)), `${location}/${hotspot} is blocked`);
};
["arrival", "delegation_walk", "courtyard_preparations", "ishan_protocol"].forEach((id) => finishDialogue(id));
exitReady("courtyard", "to_library");
store.dispatch({type: "goto", location: "library", position: 0.1});
["anwara_memory", "missing_folio", "library_search", "library_balcony"].forEach((id) => finishDialogue(id));
store.dispatch({type: "puzzle", id: "cipher"});
exitReady("library", "to_garden");
store.dispatch({type: "goto", location: "garden", position: 0.08});
["mira_garland", "garden_confidence", "garden_walk", "garden_history"].forEach((id) => finishDialogue(id));
store.dispatch({type: "puzzle", id: "flower_sequence"});
store.dispatch({type: "minigame", id: "archery", score: 70});
finishDialogue("archery_after");
exitReady("garden", "to_market");
store.dispatch({type: "goto", location: "market", position: 0.08});
content.items.flowers.forEach((item) => store.dispatch({type: "questProgress", id: "garland", item, target: 5, reward: content.quests.garland.reward}));
content.items.letters.forEach((item) => store.dispatch({type: "questProgress", id: "letters", item, target: 3, reward: content.quests.letters.reward}));
["nadia_letters", "market_walk", "market_founders", "rayhan_kitchen"].forEach((id) => finishDialogue(id));
store.dispatch({type: "minigame", id: "cooking", score: 60});
finishDialogue("anwara_truth");
exitReady("market", "to_festival");
store.dispatch({type: "goto", location: "pavilion", position: 0.08});
["ishan_stage", "pavilion_waiting", "resolution"].forEach((id) => finishDialogue(id));
store.dispatch({type: "minigame", id: "dance", score: 60});
["dance_after", "moonrise"].forEach((id) => finishDialogue(id));
const ending = resolveEnding(store.get(), content.endings);
assert.ok(["scholar", "laughter", "road", "families"].includes(ending));
assert.ok(store.get().relationships.romance > 0);
});
test("save recovery reports unknown versions and restores the previous valid envelope", () => {
class MemoryStorage {
constructor() { this.values = new Map(); }
getItem(key) { return this.values.has(key) ? this.values.get(key) : null; }
setItem(key, value) { this.values.set(key, String(value)); }
removeItem(key) { this.values.delete(key); }
}
const storage = new MemoryStorage();
const saves = createSaveService(storage);
saves.save("auto", freshState());
storage.setItem(`${SAVE_KEY}:backup`, storage.getItem(SAVE_KEY));
storage.setItem(SAVE_KEY, JSON.stringify({version: 99, slots: {}}));
assert.ok(saves.hasSave());
assert.equal(saves.recoveryInfo().source, "backup");
});