tarinai/scripts/playstyle_achievement_audit.js
2026-07-16 22:12:03 +09:00

195 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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.65"};
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===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");
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, 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");
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={
"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)"],
"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");
})();