mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-18 13:20:02 +08:00
181 lines
16 KiB
JavaScript
181 lines
16 KiB
JavaScript
// Isolated behavior checks. This is NOT a live-game acceptance test.
|
|
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { relicCatalog } from "../relics/catalog.js";
|
|
import { relicDefinitions, getRelic, heldRelics, canAcquireRelic, grantRelic, relicRewardIds, relicRewardPools } from "../relics/definitions.js";
|
|
import { relicState, grantGold, newRelicCard, resolveRelicChoices, relicCardPool } from "../relics/progression.js";
|
|
import { createRelicBattle, applyBattleEndRelics } from "../relics/battle.js";
|
|
import { applyRoomRelics, applyRelicNodeEnter, relicShopPrice } from "../relics/rooms.js";
|
|
import { addCardToDeck } from "../cards/card-data.js";
|
|
import { createBattleSettlement } from "../battle/battle-settlement.js";
|
|
import { getRandomRewardChoices } from "../progression/reward.js";
|
|
import config from "../config.js";
|
|
import { createRun } from "../progression/state.js";
|
|
import { createRelicCombatSkills } from "../relics/combat.js";
|
|
|
|
let checks=0;
|
|
const check=(condition,message)=>{assert.ok(condition,message);checks++;};
|
|
const eq=(actual,expected,message)=>{assert.deepEqual(actual,expected,message);checks++;};
|
|
const copy=value=>JSON.parse(JSON.stringify(value));
|
|
const makeRun=()=>({runId:"relic-test",actIndex:0,revision:0,status:"running",randomState:20261006,
|
|
player:{character:"mengsan_liubei_shuying",items:[],deck:[],hp:30,maxHp:80,gold:0,permanentSkills:[],handLimitBonus:0},
|
|
statistics:{goldEarned:0,completedNodes:0,defeatedEnemies:0},
|
|
map:{nodes:[{id:"battle-1",type:"battle",completed:false}],completedNodeIds:[]}});
|
|
function starter(run){for(let i=0;i<6;i++)run.player.deck.push(newRelicCard(run,i<3 ? "sha" : "mengsan_fangyu"));}
|
|
function add(run,id){assert.ok(grantRelic(run,id),`grant ${id}`);checks++;}
|
|
function fixture(run,{encounter={}}={}){
|
|
let hand=[],drawn=0,alive=true;const drawPile=run.player.deck.map(data=>({name:data.name,storage:{mengsanCard_shuying:copy(data)}}));
|
|
const owner={hp:run.player.hp,maxHp:run.player.maxHp,hujia:0,storage:{mengsanEnergy_shuying:3},getCards:()=>hand,isAlive:()=>alive};
|
|
const piles={drawCards:()=>drawPile,addToDraw(data,index){const card={name:data.name,storage:{mengsanCard_shuying:copy(data)}};drawPile.splice(index,0,card);return card;},async addToHand(data){const card={name:data.name,storage:{mengsanCard_shuying:copy(data)}};hand.push(card);return card;}};
|
|
const effects=[];
|
|
const controller=createRelicBattle(run,{active:()=>alive,owner,piles,encounter,draw:async n=>{drawn+=n;return [];},log:()=>{},
|
|
effect:async relic=>{effects.push([relic.effect,relic.amount]);if(["openingBlock","nextBlock","plating"].includes(relic.effect))owner.hujia+=relic.amount;if(["energy","nextEnergy"].includes(relic.effect))owner.storage.mengsanEnergy_shuying=Math.max(0,owner.storage.mengsanEnergy_shuying+relic.amount);if(["openingHeal","healAbsolute"].includes(relic.effect))owner.hp=Math.min(owner.maxHp,owner.hp+relic.amount);},});
|
|
return {owner,piles,controller,effects,get drawn(){return drawn;},setHand(cards){hand=cards;},stop(){alive=false;},
|
|
async turn(){controller.globalTurnStart(owner);await controller.turnStart({name:"phase",player:owner});},
|
|
async use(name="sha"){const use={name:"useCard",player:owner,card:{name},parent:null};await controller.onUse(use);return use;}};
|
|
}
|
|
|
|
eq(relicCatalog.length,298,"complete catalog");eq(new Set(relicCatalog.map(r=>r.wikiId)).size,298);
|
|
eq(relicCatalog.map(r=>r.order),Array.from({length:298},(_,i)=>i+1));
|
|
const manifest=JSON.parse(await readFile(new URL("../assets/relics/manifest.json",import.meta.url),"utf8"));
|
|
for(const asset of manifest.assets){
|
|
const bytes=await readFile(new URL(`../${asset.runtime}`,import.meta.url));
|
|
const source=await readFile(new URL(`../assets/sts2/Godot_Atlas_Sprites_v0.111.0/images/atlases/relic_atlas.sprites/${asset.source}`,import.meta.url));
|
|
eq(createHash("sha256").update(bytes).digest("hex"),asset.sha256,asset.wikiId);
|
|
eq(bytes,source,`original image ${asset.wikiId}`);
|
|
eq([bytes.readUInt32BE(16),bytes.readUInt32BE(20)],[asset.width,asset.height]);
|
|
check(asset.width<=480 && asset.height<=672 && asset.bytes<=300*1024,`icon budget ${asset.wikiId}`);
|
|
}
|
|
eq(manifest.assets.length,298);
|
|
check(heldRelics(createRun('mengsan_liubei_shuying')).some(r=>r.wikiId==='burning_blood'),'mapped new journey starts with Burning Blood');
|
|
for(const relic of Object.values(relicDefinitions)){
|
|
const run=makeRun();starter(run);run.player.relicPool=relic.pool;
|
|
if(relic.implementation==="pending"){
|
|
const before=copy(run);check(!canAcquireRelic(run,relic.id));check(!grantRelic(run,relic.id));eq(run,before,"pending does not mutate");continue;
|
|
}
|
|
add(run,relic.id);check(!canAcquireRelic(run,relic.id),`unique ${relic.id}`);
|
|
await resolveRelicChoices(run,async (title,choices)=>choices.find(c=>c.id!=="skip").id);
|
|
eq(new Set(run.player.deck.map(c=>c.id)).size,run.player.deck.length,`unique cards ${relic.id}`);
|
|
check(run.player.hp>0 && run.player.hp<=run.player.maxHp,`safe pickup ${relic.id}`);
|
|
const f=fixture(run);await f.controller.start();await f.turn();await f.use();await f.controller.turnEnd();
|
|
eq(f.controller.beforeHpLoss(0),0);
|
|
}
|
|
for(const id of relicRewardIds)check(getRelic(id.slice("shared.reward.relic.".length)).implementation!=="pending");
|
|
for(const [key,ids] of Object.entries(relicRewardPools)){
|
|
check(ids.every(id=>config.rewards[id]),key);
|
|
if(key.includes("ancient"))check(ids.length>0,`${key} populated`);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"strawberry");eq(run.player.maxHp,87);eq(run.player.hp,30);check(!grantRelic(run,"strawberry"));
|
|
add(run,"burning_blood");add(run,"black_blood");check(!heldRelics(run).some(r=>r.wikiId==="burning_blood"));check(!grantRelic(run,"burning_blood"));
|
|
eq(applyBattleEndRelics(run,"defeat"),0);eq(applyBattleEndRelics(run,"victory"),12);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"bowler_hat");add(run,"dragon_fruit");eq(grantGold(run,20),25);eq(run.player.gold,25);eq(run.player.maxHp,81);eq(run.player.hp,30);
|
|
add(run,"ectoplasm");eq(grantGold(run,200),0);eq(run.player.gold,25);eq(run.player.maxHp,81);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"molten_egg");add(run,"bing_bong");add(run,"lucky_fysh");
|
|
addCardToDeck(run,newRelicCard(run,"sha"));eq(run.player.deck.length,2);eq(run.player.deck.map(c=>c.upgrade),[1,1]);eq(run.player.gold,30);
|
|
add(run,"darkstone_periapt");addCardToDeck(run,newRelicCard(run,"mengsan_curse_greed"));eq(run.player.maxHp,92);
|
|
}
|
|
{
|
|
const run=makeRun();starter(run);add(run,"neows_talisman");eq(run.player.deck.filter(c=>c.upgrade).length,2);
|
|
const oldIds=run.player.deck.map(c=>c.id);add(run,"pandoras_box");eq(run.player.deck.map(c=>c.id),oldIds);check(run.player.deck.every(c=>!['sha','mengsan_fangyu'].includes(c.name)));
|
|
}
|
|
{
|
|
const run=makeRun();starter(run);run.player.deck[0].affixes=['eternal'];add(run,"empty_cage");
|
|
await resolveRelicChoices(run,async(title,choices)=>choices[0].id);eq(run.player.deck.length,4);check(run.player.deck.some(c=>c.affixes.includes('eternal')));
|
|
add(run,"claws");await resolveRelicChoices(run,async(title,choices)=>choices.find(c=>c.id!=='skip').id);eq(run.player.deck.filter(c=>c.name==='mengsan_event_maul').length,3);
|
|
}
|
|
{
|
|
const run=makeRun();starter(run);add(run,"paels_tooth");await resolveRelicChoices(run,async(title,choices)=>choices[0].id);
|
|
eq(run.player.deck.length,1);eq(relicState(run).storedCards.length,5);applyBattleEndRelics(run,"victory");eq(run.player.deck.length,2);eq(relicState(run).storedCards.length,4);check(run.player.deck.some(c=>c.upgrade===1));
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"meal_ticket");const shop={id:'shop',type:'shop'};
|
|
eq(applyRoomRelics(run,shop,'enter').recovered,15);eq(applyRoomRelics(run,shop,'enter'),null);
|
|
add(run,"membership_card");add(run,"the_courier");eq(relicShopPrice(run,120),48);
|
|
add(run,"regal_pillow");const rest={id:'rest',type:'rest'};applyRoomRelics(run,rest,'enter');eq(applyRoomRelics(run,rest,'heal').recovered,23);eq(applyRoomRelics(run,rest,'heal'),null);
|
|
add(run,"maw_bank");applyRelicNodeEnter(run,{id:'1'});eq(run.player.gold,12);applyRelicNodeEnter(run,{id:'1'});eq(run.player.gold,12);relicState(run).mawBankBroken=true;applyRelicNodeEnter(run,{id:'2'});eq(run.player.gold,12);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"anchor");add(run,"bag_of_preparation");add(run,"lantern");add(run,"centennial_puzzle");add(run,"venerable_tea_set");relicState(run).afterRest=true;
|
|
const f=fixture(run);await f.controller.start();eq(f.owner.hujia,10);eq(f.controller.retainedBlock(10),10);eq(f.drawn,2);eq(f.controller.openingHandBonus,2);
|
|
await f.turn();eq(f.owner.storage.mengsanEnergy_shuying,6);eq(f.controller.retainedBlock(10),0);
|
|
const event={num:-1};await f.controller.loseHp(event);await f.controller.loseHp(event);eq(f.drawn,5);await f.controller.loseHp({num:-1});eq(f.drawn,5);
|
|
const second=fixture(run);await second.controller.start();await second.turn();eq(second.owner.storage.mengsanEnergy_shuying,4,"tea receipt spent");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"tungsten_rod");add(run,"beating_remnant");const f=fixture(run);await f.controller.start();await f.turn();
|
|
eq(f.controller.beforeHpLoss(-10,10),-10,"full block must be consumed normally");eq(f.controller.beforeHpLoss(-10,5),-9,"reduce HP loss after armor");
|
|
await f.controller.loseHp({num:-19});eq(f.controller.beforeHpLoss(-8,5),-6,"turn cap after armor");
|
|
f.controller.globalTurnStart({});eq(f.controller.beforeHpLoss(-50),-20,"all turns reset cap");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,'big_mushroom');add(run,'bag_of_preparation');const f=fixture(run);
|
|
eq(f.controller.drawAdjustment,-2,"opening penalties apply before positive bonus draws");await f.controller.start();eq(f.controller.openingHandBonus,0);eq(f.drawn,2);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"lizard_tail");const f=fixture(run);await f.controller.start();f.owner.hp=-4;await f.controller.dying();eq(f.owner.hp,40);f.owner.hp=-1;await f.controller.dying();eq(f.owner.hp,-1);
|
|
const second=fixture(run);await second.controller.start();second.owner.hp=0;await second.controller.dying();eq(second.owner.hp,0,"tail once per journey");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"pen_nib");add(run,"akabeko");add(run,"strike_dummy");add(run,"nunchaku");const f=fixture(run);await f.controller.start();await f.turn();
|
|
let use=await f.use();eq(f.controller.outgoingDamage({source:f.owner,card:{name:'sha'},num:6,parent:use}),17);
|
|
for(let i=1;i<9;i++)await f.use();const next=fixture(run);await next.controller.start();await next.turn();use=await next.use();
|
|
eq(next.controller.outgoingDamage({source:next.owner,card:{name:'sha'},num:6,parent:use}),34,"pen nib persists + fresh battle vigor");eq(next.owner.storage.mengsanEnergy_shuying,4);
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"permafrost");const f=fixture(run);await f.controller.start();await f.turn();const power='mengsan_ic_demon_form';
|
|
check(relicCardPool(run,{type:'power'}).includes(power));await f.use(power);eq(f.owner.hujia,7);await f.turn();await f.use(power);eq(f.owner.hujia,7,"permafrost once per combat");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,"vambrace");add(run,"oddly_smooth_stone");const f=fixture(run);await f.controller.start();await f.turn();const use=await f.use('mengsan_fangyu');
|
|
eq(f.controller.blockAmount(5,{parent:use}),12);eq(f.controller.blockAmount(5,{parent:use}),6);eq(f.controller.blockAmount(5,{}),5,"noncard block gets no dexterity");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,'velvet_choker');add(run,'brilliant_scarf');const f=fixture(run);await f.controller.start();await f.turn();
|
|
for(let i=0;i<4;i++)await f.use();check(f.controller.isFree({name:'sha'}));await f.use();check(!f.controller.isFree({name:'sha'}));await f.use();check(f.controller.limited());f.controller.globalTurnStart({});check(!f.controller.limited(),'responses outside own turn remain enabled');
|
|
}
|
|
{
|
|
const run=makeRun();run.player.relicPool='静默猎手';add(run,'paper_krane');const f=fixture(run);await f.controller.start();
|
|
const skills=createRelicCombatSkills(()=>({session:{active:true},relics:f.controller}),()=>f.owner);
|
|
const enemy={storage:{mengsanWeak_shuying:1}},event={source:enemy,card:null,mengsanAttack_shuying:true,num:10};
|
|
check(skills.mengsan_relic_attack_shuying.filter(event),'explicit scripted attack marker supports Weak');
|
|
await skills.mengsan_relic_attack_shuying.content({},event);eq(event.num,6);
|
|
check(!skills.mengsan_relic_attack_shuying.filter({...event,mengsanScriptedSkill_shuying:true}),'relic damage is not an attack');
|
|
check(!skills.mengsan_relic_attack_shuying.filter({source:f.owner,card:{name:'tao'},num:1}),'non-attack cards do not receive Strength');
|
|
}
|
|
{
|
|
const run=makeRun();add(run,'joss_paper');add(run,'the_abacus');add(run,'charons_ashes');const f=fixture(run);await f.controller.start();
|
|
for(let i=0;i<5;i++)f.controller.queueExhaust({});f.controller.queueShuffle();await f.controller.flushExhaust();eq(f.drawn,1);eq(f.owner.hujia,6);eq(f.effects.filter(([key])=>key==='openingDamage').length,5);await f.controller.flushExhaust();eq(f.drawn,1);
|
|
}
|
|
{
|
|
const run=makeRun();starter(run);add(run,'blessed_antler');add(run,'tea_of_discourtesy');add(run,'bellows');add(run,'stone_cracker');add(run,'vexing_puzzlebox');
|
|
const f=fixture(run);f.setHand([{name:'sha',storage:{mengsanCard_shuying:copy(run.player.deck[0])}}]);await f.controller.start();eq(f.piles.drawCards().length,11);eq(run.player.deck.filter(c=>c.upgrade).length,0,"battle upgrades do not leak into permanent deck");
|
|
const second=fixture(run);await second.controller.start();eq(second.piles.drawCards().length,9,"one battle dazed consumed once");
|
|
}
|
|
{
|
|
const run=makeRun();add(run,'black_star');let data={[config.saveKey]:copy(run)},writes=0;
|
|
const store={read:()=>copy(data),async update(fn){writes++;return fn(data);}};
|
|
const settlement=createBattleSettlement({store,config,getRandomRewardChoices,applyReward:(r,id)=>{const relic=config.rewards[id]?.relic;if(relic)grantRelic(r,relic);},completeNode:(r,id)=>{r.map.nodes[0].completed=true;return true;},enterNextAct:()=>false,resolveRelicChoices:async r=>resolveRelicChoices(r,async(t,c)=>c[0].id)});
|
|
const pending=await settlement.prepare({run:copy(data[config.saveKey]),node:run.map.nodes[0],encounter:{tier:'elite',gold:20,skipRandomReward:true},hp:25});eq(pending.fixedRewards.length,2);
|
|
await settlement.choose(run.runId,pending.id,null);const one=await settlement.commit(run.runId,pending.id);const two=await settlement.commit(run.runId,pending.id);eq(writes,1);eq(one.run.player.gold,20);check(two.duplicate);eq(one.run.player.items.length,3);
|
|
}
|
|
{
|
|
const run=makeRun();let data={[config.saveKey]:copy(run)},attempts=0,resolutions=0,release;
|
|
const barrier=new Promise(resolve=>{release=resolve;});
|
|
const store={read:()=>copy(data),async update(fn){if(++attempts===1)throw new Error('injected write failure');return fn(data);}};
|
|
const settlement=createBattleSettlement({store,config,getRandomRewardChoices,applyReward:()=>{},completeNode:r=>{r.map.nodes[0].completed=true;return true;},enterNextAct:()=>false,
|
|
resolveRelicChoices:async()=>{resolutions++;await barrier;}});
|
|
const pending=await settlement.prepare({run:copy(run),node:run.map.nodes[0],encounter:{tier:'normal',gold:20,skipRandomReward:true},hp:20});
|
|
const first=settlement.choose(run.runId,pending.id,null),second=settlement.choose(run.runId,pending.id,null);
|
|
eq(first,second,'concurrent same reward shares one operation');await assert.rejects(settlement.choose(run.runId,pending.id,'other'));checks++;
|
|
release();await first;eq(resolutions,1);await assert.rejects(settlement.commit(run.runId,pending.id));checks++;
|
|
eq(data[config.saveKey].player.gold,0,'failed write keeps prior checkpoint');const saved=await settlement.commit(run.runId,pending.id);eq(saved.run.player.gold,20);eq(attempts,2);eq(resolutions,1);
|
|
}
|
|
console.log(JSON.stringify({checks,images:manifest.assets.length,wikiRelics:relicCatalog.length,
|
|
states:relicCatalog.reduce((counts,r)=>{const status=getRelic(r.id).implementation;counts[status]=(counts[status] || 0)+1;return counts;},{}),acceptance:"isolated behavior / resource checks only; live game pending"},null,2));
|