119 lines
7.8 KiB
JavaScript
119 lines
7.8 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const vm = require("vm");
|
|
const path = require("path");
|
|
const root = path.join(__dirname, "..");
|
|
const achievementSource = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
|
|
|
class ClassList { add() {} remove() {} contains() { return false; } toggle() { return false; } }
|
|
class Element {
|
|
constructor() { this.classList = new ClassList(); this.children = []; this.dataset = {}; this.style = { setProperty() {}, removeProperty() {} }; this.hidden = true; }
|
|
append(...children) { this.children.push(...children); }
|
|
replaceChildren(...children) { this.children = children; }
|
|
addEventListener() {} focus() {} setAttribute() {}
|
|
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
|
|
get offsetWidth() { return 100; }
|
|
}
|
|
function harness() {
|
|
const storage = new Map();
|
|
const ids = ["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"];
|
|
const elements = new Map(ids.map(id => [id, new Element()]));
|
|
let timer = 0;
|
|
const c = {
|
|
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
|
|
location: { protocol: "file:", search: "" }, crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
|
|
localStorage: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, String(v)), removeItem: k => storage.delete(k) },
|
|
document: { hidden: false, getElementById: id => elements.get(id) || null, createElement: () => new Element(), addEventListener() {} },
|
|
TarinaiEvents: { emit() {} }, TarinaiGameDialogs: { confirm: async () => true }, showToast() {}, audio: { uiClick() {} },
|
|
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {}, fetch: async () => { throw new Error("offline"); },
|
|
TARINAI_VERSION: "39.16.77",
|
|
};
|
|
c.window = c; c.globalThis = c;
|
|
vm.createContext(c); vm.runInContext(achievementSource, c, { filename: "achievements.js" });
|
|
return { c, api: c.TarinaiAchievements };
|
|
}
|
|
function assert(value, message) { if (!value) throw new Error(message); }
|
|
|
|
(async function run() {
|
|
// Sauna timing must start on entering the hot state, not refresh every hot frame.
|
|
let h = harness();
|
|
let temperature = 40;
|
|
const t = { dead: false, x: 0, y: 0 };
|
|
const world = {
|
|
time: 0, tarinai: [t], ants: [], items: [],
|
|
feltTemperatureFor() { return temperature; },
|
|
temperatureStatusFor(v) { return { direction: v > 25 ? "hot" : (v < 10 ? "cold" : "comfort"), comfortable: v >= 10 && v <= 25 }; },
|
|
};
|
|
h.api.evaluateWorld(world, {});
|
|
world.time = 20; h.api.evaluateWorld(world, {}); // Still hot: must not restart the timer.
|
|
world.time = 20.1; temperature = 0; h.api.evaluateWorld(world, {});
|
|
assert(!h.api.isUnlocked("sauna_cold_plunge"), "sauna timer refreshed while continuously hot");
|
|
world.time = 30; temperature = 40; h.api.evaluateWorld(world, {}); // New hot entry.
|
|
world.time = 40; temperature = 0; h.api.evaluateWorld(world, {});
|
|
assert(h.api.isUnlocked("sauna_cold_plunge"), "sauna timer did not restart on a genuine new hot entry");
|
|
|
|
// Achievement reset must clear per-world/per-object hidden progress.
|
|
h = harness();
|
|
const tarinai = {
|
|
dead: false,
|
|
_achievementDirectFeedCount: 9,
|
|
_achievementDirectTreatmentCount: 3,
|
|
_achievementSaunaHotAt: 12,
|
|
_achievementSaunaWasHot: true,
|
|
_achievementHeldStartedAt: 1234,
|
|
};
|
|
const bomb = { type: "sticky_bomb", stickyBombPassCount: 14, _achievementPlayerPlaced: true, _achievementPlacedAt: 9 };
|
|
const resetWorld = { time: 100, tarinai: [tarinai], items: [bomb] };
|
|
h.c.world = resetWorld;
|
|
await h.api.reset();
|
|
assert(tarinai._achievementDirectFeedCount === 0 && tarinai._achievementDirectTreatmentCount === 0, "care progress survived achievement reset");
|
|
assert(tarinai._achievementSaunaHotAt == null && tarinai._achievementHeldStartedAt == null, "timed per-tarinai progress survived achievement reset");
|
|
assert(bomb.stickyBombPassCount === 0, "sticky-bomb relay progress survived achievement reset");
|
|
assert(bomb._achievementPlayerPlaced === true && bomb._achievementPlacedAt == null, "reset should preserve placement identity but clear quick-delete timing");
|
|
|
|
// World-changing history/ground operations are interventions, so passive observation must restart.
|
|
h = harness();
|
|
const observerWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
|
|
h.api.evaluateWorld(observerWorld, {});
|
|
observerWorld.time = 359.9; h.api.evaluateWorld(observerWorld, {});
|
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "observer unlocked before three game days");
|
|
h.api.recordHistoryAction("undo", { world: observerWorld, deadRestored: 0 });
|
|
observerWorld.time = 360; h.api.evaluateWorld(observerWorld, {});
|
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "undo did not reset passive-observation progress");
|
|
observerWorld.time = 500;
|
|
h.api.recordGroundChange({ world: observerWorld, previous: "soil", next: "ice", now: 1000 });
|
|
observerWorld.time = 859.9; h.api.evaluateWorld(observerWorld, {});
|
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "ground change did not reset passive-observation progress");
|
|
observerWorld.time = 860; h.api.evaluateWorld(observerWorld, {});
|
|
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three uninterrupted game days");
|
|
|
|
// Manually adding a Tarinai is also a gameplay intervention. This must reset
|
|
// the surveillance-camera window even if the separate lively-making achievement
|
|
// has already been completed.
|
|
h = harness();
|
|
const addWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
|
|
h.api.evaluateWorld(addWorld, {});
|
|
addWorld.time = 359.9;
|
|
h.api.recordManualTarinaiAdded({ world: addWorld, tarinai: { id: "manual-1" }, tool: "new" });
|
|
addWorld.time = 719.8; h.api.evaluateWorld(addWorld, {});
|
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "manual Tarinai addition did not reset passive-observation progress");
|
|
addWorld.time = 719.9; h.api.evaluateWorld(addWorld, {});
|
|
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three game days following manual Tarinai addition");
|
|
|
|
// Minimalist needs player-placement identity even after Unplanned City is already unlocked.
|
|
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
|
|
assert(snapshotSource.includes('const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;'), "minimalist placement metadata is not retained independently of quick-delete progress");
|
|
assert(snapshotSource.includes('needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0'), "snapshot does not serialize minimalist placement identity");
|
|
|
|
// Lethal shocks must count before damage can mark the target dead.
|
|
const signalSource = fs.readFileSync(path.join(root, "js", "signal_system.js"), "utf8");
|
|
const shockStart = signalSource.indexOf("function shockTarget");
|
|
const shockEnd = signalSource.indexOf("function damageFromWires", shockStart);
|
|
const shockBody = signalSource.slice(shockStart, shockEnd);
|
|
const recordAt = shockBody.indexOf("recordWireShock");
|
|
const damageAt = shockBody.indexOf("target.damage?.");
|
|
assert(recordAt >= 0 && damageAt >= 0 && recordAt < damageAt, "wire-shock achievement is still recorded after potentially lethal damage");
|
|
|
|
console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions including manual Tarinai addition, minimalist save metadata, and lethal wire shocks passed");
|
|
})();
|