Add standalone export for Two Rivers at Moonrise
All checks were successful
Build Org Website / build (push) Successful in 44s

This commit is contained in:
gitea-actions
2026-07-30 23:37:18 +01:00
parent 563d4b63cc
commit 4659edc658
186 changed files with 4799 additions and 8 deletions

View File

@@ -0,0 +1,235 @@
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.mjs";
import {validateContent} from "../../assets/scripts/games/two-rivers/core/content.mjs";
import {createSaveService, SAVE_KEY} from "../../assets/scripts/games/two-rivers/core/save.mjs";
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.doesNotMatch(page, /https?:\/\//);
const source = [
page,
read("assets/scripts/games/two-rivers/main.js"),
read("assets/scripts/games/two-rivers/core/state.mjs"),
read("assets/scripts/games/two-rivers/ui/interface.mjs"),
read("assets/scripts/games/two-rivers/scenes/game-scenes.mjs")
].join("\n");
assert.doesNotMatch(source, /\bTODO\b|placeholder implementation|lorem ipsum/i);
assert.doesNotMatch(source, /\bReact\b|\bVue\b|\bAngular\b|\bjQuery\b/);
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");
});