Remove obsolete generated assets and dead code
All checks were successful
Build Org Website / build (push) Successful in 43s
All checks were successful
Build Org Website / build (push) Successful in 43s
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const data = require("../assets/scripts/pages/princess-lima-v2-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-v2-state.js");
|
||||
const systems = require("../assets/scripts/pages/princess-lima-v2-systems.js");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
|
||||
test("v2 content contracts and all dialogue graphs validate", () => {
|
||||
assert.deepEqual(data.validateAll(), []);
|
||||
assert.equal(data.SCHEMA_VERSION, 2);
|
||||
assert.equal(Object.keys(data.DIALOGUES).length >= 8, true);
|
||||
Object.values(data.DIALOGUES).forEach((dialogue) => assert.equal(data.validateDialogue(dialogue), true));
|
||||
});
|
||||
|
||||
test("all nine regions are genuine Tiled maps with authored runtime layers", () => {
|
||||
assert.equal(data.MAP_IDS.length, 9);
|
||||
data.MAP_IDS.forEach((region) => {
|
||||
const map = JSON.parse(source(`assets/maps/princess-lima/${region}.json`));
|
||||
assert.equal(map.type, "map");
|
||||
assert.equal(map.tilewidth, 32);
|
||||
assert.equal(map.tileheight, 32);
|
||||
assert.deepEqual(systems.validateMap(map, data.MAP_IDS), [], region);
|
||||
assert.deepEqual(map.layers.map((layer) => layer.name), systems.REQUIRED_MAP_LAYERS);
|
||||
assert.ok(map.layers.find((layer) => layer.name === "Base terrain").data.some((tile) => tile > 0));
|
||||
});
|
||||
});
|
||||
|
||||
test("the runtime loads Tiled maps rather than regional backdrop frames", () => {
|
||||
const maps = source("assets/scripts/pages/princess-lima-v2-maps.js");
|
||||
const scenes = source("assets/scripts/pages/princess-lima-v2-scenes.js");
|
||||
assert.match(maps, /make\.tilemap/);
|
||||
assert.match(maps, /createLayer/);
|
||||
assert.match(scenes, /tilemapTiledJSON/);
|
||||
assert.doesNotMatch(maps, /regional-style-atlas|REGION_FRAME/);
|
||||
});
|
||||
|
||||
test("character animation, cinematic dialogue and eight enemy roles are present", () => {
|
||||
const roles = new Set(Object.values(data.ENEMIES).filter((enemy) => !enemy.boss).map((enemy) => enemy.role));
|
||||
["melee", "shield", "ranged", "fast", "ambush", "caster", "flying", "support", "elite"].forEach((role) => assert.ok(roles.has(role)));
|
||||
assert.ok(fs.existsSync(path.join(root, "assets/images/play/princess-lima/actor-atlas-v2.png")));
|
||||
assert.ok(fs.existsSync(path.join(root, "assets/images/play/princess-lima/portrait-atlas-v2.png")));
|
||||
const ui = source("assets/scripts/pages/princess-lima-v2-ui.js");
|
||||
assert.match(ui, /lima-cinematic-dialogue/);
|
||||
assert.match(ui, /aria-label/);
|
||||
assert.match(ui, /Conversation history/);
|
||||
});
|
||||
|
||||
test("the standalone page loads only the local v2 runtime and accessibility surface", () => {
|
||||
const rpg = source("play/rpg.org");
|
||||
assert.match(rpg, /easystar-0\.4\.4\.min\.js/);
|
||||
assert.match(rpg, /princess-lima-v2-game\.js/);
|
||||
assert.match(rpg, /data-lima-screenreader/);
|
||||
assert.match(rpg, /Block Shift/);
|
||||
assert.doesNotMatch(rpg, /princess-lima-game\.js\?v=/);
|
||||
assert.doesNotMatch(rpg, /https?:\/\//);
|
||||
});
|
||||
|
||||
test("v1 progress migrates to v2 without overwriting legacy fields", () => {
|
||||
const legacy = {
|
||||
version: 1,
|
||||
player: { name: "Rowan", appearance: "pine" },
|
||||
chapter: 4,
|
||||
region: "fortressInterior",
|
||||
quests: { break_wards: { status: "complete", count: 3, rewarded: true } },
|
||||
inventory: [{ id: "tempered_sword", quantity: 1 }, { id: "bad_item", quantity: 5 }],
|
||||
defeatedBosses: ["stone_guardian", "unknown"],
|
||||
settings: { subtitles: false, highContrast: true },
|
||||
introSeen: true
|
||||
};
|
||||
const migrated = state.migrateLegacy(legacy);
|
||||
assert.equal(migrated.version, 2);
|
||||
assert.equal(migrated.player.name, "Rowan");
|
||||
assert.equal(migrated.quests.break_wards.status, "complete");
|
||||
assert.equal(migrated.inventory.some((item) => item.id === "bad_item"), false);
|
||||
assert.equal(migrated.position.spawn, "frontHall");
|
||||
assert.equal(migrated.settings.highContrast, true);
|
||||
});
|
||||
|
||||
test("combat phases, hitboxes and enemy states are deterministic", () => {
|
||||
assert.equal(systems.attackPhase(0, "charged"), "windup");
|
||||
assert.equal(systems.attackPhase(520, "charged"), "active");
|
||||
assert.equal(systems.attackPhase(670, "charged"), "recovery");
|
||||
const north = systems.attackHitbox("north", 100, 100, "light1");
|
||||
assert.ok(north.y + north.height <= 100);
|
||||
const enemy = data.ENEMIES.raider;
|
||||
assert.equal(systems.nextEnemyState("patrol", {
|
||||
distance: 30, homeDistance: 0, spec: enemy, health: 5, leash: 190,
|
||||
stunned: false, telegraphDone: true, attackDone: true, cooldownDone: true
|
||||
}), "telegraph");
|
||||
});
|
||||
|
||||
test("Malrec is demanding but leaves readable counterplay", () => {
|
||||
const malrec = data.ENEMIES.malrec;
|
||||
assert.equal(malrec.phases, 3);
|
||||
assert.ok(malrec.health <= 52);
|
||||
assert.ok(malrec.damage <= 13);
|
||||
assert.ok(malrec.telegraph >= 750);
|
||||
assert.ok(malrec.cooldown >= 1500);
|
||||
const scenes = source("assets/scripts/pages/princess-lima-v2-scenes.js");
|
||||
assert.match(scenes, /phase \+ 1/);
|
||||
assert.match(scenes, /state\.health \+ 25/);
|
||||
assert.match(scenes, /defences_disabled/);
|
||||
assert.match(scenes, /prisoners_freed/);
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/princess-lima-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-state.js");
|
||||
|
||||
test("fresh save uses the Princess Lima schema and safe village start", () => {
|
||||
const fresh = state.fresh(" Rowan ", "pine");
|
||||
assert.equal(fresh.version, 1);
|
||||
assert.equal(state.STORAGE_KEY, "zxh_princess_lima_rpg_v1");
|
||||
assert.equal(fresh.player.name, "Rowan");
|
||||
assert.equal(fresh.player.appearance, "pine");
|
||||
assert.equal(fresh.region, "village");
|
||||
assert.deepEqual({ x: fresh.position.x, y: fresh.position.y }, data.MAPS.village.spawns.start);
|
||||
assert.equal(fresh.settings.soundEnabled, false);
|
||||
assert.equal(fresh.introSeen, false);
|
||||
assert.equal(fresh.settings.subtitles, true);
|
||||
assert.equal(fresh.settings.narrationEnabled, true);
|
||||
});
|
||||
|
||||
test("old v1 saves continue without unexpectedly replaying the new introduction", () => {
|
||||
const candidate = state.fresh("Legacy", "azure");
|
||||
delete candidate.introSeen;
|
||||
delete candidate.settings.voice;
|
||||
delete candidate.settings.subtitles;
|
||||
delete candidate.settings.narrationEnabled;
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.introSeen, true);
|
||||
assert.equal(restored.settings.voice, 0.85);
|
||||
assert.equal(restored.settings.subtitles, true);
|
||||
assert.equal(restored.settings.narrationEnabled, true);
|
||||
});
|
||||
|
||||
test("names and appearances validate", () => {
|
||||
assert.equal(state.validName(" A Traveller "), "A Traveller");
|
||||
assert.equal(state.validName(""), null);
|
||||
assert.equal(state.validName("x".repeat(21)), null);
|
||||
assert.equal(state.normalize({ ...state.fresh("A", "azure"), player: { name: "A", appearance: "bad" } }), null);
|
||||
});
|
||||
|
||||
test("valid saves restore bounded state and derived equipment", () => {
|
||||
const candidate = state.fresh("Mira", "azure");
|
||||
candidate.health = 999;
|
||||
candidate.inventory = [
|
||||
{ id: "tempered_sword", quantity: 1 },
|
||||
{ id: "healing_tonic", quantity: 99 },
|
||||
{ id: "bad", quantity: 4 }
|
||||
];
|
||||
candidate.solvedPuzzles = ["forest_stones", "forest_stones", "bad"];
|
||||
candidate.defeatedBosses = ["captain", "captain", "bad"];
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.health, restored.maxHealth);
|
||||
assert.equal(restored.attack, 2);
|
||||
assert.equal(restored.inventory.find((item) => item.id === "healing_tonic").quantity, 9);
|
||||
assert.deepEqual(restored.solvedPuzzles, ["forest_stones"]);
|
||||
assert.deepEqual(restored.defeatedBosses, ["captain"]);
|
||||
});
|
||||
|
||||
test("invalid and unsupported saves recover without throwing", () => {
|
||||
assert.equal(state.parse("{"), null);
|
||||
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
|
||||
assert.equal(state.parse(JSON.stringify({ version: 1 })), null);
|
||||
});
|
||||
|
||||
test("quest, route, puzzle, boss and settings restoration filters malformed data", () => {
|
||||
const candidate = state.fresh("Mira", "ember");
|
||||
candidate.quests.aftermath = { status: "active", count: 999, rewarded: true };
|
||||
candidate.quests.village_defence = { status: "nonsense", count: -10 };
|
||||
candidate.unlockedRoutes = ["guide_found", "guide_found", "fake"];
|
||||
candidate.settings = { master: 4, music: -2, effects: "bad", textSpeed: "instant", highContrast: true };
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.quests.aftermath.count, 99);
|
||||
assert.equal(restored.quests.village_defence.status, "locked");
|
||||
assert.deepEqual(restored.unlockedRoutes, ["guide_found"]);
|
||||
assert.equal(restored.settings.master, 1);
|
||||
assert.equal(restored.settings.music, 0);
|
||||
assert.equal(restored.settings.effects, 0.7);
|
||||
assert.equal(restored.settings.textSpeed, "instant");
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/princess-lima-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-state.js");
|
||||
const systems = require("../assets/scripts/pages/princess-lima-systems.js");
|
||||
|
||||
test("movement is responsive and diagonal speed is normalized", () => {
|
||||
let velocity = { x: 0, y: 0 };
|
||||
for (let frame = 0; frame < 120; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 1, 1, 16, false);
|
||||
}
|
||||
assert.equal(Math.round(Math.hypot(velocity.x, velocity.y)), 176);
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 0, 0, 16, false);
|
||||
}
|
||||
assert.deepEqual(velocity, { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test("directional attacks separate wind-up, active hit and recovery timing", () => {
|
||||
assert.equal(systems.attackPhase(0), "windup");
|
||||
assert.equal(systems.attackPhase(systems.ATTACK_TIMING.windup), "active");
|
||||
assert.equal(systems.attackPhase(systems.ATTACK_TIMING.windup + systems.ATTACK_TIMING.active), "recovery");
|
||||
assert.equal(systems.attackPhase(999), "complete");
|
||||
const origin = { x: 500, y: 400 };
|
||||
const north = systems.attackHitbox("north", origin.x, origin.y);
|
||||
const south = systems.attackHitbox("south", origin.x, origin.y);
|
||||
const east = systems.attackHitbox("east", origin.x, origin.y);
|
||||
const west = systems.attackHitbox("west", origin.x, origin.y);
|
||||
assert.ok(north.y + north.height <= origin.y);
|
||||
assert.ok(south.y >= origin.y);
|
||||
assert.ok(east.x >= origin.x);
|
||||
assert.ok(west.x + west.width <= origin.x);
|
||||
});
|
||||
|
||||
test("enemy knockback stops before walls and map boundaries", () => {
|
||||
const current = state.fresh("Ada", "azure");
|
||||
const safe = systems.wallSafeKnockback(current, "village", 430, 400, 1, 0, 60);
|
||||
assert.equal(systems.isSafePosition(current, "village", safe.x, safe.y), true);
|
||||
const edge = systems.wallSafeKnockback(current, "village", 50, 400, -1, 0, 90);
|
||||
assert.equal(systems.isSafePosition(current, "village", edge.x, edge.y), true);
|
||||
});
|
||||
|
||||
test("all named spawns are collision-safe and invalid positions recover", () => {
|
||||
const current = state.fresh("Ada", "azure");
|
||||
Object.entries(data.MAPS).forEach(([regionId, map]) => {
|
||||
Object.entries(map.spawns).forEach(([spawnId, spawn]) => {
|
||||
assert.equal(systems.isSafePosition(current, regionId, spawn.x, spawn.y), true, `${regionId}:${spawnId}`);
|
||||
});
|
||||
});
|
||||
const recovered = systems.nearestSafeSpawn(current, "village", 100, 100);
|
||||
assert.equal(systems.isSafePosition(current, recovered.region, recovered.x, recovered.y), true);
|
||||
});
|
||||
|
||||
test("mountain bridge controls are reachable before repair and crossing opens afterward", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
const mountain = data.MAPS.mountain;
|
||||
const controls = mountain.puzzle.objects.map(([, x, y]) => ({ x, y }));
|
||||
controls.forEach((point) => {
|
||||
assert.equal(systems.isSafePosition(current, "mountain", point.x, point.y), true);
|
||||
});
|
||||
assert.ok(Math.hypot(controls[0].x - controls[1].x, controls[0].y - controls[1].y) <= 80);
|
||||
assert.equal(systems.isSafePosition(current, "mountain", 365, 345), false);
|
||||
current = systems.solvePuzzle(current, "bridge_winches");
|
||||
assert.equal(current.unlockedRoutes.includes("bridge_repaired"), true);
|
||||
assert.equal(systems.isSafePosition(current, "mountain", 365, 345), true);
|
||||
});
|
||||
|
||||
test("every region has an unbroken outer collision border", () => {
|
||||
const current = state.fresh("Ada", "azure");
|
||||
data.MAP_IDS.forEach((region) => {
|
||||
[[10, 360], [710, 360], [360, 10], [360, 710]].forEach(([x, y]) => {
|
||||
assert.equal(systems.isSafePosition(current, region, x, y), false, `${region}:${x},${y}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("quest progression grants rewards once and unlocks routes", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.completeQuest(current, "aftermath");
|
||||
assert.equal(systems.quantity(current, "village_sword"), 1);
|
||||
current = systems.progressQuest(current, "village_defence", 3);
|
||||
assert.equal(current.quests.village_defence.status, "complete");
|
||||
assert.equal(current.unlockedRoutes.includes("village_defended"), true);
|
||||
const tonics = systems.quantity(current, "healing_tonic");
|
||||
current = systems.completeQuest(current, "village_defence");
|
||||
assert.equal(systems.quantity(current, "healing_tonic"), tonics);
|
||||
});
|
||||
|
||||
test("optional quests remain independent of chapter progression", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.startQuest(current, "healer_herbs");
|
||||
current = systems.completeQuest(current, "healer_herbs");
|
||||
assert.equal(current.quests.healer_herbs.status, "complete");
|
||||
assert.equal(current.chapter, 1);
|
||||
});
|
||||
|
||||
test("inventory caps stacks, protects quest items and healing consumes safely", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.addItem(current, "healing_tonic", 30);
|
||||
assert.equal(systems.quantity(current, "healing_tonic"), 9);
|
||||
current = systems.addItem(current, "prison_key", 1);
|
||||
assert.equal(systems.removeItem(current, "prison_key", 1).removed, false);
|
||||
current.health = 30;
|
||||
const result = systems.useItem(current, "healing_tonic");
|
||||
assert.equal(result.used, true);
|
||||
assert.equal(result.state.health, 70);
|
||||
assert.equal(systems.quantity(result.state, "healing_tonic"), 8);
|
||||
});
|
||||
|
||||
test("combat applies defence, invulnerability, defeat and checkpoint respawn", () => {
|
||||
let current = systems.addItem(state.fresh("Ada", "azure"), "buckler", 1);
|
||||
const hit = systems.damage(current, 10, 2000, 0);
|
||||
assert.equal(hit.amount, 8);
|
||||
assert.equal(hit.state.health, 92);
|
||||
assert.equal(systems.damage(hit.state, 10, 2100, 2000).hit, false);
|
||||
const defeated = systems.damage({ ...hit.state, health: 3 }, 20, 4000, 0);
|
||||
assert.equal(defeated.defeated, true);
|
||||
const respawned = systems.respawn(defeated.state);
|
||||
assert.equal(respawned.region, "village");
|
||||
assert.ok(respawned.health >= 50);
|
||||
});
|
||||
|
||||
test("puzzles unlock chapter routes and cannot reward twice", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.solvePuzzle(current, "forest_stones");
|
||||
assert.equal(current.solvedPuzzles.includes("forest_stones"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("guide_found"), true);
|
||||
const boots = systems.quantity(current, "trail_boots");
|
||||
current = systems.solvePuzzle(current, "forest_stones");
|
||||
assert.equal(systems.quantity(current, "trail_boots"), boots);
|
||||
});
|
||||
|
||||
test("boss phases and victories are deterministic", () => {
|
||||
assert.equal(systems.bossPhase(48, 48, 3), 1);
|
||||
assert.equal(systems.bossPhase(24, 48, 3), 2);
|
||||
assert.equal(systems.bossPhase(10, 48, 3), 3);
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.recordBoss(current, "malrec");
|
||||
assert.equal(current.defeatedBosses.includes("malrec"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
|
||||
assert.equal(current.story, "rescue");
|
||||
});
|
||||
|
||||
test("complete story path reaches the Princess Lima rescue state", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
["aftermath", "village_defence", "find_guide", "ruins_light", "wolf_miniboss", "repair_bridge", "stone_guardian", "free_scout", "free_prisoners", "break_wards", "defeat_malrec"]
|
||||
.forEach((id) => { current = systems.completeQuest(current, id); });
|
||||
assert.equal(current.chapter, 4);
|
||||
assert.equal(current.unlockedRoutes.includes("wards_broken"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/princess-lima-v2-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-v2-state.js");
|
||||
const systems = require("../assets/scripts/pages/princess-lima-v2-systems.js");
|
||||
|
||||
test("fresh saves use the v2 key and complete accessibility defaults", () => {
|
||||
const fresh = state.fresh("Aster", "azure");
|
||||
assert.equal(state.STORAGE_KEY, "zxh_princess_lima_rpg_v2");
|
||||
assert.equal(state.LEGACY_KEY, "zxh_princess_lima_rpg_v1");
|
||||
assert.equal(fresh.settings.subtitles, true);
|
||||
assert.equal(fresh.settings.blur, true);
|
||||
assert.equal(fresh.settings.particles, true);
|
||||
assert.equal(fresh.settings.screenShake, true);
|
||||
});
|
||||
|
||||
test("loading imports legacy data once and leaves the legacy key untouched", () => {
|
||||
const values = new Map([[state.LEGACY_KEY, JSON.stringify({
|
||||
version: 1, player: { name: "Legacy", appearance: "ember" }, chapter: 2,
|
||||
region: "forest", inventory: [], quests: {}, settings: {}
|
||||
})]]);
|
||||
const storage = { getItem: (key) => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
const result = state.load(storage);
|
||||
assert.equal(result.migrated, true);
|
||||
assert.equal(result.state.player.name, "Legacy");
|
||||
assert.ok(values.has(state.LEGACY_KEY));
|
||||
assert.ok(values.has(state.STORAGE_KEY));
|
||||
});
|
||||
|
||||
test("dialogue choices apply flags and quest effects", () => {
|
||||
const fresh = state.fresh("Aster", "azure");
|
||||
const result = systems.dialogueChoice("elder", "choice", 0, fresh);
|
||||
assert.equal(result.next, "accept");
|
||||
assert.equal(result.state.flags.promised_village, true);
|
||||
assert.equal(result.state.quests.aftermath.status, "active");
|
||||
});
|
||||
|
||||
test("quest completion applies rewards and world state transformations", () => {
|
||||
let current = state.fresh("Aster", "azure");
|
||||
current = state.startQuest(current, "village_defence");
|
||||
current = state.progressQuest(current, "village_defence", 3);
|
||||
assert.equal(current.quests.village_defence.status, "complete");
|
||||
assert.equal(current.flags.village_resolved, true);
|
||||
assert.equal(current.flags.village_defended, true);
|
||||
assert.equal(current.chapter, 2);
|
||||
assert.equal(current.inventory.some((item) => item.id === "buckler"), true);
|
||||
});
|
||||
|
||||
test("normalization removes invalid IDs and clamps unsafe values", () => {
|
||||
const candidate = state.fresh("Aster", "azure");
|
||||
candidate.health = 999;
|
||||
candidate.inventory.push({ id: "not-real", quantity: 99 });
|
||||
candidate.region = "missing";
|
||||
candidate.settings.effectsQuality = "impossible";
|
||||
const normalized = state.normalize(candidate);
|
||||
assert.equal(normalized.health, normalized.maxHealth);
|
||||
assert.equal(normalized.region, "village");
|
||||
assert.equal(normalized.inventory.some((entry) => !data.ITEMS[entry.id]), false);
|
||||
assert.equal(normalized.settings.effectsQuality, "full");
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user