186 lines
18 KiB
JavaScript
186 lines
18 KiB
JavaScript
"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.60"};
|
||
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===54,"must have 54 achievements");
|
||
h.api.open();
|
||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||
assert(title.mercury_lifespan==="不老不死まであと一歩","mercury title");
|
||
const description=(id)=>findCard(h,id).children[1].children[0].children[1].textContent;
|
||
assert(description("robot_cleaner_100").includes("ずんち・死骸・汚れを累計200個処理させる。"),"cleaner condition disclosed in UI");
|
||
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
||
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
||
assert(description("overprotective")==="???","other new condition hidden");
|
||
|
||
// One Undo only: multiple smaller Undo operations never aggregate.
|
||
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
||
assert(off(h,"undo_mass_revival"),"mass undo aggregated across operations");
|
||
h.api.recordHistoryAction("undo",{deadRestored:10}); assert(on(h,"undo_mass_revival"),"mass undo boundary");
|
||
|
||
// Undo/Redo counters persist across field resets and reloads.
|
||
let storage=new Map(); h=harness(storage);
|
||
for(let i=0;i<12;i++)h.api.recordHistoryAction("undo",{});
|
||
h.api.resetWorldProgress({});
|
||
h=harness(storage); for(let i=0;i<7;i++)h.api.recordHistoryAction("undo",{});
|
||
assert(off(h,"undo_20"),"undo unlocked at 19"); h.api.recordHistoryAction("undo",{}); assert(on(h,"undo_20"),"undo did not persist to 20");
|
||
for(let i=0;i<11;i++)h.api.recordHistoryAction("redo",{}); h=harness(storage); for(let i=0;i<8;i++)h.api.recordHistoryAction("redo",{});
|
||
assert(off(h,"redo_20"),"redo unlocked at 19"); h.api.recordHistoryAction("redo",{}); assert(on(h,"redo_20"),"redo did not persist to 20");
|
||
|
||
// Cleaner is field-local.
|
||
h=harness(); const wa={time:0},wb={time:0}; h.api.recordRobotClean({world:wa,type:"tarinai"}); assert((wa.achievementRobotCleanCount||0)===0,"cleaner counted non-cleanup target");
|
||
for(let i=0;i<199;i++)h.api.recordRobotClean({world:wa,type:i%2?"zunchi":"trace"});
|
||
assert(off(h,"robot_cleaner_100"),"cleaner before 200"); h.api.recordRobotClean({world:wb,type:"ant_corpse"}); assert(off(h,"robot_cleaner_100"),"cleaner crossed field reset");
|
||
h.api.recordRobotClean({world:wa,type:"splat"}); assert(on(h,"robot_cleaner_100"),"cleaner at 200");
|
||
|
||
// Continuous pinch must be uninterrupted and same individual.
|
||
h=harness(); const held={dead:false,playerHeld:true}; const hw={tarinai:[held]};
|
||
h.api.recordHeldTarinai(hw,1000); h.api.recordHeldTarinai(hw,30999); assert(off(h,"held_30_seconds"),"held before 30s");
|
||
held.playerHeld=false; h.api.recordHeldTarinai(hw,31000); held.playerHeld=true; h.api.recordHeldTarinai(hw,40000); h.api.recordHeldTarinai(hw,69999); assert(off(h,"held_30_seconds"),"held did not reset after release");
|
||
h.c.document.hidden=true; h.api.recordHeldTarinai(hw,70000); h.c.document.hidden=false;
|
||
h.api.recordHeldTarinai(hw,70001); h.api.recordHeldTarinai(hw,100000); assert(off(h,"held_30_seconds"),"hidden tab time counted toward hold");
|
||
h.api.recordHeldTarinai(hw,100001); assert(on(h,"held_30_seconds"),"held at 30s after visible reset");
|
||
|
||
// Minimalist counts only surviving player placements.
|
||
h=harness(); let mw={time:0,day:5,tarinai:alive(1),ants:[],items:Array.from({length:11},()=>({_achievementPlayerPlaced:true,dead:false}))};
|
||
h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist allowed 11 items");
|
||
mw.items[10].dead=true; h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist unlocked before day 6");
|
||
mw.day=6; h.api.evaluateWorld(mw,{id:"happy"}); assert(on(h,"minimalist_happy"),"minimalist at day 6 with 10 items");
|
||
|
||
// Overprotective requires both thresholds on the same individual.
|
||
h=harness(); const cared={dead:false}; const cw={time:0};
|
||
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",treatment:true});
|
||
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
||
|
||
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
||
h=harness(); let sw={time:0,tarinai:alive(20),ants:[],items:[]}; h.api.evaluateWorld(sw,{}); sw.time=599.99;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient early");
|
||
sw.items.push({type:"duplicator",dead:false});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient with duplicator");
|
||
sw.items=[];sw.time=700;h.api.evaluateWorld(sw,{});sw.time=1300;h.api.evaluateWorld(sw,{});assert(on(h,"self_sufficient"),"self sufficient after clean 10m");
|
||
h=harness(); sw={time:0,tarinai:alive(20),ants:[],items:[]};h.api.evaluateWorld(sw,{});sw.time=500;h.api.recordDirectFeed({world:sw,tarinai:sw.tarinai[0],type:"food"});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"direct feed did not reset self sufficient");
|
||
|
||
// Unplanned city is persistent and deletion must be within 30 seconds.
|
||
storage=new Map(); h=harness(storage); let qw={time:0};
|
||
for(let i=0;i<15;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||
h=harness(storage);qw={time:0};for(let i=0;i<14;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
||
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
||
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
||
|
||
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
||
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
||
temp=0;h.api.evaluateWorld(tw,{});assert(off(h,"sauna_cold_plunge"),"cold alone unlocked sauna");temp=40;h.api.evaluateWorld(tw,{});tw.time=15;temp=0;h.api.evaluateWorld(tw,{});assert(on(h,"sauna_cold_plunge"),"sauna boundary 15s");
|
||
h=harness(); const st2={dead:false,x:0,y:0};temp=40;const tw2={time:0,tarinai:[st2],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":"cold",comfortable:false};}};h.api.evaluateWorld(tw2,{});tw2.time=15.01;temp=0;h.api.evaluateWorld(tw2,{});assert(off(h,"sauna_cold_plunge"),"sauna allowed over 15s");
|
||
|
||
// Rain shelter requires all 20 and accepts nest/pipe via shelter system.
|
||
h=harness(); h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};
|
||
const rw={time:0,weather:"light_rain",tarinai:alive(20).map(t=>({...t,sheltered:true})),ants:[],items:[]};h.api.evaluateWorld(rw,{});assert(on(h,"rain_shelter_all"),"all sheltered rain achievement");
|
||
h=harness();h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};rw.tarinai[19].sheltered=false;h.api.evaluateWorld(rw,{});assert(off(h,"rain_shelter_all"),"rain achievement allowed one exposed");
|
||
|
||
// Nest boxes and pipes are genuine weather blockers, not only achievement stubs.
|
||
h=harness();
|
||
vm.runInContext(fs.readFileSync(path.join(root,"js","tarinai_local_environment_system.js"),"utf8"),h.c,{filename:"tarinai_local_environment_system.js"});
|
||
const nest={id:"n1",type:"nest_box",dead:false};
|
||
const pipe={id:"p1",type:"pipe",dead:false};
|
||
const shelterWorld={items:[nest,pipe],nestBoxById(id){return this.items.find(x=>x.id===id)||null;}};
|
||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"n1"}),"nest box did not block weather");
|
||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"p1"}),"pipe did not block weather");
|
||
assert(!h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{}),"unsheltered target treated as sheltered");
|
||
|
||
// Nest-box and pipe occupants must also be insulated from weather temperature offsets.
|
||
{
|
||
class FakeWorld {}
|
||
const tc={console,Math,Number,Object,Array,Set,Map,WeakMap,Date,CONFIG:{standardTemperature:15,temperatureItemRange:190},
|
||
World:FakeWorld,clamp:(v,a,b)=>Math.max(a,Math.min(b,v)),distXY:(x1,y1,x2,y2)=>Math.hypot(x1-x2,y1-y2),
|
||
TarinaiParasolSystem:h.c.TarinaiRainShelterSystem};
|
||
tc.window=tc;tc.globalThis=tc;vm.createContext(tc);
|
||
vm.runInContext(fs.readFileSync(path.join(root,"js","world_temperature_system.js"),"utf8"),tc,{filename:"world_temperature_system.js"});
|
||
const box={id:"box",type:"nest_box",dead:false,x:0,y:0,r:42};
|
||
const pipeItem={id:"pipe",type:"pipe",dead:false,x:0,y:0,r:42};
|
||
const weatherWorld=new FakeWorld();Object.assign(weatherWorld,{currentTemperature:15,temperatureAuto:true,groundType:"soil",weather:"light_rain",items:[box,pipeItem],
|
||
nearbyItems(x,y,r){return this.items.filter(it=>Math.hypot(it.x-x,it.y-y)<=r);},nestBoxById(id){return this.items.find(x=>x.id===id)||null;},nestBoxBaseRect(){return null;}});
|
||
const outside={x:500,y:0}; const inBox={x:0,y:0,insideNestBoxId:"box"}; const inPipe={x:0,y:0,insideNestBoxId:"pipe"};
|
||
assert(weatherWorld.feltTemperatureFor(outside)<15,"rain weather offset missing outside");
|
||
assert(weatherWorld.feltTemperatureFor(inBox)>=15,"nest box still received rain temperature offset");
|
||
assert(weatherWorld.feltTemperatureFor(inPipe)>=15,"pipe still received rain temperature offset");
|
||
}
|
||
|
||
// Every medicine item once; chain and crown are explicitly excluded.
|
||
h=harness(); const meds=["first_aid","sedative","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug"];
|
||
meds.slice(0,-1).forEach(type=>h.api.recordConsumableUse({type}));assert(off(h,"medicine_ledger_all"),"medicine ledger early");h.api.recordConsumableUse({type:meds.at(-1)});assert(on(h,"medicine_ledger_all"),"medicine ledger complete");
|
||
|
||
// Mercury lifespan, revolution, fight mochi.
|
||
h=harness();h.api.recordDeath({world:{time:1,tarinai:[]},tarinai:{lifeItemMode:"mercury"},reason:"天寿を全うした"});assert(on(h,"mercury_lifespan"),"mercury lifespan");
|
||
h=harness();const slave={id:"s",isZunchiSlave:true,dead:false};const king={id:"k",isTarinaiChampion:true,dead:true,_achievementLastFightAttackerId:"s",_achievementLastFightDamageAt:5};const revWorld={time:5,tarinai:[slave,king]};h.api.recordDeath({world:revWorld,tarinai:king,reason:"喧嘩",fromDamage:true});assert(on(h,"revolution"),"revolution");
|
||
h=harness();const oil={};const fw={time:0};h.api.recordConsumableUse({world:fw,tarinai:oil,type:"fight_mochi"});fw.time=30;h.api.recordFightStarted({world:fw,a:oil,b:{}});assert(on(h,"fuel_to_fire"),"fight mochi at 30s");
|
||
h=harness();const noOil={};h.api.recordFightStarted({world:{time:0},a:noOil,b:{}});assert(off(h,"fuel_to_fire"),"fight without mochi unlocked at time zero");
|
||
h=harness();const oil2={};const fw2={time:0};h.api.recordConsumableUse({world:fw2,tarinai:oil2,type:"fight_mochi"});fw2.time=30.01;h.api.recordFightStarted({world:fw2,a:oil2,b:{}});assert(off(h,"fuel_to_fire"),"fight mochi over 30s");
|
||
|
||
// Enemy's enemy: 30 alive, no tarinai damage for 10 sec, danger source, field-local count.
|
||
h=harness(); const ew={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};
|
||
for(let i=0;i<9;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
|
||
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");const ant10={world:ew};h.api.recordAntDamageSource(ant10,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant:ant10});assert(on(h,"enemy_enemy_friend"),"enemy achievement at 10");
|
||
h=harness();const ew2={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:11};for(let i=0;i<10;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}assert(off(h,"enemy_enemy_friend"),"enemy counted within 10s of tarinai damage");
|
||
ew2.time=22;ew2.achievementLastTarinaiDamageAt=11;const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert(off(h,"enemy_enemy_friend"),"non-danger ant kill counted");
|
||
const noCollateral={time:50,tarinai:alive(30),achievementLastTarinaiDamageAt:-Infinity};
|
||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"food"},{world:noCollateral});
|
||
assert(noCollateral.achievementLastTarinaiDamageAt===-Infinity,"ordinary damage incorrectly blocked enemy achievement");
|
||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"firecracker"},{world:noCollateral});
|
||
assert(noCollateral.achievementLastTarinaiDamageAt===50,"danger collateral was not recorded");
|
||
h=harness();const ew3={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
|
||
|
||
// Static integration guards for all production hook points.
|
||
const checks={
|
||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction"],
|
||
"js/robot_cleaner_system.js":["recordRobotClean"],
|
||
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
||
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
||
"js/tarinai_social_move_life.js":["_achievementLastFightAttackerId"],
|
||
"js/tarinai_direct_feeding_system.js":["recordDirectCare"],
|
||
"js/tarinai_consumable_behavior.js":["recordConsumableUse"],
|
||
"js/tarinai_contact_item_system.js":["recordConsumableUse", "slave_chain", "golden_crown"],
|
||
"js/world_combat_effects.js":["recordAntDamageSource","source: it"],
|
||
"js/impact_response_system.js":["source: it"],
|
||
"js/ants.js":["recordAntDamageSource", "tarinai_struggle"],
|
||
"js/signal_system.js":["recordAntDamageSource", "electric_wire"],
|
||
"js/tarinai_item_effects.js":["recordAntDamageSource", "tarinai_ammo_collision"],
|
||
"js/tarinai_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
|
||
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
|
||
"js/tarinai_disease_nest.js":["isSheltered?.(this.world, this.x, this.y, this)"],
|
||
};
|
||
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
||
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||
})();
|