feat # 继续补全遗物系统

This commit is contained in:
2026-10-08 23:46:57 +08:00
parent 623f53a1d2
commit 1c93c77f4f
335 changed files with 11528 additions and 1797 deletions
+180
View File
@@ -0,0 +1,180 @@
// 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));
+74
View File
@@ -0,0 +1,74 @@
"""Import the reviewed Wiki snapshot and supplied split atlas without fetching the web."""
import argparse
import hashlib
import json
import struct
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ALIASES = {
"ring_of_the_snake": "mengsan_snake_ring_shuying",
"ring_of_the_drake": "mengsan_long_snake_ring_shuying",
"centennial_puzzle": "mengsan_millennium_puzzle_shuying",
"festive_popper": "mengsan_festive_poppers_shuying",
}
IMAGE_ALIASES = {
"yummy_cookie": "yummy_cookie_ironclad.png",
"forgotten_soul": "lost_soul.png",
"looming_fruit": "looming_fruit_2.png",
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("snapshot", type=Path)
args = parser.parse_args()
source = json.loads(args.snapshot.read_text(encoding="utf-8"))
rows = source["relics"]
if len(rows) != 298 or len({row["id"] for row in rows}) != 298:
raise ValueError("Expected reviewed 298 unique relics")
atlas = ROOT / "assets/sts2/Godot_Atlas_Sprites_v0.111.0/images/atlases/relic_atlas.sprites"
target = ROOT / "assets/relics"
target.mkdir(exist_ok=True)
entries, assets = [], []
for row in rows:
wiki_id = row["id"]
filename = IMAGE_ALIASES.get(wiki_id, row["image"])
data = (atlas / filename).read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"Invalid PNG: {filename}")
width, height, depth, color = struct.unpack(">IIBB", data[16:26])
# Icons are copied byte-for-byte; no enlargement, redraw or loss of alpha.
(target / f"{wiki_id}.png").write_bytes(data)
entries.append({
"id": ALIASES.get(wiki_id, f"mengsan_{wiki_id}_shuying"),
"wikiId": wiki_id, "name": row["name"],
"description": row["rendered_description"], "pool": row["pool"],
"tier": row["tier"], "ancient": row["ancient"] or None,
"order": int(row["compendium_order"]),
"image": f"extension/术樱包/mengsan/assets/relics/{wiki_id}.png",
"source": row["source_page_url"],
"descriptionRaw": row["description_raw"], "descriptionSource": row["description"],
"flavor": row["flavor"], "predecessor": row["predecessor"], "category": row["category"],
})
assets.append({"wikiId": wiki_id, "source": filename,
"runtime": f"assets/relics/{wiki_id}.png", "width": width,
"height": height, "bytes": len(data), "depth": depth,
"alpha": color in (4, 6), "sha256": hashlib.sha256(data).hexdigest()})
(ROOT / "relics/catalog.js").write_text(
"// Generated from the reviewed 2026-10-04 Wiki snapshot; use tools/import-sts2-relics.py.\n"
"export const relicCatalog = Object.freeze(" + json.dumps(entries, ensure_ascii=False, indent=2) + ");\n",
encoding="utf-8")
(target / "manifest.json").write_text(json.dumps({
"snapshot_date": "2026-10-04", "atlas_version": "v0.111.0",
"data_source": source["data_source_url"], "data_revision": source["source_revision"],
"assets": assets,
"notes": ["美味饼干使用铁甲战士版本;四个其他角色版本仍保留在来源图集中。",
"遗忘之魂映射 lost_soul.png;布质果实映射 looming_fruit_2.png。",
"图标是用户提供的原游戏拆包图片,逐字节复制,未重绘。"]
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Imported {len(entries)} relics, {sum(a['bytes'] for a in assets)} PNG bytes")
if __name__ == "__main__":
main()
@@ -0,0 +1,82 @@
import { writeFile, readFile } from "node:fs/promises";
import { relicCatalog } from "../relics/catalog.js";
import { getRelic, relicRewardIds, relicShopRewardIds, relicRewardPools, relicAdaptations } from "../relics/definitions.js";
const manifest=JSON.parse(await readFile(new URL("../assets/relics/manifest.json",import.meta.url),"utf8"));
const assets=new Map(manifest.assets.map(asset=>[asset.wikiId,asset]));
const counts=relicCatalog.reduce((n,r)=>{const s=getRelic(r.id).implementation;n[s]=(n[s] || 0)+1;return n;},{});
const labels={implemented:"已接入,待实机",adapted:"梦三适配,待实机",pending:"待补充,禁入奖励"};
const escape=value=>String(value??"—").replaceAll("|","\\|").replaceAll("\n","<br>");
const categoryCounts=key=>Object.entries(relicCatalog.reduce((result,r)=>{const value=r[key] || "无";result[value]=(result[value] || 0)+1;return result;},{})).map(([name,count])=>`${name} ${count}`).join(";");
const lines=[
"# STS2 遗物接入记录(2026-10-06)","",
`已登记全部 **298** 件 Wiki 遗物,匹配 **298** 张原图。**${counts.implemented+counts.adapted}** 件效果已接入,其中 **${counts.adapted}** 件有明确梦三适配;**${counts.pending}** 件保留资料、图片及缺口记录,获取资格拒绝这些条目。另保留原梦三束带,不计入 Wiki 298 件。`,"",
"本次直接修改实际梦三模块,未制作修复副本;没有生成或重绘三国杀风格图片。原有三个 dist 发布清单的既存改动未触碰。本记录中的“已接入”仅表示源码有对应执行接口,**不是实机验收结论**。","",
"## 来源与完整性","",
"- 使用本次对话此前抓取的 **2026-10-04** 网页快照;本次没有把旧快照宣称为 10 月 6 日 Wiki 的新修订。",
"- [遗物查找页](https://sts2.huijiwiki.com/wiki/模板:查找/遗物);[结构化数据页](https://sts2.huijiwiki.com/wiki/Data:Relic.tabx),快照修订号 `28579`。",
"- `relics/catalog.js` 保存池、原始稀有度、先古归属、图鉴顺序、中文名、可读描述、原始描述/原始 Wiki 标记、风味文字、前代及来源链接。",
"- 298 个唯一 Wiki ID;图鉴顺序完整覆盖 1—298。头环原始稀有度仍为“遗物”;先古归属与稀有度分别保存。",
`- 遗物池:${categoryCounts("pool")}。`,
`- 稀有度:${categoryCounts("tier")}。`,
`- 先古归属:${categoryCounts("ancient")}。`,"",
"## 图片资源","",
"来源为用户提供的 `assets/sts2/Godot_Atlas_Sprites_v0.111.0/images/atlases/relic_atlas.sprites/` 拆分 PNG 目录。运行时使用 `assets/relics/<wikiId>.png`,逐字节复制,不拉伸、不重新绘图。所有图片都在 480×672、300 KiB 以内。",
`298 张图片合计 **${manifest.assets.reduce((n,r)=>n+r.bytes,0).toLocaleString("en-US")} 字节**。每张的源文件名、尺寸、透明通道、体积、SHA-256 见 [资源清单](../assets/relics/manifest.json)。`,"",
"特殊映射:美味饼干使用 `yummy_cookie_ironclad.png`;遗忘之魂使用 `lost_soul.png`;布质果实使用 `looming_fruit_2.png`。美味饼干的其他角色图仍在原图集,当前仅接入铁甲战士图。原梦三束带没有对应 STS2 原图,继续显示文字。",
"战斗遗物栏、地图背包、遗物选择、商店和战利品清单已经接入图片;池/稀有度/先古归属可从遗物栏与背包查看。","",
"## 效果与获取接线","",
"- 拾取:最大生命、回血、金币、牌组强化、复制、移除、变化、变化并强化、加入卡牌、随机遗物、多组卡牌选择、临时移出永久牌组。",
"- 战斗:开场与指定回合效果、额外摸牌、费用、格挡、敏捷、力量、活力、易伤/虚弱倍率、攻击/技能/能力计数、伤害增幅、荆棘、覆甲、生命损失减免、尾巴复活、消耗及洗牌触发。",
"- 战斗生成牌和手牌/抽牌堆强化使用本场实体牌,强化不会写回永久牌组。艳丽围巾的第五张牌免费与天鹅绒项圈限制接入现有支付/禁用接口;化学物X接入 X 次数。",
"- 笔尖、双节棍、音叉、铁棒、香纸的累计计数保存在征程状态;新获得的遗物以获得时的累计值为起点。每回合三连击计数独立重置。",
"- 房间:餐券、活动星图、永恒羽毛、茶具、皇家枕头、捕梦网、铲子、壶铃、南瓜蜡烛添火、帐篷、烹饪与折扣。每个房间/动作有独立记录,重复进入不会重复回血。",
"- 战后:回血、敌人额外金币、最大生命、普通战斗/精英战强化、黑星、熔岩石、佩尔之齿逐张强化返还、石之剑变化、旺购神秘券。",
"- 金币统一经过 `grantGold`;圆顶礼帽/灵体外质/火龙果同时覆盖战斗、奖励包、事件、遗物拾取、添加牌和现有劫掠牌击杀收益。存钱罐在商店消费后失效。",
"- 商店保留【杀】与回复生命,新增3个遗物货位、折扣与信使补货。遗物基价暂设120金币;不是 Wiki 公布的商品价格。购买后完成所有遗物选牌,再提交房间结果。",
`- 普通/罕见/稀有遗物池登记 **${relicRewardIds.length}** 项;可售遗物池登记 **${relicShopRewardIds.length}** 项,均在实际获取时继续过滤重复与角色池。`,
"- 原有精英固定遗物奖励、第一章宝箱和初次剧情遗物选择自动使用扩展后的可用池。Boss 极品卡牌池保留原有专属稀有卡约定。",
"- 新建刘备征程自动持有燃烧之血,符合铁甲战士初始池;既有存档不追补、不重复发放初始遗物。其他武将暂不指定初始遗物。",
"- 先古/事件/初始遗物已提供独立奖励池;**尚未创建对应先古会面和事件剧情**,不会把这些条目混入普通掉落。后续节点可以引用下表池名或调用已有 `grantRelic`。",
"- 拾取操作在可序列化副本上计算,成功后替换运行状态;尚未实现的遗物/重复获取不会改金币、随机数或牌组。战后效果在现有结算候选里只计算一次,存档失败重试不重新发奖。", "",
"| 专用奖励池 | 当前可用数 |","| --- | --- |",
...Object.entries(relicRewardPools).map(([id,values])=>`| \`${id}\` | ${values.length} |`),"",
"## 需要用户补充或确认","",
"1. **烹饪数值与流程**:网页只说明能够烹饪,目前采用最大生命+5。确定原作具体数值后可替换适配值。",
"2. **非战斗生命归零流程**:危险剪刀/芬芳蘑菇当前扣血保留至少1生命。需要明确是否允许地图中直接死亡,以及对应失败弹窗/存档规则。",
"3. **先古会面及事件入口**:请确定梦三在哪些章节、节点或剧情中接入各先古和事件遗物。已有数据分类、效果、专用奖励池,但尚无原作的完整会面事件。",
"4. **角色池映射**:当前只把刘备映射为铁甲战士,其他四个 STS2 角色未绑定梦三武将;不擅自指定。",
"5. **原作机制**:辉星、充能球、集中、奥斯提、灾厄、仆从、铸造、可使用药水,以及 Wiki 指定附魔,需要对应玩法接口;完整逐件缺口见下表。现有词缀不会冒充原作附魔。",
"6. **先古牌与击杀口径**:尘封魔典当前抽取现有先古事件牌;石之剑按精英战胜利次数计数,需要完整原作候选表及精英单位身份口径。",
"7. **实机验收**:需要用户回来在实际梦三运行以下场景;此次不在正在进行的征程上自动操纵或替换存档。", "",
"## 已知梦三口径与限制","",
"- 基础开场4张牌、每回合原有摸牌数、费用3和手牌上限3保持现有梦三规则。大蘑菇先减2张,再叠加正向开场额外摸牌。",
"- 增加最大生命本身不额外治疗当前生命,沿用既有草莓约定;李家华夫饼另外回满生命。降低最大生命会将当前生命限制到新上限。",
"- 休息基础回复8;皇家枕头额外15。多个折扣乘算后向下取整,最低1金币。",
"- 克制、防御、保留、能力牌、虚无等牌的现有规则优先保留;符文金字塔/三角铃鼓避免手牌上限弃牌,不阻止虚无牌消耗。",
"- 回合伤害上限在所有参与者的回合开始重置。钨合金棍和跳动残渣在预计护甲抵扣后限制实际生命损失,纯护甲损失不触发百年积木。伤害事件统计仍遵循引擎原有口径。",
"- 独立牌堆不足时,梦三现有规则会结束挑战;额外摸牌遗物同样遵循该规则,未擅自改成原作的不足时少抽。",
"- 尾巴仅本次征程触发一次;数值进位/舍入遵循梦三现有攻击、虚弱、易伤与护甲规则。",
"- 没有持有来源正确的图集之外的素材缺失;主要缺口属于玩法/入口,仍全部登记。", "",
"## 验证与待实机场景","",
"已运行 `node mengsan/tools/check-sts2-relics.mjs`:完整目录/图片哈希及源字节、逐件可获得遗物拾取与基本战斗、禁用遗物不变更状态、角色池/重复资格、随机遗物/选牌、永恒限制、累计计数、格挡后的减伤、复活一次、临时强化不污染牌组、商店房间记录、战后提交只写一次等隔离回归。检查数量以本次运行输出为准。",
"已检查修改模块语法、内容注册表加载及本次改动差异。**尚未做实际游戏内的战斗、图片渲染或安装更新验收**;上述检查不能替代实机运行。", "",
"用户回来后建议覆盖:", "",
"- 持有锚开战并进入第一回合;比较格挡是否保留。",
"- 护甲完全抵挡/部分抵挡攻击时,测试钨合金棍、跳动残渣、百年积木。",
"- 同一多段攻击与第10张攻击,跨战斗测试笔尖、活力、双节棍。",
"- 能力牌首次/再次/下个回合测试永冻冰、木乃伊之手;X=0/大于0测试化学物X。",
"- 第5张免费、第6张后不能主动出牌;进入敌方回合后检查响应仍可用。",
"- 濒死测试蜥蜴尾巴,确保第二次不复活;荆棘/覆甲在实际攻击事件中测试。",
"- 休息点多选、选牌遗物、永久牌移除/变化、佩尔之齿逐场返还、商店折扣与补货。",
"- 保存失败后重试,确认不重复金币、遗物、牌组强化和房间效果;退出未完成战斗后继续旧地图检查点。", "",
"## 明确适配详情","",
...Object.entries(relicAdaptations).filter(([id])=>getRelic(id)?.implementation==="adapted").map(([id,note])=>`- **${getRelic(id).name}**:${note}`),"",
"## 逐件清单","",
"图片路径统一为 `assets/relics/<Wiki ID>.png`。表中“执行/缺口”列给出接线属性或阻塞原因,原文效果原样保留可读表达;池、稀有度和先古归属没有合并。", "",
"| 序号 | 遗物 / Wiki ID | 池 | 稀有度 | 先古归属 | 原文效果 | 状态 | 执行 / 缺口 | 来源图片 |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
...relicCatalog.map(item=>{const r=getRelic(item.id),a=assets.get(item.wikiId);return `| ${item.order} | [${escape(item.name)}](${item.source})<br>\`${item.wikiId}\` | ${escape(item.pool)} | ${escape(item.tier)} | ${escape(item.ancient)} | ${escape(item.description)} | ${labels[r.implementation]} | ${escape(r.implementation==="pending" ? r.implementationNote : r.implementation==="adapted" ? r.implementationNote : Object.entries(r.rule).map(([key,value])=>`${key}=${JSON.stringify(value)}`).join(";"))} | \`${a.source}\`<br>${a.width}×${a.height};${a.bytes} B |`;})
];
await writeFile(new URL("../relics/IMPLEMENTATION-20261006.md",import.meta.url),lines.join("\n")+"\n","utf8");
console.log(JSON.stringify({wikiRelics:relicCatalog.length,states:counts,report:"relics/IMPLEMENTATION-20261006.md"}));