590 lines
35 KiB
JavaScript
590 lines
35 KiB
JavaScript
"use strict";
|
||
|
||
const fs = require("fs");
|
||
const vm = require("vm");
|
||
const path = require("path");
|
||
|
||
const source = fs.readFileSync(path.join(__dirname, "..", "js", "achievements.js"), "utf8");
|
||
|
||
class ClassList {
|
||
constructor() { this.values = new Set(["hidden"]); }
|
||
add(...names) { names.forEach(name => this.values.add(name)); }
|
||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||
contains(name) { return this.values.has(name); }
|
||
toggle(name, force) {
|
||
const next = force === undefined ? !this.values.has(name) : Boolean(force);
|
||
if (next) this.values.add(name); else this.values.delete(name);
|
||
return next;
|
||
}
|
||
}
|
||
class Element {
|
||
constructor(id = "", tagName = "div") {
|
||
this.tagName = String(tagName || "div").toUpperCase();
|
||
this.open = false;
|
||
this.id = id;
|
||
this.className = "";
|
||
this.classList = new ClassList();
|
||
this.dataset = {};
|
||
this.styles = new Map();
|
||
this.style = {
|
||
setProperty: (name, value) => this.styles.set(name, value),
|
||
removeProperty: name => this.styles.delete(name),
|
||
};
|
||
this.children = [];
|
||
this.textContent = "";
|
||
this.tabIndex = -1;
|
||
this.isConnected = true;
|
||
}
|
||
append(...children) { this.children.push(...children); }
|
||
replaceChildren(...children) { this.children = children; }
|
||
setAttribute() {}
|
||
addEventListener() {}
|
||
focus() {}
|
||
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
|
||
get offsetWidth() { return 100; }
|
||
}
|
||
|
||
function createHarness(existingStorage = null, search = "", options = {}) {
|
||
const storage = existingStorage || new Map();
|
||
const elements = new Map();
|
||
const ids = [
|
||
"achievementsBtn", "achievementButtonCount", "achievementsDialog", "achievementDialogCount",
|
||
"achievementsCloseBtn", "achievementsCloseIconBtn", "achievementRefreshBtn", "achievementResetBtn", "achievementUnlockAllBtn",
|
||
"achievementList", "achievementSharedStatus", "achievementToast", "achievementToastTitle", "pauseBtn",
|
||
];
|
||
ids.forEach(id => elements.set(id, new Element(id)));
|
||
elements.get("achievementsDialog").classList.add("hidden");
|
||
elements.get("achievementToast").classList.add("hidden");
|
||
let timerId = 0;
|
||
const context = {
|
||
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise,
|
||
Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
|
||
location: { protocol: options.protocol || "file:", search },
|
||
crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
|
||
localStorage: {
|
||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||
setItem(key, value) { storage.set(key, String(value)); },
|
||
removeItem(key) { storage.delete(key); },
|
||
},
|
||
document: {
|
||
hidden: false,
|
||
getElementById(id) { return elements.get(id) || null; },
|
||
createElement(tagName) { return new Element("", tagName); },
|
||
addEventListener() {},
|
||
},
|
||
TarinaiEvents: { emit() {} },
|
||
showToast() {},
|
||
audio: { uiClick() {} },
|
||
setTimeout() { timerId += 1; return timerId; },
|
||
clearTimeout() {},
|
||
setInterval() { timerId += 1; return timerId; },
|
||
clearInterval() {},
|
||
fetch: options.fetch || (async () => { throw new Error("offline"); }),
|
||
TARINAI_VERSION: "39.16.60",
|
||
TarinaiGameDialogs: { confirm: async () => true },
|
||
};
|
||
context.window = context;
|
||
context.globalThis = context;
|
||
vm.createContext(context);
|
||
vm.runInContext(source, context, { filename: "achievements.js" });
|
||
return { context, api: context.TarinaiAchievements, storage, elements };
|
||
}
|
||
|
||
function assert(condition, message) { if (!condition) throw new Error(message); }
|
||
function unlocked(h, id) { return h.api.isUnlocked(id); }
|
||
|
||
const expectedIds = [
|
||
"first_birth", "natural_zunchi_slave", "natural_tarinai_king", "self_zunchi_death",
|
||
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
|
||
"death_50_in_10_seconds", "birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
|
||
"soccer_ball_death", "fight_pair_danger_kill", "all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
||
"ignite_during_birth_ritual", "laxative_starvation", "idle_observer_5_minutes",
|
||
"placed_objects_100", "mechanized_industry", "safe_colony_25_5_minutes",
|
||
"ants_alive_25", "ants_killed_100", "secret_collection_9_slots",
|
||
"pause_spam_4_in_1_second", "continuous_play_1_hour", "below_absolute_zero_item",
|
||
"undo_mass_revival", "robot_cleaner_100", "held_30_seconds", "minimalist_happy",
|
||
"overprotective", "self_sufficient", "unplanned_city_30", "sauna_cold_plunge",
|
||
"rain_shelter_all", "medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
|
||
"revolution", "fuel_to_fire", "undo_20", "redo_20",
|
||
"eternal_history_generation_10", "king_full_satisfaction", "slave_zero_satisfaction", "town_doctor_50", "poke_plushie_fling",
|
||
"chaos_seeker_666_fights", "clean_freak_robot_only", "true_tarinai_observer",
|
||
"sniper_333_shots", "megalopolis", "fertility_seeker_721_love_births",
|
||
];
|
||
|
||
(async function run() {
|
||
let h = createHarness();
|
||
let storage;
|
||
assert(h.api.definitions.length === 54, "definition count must be 54");
|
||
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
|
||
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
||
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
||
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
|
||
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
|
||
h.api.open();
|
||
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
|
||
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
|
||
assert(titles.natural_tarinai_king === "たりない王", "king title mismatch");
|
||
assert(titles.self_zunchi_death === "あぁ^~ たまらねぇぜ", "self-zunchi title mismatch");
|
||
assert(titles.natural_zunchi_slave_5_generations === "末代までの恥", "five-generation title mismatch");
|
||
assert(titles.natural_tarinai_king_3_generations === "華麗なる王族", "three-generation title mismatch");
|
||
assert(titles.death_50_in_10_seconds === "6番目の大量絶滅", "mass-extinction title mismatch");
|
||
assert(titles.birth_50_in_60_seconds === "ベビーブーム", "baby-boom title mismatch");
|
||
assert(titles.idle_observer_5_minutes === "監視カメラ", "observer title mismatch");
|
||
assert(titles.laxative_starvation === "内臓全部出た", "laxative title mismatch");
|
||
assert(titles.colony_happy === "管理された楽園", "happy colony title mismatch");
|
||
assert(titles.soccer_ball_death === "豆野、殺ッカーやろうぜ!", "soccer title mismatch");
|
||
assert(titles.ants_alive_25 === "アリの惑星", "ant planet title mismatch");
|
||
assert(titles.all_non_sleep_diseased_25 === "緊急事態宣言", "emergency title mismatch");
|
||
assert(titles.secret_collection_9_slots === "秘密のコレクション", "save-slot title mismatch");
|
||
assert(titles.pause_spam_4_in_1_second === "何やってるの?", "pause-spam title mismatch");
|
||
assert(titles.continuous_play_1_hour === "めっちゃたりない観察", "continuous-play title mismatch");
|
||
assert(titles.below_absolute_zero_item === "物理法則を下側に超越", "absolute-zero title mismatch");
|
||
assert(titles.ignite_during_birth_ritual === "燃え上がる恋", "ritual ignition title mismatch");
|
||
assert(titles.safe_colony_25_5_minutes === "安全第一", "safe colony title mismatch");
|
||
assert(titles.great_mother_1000_births === "大いなる母", "great mother title mismatch");
|
||
assert(titles.fight_pair_danger_kill === "喧嘩両成敗", "fight-pair title mismatch");
|
||
assert(titles.direct_feed_33 === "餌やりじいさん", "feeding title mismatch");
|
||
assert(titles.eternal_history_generation_10 === "悠久の歴史", "generation title mismatch");
|
||
assert(titles.king_full_satisfaction === "五体満足", "king effect title mismatch");
|
||
assert(titles.slave_zero_satisfaction === "零体満足", "slave effect title mismatch");
|
||
assert(titles.chaos_seeker_666_fights === "混沌ヲ希求スル者", "chaos seeker title mismatch");
|
||
assert(titles.clean_freak_robot_only === "潔癖症", "clean freak title mismatch");
|
||
assert(titles.true_tarinai_observer === "真・たりない観察", "completionist title mismatch");
|
||
assert(titles.sniper_333_shots === "狙撃手", "sniper title mismatch");
|
||
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
|
||
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility title mismatch");
|
||
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
|
||
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
|
||
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king description mismatch");
|
||
assert(descriptions.self_zunchi_death === "おしり鋲を抜いた時、自分のずんちで自爆する。", "self-zunchi description mismatch");
|
||
assert(descriptions.natural_zunchi_slave_5_generations === "5代連続でずんちどれい(鎖なし)", "five-generation description mismatch");
|
||
assert(descriptions.natural_tarinai_king_3_generations === "3代連続でたりない王(王冠なし)", "three-generation description mismatch");
|
||
assert(descriptions.great_mother_1000_births.includes("3333体"), "great mother description threshold mismatch");
|
||
assert(descriptions.robot_cleaner_100.includes("200個"), "cleaner description threshold mismatch");
|
||
assert(descriptions.minimalist_happy.includes("6日目以降"), "minimalist start-day description mismatch");
|
||
assert(descriptions.chaos_seeker_666_fights.includes("けんか餅") && descriptions.chaos_seeker_666_fights.includes("666回"), "chaos seeker description mismatch");
|
||
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
|
||
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
|
||
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
|
||
assert(!source.includes("TarinaiTooltips"), "achievement tooltips were not removed");
|
||
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
|
||
function walk(node, out = []) { for (const child of node?.children || []) { out.push(child); walk(child, out); } return out; }
|
||
const cards = walk(h.elements.get("achievementList")).filter(node => node.dataset?.achievementId);
|
||
const firstBirthCard = cards.find(node => node.dataset.achievementId === "first_birth");
|
||
const disclosedSlaveCard = cards.find(node => node.dataset.achievementId === "natural_zunchi_slave");
|
||
const disclosedKingCard = cards.find(node => node.dataset.achievementId === "natural_tarinai_king");
|
||
const disclosedRevolutionCard = cards.find(node => node.dataset.achievementId === "revolution");
|
||
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
|
||
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
||
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
|
||
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
|
||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard]) {
|
||
assert(card.children[1].children[0].children[1].textContent !== "???", "requested pre-unlock condition is hidden");
|
||
}
|
||
assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks");
|
||
const groups = h.elements.get("achievementList").children;
|
||
assert(groups.length === 4, "achievement UI must contain exactly four category groups");
|
||
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "experiment", "construction", "operation"]), "achievement category order mismatch");
|
||
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
||
const categoryTitles = h.api.categories.map(category => category.title);
|
||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
||
h.api.unlock("natural_zunchi_slave");
|
||
const rerenderedGroups = h.elements.get("achievementList").children;
|
||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||
const allRenderedCards = rerenderedGroups.flatMap(group => walk(group).filter(node => node.dataset?.achievementId));
|
||
assert(ecologyCards[0]?.dataset.achievementId === "natural_zunchi_slave", "unlocked achievement was not moved to the top of its category");
|
||
assert(allRenderedCards.filter(card => card.dataset.achievementId === "natural_zunchi_slave").length === 1, "unlocked achievement is duplicated across categories");
|
||
assert(await h.api.reset(), "in-game achievement reset confirmation was not accepted");
|
||
assert(!h.api.isUnlocked("natural_zunchi_slave"), "achievement reset did not clear unlocked state");
|
||
assert(source.includes('card.style.setProperty("--achievement-rate"'), "achievement percentage bar is not wired");
|
||
|
||
h = createHarness();
|
||
h.api.recordBirth({ world: { time: 0 } });
|
||
assert(unlocked(h, "first_birth"), "first birth did not unlock");
|
||
|
||
h = createHarness();
|
||
const birthWorld = { time: 0 };
|
||
for (let i = 0; i < 29; i += 1) { birthWorld.time = i; h.api.recordBirth({ world: birthWorld }); }
|
||
assert(!unlocked(h, "birth_50_in_60_seconds"), "rapid birth unlocked before 30");
|
||
birthWorld.time = 59;
|
||
h.api.recordBirth({ world: birthWorld });
|
||
assert(unlocked(h, "birth_50_in_60_seconds"), "rapid birth did not unlock at 30");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 2000; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
|
||
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked before 3333");
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 1332; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
|
||
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked at 3332");
|
||
h.api.recordBirth({ world: { time: 1 } });
|
||
assert(unlocked(h, "great_mother_1000_births"), "great mother did not persist to 3333");
|
||
|
||
h = createHarness();
|
||
const deathWorld = { time: 0 };
|
||
for (let i = 0; i < 49; i += 1) { deathWorld.time = i * 0.19; h.api.recordDeath({ world: deathWorld, reason: "accident" }); }
|
||
assert(!unlocked(h, "death_50_in_10_seconds"), "rapid death unlocked before 50");
|
||
deathWorld.time = 9.5;
|
||
h.api.recordDeath({ world: deathWorld, reason: "accident" });
|
||
assert(unlocked(h, "death_50_in_10_seconds"), "rapid death did not unlock at 50");
|
||
|
||
h = createHarness();
|
||
h.api.recordDeath({ world: { time: 1 }, tarinai: {}, reason: "天寿を全うした" });
|
||
assert(unlocked(h, "lifespan_completed"), "lifespan did not unlock");
|
||
h = createHarness();
|
||
h.api.recordDeath({ world: { time: 1 }, tarinai: { _achievementLaxativeActiveThisFrame: true }, reason: "飢餓" });
|
||
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
|
||
|
||
h = createHarness();
|
||
const slaveWorld = {};
|
||
[1, 2, 4, 5, 6].forEach(generation => h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation } }));
|
||
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "slave run ignored generation gap");
|
||
h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation: 3 } });
|
||
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "slave five-generation run did not unlock");
|
||
|
||
h = createHarness();
|
||
const kingWorld = {};
|
||
[4, 5, 6].forEach(generation => h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: { generation } }));
|
||
assert(unlocked(h, "natural_tarinai_king_3_generations"), "king three-generation run did not unlock");
|
||
|
||
h = createHarness();
|
||
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
|
||
for (let i = 0; i < 32; i += 1) h.api.recordDirectFeed({ world: feedWorldA });
|
||
assert(!unlocked(h, "direct_feed_33"), "direct feed unlocked before 33");
|
||
const feedWorldB = { time: 0, achievementDirectFeedCount: 0 };
|
||
h.api.recordDirectFeed({ world: feedWorldB });
|
||
assert(!unlocked(h, "direct_feed_33"), "direct-feed count survived a field reset");
|
||
h.api.recordDirectFeed({ world: feedWorldA });
|
||
assert(unlocked(h, "direct_feed_33"), "direct feed did not unlock at 33 in one field");
|
||
|
||
h = createHarness();
|
||
const diseased = Array.from({ length: 25 }, () => ({ fightDisease: true, dead: false }));
|
||
h.api.evaluateWorld({ tarinai: diseased, ants: [], time: 0 }, { id: "relaxed" });
|
||
assert(unlocked(h, "all_non_sleep_diseased_25"), "disease colony did not unlock");
|
||
h = createHarness();
|
||
const happyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
|
||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||
happyWorld.time = 719.99;
|
||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||
assert(!unlocked(h, "colony_happy"), "happy colony unlocked before one day");
|
||
happyWorld.time = 720;
|
||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||
assert(unlocked(h, "colony_happy"), "happy colony did not unlock after one day from day 6");
|
||
|
||
h = createHarness();
|
||
const interruptedHappyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
|
||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||
interruptedHappyWorld.time = 650;
|
||
h.api.evaluateWorld(interruptedHappyWorld, { id: "normal" });
|
||
interruptedHappyWorld.time = 700;
|
||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||
interruptedHappyWorld.time = 819.99;
|
||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||
assert(!unlocked(h, "colony_happy"), "interrupted happiness did not restart the one-day timer");
|
||
interruptedHappyWorld.time = 820;
|
||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||
assert(unlocked(h, "colony_happy"), "happy colony did not unlock one full day after restart");
|
||
|
||
h = createHarness();
|
||
h.api.recordIgnition({});
|
||
h.api.recordSelfZunchiDeath({});
|
||
h.api.recordSoccerBallDeath({});
|
||
assert(unlocked(h, "ignite_during_birth_ritual"), "ritual ignition failed");
|
||
assert(unlocked(h, "self_zunchi_death"), "self zunchi failed");
|
||
assert(unlocked(h, "soccer_ball_death"), "soccer death failed");
|
||
|
||
|
||
h = createHarness();
|
||
const dangerItem = { id: "danger-1", type: "firecracker", roles: { danger: true } };
|
||
const fighterA = { id: "a", dead: true, fightTimer: 2, fightTargetId: "b", fightTargetIds: ["b"] };
|
||
const fighterB = { id: "b", dead: false, fightTimer: 2, fightTargetId: "a", fightTargetIds: ["a"] };
|
||
const fightWorld = {
|
||
time: 5,
|
||
tarinai: [fighterA, fighterB],
|
||
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
|
||
isAlreadyFightingPair() { return true; },
|
||
};
|
||
fighterA.world = fightWorld; fighterB.world = fightWorld;
|
||
h.api.recordDamageSource(fighterA, dangerItem, { world: fightWorld });
|
||
h.api.recordDeath({ world: fightWorld, tarinai: fighterA, reason: "爆竹", fromDamage: true });
|
||
assert(!unlocked(h, "fight_pair_danger_kill"), "fight pair unlocked after only one death");
|
||
fightWorld.time = 6;
|
||
fighterB.dead = true; fighterB.fightTimer = 0; fighterB.fightTargetId = null; fighterB.fightTargetIds = [];
|
||
h.api.recordDamageSource(fighterB, dangerItem, { world: fightWorld });
|
||
h.api.recordDeath({ world: fightWorld, tarinai: fighterB, reason: "爆竹", fromDamage: true });
|
||
assert(unlocked(h, "fight_pair_danger_kill"), "fight pair danger kill did not unlock");
|
||
|
||
h = createHarness();
|
||
const falsePositiveA = { id: "fa", dead: true, fightTimer: 2, fightTargetId: "fb", fightTargetIds: ["fb"] };
|
||
const falsePositiveB = { id: "fb", dead: false, fightTimer: 2, fightTargetId: "fa", fightTargetIds: ["fa"] };
|
||
const falsePositiveWorld = {
|
||
time: 3,
|
||
tarinai: [falsePositiveA, falsePositiveB],
|
||
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
|
||
isAlreadyFightingPair() { return true; },
|
||
};
|
||
falsePositiveA.world = falsePositiveWorld; falsePositiveB.world = falsePositiveWorld;
|
||
h.api.recordDamageSource(falsePositiveA, dangerItem, { world: falsePositiveWorld });
|
||
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveA, reason: "飢餓", fromDamage: false });
|
||
falsePositiveWorld.time = 4;
|
||
falsePositiveB.dead = true;
|
||
h.api.recordDamageSource(falsePositiveB, dangerItem, { world: falsePositiveWorld });
|
||
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveB, reason: "爆竹", fromDamage: true });
|
||
assert(!unlocked(h, "fight_pair_danger_kill"), "non-lethal danger damage was misclassified as a danger kill");
|
||
|
||
h = createHarness();
|
||
const generationWorld = { time: 0, maxGeneration: 9 };
|
||
h.api.recordBirth({ world: generationWorld, child: { generation: 9, world: generationWorld } });
|
||
assert(!unlocked(h, "eternal_history_generation_10"), "generation history unlocked before generation 10");
|
||
generationWorld.maxGeneration = 10;
|
||
h.api.recordBirth({ world: generationWorld, child: { generation: 10, world: generationWorld } });
|
||
assert(unlocked(h, "eternal_history_generation_10"), "generation history did not unlock at generation 10");
|
||
|
||
h = createHarness();
|
||
const king = { dead: false, isTarinaiChampion: true, isZunchiSlave: false, powerItemMode: "protein", sizeItemMode: "", world: {} };
|
||
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "protein" });
|
||
assert(!unlocked(h, "king_full_satisfaction"), "king satisfaction unlocked without giant drug");
|
||
king.sizeItemMode = "giant_drug";
|
||
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "giant_drug" });
|
||
assert(unlocked(h, "king_full_satisfaction"), "king satisfaction did not unlock with both effects");
|
||
|
||
h = createHarness();
|
||
const slave = { dead: false, isTarinaiChampion: false, isZunchiSlave: true, powerItemMode: "niteropu", sizeItemMode: "", world: {} };
|
||
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "niteropu" });
|
||
assert(!unlocked(h, "slave_zero_satisfaction"), "slave satisfaction unlocked without dwarf drug");
|
||
slave.sizeItemMode = "dwarf_drug";
|
||
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "dwarf_drug" });
|
||
assert(unlocked(h, "slave_zero_satisfaction"), "slave satisfaction did not unlock with both effects");
|
||
|
||
h = createHarness();
|
||
const allExceptCompletionist = expectedIds.filter(id => id !== "true_tarinai_observer");
|
||
for (let i = 0; i < allExceptCompletionist.length - 1; i += 1) h.api.unlock(allExceptCompletionist[i]);
|
||
assert(!unlocked(h, "true_tarinai_observer"), "completionist unlocked before every other achievement");
|
||
h.api.unlock(allExceptCompletionist[allExceptCompletionist.length - 1]);
|
||
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
|
||
|
||
h = createHarness();
|
||
const idleWorld = { time: 300, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
|
||
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 60; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||
assert(!unlocked(h, "placed_objects_100"), "placement unlocked before 100");
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 39; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||
assert(!unlocked(h, "placed_objects_100"), "placement unlocked at 99");
|
||
h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||
assert(unlocked(h, "placed_objects_100"), "placement did not persist across field reset/reload to 100");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
h.api.recordLinkPlaced("wire", { world: { time: 1 }, item: { type: "wire" } });
|
||
h.api.recordLinkPlaced("insulated_wire", { world: { time: 2 }, item: { type: "insulated_wire" } });
|
||
const wireProgress = JSON.parse(storage.get("tarinai_achievements_v2")).progress;
|
||
assert(wireProgress.placementCount === 2, "wire placements were not counted toward placed objects");
|
||
assert(wireProgress.linkTypes.length === 0, "wire placements incorrectly advanced mechanized link types");
|
||
|
||
h = createHarness();
|
||
h.api.recordLinkPlaced("rope", { world: { time: 1 } });
|
||
h.api.recordLinkPlaced("rod", { world: { time: 1 } });
|
||
h.api.recordLinkPlaced("spring", { world: { time: 1 } });
|
||
assert(!unlocked(h, "mechanized_industry"), "mechanized industry unlocked without signal");
|
||
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
|
||
assert(unlocked(h, "mechanized_industry"), "mechanized industry did not unlock after logical AND");
|
||
assert(!unlocked(h, "signal_automation_first") && !unlocked(h, "link_craftsman"), "removed achievements remain accessible");
|
||
|
||
h = createHarness();
|
||
const safeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
|
||
const safeWorld = { time: 0, tarinai: safeAlive, ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||
safeWorld.time = 300;
|
||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
|
||
|
||
h = createHarness();
|
||
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||
h.api.evaluateWorld(antWorld, { id: "relaxed" });
|
||
assert(unlocked(h, "ants_alive_25"), "ant herd did not unlock at 25");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {} });
|
||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {} });
|
||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
|
||
h.api.recordAntKilled({ world: {} });
|
||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
||
|
||
h = createHarness();
|
||
h.api.recordSaveSlotsFilled({ filled: 8 });
|
||
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
|
||
h.api.recordSaveSlotsFilled({ filled: 9 });
|
||
assert(unlocked(h, "secret_collection_9_slots"), "save-slot collection did not unlock at 9");
|
||
|
||
h = createHarness();
|
||
[0, 250, 500].forEach(now => h.api.recordPauseClick({ now }));
|
||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked before four clicks");
|
||
h.api.recordPauseClick({ now: 999 });
|
||
assert(unlocked(h, "pause_spam_4_in_1_second"), "pause-spam did not unlock within one second");
|
||
h = createHarness();
|
||
[0, 400, 800, 1201].forEach(now => h.api.recordPauseClick({ now }));
|
||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked outside one-second window");
|
||
|
||
h = createHarness();
|
||
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
|
||
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
|
||
h.api.recordContinuousPlay({ elapsedMs: 3600000 });
|
||
assert(unlocked(h, "continuous_play_1_hour"), "continuous play did not unlock at one hour");
|
||
|
||
h = createHarness();
|
||
const thermalWorld = {
|
||
time: 0, tarinai: [], ants: [], items: [{ id: "cold", x: 10, y: 20, dead: false }],
|
||
temperatureAt() { return -273.15; }, achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1,
|
||
};
|
||
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
|
||
assert(!unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement unlocked at exactly absolute zero");
|
||
thermalWorld.temperatureAt = () => -273.16;
|
||
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
|
||
assert(unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement did not unlock below absolute zero");
|
||
|
||
storage = new Map();
|
||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||
unlocked: { signal_automation_first: 1000, link_craftsman: 2000 },
|
||
pending: ["signal_automation_first", "link_craftsman"],
|
||
progress: { linkTypes: ["rope", "rod", "spring"] },
|
||
}));
|
||
h = createHarness(storage);
|
||
assert(unlocked(h, "mechanized_industry"), "legacy AND achievements were not migrated");
|
||
|
||
storage = new Map();
|
||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||
unlocked: { link_craftsman: 2000 },
|
||
pending: [],
|
||
progress: {},
|
||
}));
|
||
h = createHarness(storage);
|
||
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
|
||
assert(unlocked(h, "mechanized_industry"), "legacy link-craftsman progress was not reconstructed before a later signal");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage, "?debug=1");
|
||
assert(h.elements.get("achievementUnlockAllBtn").hidden === false, "debug all-achievements button was not shown");
|
||
h.api.debugUnlockAll();
|
||
assert(h.api.definitions.every(def => unlocked(h, def.id)), "debug all-achievements button did not unlock all");
|
||
const debugState = JSON.parse(storage.get("tarinai_achievements_v2"));
|
||
assert(debugState.pending.length === 0, "debug unlocks were queued for shared statistics");
|
||
assert(debugState.debugUnlocked.length === h.api.definitions.length, "debug unlocks were not marked as local-only");
|
||
h = createHarness(storage, "");
|
||
assert(h.api.definitions.every(def => !unlocked(h, def.id)), "debug-only unlocks leaked into normal play");
|
||
|
||
storage = new Map();
|
||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||
unlocked: { first_birth: 1700000000123, lifespan_completed: 1700000000456 },
|
||
pending: [],
|
||
progress: {},
|
||
}));
|
||
const recoveryRequests = [];
|
||
h = createHarness(storage, "", {
|
||
protocol: "https:",
|
||
fetch: async (_url, options = {}) => {
|
||
recoveryRequests.push(JSON.parse(options.body || "{}"));
|
||
return { ok: true, async json() { return { ok: true, totalPlayers: 1, achievements: {} }; } };
|
||
},
|
||
});
|
||
await h.api.refresh();
|
||
assert(recoveryRequests.length === 1, "recovery sync did not issue exactly one request");
|
||
assert(recoveryRequests[0].action === "sync", "recovery request did not use bulk sync");
|
||
assert(recoveryRequests[0].completionistEligible === false, "full recovery sync did not report completionist eligibility");
|
||
assert(recoveryRequests[0].unlocked.first_birth === 1700000000123, "recovery sync omitted first birth timestamp");
|
||
assert(recoveryRequests[0].unlocked.lifespan_completed === 1700000000456, "recovery sync omitted lifespan timestamp");
|
||
|
||
h = createHarness();
|
||
for (let i = 0; i < 49; i += 1) h.api.recordFirstAidRecovery({ recovered: 10 });
|
||
assert(!unlocked(h, "town_doctor_50"), "doctor unlocked before 50 recoveries");
|
||
h.api.recordFirstAidRecovery({ recovered: 0 });
|
||
assert(!unlocked(h, "town_doctor_50"), "zero recovery counted for doctor");
|
||
h.api.recordFirstAidRecovery({ recovered: 1 });
|
||
assert(unlocked(h, "town_doctor_50"), "doctor did not unlock at 50 recoveries");
|
||
|
||
h = createHarness();
|
||
h.api.recordPlushiePokeFling({});
|
||
assert(unlocked(h, "poke_plushie_fling"), "plushie poke achievement did not unlock");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
const chaosWorld = { time: 0 };
|
||
for (let i = 0; i < 700; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||
assert(!unlocked(h, "chaos_seeker_666_fights"), "ordinary fights counted toward chaos seeker");
|
||
const fightMochiTarinai = { fightMochiTimer: 10 };
|
||
for (let i = 0; i < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
|
||
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 influenced fights");
|
||
h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
|
||
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 influenced fights");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 332; i += 1) h.api.recordShotFired({});
|
||
assert(!unlocked(h, "sniper_333_shots"), "sniper unlocked before 333 shots");
|
||
h = createHarness(storage);
|
||
h.api.recordShotFired({});
|
||
assert(unlocked(h, "sniper_333_shots"), "sniper did not persist to 333 shots");
|
||
|
||
storage = new Map();
|
||
h = createHarness(storage);
|
||
for (let i = 0; i < 800; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: false });
|
||
assert(!unlocked(h, "fertility_seeker_721_love_births"), "ordinary reproduction counted toward fertility seeker");
|
||
for (let i = 0; i < 720; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
|
||
assert(!unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker unlocked before 721 influenced reproductions");
|
||
h = createHarness(storage);
|
||
h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
|
||
assert(unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker did not persist to 721 influenced reproductions");
|
||
|
||
h = createHarness();
|
||
const metroWorld = {
|
||
tarinai: Array.from({ length: 99 }, () => ({ dead: false })),
|
||
items: [
|
||
...Array.from({ length: 10 }, () => ({ type: "duplicator", dead: false })),
|
||
...Array.from({ length: 15 }, (_v, i) => ({ type: i % 3 === 0 ? "grass_bed" : (i % 3 === 1 ? "nest_box" : "pipe"), dead: false })),
|
||
],
|
||
};
|
||
h.api.evaluateMegalopolis(metroWorld);
|
||
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 100-tarinai threshold");
|
||
metroWorld.tarinai.push({ dead: false });
|
||
metroWorld.items[0].dead = true;
|
||
h.api.evaluateMegalopolis(metroWorld);
|
||
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 10-duplicator threshold");
|
||
metroWorld.items[0].dead = false;
|
||
metroWorld.items[10].dead = true;
|
||
h.api.evaluateMegalopolis(metroWorld);
|
||
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 15-shelter threshold");
|
||
metroWorld.items[10].dead = false;
|
||
h.api.evaluateMegalopolis(metroWorld);
|
||
assert(unlocked(h, "megalopolis"), "megalopolis did not unlock at exact thresholds");
|
||
|
||
h = createHarness();
|
||
const cleaner = { type: "robot_cleaner", dead: false, robotCleanerMask: 0x3f, robotCleanerHighSpeed: true, _signalDriven: true, _signalOn: false };
|
||
const cleanWorld = { items: [cleaner], tarinai: [], ants: [], time: 0 };
|
||
h.api.evaluateCleanFreak(cleanWorld);
|
||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak unlocked while signal-powered cleaner was off");
|
||
cleaner._signalOn = true;
|
||
cleanWorld.tarinai.push({ dead: false });
|
||
h.api.evaluateCleanFreak(cleanWorld);
|
||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored a living tarinai");
|
||
cleanWorld.tarinai.length = 0;
|
||
cleanWorld.items.push({ type: "grass", dead: false });
|
||
h.api.evaluateCleanFreak(cleanWorld);
|
||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored another field item");
|
||
cleanWorld.items.pop();
|
||
h.api.evaluateCleanFreak(cleanWorld);
|
||
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
||
|
||
console.log("[OK] all 54 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||
})().catch(error => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|