1.9
This commit is contained in:
parent
fc7c94bd69
commit
2cc2f003df
71 changed files with 3359 additions and 1027 deletions
|
|
@ -80,7 +80,7 @@ function createHarness(existingStorage = null, search = "", options = {}) {
|
|||
setInterval() { timerId += 1; return timerId; },
|
||||
clearInterval() {},
|
||||
fetch: options.fetch || (async () => { throw new Error("offline"); }),
|
||||
TARINAI_VERSION: "39.16.60",
|
||||
TARINAI_VERSION: "39.16.65",
|
||||
TarinaiGameDialogs: { confirm: async () => true },
|
||||
};
|
||||
context.window = context;
|
||||
|
|
@ -109,12 +109,16 @@ const expectedIds = [
|
|||
"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",
|
||||
"low_fps_single_digit", "continuous_play_24_hours", "sandbox_five_toilets",
|
||||
"park_ground_changed", "ground_change_4_in_1_second",
|
||||
"ant_nest_without_tarinai", "sticky_bomb_15_passes",
|
||||
"daily_play_7_days", "wire_shock_7_tarinai", "information_industry",
|
||||
];
|
||||
|
||||
(async function run() {
|
||||
let h = createHarness();
|
||||
let storage;
|
||||
assert(h.api.definitions.length === 54, "definition count must be 54");
|
||||
assert(h.api.definitions.length === 64, "definition count must be 64");
|
||||
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");
|
||||
|
|
@ -153,6 +157,15 @@ const expectedIds = [
|
|||
assert(titles.sniper_333_shots === "狙撃手", "sniper title mismatch");
|
||||
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
|
||||
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility title mismatch");
|
||||
assert(titles.low_fps_single_digit === "スペックがたりない", "low-fps title mismatch");
|
||||
assert(titles.continuous_play_24_hours === "寝ろ", "24-hour title mismatch");
|
||||
assert(titles.sandbox_five_toilets === "砂場", "sandbox title mismatch");
|
||||
assert(titles.park_ground_changed === "そこ公共空間だよ?", "park-ground title mismatch");
|
||||
assert(titles.ground_change_4_in_1_second === "破格の工事費用", "ground-spam title mismatch");
|
||||
assert(titles.ant_nest_without_tarinai === "アリ観察", "ant observation title mismatch");
|
||||
assert(titles.sticky_bomb_15_passes === "命のバトン", "sticky-bomb relay title mismatch");
|
||||
assert(titles.daily_play_7_days === "毎日たりない観察", "daily observation title mismatch");
|
||||
assert(titles.wire_shock_7_tarinai === "抵抗器じゃない", "wire shock 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");
|
||||
|
|
@ -166,6 +179,15 @@ const expectedIds = [
|
|||
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(descriptions.idle_observer_5_minutes === "5分間画面を見るだけ", "observer description mismatch");
|
||||
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
|
||||
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
|
||||
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
|
||||
assert(descriptions.park_ground_changed === "公園の地面を勝手に変更する。", "park-ground description mismatch");
|
||||
assert(descriptions.ground_change_4_in_1_second === "地面を変えすぎて破産する。", "ground-spam description mismatch");
|
||||
assert(descriptions.sauna_cold_plunge === "暑すぎる状態になったたりないを、15秒以内に寒すぎる状態にする。", "sauna description mismatch");
|
||||
assert(descriptions.ant_nest_without_tarinai === "アリの巣があるが、たりないがいない。", "ant observation description mismatch");
|
||||
assert(descriptions.sticky_bomb_15_passes === "粘着ボムを爆発までに15回以上パスする。", "sticky-bomb relay 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; }
|
||||
|
|
@ -176,9 +198,11 @@ const expectedIds = [
|
|||
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 lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
|
||||
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
|
||||
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]) {
|
||||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard]) {
|
||||
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");
|
||||
|
|
@ -188,6 +212,8 @@ const expectedIds = [
|
|||
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");
|
||||
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
|
||||
assert(JSON.stringify(operationIds.slice(-2)) === JSON.stringify(["continuous_play_1_hour", "true_tarinai_observer"]), "observation achievements are not at the bottom of その他");
|
||||
h.api.unlock("natural_zunchi_slave");
|
||||
const rerenderedGroups = h.elements.get("achievementList").children;
|
||||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||||
|
|
@ -403,6 +429,14 @@ const expectedIds = [
|
|||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const lifespanSafeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
|
||||
const lifespanSafeWorld = { time: 100, tarinai: lifespanSafeAlive, achievementNoDeathStartAt: 0 };
|
||||
h.api.recordDeath({ world: lifespanSafeWorld, reason: "天寿を全うした", tarinai: { deathReason: "天寿を全うした" } });
|
||||
assert(lifespanSafeWorld.achievementNoDeathStartAt === 0, "lifespan death reset safe-colony timer");
|
||||
h.api.recordDeath({ world: lifespanSafeWorld, reason: "事故", tarinai: { deathReason: "事故" } });
|
||||
assert(lifespanSafeWorld.achievementNoDeathStartAt === 100, "non-lifespan death did not reset safe-colony timer");
|
||||
|
||||
h = createHarness();
|
||||
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
h.api.evaluateWorld(antWorld, { id: "relaxed" });
|
||||
|
|
@ -418,6 +452,26 @@ const expectedIds = [
|
|||
h.api.recordAntKilled({ world: {} });
|
||||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
||||
|
||||
|
||||
h = createHarness();
|
||||
const antObservationWorld = { tarinai: [], ants: [], items: [] };
|
||||
h.api.evaluateWorld(antObservationWorld, {});
|
||||
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked without an ant nest");
|
||||
antObservationWorld.items.push({ type: "ant_nest", dead: false });
|
||||
antObservationWorld.tarinai.push({ dead: false });
|
||||
h.api.evaluateWorld(antObservationWorld, {});
|
||||
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked while a tarinai was alive");
|
||||
antObservationWorld.tarinai[0].dead = true;
|
||||
h.api.evaluateWorld(antObservationWorld, {});
|
||||
assert(unlocked(h, "ant_nest_without_tarinai"), "ant observation did not unlock with a nest and zero living tarinai");
|
||||
|
||||
h = createHarness();
|
||||
const stickyBomb = { type: "sticky_bomb", dead: false };
|
||||
for (let i = 0; i < 14; i += 1) h.api.recordStickyBombPass(stickyBomb, {});
|
||||
assert(!unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay unlocked before 15 passes");
|
||||
h.api.recordStickyBombPass(stickyBomb, {});
|
||||
assert(unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay did not unlock at 15 passes");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordSaveSlotsFilled({ filled: 8 });
|
||||
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
|
||||
|
|
@ -425,19 +479,50 @@ const expectedIds = [
|
|||
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");
|
||||
[0, 250, 500].forEach(now => h.api.recordEmptyClick({ now }));
|
||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked before four clicks");
|
||||
h.api.recordEmptyClick({ now: 999 });
|
||||
assert(unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement 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");
|
||||
[0, 400, 800, 1201].forEach(now => h.api.recordEmptyClick({ now }));
|
||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement 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");
|
||||
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked at one hour");
|
||||
h.api.recordContinuousPlay({ elapsedMs: 86399999 });
|
||||
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked early");
|
||||
h.api.recordContinuousPlay({ elapsedMs: 86400000 });
|
||||
assert(unlocked(h, "continuous_play_24_hours"), "24-hour play did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordLowFps({ fps: 10 });
|
||||
assert(!unlocked(h, "low_fps_single_digit"), "low-fps achievement unlocked at 10 fps");
|
||||
h.api.recordLowFps({ fps: 9 });
|
||||
assert(unlocked(h, "low_fps_single_digit"), "low-fps achievement did not unlock at 9 fps");
|
||||
|
||||
h = createHarness();
|
||||
const sandboxWorld = { items: Array.from({ length: 4 }, () => ({ type: "toilet", dead: false })) };
|
||||
h.api.evaluateSandbox(sandboxWorld);
|
||||
assert(!unlocked(h, "sandbox_five_toilets"), "sandbox unlocked before five toilet sands");
|
||||
sandboxWorld.items.push({ type: "toilet", dead: false });
|
||||
h.api.evaluateSandbox(sandboxWorld);
|
||||
assert(unlocked(h, "sandbox_five_toilets"), "sandbox did not unlock at five toilet sands");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordGroundChange({ world: { fieldType: "garden" }, previous: "soil", next: "ice", now: 0 });
|
||||
assert(!unlocked(h, "park_ground_changed"), "park-ground achievement unlocked outside park");
|
||||
h.api.recordGroundChange({ world: { fieldType: "park" }, previous: "soil", next: "ice", now: 100 });
|
||||
assert(unlocked(h, "park_ground_changed"), "park-ground achievement did not unlock in park");
|
||||
h = createHarness();
|
||||
const groundWorld = { fieldType: "garden" };
|
||||
[0, 250, 500].forEach((now, index) => h.api.recordGroundChange({ world: groundWorld, previous: `g${index}`, next: `g${index + 1}`, now }));
|
||||
assert(!unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement unlocked before four changes");
|
||||
h.api.recordGroundChange({ world: groundWorld, previous: "g3", next: "g4", now: 999 });
|
||||
assert(unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement did not unlock within one second");
|
||||
|
||||
h = createHarness();
|
||||
const thermalWorld = {
|
||||
|
|
@ -583,7 +668,7 @@ const expectedIds = [
|
|||
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");
|
||||
console.log("[OK] all 64 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
const fs=require('fs'),vm=require('vm');
|
||||
const path=require('path');
|
||||
const root=path.join(__dirname,'..');
|
||||
const c={console,Math,JSON,Object,Array,Map,Set,Date,Number,String,Boolean,Error,Uint8Array,
|
||||
TarinaiAchievements:{isUnlocked(){return true;},exportSpellState(){return [3,0,0,0,[],Array(12).fill(0)];}},
|
||||
TarinaiSeedFactory:{serialFromBirthSeed(){return 0;},ensureWorldSeed(world){world.worldSeed=world.worldSeed||'w0000000000000000';return world.worldSeed;}},
|
||||
TarinaiSaveSchema:{SNAPSHOT_VERSION:35,FIELD_IDS:['garden'],GROUND_IDS:['soil'],WEATHER_IDS:['clear'],MOOD_IDS:['relaxed'],STATE_IDS:['idle'],POWER_MODE_IDS:['normal'],SIZE_MODE_IDS:['normal'],LIFE_MODE_IDS:['normal'],PIN_STATE_IDS:['none'],STAGE_IDS:['adult'],ITEM_TYPE_IDS:[],itemTypeIndex(){return -1;},canonicalMoodId(v){return v||'relaxed';},moodValue(v,f){return v||f;},enumIndex(list,v,f=0){const i=list.indexOf(v);return i>=0?i:f;},enumValue(list,i,f=''){return list[i]??f;}},
|
||||
}; c.globalThis=c;c.window=c;vm.createContext(c);
|
||||
vm.runInContext(fs.readFileSync(root+'/js/snapshot_system.js','utf8'),c);
|
||||
const world={day:10,fieldType:'garden',tarinai:[],items:[],weather:'clear',time:100,weatherTimer:0,nextWeatherChange:10,deadCount:0,lastBirthAt:0,maxGeneration:1,colonyMood:{id:'relaxed'},groundType:'soil',achievementNaturalSlaveGenerations:[1,2,3,4,5],achievementNaturalKingGenerations:[1,2,3],achievementDirectFeedCount:33,achievementRobotCleanCount:200,achievementEnemyAntKills:10,achievementLastTarinaiDamageAt:99,achievementSelfSufficientStartAt:1,achievementLastDirectFeedAt:2,achievementLastInterventionAt:3,achievementNoDeathStartAt:4,achievementHappyStartAt:5};
|
||||
const snap=c.TarinaiSnapshot.createSnapshot(world,{includeAchievements:true});
|
||||
if(JSON.stringify(snap.g.w)!==JSON.stringify([[],[],0,0,0,null,-1,null,0,-1,-1])) throw new Error(JSON.stringify(snap.g.w));
|
||||
if(snap.g.t.length||snap.g.i.length) throw new Error('entity metadata not empty');
|
||||
if(!Array.isArray(snap.g.s)||snap.g.s[5].some(Boolean)) throw new Error('spell progress not zero');
|
||||
console.log('[OK] completed world/entity/item achievement counters omitted from spell snapshot');
|
||||
|
|
@ -17,14 +17,14 @@ 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) == 54, 'JS achievement count is not 54')
|
||||
require(len(ids_js) == 64, 'JS achievement count is not 64')
|
||||
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','sniper_333_shots','megalopolis','fertility_seeker_721_love_births']), 'latest 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','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai']), 'latest 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 = {
|
||||
|
|
@ -67,12 +67,23 @@ checks = {
|
|||
'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,
|
||||
'safe colony ignores lifespan deaths': 'lifespanDeath' in ach and '\\u5bff\\u547d|\\u5929\\u5bff' in ach,
|
||||
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
|
||||
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
|
||||
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
|
||||
'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,
|
||||
'empty-click hook': 'recordEmptyClick' in ach and 'recordEmptyClick?.' in read('js/world_placement_log.js') and 'recordPauseClick' not in ach and 'pauseButton?.addEventListener' not in ach,
|
||||
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'CONTINUOUS_PLAY_24H_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach and 'continuousPlayStartedAt = Date.now()' in ach and 'if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now();' in ach,
|
||||
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
|
||||
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
|
||||
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
|
||||
'current-only achievement spell': 'const SPELL_STATE_VERSION = 5;' in ach and 'Number(payload[0]) !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js'),
|
||||
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
|
||||
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
|
||||
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' 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,
|
||||
|
|
@ -93,10 +104,15 @@ checks = {
|
|||
'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,
|
||||
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
|
||||
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
|
||||
'old generic fight progress is not migrated': 'progressSource.fightCount' not in ach and 'progressVersion === SPELL_STATE_VERSION' in ach,
|
||||
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
|
||||
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
|
||||
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
|
||||
'summary is read-only': "$mutatesPlayer = $action !== 'summary';" in api and 'if ($mutatesPlayer)' in api,
|
||||
'completionist false cannot be re-added': "unset($syncUnlocks['true_tarinai_observer'])" in api,
|
||||
'GET mutations are rejected': "$method === 'GET' && $action !== 'summary'" in api,
|
||||
}
|
||||
for name, ok in checks.items():
|
||||
require(ok, name)
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
"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 },
|
||||
TarinaiSeedFactory: {
|
||||
personalityArray() { return [100, 100, 100, 100]; },
|
||||
birthProfile() { return { genetics: {} }; },
|
||||
childSeed() { return "b1_0"; },
|
||||
worldSeedFromWords(high, low) { return `w${(high >>> 0).toString(16).padStart(8, "0")}${(low >>> 0).toString(16).padStart(8, "0")}`; },
|
||||
},
|
||||
TarinaiSaveSchema: {
|
||||
BINARY_SCHEMA_VERSION: 43,
|
||||
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, -1],
|
||||
t: [[10, 3, 120, 130]],
|
||||
i: [[1, 121]],
|
||||
s: [3, 3, 0, 28333333, [0, 2], [77, 13, 64, 2222, 19, 11, 12, 1025, 31, 444, 222, 555]],
|
||||
};
|
||||
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: [[0, 0, 0, 0, []]],
|
||||
g: metadata,
|
||||
};
|
||||
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
|
||||
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
|
||||
if (JSON.stringify(decoded.g) !== JSON.stringify(metadata)) {
|
||||
console.error("expected", JSON.stringify(metadata));
|
||||
console.error("actual ", JSON.stringify(decoded.g));
|
||||
throw new Error("achievement metadata changed during binary save roundtrip");
|
||||
}
|
||||
console.log(`[OK] sparse binary achievement metadata roundtrip passed (${encoded.length} spell chars)`);
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
341
scripts/achievement_server_state_audit.py
Normal file
341
scripts/achievement_server_state_audit.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API_SOURCE = ROOT / "achievement_api.php"
|
||||
|
||||
|
||||
def achievement_ids() -> list[str]:
|
||||
source = API_SOURCE.read_text(encoding="utf-8")
|
||||
match = re.search(r"const ACHIEVEMENT_IDS = \[(.*?)\n\];", source, re.S)
|
||||
if not match:
|
||||
raise AssertionError("ACHIEVEMENT_IDS was not found")
|
||||
values = re.findall(r"'([^']+)'", match.group(1))
|
||||
if len(values) != 64 or len(values) != len(set(values)):
|
||||
raise AssertionError(f"expected 64 unique achievement IDs, got {len(values)}")
|
||||
return values
|
||||
|
||||
|
||||
ACHIEVEMENTS = achievement_ids()
|
||||
|
||||
|
||||
def compact_uuid(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(uuid.UUID(value).bytes).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def expand_uuid(value: str) -> str:
|
||||
return str(uuid.UUID(bytes=base64.urlsafe_b64decode(value + "==")))
|
||||
|
||||
|
||||
def decode_v3(value: object) -> dict:
|
||||
if not isinstance(value, list) or len(value) < 5 or value[0] != 3:
|
||||
raise AssertionError("state is not compact v3")
|
||||
base_time = int(value[1])
|
||||
versions = value[2]
|
||||
rows = value[3]
|
||||
unlock_rows = value[4]
|
||||
if not isinstance(versions, list) or not isinstance(rows, list) or not isinstance(unlock_rows, list):
|
||||
raise AssertionError("invalid v3 sections")
|
||||
players: dict[str, dict] = {}
|
||||
player_ids: list[str] = []
|
||||
for row in rows:
|
||||
player_id = expand_uuid(str(row[0]))
|
||||
first_seen = base_time + max(0, int(row[1] if len(row) > 1 else 0))
|
||||
last_seen = first_seen + max(0, int(row[2] if len(row) > 2 else 0))
|
||||
version_index = max(0, int(row[3] if len(row) > 3 else 0))
|
||||
player_ids.append(player_id)
|
||||
players[player_id] = {
|
||||
"firstSeen": first_seen,
|
||||
"lastSeen": last_seen,
|
||||
"gameVersion": str(versions[version_index] if version_index < len(versions) else ""),
|
||||
}
|
||||
unlocks: dict[str, dict[str, int]] = {}
|
||||
for row in unlock_rows:
|
||||
achievement_index = int(row[0])
|
||||
if achievement_index < 0 or achievement_index >= len(ACHIEVEMENTS):
|
||||
continue
|
||||
records: dict[str, int] = {}
|
||||
for offset in range(1, len(row) - 1, 2):
|
||||
player_index = int(row[offset])
|
||||
if 0 <= player_index < len(player_ids):
|
||||
records[player_ids[player_index]] = base_time + max(0, int(row[offset + 1]))
|
||||
if records:
|
||||
unlocks[ACHIEVEMENTS[achievement_index]] = records
|
||||
return {"players": players, "unlocks": unlocks}
|
||||
|
||||
|
||||
def make_random_v2(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
|
||||
rng = random.Random(seed)
|
||||
versions = ["39.16.60", "39.16.63", "39.16.64", "39.16.65", "dev"]
|
||||
base_time = 1_780_000_000 + seed * 10_000
|
||||
rows: list[list] = []
|
||||
players: dict[str, dict] = {}
|
||||
player_ids: list[str] = []
|
||||
for index in range(player_count):
|
||||
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
|
||||
first_seen = base_time + rng.randint(0, 200_000)
|
||||
last_seen = first_seen + rng.randint(0, 80_000)
|
||||
version = rng.choices(versions, weights=[2, 4, 9, 12, 1], k=1)[0]
|
||||
player_ids.append(player_id)
|
||||
rows.append([player_id, first_seen, last_seen, version])
|
||||
players[player_id] = {"firstSeen": first_seen, "lastSeen": last_seen, "gameVersion": version}
|
||||
unlocks_flat: dict[str, list[int]] = {}
|
||||
unlocks: dict[str, dict[str, int]] = {}
|
||||
for achievement_id in ACHIEVEMENTS:
|
||||
flat: list[int] = []
|
||||
records: dict[str, int] = {}
|
||||
for player_index, player_id in enumerate(player_ids):
|
||||
if rng.random() >= unlock_probability:
|
||||
continue
|
||||
timestamp = players[player_id]["firstSeen"] + rng.randint(0, max(0, players[player_id]["lastSeen"] - players[player_id]["firstSeen"]))
|
||||
flat.extend([player_index, timestamp])
|
||||
records[player_id] = timestamp
|
||||
if flat:
|
||||
unlocks_flat[achievement_id] = flat
|
||||
unlocks[achievement_id] = records
|
||||
return {"v": 2, "p": rows, "u": unlocks_flat}, {"players": players, "unlocks": unlocks}
|
||||
|
||||
|
||||
def make_random_verbose(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
|
||||
v2, expected = make_random_v2(seed, player_count, unlock_probability)
|
||||
players = {
|
||||
row[0]: {"firstSeen": row[1], "lastSeen": row[2], "gameVersion": row[3]}
|
||||
for row in v2["p"]
|
||||
}
|
||||
player_ids = [row[0] for row in v2["p"]]
|
||||
unlocks = {
|
||||
achievement_id: {player_ids[flat[i]]: flat[i + 1] for i in range(0, len(flat), 2)}
|
||||
for achievement_id, flat in v2["u"].items()
|
||||
}
|
||||
return {"players": players, "unlocks": unlocks}, expected
|
||||
|
||||
|
||||
def normalize_expected(value: dict) -> dict:
|
||||
return {
|
||||
"players": {key: value["players"][key] for key in sorted(value["players"])},
|
||||
"unlocks": {
|
||||
achievement_id: {key: records[key] for key in sorted(records)}
|
||||
for achievement_id, records in value["unlocks"].items()
|
||||
if records
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
class PhpServer:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
self.port = free_port()
|
||||
env = dict(os.environ)
|
||||
env["PHP_CLI_SERVER_WORKERS"] = "8"
|
||||
self.process = subprocess.Popen(
|
||||
["php", "-S", f"127.0.0.1:{self.port}"],
|
||||
cwd=root,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError("PHP test server exited")
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("PHP test server did not start")
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process.poll() is None:
|
||||
os.killpg(self.process.pid, signal.SIGTERM)
|
||||
try:
|
||||
self.process.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
self.process.wait(timeout=4)
|
||||
|
||||
def request(self, *, action: str = "summary", player_id: str, payload: dict | None = None) -> tuple[int, dict]:
|
||||
if payload is None:
|
||||
url = f"http://127.0.0.1:{self.port}/achievement_api.php?action={action}&playerId={player_id}"
|
||||
request = urllib.request.Request(url, method="GET")
|
||||
else:
|
||||
url = f"http://127.0.0.1:{self.port}/achievement_api.php"
|
||||
data = json.dumps(payload, separators=(",", ":")).encode()
|
||||
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.loads(error.read())
|
||||
|
||||
|
||||
def assert_equal_state(actual: dict, expected: dict, label: str) -> None:
|
||||
actual_normalized = normalize_expected(actual)
|
||||
expected_normalized = normalize_expected(expected)
|
||||
if actual_normalized != expected_normalized:
|
||||
raise AssertionError(f"{label}: semantic state changed")
|
||||
|
||||
|
||||
def run_migration_cases(server: PhpServer, state_path: Path) -> tuple[int, int]:
|
||||
total_original = 0
|
||||
total_compact = 0
|
||||
probe_id = str(uuid.uuid4())
|
||||
for case_index in range(32):
|
||||
player_count = 1 + (case_index * 37) % 260
|
||||
probability = 0.02 + ((case_index * 11) % 55) / 100
|
||||
if case_index % 5 == 0:
|
||||
source, expected = make_random_verbose(10_000 + case_index, player_count, probability)
|
||||
else:
|
||||
source, expected = make_random_v2(10_000 + case_index, player_count, probability)
|
||||
original = json.dumps(source, separators=(",", ":")).encode()
|
||||
state_path.write_bytes(original)
|
||||
status, summary = server.request(player_id=probe_id)
|
||||
if status != 200 or not summary.get("ok"):
|
||||
raise AssertionError(f"migration case {case_index} failed: {status} {summary}")
|
||||
if summary.get("totalPlayers") != player_count:
|
||||
raise AssertionError("read-only summary changed or miscounted players")
|
||||
first_bytes = state_path.read_bytes()
|
||||
decoded = decode_v3(json.loads(first_bytes))
|
||||
assert_equal_state(decoded, expected, f"migration case {case_index}")
|
||||
status, second_summary = server.request(player_id=probe_id)
|
||||
if status != 200 or second_summary.get("totalPlayers") != player_count:
|
||||
raise AssertionError("v3 reread failed")
|
||||
if state_path.read_bytes() != first_bytes:
|
||||
raise AssertionError("canonical v3 changed on a second read")
|
||||
total_original += len(original)
|
||||
total_compact += len(first_bytes)
|
||||
if total_compact >= total_original * 0.72:
|
||||
raise AssertionError(f"aggregate compaction is insufficient: {total_compact}/{total_original}")
|
||||
return total_original, total_compact
|
||||
|
||||
|
||||
def run_corruption_cases(server: PhpServer, state_path: Path) -> None:
|
||||
probe_id = str(uuid.uuid4())
|
||||
for payload in [b'{"v":2,', b'{"v":99}', b'[3,1]']:
|
||||
state_path.write_bytes(payload)
|
||||
status, body = server.request(player_id=probe_id)
|
||||
if status != 503 or body.get("error") != "storage_corrupt":
|
||||
raise AssertionError(f"corrupt state was not rejected: {status} {body}")
|
||||
if state_path.read_bytes() != payload:
|
||||
raise AssertionError("corrupt state was overwritten")
|
||||
|
||||
|
||||
def run_completionist_case(server: PhpServer, state_path: Path) -> None:
|
||||
state_path.write_text("", encoding="utf-8")
|
||||
player_id = str(uuid.uuid4())
|
||||
now_ms = int(time.time() * 1000)
|
||||
status, body = server.request(player_id=player_id, payload={
|
||||
"action": "sync",
|
||||
"playerId": player_id,
|
||||
"gameVersion": "39.16.65",
|
||||
"completionistEligible": False,
|
||||
"unlocked": {"first_birth": now_ms, "true_tarinai_observer": now_ms},
|
||||
})
|
||||
if status != 200 or body["achievements"]["first_birth"]["unlockedPlayers"] != 1:
|
||||
raise AssertionError("ordinary sync unlock was lost")
|
||||
if body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 0:
|
||||
raise AssertionError("ineligible completionist unlock was re-added")
|
||||
status, body = server.request(player_id=player_id, payload={
|
||||
"action": "sync",
|
||||
"playerId": player_id,
|
||||
"gameVersion": "39.16.65",
|
||||
"completionistEligible": True,
|
||||
"unlocked": {"true_tarinai_observer": now_ms},
|
||||
})
|
||||
if status != 200 or body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 1:
|
||||
raise AssertionError("eligible completionist unlock was not accepted")
|
||||
before = state_path.read_bytes()
|
||||
status, body = server.request(action="reset", player_id=player_id)
|
||||
if status != 405 or body.get("error") != "method_not_allowed":
|
||||
raise AssertionError("GET reset was not rejected")
|
||||
if state_path.read_bytes() != before:
|
||||
raise AssertionError("rejected GET reset mutated state")
|
||||
|
||||
|
||||
def run_concurrency_case(server: PhpServer, state_path: Path) -> None:
|
||||
state_path.write_text("", encoding="utf-8")
|
||||
rng = random.Random(52_001)
|
||||
requests: list[tuple[str, dict[str, int]]] = []
|
||||
now_ms = int(time.time() * 1000)
|
||||
for index in range(72):
|
||||
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
|
||||
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
|
||||
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
|
||||
requests.append((player_id, unlocks))
|
||||
|
||||
def submit(entry: tuple[str, dict[str, int]]) -> None:
|
||||
player_id, unlocks = entry
|
||||
status, body = server.request(player_id=player_id, payload={
|
||||
"action": "sync",
|
||||
"playerId": player_id,
|
||||
"gameVersion": "39.16.65",
|
||||
"completionistEligible": "true_tarinai_observer" in unlocks,
|
||||
"unlocked": unlocks,
|
||||
})
|
||||
if status != 200 or not body.get("ok"):
|
||||
raise AssertionError(f"concurrent sync failed: {status} {body}")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as executor:
|
||||
list(executor.map(submit, requests))
|
||||
|
||||
status, summary = server.request(player_id=str(uuid.uuid4()))
|
||||
if status != 200 or summary.get("totalPlayers") != len(requests):
|
||||
raise AssertionError("concurrent updates lost players")
|
||||
decoded = decode_v3(json.loads(state_path.read_bytes()))
|
||||
if len(decoded["players"]) != len(requests):
|
||||
raise AssertionError("concurrent state player count mismatch")
|
||||
for player_id, unlocks in requests:
|
||||
for achievement_id in unlocks:
|
||||
if player_id not in decoded["unlocks"].get(achievement_id, {}):
|
||||
raise AssertionError(f"concurrent update lost {achievement_id} for {player_id}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="tarinai-achievement-random-audit-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
shutil.copy2(API_SOURCE, root / "achievement_api.php")
|
||||
data_dir = root / ".achievement_data"
|
||||
data_dir.mkdir()
|
||||
state_path = data_dir / "state.json"
|
||||
server = PhpServer(root)
|
||||
try:
|
||||
original, compact = run_migration_cases(server, state_path)
|
||||
run_corruption_cases(server, state_path)
|
||||
run_completionist_case(server, state_path)
|
||||
run_concurrency_case(server, state_path)
|
||||
finally:
|
||||
server.close()
|
||||
print(
|
||||
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
|
||||
f"completionist filtering, GET mutation rejection, and 72 concurrent syncs passed; "
|
||||
f"aggregate JSON shrank {original}→{compact} bytes ({compact / original:.1%})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
"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.style = { setProperty() {}, removeProperty() {} }; this.children = []; this.textContent = ""; this.open = false; }
|
||||
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(initialState = null) {
|
||||
const storage = new Map();
|
||||
if (initialState) storage.set("tarinai_achievements_v2", JSON.stringify(initialState));
|
||||
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 context = {
|
||||
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error,
|
||||
AbortController, encodeURIComponent, URLSearchParams, Uint8Array,
|
||||
location: { 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() { return new Element(); }, addEventListener() {} },
|
||||
TarinaiEvents: { emit() {} }, showToast() {}, audio: { uiClick() {} },
|
||||
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {},
|
||||
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 { api: context.TarinaiAchievements, storage };
|
||||
}
|
||||
|
||||
function assert(condition, message) { if (!condition) throw new Error(message); }
|
||||
function storedState(harness) { return JSON.parse(harness.storage.get("tarinai_achievements_v2")); }
|
||||
|
||||
const state = {
|
||||
unlocked: {
|
||||
first_birth: 1700000000000,
|
||||
natural_zunchi_slave: 1700000123000,
|
||||
lifespan_completed: 1700000999000,
|
||||
},
|
||||
pending: [], resetPending: false, debugUnlocked: [],
|
||||
progress: {
|
||||
placementCount: 77,
|
||||
linkTypes: ["rope", "spring"],
|
||||
signalActivated: true,
|
||||
antKills: 64,
|
||||
birthCount: 2222,
|
||||
quickDeleteCount: 19,
|
||||
undoCount: 11,
|
||||
redoCount: 12,
|
||||
medicineTypes: ["first_aid", "protein", "dwarf_drug"],
|
||||
firstAidHeals: 31,
|
||||
fightMochiFightCount: 444,
|
||||
shotCount: 222,
|
||||
loveMochiBirthCount: 555,
|
||||
},
|
||||
};
|
||||
|
||||
const sourceHarness = createHarness(state);
|
||||
assert(sourceHarness.api.definitions.length === 54, "spell audit expected 54 achievements");
|
||||
const payload = sourceHarness.api.exportSpellState();
|
||||
const compactLength = JSON.stringify(payload).length;
|
||||
assert(Array.isArray(payload) && payload[0] === 3, "spell achievement payload version mismatch");
|
||||
assert(payload[5].length === 12, "spell achievement progress field count mismatch");
|
||||
assert(compactLength < 230, `spell achievement payload is not compact enough: ${compactLength} chars`);
|
||||
|
||||
const allUnlocked = Object.fromEntries(sourceHarness.api.definitions.map((definition, index) => [definition.id, 1700000000000 + index * 60000]));
|
||||
const fullState = { ...state, unlocked: allUnlocked };
|
||||
const fullPayload = createHarness(fullState).api.exportSpellState();
|
||||
const fullPayloadLength = JSON.stringify(fullPayload).length;
|
||||
assert(fullPayloadLength < 500, `full achievement payload is not compact enough: ${fullPayloadLength} chars`);
|
||||
assert(fullPayload[5].every(value => value === 0), "completed achievement counters were stored in the spell payload");
|
||||
|
||||
const restored = createHarness();
|
||||
const result = restored.api.importSpellState(payload);
|
||||
assert(result.included && result.unlockedAdded === 3, "spell import did not restore unlocked achievements");
|
||||
for (const id of ["first_birth", "natural_zunchi_slave", "lifespan_completed"]) assert(restored.api.isUnlocked(id), `${id} was not restored`);
|
||||
const restoredState = storedState(restored);
|
||||
assert(restoredState.progress.placementCount === 77, "placement progress was not restored");
|
||||
assert(restoredState.progress.birthCount === 2222, "birth progress was not restored");
|
||||
assert(restoredState.progress.fightMochiFightCount === 444, "fight-mochi progress was not restored");
|
||||
assert(restoredState.progress.shotCount === 222, "shot progress was not restored");
|
||||
assert(restoredState.progress.loveMochiBirthCount === 555, "love-mochi birth progress was not restored");
|
||||
assert(restoredState.progress.signalActivated === true, "signal progress was not restored");
|
||||
assert(restoredState.progress.linkTypes.includes("rope") && restoredState.progress.linkTypes.includes("spring"), "link progress was not restored");
|
||||
assert(restoredState.progress.medicineTypes.includes("first_aid") && restoredState.progress.medicineTypes.includes("dwarf_drug"), "medicine progress was not restored");
|
||||
|
||||
const localState = {
|
||||
unlocked: { soccer_ball_death: 1600000000000 }, pending: [], resetPending: false, debugUnlocked: [],
|
||||
progress: {
|
||||
placementCount: 90, linkTypes: ["rod"], signalActivated: false, antKills: 80, birthCount: 2500,
|
||||
quickDeleteCount: 20, undoCount: 15, redoCount: 14, medicineTypes: ["mercury"], firstAidHeals: 40,
|
||||
fightMochiFightCount: 500, shotCount: 300, loveMochiBirthCount: 600,
|
||||
},
|
||||
};
|
||||
const merged = createHarness(localState);
|
||||
merged.api.importSpellState(payload);
|
||||
const mergedState = storedState(merged);
|
||||
assert(merged.api.isUnlocked("soccer_ball_death") && merged.api.isUnlocked("first_birth"), "spell import erased or failed to merge achievements");
|
||||
assert(mergedState.progress.placementCount === 90, "lower imported placement progress overwrote local progress");
|
||||
assert(mergedState.progress.birthCount === 2500, "lower imported birth progress overwrote local progress");
|
||||
assert(mergedState.progress.fightMochiFightCount === 500, "lower imported fight-mochi progress overwrote local progress");
|
||||
assert(mergedState.progress.shotCount === 300, "lower imported shot progress overwrote local progress");
|
||||
assert(mergedState.progress.loveMochiBirthCount === 600, "lower imported fertility progress overwrote local progress");
|
||||
assert(mergedState.progress.linkTypes.length === 3, "link progress was not union-merged");
|
||||
assert(mergedState.progress.medicineTypes.includes("mercury") && mergedState.progress.medicineTypes.includes("protein"), "medicine progress was not union-merged");
|
||||
|
||||
const legacyMask = "01000000000000";
|
||||
const legacyRestored = createHarness();
|
||||
const legacyResult = legacyRestored.api.importSpellState([1, legacyMask, 1700000000, [0], [1, 0, 0, 0, 0, 0, 0, 0, 0, 999]]);
|
||||
assert(legacyResult.included && legacyRestored.api.isUnlocked("first_birth"), "legacy v1 achievement spell was not imported");
|
||||
assert(storedState(legacyRestored).progress.fightMochiFightCount === 0, "legacy generic fight progress polluted the new fight-mochi counter");
|
||||
|
||||
const previousRestored = createHarness();
|
||||
previousRestored.api.importSpellState([2, 1, 0, 28333333, [0], [1, 0, 0, 0, 0, 0, 0, 0, 0, 666]]);
|
||||
assert(storedState(previousRestored).progress.fightMochiFightCount === 0, "v2 generic fight progress polluted the new fight-mochi counter");
|
||||
|
||||
let rejected = false;
|
||||
try { restored.api.importSpellState([3, -1, 0, 0, [], []]); } catch (_) { rejected = true; }
|
||||
assert(rejected, "malformed achievement spell payload was accepted");
|
||||
|
||||
const saveSystemSource = fs.readFileSync(path.join(root, "js", "save_system.js"), "utf8");
|
||||
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
|
||||
assert(saveSystemSource.includes("createSnapshot(global.world, { includeAchievements: true })"), "spell export does not request achievement state");
|
||||
assert(!/async function saveSlot[\s\S]*?includeAchievements:\s*true/.test(saveSystemSource.match(/async function saveSlot[\s\S]*?async function loadSlot/)?.[0] || ""), "slot saves unexpectedly include global achievement state");
|
||||
assert(snapshotSource.includes("metadata.s = spellState"), "snapshot metadata does not carry compact achievement state");
|
||||
assert(snapshotSource.includes("importSpellState?.(snapshot.g.s)"), "spell load does not import achievement state");
|
||||
assert(snapshotSource.includes('needRobotClean = !isUnlocked("robot_cleaner_100")'), "completed field counters are not omitted from spell metadata");
|
||||
assert(snapshotSource.includes('needOverprotective = !isUnlocked("overprotective")'), "completed per-tarinai counters are not omitted from spell metadata");
|
||||
assert(snapshotSource.includes('needQuickDelete = !isUnlocked("unplanned_city_30")'), "completed per-item counters are not omitted from spell metadata");
|
||||
|
||||
console.log(`[OK] v3 compact achievement spell payload (${compactLength} chars sample / ${fullPayloadLength} chars full), completed-counter omission, merge restore, old-format isolation, malformed-data rejection, and slot isolation passed`);
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
"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,
|
||||
CompressionStream, DecompressionStream,
|
||||
location: { search: "" },
|
||||
TarinaiSnapshot: { version: 35 },
|
||||
TarinaiSaveSchema: {
|
||||
BINARY_SCHEMA_VERSION: 43,
|
||||
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" });
|
||||
|
||||
const legacySpell = "た㣏洰祶蘬䕘㘎㗱餞皤庒袿랿涾뎳夃靅㸊倄遲诡㶣莡䵡錻缀袔鐨묪辶鎽묽砻菏뼿椳欛琛뺯琘뀛矎䗁蓐筓俇뗇㛂熋汯伂磌鉡䞳搶逷礣됽뷻鏲㨝尻㷿㭫牫㬈焂铤䅳㸢粃腓誘岜圉缴聦秮杰醆㳇幖豣羬懄䫌릘㾎惛橉芊芰㙞랷邆묒罸䅰哿鷔鉐皣栠䝹燂䒹蟜㲈婑裈㲵嫡䋪㲛갭效处彤䒴庭䴐姜脈礩弔粋肫䬑熶嬔翈哥渖㤈㱶闔䒚抉弐㡝酵义秮肘醃䆿傏铪彂櫾佌뭰叺馞稘焤봾岲䆫㐝";
|
||||
const progress = [77, 13, 64, 2222, 19, 11, 12, 1025, 31, 444, 222, 555];
|
||||
const metadata = {
|
||||
w: [[1, 2, 3, 4, 5], [3, 4, 5], 12, 187, 6, 145.5, 20, 18, 100, 90, -1],
|
||||
t: [],
|
||||
i: [],
|
||||
s: [3, 0xffffffff, (2 ** 22) - 1, Math.floor(1700000000 / 60), Array.from({ length: 54 }, (_value, index) => Math.floor(index * 12345 / 60)), progress],
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const legacyDecoded = await context.TarinaiSaveCodec.decodeSnapshot(legacySpell);
|
||||
if (legacyDecoded?.g?.s?.[0] !== 1) throw new Error("legacy schema-41 spell was not decoded");
|
||||
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
|
||||
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
|
||||
if (decoded?.g?.s?.[0] !== 3) throw new Error("binary achievement spell did not roundtrip");
|
||||
const saved = legacySpell.length - encoded.length;
|
||||
const ratio = saved / legacySpell.length;
|
||||
if (saved <= 0 || ratio < 0.35) throw new Error(`achievement spell compression regression: ${legacySpell.length} -> ${encoded.length}`);
|
||||
console.log(`[OK] achievement spell binary compression ${legacySpell.length} -> ${encoded.length} chars (${(ratio * 100).toFixed(1)}% reduction), legacy decode passed`);
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ function harness(storage = new Map()) {
|
|||
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.60"};
|
||||
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.65"};
|
||||
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
|
||||
return {c,api:c.TarinaiAchievements,storage,elements};
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
|
||||
(function run(){
|
||||
let h=harness();
|
||||
assert(h.api.definitions.length===54,"must have 54 achievements");
|
||||
assert(h.api.definitions.length===64,"must have 64 achievements");
|
||||
h.api.open();
|
||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||||
|
|
@ -149,18 +149,24 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
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};
|
||||
// Enemy's enemy: 30 alive, danger-item kills, and any dangerous tarinai hit resets the streak.
|
||||
h=harness(); const ew={time:20,tarinai:alive(30)};
|
||||
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");
|
||||
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");
|
||||
h.api.recordDamageSource(ew.tarinai[0],{type:"firecracker"},{world:ew});
|
||||
assert((ew.achievementEnemyAntKills||0)===0,"dangerous tarinai damage did not reset enemy streak");
|
||||
for(let i=0;i<10;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
|
||||
assert(on(h,"enemy_enemy_friend"),"enemy achievement did not unlock after 10 clean danger kills");
|
||||
h=harness();const ew2={time:20,tarinai:alive(30)};
|
||||
for(let i=0;i<5;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}
|
||||
ew2.tarinai[0].dead=true;const resetAnt={world:ew2};h.api.recordAntDamageSource(resetAnt,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:resetAnt});
|
||||
assert((ew2.achievementEnemyAntKills||0)===0,"population below 30 did not reset enemy streak");
|
||||
const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert((ew2.achievementEnemyAntKills||0)===0,"non-danger ant kill counted");
|
||||
h=harness();const ew3={time:20,tarinai:alive(30)};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");
|
||||
|
||||
// New observation and sticky-bomb relay achievements.
|
||||
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
|
||||
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
|
||||
|
||||
// Static integration guards for all production hook points.
|
||||
const checks={
|
||||
|
|
@ -180,6 +186,9 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
"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)"],
|
||||
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
|
||||
"js/snapshot_system.js":["stickyBombPassCount"],
|
||||
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
|
||||
};
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -2106,7 +2106,7 @@ def check_connection_overlay_and_time_detector() -> None:
|
|||
fail("detector dual-thumb slider styling is missing")
|
||||
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:
|
||||
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureMin' not in placement or 'data.pressureMax' 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")
|
||||
|
|
@ -2587,8 +2587,8 @@ def check_parasol_rain_and_preset_updates() -> None:
|
|||
|
||||
required = [
|
||||
(placement, 'overlay: overlayForItem(tmp)', "parasol placement preview overlay is not exported"),
|
||||
(placement, 'shape: "ellipse", centerX: footprint.centerX, centerY: footprint.centerY', "parasol preview does not reuse shade geometry"),
|
||||
(render, 'ctx.ellipse(overlay.centerX, overlay.centerY', "parasol placement overlay is not drawn at shade center"),
|
||||
(placement, 'shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY', "parasol preview does not reuse shade geometry"),
|
||||
(render, 'overlay?.shape === "roundedRect"', "shape-matched placement overlays are not rendered"),
|
||||
(weather, 'drop.rainDrop = opts?.rainDrop === true || (!hasX && !hasY);', "rain-created water is not tagged"),
|
||||
(ambient, 'const sand = worldRef.toiletSandAt?.(drop.x, drop.y) || null;', "toilet sand does not intercept rain water"),
|
||||
(ambient, '"toilet-sand-rain-absorb"', "rain absorption does not invalidate toilet sand rendering"),
|
||||
|
|
@ -2630,7 +2630,7 @@ vm.runInContext({placement!r}, overlayContext, {{ filename: 'placement_preview_s
|
|||
const previewWorld = {{ tool: 'rain_shelter', pointer: {{ inside: true, x: 100, y: 120 }}, toolSizeScale() {{ return 1; }}, toolSizeFor() {{ return 'medium'; }}, placementBlocked() {{ return false; }}, w: 800, h: 600 }};
|
||||
const preview = overlayContext.TarinaiPlacementPreviewSystem.forWorld(previewWorld);
|
||||
const shade = overlayContext.TarinaiParasolSystem.parasolFootprint(preview.item);
|
||||
if (!preview.overlay || preview.overlay.centerX !== shade.centerX || preview.overlay.centerY !== shade.centerY || preview.overlay.radiusX !== shade.radiusX || preview.overlay.radiusY !== shade.radiusY) throw new Error('parasol preview overlay and shade diverged');
|
||||
if (!preview.overlay || preview.overlay.centerX !== shade.centerX || preview.overlay.centerY !== shade.centerY || preview.overlay.halfWidth !== shade.radiusX || preview.overlay.halfHeight !== shade.radiusY || preview.overlay.shape !== 'roundedRect') throw new Error('parasol preview overlay and shade diverged');
|
||||
|
||||
const ambientContext = {{ console, globalThis: null, window: null, Math, Object }};
|
||||
ambientContext.globalThis = ambientContext; ambientContext.window = ambientContext;
|
||||
|
|
@ -2817,7 +2817,7 @@ def check_hair_trigger_tooltips_and_physics_icons() -> None:
|
|||
contains_local(layout, '"hair_trigger"', "field dialog preset fallback omits hair-trigger preset")
|
||||
contains_local(index, 'data-reset-preset="hair_trigger"', "hair-trigger preset button missing")
|
||||
contains_local(tools, 'function drawPhysicsToolIcon(canvas, toolId)', "dedicated physics icon renderer missing")
|
||||
contains_local(tools, 'category.id === "physics" && attachPhysicsToolIcon(btn, toolId)', "physics category does not use dedicated icons")
|
||||
contains_local(tools, 'const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId);', "dedicated item silhouettes are not attached across categories")
|
||||
for tool_id in ["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]:
|
||||
contains_local(tools, f'toolId === "{tool_id}"', f"distinct physics icon branch missing: {tool_id}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
"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");
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"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