p
This commit is contained in:
parent
d14550dad6
commit
63e10af865
86 changed files with 5400 additions and 1209 deletions
539
scripts/achievement_audit.js
Normal file
539
scripts/achievement_audit.js
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"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.56",
|
||||
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",
|
||||
];
|
||||
|
||||
(async function run() {
|
||||
let h = createHarness();
|
||||
let storage;
|
||||
assert(h.api.definitions.length === 51, "definition count must be 51");
|
||||
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");
|
||||
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("666回"), "chaos seeker 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].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 < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 fights");
|
||||
h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 fights");
|
||||
|
||||
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 51 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
106
scripts/achievement_integration_audit.py
Normal file
106
scripts/achievement_integration_audit.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(name):
|
||||
return (ROOT / name).read_text()
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
ach = read('js/achievements.js')
|
||||
api = read('achievement_api.php')
|
||||
css = read('css/components.css')
|
||||
|
||||
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
|
||||
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
|
||||
require(len(ids_js) == 51, 'JS achievement count is not 51')
|
||||
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
|
||||
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
|
||||
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
|
||||
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
|
||||
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
|
||||
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['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']), 'v56 achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['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']), 'playstyle achievement IDs are missing')
|
||||
|
||||
checks = {
|
||||
'birth event': 'recordBirth?.({ world: this, child, parents: [a, b] })' in read('js/world_family_social.js'),
|
||||
'death event after final reason': 'recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) })' in read('js/tarinai_social_move_life.js'),
|
||||
'natural slave event only in fight-record path': 'recordNaturalZunchiSlave?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
|
||||
'natural king event only in fight-record path': 'recordNaturalTarinaiKing?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
|
||||
'direct feed is food-only': 'if (foodGift) {' in read('js/tarinai_direct_feeding_system.js') and 'recordDirectFeed?.' in read('js/tarinai_direct_feeding_system.js'),
|
||||
'ritual ignition checks state before mutation': 'const ignitedDuringBirthRitual = !already' in read('js/tarinai_disease_nest.js'),
|
||||
'soccer unlock follows fatal ball impact': 'if (t.dead && ball.type === "ball")' in read('js/collision_response_system.js'),
|
||||
'self-zunchi unlock checks owner and never-stopped': 'item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false' in read('js/item_dynamic_zunchi_system.js'),
|
||||
'held launched zunchi is disqualified': 'if (item.playerHeld || item._heldByPlayer)' in read('js/item_dynamic_zunchi_system.js') and 'target.achievementBurstNeverStopped = false' in read('js/ui_input_shared.js'),
|
||||
'generation runs persist in saves': 'achievementNaturalSlaveGenerations' in read('js/snapshot_system.js') and 'achievementNaturalKingGenerations' in read('js/snapshot_system.js'),
|
||||
'direct-feed field progress persists in saves': 'achievementDirectFeedCount' in read('js/snapshot_system.js'),
|
||||
'direct-feed field progress resets with world': 'this.achievementDirectFeedCount = 0;' in read('js/world.js'),
|
||||
'simultaneous toast queue exists': 'let toastQueue = [];' in ach and 'runNextUnlockToast' in ach,
|
||||
'achievement toast is top-left': 'top: 18px;' in css and 'left: 18px;' in css and 'right: auto;' in css,
|
||||
'laxative frame-edge marker exists': '_achievementLaxativeActiveThisFrame' in read('js/tarinai_update_step_frame.js') and '_achievementLaxativeActiveThisFrame' in ach,
|
||||
'disease condition excludes sleep-only': 't.zunchiDisease || t.explosionDisease || t.fightDisease' in ach,
|
||||
'locked condition display policy': 'lockedDescriptionText' in ach and '\\uff1f\\uff1f\\uff1f' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach,
|
||||
'condition shown beside title': 'heading.append(title, description);' in ach,
|
||||
'achievement tooltips removed': 'TarinaiTooltips' not in ach and 'dataset.tip' not in ach and 'achievementTooltipText' not in ach,
|
||||
'percentage background bar including unlocked cards': '--achievement-rate' in ach and '.achievement-entry::before' in css and 'width: var(--achievement-rate);' in css and '.achievement-entry > *' in css and 'z-index: 0;' in css,
|
||||
'achievement category folds': 'ACHIEVEMENT_CATEGORIES' in ach and 'createAchievementGroup' in ach and 'achievement-group' in css,
|
||||
'category-local unlocked ordering': 'orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]' in ach and 'id: "completed"' not in ach,
|
||||
'requested pre-unlock disclosures': all(item in ach.split('const PRE_UNLOCK_DESCRIPTION_IDS', 1)[1].split(']);', 1)[0] for item in ['natural_zunchi_slave','natural_tarinai_king','revolution','laxative_starvation']),
|
||||
'native browser confirmations removed': not re.search(r'\b(?:alert|confirm|prompt)\s*\(', ach + read('js/save_system.js')) and 'TarinaiGameDialogs' in ach and 'TarinaiGameDialogs' in read('js/save_system.js'),
|
||||
'achiever count beside percentage': 'unlockedPlayers' in ach and 'sharedAchievementText' in ach and '\\u9054\\u6210\\u8005' in ach,
|
||||
'persistent placement count': 'progress.placementCount' in ach and 'unlock("placed_objects_100"' in ach,
|
||||
'mechanized logical AND': 'progress.signalActivated && hasAllLinks' in ach,
|
||||
'persistent ant kill count': 'progress.antKills' in ach and 'unlock("ants_killed_100"' in ach,
|
||||
'persistent reproduction count': 'progress.birthCount' in ach and 'progress.birthCount >= 3333' in ach and 'unlock("great_mother_1000_births"' in ach,
|
||||
'danger damage source pipeline': 'recordDamageSource' in ach and 'options.source' in read('js/health.js') and '{ source: it }' in read('js/world_combat_effects.js') and 'type: "shoot"' in read('js/world_tool_actions.js') and '_achievementDangerSource = pin' in read('js/world_combat_effects.js') and 'recordDamageSource?.(target, home' in read('js/world_ants_system.js'),
|
||||
'fight-pair danger tracker': 'recordFightPairDangerDeath' in ach and 'fight_pair_danger_kill' in ach and 'detail.fromDamage || detail.achievementDangerKill' in ach,
|
||||
'debug unlock-all button': 'achievementUnlockAllBtn' in read('index.html') and 'unlockAllAchievementsForDebug' in ach and 'DEBUG_MODE' in ach and 'debugUnlocked' in ach,
|
||||
'idle intervention hooks': 'recordIntervention?.({ world: this, tool: "poke"' in read('js/world_placement_log.js') and 'recordIntervention?.({ world, tool: "pinch"' in read('js/ui_input_shared.js') and 'recordIntervention?.({ world: this, tool: "shoot"' in read('js/world_tool_actions.js'),
|
||||
'link placement hook': 'recordLinkPlaced?.(constraintType' in read('js/world_tool_actions.js') and 'insulated_wire' in ach and 'recordPlayerPlacement(detail);' in ach,
|
||||
'signal activation hook': 'recordSignalActivation?.({ world: worldRef' in read('js/signal_system.js'),
|
||||
'ant kill hook': 'recordAntKilled?.({ world: this.world, ant: this' in read('js/ants.js'),
|
||||
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
|
||||
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
|
||||
'safe colony timer': 'now - safeStart >= 300' in ach,
|
||||
'idle observer timer': 'now - lastInterventionAt >= 300' in ach,
|
||||
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
|
||||
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
|
||||
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
|
||||
'pause-spam hook': 'pauseButton' in ach and 'recordPauseClick' in ach and 'pauseBtn' in read('index.html'),
|
||||
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach,
|
||||
'absolute-zero item evaluation': 'ABSOLUTE_ZERO_C' in ach and 'recordBelowAbsoluteZeroItem(worldRef)' in ach and 'temperatureAt?.(item.x, item.y, null)' in ach,
|
||||
'recovery sync is additive': "if (isset($state['unlocks'][$achievementId][$playerId])) continue;" in api,
|
||||
'debug unlocks excluded from recovery': 'state.debugUnlocked?.includes?.(definition.id)' in ach,
|
||||
'hidden achievement UI skips list rebuild': 'dom.dialog?.classList.contains("hidden")' in ach and 'achievementListSignature' in ach,
|
||||
'achievement category definitions are cached': 'CATEGORY_DEFINITIONS' in ach,
|
||||
'achievement sync is debounced and pending-only': 'SYNC_DEBOUNCE_MS' in ach and 'schedulePendingSync' in ach and 'pendingIds' in ach,
|
||||
'completionist excludes itself': 'definition.id === COMPLETIONIST_ID' in ach and 'allOtherAchievementsUnlocked' in ach,
|
||||
'generation ten is event-driven': 'generation >= GENERATION_TARGET' in ach and 'eternal_history_generation_10' in ach,
|
||||
'status effect combinations are event-driven': 'evaluateStatusEffectAchievements' in ach and 'king_full_satisfaction' in ach and 'slave_zero_satisfaction' in ach,
|
||||
'doctor counts actual recovery only': 'recordFirstAidRecovery' in ach and 'recovered > 0.0001' in read('js/item_effect_definitions.js'),
|
||||
'plushie poke uses shared target and overlay': 'findPokeTargetAt' in read('js/world_placement_log.js') and 'hoverPokeTarget' in read('js/ui_input_shared.js') and 'recordPlushiePokeFling' in ach,
|
||||
'persistent chaos fight counter': 'progress.fightCount' in ach and 'CHAOS_FIGHT_TARGET = 666' in ach and 'recordFightStarted' in ach,
|
||||
'clean-freak robot-only evaluation': 'evaluateCleanFreak' in ach and 'ROBOT_ALL_TARGET_MASK = 0x3f' in ach and 'robotCleanerHighSpeed' in ach and 'signal-power' in read('js/signal_system.js'),
|
||||
'medicine ledger excludes chain and crown': 'slave_chain' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0] and 'golden_crown' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0],
|
||||
'cleaner target raised to 200': 'ROBOT_CLEAN_TARGET = 200' in ach and r'\u7d2f\u8a08200\u500b' in ach,
|
||||
'minimalist starts on day six': 'day >= 6 && !state.unlocked.minimalist_happy' in ach,
|
||||
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
|
||||
'compact server state v2': 'function compact_state' in api and "['v' => 2, 'p' => $players, 'u' => $unlocks]" in api,
|
||||
}
|
||||
for name, ok in checks.items():
|
||||
require(ok, name)
|
||||
|
||||
social = read('js/world_family_social.js') + read('js/tarinai_social_action_runtime.js')
|
||||
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
|
||||
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
|
||||
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
|
||||
require('legacyLinkTypes' in ach, 'legacy link-craftsman partial migration is missing')
|
||||
|
||||
print('[OK] achievement integration audit passed')
|
||||
for name in checks:
|
||||
print(f'[OK] {name}')
|
||||
58
scripts/achievement_save_metadata_audit.js
Normal file
58
scripts/achievement_save_metadata_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
const root = path.join(__dirname, "..");
|
||||
const context = {
|
||||
console,
|
||||
Date,
|
||||
Math,
|
||||
JSON,
|
||||
Object,
|
||||
Array,
|
||||
Map,
|
||||
Set,
|
||||
Uint8Array,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
performance,
|
||||
location: { search: "" },
|
||||
CompressionStream: undefined,
|
||||
DecompressionStream: undefined,
|
||||
TarinaiSnapshot: { version: 35 },
|
||||
TarinaiSaveSchema: {
|
||||
BINARY_SCHEMA_VERSION: 41,
|
||||
FIELD_IDS: ["garden"],
|
||||
itemTypeValue(_id, fallback = "") { return fallback; },
|
||||
},
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, "js", "save_codec.js"), "utf8"), context, { filename: "save_codec.js" });
|
||||
|
||||
(async () => {
|
||||
const metadata = {
|
||||
w: [[1, 2], [3], 12, 87, 6, 145.5, 20, 18, 100, 90],
|
||||
t: [[10, 3, 120, 130]],
|
||||
i: [[1, 121]],
|
||||
};
|
||||
const snapshot = {
|
||||
v: 35,
|
||||
a: "tj1",
|
||||
m: [1, 0, "garden"],
|
||||
w: [0, 0, 0, 120, 0, -9990, 1, 0, "seed", 0, 0, 0],
|
||||
t: [],
|
||||
i: [],
|
||||
g: metadata,
|
||||
};
|
||||
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
|
||||
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
|
||||
if (JSON.stringify(decoded.g) !== JSON.stringify(metadata)) throw new Error("achievement metadata changed during binary save roundtrip");
|
||||
console.log("[OK] achievement metadata survives current binary save roundtrip");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
186
scripts/playstyle_achievement_audit.js
Normal file
186
scripts/playstyle_achievement_audit.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const source = fs.readFileSync(path.join(root, "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 = "") { this.id = id; this.className = ""; this.classList = new ClassList(); this.dataset = {}; this.styles = new Map(); this.style = { setProperty: (n,v)=>this.styles.set(n,v), removeProperty:n=>this.styles.delete(n) }; this.children=[]; this.textContent=""; this.tabIndex=-1; this.isConnected=true; this.hidden=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 harness(storage = new Map()) {
|
||||
const elements = new Map();
|
||||
["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"].forEach(id=>elements.set(id,new Element(id)));
|
||||
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.has(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(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.56"};
|
||||
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
|
||||
return {c,api:c.TarinaiAchievements,storage,elements};
|
||||
}
|
||||
function assert(v,m){if(!v)throw new Error(m);} function off(h,id){return !h.api.isUnlocked(id);} function on(h,id){return h.api.isUnlocked(id);}
|
||||
function alive(n){return Array.from({length:n},(_,i)=>({id:`t${i}`,dead:false,x:i,y:0}));}
|
||||
function findCard(h,id){const stack=[h.elements.get("achievementList")];while(stack.length){const node=stack.shift();if(node?.dataset?.achievementId===id)return node;stack.push(...(node?.children||[]));}return null;}
|
||||
|
||||
(function run(){
|
||||
let h=harness();
|
||||
assert(h.api.definitions.length===51,"must have 51 achievements");
|
||||
h.api.open();
|
||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||||
assert(title.mercury_lifespan==="不老不死まであと一歩","mercury title");
|
||||
const description=(id)=>findCard(h,id).children[1].children[0].children[1].textContent;
|
||||
assert(description("robot_cleaner_100").includes("ずんち・死骸・汚れを累計200個処理させる。"),"cleaner condition disclosed in UI");
|
||||
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
||||
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
||||
assert(description("overprotective")==="???","other new condition hidden");
|
||||
|
||||
// One Undo only: multiple smaller Undo operations never aggregate.
|
||||
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
||||
assert(off(h,"undo_mass_revival"),"mass undo aggregated across operations");
|
||||
h.api.recordHistoryAction("undo",{deadRestored:10}); assert(on(h,"undo_mass_revival"),"mass undo boundary");
|
||||
|
||||
// Undo/Redo counters persist across field resets and reloads.
|
||||
let storage=new Map(); h=harness(storage);
|
||||
for(let i=0;i<12;i++)h.api.recordHistoryAction("undo",{});
|
||||
h.api.resetWorldProgress({});
|
||||
h=harness(storage); for(let i=0;i<7;i++)h.api.recordHistoryAction("undo",{});
|
||||
assert(off(h,"undo_20"),"undo unlocked at 19"); h.api.recordHistoryAction("undo",{}); assert(on(h,"undo_20"),"undo did not persist to 20");
|
||||
for(let i=0;i<11;i++)h.api.recordHistoryAction("redo",{}); h=harness(storage); for(let i=0;i<8;i++)h.api.recordHistoryAction("redo",{});
|
||||
assert(off(h,"redo_20"),"redo unlocked at 19"); h.api.recordHistoryAction("redo",{}); assert(on(h,"redo_20"),"redo did not persist to 20");
|
||||
|
||||
// Cleaner is field-local.
|
||||
h=harness(); const wa={time:0},wb={time:0}; h.api.recordRobotClean({world:wa,type:"tarinai"}); assert((wa.achievementRobotCleanCount||0)===0,"cleaner counted non-cleanup target");
|
||||
for(let i=0;i<199;i++)h.api.recordRobotClean({world:wa,type:i%2?"zunchi":"trace"});
|
||||
assert(off(h,"robot_cleaner_100"),"cleaner before 200"); h.api.recordRobotClean({world:wb,type:"ant_corpse"}); assert(off(h,"robot_cleaner_100"),"cleaner crossed field reset");
|
||||
h.api.recordRobotClean({world:wa,type:"splat"}); assert(on(h,"robot_cleaner_100"),"cleaner at 200");
|
||||
|
||||
// Continuous pinch must be uninterrupted and same individual.
|
||||
h=harness(); const held={dead:false,playerHeld:true}; const hw={tarinai:[held]};
|
||||
h.api.recordHeldTarinai(hw,1000); h.api.recordHeldTarinai(hw,30999); assert(off(h,"held_30_seconds"),"held before 30s");
|
||||
held.playerHeld=false; h.api.recordHeldTarinai(hw,31000); held.playerHeld=true; h.api.recordHeldTarinai(hw,40000); h.api.recordHeldTarinai(hw,69999); assert(off(h,"held_30_seconds"),"held did not reset after release");
|
||||
h.c.document.hidden=true; h.api.recordHeldTarinai(hw,70000); h.c.document.hidden=false;
|
||||
h.api.recordHeldTarinai(hw,70001); h.api.recordHeldTarinai(hw,100000); assert(off(h,"held_30_seconds"),"hidden tab time counted toward hold");
|
||||
h.api.recordHeldTarinai(hw,100001); assert(on(h,"held_30_seconds"),"held at 30s after visible reset");
|
||||
|
||||
// Minimalist counts only surviving player placements.
|
||||
h=harness(); let mw={time:0,day:5,tarinai:alive(1),ants:[],items:Array.from({length:11},()=>({_achievementPlayerPlaced:true,dead:false}))};
|
||||
h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist allowed 11 items");
|
||||
mw.items[10].dead=true; h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist unlocked before day 6");
|
||||
mw.day=6; h.api.evaluateWorld(mw,{id:"happy"}); assert(on(h,"minimalist_happy"),"minimalist at day 6 with 10 items");
|
||||
|
||||
// Overprotective requires both thresholds on the same individual.
|
||||
h=harness(); const cared={dead:false}; const cw={time:0};
|
||||
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
||||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",treatment:true});
|
||||
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
||||
|
||||
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
||||
h=harness(); let sw={time:0,tarinai:alive(20),ants:[],items:[]}; h.api.evaluateWorld(sw,{}); sw.time=599.99;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient early");
|
||||
sw.items.push({type:"duplicator",dead:false});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient with duplicator");
|
||||
sw.items=[];sw.time=700;h.api.evaluateWorld(sw,{});sw.time=1300;h.api.evaluateWorld(sw,{});assert(on(h,"self_sufficient"),"self sufficient after clean 10m");
|
||||
h=harness(); sw={time:0,tarinai:alive(20),ants:[],items:[]};h.api.evaluateWorld(sw,{});sw.time=500;h.api.recordDirectFeed({world:sw,tarinai:sw.tarinai[0],type:"food"});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"direct feed did not reset self sufficient");
|
||||
|
||||
// Unplanned city is persistent and deletion must be within 30 seconds.
|
||||
storage=new Map(); h=harness(storage); let qw={time:0};
|
||||
for(let i=0;i<15;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||||
h=harness(storage);qw={time:0};for(let i=0;i<14;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||||
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
||||
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
||||
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
||||
|
||||
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
||||
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
||||
temp=0;h.api.evaluateWorld(tw,{});assert(off(h,"sauna_cold_plunge"),"cold alone unlocked sauna");temp=40;h.api.evaluateWorld(tw,{});tw.time=15;temp=0;h.api.evaluateWorld(tw,{});assert(on(h,"sauna_cold_plunge"),"sauna boundary 15s");
|
||||
h=harness(); const st2={dead:false,x:0,y:0};temp=40;const tw2={time:0,tarinai:[st2],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":"cold",comfortable:false};}};h.api.evaluateWorld(tw2,{});tw2.time=15.01;temp=0;h.api.evaluateWorld(tw2,{});assert(off(h,"sauna_cold_plunge"),"sauna allowed over 15s");
|
||||
|
||||
// Rain shelter requires all 20 and accepts nest/pipe via shelter system.
|
||||
h=harness(); h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};
|
||||
const rw={time:0,weather:"light_rain",tarinai:alive(20).map(t=>({...t,sheltered:true})),ants:[],items:[]};h.api.evaluateWorld(rw,{});assert(on(h,"rain_shelter_all"),"all sheltered rain achievement");
|
||||
h=harness();h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};rw.tarinai[19].sheltered=false;h.api.evaluateWorld(rw,{});assert(off(h,"rain_shelter_all"),"rain achievement allowed one exposed");
|
||||
|
||||
// Nest boxes and pipes are genuine weather blockers, not only achievement stubs.
|
||||
h=harness();
|
||||
vm.runInContext(fs.readFileSync(path.join(root,"js","tarinai_local_environment_system.js"),"utf8"),h.c,{filename:"tarinai_local_environment_system.js"});
|
||||
const nest={id:"n1",type:"nest_box",dead:false};
|
||||
const pipe={id:"p1",type:"pipe",dead:false};
|
||||
const shelterWorld={items:[nest,pipe],nestBoxById(id){return this.items.find(x=>x.id===id)||null;}};
|
||||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"n1"}),"nest box did not block weather");
|
||||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"p1"}),"pipe did not block weather");
|
||||
assert(!h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{}),"unsheltered target treated as sheltered");
|
||||
|
||||
// Nest-box and pipe occupants must also be insulated from weather temperature offsets.
|
||||
{
|
||||
class FakeWorld {}
|
||||
const tc={console,Math,Number,Object,Array,Set,Map,WeakMap,Date,CONFIG:{standardTemperature:15,temperatureItemRange:190},
|
||||
World:FakeWorld,clamp:(v,a,b)=>Math.max(a,Math.min(b,v)),distXY:(x1,y1,x2,y2)=>Math.hypot(x1-x2,y1-y2),
|
||||
TarinaiParasolSystem:h.c.TarinaiRainShelterSystem};
|
||||
tc.window=tc;tc.globalThis=tc;vm.createContext(tc);
|
||||
vm.runInContext(fs.readFileSync(path.join(root,"js","world_temperature_system.js"),"utf8"),tc,{filename:"world_temperature_system.js"});
|
||||
const box={id:"box",type:"nest_box",dead:false,x:0,y:0,r:42};
|
||||
const pipeItem={id:"pipe",type:"pipe",dead:false,x:0,y:0,r:42};
|
||||
const weatherWorld=new FakeWorld();Object.assign(weatherWorld,{currentTemperature:15,temperatureAuto:true,groundType:"soil",weather:"light_rain",items:[box,pipeItem],
|
||||
nearbyItems(x,y,r){return this.items.filter(it=>Math.hypot(it.x-x,it.y-y)<=r);},nestBoxById(id){return this.items.find(x=>x.id===id)||null;},nestBoxBaseRect(){return null;}});
|
||||
const outside={x:500,y:0}; const inBox={x:0,y:0,insideNestBoxId:"box"}; const inPipe={x:0,y:0,insideNestBoxId:"pipe"};
|
||||
assert(weatherWorld.feltTemperatureFor(outside)<15,"rain weather offset missing outside");
|
||||
assert(weatherWorld.feltTemperatureFor(inBox)>=15,"nest box still received rain temperature offset");
|
||||
assert(weatherWorld.feltTemperatureFor(inPipe)>=15,"pipe still received rain temperature offset");
|
||||
}
|
||||
|
||||
// Every medicine item once; chain and crown are explicitly excluded.
|
||||
h=harness(); const meds=["first_aid","sedative","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug"];
|
||||
meds.slice(0,-1).forEach(type=>h.api.recordConsumableUse({type}));assert(off(h,"medicine_ledger_all"),"medicine ledger early");h.api.recordConsumableUse({type:meds.at(-1)});assert(on(h,"medicine_ledger_all"),"medicine ledger complete");
|
||||
|
||||
// Mercury lifespan, revolution, fight mochi.
|
||||
h=harness();h.api.recordDeath({world:{time:1,tarinai:[]},tarinai:{lifeItemMode:"mercury"},reason:"天寿を全うした"});assert(on(h,"mercury_lifespan"),"mercury lifespan");
|
||||
h=harness();const slave={id:"s",isZunchiSlave:true,dead:false};const king={id:"k",isTarinaiChampion:true,dead:true,_achievementLastFightAttackerId:"s",_achievementLastFightDamageAt:5};const revWorld={time:5,tarinai:[slave,king]};h.api.recordDeath({world:revWorld,tarinai:king,reason:"喧嘩",fromDamage:true});assert(on(h,"revolution"),"revolution");
|
||||
h=harness();const oil={};const fw={time:0};h.api.recordConsumableUse({world:fw,tarinai:oil,type:"fight_mochi"});fw.time=30;h.api.recordFightStarted({world:fw,a:oil,b:{}});assert(on(h,"fuel_to_fire"),"fight mochi at 30s");
|
||||
h=harness();const noOil={};h.api.recordFightStarted({world:{time:0},a:noOil,b:{}});assert(off(h,"fuel_to_fire"),"fight without mochi unlocked at time zero");
|
||||
h=harness();const oil2={};const fw2={time:0};h.api.recordConsumableUse({world:fw2,tarinai:oil2,type:"fight_mochi"});fw2.time=30.01;h.api.recordFightStarted({world:fw2,a:oil2,b:{}});assert(off(h,"fuel_to_fire"),"fight mochi over 30s");
|
||||
|
||||
// Enemy's enemy: 30 alive, no tarinai damage for 10 sec, danger source, field-local count.
|
||||
h=harness(); const ew={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};
|
||||
for(let i=0;i<9;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
|
||||
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");const ant10={world:ew};h.api.recordAntDamageSource(ant10,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant:ant10});assert(on(h,"enemy_enemy_friend"),"enemy achievement at 10");
|
||||
h=harness();const ew2={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:11};for(let i=0;i<10;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}assert(off(h,"enemy_enemy_friend"),"enemy counted within 10s of tarinai damage");
|
||||
ew2.time=22;ew2.achievementLastTarinaiDamageAt=11;const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert(off(h,"enemy_enemy_friend"),"non-danger ant kill counted");
|
||||
const noCollateral={time:50,tarinai:alive(30),achievementLastTarinaiDamageAt:-Infinity};
|
||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"food"},{world:noCollateral});
|
||||
assert(noCollateral.achievementLastTarinaiDamageAt===-Infinity,"ordinary damage incorrectly blocked enemy achievement");
|
||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"firecracker"},{world:noCollateral});
|
||||
assert(noCollateral.achievementLastTarinaiDamageAt===50,"danger collateral was not recorded");
|
||||
h=harness();const ew3={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
|
||||
|
||||
// Static integration guards for all production hook points.
|
||||
const checks={
|
||||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction"],
|
||||
"js/robot_cleaner_system.js":["recordRobotClean"],
|
||||
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
||||
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
||||
"js/tarinai_social_move_life.js":["_achievementLastFightAttackerId"],
|
||||
"js/tarinai_direct_feeding_system.js":["recordDirectCare"],
|
||||
"js/tarinai_consumable_behavior.js":["recordConsumableUse"],
|
||||
"js/tarinai_contact_item_system.js":["recordConsumableUse", "slave_chain", "golden_crown"],
|
||||
"js/world_combat_effects.js":["recordAntDamageSource","source: it"],
|
||||
"js/impact_response_system.js":["source: it"],
|
||||
"js/ants.js":["recordAntDamageSource", "tarinai_struggle"],
|
||||
"js/signal_system.js":["recordAntDamageSource", "electric_wire"],
|
||||
"js/tarinai_item_effects.js":["recordAntDamageSource", "tarinai_ammo_collision"],
|
||||
"js/tarinai_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
|
||||
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
|
||||
"js/tarinai_disease_nest.js":["isSheltered?.(this.world, this.x, this.y, this)"],
|
||||
};
|
||||
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
||||
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||||
})();
|
||||
|
|
@ -2104,12 +2104,16 @@ def check_connection_overlay_and_time_detector() -> None:
|
|||
fail(f"detector dual time slider is missing {control_id}")
|
||||
if '.pressure-time-thumb-start' not in styles or '.pressure-time-thumb-end' not in styles:
|
||||
fail("detector dual-thumb slider styling is missing")
|
||||
if 'item.pressureTarget === "time" ? 2' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
|
||||
fail("time detector snapshot persistence is missing")
|
||||
if 'data.pressureTarget = item.pressureTarget === "time"' not in placement or 'item.pressureTarget = data.pressureTarget === "time"' not in placement:
|
||||
fail("time detector copy persistence is missing")
|
||||
if 'item.pressureTarget === "time") continue' not in render:
|
||||
fail("time detectors still render a spatial detection rectangle")
|
||||
if 'const targets = ["tarinai", "item", "time", "hunger_avg"' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
|
||||
fail("expanded detector snapshot persistence is missing")
|
||||
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureComparator' not in placement:
|
||||
fail("expanded detector copy persistence is missing")
|
||||
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
|
||||
fail("non-spatial detectors still render a detection rectangle")
|
||||
removed = ["bio_sensor", "power_relay", "automationItemEditor"]
|
||||
for token in removed:
|
||||
if token in placement or token in snapshot or token in signal:
|
||||
fail(f"removed automation item remains in runtime source: {token}")
|
||||
|
||||
runtime = f"""
|
||||
const vm = require('vm');
|
||||
|
|
|
|||
19
scripts/ui_dialog_achievement_audit.js
Normal file
19
scripts/ui_dialog_achievement_audit.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const achievement = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
||||
const save = fs.readFileSync(path.join(root, "js", "save_system.js"), "utf8");
|
||||
const dialog = fs.readFileSync(path.join(root, "js", "game_dialogs.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "index.html"), "utf8");
|
||||
function assert(value, message) { if (!value) throw new Error(message); }
|
||||
assert(!/\b(?:window\.|global\.)?(?:alert|confirm|prompt)\s*\(/.test(achievement + save), "native browser popup call remains");
|
||||
assert(achievement.includes("TarinaiGameDialogs") && save.includes("TarinaiGameDialogs"), "in-game confirmation API is not used");
|
||||
assert(dialog.includes('role", "alertdialog"') && dialog.includes("data-game-dialog-confirm"), "shared in-game confirmation dialog is incomplete");
|
||||
assert(!achievement.includes("TarinaiTooltips") && !achievement.includes("dataset.tip"), "achievement tooltip binding remains");
|
||||
assert(achievement.includes("ACHIEVEMENT_CATEGORIES") && achievement.includes("createAchievementGroup"), "achievement category rendering is missing");
|
||||
assert(!achievement.includes('id: "completed"'), "separate completed group still exists");
|
||||
assert(achievement.includes("orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]"), "unlocked achievements are not sorted to the top inside categories");
|
||||
assert(["\\u751f\\u614b", "\\u5b9f\\u9a13", "\\u5efa\\u7bc9", "\\u305d\\u306e\\u4ed6"].every(label => achievement.includes(`title: "${label}"`)), "category titles are incorrect");
|
||||
assert(index.includes('placeholder="検索"'), "search placeholder was not shortened");
|
||||
console.log("[OK] achievement grouping, inline disclosures, and in-game confirmation dialogs passed");
|
||||
64
scripts/v391655_gameplay_fix_audit.js
Normal file
64
scripts/v391655_gameplay_fix_audit.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
function assert(v, m) { if (!v) throw new Error(m); }
|
||||
|
||||
// Verify the manual push direction for all three passive mechanical types.
|
||||
const context = {
|
||||
console, Math, Number, Object, Array, Set, Map, WeakMap, Date,
|
||||
clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); },
|
||||
TarinaiCollisionFootprints: { hitTestItem() { return { hit: false, distance: Infinity }; } },
|
||||
TarinaiPhysicsBodySystem: {
|
||||
ensureBody(item) { return item.physicsBody; },
|
||||
scalar(item, key, fallback = 0) {
|
||||
const b = item.physicsBody || {};
|
||||
if (key === "spin") return b.velocity?.angular ?? fallback;
|
||||
if (key === "slideSpeed") return b.velocity?.linear ?? fallback;
|
||||
if (key === "xv") return b.velocity?.x ?? fallback;
|
||||
if (key === "yv") return b.velocity?.y ?? fallback;
|
||||
if (key === "motorOn") return b.motor?.powered ?? fallback;
|
||||
if (key === "railOn") return b.motor?.powered ?? fallback;
|
||||
return b.state?.[key] ?? fallback;
|
||||
},
|
||||
setScalar(item, key, value) {
|
||||
const b = item.physicsBody;
|
||||
b.velocity ||= { x: 0, y: 0, angular: 0, linear: 0 };
|
||||
b.state ||= {};
|
||||
if (key === "spin") b.velocity.angular = value;
|
||||
else if (key === "slideSpeed") b.velocity.linear = value;
|
||||
else if (key === "xv") b.velocity.x = value;
|
||||
else if (key === "yv") b.velocity.y = value;
|
||||
else b.state[key] = value;
|
||||
return true;
|
||||
},
|
||||
applyBodyState() { return true; },
|
||||
},
|
||||
TarinaiSignalSystem: { powerOverride() { return null; } },
|
||||
};
|
||||
context.window = context; context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, "js", "mechanical_system.js"), "utf8"), context, { filename: "mechanical_system.js" });
|
||||
const body = (type) => ({
|
||||
type, x: 0, y: 0, angle: 0, r: 40, world: { time: 0, markSpatialDirty() {} },
|
||||
physicsBody: { type, pose: { x: 0, y: 0, angle: 0 }, velocity: { x: 0, y: 0, angular: 0, linear: 0 }, motor: { powered: false }, rail: { axisAngle: 0 }, state: { mass: 1, inertia: 3200 } },
|
||||
});
|
||||
let item = body("reciprocator");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
|
||||
assert(item.physicsBody.velocity.linear < 0, "reciprocator moved toward the poke point");
|
||||
item = body("poison_block");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
|
||||
assert(item.physicsBody.velocity.x < 0, "poison block moved toward the poke point");
|
||||
item = body("rotator");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 0, -30, item.world);
|
||||
assert(item.physicsBody.velocity.angular > 0, "rotator poke torque is reversed");
|
||||
|
||||
const placement = fs.readFileSync(path.join(root, "js", "world_placement_log.js"), "utf8");
|
||||
const input = fs.readFileSync(path.join(root, "js", "ui_input_shared.js"), "utf8");
|
||||
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
|
||||
assert(placement.includes("findPokeTargetAt"), "shared poke target finder is missing");
|
||||
assert(placement.indexOf('item.type !== "plushie"') < placement.indexOf('item.type !== "ball"'), "plushie is not prioritized by poke targeting");
|
||||
assert(placement.includes("recordPlushiePokeFling"), "plushie achievement hook is missing");
|
||||
assert(input.includes("hoverPokeTarget") && render.includes('label: "\\u3064\\u3064\\u304f"'), "poke overlay is missing");
|
||||
console.log("[OK] poke targeting, overlay, plushie fling, and passive mechanism push directions passed");
|
||||
Loading…
Add table
Add a link
Reference in a new issue