feat: 发布咸鱼喵喵全能助手,加入全收集地图与图文说明

This commit is contained in:
Kitasami_Nya
2026-10-24 01:37:15 +08:00
parent 8f6ac4ff04
commit 3823e38b07
39 changed files with 2918 additions and 194 deletions
+103
View File
@@ -0,0 +1,103 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {catalogCellBounds,findCatalogLanding}=require('../lib/catalog-position');
const {OPS}=require('../lib/ops');
test('catalog cell bounds use the actual submap cell and coordinate offsets',()=>{
const thumbnail={minX:35,maxX:35,minY:32,maxY:35,data:[{x:35,y:32},{x:35,y:33},{x:35,y:34},{x:35,y:35}]};
assert.deepEqual(catalogCellBounds({width:20,height:64,meta:{}},thumbnail,{x:35,y:34}),{left:0,top:32,right:20,bottom:48,cellX:35,cellY:34});
assert.deepEqual(catalogCellBounds({width:20,height:64,meta:{cellOx:2,cellOy:3}},thumbnail,{x:35,y:34}),{left:0,top:29,right:18,bottom:45,cellX:35,cellY:34});
assert.throws(()=>catalogCellBounds({},thumbnail,{x:99,y:99}),/分块信息/);
});
test('catalog landing checks the whole body, avoids walls and refines standing height above thin ground',()=>{
const bounds={left:0,top:12,right:18,bottom:24},shape={left:20,right:20,top:80,bottom:20};
const floorY=23*96+7;
const hitsAt=(px,py)=>px-shape.left<96?[{terrain:true,type:0}]:py+shape.bottom>=floorY?[{terrain:true,type:0}]:[];
const pick=findCatalogLanding({bounds,shape,tileWidth:96,tileHeight:96,hitsAt});
assert.equal(pick.grounded,true);assert.equal(hitsAt(pick.px,pick.py).length,0);
assert.ok(pick.py+shape.bottom<floorY);assert.ok(floorY-(pick.py+shape.bottom)<=1);
assert.ok(pick.px-shape.left>=96);assert.ok(pick.py-shape.top>=bounds.top*96);
assert.ok(pick.py+shape.bottom<=bounds.bottom*96);
});
test('catalog landing refuses blocked or undersized cells and bounds its search',()=>{
const args={bounds:{left:0,top:0,right:4,bottom:4},shape:{left:20,right:20,top:80,bottom:20},tileWidth:96,tileHeight:96};
assert.equal(findCatalogLanding({...args,hitsAt:()=>[{terrain:true,type:0}]}),null);
assert.equal(findCatalogLanding({...args,bounds:{left:0,top:0,right:1,bottom:0.5},hitsAt:()=>[]}),null);
const limited=findCatalogLanding({...args,maxProbes:2,hitsAt:()=>[]});assert.equal(limited.grounded,false);assert.ok(limited.probes<=3);
const floating=findCatalogLanding({...args,hitsAt:()=>[]});assert.equal(floating.grounded,false);
const hazardous=findCatalogLanding({...args,hitsAt:()=>[{terrain:true,type:2}]});assert.equal(hazardous,null);
});
function runtime({blocked=false,blockAfterTransfer=false,blockedTargetCell=false}={}){
class Character{
constructor(){Object.assign(this,{px:500,py:500,_vx:0,_vy:0,dotX:0,dotY:0,_direction:6});}
hitOffsetL(){return this._hitOffsetL??24;}hitOffsetR(){return this._hitOffsetR??24;}
hitOffsetT(){return this._hitOffsetT??72;}hitOffsetB(){return this._hitOffsetB??16;}
tileXToCenterX(x){return x*96+48;}tileYToCenterY(y){return y*96+80;}
locate(x,y){this._x=x;this._y=y;this.unflush(x,y);}unflush(x,y){this.px=this.tileXToCenterX(x);this.py=this.tileYToCenterY(y);}
suspendGravity(){this._suspendGravity=true;}resumeGravity(){this._suspendGravity=false;}
suspendPhysicsHitTest(){this._suspendPhysicsHitTest=true;}resumePhysicsHitTest(){this._suspendPhysicsHitTest=false;}
isSuspendGravity(){return !!this._suspendGravity;}clearImpact(){}clearGrounding(){}
doGravity(){}makeMomentum(){}cacheDeltaTimeSpeed(){}isRemove(){return false;}
}
class Grid{
rebuild(){}
getObjGrid(p,hits){if(this.blocked||(this.blockedTargetCell&&p.px<10*96)||p.py+p.hitOffsetB()>=14*96)hits.push({terrain:true,type:0});}
}
class Map{
mapId(){return this._mapId;}tileWidth(){return 96;}tileHeight(){return 96;}width(){return 20;}height(){return 16;}
tilemapTerrain(){}isAnyEventStarting(){return false;}
}
const p=new Character(),map=new Map(),terrain=new Grid();map._mapId=1;map._terrain=terrain;terrain.blocked=blocked;terrain.blockedTargetCell=blockedTargetCell;
const data={width:20,height:16,meta:{},events:[null,{id:1,x:10,y:10}]};
const context=vm.createContext({window:{},Game_CharacterBase:Character,Game_Map:Map,PhysicsGrid:Grid,
$gamePlayer:p,$gameLily:null,$gameActors:{},$gameMap:map,$dataMap:data,$dataMapInfos:[null,{},{}],
SceneManager:{_scene:{constructor:{name:'Scene_Map'}}},setTimeout:fn=>fn(),
DataManager:{getCachedData:()=>data,getThumbnailCell:(x,y,id)=>({x:0,y:0,mapId:id}),
getThumbnailMapInfo:()=>({minX:0,maxX:blockedTargetCell?1:0,minY:0,maxY:0,data:blockedTargetCell?[{x:0,y:0},{x:1,y:0}]:[{x:0,y:0}]})}});
const transfers=[];
p.reserveTransfer=(id,x,y)=>{transfers.push(id);map._mapId=id;map._terrain=new Grid();map._terrain.blocked=id===2&&blockAfterTransfer;context.$dataMap=data;p._x=x;p._y=y;};
p.isTransferring=()=>false;
p.locate=()=>{throw Error('Catalog must use base locate, without the native player grounding adjustment');};
return{context,p,map,transfers};
}
test('catalog travel validates before moving and arrives with hover and collision',async()=>{
const f=runtime();const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),f.context));
assert.equal(r.ok,true);assert.equal(r.gravitySuspended,true);assert.equal(r.noclip,false);
assert.equal(r.catalogCell.cellX,0);assert.ok(f.p.py+f.p.hitOffsetB()<14*96);
const bad=runtime({blocked:true}),before=[bad.p.px,bad.p.py];
const refused=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),bad.context));
assert.equal(refused.ok,false);assert.deepEqual([bad.p.px,bad.p.py],before);assert.deepEqual(bad.transfers,[]);
});
test('a blocked target cell expands to another cell in the same map and still teleports',async()=>{
const f=runtime({blockedTargetCell:true});
const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),f.context));
assert.equal(r.ok,true);assert.equal(r.catalogCell.cellX,1);assert.ok(f.p.px>10*96);
assert.match(r.positionNote,/同图其他可用位置/);assert.equal(r.noclip,false);
});
test('catalog travel rechecks the loaded destination and returns on newly blocked geometry',async()=>{
const f=runtime({blockAfterTransfer:true}),before=[f.p.px,f.p.py];
const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(2,1),f.context));
assert.equal(r.ok,false);assert.match(r.error,/返回原位置/);
assert.deepEqual(f.transfers,[2,1]);assert.equal(f.map.mapId(),1);assert.deepEqual([f.p.px,f.p.py],before);
});
test('world cell travel validates map and page before moving, and supports empty cells with safe fallback',async()=>{
assert.throws(()=>OPS.worldMapTravel(1,0,0,'1'),/坐标无效/);
const f=runtime({blockedTargetCell:true});
f.context.DataManager._thumbnailMapCell={a:{mapId:1,x:0,y:0,page:3},b:{mapId:1,x:1,y:0,page:3}};
const before=[f.p.px,f.p.py];
for(const args of [[1,0,0,4],[2,0,0,3],[1,99,0,3]]){
const r=JSON.parse(await vm.runInContext(OPS.worldMapTravel(...args),f.context));
assert.equal(r.ok,false);assert.match(r.error,/不属于/);assert.deepEqual([f.p.px,f.p.py],before);
}
assert.deepEqual(f.transfers,[]);
const r=JSON.parse(await vm.runInContext(OPS.worldMapTravel(1,0,0,3),f.context));
assert.equal(r.ok,true);assert.equal(r.catalogCell.cellX,1);assert.equal(r.noclip,false);assert.equal(r.gravitySuspended,true);
});
+22
View File
@@ -0,0 +1,22 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm');
const {installCombat}=require('../lib/combat');
test('combat scales player attacks and incoming damage, supports bullets and respects healing and scripted immunity',()=>{
function Character(team){this.battle={team,hp:100,unlimitHp:false};}
Character.prototype.battleObject=function(){return this.battle;};
Character.prototype.overrideBeAttackDamage=function(d){return d;};
Character.prototype.onAttackHit=function(info,target){const d=target.overrideBeAttackDamage(info.damage);if(!target.battle.unlimitHp)target.battle.hp-=d;return d;};
Character.prototype.requestDamage=function(d){this.battle.hp-=d;};
const p=new Character(0),lily=new Character(0),enemy=new Character(1),other=new Character(1),timers=[];
const c=vm.createContext({window:{},Game_CharacterBase:Character,$gamePlayer:p,$gameLily:lily,$gameActors:{},setInterval:fn=>timers.push(fn)});
const api=vm.runInContext(`(${installCombat.toString()})()`,c);
// The real Player and Event classes override the base method independently.
enemy.overrideBeAttackDamage=function(d){return d;};p.overrideBeAttackDamage=function(d){return d;};
api.change({outgoing:3,incoming:0.5});assert.equal(p.onAttackHit({damage:10},enemy),30);
assert.equal(enemy.onAttackHit({damage:10},p),5);p.requestDamage(10);assert.equal(p.battle.hp,90);
assert.equal(other.onAttackHit({damage:10,bulletOwner:p},enemy),30);
assert.equal(enemy.onAttackHit({damage:-10},p),-10);
api.change({oneHit:true});p.onAttackHit({damage:1},enemy);assert.ok(enemy.battle.hp<=0);
enemy.battle.hp=100;enemy.battle.unlimitHp=true;p.onAttackHit({damage:1},enemy);assert.equal(enemy.battle.hp,100);
assert.throws(()=>api.change({outgoing:Infinity}));assert.throws(()=>api.change({incoming:-1}));
c.$gameActors={};api.read();assert.equal(api.read().outgoing,1);assert.equal(api.read().oneHit,false);
});
+49
View File
@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function fixture() {
const source=fs.readFileSync(path.join(__dirname,'../web/app.js'),'utf8');
const start=source.indexOf('// ---------- 收集百科 ----------'),end=source.indexOf('// ---------- 标签页 ----------',start);
assert.ok(start>=0&&end>start);
const a={key:'ornaments:0:14:1:0:9',type:'ornaments',id:0,mapId:14,mapName:'MAP014',area:'森林',
eventId:1,pageIndex:0,x:9,y:22,collected:false,conditions:[]};
const b={...a,key:'ornaments:0:88:2:0:9',mapId:88,mapName:'MAP088',area:'城堡',eventId:2,x:0,y:0};
// HTTP JSON duplicates shared objects; item.locations and locations no longer
// contain identical object references even though their keys still match.
const encyclopedia=JSON.parse(JSON.stringify({context:'current',locations:[a,b],shops:[{...a,x:5,y:6}],
items:[{type:'ornaments',id:0,name:'挂件',desc:'效果',count:0,shopCandidate:true,price:100,locations:[a,b]}]}));
const elements=new Map(),calls=[],messages=[];let refreshed=0;
const $=key=>{if(!elements.has(key))elements.set(key,{value:'',innerHTML:'',replaceChildren(){}});return elements.get(key);};
const context=vm.createContext({$,ENCYCLOPEDIA:encyclopedia,ENCYCLOPEDIA_BUSY:false,ENCYCLOPEDIA_REVISION:0,
CURRENT_CONTEXT:'current',CURRENT_GAME:{inMap:true},ITEM_TYPES:{ornaments:'配饰'},esc:v=>String(v??''),
api:async(route,body)=>{calls.push({route,body:JSON.parse(JSON.stringify(body))});return{ok:true,positionNote:'目标分块边缘附近的可落脚空处'};},
toast:(message,kind)=>messages.push({message,kind}),refreshStatus:()=>refreshed++});
vm.runInContext(source.slice(start,end),context);
const click=(attribute,value)=>$('#encyclopedia-items').onclick({target:{closest:()=>({
hasAttribute:key=>key===attribute,dataset:{location:value,shop:value}})}});
return{context,$,calls,messages,encyclopedia,click,refreshed:()=>refreshed};
}
test('encyclopedia buttons resolve JSON-loaded and filtered locations by stable key',async()=>{
const f=fixture();assert.equal(f.encyclopedia.locations.indexOf(f.encyclopedia.items[0].locations[0]),-1);
f.$('#encyclopedia-area').value='城堡';vm.runInContext('renderEncyclopedia()',f.context);
const html=f.$('#encyclopedia-items').innerHTML;
assert.match(html,/data-location="ornaments:0:88:2:0:9"/);assert.doesNotMatch(html,/data-location="-1"|data-location="ornaments:0:14/);
await f.click('data-location','ornaments:0:88:2:0:9');
assert.deepEqual(f.calls,[{route:'/encyclopedia/travel',body:{mapId:88,eventId:2}}]);
assert.match(f.messages[0].message,/MAP088.*分块边缘.*碰撞已恢复/);assert.equal(f.refreshed(),1);
await f.click('data-location','ornaments:0:14:1:0:9');
assert.deepEqual(f.calls[1],{route:'/encyclopedia/travel',body:{mapId:14,eventId:1}});
});
test('encyclopedia still rejects stale locations and menu movement; shop travel keeps working',async()=>{
const f=fixture();f.context.CURRENT_CONTEXT='another-map';
await f.click('data-location','ornaments:0:14:1:0:9');assert.equal(f.calls.length,0);assert.match(f.messages.at(-1).message,/刷新百科/);
f.context.CURRENT_CONTEXT='current';f.context.CURRENT_GAME.inMap=false;
await f.click('data-location','ornaments:0:14:1:0:9');assert.equal(f.calls.length,0);assert.match(f.messages.at(-1).message,/关闭游戏菜单/);
f.context.CURRENT_GAME.inMap=true;await f.click('data-location','missing');assert.equal(f.calls.length,0);
await f.click('data-shop','0');assert.deepEqual(f.calls,[{route:'/encyclopedia/travel',body:{mapId:14,eventId:1}}]);
});
+30
View File
@@ -0,0 +1,30 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { readEncyclopedia } = require('../lib/encyclopedia');
test('encyclopedia separates ownership from collected flags and never guesses branch rewards', () => {
const flags = {'14,1,A':true};
const add=(type,idx,indent=0)=>({code:357,indent,parameters:['NYA.Utils','addItem','',{type,idx:String(idx),antiCollection:'false'}]});
const event=(id,list,conditions={})=>({id,name:'pickup',x:id+1,y:4,pages:[{conditions,list}]});
const map={events:[null,event(1,[add('hpst',0)]),event(2,[add('hpst',0)]),event(3,[add('ornaments',0,1),{code:123,indent:1,parameters:['A',0]}]),
event(4,[{code:357,parameters:['NYA.Utils','shop','',{}]}]),event(5,[{code:357,parameters:['NYA.Utils','add_coin','',{num:'-20'}]}]),
event(6,[{code:357,parameters:['NYA.EVENT.AI_Adapter','ai','',{type:'60',battleObject:'104'}]}]) ]};
const inventory={subject:'actor',stats:{coin:{value:100}},items:[{type:'hpst',id:0,name:'逗猫棒',count:0},{type:'ornaments',id:0,name:'挂件',count:1}]};
const actor={_ornaments:{data:()=>({0:{price:900,openTask:0}})},hpStrengthData:()=>({price:500})};
const context=vm.createContext({inventory,$gamePlayer:{actor:()=>actor},DataManager:{_cacheMapData:{14:map},mapRegion:()=>2},
$dataRegion:{2:{name:'幽暗之森',exploreItem:[[14,1],[14,2],[14,6]]}},$dataMapInfos:{14:{name:'MAP014'}},$dataSystem:{switches:[],variables:[]},
$gameSwitches:{value:()=>false},$gameVariables:{value:()=>0},$gameSelfSwitches:{value:key=>!!flags[key.join(',')]}});
const result=JSON.parse(vm.runInContext(`JSON.stringify((${readEncyclopedia.toString()})(inventory))`,context));
assert.equal(result.items[0].count,0); assert.equal(result.items[0].locations[0].collected,true); assert.equal(result.items[0].locations[1].collected,false);
assert.equal(result.items[1].locations[0].collected,null); assert.equal(result.items[1].shopCandidate,true);
assert.equal(result.locations.length,4); assert.equal(result.shops.length,1);
assert.equal(result.items[2].locations[0].kind,'coinpile'); assert.equal(result.items[2].locations[0].collected,false);
assert.deepEqual(result.stats,{items:3,locations:4,shops:1,total:3,collected:1});
assert.deepEqual(flags,{'14,1,A':true}); assert.equal(inventory.items[0].locations,undefined);
});
test('encyclopedia reports missing runtime map data instead of empty success', () => {
const context=vm.createContext({DataManager:{_cacheMapData:{}},inventory:{}});
assert.throws(()=>vm.runInContext(`(${readEncyclopedia.toString()})(inventory)`,context),/尚未加载/);
});
+15
View File
@@ -0,0 +1,15 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {readItemIcons}=require('../lib/item-icons');
test('item thumbnails use the native icon frames without creating sprites or writing files',async()=>{
const draws=[];
const bitmap={width:384,height:192,isReady:()=>true,canvas:{source:true}};
const c=vm.createContext({items:[{type:'consumables',id:0},{type:'ornaments',id:25}],setTimeout,clearTimeout,
Sprite_ItemIcon:{prototype:{itemBitmap:()=>bitmap,refreshIdx(id){this.setFrame(id===25?32:0,id===25?160:32,32,32);}}},
document:{createElement:()=>({getContext:()=>({drawImage(...args){draws.push(args.slice(1));}}),toDataURL:()=> 'data:image/png;base64,dGVzdA=='})}});
const result=JSON.parse(await vm.runInContext(`(${readItemIcons.toString()})(items).then(JSON.stringify)`,c));
assert.equal(result.ok,true);assert.equal(Object.keys(result.icons).length,2);
assert.deepEqual(draws[0],[0,32,32,32,0,0,32,32]);assert.deepEqual(draws[1],[32,160,32,32,0,0,32,32]);
});
+187
View File
@@ -0,0 +1,187 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { installMotion } = require('../lib/motion');
const { OPS } = require('../lib/ops');
function fixture() {
let now = 0;
const timers = [];
const character = () => ({ _x: 3, _y: 4, px: 3 * 96 + 40, py: 4 * 96 + 80, _vx: 0, _vy: 12,
get vx() { return this._vx; }, set vx(v) { this._vx = v; },
get vy() { return Math.round(this._vy); }, set vy(v) { this._vy = v; },
g: () => 0, deltaTime: () => 1, doGravity() { this._vy += 0.5; }, updateMapBoundTransfer() { this.transfer = true; },
makeMomentum() { this.rx = this.px + this._vx; this.ry = this.py + this._vy; },
cacheDeltaTimeSpeed() { this._vx *= this.deltaTime(); this._vy *= this.deltaTime(); },
startTestV() { if(!this._testV){this._testV=true;this._tvx=this._vx;this._tvy=this._vy;} },
testV(v) {this.vx=v.x;this.vy=v.y;this.makeMomentum();},
endTestV() {if(this._testV){this.vx=this._tvx;this.vy=this._tvy;this._testV=false;this.makeMomentum();}},
tileXToCenterX: x => x * 96 + 40, tileYToCenterY: y => y * 96 + 80,
locate(x,y) { this._x = Math.round(x); this._y = Math.round(y); this.unflush(x,y); },
unflush(x,y) { this.px = Math.round(x * 96 + 40); this.py = Math.round(y * 96 + 80); },
clearImpact() {}, clearGrounding() {}, suspendGravity() { this._suspendGravity = true; }, resumeGravity() { this._suspendGravity = false; },
suspendPhysicsHitTest() { this._suspendPhysicsHitTest = true; }, resumePhysicsHitTest() { this._suspendPhysicsHitTest = false; },
isSuspendGravity() { return !!this._suspendGravity; }, isRemove() { return false; },
hitPreview() { return []; },
isFlexibleGrounding() { return !!this.grounded; }, isTriggeredJump() { return !!this.jumpPressed; },
isPressDown() { return !!this.downPressed; }, isOutControl() { return !!this.blocked; }, jumpStrength() { return -8; },
controlJump() { if(this.grounded && this.jumpPressed)this.vy=-8; },
controlDoubleJump() { if(!this.grounded && !this.useDoubleJump && this.jumpPressed) { this.vy=-6; this.useDoubleJump=true; } },
});
const p = character(), lily = character();
const context = vm.createContext({ window: {}, $gamePlayer:p, $gameLily:lily, $dataMap: {},
$gameMap: { g: () => 0.5, width: () => 20, height: () => 12, tileWidth: () => 96, tileHeight: () => 96, mapId: () => 1 },
SceneManager: { _scene: { constructor: { name:'Scene_Map' } } },
Date: { now: () => now }, setInterval: fn => timers.push(fn),
});
const api = vm.runInContext(`(${installMotion.toString()})()`, context);
return { context, api, p, lily, advance(ms) { now += ms; timers.forEach(fn=>fn()); } };
}
test('hover stops falling, slows upward jump to zero, supports inverted gravity and restores hooks', () => {
const f=fixture(), original=f.p.doGravity, transfer=f.p.updateMapBoundTransfer;
f.api.enable(f.p); assert.equal(f.p._vy,0); assert.equal(f.p._suspendGravity,true);
const saved=JSON.parse(JSON.stringify(f.p));
assert.equal(Object.hasOwn(saved,'_suspendGravity'),false);
assert.equal(Object.hasOwn(saved,'_suspendPhysicsHitTest'),false);
f.p._vy=-8; const speeds=[];
for(let i=0;i<40;i++) { f.p.doGravity(); speeds.push(f.p._vy); }
assert.equal(speeds[0],-7.5); assert.equal(speeds.at(-1),0); assert.ok(speeds.every(v=>v<=0));
f.p._vy=20; f.p.doGravity(); assert.equal(f.p._vy,0);
f.p._vy=20; f.p.cacheDeltaTimeSpeed(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p._testV=true; f.p._vy=5; f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py+5); f.p._testV=false;
f.p._invertMode=true; f.p._vy=8;
for(let i=0;i<40;i++) f.p.doGravity();
assert.equal(f.p._vy,0);
f.p.updateMapBoundTransfer(); assert.equal(f.p.transfer,true);
assert.equal(Object.keys(f.p).includes('doGravity'),false);
f.api.clear(); assert.equal(f.p.doGravity,original); assert.equal(f.p.updateMapBoundTransfer,transfer);
assert.equal(f.p._suspendGravity,false); assert.equal(f.p._suspendPhysicsHitTest,false);
});
test('noclip enables hover for both characters and refuses to restore collision inside terrain', async () => {
const f=fixture();
const run=async code=>JSON.parse(await vm.runInContext(code,f.context));
assert.equal((await run(OPS.setNoclip(true))).ok,true);
for(const c of [f.p,f.lily]) { assert.equal(c._suspendGravity,true); assert.equal(c._suspendPhysicsHitTest,true); c._vy=99; c.doGravity(); assert.equal(c._vy,0); }
f.p.hitPreview=()=>[{terrain:true,type:0}];
assert.equal((await run(OPS.setNoclip(false))).ok,false); assert.equal(f.p._suspendPhysicsHitTest,true);
f.p.hitPreview=()=>[];
assert.equal((await run(OPS.setNoclip(false))).ok,true); assert.equal(f.p._suspendPhysicsHitTest,false); assert.equal(f.p._suspendGravity,true);
});
test('pixel nudge uses physical position, accumulates exact small steps and respects map bounds', async () => {
const f=fixture(), run=async code=>JSON.parse(await vm.runInContext(code,f.context));
f.p.px=500; f.p.py=600;
const first=await run(OPS.nudge(1,0,4)); assert.equal(first.px,504); assert.equal(first.py,600);
const next=await run(OPS.nudge(1,0,4)); assert.equal(next.px,508);
f.p.px=40; assert.equal((await run(OPS.nudge(-1,0,16))).px,40);
f.p.py=80; assert.equal((await run(OPS.nudge(0,-1,1))).py,80);
f.p.px=99999; f.p.py=99999; f.p.updateMapBoundTransfer(); assert.equal(f.p.px,19*96+40); assert.equal(f.p.py,11*96+80);
assert.throws(()=>OPS.nudge(1,1,4)); assert.throws(()=>OPS.nudge(1,0,Infinity));
});
test('hover survives normal map changes but releases on save load and heartbeat expiry', () => {
const f=fixture(); f.api.enable(f.p,true); f.advance(120001);
assert.equal(f.api.has(f.p),false); assert.equal(f.p._suspendGravity,false);
f.api.enable(f.p,true); f.context.$dataMap={}; f.p.resumeGravity();f.p.resumePhysicsHitTest();f.advance(1);
assert.equal(f.api.has(f.p),true);assert.equal(f.p.isSuspendGravity(),true);assert.equal(f.p._suspendPhysicsHitTest,true);
f.context.$gameMap={...f.context.$gameMap};f.advance(1);
assert.equal(f.api.has(f.p),false);
});
test('hover allows repeated airborne jumps, manual descent, and preserves downward skill movement', () => {
const f=fixture(), jump=f.p.controlJump, double=f.p.controlDoubleJump;
f.p.useDoubleJump=true; f.api.enable(f.p); f.p.jumpPressed=true;
for(let i=0;i<5;i++) { f.p._vy=0; f.p.controlJump({}); f.p.controlDoubleJump({}); assert.equal(f.p._vy,-8); }
assert.equal(f.p.useDoubleJump,true);
f.p.jumpPressed=false; f.p.downPressed=true; f.p.doGravity(); f.p.makeMomentum();
assert.equal(f.p.ry-f.p.py,4);
f.p.downPressed=false; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p._bodyAttacking='toru_down_dash_attack'; f.p._vy=22; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry-f.p.py,22);
f.p.downPressed=true; f.p._vy=25; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry-f.p.py,25);
f.p._bodyAttacking=''; f.p.downPressed=false; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p.blocked=true; f.p.downPressed=true; f.p.doGravity(); assert.equal(f.p._vy,0);
f.api.clear(); assert.equal(f.p.controlJump,jump); assert.equal(f.p.controlDoubleJump,double);
});
test('native skill and stagger cleanup cannot cancel tool hover; disabling restores the native query', async () => {
const f=fixture(), originalQuery=f.p.isSuspendGravity;
f.api.enable(f.p);
// The original game does this when a skill or stagger ends.
f.p.resumeGravity();
assert.equal(f.p._suspendGravity,false);
assert.equal(f.api.has(f.p),true);
assert.equal(f.p.isSuspendGravity(),true);
f.p._vy=20; f.p.doGravity(); f.p.makeMomentum();
assert.equal(f.p.ry,f.p.py);
f.advance(15000);
f.p.isInvincible=()=>false;
Object.assign(f.context,{$dataMapInfos:[], $dataSystem:{switches:[]}, $gameSwitches:{value:()=>false}, Graphics:{}});
const snap=JSON.parse(await vm.runInContext(OPS.snapshot(),f.context));
assert.equal(snap.ok,true);
assert.equal(snap.gravitySuspended,true);
assert.equal(f.p.isSuspendGravity(),true);
assert.equal(Object.keys(f.p).includes('isSuspendGravity'),false);
f.api.disable(f.p);
assert.equal(f.p.isSuspendGravity,originalQuery);
assert.equal(f.p.isSuspendGravity(),false);
// Native skill suspension must not turn the panel's tool switch back on.
f.p.suspendGravity();
assert.equal(JSON.parse(await vm.runInContext(OPS.snapshot(),f.context)).gravitySuspended,false);
});
test('hot upgrading an active v4 helper preserves hover, noclip and velocity', () => {
const f=fixture(), nativeQuery=f.p.isSuspendGravity;
f.api.enable(f.p,true); f.p._vx=3; f.p._vy=-7;
f.api.version=4;
const next=vm.runInContext(`(${installMotion.toString()})()`,f.context);
assert.equal(next.version,5); assert.equal(next.has(f.p),true);
assert.equal(f.p._suspendPhysicsHitTest,true);
assert.equal(f.p._vx,3); assert.equal(f.p._vy,-7);
f.p.resumeGravity(); assert.equal(f.p.isSuspendGravity(),true);
next.clear(); assert.equal(f.p.isSuspendGravity,nativeQuery);
assert.equal(f.p.isSuspendGravity(),false);
});
test('collision-resolved motion is preserved through grounding probes while holding down', () => {
const f=fixture(), momentum=f.p.makeMomentum,cache=f.p.cacheDeltaTimeSpeed;
f.api.enable(f.p); f.p.downPressed=true;
assert.equal(f.p.makeMomentum,momentum);
for(let i=0;i<30;i++){
f.p.doGravity();f.p.cacheDeltaTimeSpeed();f.p.makeMomentum();
assert.equal(f.p._vy,4);
// Native solid-floor collision resolves to no vertical displacement.
f.p._vy=0;f.p.makeMomentum();
// generateObjectPhysicsInfo probes below, then restores resolved velocity.
f.p.startTestV();f.p.testV({x:0,y:1});f.p.endTestV();
assert.equal(f.p._vy,0);assert.equal(f.p.ry,f.p.py);
}
f.p.deltaTime=()=>0.5;f.p.cacheDeltaTimeSpeed();assert.equal(f.p._vy,2);
f.api.clear();assert.equal(f.p.cacheDeltaTimeSpeed,cache);
});
test('native map exits run before the safety clamp; noclip side exits use native destinations', () => {
const f=fixture();let nativeCalls=0,exitType=null,transferring=false;
f.p.hitOffsetL=f.p.hitOffsetR=()=>12;f.p.hitOffsetT=()=>64;f.p.hitOffsetB=()=>16;
f.p.updateMapBoundTransfer=()=>{nativeCalls++;};f.p.isTransferring=()=>transferring;
f.p.autoTransfer=type=>{exitType=type;transferring=true;return true;};
f.api.enable(f.p,true);f.p.px=-5;f.p.py=600;f.p.updateMapBoundTransfer();
assert.equal(nativeCalls,1);assert.equal(exitType,3);assert.equal(f.p.px,-5);
transferring=false;f.p.autoTransfer=()=>false;f.p.updateMapBoundTransfer();assert.equal(f.p.px,12);
f.p.updateMapBoundTransfer();assert.equal(nativeCalls,3);
f.api.setNoclip(f.p,false);f.context.$dataMap={};f.api.touch();assert.equal(f.p._suspendPhysicsHitTest,false);
assert.equal(f.api.has(f.p),true);
});
test('normal map transitions retain helpers; transient loading and replaced actors do not revive old helpers', () => {
const f=fixture();f.context.$gameActors={};f.api.touch();f.api.enable(f.p,true);
f.context.$dataMap=null;f.context.SceneManager.isSceneChanging=()=>true;
f.api.touch();assert.equal(f.api.has(f.p),true);
f.context.$dataMap={};f.context.SceneManager.isSceneChanging=()=>false;
f.p.resumePhysicsHitTest();f.p._vy=40;f.api.touch();
assert.equal(f.p._suspendPhysicsHitTest,true);assert.equal(f.p._vy,0);
f.context.$gameActors={};f.api.touch();
assert.equal(f.api.has(f.p),false);assert.equal(f.p.isSuspendGravity(),false);
f.context.$dataMap={};f.api.touch();assert.equal(f.api.has(f.p),false);
});
+498
View File
@@ -0,0 +1,498 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const vm = require('vm');
const { EventEmitter } = require('events');
const SAFETY = require('../lib/safety');
const FILES = require('../lib/files');
const { OPS } = require('../lib/ops');
const { loadAll } = require('../lib/data');
const { storyPosition, chooseStoryPosition } = require('../lib/story-position');
function fixture(t) {
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'nyaruru-regression-')));
t.after(() => {
const resolved = path.resolve(root);
assert.equal(path.dirname(resolved), fs.realpathSync.native(os.tmpdir()));
assert.match(path.basename(resolved), /^nyaruru-regression-/);
fs.rmSync(resolved, { recursive: true, force: true });
});
const game = path.join(root, 'game'), sandbox = path.join(root, 'sandbox'), backup = path.join(root, 'backups');
fs.mkdirSync(path.join(game, 'save'), { recursive: true });
fs.mkdirSync(path.join(game, 'aitemp', 'tool'), { recursive: true });
fs.writeFileSync(path.join(game, 'save', 'file1.rmmzsave'), 'first');
fs.writeFileSync(path.join(game, 'save', 'global.rmmzsave'), 'global');
fs.writeFileSync(path.join(game, 'save', 'steam_autocloud.vdf'), 'cloud');
fs.writeFileSync(path.join(game, 'package.json'), '{}');
fs.writeFileSync(path.join(game, 'index.html'), 'game');
return { root, game, sandbox, backup, tool: path.join(game, 'aitemp', 'tool') };
}
test('reject overlapping paths, relative paths and identical ports at startup and save', t => {
const f = fixture(t), defaults = { cdpPort: 9223, webPort: 8787, sandboxDir: f.sandbox };
assert.equal(SAFETY.validateConfig(defaults, f.game).sandboxDir, f.sandbox);
for (const patch of [{ cdpPort: 8787 }, { sandboxDir: f.game }, { sandboxDir: f.root }, { sandboxDir: path.join(f.game, 'nested') }, { sandboxDir: 'relative' }]) {
assert.throws(() => SAFETY.validateConfig({ ...defaults, ...patch }, f.game));
}
const file = path.join(f.root, 'config.json');
fs.writeFileSync(file, '{bad'); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /损坏/);
fs.writeFileSync(file, JSON.stringify({ webPort: 9223 })); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /不能相同/);
});
test('standalone layout uses configured game directory and rejects incomplete or overlapping targets', t => {
const f = fixture(t);
const defaults = { cdpPort: 9224, webPort: 8788, sandboxDir: f.sandbox, gameDir: f.game };
const inferred = path.join(f.root, 'unrelated-workspace');
assert.equal(SAFETY.validateConfig(defaults, inferred).gameDir, f.game);
assert.throws(() => SAFETY.validateConfig({ ...defaults, sandboxDir: path.join(f.game, 'copy') }, inferred), /完全分开/);
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: f.root }, inferred), /缺少/);
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: 'relative' }, inferred), /绝对路径/);
const configFile = path.join(f.tool, 'config.json');
fs.writeFileSync(configFile, JSON.stringify(defaults));
const config = SAFETY.readConfig(configFile, {}, inferred);
assert.equal(config.gameDir, f.game);
assert.equal(FILES.sandboxStatus(config.gameDir, config.sandboxDir, path.dirname(f.tool)).ready, false);
});
test('backup is unique, verifies content, records source and rejects traversal/corruption', t => {
const f = fixture(t);
const a = FILES.backupSaves(f.backup, f.game, 'original');
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'second');
const b = FILES.backupSaves(f.backup, f.game, 'original');
assert.notEqual(a.name, b.name);
assert.equal(fs.readFileSync(path.join(a.dir, 'file1.rmmzsave'), 'utf8'), 'first');
assert.equal(FILES.listBackups(f.backup)[0].sourceDir, f.game);
assert.throws(() => FILES.inspectBackup(f.backup, '../outside'), /非法/);
fs.writeFileSync(path.join(a.dir, 'file1.rmmzsave'), 'corrupt');
assert.throws(() => FILES.inspectBackup(f.backup, a.name), /校验失败/);
assert.equal(FILES.listBackups(f.backup).length, 1);
});
test('restore produces exact save set, preserves cloud settings, keeps previous directory', t => {
const f = fixture(t), b = FILES.backupSaves(f.backup, f.game, 'original');
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'changed');
fs.writeFileSync(path.join(f.game, 'save', 'file9.rmmzsave'), 'extra');
const r = FILES.restoreBackup(f.backup, b.name, f.game);
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
assert.equal(fs.existsSync(path.join(f.game, 'save', 'file9.rmmzsave')), false);
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'steam_autocloud.vdf'), 'utf8'), 'cloud');
assert.equal(fs.readFileSync(path.join(r.previousDirectory, 'file9.rmmzsave'), 'utf8'), 'extra');
assert.ok(r.safetyBackup);
});
test('empty backup is rejected and legacy backups remain readable', t => {
const f = fixture(t);
const empty = path.join(f.root, 'empty'); fs.mkdirSync(path.join(empty, 'save'), { recursive: true });
assert.throws(() => FILES.backupSaves(f.backup, empty), /没有找到/);
const legacy = path.join(f.backup, '20261002_123456'); fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'file1.rmmzsave'), 'old');
assert.equal(FILES.listBackups(f.backup)[0].legacy, true);
});
test('sandbox rejects partial copy; actual robocopy excludes both tool versions', { skip: process.platform !== 'win32' }, async t => {
const f = fixture(t);
fs.mkdirSync(f.sandbox); fs.writeFileSync(path.join(f.sandbox, 'package.json'), '{}');
assert.equal(FILES.sandboxStatus(f.game, f.sandbox, path.dirname(f.tool)).ready, false);
fs.mkdirSync(path.join(f.game, 'aitemp发布版')); fs.writeFileSync(path.join(f.game, 'aitemp发布版', 'private.txt'), 'no copy');
const target = path.join(f.root, 'complete');
await FILES.copySandbox(f.game, target, path.dirname(f.tool));
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, true);
assert.equal(fs.existsSync(path.join(target, 'aitemp')), false);
assert.equal(fs.existsSync(path.join(target, 'aitemp发布版')), false);
assert.equal(fs.readFileSync(path.join(target, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
fs.writeFileSync(path.join(target, '.nyaruru-sandbox-incomplete'), 'interrupted');
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, false);
});
function character() {
return { _x: 1, _y: 1, px: 48, py: 48, _suspendPhysicsHitTest: true, gravity: true,
locate(x, y) { this._x = x; this._y = y; },
clearImpact() {}, clearGrounding() {}, resumeFriction() {}, unlockInvincible() {},
suspendGravity() { this.gravity = true; }, resumeGravity() { this.gravity = false; },
suspendPhysicsHitTest() { this._suspendPhysicsHitTest = true; }, resumePhysicsHitTest() { this._suspendPhysicsHitTest = false; },
isSuspendGravity() { return this.gravity; }, isInvincible() { return false; },
isRemove() { return !!this.removed; }, hitPreview() { return [{ terrain: true, type: 0 }]; },
};
}
function gameContext() {
const c = { window: {}, $gamePlayer: character(), $gameLily: character(), $dataMapInfos: [null, {}, {}], $dataMap: {},
SceneManager: { _scene: { constructor: { name: 'Scene_Map' } } },
$gameMap: { mapId: () => 1, width: () => 20, height: () => 20, tileHeight: () => 48 },
};
vm.createContext(c); return c;
}
async function run(c, script) { return JSON.parse(await vm.runInContext(script, c)); }
test('land restores both companions, preserves unsafe lily and restore handles hidden lily', async () => {
const c = gameContext(); assert.equal((await run(c, OPS.land())).ok, true);
assert.equal(c.$gamePlayer.gravity, false); assert.equal(c.$gameLily.gravity, false);
assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
c.$gameLily.hitPreview = () => [{ terrain: true, type: 2 }];
const unsafe = await run(c, OPS.land()); assert.match(unsafe.warning, /不安全/); assert.equal(c.$gameLily.gravity, true);
c.$gameLily.removed = true; await run(c, OPS.restoreAll());
assert.equal(c.$gameLily.gravity, false); assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
});
test('invalid coordinates cannot reach locate; unfinished transfer fails and cancels reservation', async () => {
assert.throws(() => OPS.teleport(NaN, 2)); assert.throws(() => OPS.teleport('', 2)); assert.throws(() => OPS.teleport(1, 2, { mapId: -1 }));
const c = gameContext(); let id = 1, now = 0, cancelled = false;
c.$gameMap.mapId = () => id; c.$gamePlayer.reserveTransfer = () => { id = 2; };
c.$gamePlayer.isTransferring = () => true; c.$gamePlayer.clearTransferInfo = () => { cancelled = true; };
c.Date = { now: () => now }; c.setTimeout = (fn, ms) => { now += ms; fn(); };
const result = await run(c, OPS.teleport(3, 4, { mapId: 2 }));
assert.equal(result.ok, false); assert.equal(cancelled, true); assert.equal(now, 12000);
});
test('identity is based on runtime storage path and changes for reload and revisiting a map', async () => {
const c = gameContext();
c.require = require; c.process = { mainModule: { filename: path.join(os.tmpdir(), 'game', 'index.html') } };
c.StorageManager = { fileDirectoryPath: () => path.join(os.tmpdir(), 'game', 'save') };
c.location = { href: 'chrome-extension://id/index.html', pathname: '/index.html' };
const a = await run(c, OPS.selfPath()); c.$dataMap = {};
const b = await run(c, OPS.selfPath()); assert.notEqual(a.mapToken, b.mapToken);
c.$gamePlayer = character(); const d = await run(c, OPS.selfPath()); assert.notEqual(b.mapToken, d.mapToken);
assert.equal(a.gameDir, path.join(os.tmpdir(), 'game'));
});
test('scene pin is inert and reversible; staging permits all game saves and panel saves', async () => {
const c = gameContext(); let started = 0, saved = 0;
let now = 0, watchdog;
c.Date = { now: () => now }; c.setInterval = fn => { watchdog = fn; };
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { maxSavefiles: () => 23, extractSaveContents() {} };
const saveMethods = ['saveGame', 'saveGameHidden', 'autoSaveGame', 'autoSaveGameInternal'];
const originals = {};
for (const name of saveMethods) {
originals[name] = c.DataManager[name] = function(slot) {
assert.equal(this, c.DataManager); assert.equal(slot, 23);
saved++; return Promise.resolve(name);
};
}
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
c.StorageManager = { saveZip() {}, saveObject() {} };
const ev = { _erased: true, _pageIndex: -1, physics: true, _statemMembers: { name: 'old' },
eventId: () => 1, event: () => ({ name: 'event', pages: [{ image: { characterName: 'body' } }] }),
findProperPageIndex: () => 0, setupPageSettings() { this._trigger = 4; this._interpreter = {}; this._statemMembers.name = 'new'; },
characterName: () => 'body', start() { started++; }, update() { this.start(); },
};
const originalUpdate = ev.update; c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
assert.equal((await run(c, OPS.forcePage(1, 0))).ok, true);
ev.update(); ev.start(); assert.equal(started, 0); assert.equal(ev._interpreter, null); assert.equal(ev.physics, false);
for (const name of saveMethods) {
assert.equal(c.DataManager[name], originals[name]);
assert.equal(await c.DataManager[name](23), name);
}
assert.equal(saved, 4);
assert.equal((await run(c, OPS.savePhotoSlot(23))).ok, true); assert.equal(saved, 5);
assert.equal(c.window.__nyaruruPhoto.count(), 1); assert.equal(ev.physics, false);
await run(c, OPS.unpinPages()); assert.equal(ev._erased, true); assert.equal(ev._pageIndex, -1);
assert.equal(ev.update, originalUpdate); assert.equal(ev._statemMembers.name, 'old');
await c.DataManager.saveGame(23); assert.equal(saved, 6);
await run(c, OPS.forcePage(1, 0)); now = 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
c.window.__nyaruruPhoto.touch(); now += 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
now += 2000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 0); assert.equal(ev._erased, true);
});
test('staged actors settle on terrain, animate without AI and face the player; undo restores pose', async () => {
const c = gameContext(); let aiCalls = 0;
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { extractSaveContents() {} };
c.$gameMap.tileWidth = c.$gameMap.tileHeight = () => 96;
c.$gameMap.requestRefresh = () => {};
c.ImageFrame = { info: () => ({ width: 3, height: 1 }) };
const ai = { smoothfloor: 'true', skipSmoothfloor: 'false', fix: 'false' };
const ev = { _x: 16, _y: 12, _realX: 16, _realY: 12, px: 1584, py: 1248, rx: 1584, ry: 1248,
_direction: 6, _directionFix: true, _framePos: 0, physics: true, fix: false,
eventId: () => 1, event: () => ({ pages: [{ list: [{ code: 357, parameters: ['ai-plugin', 'ai', '', ai] }] }] }),
setupPageSettings() {}, characterName: () => 'actor',
tileXToCenterX: x => (x + 0.5) * 96, tileYToCenterY: y => (y + 1) * 96,
locate(x, y) { this._realX = x; this._realY = y; this._x = Math.round(x); this._y = Math.round(y); this.px = this.rx = (x + 0.5) * 96; this.py = this.ry = (y + 1) * 96; },
setDirection(d) { if (!this._directionFix) this._direction = d; },
update() { aiCalls++; }, start() { aiCalls++; },
updateFrameAnimation(info) { this._framePos = (this._framePos + 1) % info.width; },
hitPreview(dx, dy) { return [{ terrain: false, type: 0 }, ...(dy >= 47 ? [{ terrain: true, type: 0 }] : [])]; },
};
c.$gameMap.events = () => [ev]; c.$gamePlayer.px = 1488;
const originalUpdate = ev.update;
await run(c, OPS.forcePage(1, 0));
assert.equal(ev.py, 1294); assert.equal(ev.ry, 1294); assert.equal(ev.physics, false);
assert.equal(ev._direction, 4); assert.equal(ev._directionFix, true);
ev.update(); assert.equal(ev._framePos, 1);
ev.update(); ev.update(); assert.equal(ev._framePos, 0); ev.start(); assert.equal(aiCalls, 0);
c.$gamePlayer.px = 1680; c.window.__nyaruruPhoto.facePlayer(ev); assert.equal(ev._direction, 6);
await run(c, OPS.unpinPages());
assert.equal(ev.py, 1248); assert.equal(ev.ry, 1248); assert.equal(ev.physics, true); assert.equal(ev.update, originalUpdate);
const originalProbe = ev.hitPreview;
for (const mode of ['flying', 'hazard', 'no-ground']) {
ai.smoothfloor = mode === 'flying' ? 'false' : 'true';
ev.hitPreview = mode === 'hazard' ? () => [{ terrain: true, type: 2 }] : mode === 'no-ground' ? () => [] : originalProbe;
await run(c, OPS.forcePage(1, 0)); assert.equal(ev.py, 1248);
await run(c, OPS.unpinPages());
}
});
for (const delayed of [false, true]) test(`staging crops the sprite sheet on pin and undo (${delayed ? 'delayed' : 'cached'} bitmap)`, async () => {
const c = gameContext();
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { extractSaveContents() {} };
const ev = {
_characterName: 'original', _pageIndex: 0, _framePos: 0,
eventId: () => 1, event: () => ({ pages: [{}, {}] }),
characterName() { return this._characterName; },
bitmapName() { return this._characterName + '_atlas'; },
characterIndex: () => 0, tileId: () => 0,
setupPageSettings() { this._characterName = 'dracula'; },
};
c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
const images = {}, sprite = {
_character: ev, _framePos: 0, children: [], _contentSprite: {}, updates: 0,
setCharacterBitmap() {
const name = this._characterName;
const bitmap = images[name] ||= {
width: name === 'dracula_atlas' ? 558 : 240, height: 73,
columns: name === 'dracula_atlas' ? 6 : 3, ready: !delayed, listeners: [],
addLoadListener(fn) { if (this.ready) fn(); else this.listeners.push(fn); },
load() { this.ready = true; for (const fn of this.listeners.splice(0)) fn(); },
};
this._contentSprite.bitmap = bitmap;
bitmap.addLoadListener(() => {
if (this._contentSprite.bitmap === bitmap) this._contentSprite.frame = { width: bitmap.width, height: bitmap.height };
});
},
updateFrame() {
this.updates++;
if (this._framePos === ev._framePos) return;
this._framePos = ev._framePos;
const bitmap = this._contentSprite.bitmap;
this._contentSprite.frame = { width: bitmap.width / bitmap.columns, height: bitmap.height };
},
};
c.SceneManager._scene.children = [sprite];
assert.equal((await run(c, OPS.forcePage(1, 1))).ok, true);
if (delayed) images.dracula_atlas.load();
assert.equal(sprite._characterName, 'dracula_atlas');
assert.deepEqual(sprite._contentSprite.frame, { width: 93, height: 73 });
await run(c, OPS.unpinPages());
if (delayed) images.original_atlas.load();
assert.equal(ev.characterName(), 'original');
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
if (delayed) {
images.dracula_atlas.ready = false;
await run(c, OPS.forcePage(1, 1));
await run(c, OPS.unpinPages());
const updates = sprite.updates;
images.dracula_atlas.load(); // 旧图迟到,不能改动已撤销的显示状态。
assert.equal(sprite.updates, updates);
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
}
});
test('save awaits disk rejection instead of reporting success', async () => {
const c = gameContext(); c.setTimeout = setTimeout;
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
const originalZip = () => new Promise((resolve, reject) => setTimeout(() => reject(new Error('disk full')), 5));
c.StorageManager = { saveZip: originalZip, saveObject() { return Promise.resolve(); } };
c.DataManager = { maxSavefiles: () => 23, saveGame() { c.StorageManager.saveZip(); return Promise.resolve(); } };
const r = await run(c, OPS.savePhotoSlot(23)); assert.equal(r.ok, false); assert.match(r.error, /disk full/);
assert.equal(c.StorageManager.saveZip, originalZip);
});
test('CDP shares concurrent connect, rejects pending on disconnect, ignores old socket close', async () => {
const sockets = [];
class WS {
constructor() { this.readyState = 1; sockets.push(this); queueMicrotask(() => this.onopen()); }
send(raw) {
const p = JSON.parse(raw);
if (p.method === 'Runtime.evaluate') return;
const result = p.method === 'Target.getTargets' ? { targetInfos: [{ type: 'page', targetId: 'game', url: 'chrome-extension://id/index.html' }] } : { sessionId: 'session' };
queueMicrotask(() => this.onmessage({ data: JSON.stringify({ id: p.id, result }) }));
}
close() {}
}
const c = { module: { exports: {} }, WebSocket: WS, fetch: async () => ({ ok: true, json: async () => ({ webSocketDebuggerUrl: 'ws://test' }) }), AbortSignal, setTimeout, clearTimeout };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../lib/cdp.js'), 'utf8'), c);
const client = new c.module.exports.CdpClient();
await Promise.all([client.connect(), client.connect()]); assert.equal(sockets.length, 1);
const old = sockets[0]; client.close(); await client.connect(); old.onclose(); assert.equal(client.ws, sockets[1]);
const pending = client.eval('test'); await new Promise(r => setImmediate(r));
const rejection = assert.rejects(pending, /断开/); sockets[1].onclose(); await rejection;
assert.equal(client._pending.size, 0); client.close();
});
function serverHarness(t, ops = OPS) {
const f = fixture(t), calls = [];
const fake = { generation: 1, port: 9223, close() {}, async isAlive() { return false; },
async evalJson(script) { calls.push(script); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save'), mapToken: 'mock:1', mapId: 1 }; } };
const c = { __dirname: f.tool, module: { exports: {} }, process, console: { log() {}, error() {} }, URL, Buffer, setTimeout,
require(n) {
if (n === './lib/cdp.js') return { CdpClient: function() { return fake; } };
if (n === './lib/ops.js') return { OPS: ops };
if (n === './lib/story-position') return { storyPosition };
if (n === './lib/data.js') return { loadAll: () => ({ stats: {}, triggers: [] }) };
if (n === './lib/safety') return SAFETY;
if (n === './lib/files') return FILES;
if (n === './lib/processes') return { assertGamesStopped: async () => {} };
if (n === './lib/story-cache') return require('../lib/story-cache');
return require(n);
},
};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'), c);
c.module.exports.CONFIG.sandboxDir = f.sandbox;
return { ...f, api: c.module.exports, fake, calls };
}
async function request(h, url, body, method = 'POST') {
const req = new EventEmitter(); req.method = method; req.headers = {};
let result, code;
const res = { writeHead(c) { code = c; }, end(s) { result = JSON.parse(s); } };
const task = h.api.handleApi(req, res, url);
if (method === 'POST') { req.emit('data', JSON.stringify(body)); req.emit('end'); }
await task; return { result, code };
}
test('controller stories use the verified scene entry without changing other or explicit positions', () => {
const data = loadAll(path.resolve(__dirname, '../..'));
for (const [story, map, expected] of [[449, 9, [11, 9]], [449, 296, [28, 13]], [467, 254, [6, 5]]]) {
const trigger = data.triggers.find(t => t.storyId === story && t.mapId === map);
assert.ok(trigger);
const p = storyPosition(trigger); assert.deepEqual([p.x, p.y], expected);
}
// Published tests validate the structural trigger index; original map scripts are not distributed.
for (const storyId of [298, 299, 305]) {
const trigger = data.triggers.find(t => t.storyId === storyId && t.mapId === 182 && Number(t.eventId) === 3 && t.pageIndex === 0);
assert.ok(trigger); assert.equal(trigger.x, 0); assert.equal(trigger.y, 0);
const p = storyPosition(trigger);
assert.equal(p.x, 11); assert.equal(p.y, 11); assert.ok(p.positionNote);
for (const patch of [{ mapId: 183 }, { eventId: 4 }, { storyId: 370 }, { pageIndex: 1 }, { pageIndex: null }, { x: 7, y: 8 }, { x: null }]) {
const input = { ...trigger, ...patch };
assert.deepEqual(storyPosition(input), { x: input.x, y: input.y });
}
}
});
test('unknown controller scenes preserve safe positions or find floor without story-number rules', () => {
const actor = { _x: 6, _y: 7, eventId: () => 2, characterName: () => 'actor', event: () => ({ pages: [] }) };
const page = { trigger: 3, image: { characterName: '', tileId: 0 }, list: [] };
let probes = 0, hazard = false, noFloor = false;
const player = { _x: 0, _y: 0, hitPreview(dx, dy) {
probes++; if (noFloor || dy < 8 * 96) return [];
return [{ terrain: true, type: hazard && dx === 5 * 96 ? 2 : 0 }];
} };
const input = { map: { tileWidth: () => 96, tileHeight: () => 96, width: () => 20, height: () => 15, events: () => [actor] },
player, data: { events: [null, { x: 0, y: 0, pages: [page] }] }, eventId: 1, pageIndex: 0,
storyId: 987654, sameMap: true, previous: { x: 10, y: 7 }, requested: { x: 0, y: 0 } };
let r = chooseStoryPosition(input); assert.equal(r.x, 10); assert.equal(r.y, 7);
input.sameMap = false; hazard = true;
r = chooseStoryPosition(input); assert.equal(r.x, 7); assert.equal(r.y, 7); // 左侧陷阱被拒绝,选右侧。
page.trigger = 4; r = chooseStoryPosition(input); assert.equal(r.controller, true);
noFloor = true; probes = 0; r = chooseStoryPosition(input);
assert.equal(r.ok, false); assert.match(r.error, /未找到/); assert.ok(probes <= 800);
page.trigger = 0; probes = 0; r = chooseStoryPosition(input);
assert.equal(r.controller, false); assert.equal(r.x, 0); assert.equal(probes, 0);
page.trigger = 3; page.image.characterName = 'visible';
assert.equal(chooseStoryPosition(input).controller, false); // 有形事件不能当成控制事件。
});
test('scene, dialogue and goto APIs correct stale controller coordinates before teleport', async t => {
const targets = [], order = [];
const h = serverHarness(t, { ...OPS,
teleport(x, y, options) { targets.push({ x, y, map: options.mapId }); return OPS.teleport(x, y, options); },
runStoryScene(...args) { order.push('scene'); return OPS.runStoryScene(...args); },
land() { order.push('land'); return OPS.land(); },
});
const body = { context: '1:mock:1', mapId: 182, eventId: 3, pageIndex: 0, storyId: 305, x: 0, y: 0 };
await request(h, '/api/story/start', { ...body, mode: 'scene' });
await request(h, '/api/story/start', { ...body, mode: 'play' });
await request(h, '/api/story/goto', { ...body, pairs: [] });
assert.deepEqual(order, ['scene', 'land']); // 只对运行过场自动落地,且在补角色之后。
assert.deepEqual(targets, Array.from({ length: 3 }, () => ({ x: 11, y: 11, map: 182 })));
await request(h, '/api/story/start', { ...body, mode: 'play', x: 7, y: 8 });
assert.deepEqual(targets[3], { x: 7, y: 8, map: 182 });
});
test('restore never defaults to original; GET cannot mutate', async t => {
const h = serverHarness(t);
await assert.rejects(request(h, '/api/restore-backup', { name: 'any', confirm: true }), /明确/);
assert.equal((await request(h, '/api/restore', null, 'GET')).code, 405);
assert.equal(h.calls.length, 0);
});
test('photo save checks target and blocks both stale context and backup failure', async t => {
const h = serverHarness(t);
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'sandbox' }), /不一致/);
await assert.rejects(request(h, '/api/save-photo', { context: '1:old', slot: 23, target: 'original' }), /已变化/);
fs.unlinkSync(path.join(h.game, 'save', 'file1.rmmzsave')); fs.unlinkSync(path.join(h.game, 'save', 'global.rmmzsave'));
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'original' }), /没有找到/);
assert.equal(h.calls.some(s => s.includes('await DataManager.saveGame(s)')), false);
});
test('mutations are serialized and stale map-local actions are rejected', async t => {
const h = serverHarness(t); let active = 0, max = 0;
h.fake.evalJson = async s => {
if (!s.includes('const i=JSON.parse')) return { ok: true, gameDir: h.game, saveDir: path.join(h.game, 'save'), mapToken: 'mock:1' };
active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 10)); active--; return { ok: true };
};
await Promise.all([request(h, '/api/gravity', { context: '1:mock:1', on: true }), request(h, '/api/gravity', { context: '1:mock:1', on: false })]);
assert.equal(max, 1);
await assert.rejects(request(h, '/api/events/force-page', { context: '1:old-map', eventId: 1, page: 0 }), /已变化/);
});
test('public structural index excludes text and preserves distinct event/page alternatives', () => {
const data = loadAll(path.resolve(__dirname, '../..'));
assert.ok(data.triggers.length > 0);
assert.ok(data.triggers.every(t => !('lines' in t) && !('line' in t) && !('searchText' in t)));
assert.equal(data.storyIndex, undefined);
const story11 = data.triggers.filter(t => t.storyId === 11);
assert.ok(new Set(story11.map(t => t.page)).size > 1);
});
function launcherHarness(t, { ready = false, choice = '3', occupied = false, identityError = false, backupError = false } = {}) {
const f = fixture(t), actions = [];
fs.writeFileSync(path.join(f.tool, 'config.json'), JSON.stringify({ sandboxDir: f.sandbox }));
const c = { __dirname: f.tool, module: { exports: {} }, console: { log() {} }, setTimeout,
process: { versions: { node: '24.0.0' }, argv: [], stdin: {}, stdout: {}, execPath: process.execPath },
require(n) {
if (n === './lib/safety') return SAFETY;
if (n === './lib/files') return { sandboxStatus: () => ({ ready, reason: 'mock' }), backupSaves: () => { actions.push('backup'); if (backupError) throw Error('backup failed'); return { name: 'mock' }; } };
if (n === './lib/cdp') return { CdpClient: function() { return { close() {}, async evalJson() { if (identityError) throw Error('unknown'); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save') }; } }; } };
if (n === './lib/ops') return { OPS };
if (n === 'readline/promises') return { createInterface: () => ({ question: async () => choice, close() {} }) };
if (n === 'net') return { connect: ({ port }) => { const s = new EventEmitter(); s.destroy = () => {}; s.setTimeout = () => {}; queueMicrotask(() => s.emit(occupied && port === 9223 ? 'connect' : 'error')); return s; } };
if (n === 'child_process') return { spawn: () => { actions.push('spawn'); throw Error('unexpected spawn'); } };
return require(n);
},
};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../launcher.js'), 'utf8'), c);
return { main: c.module.exports.main, actions };
}
test('launcher exit stops completely, wrong and unknown instances stop before backup/panel', async t => {
const exit = launcherHarness(t); await exit.main(); assert.deepEqual(exit.actions, []);
for (const identityError of [false, true]) {
const h = launcherHarness(t, { ready: true, occupied: true, identityError });
await assert.rejects(h.main(), /不能确认/); assert.deepEqual(h.actions, []);
}
});
test('launcher backup failure prevents starting either game or panel', async t => {
const h = launcherHarness(t, { ready: true, backupError: true });
await assert.rejects(h.main(), /backup failed/); assert.deepEqual(h.actions, ['backup']);
});
test('panel save warning lets the user continue or cancel using the existing confirmation', async () => {
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
const start = source.indexOf("$('#photo-save').onclick");
const end = source.indexOf('// ---------- 对话跳转点', start);
for (const proceed of [false, true]) {
const button = {}, calls = []; let confirmation;
vm.runInNewContext(source.slice(start, end), {
$: key => key === '#photo-save' ? button : { value: '23' },
CURRENT_CONTEXT: 'current', CURRENT_GAME: { kind: 'sandbox', saveDir: 'sandbox/save' },
confirm: text => { confirmation = text; return proceed; },
api: async (route, body) => { calls.push({ route, body }); return { ok: true, slot: 23 }; },
toast() {}, loadBackups() {},
});
await button.onclick();
assert.match(confirmation, /不推荐.*保存/); assert.match(confirmation, /仍可确定继续保存/);
assert.equal(calls.length, proceed ? 1 : 0);
if (proceed) { assert.equal(calls[0].route, '/save-photo'); assert.equal(calls[0].body.slot, 23); assert.equal(calls[0].body.target, 'sandbox'); }
}
});
test('map event search matches later lines and displays the matched text', () => {
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
const start = source.indexOf('function renderEvents()');
const end = source.indexOf("$('#ev-load').onclick", start);
const list = { innerHTML: '', querySelectorAll: () => [] };
const elements = { '#ev-filter': { value: 'later sentence' }, '#ev-all': { checked: false }, '#ev-list': list };
vm.runInNewContext(source.slice(start, end) + ';renderEvents();', {
$: key => elements[key], esc: value => String(value), spriteLabel: () => '',
EVENTS: [{ id: 1, hasStory: true, name: 'event', sprite: '', trigger: 'test', x: 0, y: 0,
lines: [{ first: 'first sentence', speaker: 'actor', searchText: 'first sentence\nlater sentence', lines: [{ text: 'first sentence', speaker: 'actor' }, { text: 'later sentence', speaker: 'actor' }] }] }],
});
assert.match(list.innerHTML, /later sentence/); assert.doesNotMatch(list.innerHTML, /没有匹配|没有带剧情/);
});
+59
View File
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { buildRelease, manifestFiles } = require('../package-release');
function tempRoot(t) {
const parent = fs.realpathSync.native(os.tmpdir());
const root = fs.mkdtempSync(path.join(parent, 'nyaruru-public-release-'));
t.after(() => {
assert.equal(path.dirname(path.resolve(root)), parent);
assert.match(path.basename(root), /^nyaruru-public-release-/);
fs.rmSync(root, { recursive: true, force: true });
});
return root;
}
function walk(root, prefix = '') {
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap(entry => {
const relative = prefix ? prefix + '/' + entry.name : entry.name;
return entry.isDirectory() ? walk(root, relative) : [relative];
});
}
test('clean release contains only allow-listed files and serves its page without game access', async t => {
const root = tempRoot(t), destination = path.join(root, 'Nyaruru_modifier');
const result = buildRelease(destination);
assert.deepEqual(walk(destination).sort(), manifestFiles().sort());
assert.equal(result.files.length, manifestFiles().length);
assert.throws(() => buildRelease(destination), /已存在/);
const mod = require(path.join(destination, 'tool/server.js'));
let gameCalls = 0;
for (const method of ['connect', 'evalJson', 'isAlive']) mod.cdp[method] = async () => { gameCalls++; throw Error('No live game access in release tests'); };
await new Promise(resolve => mod.server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { mod.server.closeAllConnections(); mod.server.close(resolve); }));
const origin = 'http://127.0.0.1:' + mod.server.address().port;
const health = await fetch(origin + '/api/health').then(r => r.json());
assert.equal(health.ok, true); assert.equal(health.config.webPort, 8787);
const html = await fetch(origin).then(r => r.text());
for (const id of ['story-text-hint', 'story-load-text', 'panel-world-map', 'panel-encyclopedia']) assert.ok(html.includes('id="' + id + '"'));
assert.equal((await fetch(origin + '/app.js')).status, 200);
assert.equal(gameCalls, 0);
assert.doesNotMatch(html, /panel-workspace|data-panel="workspace"|快照与 TAS/);
for (const route of ['/api/checkpoints','/api/tas']) assert.equal((await fetch(origin + route)).status, 405);
const app = await fetch(origin + '/app.js').then(r => r.text());
assert.doesNotMatch(app, /loadWorkspace|WORK_BUSY|panel-workspace|checkpoints|\/tas/);
const { OPS } = require(path.join(destination, 'tool/lib/ops.js'));
for (const name of ['tas','captureCheckpoint','loadCheckpoint','restoreCheckpointTools']) assert.equal(OPS[name], undefined);
assert.equal(typeof OPS.snapshot, 'function'); assert.equal(typeof OPS.combat, 'function');
assert.deepEqual(walk(destination).sort(), manifestFiles().sort());
});
test('release manifest rejects raw maps, runtime configuration and path traversal', t => {
const root = tempRoot(t); fs.mkdirSync(path.join(root, 'tool'));
for (const file of ['unpacked/data/Map181.json', 'out/story.json', 'tool/config.json', '../outside', 'backups/states.bin']) {
fs.writeFileSync(path.join(root, 'tool/发布清单.json'), JSON.stringify({ files: [file] }));
assert.throws(() => manifestFiles(root));
}
});
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { StoryCache } = require('../lib/story-cache');
const { OPS } = require('../lib/ops');
function fixture(options = {}) {
let calls = 0;
const cdp = { port: 9223, generation: 1, sessionId: 'session-1', ws: { readyState: 1 },
async isAlive() { return true; }, async connect() { this.ws = { readyState: 1 }; },
async evalJson() { calls++; return { ok: true, index: { 1: { first: 'fixture', lines: [{ text: 'fixture' }] } } }; } };
const cache = new StoryCache(cdp, OPS, [{ storyId: 1 }, { storyId: 2 }], options);
return { cdp, cache, calls: () => calls };
}
test('runtime story index searches later synthetic lines and excludes commands and comments', async () => {
const context = vm.createContext({ $dataActors: { 2: { name: '模拟角色' } }, $dataStory: {
1: [{ name: 'story', contents: '模拟首句', speaker: 2 }, { name: 'story', contents: '模拟后续检索句' },
{ name: 'story', contents: '#注释' }, { name: 'story', contents: '///备注' }, { name: 'other', contents: '不属于台词' }],
2: [{ name: 'story', contents: ' ' }], invalid: [{ name: 'story', contents: '忽略' }],
} });
const result = JSON.parse(await vm.runInContext(OPS.readStoryIndex(), context));
assert.equal(result.ok, true); assert.equal(result.count, 1);
assert.equal(result.index[1].count, 2); assert.equal(result.index[1].speaker, '模拟角色');
assert.match(result.index[1].searchText, /模拟后续检索句/);
assert.doesNotMatch(result.index[1].searchText, /注释|备注|不属于台词/);
});
test('story cache shares reads, clears disconnected text and reloads a new connection', async () => {
const f = fixture();
await Promise.all([f.cache.ensure(), f.cache.ensure()]);
assert.equal(f.calls(), 1); assert.equal(f.cache.status().triggersWithLine, 1);
f.cdp.ws = null; assert.equal(f.cache.get(1), null); assert.equal(f.cache.status().state, 'disconnected');
f.cdp.generation++; f.cdp.sessionId = 'session-2'; await f.cache.ensure();
assert.equal(f.calls(), 2); assert.equal(f.cache.status().loaded, true);
});
test('story cache rate limits four retries and permits explicit retry after failure', async () => {
let now = 0; const f = fixture({ now: () => now });
f.cdp.evalJson = async () => ({ ok: false, error: 'fixture not ready' });
await f.cache.ensure(); await f.cache.ensure(); assert.equal(f.cache.status().attempts, 1);
for (let i = 0; i < 3; i++) { now += 3000; await f.cache.ensure(); }
now += 3000; await f.cache.ensure();
assert.equal(f.cache.status().attempts, 4); assert.equal(f.cache.status().state, 'error');
f.cdp.evalJson = async () => ({ ok: true, index: { 1: { first: 'ready' } } });
await f.cache.ensure({ force: true }); assert.equal(f.cache.get(1).first, 'ready');
});
test('late story response from an old connection cannot overwrite new text', async () => {
const f = fixture(); let resolve;
f.cdp.evalJson = () => new Promise(r => { resolve = r; });
const old = f.cache.ensure();
f.cdp.generation++; f.cdp.sessionId = 'session-2'; f.cache.status();
f.cdp.evalJson = async () => ({ ok: true, index: { 1: { first: 'new connection' } } });
await f.cache.ensure(); resolve({ ok: true, index: { 1: { first: 'stale connection' } } }); await old;
assert.equal(f.cache.get(1).first, 'new connection');
});
+186
View File
@@ -0,0 +1,186 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { installTrainer } = require('../lib/trainer');
const { OPS } = require('../lib/ops');
function fixture() {
let now = 0;
const timers = [];
function makeActor() {
const battle = { _hp: 80, mhp: 100, sp: 30, msp: 100, zp: 60, mzp: 200,
get hp() { return this._hp | 0; },
set hp(value) { if (this._hp !== 0) this._hp = Math.max(0, value); },
addHp(delta) { this.hp = Math.min(this.mhp, Math.max(0, this._hp + delta)); },
addSp(delta) { this.sp = Math.min(this.msp, Math.max(0, this.sp + delta)); },
addZp(delta) { this.zp = Math.min(this.mzp, Math.max(0, this.zp + delta)); },
};
const definitions = { 0: { name: 'item0', desc: 'desc0', maxNum: 6 }, 1: { name: 'item1', maxNum: 2 } };
function equipment(type) {
return { _unlock: [0, 1], _equip: [0], _currentWeapon: 0,
data: () => definitions, isUnlock(id) { return this._unlock.includes(id); },
isEquip(id) { return this._equip.includes(id); }, currentWeapon() { return this._currentWeapon; },
unlock(id) { if (!this.isUnlock(id)) { this._unlock.push(id); if (type === 'stones') this._equip.push(id); } },
equip(id) { if (type === 'weapons') this._currentWeapon = id; else this._equip = this.isEquip(id) ? this._equip.filter(n => n !== id) : [...this._equip, id]; },
remove(id) { this._equip = this._equip.filter(n => n !== id); this._unlock = this._unlock.filter(n => n !== id); },
};
}
return { battle, hp: 1, _coin: 123, name: () => '喵露露', coin() { return this._coin; },
addCoin(n) { this._coin = Math.min(99999, this._coin + n); },
deductCoin(n) { if (this._coin < n) return false; this._coin -= n; return true; },
_consumables: { _data: { 0: 3 }, data: () => definitions, itemNum(id) { return this._data[id] || 0; }, maxNum: id => definitions[id].maxNum,
add(id, n, over) { this._data[id] = Math.max(0, Math.min(over ? Infinity : definitions[id].maxNum, this.itemNum(id) + n)); } },
_weapons: equipment('weapons'), _stones: equipment('stones'), _ornaments: equipment('ornaments'),
changeWeapon(id) { this._weapons.equip(id); },
_joyCoin: 2, _hpStrengthItem: 1, _skillStrengthItem: 3,
joyCoinData: () => definitions[0], hpStrengthData: () => definitions[0], skillStrengthData: () => definitions[0],
joyCoinNum() { return this._joyCoin; }, hpStrengthNum() { return this._hpStrengthItem; }, skillStrengthNum() { return this._skillStrengthItem; },
};
}
const actor = makeActor(), companion = makeActor();
const player = { current: actor, lily: false, actor() { return this.current; }, battleObject() { return this.current.battle; }, isLilyMode() { return this.lily; }, setWeaponChange() { this.weaponChanged = true; } };
const context = vm.createContext({ window: {}, $gamePlayer: player, $gameLily: { actor: () => companion }, $gameParty: {}, $gameActors: {},
TextManager: { item0: '卷心菜', item1: '物品一', desc0: '恢复生命' },
Date: { now: () => now }, setInterval: fn => { timers.push(fn); },
$gameMap: { mapId: () => 1 }, SceneManager: { _scene: { constructor: { name: 'Scene_Map' } } },
});
const api = vm.runInContext(`(${installTrainer.toString()})()`, context);
const change = body => api.change({ subject: api.read().subject, ...body });
return { api, change, context, actor, companion, player, timers, makeActor, advance(ms) { now += ms; timers.forEach(fn => fn()); } };
}
test('trainer edits actual NYA values, supports Lily mode and rejects stale actor requests', () => {
const f = fixture();
f.change({ action: 'stat', field: 'coin', value: 9000 });
f.change({ action: 'stat', field: 'hp', value: 95 });
f.change({ action: 'stat', field: 'sp', value: 40 });
f.change({ action: 'stat', field: 'zp', value: 150 });
assert.equal(f.actor.hp, 1); assert.equal(f.actor.battle.hp, 95); assert.equal(f.actor.coin(), 9000);
const old = f.api.read().subject;
f.player.lily = true;
assert.equal(f.api.read().role, '莉莉娅');
assert.throws(() => f.api.change({ subject: old, action: 'stat', field: 'hp', value: 20 }), /角色已变化/);
f.change({ action: 'stat', field: 'hp', value: 50 });
assert.equal(f.actor.battle.hp, 50); assert.equal(f.companion.battle.hp, 80);
for (const value of [null, '20', -1, 101, NaN, Infinity, 1.5]) {
assert.throws(() => f.change({ action: 'stat', field: 'hp', value }), /整数/);
}
assert.equal(f.actor.battle.hp, 50);
f.change({ action: 'stat', field: 'hp', value: 0 });
assert.throws(() => f.change({ action: 'stat', field: 'hp', value: 50 }), /已倒下/);
});
test('locks stop lethal damage and spending immediately, survive form changes and restore descriptors', () => {
const f = fixture(), b = f.actor.battle;
for (const [field, value] of Object.entries({ hp: 70, sp: 45, zp: 90, coin: 1000 })) f.change({ action: 'stat', field, value, locked: true });
b.hp = 0; b.sp -= 50; b.zp += 80;
assert.equal(f.actor.deductCoin(999), true); f.actor.addCoin(500);
assert.equal(b.hp, 70); assert.equal(b.sp, 45); assert.equal(b.zp, 90); assert.equal(f.actor.coin(), 1000);
f.player.lily = true; b.hp = 0; assert.equal(b.hp, 70);
b.mhp = 40; assert.equal(b.hp, 40);
f.change({ action: 'stat', field: 'coin', value: 2000 });
assert.equal(f.api.read().stats.coin.locked, true); assert.equal(f.actor.coin(), 2000);
const saved = JSON.parse(JSON.stringify(f.actor));
assert.equal(saved.battle._hp, 40); assert.equal(saved._coin, 2000);
assert.equal(Object.keys(saved).some(k => /lock|trainer/i.test(k)), false);
f.change({ action: 'stat', field: 'hp', locked: false });
const desc = Object.getOwnPropertyDescriptor(b, '_hp');
assert.equal(desc.value, 40); assert.equal(desc.writable, true); assert.equal(desc.enumerable, true);
b.hp = 0; assert.equal(b.hp, 0);
f.api.clear(); assert.equal(f.actor.deductCoin(500), true); assert.equal(f.actor.coin(), 1500);
});
test('locks follow current player objects but clear on load and heartbeat expiry; install is idempotent', () => {
const f = fixture();
f.change({ action: 'stat', field: 'sp', value: 55, locked: true });
const other = f.makeActor(); f.player.current = other; f.advance(100);
other.battle.sp = 0; assert.equal(other.battle.sp, 55);
f.actor.battle.sp = 10; assert.equal(f.actor.battle.sp, 10);
vm.runInContext(`(${installTrainer.toString()})()`, f.context); assert.equal(f.timers.length, 1);
f.context.$gameActors = {}; f.context.$gameParty = {}; f.player.current = f.makeActor(); f.advance(100);
assert.equal(f.api.read().stats.sp.locked, false); assert.equal(f.player.current.battle.sp, 30);
f.change({ action: 'stat', field: 'coin', value: 500, locked: true });
f.advance(120001); assert.equal(f.api.read().stats.coin.locked, false);
f.player.current.addCoin(1); assert.equal(f.player.current.coin(), 501);
});
test('inventory accepts item zero, respects optional consumable cap and keeps equipment references valid', () => {
const f = fixture();
assert.equal(f.api.read().items[0].name, '卷心菜');
const item = (type, id, count, extra = {}) => f.change({ action: 'item', type, id, count, ...extra });
item('consumables', 0, 6); assert.equal(f.actor._consumables.itemNum(0), 6);
assert.throws(() => item('consumables', 0, 7), /整数/);
item('consumables', 0, 99, { overCap: true }); assert.equal(f.actor._consumables.itemNum(0), 99);
item('consumables', 0, 0); assert.equal(f.actor._consumables.itemNum(0), 0);
for (const type of ['stones', 'ornaments']) {
item(type, 0, 0); assert.equal(f.actor['_' + type].isEquip(0), false); assert.equal(f.actor['_' + type].isUnlock(0), false);
item(type, 0, 1); item(type, 0, 1); assert.equal(f.actor['_' + type]._unlock.filter(n => n === 0).length, 1);
}
item('weapons', 0, 0); assert.equal(f.actor._weapons.currentWeapon(), 1); assert.equal(f.player.weaponChanged, true);
assert.throws(() => item('weapons', 1, 0), /保留一件/); assert.equal(f.actor._weapons.isUnlock(1), true);
item('joycoin', 0, 500); assert.equal(f.actor.joyCoinNum(), 500);
assert.throws(() => item('__proto__', 0, 1), /分类/);
assert.throws(() => item('consumables', 999, 1), /不存在/);
assert.throws(() => item('hpst', 1, 1), /编号/);
});
test('OPS wraps trainer scripts with map guard and returns structured validation failures', async () => {
const f = fixture();
const result = JSON.parse(await vm.runInContext(OPS.trainer(), f.context));
assert.equal(result.ok, true); assert.equal(result.stats.hp.value, 80);
const bad = JSON.parse(await vm.runInContext(OPS.trainer({ action: 'stat', field: 'hp', value: 999, subject: result.subject }), f.context));
assert.equal(bad.ok, false); assert.match(bad.error, /整数/);
f.context.SceneManager._scene.constructor.name = 'Scene_Title';
const menu = JSON.parse(await vm.runInContext(OPS.trainer(), f.context));
assert.equal(menu.ok, false); assert.match(menu.error, /进入地图/);
});
test('batch changes validate every entry before modifying and roll back failed game methods', () => {
const f=fixture();
assert.throws(()=>f.change({action:'batch',changes:[{type:'joycoin',id:0,count:9},{type:'ornaments',id:999,count:1}]}),/不存在/);
assert.equal(f.actor.joyCoinNum(),2);
assert.throws(()=>f.change({action:'batch',changes:[{type:'weapons',id:0,count:0},{type:'weapons',id:1,count:0}]}),/保留一件/);
f.change({action:'batch',changes:[{type:'consumables',id:0,count:6},{type:'ornaments',id:1,count:0},{type:'joycoin',id:0,count:9}]});
assert.equal(f.actor._consumables.itemNum(0),6); assert.equal(f.actor._ornaments.isUnlock(1),false); assert.equal(f.actor.joyCoinNum(),9);
const original=f.actor._ornaments.unlock;
f.actor._ornaments.unlock=()=>{throw Error('simulated failure');};
assert.throws(()=>f.change({action:'batch',changes:[{type:'joycoin',id:0,count:20},{type:'consumables',id:0,count:1},{type:'ornaments',id:1,count:1}]}),/simulated/);
assert.equal(f.actor.joyCoinNum(),9); assert.equal(f.actor._consumables.itemNum(0),6); assert.equal(f.actor._ornaments.isUnlock(1),false);
f.actor._ornaments.unlock=original;
});
test('accessories equip and unequip; bells control extra capacity without altering map collection', () => {
const f=fixture(), o=f.actor._ornaments;
o._equip=[]; o._hasBuyBellNum=1;
o.bellData=()=>({name:'bell',desc:'capacity'}); o.eventBellGainNum=()=>1;
o.hasEquipNum=()=>o._equip.filter(id=>id!==20).length; o.maxEquip=()=>1+o._hasBuyBellNum+o.eventBellGainNum();
o.unequip=id=>{o._equip=o._equip.filter(n=>n!==id);};
o.equip=id=>{if(o.hasEquipNum()>=o.maxEquip())return false;if(!o.isEquip(id))o._equip.push(id);return true;};
f.actor.bellNum=()=>o._hasBuyBellNum+o.eventBellGainNum();
f.change({action:'equip',type:'ornaments',id:0,equipped:true}); assert.equal(o.isEquip(0),true);
f.change({action:'equip',type:'ornaments',id:0,equipped:false}); assert.equal(o.isEquip(0),false);
assert.throws(()=>f.change({action:'item',type:'bell',id:0,count:0}),/地图取得/);
f.change({action:'item',type:'bell',id:0,count:8}); assert.equal(o.maxEquip(),9);
o._equip=[0,1,2,3]; f.change({action:'item',type:'bell',id:0,count:1});
assert.equal(o.hasEquipNum(),2); assert.equal(o.eventBellGainNum(),1); assert.equal(o._hasBuyBellNum,0);
assert.equal(f.api.read().items.find(i=>i.type==='bell').min,1);
const flags={'174,229,A':true,'222,6,A':false};
f.context.DataManager={_cacheBell:[[174,229,'A'],[222,6,'A']]};
f.context.$gameSelfSwitches={value:key=>!!flags[key.join(',')],setValue:(key,value)=>{flags[key.join(',')]=value;}};
o.eventBellGainNum=()=>Number(flags['174,229,A'])+Number(flags['222,6,A']);
f.change({action:'item',type:'bell',id:0,count:0,adjustMapBells:true});
assert.equal(flags['174,229,A'],false); assert.equal(f.actor.bellNum(),0); assert.equal(o.maxEquip(),1);
flags['174,229,A']=true;
o.unlock=()=>{throw Error('bell rollback');};
assert.throws(()=>f.change({action:'batch',changes:[{type:'bell',id:0,count:0,adjustMapBells:true},{type:'ornaments',id:0,count:1}]}),/bell rollback/);
assert.equal(flags['174,229,A'],true); assert.equal(o._hasBuyBellNum,0);
});
test('trainer is usable in backpack menu and rejects title screen', async () => {
const f=fixture(); f.context.SceneManager._scene.constructor.name='Scene_Menu';
const read=JSON.parse(await vm.runInContext(OPS.trainer(),f.context)); assert.equal(read.ok,true);
const changed=JSON.parse(await vm.runInContext(OPS.trainer({action:'item',type:'ornaments',id:0,count:0,subject:read.subject}),f.context));
assert.equal(changed.ok,true); assert.equal(f.actor._ornaments.isUnlock(0),false);
f.context.SceneManager._scene.constructor.name='Scene_Title';
assert.equal(JSON.parse(await vm.runInContext(OPS.trainer(),f.context)).ok,false);
});
+96
View File
@@ -0,0 +1,96 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
function fixture() {
const source=fs.readFileSync(path.join(__dirname,'../web/app.js'),'utf8');
const start=source.indexOf('// ---------- 世界地图 ----------'),end=source.indexOf('// ---------- 收集百科 ----------');
const elements=new Map(),calls=[],messages=[];
const $=key=>{
if(!elements.has(key))elements.set(key,{value:'',checked:false,innerHTML:'',clientWidth:800,clientHeight:560,
setAttribute(){},addEventListener(){},setPointerCapture(){}});
return elements.get(key);
};
const data=JSON.parse(JSON.stringify({context:'current',regions:[{id:2,name:'小镇'}],pages:[{id:1},{id:2}],
player:{cellKey:'1:3:4',page:1,regionId:2},coverage:{message:'映射 3/3'},
cells:[{key:'1:3:4',page:1,mapId:14,regionId:2,x:3,y:4,explored:true,walls:{}},
{key:'2:3:4',page:2,mapId:99,regionId:2,x:3,y:4,walls:{}}],
markers:[false,true,null].map((collected,i)=>({key:'marker-'+i,cellKey:'1:3:4',type:'bell',id:0,collected,name:'铃铛',desc:'增加容量',official:true}))}));
const context=vm.createContext({$,CURRENT_CONTEXT:'current',CURRENT_GAME:{inMap:true},ITEM_ICONS:{},ITEM_TYPES:{bell:'铃铛'},
esc:v=>String(v??''),itemArt:()=>'',loadItemIcons(){},refreshStatus:async()=>{},
api:async(route,body)=>{calls.push({route,body});return route==='/world-map'?data:{ok:true};},toast:message=>messages.push(message)});
// Use the actual shared SVG definition, including its encoding, for coin markers.
vm.runInContext(source.slice(source.indexOf('const COIN_ICON ='),source.indexOf('function invalidateTrainer')),context);
vm.runInContext(source.slice(start,end),context);
const run=code=>vm.runInContext(code,context);
context.fixtureData=data;run('WORLD_MAP=fixtureData');$('#world-map-page').value='1';
return {$,data,calls,messages,context,run};
}
test('map JSON marker keys associate correctly; overlapping page coordinates stay separated',()=>{
const f=fixture();f.run('renderWorldMap()');
assert.match(f.$('#world-map-drawing').innerHTML,/data-world-cell="1:3:4"/);
assert.doesNotMatch(f.$('#world-map-drawing').innerHTML,/data-world-cell="2:3:4"/);
assert.equal((f.$('#world-map-drawing').innerHTML.match(/data-world-marker=/g)||[]).length,3);
f.run("WORLD_MAP_SELECTED='1:3:4';renderWorldMapDetail()");
assert.match(f.$('#world-map-detail').innerHTML,/未收集 1 · 已收集 1 · 未知 1/);
});
test('mixed collected state never marks a group fully collected; unknown is not uncollected',()=>{
const f=fixture(),state=f.run('worldMapState(WORLD_MAP.markers)');
assert.equal(state.allCollected,false);assert.equal(state.missing,1);assert.equal(state.unknown,1);
f.$('#world-map-missing').checked=true;assert.equal(f.run("worldMapMarkers('1:3:4').length"),1);
f.run('renderWorldMap()');assert.doesNotMatch(f.$('#world-map-drawing').innerHTML,/data-world-marker="marker-[12]"/);
});
test('coin map markers render the shared double-rim paw SVG at every collection state',()=>{
const f=fixture();
for(const marker of f.data.markers)Object.assign(marker,{type:'coin',name:'金币',iconKey:'coin:0'});
f.run('renderWorldMap()');
const html=f.$('#world-map-drawing').innerHTML;
assert.equal((html.match(/data:image\/svg\+xml/g)||[]).length,3);
const svg=decodeURIComponent(f.run('COIN_ICON').split(',')[1]);
assert.match(svg,/viewBox="0 0 32 32"/);
assert.ok((svg.match(/<circle/g)||[]).length>=2);
});
test('map details explain conditions and branch sources without exposing JSON',()=>{
const f=fixture();Object.assign(f.data.markers[0],{conditions:[{label:'已打开大门',met:true},{label:'击败守卫',met:false},{label:'剧情条件',met:null}],branch:true,sourceNote:'击打 8 次耗尽'});
f.run("WORLD_MAP_SELECTED='1:3:4';renderWorldMapDetail()");
const html=f.$('#world-map-detail').innerHTML;
assert.match(html,/✓ 已打开大门;✕ 击败守卫;\? 剧情条件;位于条件分支,需满足剧情条件;击打 8 次耗尽/);
assert.doesNotMatch(html,/"label"|"met"/);
});
test('travel uses cell coordinates and captured context, not marker event coordinates',async()=>{
const f=fixture();await f.run("travelWorldMap('1:3:4')");
assert.deepEqual(JSON.parse(JSON.stringify(f.calls)),[{route:'/world-map/travel',body:{context:'current',mapId:14,cellX:3,cellY:4,page:1}}]);
});
test('stale contexts and game menus reject travel before any request',async()=>{
const f=fixture();f.context.CURRENT_CONTEXT='changed';await f.run("travelWorldMap('1:3:4')");
assert.equal(f.calls.length,0);assert.match(f.messages.at(-1),/刷新地图/);
f.context.CURRENT_CONTEXT='current';f.context.CURRENT_GAME.inMap=false;await f.run("travelWorldMap('1:3:4')");
assert.equal(f.calls.length,0);assert.match(f.messages.at(-1),/关闭游戏菜单/);
});
test('drag does not select or travel, but a tap selects by stable cell key',()=>{
const f=fixture(),viewport=f.$('#world-map-viewport');
const target={closest:selector=>selector==='[data-world-cell]'?{dataset:{worldCell:'1:3:4'}}:null};
const down={button:0,pointerId:1,clientX:10,clientY:20,target,currentTarget:viewport};
viewport.onpointerdown(down);viewport.onpointermove({...down,clientX:40});viewport.onpointerup(down);
assert.equal(f.run('WORLD_MAP_SELECTED'),null);assert.equal(f.calls.length,0);
viewport.onpointerdown(down);viewport.onpointerup(down);
assert.equal(f.run('WORLD_MAP_SELECTED'),'1:3:4');assert.equal(f.calls.length,0);
});
test('poll refresh preserves selection and zoom; late response cannot restore invalidated data',async()=>{
const f=fixture();f.run("WORLD_MAP_FITTED=true;WORLD_MAP_VIEW={x:123,y:234,scale:1.75};WORLD_MAP_SELECTED='1:3:4'");
await f.run('loadWorldMap()');assert.equal(f.run('WORLD_MAP_VIEW.scale'),1.75);assert.equal(f.run('WORLD_MAP_VIEW.x'),123);
assert.equal(f.run('WORLD_MAP_SELECTED'),'1:3:4');
let finish;f.context.api=()=>new Promise(resolve=>{finish=resolve;});
const pending=f.run('loadWorldMap()');f.context.CURRENT_CONTEXT='new-save';f.run('invalidateWorldMap()');finish(f.data);await pending;
assert.equal(f.run('WORLD_MAP'),null);assert.equal(f.run('WORLD_MAP_SELECTED'),null);
});
+27
View File
@@ -0,0 +1,27 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {readWorldMap}=require('../lib/world-map');
test('world schematic preserves page boundaries, per-item collection and unexplored cells without writes',()=>{
const cells=[{x:1,y:2,page:1,mapId:7,regionId:2,l:true,pt:true},
{x:1,y:2,page:2,mapId:8,regionId:3,hidden:true}];
const locations=[{key:'a',type:'hpst',id:0,mapId:7,eventId:1,x:4,y:4,collected:true,official:true},
{key:'b',type:'hpst',id:0,mapId:7,eventId:2,x:4,y:4,collected:false,official:true},
{key:'c',type:'hpst',id:0,mapId:8,eventId:3,x:4,y:4,collected:null,official:false}];
const encyclopedia={items:[{type:'hpst',id:0,name:'强化',desc:'效果'}],locations};
const before=JSON.stringify({cells,encyclopedia});
const context=vm.createContext({encyclopedia,DataManager:{_thumbnailMapCell:{a:cells[0],b:cells[1]},
makePosKey:(x,y)=>x+':'+y,getThumbnailCell:(x,y,id)=>cells.find(c=>c.mapId===id),getCurrentThumbnailCell:()=>cells[1]},
$gameSystem:{isThumbnailActive:()=>false},$dataMapInfos:{},$dataRegion:{2:{name:'森林',exploreItem:[[7,1],[7,2],[7,9]]},3:{name:'里'}},
$gameMap:{mapId:()=>8},$gamePlayer:{_x:5,_y:6}});
const r=JSON.parse(vm.runInContext(`JSON.stringify((${readWorldMap.toString()})(encyclopedia))`,context));
assert.equal(r.cells.length,2);assert.equal(r.pages.length,2);
assert.deepEqual(r.markers.map(m=>m.collected),[true,false,null]);
assert.deepEqual(r.markers.map(m=>m.cellKey),['1:1:2','1:1:2','2:1:2']);
assert.equal(r.cells[0].walls.left,true);assert.equal(r.cells[0].exits.top,true);
assert.equal(r.cells[1].explored,false);assert.equal(r.cells[1].hidden,true);
assert.equal(r.player.cellKey,'2:1:2');assert.equal(r.markers[0].name,'强化');
assert.equal(r.coverage.mappedOfficial,2);assert.equal(r.coverage.officialTotal,3);assert.deepEqual(r.coverage.missing,['7:9']);
assert.equal(JSON.stringify({cells,encyclopedia}),before);
});