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", "