240 lines
12 KiB
JavaScript
Executable File
240 lines
12 KiB
JavaScript
Executable File
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");
|
||
});
|