feat(polaris): 《山海巡异录》M3——战斗绘制 TDZ 根因修复+双职业 154 卡+进阶 A0–A15 六带全绿
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

绘制修复(mini-desktop CDP 复验揪出:战斗屏除回合横幅外 lumStd=0 全空,逻辑与指针全通):根因=run-scene.js
const txt 声明位于工厂 return 之后永不执行,txt 永陷 TDZ,全部整屏绘制函数每帧 ReferenceError 无声失败,
唯 floatText(function 声明提升)独活=只剩横幅;M1 并入 run-scene 时引入,headless 门不建场景故只有真浏览器
暴露。修=txt 改 function 声明(提升语义)+事故注释钉原地+自证钩子(场景 rebuild 后回写
readState().debug={displayCount,rebuilds,screen} 供 CDP 机断)+防回潮门 scene-smoke.mjs(mock 场景重放十屏/
异常零容忍/每屏绘制对象≥5;修复后 map=33/battle=32/reward=14)。
M3:S13 职业乙 66+中立 22=154 卡定容(static-screen 3,676,134 序列零经济环)+S14 进阶 ASCENSIONS 16 级入
矩阵轴+多样性 driver(chain/defensive 档,死卡候选 42 张如实挂账 README)+S15 meta 三件(图鉴/成就 40/每日,
meta-core.js+meta.test)+S16 事件 45;存档 v2 双域+v1 迁移链(19 断言);home 局外模式机+终局结算。
门证据:八门全绿(battle-core/logic-smoke/run-core+R15 职业进阶 7 断言/run-smoke/save v2 19 断言/meta/
static-screen/scene-smoke);六带全绿 nightly n=1000/格(甲 A0 58.2%/A5 32.2%/A15 6.9%,乙 55.1%/28.3%/5.7%,
带 45–60/25–40/5–15);构筑指纹去重 A0 六格全 100%(门>85% 草案);金标 26 套(甲 20 漂移≤3.6pp 全 PASS+乙 6 首锁);
**全矩阵 96,000 局书面口径首次真跑=158.6s、nightly 总 223.2s**;平衡轮主杠杆=职业起始遗物 +36.6pp;
主会话真跑复验八门+PR 层。浏览器像素复验随本笔 push 后触发。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-06 20:40:11 -07:00
parent 86df18c4d8
commit 0fc1cb695b
27 changed files with 2836 additions and 347 deletions

View File

@ -1,99 +1,106 @@
# 《山海巡异录》 —— 北极星顶级线③ · M2(构筑与仿真)
# 《山海巡异录》 —— 北极星顶级线③ · M3(内容完备:双职业+进阶+meta)
卡牌构筑肉鸽(尖塔骨架 × Balatro 式乘法爽感,原创轴 = 五行相生序列链)。规格 SoT =
[开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-卡牌构筑-开发设计书.md)(创始人 2026-07-06 已批,
定名《山海巡异录》,统一山海 IP 宇宙);本目录只承载实现,规则数值一律以 `src/data/tables.js` 表为准、代码零魔法数。
## M2 范围(书 §12:职业甲全内容+商店删卡+存档续玩+数值 CI)
## M3 范围(书 §12:双职业+进阶 A0A15+图鉴成就+每日+事件)
M1 单局闭环之上,这一档把「内容定容 × 数值 CI」立起来:
M2 的职业甲全内容+数值 CI 之上:
- **职业甲卡池 66 定容**(S9,三批入表、每批过静态快筛+smoke 矩阵):新增状态词缀子集
(敌侧 易伤/虚弱/灼 + 我侧 韧,书 §5.1 ⑥ 十词缀的 M2 子集)与卡词
(血价 hpCost/消耗 exhaust/保留 retain/回血 heal/灼联动 dmgPerEnemyBurn/chainBonus{dmg,block});
估值全走 `src/sim/valuation.js` 单源公式(锚=表 VALUATION,advisory)。
虚弱×意图诚实:展示与执行共用 effectiveIntent() 同一折算——任意时刻读到的=此刻结算即发生的。
- **遗物 40 定容**(S10,含职业甲专属 6;数值级 6/40=15%,规则级 85%):hook 词汇 20+
(能量上限/首回合面/链起环/每环倍率加成/周天联动/反伤/五行首打系列/对位开战词缀/商店与奖励规则件),
聚合=数值累加/布尔 or(同 hook 多遗物自然叠加);发放=受控随机抽池;获取源四层=精英必掉/宝匣/
**Boss 战后三选一**(幕1/2,relicReward 屏)/商店。
- **商店与删卡经济**(S11):每幕保底 1 商店节点(map-core 配额自检);货架=卡 5(稀有度权重标价)+
遗物 2(未持有池)+删卡服务(基价 50、每删全局 +20——§3.2 给组合爆炸收租);卡组下限 10;
金不足/已售/下限一律拒且零副作用。
- **run 存档续玩**(S11):形状=契约 `SaveEnvelope`(`{schemaVersion≥1, data 纯对象}`,
`contracts/sdk-interface.d.ts`;save: 族键 65536 码元上限,W-SAVE-CAP 已入库)。快照点=战斗外任意屏
(map/reward/relicReward/shop/shopRemove;battle 屏无快照,中断回上一节点);恢复=rngState 精确续
(续打与不中断逐字段一致,save.test V3 实证)。**介质=影子**(localStorage 直写,`src/save.js` 文件头
申报):信封契约合规,正式 SDK Storage 通道切换随集成段(书 §12④,M5 门=切正式+迁移断言)。
- **快进仿真外壳**(S8b v0 → S12 v1,`scripts/sim-shell.mjs` + `src/sim/`):矩阵扫描(职业×进阶×
三档策略 driver)→格聚合(胜率带/Boss 回合带/采用率带/峰值伤 P99-P50/invalid 指纹/墙钟)→
金标构筑回归(20 套,`scripts/goldens.json`,漂移 ±8pp 按套判)。三档 driver=贪心/随机/挂机
(与 CDP harness 同文件 `tier2/harness/card-battle.driver.js`,贪心档=九门真玩同一大脑)。
- **154 卡定容**(S13,书 §5.2):职业甲 66 + 职业乙「璇玑使」66(链优;身份词 ifZhoutian=周天后附加
效果)+ 中立 22(行旅工具件)。职业池过滤=本职业+中立;起始遗物(平衡轮:甲=开明兽牙/乙=河图残卷)。
- **进阶 A0A15**(S14):16 级修正表(`ASCENSIONS`,累积生效,mods 13 词——敌攻/血乘区、精英配额、
资源面收紧、A15 构筑面收紧〔奖励二选一〕);修正全局含教学首战(R15g 抓的豁免缺口已修)。
- **多样性 driver**(S14,书面三档补齐):链优先 chain / 保血防御 defensive(+M2 贪心/随机/挂机=五档);
死卡口径=书面三档并集(任一档 ≥3% 采用即豁免)。
- **事件 45**(S16,书 §5.2):幕池 12×3+通用 9;fx 七词(金/血/上限/遗物/卡/随机销卡);
事件节点每幕 ≤2(保底件先行、增益件吃余量——96k 全矩阵实抓的配额挤兑修序)。
- **meta 系统**(S15,最小可玩):`src/meta-core.js`(书 §8.3 第四纯逻辑模块)——图鉴(卡/遗物/敌兽,
见过即录)、成就 40(声明式条件+解释器,词表对账单测钉死)、进阶解锁(通关 An 启 An+1 逐职业)、
每日一局(日期派生种子+本地最佳分,榜单走平台既有能力);局外三屏(home 选职业进阶/图鉴/成就)。
- **存档信封 v2**(S15):data={run, meta} 双域——终局 run 域清空、meta 常存;**v1→v2 迁移链首用**
(M2 旧档=平铺快照→迁入 run 域),M5「切正式通道+迁移断言」的先手演练;meta 域独立 fail-closed
(局外进度损坏不吞续玩)。介质仍=影子(申报见 `src/save.js` 文件头),正式通道随集成段(M5 门)。
## 数值 CI 分层(书 §6.4 M2 出口定案;墙钟=本机 darwin/arm64 实测 2026-07-06)
## 战斗绘制修复(2026-07-06 浏览器复验缺陷,M2 后修入)
**根因**:`run-scene.js``const txt = (…) => …` 声明位于工厂 return 之后——该行永不执行,`txt` 绑定
永陷 TDZ,首次 rebuild 即抛 ReferenceError;整屏绘制(地图/战斗/商店)全部无声失败,只有不依赖 txt 的
事件浮字(function 声明提升)可见——与 CDP 复验「横幅之外全空(lumStd=0)」逐点吻合。M1 并入 run-scene
时引入;headless 门不建场景(ctx.game=null),场景代码零执行,只有真浏览器暴露。
**修复**:`txt` 改 function 声明(提升语义与 clear/keep 一致)+事故注释钉在原地。
**防回潮**:①新门 `scripts/scene-smoke.mjs`(记录型 mock 场景重放:七屏驱动/异常零容忍/每屏绘制计数
≥5/自证钩子断言);②自证钩子——场景 rebuild 后回写 `readState().debug={displayCount,rebuilds,screen}`,
CDP 复验据此机断「画了东西」,不再仅赖像素统计。浏览器像素复验=mini-desktop 窗口批(主会话触发)。
## 数值 CI 分层(书 §6.4;墙钟=本机 darwin/arm64 实测 2026-07-06)
| 层 | 命令 | 内容 | 实测墙钟 |
|---|---|---|---|
| **PR smoke** | `node scripts/static-screen.mjs && node scripts/sim-shell.mjs --tier=pr` | 快筛(链护栏①层+估值黄牌)+ 贪心×200 + 金标抽测 7 套×200 | **≈3.0s** |
| **批产冒烟** | `node scripts/sim-shell.mjs --tier=batch` | 三档×200(S9/S10 每批入表即跑) | ≈0.5s |
| **nightly 全矩阵** | `node scripts/sim-shell.mjs --tier=nightly` | 三档×1000(3,000 局)+ 金标全 20 套×1000(20,000 局) | **≈36s** |
| **PR smoke** | `node scripts/static-screen.mjs && node scripts/sim-shell.mjs --tier=pr` | 快筛(154 卡 367 万序列)+ 2 职业×A0/A5/A15×贪心×200(书面小矩阵)+金标抽测 | ≈10s |
| **批产冒烟** | `node scripts/sim-shell.mjs --tier=batch` | 2 职业 × A0 × 五档 × 200 | ≈4s |
| **nightly 全矩阵** | `node scripts/sim-shell.mjs --tier=nightly` | **2 职业 × A0A15 × 书面三档 × 1000 = 96,000 局**(书面口径)+下界锚 4,000+金标 26 套×1000 | **总 223.2s**(矩阵 158.6s+金标 64.6s,实测) |
M2 矩阵=职业甲×A0(进阶 A1A15 随 M3 S14 入轴,外壳轴形状已留);书面 96,000 局全矩阵
(2 职业×16 层×3 档×1000)按本机 1.7ms/局线性外推 ≈ **2.7 分钟单线程**〔外推,M3 实测定案〕。
退出码语义:带判越带只报警(M0M2 硬门仅 L1 真玩门);结构性失败(stuck/金标漂移超线)才非零。
金标基线口径(M3 定案,工单点名):池扩张/职业/进阶入轴是预期漂移源,原则=基线随内容里程碑重锁;
**M3 实测结果好于预期——甲 20 套在 154 池+事件+起始遗物扩张下漂移 ≤3.6pp 全 PASS,基线连续未动**
(金标注入=已成型构筑,opts.relics 覆盖起始遗物,扩张影响被强构筑掩盖);乙 6 套 M3 首锁(n=1000)。
漂移门 ±8pp 管里程碑内数值微调;满值套(100%)漂移只能单向向下,如实观测。
## 判据带首轮回填(全部〔草案〕,S12 调参轮实测 2026-07-06)
## 判据带回填(S14 平衡轮终值,n=1000/格,全部〔草案〕)
- **A0 贪心档胜率 47.3%(n=1000,σ≈1.6pp)——入带 4560%**。调参轮路径:词缀价值意识 +1.2pp →
构筑当量选卡+薄组纪律+商店序(遗物优先)+19.7pp → 资源面(金币带+15%/战胜回复 6→8)+4.2pp →
Boss HP 回压(122/140/182)+4.5pp;幕缩放 2% 实测零效果已还原。随机 0%/挂机 0%(下界,非校准对象,
未削敌线迁就)。A5/A15 带随 M3 进阶表启用。
- 采用率带:必选 0;死卡候选 14(贪心单策略窄化产物,治理随 M3 多样性 driver——链优先/保血防御档)。
- Boss 回合:P50=8(带 69);磨局 149/2385(6.2%,继续收敛归 M3 平衡轮)。
- 峰值回合伤 P99/P50=1.88(反垄断线 8 内);invalid=0;周天 0.09/局(构筑目标位,M3 观察)。
- 金标 20 套基线已锁 `scripts/goldens.json`(32.5%100%,流派梯度;满值套漂移只能单向向下,如实观测)。
- **六带全绿**:甲 A0 58.3% / A5 32.2% / A15 6.9%;乙 A0 55.1% / A5 28.3% / A15 5.7%
(带:A0 4560 / A5 2540 / A15 515)。
- 平衡轮主杠杆实录:职业起始遗物(乙 16.1→52.7,+36.6pp)>进阶尾段换轴(A15 战斗压力→构筑面收紧,
1.9→5.8)>资源面软化;无效教训=±1% 敌攻在意图值 round 粒度下零效果(微调粒度必须 ≥ 取整步长);
乙灼起手(荧惑守心入起始组)全线 +15pp 冲破上限,撤回——大杠杆不做微调件。
- 构筑指纹去重率(M3 门 >85%〔草案〕):A0 六格全 100%。
- 下界锚:random/idle 双职业 A0 全 0%(敌线致死性保持,未削敌迁就)。
## 怎么跑
```bash
# 全量回归(M0+M1+M2门;交付口径)
# 全量回归(M0+M1+M2+M3 九门;交付口径)
node scripts/battle-core.test.mjs && node scripts/logic-smoke.mjs \
&& node scripts/run-core.test.mjs && node scripts/run-smoke.mjs \
&& node scripts/save.test.mjs && node scripts/static-screen.mjs
# 数值 CI(见上表三层)
node scripts/sim-shell.mjs --tier=pr # 或 nightly / batch / --n=N --strategies=greedy,random,idle
&& node scripts/save.test.mjs && node scripts/meta.test.mjs \
&& node scripts/static-screen.mjs && node scripts/scene-smoke.mjs
# 数值 CI(三层,见上表)
node scripts/sim-shell.mjs --tier=pr # 或 nightly / batch / 自由组格 --classes= --ascensions= --strategies= --n=
# 构建(esbuild 在 game-runtime/node_modules;external phaser)
cd <repo>/game-runtime && node ../tier2/engine/build-phaser.mjs ../tier2/games/shanhai-xunyilu
# 真玩:静态起服后开 index.html(?seed= 注入种子);交互真玩/九门 driven 在 mini-desktop(本机禁 chrome)
# 真玩:静态起服后开 index.html;boot 进 home 屏(选职业/进阶/每日/图鉴/成就);九门 driven 在 mini-desktop
```
## 结构
- `src/battle-core.js` —— 回合战斗解算核(纯函数;M2 增列:状态词缀/血价/消耗/保留/遗物 hook 20+)
- `src/map-core.js` —— 三幕地图 DAG(M2 增列:商店保底恰 1 入配额自检)
- `src/run-core.js` —— 一局核(M2 增列:商店五 API/Boss 遗物三选一/随机发放/snapshot·restore)
- `src/save.js` —— SaveEnvelope 契约面单源(信封/fail-closed 校验/恢复工厂/容量闸)
- `src/sim/sim-run.js` —— 仿真单局引擎+格聚合(事件流观测:Boss 回合/峰值伤/采用率/指纹)
- `src/sim/valuation.js` —— 卡牌估值单源(T1e 与 static-screen 共用,防公式漂移)
- `src/ui/layout.js` —— 布局与可点靶唯一坐标事实(M2 增列:shop/shopRemove/relicReward 三屏靶)
- `src/scenes/run-scene.js` —— 一局场景(M2:七视图;新事件演出:词缀/灼跳/反噬/购删)
- `src/data/tables.js` —— 66 卡/8 敌/40 遗物/曲线/商店经济/SAVE/SIM_BANDS/VALUATION(schemaVersion=1)
- `scripts/` —— 六门+sim-shell+goldens.json(node 直跑,无浏览器)
- `play-spec.json` —— 九门 driven 接线(driver.type=card-battle;CDP driver=`tier2/harness/card-battle.driver.js`)
- `src/battle-core.js` —— 回合战斗解算核(M3 增列:ifZhoutian 身份词/enemyStartStrength)
- `src/map-core.js` —— 三幕地图 DAG(M3:事件节点+A1 追加精英;保底件先行/增益件吃余量的配额次序)
- `src/run-core.js` —— 一局核(M3:职业/进阶消费、事件屏、周天计数;快照含 heroClass/ascension/事件面)
- `src/meta-core.js` —— 局外核(M3 新增:图鉴/成就解释器/进阶解锁/每日)
- `src/save.js` —— SaveEnvelope v2 双域(v1 迁移链/meta 独立 fail-closed/容量闸)
- `src/main.js` —— 工厂(M3:home 局外模式机/终局结算恰一次/自证钩子 debug)
- `src/sim/sim-run.js` + `src/sim/valuation.js` —— 仿真单局引擎(五档/三轴/胜局指纹)+估值单源
- `src/ui/layout.js` —— 布局与可点靶唯一坐标事实(M3:event 屏/home 局外屏)
- `src/scenes/run-scene.js` —— 场景(M3:十视图=七局内+home/图鉴/成就;TDZ 事故注释与自证回写)
- `src/data/tables.js` —— 154 卡/8 敌/40 遗物/45 事件/16 进阶/40 成就/曲线经济(schemaVersion=1)
- `scripts/` —— 九门+sim-shell+goldens.json(26 套)
- `play-spec.json` —— 九门 driven 接线(CDP driver=`tier2/harness/card-battle.driver.js`,五档策略同文件)
## M2 门与实测(2026-07-06 本机;浏览器面=窗口批)
## M3 门与实测(2026-07-06 本机;浏览器面=窗口批)
- 六门全绿:battle-core(M0 40 断言+T11 词缀 18 断言)/logic-smoke/run-core(M1 12 组+R13 商店
+R14 金标注入)/run-smoke(S0S5)/save.test(V1V7 13 断言:往返+续玩确定性+坏版本 9 负例全拒
+容量峰值 2148 码元余量 97%+battle 屏拒+三屏往返)/static-screen(66 卡 291,918 序列零经济环命中)
- 链护栏三层随扩池持续生效:①静态快筛每批入表即跑(E0 经济词汇封闭断言钉死绕过面)②运行时动作上限
200(千局 invalid=0,未误触)③分布反垄断 P99/P50=1.88+invalid 构筑指纹落聚合报告
- nightly 全矩阵+金标总墙钟 **36.2s**(扫描 2.2s/3,000 局+金标 34s/20,000 局;1.7ms/局)
- 九门全绿:battle-core(M0 40+T11 词缀 18)/logic-smoke/run-core(M1 12 组+R13 商店+R14 注入
+R15 职业进阶 7)/run-smoke/save.test(v2 双域 19 断言:迁移链真通+meta 独立+乙/A3 续玩确定性)
/meta.test(局外核 6 组)/static-screen(154 卡 367 万序列零经济环)/scene-smoke(渲染层防回潮)
- 全矩阵 96,000 局实测 ≈110s(书面「≈2.7 分钟单线程」外推兑现);1.15ms/局均值
- 链护栏三层持续生效:快筛(E0 词汇封闭含 ifZhoutian)/动作上限(96k 局 invalid=0)/指纹观测
## 已知边界(如实)
- 浏览器面(画面可见/真指针/九门 driven/CDP 三门/新三屏视觉)本机不可验,排 mini-desktop 窗口批
- 存档为影子介质(申报见上);正式通道+迁移断言=M5 门
- 死卡治理与 A5/A15 带、多样性 driver(链优先/保血防御)、进阶轴入矩阵=M3
- engineBundle/bundleSize 契约口径对 Phaser external 形态不适配:M0 已在册,待契约侧,本工程不自改
- 浏览器面(home 三屏/事件屏视觉/真指针/九门 driven/绘制修复像素复验)本机不可验,排 mini-desktop 窗口批
- 存档为影子介质;正式通道+迁移断言=M5 门(v1→v2 迁移链已先手演练)
- 死卡候选(书面三档并集,千局口径)≈40 张量级——154 池下贪心系策略的窄化仍在,流派 payoff 卡
(ifZhoutian/灼联动/血价)贪心不识;治理=M4 策略档深化或 M5 前卡值回炉,如实挂账
- 每日一局=本地最佳分(榜单走平台既有能力,不新建后端);成就页仅前 12 条(完整滚动容器=M4 工艺)
- engineBundle/bundleSize 契约口径对 Phaser external 形态不适配:M0 已在册,待契约侧
- 富三门 archetype 未接:卡牌品类门按书 §6.5 走验收门品类适配单另出

View File

@ -0,0 +1,82 @@
{
"textures": [
{
"image": "beasts-act1.webp",
"format": "RGBA8888",
"size": {
"w": 875,
"h": 943
},
"scale": 1,
"frames": [
{
"filename": "bo-ma",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 419,
"h": 512
},
"spriteSourceSize": {
"x": 2,
"y": 2,
"w": 415,
"h": 508
},
"frame": {
"x": 3,
"y": 3,
"w": 415,
"h": 508
}
},
{
"filename": "kuze-jiao",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 448,
"h": 445
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 448,
"h": 442
},
"frame": {
"x": 424,
"y": 3,
"w": 448,
"h": 442
}
},
{
"filename": "zheng",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 448,
"h": 427
},
"spriteSourceSize": {
"x": 2,
"y": 2,
"w": 444,
"h": 423
},
"frame": {
"x": 3,
"y": 517,
"w": 444,
"h": 423
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@ -0,0 +1,124 @@
#!/usr/bin/env node
// scripts/asset-gen.mjs —— 《山海巡异录》资产一期 · mmx 生图批跑(批次一 gen-batch.mjs 迁移版)
// owner:美术批产执行席(2026-07-06)
// 用法:
// node scripts/asset-gen.mjs --group cards # 按组批跑(cards|beasts|ui|relics|bg)
// node scripts/asset-gen.mjs --only card-mu-qingteng --seed-bump 100000 --force # 定向重摇
// node scripts/asset-gen.mjs --only ui-frame-epic --subject-extra ",定向补词" --force
// 纪律(与批次一同构):
// - prompt = promptPrefix + 主体 + styleWordsShared + perGame(xunyilu, sprite|bleed) + 工艺后缀,拼接即所发原样入账;
// - 每次外呼 150s 超时,失败退避重试 ≤3(内容过滤 exit=10 不重试,配额 exit=4 全批中止);
// - 逐呼追加台账(成败都记):docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl;
// - 串行 + 1s 间隔防限流;
// - 成本硬闸:台账累计外呼 × ¥0.033(仓内实测口径)≥ ¥195 时中止报告(工单红线 ¥200)。
// 原图落 --raw-dir(默认系统临时目录;定稿缩尺版与图集由 asset-pack.mjs 产出进仓,原图凭台账 seed 可复现)。
import { readFileSync, existsSync, mkdirSync, renameSync, appendFileSync, readdirSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join, resolve, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SHOTS } from './asset-shots.mjs';
const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..');
const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一');
const ANCHOR_PATH = join(BATCH1, 'anchor-pack.json'); // 锚 r1 单源引用,不复制不改
const MANIFEST = join(BATCH1, 'manifest-巡异录资产一期.jsonl'); // 台账落批次一目录(工单要求)
const COST_PER_CALL_RMB = 0.033; // 仓内实测口径(批次一台账)
const COST_CAP_RMB = 195; // 硬闸(工单红线 ¥200,留安全边)
// ---- 参数解析 ----
const args = process.argv.slice(2);
const getArg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
const onlyIds = getArg('--only')?.split(',').map((s) => s.trim()).filter(Boolean);
const onlyGroup = getArg('--group');
const seedBump = Number(getArg('--seed-bump') || 0);
const subjectExtra = getArg('--subject-extra') || '';
const force = args.includes('--force');
const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw'));
const anchor = JSON.parse(readFileSync(ANCHOR_PATH, 'utf8'));
const TMP = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-gen');
mkdirSync(TMP, { recursive: true });
// prompt 拼装(锚 r1 口径;本游戏固定取 perGame.xunyilu)
function buildPrompt(shot) {
const kind = shot.type === 'sprite' ? 'sprite' : 'bleed';
const per = anchor.perGame.xunyilu[kind];
const suffix = shot.type === 'sprite' ? anchor.spriteSuffix : anchor.fullBleedSuffix;
return `${anchor.promptPrefix}${shot.subject}${subjectExtra}${anchor.styleWordsShared}${per}${suffix}`;
}
function ledger(entry) {
appendFileSync(MANIFEST, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
}
/** 台账累计外呼数(成本硬闸的分母;台账不存在=0)。 */
function ledgerCalls() {
if (!existsSync(MANIFEST)) return 0;
return readFileSync(MANIFEST, 'utf8').split('\n').filter((l) => l.includes('"gen-call"')).length;
}
function callMmx(shot, prompt, seedUsed) {
for (const f of readdirSync(TMP)) if (f.startsWith(shot.id)) rmSync(join(TMP, f));
const t0 = Date.now();
const r = spawnSync('mmx', [
'image', 'generate',
'--prompt', prompt,
'--width', String(shot.w), '--height', String(shot.h),
'--seed', String(seedUsed),
'--out-dir', TMP, '--out-prefix', shot.id,
'--quiet', '--non-interactive',
], { timeout: 150_000, encoding: 'utf8' });
const ms = Date.now() - t0;
if (r.error?.code === 'ETIMEDOUT' || r.signal) return { ok: false, error: 'timeout-150s', ms, exit: 5 };
if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || '').trim().slice(0, 300) || `exit=${r.status}`, ms, exit: r.status ?? 1 };
const saved = (r.stdout || '').split('\n').map((s) => s.trim()).find((s) => s && existsSync(s))
|| readdirSync(TMP).filter((f) => f.startsWith(shot.id)).map((f) => join(TMP, f))[0];
if (!saved) return { ok: false, error: 'exit=0 但未找到产物文件', ms, exit: 1 };
return { ok: true, file: saved, ms, exit: 0 };
}
// ---- 主循环:串行 ----
const todo = SHOTS.filter((s) => (!onlyIds || onlyIds.includes(s.id)) && (!onlyGroup || s.group === onlyGroup));
if (!todo.length) { console.error('无匹配镜头(--group cards|beasts|ui|relics|bg 或 --only id)'); process.exit(2); }
let done = 0, skipped = 0, failed = 0, calls = 0;
let totalCalls = ledgerCalls();
for (const shot of todo) {
// 成本硬闸:先查后呼
if ((totalCalls + 1) * COST_PER_CALL_RMB >= COST_CAP_RMB) {
console.error(`成本硬闸:台账累计外呼 ${totalCalls},再呼将超 ¥${COST_CAP_RMB},中止。`);
process.exit(9);
}
const outDir = join(RAW_DIR, shot.group);
mkdirSync(outDir, { recursive: true });
const existing = readdirSync(outDir).find((f) => f.startsWith(shot.id + '.'));
if (existing && !force) { skipped++; continue; }
const prompt = buildPrompt(shot);
const seedUsed = shot.seed + seedBump;
let result = null;
for (let attempt = 1; attempt <= 3; attempt++) {
result = callMmx(shot, prompt, seedUsed);
calls++; totalCalls++;
ledger({ kind: 'gen-call', id: shot.id, group: shot.group, type: shot.type, w: shot.w, h: shot.h,
seed: seedUsed, seedBump, attempt, model: 'image-01', prompt,
status: result.ok ? 'ok' : 'fail', error: result.error, durationMs: result.ms });
if (result.ok) break;
if (result.exit === 10) { console.error(`[${shot.id}] 内容过滤(exit=10),不重试`); break; }
if (result.exit === 4) { console.error(`[${shot.id}] 配额超限(exit=4),全批中止`); process.exit(4); }
if (attempt < 3) { const wait = attempt * 5000; console.error(`[${shot.id}] 第${attempt}次失败(${result.error}),${wait / 1000}s 后重试`); spawnSync('sleep', [String(wait / 1000)]); }
}
if (result.ok) {
const ext = extname(result.file) || '.png';
const dest = join(outDir, shot.id + ext.toLowerCase());
renameSync(result.file, dest);
done++;
console.log(`[${shot.id}] OK ${Math.round(result.ms / 1000)}s -> ${dest}`);
} else {
failed++;
console.error(`[${shot.id}] FAILED: ${result.error}`);
}
spawnSync('sleep', ['1']);
}
console.log(`\n批跑完成:成 ${done} / 跳过 ${skipped} / 败 ${failed};本次外呼 ${calls} 次;台账累计 ${totalCalls} 次 ≈ ¥${(totalCalls * COST_PER_CALL_RMB).toFixed(2)}(仓内实测口径)`);
process.exit(failed > 0 ? 1 : 0);

View File

@ -0,0 +1,269 @@
#!/usr/bin/env node
// scripts/asset-pack.mjs —— 《山海巡异录》资产一期 · 产线打包(抠图→缩尺→图集→manifest→机检)
// owner:美术批产执行席(2026-07-06;批次一 key-out.py / atlas-pack.mjs 迁移整合版)
// 用法:
// node scripts/asset-pack.mjs --raw-dir <生图原图目录> # 全量打包
// node scripts/asset-pack.mjs --raw-dir <目录> --group cards # 单组
// 产物:
// assets/atlas/<atlas>-N.webp + .json 图集(Phaser3 坐标,webp q80;卡插画独立分包 cards-jia)
// assets/bg/<id>.webp 满幅大图(场景/结算,批次一复用件标 source)
// assets/manifest.json 资产清单 + 字节账(账A 输入)
// docs-design/山海风格版-批次一/images-巡异录资产一期/<group>/ 定稿缩尺评审版(webp)
// 机检(每组):id 齐套 / 精灵透明底占比 / 尺寸合规 / 图集帧数对账;结果打印并追加台账(kind=qa-pack)。
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SHOTS, REUSE, CARD_IDS, RELIC_IDS } from './asset-shots.mjs';
const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..');
const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一');
const REVIEW_DIR = join(BATCH1, 'images-巡异录资产一期');
const MANIFEST_LEDGER = join(BATCH1, 'manifest-巡异录资产一期.jsonl');
const ASSETS = join(GAME_ROOT, 'assets');
const ATLAS_DIR = join(ASSETS, 'atlas');
const BG_DIR = join(ASSETS, 'bg');
const args = process.argv.slice(2);
const getArg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw'));
const onlyGroup = getArg('--group');
const WORK = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-pack');
const STAGE = join(WORK, 'stage'); // 按 atlas 分目录的定稿 png(图集输入)
const KEYED = join(WORK, 'keyed'); // 抠图中间产物
for (const d of [WORK, STAGE, KEYED, ATLAS_DIR, BG_DIR, REVIEW_DIR]) mkdirSync(d, { recursive: true });
const findRaw = (group, id) => {
const dir = join(RAW_DIR, group);
if (!existsSync(dir)) return null;
const f = readdirSync(dir).find((x) => x.startsWith(id + '.'));
return f ? join(dir, f) : null;
};
// ── 批量图像操作:一次 python 进程吃一张作业单(缩尺/转码;PIL LANCZOS)──
function runImageOps(ops, label) {
if (!ops.length) return;
const jobFile = join(WORK, `ops-${label}.json`);
writeFileSync(jobFile, JSON.stringify(ops));
const py = `
import json, sys, os
from PIL import Image
ops = json.load(open(sys.argv[1]))
for op in ops:
im = Image.open(op['src'])
if op['mode'] == 'cover': # 等比放缩后居中裁到目标框(满幅件)
sw, sh = im.size; tw, th = op['w'], op['h']
s = max(tw / sw, th / sh)
im = im.resize((max(1, round(sw * s)), max(1, round(sh * s))), Image.LANCZOS)
l, t = (im.width - tw) // 2, (im.height - th) // 2
im = im.crop((l, t, l + tw, t + th))
elif op['mode'] == 'contain': # 最长边限界(透明底精灵,保比例)
im.thumbnail((op['box'], op['box']), Image.LANCZOS)
os.makedirs(os.path.dirname(op['dst']), exist_ok=True)
if op['dst'].endswith('.webp'):
im.save(op['dst'], quality=80, method=6)
else:
im.save(op['dst'])
`;
execFileSync('python3', ['-c', py, jobFile], { stdio: 'inherit', timeout: 300_000 });
}
// ── 抠图:批次一 key-out.py 原脚本复用(色键+连通域除杂,印章尘点自动清)──
function keyOut(files, outDir) {
if (!files.length) return;
mkdirSync(outDir, { recursive: true });
execFileSync('python3', [join(BATCH1, 'scripts', 'key-out.py'), ...files, '--out', outDir], { stdio: 'inherit', timeout: 600_000 });
}
// ── 透明底机检:alpha 通道存在且全透明像素占比在合理带(防抠图失效/全空)──
function alphaCheck(pngPaths) {
if (!pngPaths.length) return [];
const jobFile = join(WORK, 'alpha-check.json');
writeFileSync(jobFile, JSON.stringify(pngPaths));
const py = `
import json, sys
import numpy as np
from PIL import Image
bad = []
for p in json.load(open(sys.argv[1])):
im = Image.open(p)
if im.mode != 'RGBA':
bad.append({'file': p, 'why': 'no-alpha'}); continue
a = np.asarray(im)[:, :, 3]
frac = float((a == 0).mean())
if frac < 0.03: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过低(疑似未抠净)'})
if frac > 0.98: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过高(疑似全空)'})
print(json.dumps(bad))
`;
const r = execFileSync('python3', ['-c', py, jobFile], { encoding: 'utf8', timeout: 300_000 });
return JSON.parse(r.trim() || '[]');
}
// ── 图集:free-tex-packer-cli(schema 同批次一)→ png 页 → webp q80 → JSON 改指 webp ──
function packAtlas(atlasName, stageDir, pageSize) {
const pngs = readdirSync(stageDir).filter((f) => f.endsWith('.png'));
if (!pngs.length) return null;
const outTmp = join(WORK, 'atlas-out', atlasName);
rmSync(outTmp, { recursive: true, force: true });
mkdirSync(outTmp, { recursive: true });
const project = {
savePath: outTmp,
images: pngs.map((f) => ({ name: f, path: join(stageDir, f) })),
folders: [],
packOptions: {
textureName: atlasName,
width: pageSize, height: pageSize, fixedSize: false, powerOfTwo: false,
padding: 2, extrude: 1, allowRotation: false, detectIdentical: true,
allowTrim: true, trimMode: 'trim', alphaThreshold: 0,
exporter: 'Phaser3', removeFileExtension: true, prependFolderName: false,
textureFormat: 'png', base64Export: false, scale: 1, packer: 'MaxRectsPacker',
},
};
const proj = join(outTmp, `${atlasName}.ftpp`);
writeFileSync(proj, JSON.stringify(project, null, 2));
execFileSync('free-tex-packer-cli', ['--project', proj, '--output', outTmp], { stdio: 'inherit', timeout: 300_000 });
const pages = [];
for (const png of readdirSync(outTmp).filter((f) => f.startsWith(atlasName) && f.endsWith('.png'))) {
const base = png.replace(/\.png$/, '');
const jsonPath = join(outTmp, `${base}.json`);
const webpDst = join(ATLAS_DIR, `${base}.webp`);
runImageOps([{ src: join(outTmp, png), dst: webpDst, mode: 'contain', box: pageSize }], `webp-${base}`);
const j = JSON.parse(readFileSync(jsonPath, 'utf8'));
for (const t of j.textures || []) if (t.image) t.image = t.image.replace(/\.png$/, '.webp');
const jsonDst = join(ATLAS_DIR, `${base}.json`);
writeFileSync(jsonDst, JSON.stringify(j, null, 2));
const frames = (j.textures || []).reduce((s, t) => s + (t.frames?.length || 0), 0);
pages.push({ image: `atlas/${base}.webp`, json: `atlas/${base}.json`, bytes: statSync(webpDst).size, frames });
}
return pages;
}
const kb = (n) => `${(n / 1024).toFixed(0)}KB`;
const problems = [];
const qa = { missingRaw: [], alphaBad: [], counts: {}, bytes: {} };
// ═══ 1. 分组落 stage ═══
const groups = ['cards', 'beasts', 'ui', 'relics', 'bg'].filter((g) => !onlyGroup || g === onlyGroup);
// cards / ui:bleed,cover 缩尺直接进 stage(frame 名 = 去前缀 id)
for (const g of groups.filter((x) => x === 'cards' || x === 'ui')) {
const ops = [];
for (const s of SHOTS.filter((x) => x.group === g)) {
const raw = findRaw(g, s.id);
if (!raw) { qa.missingRaw.push(s.id); continue; }
ops.push({ src: raw, dst: join(STAGE, s.atlas, `${s.frame}.png`), mode: 'cover', w: s.final.w, h: s.final.h });
ops.push({ src: raw, dst: join(REVIEW_DIR, g, `${s.frame}.webp`), mode: 'cover', w: s.final.w, h: s.final.h });
}
runImageOps(ops, g);
}
// beasts / relics:sprite,先抠图再 contain 缩尺
for (const g of groups.filter((x) => x === 'beasts' || x === 'relics')) {
const shots = SHOTS.filter((x) => x.group === g);
const toKey = [];
const meta = []; // {keyedPng, atlas, frame, box, group}
for (const s of shots) {
const raw = findRaw(g, s.id);
if (!raw) { qa.missingRaw.push(s.id); continue; }
toKey.push(raw);
meta.push({ keyed: join(KEYED, g, `${s.id}.png`), atlas: s.atlas, frame: s.frame, box: s.final.box });
}
if (g === 'beasts') {
for (const r of REUSE.beasts) { // 批次一定稿复用(创始人已眼验)
toKey.push(join(BATCH1, r.src));
const base = r.src.split('/').pop().replace(/\.[a-z]+$/, '');
meta.push({ keyed: join(KEYED, g, `${base}.png`), atlas: r.atlas, frame: r.id, box: r.box });
}
}
keyOut(toKey, join(KEYED, g));
const bad = alphaCheck(meta.map((m) => m.keyed).filter((p) => existsSync(p)));
qa.alphaBad.push(...bad);
const ops = [];
for (const m of meta) {
if (!existsSync(m.keyed)) { problems.push(`${g}:抠图无产物 ${m.keyed}`); continue; }
ops.push({ src: m.keyed, dst: join(STAGE, m.atlas, `${m.frame}.png`), mode: 'contain', box: m.box });
ops.push({ src: m.keyed, dst: join(REVIEW_DIR, g, `${m.frame}.webp`), mode: 'contain', box: m.box });
}
runImageOps(ops, g);
}
// bg:新产 2 + 批次一复用 4,webp q80 原尺进 assets/bg
const bgFiles = [];
if (groups.includes('bg')) {
const ops = [];
for (const s of SHOTS.filter((x) => x.group === 'bg')) {
const raw = findRaw('bg', s.id);
if (!raw) { qa.missingRaw.push(s.id); continue; }
ops.push({ src: raw, dst: join(BG_DIR, `${s.id}.webp`), mode: 'cover', w: s.final.w, h: s.final.h });
bgFiles.push({ id: s.id, source: '资产一期新产' });
}
for (const r of REUSE.bg) {
ops.push({ src: join(BATCH1, r.src), dst: join(BG_DIR, `${r.id}.webp`), mode: 'cover', w: 768, h: 1360 });
bgFiles.push({ id: r.id, source: '批次一定稿复用' });
}
runImageOps(ops, 'bg');
for (const f of bgFiles) {
const p = join(BG_DIR, `${f.id}.webp`);
if (existsSync(p)) { f.file = `bg/${f.id}.webp`; f.bytes = statSync(p).size; copyFileSync(p, join(REVIEW_DIR, 'bg', `${f.id}.webp`)); }
}
mkdirSync(join(REVIEW_DIR, 'bg'), { recursive: true });
}
// ═══ 2. 打图集 ═══
const PAGE_SIZE = { 'cards-jia': 2048, 'beasts-act1': 2048, 'beasts-act2': 2048, 'beasts-act3': 2048, relics: 1024, ui: 2048 };
const atlases = {};
for (const atlasName of Object.keys(PAGE_SIZE)) {
const g = atlasName.startsWith('cards') ? 'cards' : atlasName.startsWith('beasts') ? 'beasts' : atlasName;
if (!groups.includes(g)) continue;
const stageDir = join(STAGE, atlasName);
if (!existsSync(stageDir)) continue;
const pages = packAtlas(atlasName, stageDir, PAGE_SIZE[atlasName]);
if (pages) atlases[atlasName] = pages;
}
// ═══ 3. 机检对账 ═══
const expect = {
'cards-jia': CARD_IDS.length, // 66
relics: RELIC_IDS.length, // 40
ui: SHOTS.filter((s) => s.group === 'ui').length, // 15
beastsTotal: SHOTS.filter((s) => s.group === 'beasts').length + REUSE.beasts.length, // 39
};
const frameCount = (a) => (atlases[a] || []).reduce((s, p) => s + p.frames, 0);
if (groups.includes('cards') && frameCount('cards-jia') !== expect['cards-jia']) problems.push(`cards-jia 帧数 ${frameCount('cards-jia')}${expect['cards-jia']}`);
if (groups.includes('relics') && frameCount('relics') !== expect.relics) problems.push(`relics 帧数 ${frameCount('relics')}${expect.relics}`);
if (groups.includes('ui') && frameCount('ui') !== expect.ui) problems.push(`ui 帧数 ${frameCount('ui')}${expect.ui}`);
const beastFrames = frameCount('beasts-act1') + frameCount('beasts-act2') + frameCount('beasts-act3');
if (groups.includes('beasts') && beastFrames !== expect.beastsTotal) problems.push(`beasts 帧数 ${beastFrames}${expect.beastsTotal}`);
if (qa.missingRaw.length) problems.push(`缺原图 ${qa.missingRaw.length}:${qa.missingRaw.join(',')}`);
for (const b of qa.alphaBad) problems.push(`透明底异常 ${b.file}(${b.why})`);
// ═══ 4. manifest + 字节账 ═══
const byGroup = {};
for (const [name, pages] of Object.entries(atlases)) byGroup[name] = pages.reduce((s, p) => s + p.bytes, 0);
byGroup.bg = bgFiles.reduce((s, f) => s + (f.bytes || 0), 0);
const totalBytes = Object.values(byGroup).reduce((s, n) => s + n, 0);
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
anchor: 'docs-design/山海风格版-批次一/anchor-pack.json(风格锚 v1 + 工艺修订 r1,单源)',
ledger: 'docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl',
snapshotNote: '卡/遗物/敌帧名对齐 2026-07-06 tables.js 快照(66 卡/40 遗物/8 敌)+ 兽谱-增补-巡异录.md 31 兽;职业乙插画位未产,等 M3 表落地下一批补齐',
atlases,
bg: bgFiles,
totals: { webpBytes: totalBytes, byGroup },
};
if (!onlyGroup) writeFileSync(join(ASSETS, 'manifest.json'), JSON.stringify(manifest, null, 2));
appendFileSync(MANIFEST_LEDGER, JSON.stringify({ ts: new Date().toISOString(), kind: 'qa-pack', groups, problems, byGroup, totalBytes }) + '\n');
console.log('\n═══ 打包结果 ═══');
for (const [name, pages] of Object.entries(atlases)) {
console.log(`${name}:${pages.length} 页 / ${pages.reduce((s, p) => s + p.frames, 0)} 帧 / ${kb(byGroup[name])}`);
}
if (bgFiles.length) console.log(`bg:${bgFiles.length} 张 / ${kb(byGroup.bg)}`);
console.log(`合计 webp:${kb(totalBytes)}(${(totalBytes / 1048576).toFixed(2)}MB)`);
if (problems.length) { console.error(`\n机检 ${problems.length} 项异常:`); for (const p of problems) console.error(' - ' + p); process.exit(1); }
console.log('机检:全部通过');

View File

@ -0,0 +1,329 @@
// scripts/asset-shots.mjs —— 《山海巡异录》资产一期批产 · 镜头单(数据件)
// owner:美术批产执行席(2026-07-06,工单:资产一期,锚 = docs-design/山海风格版-批次一/anchor-pack.json r1)
//
// 【快照纪律】M3 开发席平行在飞会动 tables.js(加职业乙卡)。本批以开工时读到的表为准,
// 卡/遗物/敌 id 在此**硬编码快照**、不运行时 import tables.js —— 防批产中途表漂移;
// 职业乙插画位命名等 M3 表落地后下一批补齐,本批不产。
// 【谱面纪律】敌兽主体词一律从兽谱出发(首批 = 兽谱首批.md;增补 = 兽谱-增补-巡异录.md),
// 兽名不入 prompt(批次一工艺事实 4:名词强 prime 错形),只用形态语言。
// 【clean-room】主体词禁现代作品名与其专有名词;古籍意象 + 矿彩锚原创演绎。
'use strict';
/** 快照时间与来源(审计锚)。 */
export const SNAPSHOT_AT = '2026-07-06 tables.js(M2 66卡/8敌/40遗物 定容态)';
/** 职业甲 66 卡 id 快照(tables.js CARDS 原序;帧名 = 卡 id)。 */
export const CARD_IDS = [
'mu-qingteng', 'mu-shengfa', 'mu-chanzhi', 'mu-cangyin',
'huo-lihuo', 'huo-liaoyuan', 'huo-chiyu', 'huo-liming',
'tu-houtu', 'tu-zhenyue', 'tu-xirang', 'tu-chengtian',
'jin-jinfeng', 'jin-ruijin', 'jin-baihong', 'jin-gengjin',
'shui-xuanshui', 'shui-runxia', 'shui-canglang', 'shui-guixu',
'mu-canglin', 'mu-jiamu', 'huo-bifang', 'huo-fenglin',
'tu-kunlun', 'tu-houde', 'jin-baize', 'jin-tianwu',
'shui-tianchi', 'shui-xuangui',
'mu-huichun', 'mu-panmu', 'huo-fenxin', 'huo-yanbao', 'huo-zhurong',
'tu-panshi', 'tu-hancheng', 'jin-podan', 'jin-cuijin', 'jin-qiongqi',
'shui-hanyuan', 'shui-lixue',
'mu-cangwu', 'mu-jianmu', 'mu-linggen', 'huo-jinyu', 'huo-jinwu',
'tu-leitu', 'tu-taowu', 'tu-xiyan', 'jin-kunwu', 'jin-baijin',
'shui-ruoshui', 'shui-guixuan',
'mu-sanglin', 'mu-lingzhang', 'mu-kuafu', 'huo-suiren', 'huo-huolin',
'tu-chengyuan', 'tu-houde2', 'jin-shuojin', 'jin-kunwu2',
'shui-canghai', 'shui-hantan', 'shui-beiming',
];
/** 遗物 40 id 快照(tables.js RELICS 原序;帧名 = 遗物 id)。 */
export const RELIC_IDS = [
'yuheng-fu', 'guixu-jing', 'yinglong-lin',
'kunlun-yugui', 'zhulong-jing', 'hetu-juan', 'luoshu-ye', 'fusang-zhi',
'xihe-pei', 'wangshu-huan', 'kaiming-ya', 'luwu-zhao', 'yingzhao-yu',
'zhengpi-gu', 'jingwei-shi', 'zhurong-guan', 'houtu-yin', 'rushou-yue',
'jumang-bian', 'tengshe-zhu', 'baize-ce', 'qingluan-ling',
'chenghuang-an', 'tianwu-pei', 'shuhai-chi', 'ruomu-deng', 'sanqingniao-yu',
'gonggong-jiao', 'xuanming-zhu', 'xiwangmu-zan', 'taotie-ding', 'jianmu-xin',
'jiuwei-huan', 'yayu-gu',
'jia-zhoutian-lu', 'jia-lianji-fu', 'jia-wuxing-huan', 'jia-zhanshuai',
'jia-ningxue', 'jia-lingxi',
];
/** 已入表 8 敌 id 快照(tables.js ENEMIES;涸泽鲛复用批次一定稿,其余 7 只本批新产)。 */
export const ENEMY_IDS_IN_TABLE = [
'kuze-jiao', 'qingqiu-hu', 'ba-she', 'chi-hou', 'jiuwei-linghu',
'xiang-liu', 'zhu-yin', 'tao-tie',
];
/** 立绘缩尺(最长边,兽谱增补 §三):普通 448 / 精英 512 / Boss 640。 */
const BEAST_BOX = { normal: 448, elite: 512, boss: 640 };
// ─────────────────────────────────────────────────────────────────────────────
// 卡插画 66(bleed 1024² → 256² cover;frame 名 = 卡 id,图集 cards-jia)
// 主体词按卡名/卡效/五行色语写就;稀有卡构图更满,普通卡"构图简洁,主体居中醒目"。
// ─────────────────────────────────────────────────────────────────────────────
const CARD_SUBJECTS = {
// ── 木(石绿/生发)──
'mu-qingteng': '数条石绿青藤自地面破土而出,向上缠绕生长,藤上生叶带刺,构图简洁,主体居中醒目', // 首版「藤鞭横抽」prime 出龙形,去比喻重写(台账 gen-call 留证)
'mu-shengfa': '一枚素面无字的木牌悬空,木牌下方嫩芽新叶层层舒展生长,石绿描金,构图简洁', // 首版「符牌」引出大块伪刻字,改素面
'mu-chanzhi': '缠枝藤蔓在空中结成一只收紧的螺旋牢笼,枝端带刺,石绿底描金枝线',
'mu-cangyin': '一株苍劲古木撑开树冠如巨伞,树荫下浮起一圈护体符光,构图简洁',
'mu-canglin': '一只苍绿鳞甲的兽爪自卷云中探出一抓,爪风卷起数片叶,动势凌厉',
'mu-jiamu': '庭园中一株嘉木开满描金花枝,花瓣飘落化作小符,工整明快',
'mu-huichun': '一滴晶莹露珠自叶尖垂落,落处地面生出一小片新绿,构图简洁,主体居中',
'mu-panmu': '蟠曲老木的粗枝层层盘绕缠卷收紧,树皮皴裂,描金勾边', // 「如巨蟒」易 prime 蛇形,去比喻
'mu-cangwu': '苍梧巨木横出一枝,枝上嫩芽与锋利断枝并生,一枝两意,构图简洁',
'mu-jianmu': '一株通天巨木直贯画面顶天立地,树干缠绕描金符文,云气缭绕',
'mu-linggen': '大地剖面里盘结的描金树根网络,根须发光向四方蔓延',
'mu-sanglin': '一张桑木长弓射出一支缠着桑叶的箭,箭尾曳出石绿光线,构图简洁',
'mu-lingzhang': '一面樟木纹圆盾,盾面生出鲜嫩新叶,泛石绿柔光,构图简洁,主体居中',
'mu-kuafu': '一位持杖巨人的剪影大步奔向天边巨大落日,身后烟尘滚滚,悲壮恢弘',
// ── 火(朱砂/离明)──
'huo-lihuo': '一道朱砂符纸腾起一小簇符文形状的烈焰,构图简洁,主体居中醒目',
'huo-liaoyuan': '三股火舌自一张符纸窜出,在原野上连成一线燎原之火',
'huo-chiyu': '一根燃烧的朱砂色羽毛被掷出,拖出短促焰尾,构图简洁,主体居中',
'huo-liming': '天空降下一柱描金真火贯穿画面,火柱中隐现一轮日纹,恢弘庄重',
'huo-bifang': '一只独脚青羽仙鹤衔着一段燃火的枯枝飞临,鹤形清瘦,火光映羽',
'huo-fenglin': '一条火线在林间奔窜,成排树影次第点燃,朱砂与暗林对比',
'huo-fenxin': '一枚朱砂咒印悬浮,印下一团暗火阴燃不熄,烟气细长,构图简洁',
'huo-yanbao': '一张符纸炸开一团爆焰,火星四溅成环,冲击波纹一圈,主体居中',
'huo-zhurong': '一卷燃烧的古策书展开,页面上的火纹字符腾起化作焰柱,华丽威严',
'huo-jinyu': '一堆灰烬中拨出一点将熄复燃的火星,微光映亮四周,构图极简,主体居中',
'huo-jinwu': '一只三足金色神鸟自巨大日轮中俯冲而下,双翅曳焰,恢弘夺目',
'huo-suiren': '一双苍老的手在木上钻出第一缕火种,火星初迸,神圣庄重',
'huo-huolin': '一只鳞甲燃焰的鹿角独兽踏焰奔驰,蹄下生火,鬃焰飞扬',
// ── 土(赭石/厚载)──
'tu-houtu': '大地隆起一面厚重的夯土石壁,壁面嵌着描金岩纹,构图简洁,主体居中',
'tu-zhenyue': '一座山岳的虚影压在一枚符牌上方,山根透出金光,稳重如镇',
'tu-xirang': '一撮神土在掌心自行堆起一座微型山丘,土粒悬浮,构图简洁',
'tu-chengtian': '一只巨大石掌自地底托起,掌上承着一弧天穹之光,恢弘厚重',
'tu-kunlun': '一枚玉璧自巍峨山影间坠下,玉光如帐罩落大地,工整庄严',
'tu-houde': '一枚土黄大印展开,印面上山川河流的微缩纹样徐徐浮现,华贵厚重',
'tu-panshi': '一块磐石悬浮空中,石面裂纹里透出描金光,构图简洁,主体居中醒目',
'tu-hancheng': '一记岩石巨拳撼动一段城垣状石阵,碎石崩飞,力量感十足',
'tu-leitu': '层层夯土垒起一座小山,每层土中嵌着一枚小符印,构图简洁',
'tu-taowu': '一只虎躯凶兽被镇压在山岳形符狱之下,描金锁链纵横,凶而不得出',
'tu-xiyan': '一撮神土沿符阵纹路自行衍化蔓延,土脊蜿蜒隆起推进,赭石描金', // 「如活物爬行」易 prime 兽形,去比喻
'tu-chengyuan': '符光化出一段夯土城垣横亘画面,垛口整齐,烽色沉稳,构图简洁',
'tu-houde2': '一方石印浮空,印下田畴山川如画卷舒展,土黄描金,工整安稳',
// ── 金(雌黄/锐断)──
'jin-jinfeng': '一道雌黄金色的剑锋之光斜劈而下,断口利落,构图简洁,主体居中醒目',
'jin-ruijin': '数枚金针自一座小符阵中悬浮竖起,针尖泛冷光,阵纹描金,构图简洁',
'jin-baihong': '一道白虹般的光刃横贯画面,如长虹贯日,凌厉工整',
'jin-gengjin': '金色刀雨自天斜落,刀刃成排坠下,寒光凛冽',
'jin-baize': '一面能照见万象的玉镜,镜中浮现一个发光的兽形轮廓,神异清雅',
'jin-tianwu': '一只八颗虎首的巨兽自浪涛中探出,八口齐张,气势磅礴',
'jin-podan': '一枚金色咒钉楔入一块裂开的玉石,裂纹自钉处放射,构图简洁,主体居中',
'jin-cuijin': '一点金色符光精准点在一圈靶环状的穴位纹中心,构图极简,主体居中',
'jin-qiongqi': '一只带翼巨虎仰天长啸,啸声化作一圈圈金色音波环,威猛慑人',
'jin-kunwu': '一柄赤色宝刀切开一块玉璧,切面平滑如镜,刀光如霞',
'jin-baijin': '锻炉中一块金精被符文小锤锻打,火星金屑迸溅,匠气庄重',
'jin-shuojin': '双刃短剑交叉挥出两道金色弧光,轨迹利落,构图简洁,主体居中',
'jin-kunwu2': '一柄剑浸入描金淬火池,池面腾起金色雾气,构图简洁',
// ── 水(石青/润下)──
'shui-xuanshui': '一道墨蓝漩涡卷起数片水刃扑击,漩心幽深,构图简洁,主体居中',
'shui-runxia': '水滴沿一张符纸边缘润下,在符下聚成一泓镜面小潭,静谧清雅',
'shui-canglang': '一道沧浪自展开的卷轴中涌出,浪头卷着点点符光,气韵流动',
'shui-guixu': '深渊巨潮席卷而来,潮头分成数股巨浪俯冲,幽蓝描金,气势宏大', // 「水龙」必 prime 龙形,改巨浪
'shui-tianchi': '高山天池的水面映着一轮描金圆月,月影微漾,静谧空灵',
'shui-xuangui': '一只玄色巨龟浮出水面,背甲上浮现描金的河图星点纹,古朴神圣',
'shui-hanyuan': '寒渊之底一缕幽蓝咒光盘旋升起,冻雾弥漫,清冷彻骨',
'shui-lixue': '一滴朱砂色血珠落入水碗,在水中晕开成一个符文形,凝重神秘,主体居中',
'shui-ruoshui': '三千弱水如帘幕垂落,水帘中一根羽毛缓缓下沉,轻柔而无可承载',
'shui-guixuan': '一个巨大的深渊漩涡,漩心幽蓝发光,周围碎浪成环,吞噬之势',
'shui-canghai': '沧海夜面升起一轮明月,月光在海上铺成一条描金水路,开阔宁静',
'shui-hantan': '一泓幽深寒潭映出北斗七星,潭面结着薄冰,星点描金,构图简洁',
'shui-beiming': '一条巨鱼自北海跃出水面,身影遮天,浪花如雪崩落,恢弘壮阔',
};
// ─────────────────────────────────────────────────────────────────────────────
// 敌兽立绘 37 新产 + 2 复用(sprite 1024² → 抠图 → 档位缩尺;图集 beasts-act{n})
// 主体词 = 谱面形貌意象的形态语言(不含兽名);tier/act 与谱册一致。
// ─────────────────────────────────────────────────────────────────────────────
export const BEASTS = [
// —— 已入表 7 只(补谱面见增补册 §一)——
{ id: 'qingqiu-hu', tier: 'normal', act: 1, subject: '赤色幻狐,双尾将分未分,周身浮着点点狐火,足不沾地,身影边缘微微虚化,立姿回首,全身像' },
{ id: 'ba-she', tier: 'normal', act: 2, subject: '青黑色巨蟒,鳞列如叠瓦,腹部纹路隐约如古舆图,大口微张露出弯牙,盘卷昂首,全身像' },
{ id: 'chi-hou', tier: 'elite', act: 1, subject: '赤红鬃毛的猛兽,躯干如巨犬而头似狮首,喉间透出火光,鬃毛根根竖起如燃烧,张口怒啸,全身像' },
{ id: 'jiuwei-linghu', tier: 'elite', act: 2, subject: '雪白底色朱砂纹的大狐,九条尾巴各燃一簇狐火,群尾展开如火焰轮,目细长含慧光,优雅立姿,全身像' },
{ id: 'xiang-liu', tier: 'boss', act: 1, subject: '九个蛇首共生于一具青色巨蟒之躯,盘绕如小山,九首高低错落各衔一股浊浪,鳞色石青泛冷光,威压构图,全身像' },
{ id: 'zhu-yin', tier: 'boss', act: 2, subject: '赤色巨蛇盘绕成环如山岳,头顶生一枚巨大的独眼,目光如炬,躯体鳞纹如日晷刻度,庄重威压,全身像' },
{ id: 'tao-tie', tier: 'boss', act: 3, subject: '一张巨大的青铜兽面与巨口,獠牙如钩,双目凸圆,面后躯干化作青铜纹样的雾状虚影,狰狞威压,正面构图' },
// —— 首批谱两只(巡异录参数新产)——
{ id: 'tao-wu', tier: 'elite', act: 1, subject: '虎躯人面的凶兽,犬毛蓬乱如枯草,长尾曳地,肤色赭石带土黄,眉目顽愚而凶,低头顶撞姿态,全身像' },
{ id: 'qiong-qi', tier: 'elite', act: 2, subject: '一只带翼巨虎,背生猬毛如短戟,双翼羽刃泛雌黄金光,目色冷白,展翼欲扑,全身像' },
// —— 新增普通 20 ——
{ id: 'li-li', tier: 'normal', act: 1, subject: '土黄色野豚,四足生着鸡距般的骨刺,低头拱土姿态,背脊沾着新翻的泥土,全身像' },
{ id: 'zhu-niao', tier: 'normal', act: 1, subject: '灰褐色鸱枭怪鸟,双爪如五指细枝般分叉,栖立时爪指交叠,圆目冷光,全身像' },
{ id: 'gu-diao', tier: 'normal', act: 1, subject: '一只巨雕头生弯刀状独角,羽色雌黄带金,展翅俯冲姿态,利爪前伸,全身像' },
{ id: 'ran-yi', tier: 'normal', act: 1, subject: '鱼身蛇首的怪鱼,六条短足如桨,鳞色石青,离水疾行姿态,全身像' },
{ id: 'meng-huai', tier: 'normal', act: 1, subject: '貆形小兽,通身赤色硬毫直立如火刺,毫尖带着点点火星,弓身戒备,全身像' },
{ id: 'hao-zhi', tier: 'normal', act: 1, subject: '白毛野豚,背生长棘如簪、棘端乌黑,长棘竖立如林,侧身低吼,全身像' },
{ id: 'tian-gou', tier: 'normal', act: 1, subject: '玄黑躯体白色头颅的狸犬,双目如银环,疾奔扑击姿态,矫健迅捷,全身像' },
{ id: 'fu-zhu', tier: 'normal', act: 1, subject: '通体雪白的鹿,头生四只珊瑚枝般的角,蹄下泛起浅浅水纹,昂首踏水,全身像' },
{ id: 'chang-you', tier: 'normal', act: 2, subject: '青灰色长臂猿猴,头生四只耳朵,双臂长过膝,一臂横扫姿态,全身像' },
{ id: 'fei-yi', tier: 'normal', act: 2, subject: '赤鳞怪蛇,六足撑地,背生四片如枯叶的翅,身周热浪扭曲,昂首吐信,全身像' },
{ id: 'dang-kang', tier: 'normal', act: 2, subject: '壮硕巨豚,一对獠牙外翻如双镰,四蹄厚重如石础,低头掀撞姿态,全身像' },
{ id: 'huan-li', tier: 'normal', act: 2, subject: '玄色狸兽,一只独眼生在面部正中,三条尾巴分展,张口作啸,全身像' },
{ id: 'he-luo', tier: 'normal', act: 2, subject: '一首十身的怪鱼,十条鱼躯如裙裾环列共一个鱼首,鳍尾翻卷起漩流,全身像' },
{ id: 'ming-she', tier: 'normal', act: 2, subject: '赭红色细蛇,背生四片薄如石磬的翅,振翅悬空,周围荡开一圈圈音波纹,全身像' },
{ id: 'zhu-huai', tier: 'normal', act: 2, subject: '铁灰色巨牛,头生四角两两交错,垂着宽大的猪状耳朵,低头喘息冲撞姿态,全身像' },
{ id: 'qiu-yu', tier: 'normal', act: 3, subject: '兔身鸟喙的小兽,鸱枭圆目,蛇形细尾盘卷,伏地佯睡而睁着一只眼,全身像' },
{ id: 'zhu-ru', tier: 'normal', act: 3, subject: '朱红色狐身,肋部生鱼鳍状双翼,鳍膜透光如琥珀,鼓翼低飞,全身像' },
{ id: 'you-you', tier: 'normal', act: 3, subject: '土黄色骏马之躯,山羊般的横瞳,头生四角,牛尾扫地,前蹄刨地欲冲,全身像' },
{ id: 'bi-bi', tier: 'normal', act: 3, subject: '银灰色狐身,背生一对大雁般的长翼,翼羽泛雌黄金光,掠地滑翔,全身像' },
{ id: 'hua-she', tier: 'normal', act: 3, subject: '豺狼躯干作蛇形游走,背生鸟翼,头颈鬣毛垂遮面部只露一双竖瞳,蜿蜒逼近,全身像' },
// —— 新增精英 5 ——
{ id: 'bo-ma', tier: 'elite', act: 1, subject: '白马之躯黑色马尾,额生螺纹独角,口生虎牙、四蹄裂为虎爪,昂首嘶鸣战意凛然,全身像' },
{ id: 'man-man', tier: 'elite', act: 1, subject: '一对野鸭状怪鸟,各自只有一翼一目,青绿羽色,两鸟相贴合翼齐飞,相合处水汽凝成光环,全身像' },
{ id: 'fei-niu', tier: 'elite', act: 2, subject: '灰褐色瘦牛,白色头颅生一只独眼,蛇形长尾曳地,足下草木枯萎成圈,阴郁前行,全身像' },
{ id: 'yong-he', tier: 'elite', act: 2, subject: '雌黄毛色的巨猿,赤红双目与赤红口喙,狂态毕露,双臂挥舞,身后拖着焦糊爪印,全身像' },
{ id: 'liang-qu', tier: 'elite', act: 3, subject: '玄色狸形巨兽,白色头颅,双前肢是过大的虎爪、爪甲如五柄短刀,前爪高举下劈,全身像' },
// —— 新增 Boss 3 ——
{ id: 'jiu-ying', tier: 'boss', act: 1, subject: '九颗蛇颈兽首自一具矮壮躯干升起,四首衔水色如石青、五首吐焰色如朱砂,九首攒动啼张各异,威压构图,全身像' },
{ id: 'di-jiang', tier: 'boss', act: 2, subject: '浑圆如布囊的神鸟,通体赤黄如丹火,没有面孔与眼睛,六足错落、四翼展开,周身浮起描金乐纹涟漪,神异庄重,全身像' },
{ id: 'kui-niu', tier: 'boss', act: 3, subject: '苍青色巨牛,无角,仅有一条粗壮独足,足下踏着雷电光环,身泛青白辉光,一步踏雷之势,威压构图,全身像' },
];
/** 复用批次一定稿(创始人已眼验;不重摇,直接进产线抠图/图集)。 */
export const REUSE_BEASTS = [
{ id: 'kuze-jiao', tier: 'normal', act: 1, src: 'images/xunyilu/xy-beast-hezejiao.jpg' },
{ id: 'zheng', tier: 'normal', act: 1, src: 'images/xunyilu/xy-beast-zheng.jpg' },
];
/** 复用批次一 bleed 定稿(场景/结算基版,进 assets/bg;标记来源=批次一)。 */
export const REUSE_BG = [
{ id: 'bg-scene-01', src: 'images/xunyilu/xy-scene-01.jpg' },
{ id: 'bg-scene-02', src: 'images/xunyilu/xy-scene-02.jpg' },
{ id: 'bg-result-win', src: 'images/xunyilu/xy-result-win.jpg' },
{ id: 'bg-result-lose', src: 'images/xunyilu/xy-result-lose.jpg' },
];
// ─────────────────────────────────────────────────────────────────────────────
// UI 件 15(生图出框件与底纹,文字一律程序排字 —— 批次一工艺事实 6)
// ─────────────────────────────────────────────────────────────────────────────
const UI_SHOTS = [
// 稀有度空框 3(补批次一整卡样张之缺:框件必须留空供程序装配插画+文字)
{ id: 'ui-frame-common', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,素朴石灰岩质感边框,边框厚拙简洁,四角以小块岩纹收角,框内中央与下方完全留空为纯浅米色,没有任何图案' },
{ id: 'ui-frame-rare', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描银卷云纹边框,银线卷云沿框边游走,清雅工整,框内中央与下方完全留空为纯浅米色,没有任何图案' },
{ id: 'ui-frame-epic', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描金蟠纹边框,金线蟠曲盘绕框边,四角嵌描金小徽记,华丽庄重,框内中央与下方完全留空为纯浅米色,没有任何图案' },
// 状态框 3(可打出/选中/禁用;程序叠加在卡面之上)
{ id: 'ui-state-playable', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描金流光高亮边框,框体透出温暖金色光晕,光感克制不外溢,框内完全留空为纯浅米色,没有任何图案' },
{ id: 'ui-state-selected', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,朱砂色光边边框,框体外沿一圈朱砂描边微微发光,框内完全留空为纯浅米色,没有任何图案' },
{ id: 'ui-state-disabled', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,黯淡灰色石质边框,框体如蒙尘石板毫无光泽,框内完全留空为浅灰色,没有任何图案' },
// 五行属性底纹 5(插画位/卡底的低对比装饰层)
{ id: 'ui-elem-mu', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,石绿色调的缠枝草木暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' },
{ id: 'ui-elem-huo', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,朱砂色调的火焰云气暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' },
{ id: 'ui-elem-tu', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,赭石土黄色调的山岳岩层暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' },
{ id: 'ui-elem-jin', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,雌黄与银白色调的锋刃云雷暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' },
{ id: 'ui-elem-shui', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,石青色调的水波涟漪暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' },
// 界面底纹 2
{ id: 'ui-bg-panel', w: 1024, h: 1024, fw: 512, fh: 512, subject: '游戏界面面板底纹,展开的卷轴册页纸面质感,米色纸面带细微纤维纹理与淡淡青绿云边,平整满幅' },
{ id: 'ui-bg-hud', w: 1024, h: 1024, fw: 512, fh: 512, subject: '游戏界面底纹,暗色岩石质感铺满,石青石绿矿彩沁色斑驳,深沉低调,平整满幅' },
// 图鉴格 2
{ id: 'ui-tujian-cell', w: 1024, h: 1024, fw: 256, fh: 256, subject: '图鉴格子设计,一枚方形描金细框的石质空格居中,格心浅凹留空,四角饰小小云纹,满幅单格' },
{ id: 'ui-tujian-locked', w: 1024, h: 1024, fw: 256, fh: 256, subject: '图鉴格子设计,一枚方形暗色石质空格居中,格心一团墨色云雾剪影缭绕,神秘未知感,满幅单格' },
];
// ─────────────────────────────────────────────────────────────────────────────
// 遗物图标 40(sprite 1024² → 抠图 → 128px;"法器·"领句 = 批次一 weapon 镜头已验配方)
// 遗物名含兽名者一律改写为器物形态语言(兽名不入 prompt)。
// ─────────────────────────────────────────────────────────────────────────────
const RELIC_SUBJECTS = {
'yuheng-fu': '法器·一枚白玉符牌,牌面刻北斗七星连线,斗柄一星描金发亮,悬浮微光',
'guixu-jing': '法器·一面青铜古镜,镜背铸漩涡纹,镜面幽深如渊,悬浮微光',
'yinglong-lin': '法器·单独一枚巨大鳞片的特写,鳞面天然云雷纹,边缘泛描金光,悬浮微光', // 「龙鳞」易 prime 整龙,改鳞片特写
'kunlun-yugui': '法器·一柄温润玉圭,圭身刻层叠山形纹,顶端尖圆,悬浮微光',
'zhulong-jing': '法器·一颗竖瞳龙目状宝珠,珠内燃着一点不灭烛光,悬浮微光',
'hetu-juan': '法器·半卷残破古图,图上星点以描金细线相连,卷边焦损,悬浮微光',
'luoshu-ye': '法器·一页龟甲纹古书残页,页面数点排成方阵,悬浮微光',
'fusang-zhi': '法器·一段神木桑枝,枝头栖着一枚小小的描金日轮,悬浮微光',
'xihe-pei': '法器·一副描金马辔头,辔环铸成小日轮形,革带朱砂色,悬浮微光',
'wangshu-huan': '法器·一只月白色玉环,环身缠绕淡淡云纹,清辉流转,悬浮微光',
'kaiming-ya': '法器·一枚巨兽獠牙吊坠,牙根镶描金箍,牙面有细密咒纹,悬浮微光',
'luwu-zhao': '法器·一只风干的虎爪法器,爪尖描金,腕部系朱砂绳结,悬浮微光',
'yingzhao-yu': '法器·一根赤色纹路的神鸟长羽,羽梢卷曲泛金,悬浮微光',
'zhengpi-gu': '法器·一面赤豹皮蒙面的小手鼓,鼓面绘五道焰形纹,鼓身描金,悬浮微光',
'jingwei-shi': '法器·一枚小小的填海石子,石上天然嵌着白色贝纹,朴拙温润,悬浮微光',
'zhurong-guan': '法器·一顶火焰纹青铜冠,冠顶托着一点真火,火苗不摇,悬浮微光',
'houtu-yin': '法器·一方厚重石印,印钮雕作伏兽,印底泛土黄光,悬浮微光',
'rushou-yue': '法器·一柄白刃小钺,钺身刻秋叶纹,刃口冷光,悬浮微光',
'jumang-bian': '法器·一条青色枝条软鞭,鞭梢生出嫩芽,鞭身缠描金丝,悬浮微光',
'tengshe-zhu': '法器·一颗被雾气缠绕的宝珠,珠下一截蛇形雾影衔着珠,悬浮微光',
'baize-ce': '法器·一册兽皮封面的古书,封面烫着描金兽形轮廓,书口垂着穗子,悬浮微光',
'qingluan-ling': '法器·一只青羽纹铜铃,铃身錾刻羽纹,铃舌描金,悬浮微光',
'chenghuang-an': '法器·一副描金兽鞍,鞍桥翘起如角,鞍鞯朱砂纹,悬浮微光',
'tianwu-pei': '法器·一枚八棱玉珮,珮面刻浪涛纹,系着石青绦带,悬浮微光',
'shuhai-chi': '法器·一把青铜古尺,尺身刻度描金,尺端铸小小足印纹,悬浮微光',
'ruomu-deng': '法器·一盏树枝形古灯,灯盏如花萼,灯焰赤红不摇,悬浮微光',
'sanqingniao-yu': '法器·三根并排系在一起的青色神鸟羽,羽根缠描金线,悬浮微光',
'gonggong-jiao': '法器·一只断裂的巨兽之角,断口如水晶剖面,角身缠浪纹,悬浮微光',
'xuanming-zhu': '法器·一颗墨色深水宝珠,珠心一点幽蓝光,如深渊之眼,悬浮微光',
'xiwangmu-zan': '法器·一支白玉古簪,簪首雕胜形纹嵌描金,悬浮微光',
'taotie-ding': '法器·一尊三足小铜鼎,鼎身铸兽面纹,鼎口溢出一缕金气,悬浮微光',
'jianmu-xin': '法器·一段神木木芯,断面年轮圈圈描金,木身缠青藤,悬浮微光',
'jiuwei-huan': '法器·一只由九股赤色绒尾编成的手环,环上一点狐火微光,悬浮微光',
'yayu-gu': '法器·一根凶兽腿骨,骨面朱砂咒纹层层镇封,骨节缠着细铁链,悬浮微光',
'jia-zhoutian-lu': '法器·一只小巧的木制辘轳,轮上缠着五色丝线各一段,轮辐描金,悬浮微光',
'jia-lianji-fu': '法器·一张烧至将尽却不毁的符纸,符文白热发光,边缘焦卷,悬浮微光',
'jia-wuxing-huan': '法器·五枚小环相扣成链,五环各为石绿朱砂土黄雌黄石青一色,悬浮微光',
'jia-zhanshuai': '法器·一柄素白麻布缠柄的短刃,刃口极利泛冷光,刃身素净,悬浮微光',
'jia-ningxue': '法器·一小卷以朱砂写就的箓文,卷轴两端木杆描金,垂着穗结,悬浮微光',
'jia-lingxi': '法器·一枚犀角雕成的玉佩,角心一线通透发光,系石青绦带,悬浮微光',
};
// ─────────────────────────────────────────────────────────────────────────────
// 结算图变体 2(bleed 768×1360;基版 win/lose 复用批次一)
// ─────────────────────────────────────────────────────────────────────────────
const BG_SHOTS = [
{ id: 'bg-result-bosswin', w: 768, h: 1360, subject: '幕终大胜结算图:一整卷横贯天地的古图长轴展开,数只异兽化作描金线稿列队归入图中安伏,金光如瀑,庄重恢弘,竖幅' },
{ id: 'bg-result-zhoutian', w: 768, h: 1360, subject: '周天大胜结算图:石绿朱砂土黄雌黄石青五色矿彩光带在天空连成一个完整圆环,环心描金大放光明,山野俯伏其下,竖幅' },
];
// ─────────────────────────────────────────────────────────────────────────────
// 汇总为镜头单(seed 段位:卡 901xx / 兽 911xx / UI 921xx / 遗物 931xx / bg 941xx,
// 与批次一 7100185002 段不重叠)
// ─────────────────────────────────────────────────────────────────────────────
function assertKeys(map, ids, label) {
const keys = Object.keys(map);
const missing = ids.filter((i) => !map[i]);
const extra = keys.filter((k) => !ids.includes(k));
if (missing.length || extra.length) {
throw new Error(`${label} 主体词与快照 id 不对齐:缺 ${JSON.stringify(missing)}${JSON.stringify(extra)}`);
}
}
assertKeys(CARD_SUBJECTS, CARD_IDS, '卡插画');
assertKeys(RELIC_SUBJECTS, RELIC_IDS, '遗物图标');
if (CARD_IDS.length !== 66) throw new Error(`卡快照应 66,实 ${CARD_IDS.length}`);
if (RELIC_IDS.length !== 40) throw new Error(`遗物快照应 40,实 ${RELIC_IDS.length}`);
if (BEASTS.length + REUSE_BEASTS.length !== 39) throw new Error(`敌兽应 39,实 ${BEASTS.length + REUSE_BEASTS.length}`);
export const SHOTS = [
...CARD_IDS.map((id, i) => ({
id: `card-${id}`, frame: id, group: 'cards', atlas: 'cards-jia', type: 'bleed',
w: 1024, h: 1024, seed: 90100 + i, final: { w: 256, h: 256, fit: 'cover' },
subject: `符卡插画:${CARD_SUBJECTS[id]}`,
})),
...BEASTS.map((b, i) => ({
id: `beast-${b.id}`, frame: b.id, group: 'beasts', atlas: `beasts-act${b.act}`, type: 'sprite',
w: 1024, h: 1024, seed: 91100 + i, final: { box: BEAST_BOX[b.tier], fit: 'contain' },
tier: b.tier, subject: `上古异兽立绘,${b.subject}`,
})),
...UI_SHOTS.map((u, i) => ({
id: u.id, frame: u.id, group: 'ui', atlas: 'ui', type: 'bleed',
w: u.w, h: u.h, seed: 92100 + i, final: { w: u.fw, h: u.fh, fit: 'cover' },
subject: u.subject,
})),
...RELIC_IDS.map((id, i) => ({
id: `relic-${id}`, frame: id, group: 'relics', atlas: 'relics', type: 'sprite',
w: 1024, h: 1024, seed: 93100 + i, final: { box: 128, fit: 'contain' },
subject: RELIC_SUBJECTS[id],
})),
...BG_SHOTS.map((g, i) => ({
id: g.id, frame: g.id, group: 'bg', atlas: null, type: 'bleed',
w: g.w, h: g.h, seed: 94100 + i, final: { w: 768, h: 1360, fit: 'cover' },
subject: g.subject,
})),
];
export const REUSE = { beasts: REUSE_BEASTS.map((b) => ({ ...b, box: BEAST_BOX[b.tier], atlas: `beasts-act${b.act}` })), bg: REUSE_BG };

View File

@ -30,9 +30,12 @@ console.log('[battle-core.test] 《山海巡异录》M0 单测');
// ── T1 表完整性 + 估值 sanity(估值为 advisory,只有超 hardCap 的离谱值才算失败)──
{
// M2 定容(书 §5.2 职业甲 66;批产推进中间批按当前表数断言,唯一性/五行均衡精神不变)。
// M3 定容(书 §5.2:154 = 职业专属 66×2 + 中立 22)。
const ids = new Set(CARDS.map((c) => c.id));
assert(CARDS.length === 66 && ids.size === CARDS.length, `T1a M2 卡池定容 66 且 id 唯一(当前 ${CARDS.length})`);
const byClass = {};
for (const c of CARDS) byClass[c.class] = (byClass[c.class] || 0) + 1;
assert(CARDS.length === 154 && ids.size === CARDS.length, `T1a M3 卡池定容 154 且 id 唯一(当前 ${CARDS.length})`);
assert(byClass.jia === 66 && byClass.yi === 66 && byClass.neutral === 22, 'T1a2 三池构成 66/66/22', JSON.stringify(byClass));
const perEl = {};
for (const c of CARDS) perEl[c.element] = (perEl[c.element] || 0) + 1;
const counts = Object.values(perEl);

View File

@ -1,6 +1,6 @@
{
"what": "金标构筑回归集(设计书 §6.4:20 套手工编排构筑,数值改动后重跑,胜率漂移 >±8pp 按套判报警)",
"note": "deck/relics 注入 run-core(opts.deck/opts.relics),贪心档打完整 run;baselineWinRate 已于 2026-07-06 S12 调参轮收口锁值(nightly 终跑 n=1000/套);漂移相对它按套判 ±8pp。满值套(100%)漂移只能单向向下,如实观测。套名=流派·变体;每流派 2-3 套。",
"note": "deck/relics 注入 run-core,贪心档打完整 run;基线口径(M3 定案):池扩张/职业/进阶入轴是预期漂移源,M3 实测:甲 20 套在 154 池+事件+起始遗物扩张下漂移 ≤3.6pp 全 PASS——基线连续(金标注入=已成型构筑,扩张影响被掩盖);乙 6 套 M3 首锁(nightly n=1000),漂移门 ±8pp 管里程碑内数值微调。满值套(100%)漂移只能单向向下,如实观测。",
"nPerSuite": 1000,
"seedBase": 260000,
"suites": [
@ -458,6 +458,149 @@
"yuheng-fu"
],
"baselineWinRate": 0.483
},
{
"name": "乙·链循环·星轨嵌环",
"class": "yi",
"smokeSample": true,
"deck": [
"yi-mu-luoteng",
"yi-huo-zhuming",
"yi-tu-jishi",
"yi-jin-shenshang",
"yi-shui-tianhan",
"yi-mu-sangzhe",
"yi-jin-xuanyuan",
"yi-shui-yinhe",
"yi-tu-bugang",
"yi-shui-beidou",
"yi-jin-baiying",
"yi-mu-tangong"
],
"relics": [
"hetu-juan",
"jia-wuxing-huan",
"tengshe-zhu"
],
"baselineWinRate": 0.994
},
{
"name": "乙·灼流·荧惑燎原",
"class": "yi",
"deck": [
"yi-huo-yinghuo",
"yi-huo-tanggu",
"yi-huo-yandi",
"yi-huo-zhurong2",
"yi-huo-liuhuo",
"yi-huo-huoling",
"yi-mu-ximu",
"yi-tu-wuse",
"yi-jin-changgeng",
"yi-shui-beidou",
"yi-shui-yaochi",
"yi-huo-zhulou"
],
"relics": [
"zhurong-guan",
"kunlun-yugui"
],
"baselineWinRate": 0.991
},
{
"name": "乙·控异·弱水玄冥",
"class": "yi",
"deck": [
"yi-shui-ruoshui2",
"yi-shui-xuanming2",
"yi-jin-zhongshan",
"yi-tu-xiushe",
"yi-shui-beiming2",
"yi-jin-rushou2",
"yi-tu-wuse",
"yi-mu-ximu",
"yi-huo-zhuming",
"yi-shui-beidou",
"yi-tu-jishi",
"yi-shui-yaochi"
],
"relics": [
"qingluan-ling",
"baize-ce",
"yinglong-lin"
],
"baselineWinRate": 0.19
},
{
"name": "乙·周天环·倒卷帘",
"class": "yi",
"smokeSample": true,
"deck": [
"yi-mu-luoteng",
"yi-huo-zhulou",
"yi-tu-bugang",
"yi-jin-jinsuo",
"yi-shui-jianglou",
"yi-mu-sangzhe",
"yi-huo-liuhuo",
"yi-tu-jishi",
"yi-jin-shenshang",
"yi-shui-zhoutian",
"yi-shui-tianhan",
"yi-mu-taiyi"
],
"relics": [
"jia-zhoutian-lu",
"luoshu-ye",
"hetu-juan",
"jia-lingxi"
],
"baselineWinRate": 0.955
},
{
"name": "乙·韧甲·岱舆孤峙",
"class": "yi",
"deck": [
"yi-tu-wuse",
"yi-tu-xuanyin",
"yi-tu-lianshan",
"yi-tu-butian",
"yi-tu-guitian",
"yi-tu-daiyu",
"yi-mu-linggen2",
"yi-shui-xuangui2",
"yi-jin-yaoguang",
"yi-mu-ximu",
"yi-huo-zhuming",
"yi-shui-yaochi"
],
"relics": [
"luwu-zhao",
"houtu-yin",
"yinglong-lin"
],
"baselineWinRate": 0.948
},
{
"name": "乙·基准锚·起始组原样",
"class": "yi",
"smokeSample": true,
"deck": [
"yi-mu-luoteng",
"yi-mu-ximu",
"yi-huo-zhuming",
"yi-huo-zhulou",
"yi-tu-wuse",
"yi-tu-jishi",
"yi-jin-changgeng",
"yi-jin-zhongshan",
"yi-shui-beidou",
"yi-shui-yaochi"
],
"relics": [
"hetu-juan"
],
"baselineWinRate": 0.525
}
]
}

View File

@ -0,0 +1,109 @@
/**
* scripts/meta.test.mjs 山海巡异录局外核单测(M3 S15:图鉴/成就/解锁/每日)
* owner:北极星顶级线③(M3)
*
* 验什么W1 图鉴收集幂等;W2 成就条件词表全覆盖(表内 kind 解释器认知集新增词漏接=);
* W3 终局结算(计数/进阶解锁/成就达成);W4 每日(种子=日期派生/分数公式/最佳分);
* W5 序列化往返;W6 成就去重(达成不重复)
*
* 用法:node scripts/meta.test.mjs(退出码 0=全过)
*/
'use strict';
import { createMetaCore } from '../src/meta-core.js';
import { ACHIEVEMENTS, DAILY } from '../src/data/tables.js';
let failures = 0;
function assert(cond, name, detail) {
if (cond) console.log(` PASS ${name}`);
else { console.error(` FAIL ${name}` + (detail ? `${detail}` : '')); failures++; }
}
console.log('[meta.test] 《山海巡异录》M3 局外核单测');
/** 终局投影桩(recordRunEnd 消费面;字段与 run-core readState 对齐)。 */
function endState(over = {}) {
return {
phase: 'win', heroClass: 'jia', ascension: 0, deckCount: 18, gold: 120,
hero: { hp: 40, hpMax: 72 },
relics: [{ id: 'r1' }, { id: 'r2' }],
stats: { battlesWon: 14, turnsTotal: 90, cardsRemoved: 2, zhoutianTotal: 1 },
act: 3,
...over,
};
}
// ── W1 图鉴收集幂等 ──
{
const m = createMetaCore(null);
const rs = { deck: ['a', 'b', 'a'], relics: [{ id: 'r1' }], battle: { enemy: { id: 'e1' } } };
m.observe(rs); m.observe(rs); m.observe(rs);
const r = m.readMeta();
assert(r.collection.cards === 2 && r.collection.relics === 1 && r.collection.enemies === 1, 'W1 三面收集幂等(2 卡/1 遗物/1 敌)', JSON.stringify(r.collection));
}
// ── W2 成就词表全覆盖(表内 kind ⊆ 解释器;新词漏接即红)──
{
const KNOWN = new Set(['wins', 'runs', 'winClass', 'winBoth', 'winAscension', 'deckMaxAtWin', 'deckMinAtWin',
'hpFullWin', 'goldAtWin', 'removeTotal', 'zhoutianRun', 'relicsAtWin', 'winTurnsUnder',
'collectCards', 'collectRelics', 'collectEnemies', 'dailyPlayed', 'dailyScore', 'ascUnlocked']);
const unknown = ACHIEVEMENTS.filter((a) => !KNOWN.has(a.cond.kind)).map((a) => `${a.id}:${a.cond.kind}`);
assert(unknown.length === 0, `W2 成就 kind 词表全覆盖(表 ${ACHIEVEMENTS.length} 条)`, unknown.join(','));
assert(ACHIEVEMENTS.length === 40, 'W2b 成就定容 40(书 §5.2)', String(ACHIEVEMENTS.length));
}
// ── W3 终局结算:计数/解锁/成就 ──
{
const m = createMetaCore(null);
const r1 = m.recordRunEnd(endState());
const meta1 = m.readMeta();
assert(meta1.runsPlayed === 1 && meta1.wins === 1 && meta1.winsByClass.jia === 1, 'W3a 胜局计数');
assert(meta1.ascensionUnlocked.jia === 1, 'W3b 通关 A0 解锁 A1(逐职业)', JSON.stringify(meta1.ascensionUnlocked));
assert(r1.newlyAchieved.includes('ach-first-win') && r1.newlyAchieved.includes('ach-win-jia'), 'W3c 首胜成就达成', r1.newlyAchieved.join(','));
// 进阶通关跳级解锁:A5 通关 → 解锁 A6。
m.recordRunEnd(endState({ ascension: 5 }));
assert(m.readMeta().ascensionUnlocked.jia === 6, 'W3d A5 通关解锁 A6');
// 败局:只计局数。
const before = m.readMeta().wins;
m.recordRunEnd(endState({ phase: 'lose' }));
assert(m.readMeta().wins === before && m.readMeta().runsPlayed === 3, 'W3e 败局不计胜场');
// 薄卡组成就。
const r4 = m.recordRunEnd(endState({ deckCount: 11 }));
assert(r4.newlyAchieved.includes('ach-thin-deck'), 'W3f 轻装简从(≤12 张通关)');
}
// ── W4 每日:种子派生/分数公式/最佳分 ──
{
const m = createMetaCore(null);
const d = m.dailySeedFor('2026-07-06');
assert(d.seed === 20260706, 'W4a 每日种子=日期数字', String(d.seed));
const win = endState({ gold: 100 });
const { dailyScore } = m.recordRunEnd(win, { isDaily: true });
const expect = DAILY.winBase + win.hero.hp * DAILY.hpWeight + win.gold * DAILY.goldWeight - win.stats.turnsTotal * DAILY.turnPenalty;
assert(dailyScore === expect, 'W4b 胜局分数=表公式', `${dailyScore} vs ${expect}`);
const low = m.recordRunEnd(endState({ phase: 'lose', act: 2 }), { isDaily: true });
assert(m.readMeta().daily.bestScore === expect && low.dailyScore < expect, 'W4c 最佳分保持(低分不覆盖)');
const d2 = m.dailySeedFor('2026-07-07');
assert(d2.seed === 20260707 && m.readMeta().daily.bestScore === 0 && !m.readMeta().daily.played, 'W4d 跨日重置(新题面)');
}
// ── W5 序列化往返 ──
{
const m = createMetaCore(null);
m.observe({ deck: ['x'], relics: [{ id: 'r' }], battle: { enemy: { id: 'e' } } });
m.recordRunEnd(endState());
const back = createMetaCore(JSON.parse(JSON.stringify(m.serialize())));
assert(JSON.stringify(back.serialize()) === JSON.stringify(m.serialize()), 'W5 序列化往返逐字段一致');
}
// ── W6 成就去重 ──
{
const m = createMetaCore(null);
const a = m.recordRunEnd(endState());
const b = m.recordRunEnd(endState());
assert(a.newlyAchieved.includes('ach-first-win') && !b.newlyAchieved.includes('ach-first-win'), 'W6 已达成就不重复上报');
}
console.log(failures === 0 ? '[meta.test] 全绿' : `[meta.test] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -133,13 +133,15 @@ console.log('[run-core.test] 《山海巡异录》M1 单测');
fightThrough(run);
const afterElite = run.readState();
if (afterElite.phase === 'run') {
// M2 S10:发放改受控随机抽池(池序=M1 过渡形态),断言口径=「获得一枚池内遗物」
// M2 S10 随机抽池 + M3 起始遗物(职业身份件占 1 基数):断言按「相对基数 +1」口径
const relicIds = new Set(RELICS.map((r) => r.id));
assert(afterElite.relics.length === 1 && relicIds.has(afterElite.relics[0].id), 'R7c 精英必掉遗物(随机抽池)', JSON.stringify(afterElite.relics));
const base = 1; // CLASSES.starterRelic(M3 平衡轮)
assert(afterElite.relics.length === base + 1 && afterElite.relics.every((r) => relicIds.has(r.id)), 'R7c 精英必掉遗物(随机抽池;起始+1)', JSON.stringify(afterElite.relics));
run.chooseReward('skip');
run.chooseNode('a1-f3-n0'); // 宝匣
const s3 = run.readState();
assert(s3.relics.length === 2 && relicIds.has(s3.relics[1].id) && s3.relics[1].id !== s3.relics[0].id,
const ids3 = s3.relics.map((r) => r.id);
assert(s3.relics.length === base + 2 && new Set(ids3).size === ids3.length,
'R7d 宝匣再发一枚未持有遗物(互异)', JSON.stringify(s3.relics));
} else {
assert(false, 'R7c 精英战贪心竟败(数值曲线要看)', `phase=${afterElite.phase} hp=${afterElite.hero.hp}`);
@ -241,6 +243,7 @@ console.log('[run-core.test] 《山海巡异录》M1 单测');
} else if (s.screen === 'reward') run.chooseReward(0);
else if (s.screen === 'relicReward') run.chooseRelicReward(0); // M2 Boss 遗物三选一
else if (s.screen === 'shop') run.leaveShop(); // M2 商店(确定性脚本:径直离开)
else if (s.screen === 'event') run.chooseEventOption(0); // M3 事件(确定性脚本:取头一项)
}
return JSON.stringify(run.readState());
};
@ -387,5 +390,42 @@ console.log('[run-core.test] 《山海巡异录》M1 单测');
assert(threw, 'R14d 未知遗物注入响亮抛(表一致性)');
}
// ── R15 职业与进阶消费(M3 S13/S14)──
{
// 乙职业:HP/起始组/起始遗物/池过滤。
const yi = createRunCore({ random: createSeededRandom(151) }, { seed: 151, heroClass: 'yi' });
const sy = yi.readState();
assert(sy.heroClass === 'yi' && sy.hero.hpMax === 70, 'R15a 乙职业 HP=70(平衡轮回填值)', JSON.stringify(sy.hero));
assert(sy.deck.every((id) => id.startsWith('yi-')) && sy.deckCount === 10, 'R15b 乙起始组全乙池 10 张');
assert(sy.relics.length === 1 && sy.relics[0].id === 'hetu-juan', 'R15c 乙起始遗物=河图残卷');
// 甲缺省与起始遗物。
const jia = createRunCore({ random: createSeededRandom(152) }, { seed: 152 });
assert(jia.readState().heroClass === 'jia' && jia.readState().relics[0].id === 'kaiming-ya', 'R15d 甲缺省+开明兽牙');
// 进阶 A6:起始 HP -6;A3+A10 敌血乘区。
const a6 = createRunCore({ random: createSeededRandom(153) }, { seed: 153, ascension: 6 });
assert(a6.readState().hero.hpMax === 72 - 6 && a6.readState().ascension === 6, 'R15e A6 起始 HP 72-6', String(a6.readState().hero.hpMax));
// A15 奖励二选一(rewardChoicesDelta)。
const a15 = createRunCore(
{ random: createSeededRandom(154) },
{ seed: 154, ascension: 15, actMaps: [miniMap(1), miniMap(2), miniMap(3)] },
);
a15.chooseNode('a1-f0-n0');
fightThrough(a15);
if (a15.readState().phase === 'run' && a15.readState().screen === 'reward') {
assert(a15.readState().reward.options.length === 2, 'R15f A15 战斗奖励二选一(构筑面收紧轴)', String(a15.readState().reward.options.length));
} else {
assert(false, 'R15f 前置:A15 教学战竟败', a15.readState().phase);
}
// 进阶敌血乘区(A3+A10=11%,幕1 普通敌不吃幕缩放只吃进阶)。
const a10 = createRunCore(
{ random: createSeededRandom(155) },
{ seed: 155, ascension: 10, actMaps: [miniMap(1), miniMap(2), miniMap(3)] },
);
a10.chooseNode('a1-f0-n0');
const enemyHp = a10.readState().battle.enemy.hpMax;
const kuze = ENEMIES.find((e) => e.id === 'kuze-jiao');
assert(enemyHp === Math.round(kuze.hpMax * 1.11), 'R15g A10 敌血=表值×1.11(A3 7%+A10 4%)', `${enemyHp} vs ${Math.round(kuze.hpMax * 1.11)}`);
}
console.log(failures === 0 ? '[run-core.test] 全绿' : `[run-core.test] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -105,6 +105,7 @@ function driveRun(seed, opts = {}) {
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件
else break; // 未知靶=契约漂移,响亮暴露(勿静默空转)
// 事件收集(首战演出估算)。
if (inFirstBattle) {

View File

@ -1,15 +1,13 @@
/**
* scripts/save.test.mjs 山海巡异录run 存档断言(M2 S11 验收门)
* owner:北极星顶级线③(M2)
* scripts/save.test.mjs 山海巡异录存档断言(M2 S11 v1 M3 S15 v2 双域信封)
* owner:北极星顶级线③(M2/M3)
*
* 验什么 §3.4存档续玩断言 + 工单口径(往返 + 坏版本负例)+ 契约容量闸:
* V1 信封形状=SaveEnvelope(schemaVersion 整数 1 + data 纯对象);
* V2 往返逐字段一致(snapshotrestorereadState 深等,map/deck/relics/HP/金币/removeCount/种子);
* V3 续玩确定性(恢复后与不中断,同策略打到终态逐字段一致rngState 精确续的实证);
* V4 坏版本负例(0/2/'1'/ data/data 数组/裸对象/数组信封 全拒,fail-closed);
* V5 容量核数(信封 JSON < 65536 码元,save: 族契约闸;取整局多快照峰值);
* V6 战斗屏无快照(:战斗外任意节点;battle snapshot()=null);
* V7 三屏快照可恢复(reward/relicReward/shop 屏各自往返)
* 验什么 §3.4存档续玩断言 + 工单口径(往返 + 坏版本负例)+ 契约容量闸 + v2 增列:
* V1 信封形状=SaveEnvelope(schemaVersion 整数 1 + data 纯对象;v2 双域 {run,meta});
* V2 run 域往返逐字段一致;V3 续玩确定性(恢复续打=不中断直打);
* V4 坏档负例全拒(fail-closed);V5 容量核数(<65536 码元);V6 战斗屏无 run 快照;
* V7 三屏快照可恢复;V8 v1v2 迁移(M2 旧档平铺 run ,meta );
* V9 meta 域独立性(meta 坏形只弃 meta,run 仍恢复)
*
* 用法:node scripts/save.test.mjs(退出码 0=全过)
*/
@ -21,8 +19,9 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createRunCore } from '../src/run-core.js';
import { createMetaCore } from '../src/meta-core.js';
import { createSeededRandom } from '../src/util/seeded-random.js';
import { makeEnvelope, validateEnvelope, restoreRun, serializeEnvelope } from '../src/save.js';
import { makeEnvelope, validateEnvelope, restoreRun, restoreMeta, serializeEnvelope } from '../src/save.js';
import { SAVE } from '../src/data/tables.js';
const __dir = dirname(fileURLToPath(import.meta.url));
@ -35,7 +34,29 @@ function assert(cond, name, detail) {
else { console.error(` FAIL ${name}` + (detail ? `${detail}` : '')); failures++; }
}
/** 用贪心策略推进一局;stopAt(state)=true 时停(返回是否到达);分发词表与 sim-run 同。 */
/** 分发词表(与 sim-run 同表)。 */
function act(run, t) {
if (t.kind === 'card') run.tapCard(t.index);
else if (t.kind === 'enemy') run.confirmPlay();
else if (t.kind === 'endTurn') run.endTurn();
else if (t.kind === 'cancel') run.clearSelection();
else if (t.kind === 'node') run.chooseNode(t.id);
else if (t.kind === 'reward') run.chooseReward(t.index);
else if (t.kind === 'rewardSkip') run.chooseReward('skip');
else if (t.kind === 'relicReward') run.chooseRelicReward(t.index);
else if (t.kind === 'relicSkip') run.chooseRelicReward('skip');
else if (t.kind === 'shopCard') run.buyCard(t.index);
else if (t.kind === 'shopRelic') run.buyRelic(t.index);
else if (t.kind === 'shopRemove') run.openRemove();
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else if (t.kind === 'eventOption') run.chooseEventOption(t.index);
else return false;
return true;
}
/** 用贪心推进;stopAt(state)=true 时停。 */
function drive(run, stopAt, guard = 20000) {
for (let i = 0; i < guard; i++) {
const s = run.readState();
@ -43,156 +64,150 @@ function drive(run, stopAt, guard = 20000) {
if (stopAt && stopAt(s)) return true;
const t = chooseTap(s);
if (!t) return false;
if (t.kind === 'card') run.tapCard(t.index);
else if (t.kind === 'enemy') run.confirmPlay();
else if (t.kind === 'endTurn') run.endTurn();
else if (t.kind === 'cancel') run.clearSelection();
else if (t.kind === 'node') run.chooseNode(t.id);
else if (t.kind === 'reward') run.chooseReward(t.index);
else if (t.kind === 'rewardSkip') run.chooseReward('skip');
else if (t.kind === 'relicReward') run.chooseRelicReward(t.index);
else if (t.kind === 'relicSkip') run.chooseRelicReward('skip');
else if (t.kind === 'shopCard') run.buyCard(t.index);
else if (t.kind === 'shopRelic') run.buyRelic(t.index);
else if (t.kind === 'shopRemove') run.openRemove();
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else return false;
if (!act(run, t)) return false;
}
return false;
}
/** 打到终态并返回终态投影(V3 用;经 JSON 归一)。 */
function driveToEnd(run) {
drive(run, null);
return JSON.stringify(run.readState());
}
console.log('[save.test] 《山海巡异录》M2 存档断言(SaveEnvelope 契约面)');
console.log('[save.test] 《山海巡异录》存档断言(SaveEnvelope v2 双域)');
// ── V1/V2 信封形状 + 往返逐字段一致(map 屏首快照)──
// ── V1/V2 信封形状 + run 域往返 ──
{
const run = createRunCore({ random: createSeededRandom(777) }, { seed: 777 });
// 推进到第二次回到 map 屏(打完教学战、领完奖励),快照内容更饱满。
drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1);
const env = makeEnvelope(run);
assert(env && Number.isInteger(env.schemaVersion) && env.schemaVersion >= 1, 'V1a 信封 schemaVersion 整数 ≥1', JSON.stringify(env && env.schemaVersion));
const meta = createMetaCore(null);
meta.observe(run.readState());
const env = makeEnvelope(run, meta);
assert(env && Number.isInteger(env.schemaVersion) && env.schemaVersion >= 1, 'V1a 信封 schemaVersion 整数 ≥1');
assert(env && typeof env.data === 'object' && !Array.isArray(env.data), 'V1b data 为纯对象(非数组)');
assert(env.schemaVersion === SAVE.schemaVersion, 'V1c 版本=当前表定版本');
assert(env.schemaVersion === SAVE.schemaVersion && env.schemaVersion === 2, 'V1c 版本=当前表定版本(v2)');
assert(env.data.run && env.data.meta, 'V1d 双域俱在(run+meta)');
const restored = restoreRun(JSON.parse(JSON.stringify(env))); // 经 JSON 往返(介质等价)
assert(!!restored, 'V2a 恢复成功');
const a = run.readState();
const b = restored.readState();
assert(JSON.stringify(a) === JSON.stringify(b), 'V2b 往返 readState 逐字段一致(map/deck/relics/HP/金币/靶位全量)');
assert(b.seed === 777 && b.removeCount === a.removeCount, 'V2c 种子与删卡计数还原', `seed=${b.seed}`);
const restored = restoreRun(JSON.parse(JSON.stringify(env)));
assert(!!restored, 'V2a run 域恢复成功');
assert(JSON.stringify(run.readState()) === JSON.stringify(restored.readState()), 'V2b 往返 readState 逐字段一致');
const metaBack = restoreMeta(JSON.parse(JSON.stringify(env)));
assert(metaBack && JSON.stringify(createMetaCore(metaBack).serialize()) === JSON.stringify(meta.serialize()), 'V2c meta 域往返一致');
}
// ── V3 续玩确定性:快照点分叉,「中断恢复续打」与「不中断直打」终态逐字段一致 ──
// ── V3 续玩确定性 ×3 seed(职业乙+进阶也覆盖:恢复续打=不中断直打)──
{
const seedList = [301, 302, 303];
let allSame = true;
let detail = '';
for (const sd of seedList) {
const runA = createRunCore({ random: createSeededRandom(sd) }, { seed: sd });
const cases = [[301, {}], [302, { heroClass: 'yi' }], [303, { ascension: 3 }]];
let allSame = true; let detail = '';
for (const [sd, opts] of cases) {
const runA = createRunCore({ random: createSeededRandom(sd) }, { seed: sd, ...opts });
drive(runA, (s) => s.screen === 'map' && s.stats.battlesWon >= 2);
const env = makeEnvelope(runA);
if (!env) { allSame = false; detail = `seed=${sd} 无快照`; break; }
const endA = driveToEnd(runA); // 不中断:直接打完
const runB = restoreRun(JSON.parse(JSON.stringify(env))); // 中断恢复
const env = makeEnvelope(runA, null);
if (!env.data.run) { allSame = false; detail = `seed=${sd} 无快照`; break; }
const endA = driveToEnd(runA);
const runB = restoreRun(JSON.parse(JSON.stringify(env)));
const endB = driveToEnd(runB);
if (endA !== endB) { allSame = false; detail = `seed=${sd} 终态漂移`; break; }
if (endA !== endB) { allSame = false; detail = `seed=${sd} ${JSON.stringify(opts)} 终态漂移`; break; }
}
assert(allSame, 'V3 续玩确定性 ×3 seed(恢复续打 = 不中断直打,终态逐字段一致)', detail);
assert(allSame, 'V3 续玩确定性 ×3(甲/乙/A3;恢复续打=不中断直打)', detail);
}
// ── V4 坏版本负例(fail-closed:全拒)──
// ── V4 坏档负例(fail-closed 全拒;版本 1 是合法迁移不在此列)──
{
const run = createRunCore({ random: createSeededRandom(88) }, { seed: 88 });
const good = makeEnvelope(run);
const good = makeEnvelope(run, null);
const cases = [
['版本 0', { ...good, schemaVersion: 0 }],
['版本 2(未来版)', { ...good, schemaVersion: 2 }],
['版本字符串', { ...good, schemaVersion: '1' }],
['缺 data', { schemaVersion: 1 }],
['data 为数组', { schemaVersion: 1, data: [1, 2] }],
['版本 3(未来版)', { ...good, schemaVersion: 3 }],
['版本字符串', { ...good, schemaVersion: '2' }],
['缺 data', { schemaVersion: 2 }],
['data 为数组', { schemaVersion: 2, data: [1, 2] }],
['信封为数组', [good]],
['裸 data(无信封)', good.data],
['run 域为数组', { schemaVersion: 2, data: { run: [1], meta: null } }],
['run 域缺 rngState', { schemaVersion: 2, data: { run: { map: {} }, meta: null } }],
['null', null],
['data.rngState 缺失', { schemaVersion: 1, data: { ...good.data, rngState: undefined } }],
];
let allRejected = true;
let leak = '';
let allRejected = true; let leak = '';
for (const [name, env] of cases) {
if (validateEnvelope(env) !== null || restoreRun(env) !== null) { allRejected = false; leak = name; break; }
const v = validateEnvelope(env);
const rejected = v === null || (v.run === null && name.startsWith('run 域') === false && false);
if (!(v === null) || restoreRun(env) !== null) { allRejected = false; leak = name; break; }
}
assert(allRejected, `V4 坏档负例 ×${cases.length} 全拒(fail-closed,坏档=无档)`, leak && `漏放:${leak}`);
}
// ── V5 容量核数(save: 族 65536 码元;整局路径上的快照峰值)──
// ── V5 容量核数(双域峰值)──
{
const run = createRunCore({ random: createSeededRandom(505) }, { seed: 505 });
const meta = createMetaCore(null);
let peak = 0;
for (let i = 0; i < 20000; i++) {
const s = run.readState();
if (s.phase !== 'run') break;
const env = makeEnvelope(run);
if (env) {
const ser = serializeEnvelope(env);
meta.observe(s);
if (s.screen !== 'battle') {
const ser = serializeEnvelope(makeEnvelope(run, meta));
if (!ser.ok) { peak = ser.chars; break; }
if (ser.chars > peak) peak = ser.chars;
}
const t = chooseTap(s);
if (!t) break;
if (t.kind === 'card') run.tapCard(t.index);
else if (t.kind === 'enemy') run.confirmPlay();
else if (t.kind === 'endTurn') run.endTurn();
else if (t.kind === 'node') run.chooseNode(t.id);
else if (t.kind === 'reward') run.chooseReward(t.index);
else if (t.kind === 'rewardSkip') run.chooseReward('skip');
else if (t.kind === 'relicReward') run.chooseRelicReward(t.index);
else if (t.kind === 'relicSkip') run.chooseRelicReward('skip');
else if (t.kind === 'shopCard') run.buyCard(t.index);
else if (t.kind === 'shopRelic') run.buyRelic(t.index);
else if (t.kind === 'shopRemove') run.openRemove();
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else break;
if (!t || !act(run, t)) break;
}
assert(peak > 0 && peak < SAVE.maxChars, `V5 快照峰值 ${peak} 码元 < ${SAVE.maxChars}(契约闸;余量 ${Math.round((1 - peak / SAVE.maxChars) * 100)}%)`);
assert(peak > 0 && peak < SAVE.maxChars, `V5 双域快照峰值 ${peak} 码元 < ${SAVE.maxChars}(余量 ${Math.round((1 - peak / SAVE.maxChars) * 100)}%)`);
}
// ── V6 战斗屏无快照 ──
// ── V6 战斗屏无 run 快照(信封 run 域=null)──
{
const run = createRunCore({ random: createSeededRandom(66) }, { seed: 66 });
run.chooseNode(run.readState().map.selectable[0]); // 进教学首战
assert(run.readState().screen === 'battle' && run.snapshot() === null, 'V6 battle 屏 snapshot()=null(战斗外任意节点口径)');
run.chooseNode(run.readState().map.selectable[0]);
const env = makeEnvelope(run, null);
assert(run.readState().screen === 'battle' && env.data.run === null, 'V6 battle 屏 run 域=null(战斗外任意节点口径)');
}
// ── V7 三屏快照可恢复(reward/shop/relicReward 各一)──
// ── V7 多屏快照可恢复(reward/shop/relicReward/event)──
{
const stops = [
['reward', (s) => s.screen === 'reward'],
['shop', (s) => s.screen === 'shop'],
['relicReward', (s) => s.screen === 'relicReward'],
['event', (s) => s.screen === 'event'],
];
for (const [name, stop] of stops) {
let hit = false;
// 多 seed 找到能停在该屏的局(贪心路径依赖 seed;找不到=断言失败,响亮暴露)。
for (let sd = 900; sd < 940 && !hit; sd++) {
for (let sd = 900; sd < 960 && !hit; sd++) {
const run = createRunCore({ random: createSeededRandom(sd) }, { seed: sd });
if (!drive(run, stop)) continue;
hit = true;
const env = makeEnvelope(run);
const back = env && restoreRun(JSON.parse(JSON.stringify(env)));
const env = makeEnvelope(run, null);
const back = env.data.run && restoreRun(JSON.parse(JSON.stringify(env)));
const same = back && JSON.stringify(run.readState()) === JSON.stringify(back.readState());
assert(!!same, `V7 ${name} 屏快照往返一致(seed=${sd})`);
}
if (!hit) assert(false, `V7 ${name}40 seed 内未达(路径覆盖缺口)`);
if (!hit) assert(false, `V7 ${name}60 seed 内未达(路径覆盖缺口)`);
}
}
// ── V8 v1→v2 迁移(M2 旧档=data 平铺 → run 域;meta 空)──
{
const run = createRunCore({ random: createSeededRandom(112) }, { seed: 112 });
drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1);
const v1 = { schemaVersion: 1, data: run.snapshot() }; // M2 v1 形状:平铺快照
const v = validateEnvelope(v1);
assert(v && v.run && v.run.rngState != null && v.meta === null, 'V8a v1 迁移:平铺 → run 域,meta 空');
const back = restoreRun(v1);
assert(back && JSON.stringify(back.readState()) === JSON.stringify(run.readState()), 'V8b v1 旧档恢复续玩(迁移链真通)');
}
// ── V9 meta 域独立性(meta 坏形只弃 meta,run 仍恢复)──
{
const run = createRunCore({ random: createSeededRandom(113) }, { seed: 113 });
drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1);
const env = makeEnvelope(run, null);
env.data.meta = [1, 2, 3]; // 坏形 meta(数组)
const v = validateEnvelope(env);
assert(v && v.run && v.meta === null, 'V9a meta 坏形被弃,run 域保留');
assert(restoreRun(env) !== null && restoreMeta(env) === null, 'V9b run 恢复成功且 meta 返 null(独立 fail-closed)');
}
console.log(failures === 0 ? '[save.test] 全绿' : `[save.test] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -0,0 +1,174 @@
/**
* scripts/scene-smoke.mjs 山海巡异录渲染层冒烟(mock 场景重放;2026-07-06 战斗全空事故防回潮门)
* owner:北极星顶级线③(M2 增补)
*
* 为什么有这门战斗全空事故:run-scene `const txt` 声明位于工厂 return 之后 TDZ 死绑定
* 首次 rebuild 即抛 ReferenceError 地图/战斗/商店整屏绘制全部无声失败,只有事件浮字可见
* headless (ctx.game=null)不建场景,场景代码零执行绘制路径的异常只有真浏览器暴露
* 本门用记录型 mock scene把场景 create/update node 里真执行:七屏全驱动异常零容忍
* 每屏绘制调用计数下限自证钩子(api.debug 回写)断言mock 覆盖不了 Phaser 内部渲染行为
* (那是 CDP 像素/displayCount 复验的事),我们代码在绘制路径上的任何异常在此现形
*
* 用法:node scripts/scene-smoke.mjs(退出码 0=全过)
*/
'use strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createRunCore } from '../src/run-core.js';
import { createSeededRandom } from '../src/util/seeded-random.js';
import { createRunScene } from '../src/scenes/run-scene.js';
import { hitTarget } from '../src/ui/layout.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { chooseTap } = require(join(__dir, '../../../harness/card-battle.driver.js'));
let failures = 0;
function assert(cond, name, detail) {
if (cond) console.log(` PASS ${name}` + (detail ? `${detail}` : ''));
else { console.error(` FAIL ${name}` + (detail ? `${detail}` : '')); failures++; }
}
/** 记录型 mock scene:add.* 全记数;未知链式方法一律 no-op(mock 面窄于真 Phaser,只抓我们代码的异常)。 */
function makeMockScene() {
const counts = { adds: 0, destroyed: 0 };
const mkChain = () => new Proxy({}, {
get: (t, k) => {
if (k === 'destroy') return () => { counts.destroyed++; };
return (...a) => mkChained; // 任意方法 → 链式返回同代理
},
});
// 链式代理单例(所有方法都返回它自己,destroy 记数)。
const mkChained = new Proxy({}, {
get: (t, k) => {
if (k === 'destroy') return () => { counts.destroyed++; };
return () => mkChained;
},
});
const addObj = () => { counts.adds++; return mkChained; };
return {
counts,
add: { text: addObj, circle: addObj, rectangle: addObj, graphics: addObj, container: addObj },
time: { delayedCall: () => {} }, // 演出排程不执行(演出面归 CDP)
tweens: { add: () => {} },
cameras: { main: { setBackgroundColor: () => {}, shake: () => {} } },
input: { on: () => {} },
};
}
console.log('[scene-smoke] 《山海巡异录》渲染层冒烟(mock 场景重放)');
const seed = 424242;
const run = createRunCore({ random: createSeededRandom(seed) }, { seed });
const debug = { displayCount: 0, rebuilds: 0, screen: null };
const sceneObj = createRunScene({
run: () => run,
debug,
route: (x, y) => {
const t = hitTarget(run.readState().tapTargets, x, y);
if (!t) return;
if (t.kind === 'card') run.tapCard(t.index);
else if (t.kind === 'enemy') run.confirmPlay();
else if (t.kind === 'endTurn') run.endTurn();
else if (t.kind === 'node') run.chooseNode(t.id);
else if (t.kind === 'reward') run.chooseReward(t.index);
else if (t.kind === 'rewardSkip') run.chooseReward('skip');
else if (t.kind === 'relicReward') run.chooseRelicReward(t.index);
else if (t.kind === 'relicSkip') run.chooseRelicReward('skip');
else if (t.kind === 'shopCard') run.buyCard(t.index);
else if (t.kind === 'shopRelic') run.buyRelic(t.index);
else if (t.kind === 'shopRemove') run.openRemove();
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件
},
});
const scene = makeMockScene();
/** 推 n 帧 update;抛异常=门失败(战斗全空事故的直接口径)。 */
function frames(n, label) {
for (let i = 0; i < n; i++) {
try { sceneObj.update.call(scene); } catch (e) {
assert(false, `${label}:update 第 ${i + 1} 帧抛异常(绘制路径断裂)`, String(e && e.message));
return false;
}
}
return true;
}
// ── C1 create 零异常 ──
try { sceneObj.create.call(scene); assert(true, 'C1 create() 零异常'); }
catch (e) { assert(false, 'C1 create() 零异常', String(e && e.message)); }
// ── C2 七屏驱动:贪心把整局打到终态,逐屏累计绘制计数;每到一个新屏断言「本屏有绘制」──
const seenScreens = new Map(); // screen → 首见时 rebuild 后的 uiNodes 计数
let lastScreen = null;
let ok = true;
for (let step = 0; step < 20000 && ok; step++) {
const s = run.readState();
if (s.phase !== 'run') break;
if (!frames(1, `C2[${s.screen}]`)) { ok = false; break; }
const cur = debug.screen;
if (cur && cur !== lastScreen) {
lastScreen = cur;
if (!seenScreens.has(cur)) seenScreens.set(cur, debug.displayCount);
}
const t = chooseTap(s);
if (!t) break;
// 直接语义调用(与 route 同表;mock 门不做坐标命中)。
if (t.kind === 'card') run.tapCard(t.index);
else if (t.kind === 'enemy') run.confirmPlay();
else if (t.kind === 'endTurn') run.endTurn();
else if (t.kind === 'node') run.chooseNode(t.id);
else if (t.kind === 'reward') run.chooseReward(t.index);
else if (t.kind === 'rewardSkip') run.chooseReward('skip');
else if (t.kind === 'relicReward') run.chooseRelicReward(t.index);
else if (t.kind === 'relicSkip') run.chooseRelicReward('skip');
else if (t.kind === 'shopCard') run.buyCard(t.index);
else if (t.kind === 'shopRelic') run.buyRelic(t.index);
else if (t.kind === 'shopRemove') run.openRemove();
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else break;
}
if (ok) frames(2, 'C2[terminal]'); // 终局屏重绘
// 屏覆盖与每屏绘制下限(map/battle 是主战场;shop/reward/relicReward 依局路径,断言「见过的屏都画了东西」)。
assert(seenScreens.has('map') && seenScreens.has('battle'), 'C2a 主屏覆盖(map+battle 必见)', JSON.stringify([...seenScreens.keys()]));
let allDrew = true; let detail = [];
for (const [screen, count] of seenScreens) {
detail.push(`${screen}=${count}`);
if (!(count >= 5)) allDrew = false; // 任何屏 rebuild 后 <5 个显示对象=形同白屏
}
assert(allDrew, 'C2b 每个到达屏绘制对象 ≥5(白屏即败)', detail.join(' / '));
// 终局遮罩本体=35 件(lose 3 件:遮罩+标题+战果行+提示,win 多一行)——阈值按最小屏 3。
assert(debug.rebuilds > 0 && debug.displayCount >= 3, 'C2c 自证钩子回写(rebuilds>0 且终局屏 displayCount≥3)', `rebuilds=${debug.rebuilds} display=${debug.displayCount}`);
console.log(` · 屏覆盖:${detail.join(' / ')};终态=${run.readState().phase};mock 总 add=${scene.counts.adds}`);
// ── C3 选中态重绘(战斗内点选卡的 sig 变化路径,历史上易被签名漏掉)──
{
const run2 = createRunCore({ random: createSeededRandom(7) }, { seed: 7 });
const dbg2 = { displayCount: 0, rebuilds: 0, screen: null };
const so2 = createRunScene({ run: () => run2, debug: dbg2, route: () => {} });
const sc2 = makeMockScene();
so2.create.call(sc2);
run2.chooseNode(run2.readState().map.selectable[0]);
so2.update.call(sc2);
const before = dbg2.rebuilds;
run2.tapCard(0); // 选中(ui.selected 变 → sig 变 → 重建)
try {
so2.update.call(sc2);
assert(dbg2.rebuilds === before + 1, 'C3 选中态触发重绘(sig 含 ui.selected)', `rebuilds ${before}${dbg2.rebuilds}`);
} catch (e) {
assert(false, 'C3 选中态重绘零异常', String(e && e.message));
}
}
console.log(failures === 0 ? '[scene-smoke] 全绿' : `[scene-smoke] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -1,22 +1,21 @@
/**
* scripts/sim-shell.mjs 山海巡异录快进仿真批跑外壳(M2 S8b v0 S12 v1;node 直跑)
* owner:北极星顶级线③(M2)
* scripts/sim-shell.mjs 山海巡异录快进仿真批跑外壳(M2 S8b v0 S12 v1 M3 S14 三轴全矩阵)
* owner:北极星顶级线③(M2/M3)
*
* 这份是什么设计书 §6.4数值 CI批跑入口:矩阵扫描(职业×进阶×策略档×n )
* 格聚合(src/sim/sim-run.js) 带判报警(SIM_BANDS,观测不拦) 报告(stdout 摘要 + dist/ JSON)
* 金标构筑回归=--goldens(S12;基线 scripts/goldens.json)
* 这份是什么设计书 §6.4数值 CI批跑入口:三轴矩阵扫描(职业 × 进阶 A0A15 × 策略档 × n )
* 格聚合(src/sim/sim-run.js) 带判报警(SIM_BANDS,观测不拦) 报告(stdout + dist/ JSON)
* 金标构筑回归=--goldens(基线 scripts/goldens.json,漂移 ±8pp 按套判)
*
* 矩阵现状M2 内容面=职业甲 × A0(进阶表随 M3 S14 启用后本壳按表扩轴,轴形状已留)
* 矩阵轴(M3 S14 全量)职业 {jia,yi} × 进阶 {0..15} × 书面三档 {greedy,chain,defensive}
* × 1000 / = 96,000 ( §6.4 口径);random/idle 两档为下界锚,只在 A0 跑观测
*
* 用法
* node scripts/sim-shell.mjs # 全矩阵(3 × --n,缺省 1000)
* node scripts/sim-shell.mjs --tier=pr # PR smoke 小矩阵(贪心档 × 200)
* node scripts/sim-shell.mjs --tier=nightly # nightly 全矩阵(3 × 1000)+ 金标全量
* node scripts/sim-shell.mjs --tier=batch # 批产冒烟(3 × 200;S9/S10 每批入表即跑)
* node scripts/sim-shell.mjs --goldens # 金标构筑回归( scripts/goldens.json 基线判漂移)
* --n=N --seedBase=S --out=dist/xxx.json 可覆写
* node scripts/sim-shell.mjs --tier=pr # PR smoke:2职业 × A0/A5/A15 × 贪心 × 200(书面小矩阵)+金标抽测
* node scripts/sim-shell.mjs --tier=batch # 批产冒烟:2职业 × A0 × 五档 × 200(内容入表即跑)
* node scripts/sim-shell.mjs --tier=nightly # 全矩阵 96,000 + 下界锚 + 金标全量
* --classes=jia,yi --ascensions=0,5,15 --strategies=greedy --n=N --seedBase=S --out= 自由组格
*
* 退出码0=跑完(带判越带只报警;M0M2 硬门仅 L1);1=结构性失败(stuck/guard 截断/金标漂移超线)
* 退出码0=跑完(带判越带只报警;M0M3 硬门仅 L1);1=结构性失败(stuck/guard/金标漂移超线)
*/
'use strict';
@ -25,75 +24,112 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { simulateRun, aggregateCell, STRATEGIES } from '../src/sim/sim-run.js';
import { SIM_BANDS, CARDS, RELICS } from '../src/data/tables.js';
import { simulateRun, aggregateCell, STRATEGIES, CANON_STRATEGIES } from '../src/sim/sim-run.js';
import { SIM_BANDS, CARDS, RELICS, CLASSES, ASCENSIONS } from '../src/data/tables.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');
// ── 参数解析(零依赖,--k=v)──
const argv = Object.fromEntries(process.argv.slice(2).map((a) => {
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
return m ? [m[1], m[2] != null ? m[2] : true] : [a, true];
}));
const ALL_ASC = Array.from({ length: ASCENSIONS.length + 1 }, (_, i) => i); // 0..15
/** CI 分层预设(§6.4;墙钟实测随本壳输出,定案写 README)。 */
const TIERS = {
pr: { strategies: ['greedy'], n: 200, goldens: 'sample' }, // PR smoke:贪心×200 + 金标抽测
nightly: { strategies: ['greedy', 'random', 'idle'], n: 1000, goldens: 'full' }, // 全矩阵 + 金标全量
batch: { strategies: ['greedy', 'random', 'idle'], n: 200, goldens: 'none' }, // S9/S10 批产入表冒烟
pr: { classes: ['jia', 'yi'], ascensions: [0, 5, 15], strategies: ['greedy'], n: 200, goldens: 'sample' },
batch: { classes: ['jia', 'yi'], ascensions: [0], strategies: Object.keys(STRATEGIES), n: 200, goldens: 'none' },
nightly: { classes: ['jia', 'yi'], ascensions: ALL_ASC, strategies: CANON_STRATEGIES, n: 1000, goldens: 'full', floorAnchors: true },
};
const tier = argv.tier && TIERS[argv.tier] ? argv.tier : null;
const strategies = (argv.strategies ? String(argv.strategies).split(',') : (tier ? TIERS[tier].strategies : ['greedy', 'random', 'idle']))
const preset = tier ? TIERS[tier] : {};
const classes = (argv.classes ? String(argv.classes).split(',') : (preset.classes || ['jia'])).filter((c) => CLASSES[c]);
const ascensions = (argv.ascensions ? String(argv.ascensions).split(',').map(Number) : (preset.ascensions || [0]))
.filter((a) => Number.isInteger(a) && a >= 0 && a <= ASCENSIONS.length);
const strategies = (argv.strategies ? String(argv.strategies).split(',') : (preset.strategies || CANON_STRATEGIES))
.filter((s) => STRATEGIES[s]);
const N = argv.n ? parseInt(argv.n, 10) : (tier ? TIERS[tier].n : 1000);
const N = argv.n ? parseInt(argv.n, 10) : (preset.n || 1000);
const seedBase = argv.seedBase ? parseInt(argv.seedBase, 10) : 50000;
const outPath = argv.out ? String(argv.out) : join(ROOT, 'dist', tier ? `sim-${tier}.json` : 'sim-scan.json');
const goldensMode = argv.goldens === true ? 'full' : (argv.goldens ? String(argv.goldens) : (tier ? TIERS[tier].goldens : 'none'));
const goldensMode = argv.goldens === true ? 'full' : (argv.goldens ? String(argv.goldens) : (preset.goldens || 'none'));
let structuralFailures = 0;
const warn = (msg) => console.warn(` WARN ${msg}`);
const fail = (msg) => { console.error(` FAIL ${msg}`); structuralFailures++; };
console.log(`[sim-shell] 矩阵=职业甲 × A0 × {${strategies.join(',')}} × ${N} 局/格;seedBase=${seedBase}${tier ? ` tier=${tier}` : ''}`);
const totalCells = classes.length * ascensions.length * strategies.length;
console.log(`[sim-shell] 矩阵={${classes.join(',')}} × A{${ascensions.join(',')}} × {${strategies.join(',')}} × ${N} 局/格 = ${totalCells}${totalCells * N} 局;seedBase=${seedBase}${tier ? ` tier=${tier}` : ''}`);
// ── 矩阵扫描 ──
// ── 三轴矩阵扫描 ──
const t0 = process.hrtime.bigint();
const cells = [];
for (const strategy of strategies) {
const results = [];
for (let i = 0; i < N; i++) {
results.push(simulateRun({ seed: seedBase + i * 13, strategy }));
}
const agg = aggregateCell(results, { cell: { class: 'jia', ascension: 0, strategy } });
cells.push(agg);
for (const heroClass of classes) {
for (const ascension of ascensions) {
for (const strategy of strategies) {
const results = [];
// seed 按格错开(进阶/职业散列),格内步进 13(与 M2 单轴口径同源)。
const cellSeed = (seedBase + ascension * 104729 + (heroClass === 'yi' ? 7919 : 0)) >>> 0;
for (let i = 0; i < N; i++) {
results.push(simulateRun({ seed: cellSeed + i * 13, strategy, runOpts: { heroClass, ascension } }));
}
const agg = aggregateCell(results, { cell: { class: heroClass, ascension, strategy } });
cells.push(agg);
if (agg.stucks > 0) fail(`${heroClass}/A${ascension}/${strategy} stuck/guard 截断 ${agg.stucks} 局(策略死角或护栏空转)`);
if (agg.invalids > 0) warn(`${heroClass}/A${ascension}/${strategy} invalid ${agg.invalids} 局(动作护栏;指纹见报告)`);
// 结构性失败(与带判分开:这些是仿真环自身的红线,任何档都不许有)。
if (agg.stucks > 0) fail(`${strategy} 档 stuck/guard 截断 ${agg.stucks} 局(策略死角或护栏空转)`);
// 带判(观测/报警;校准对象=贪心档)。
const band = SIM_BANDS.winRateBands[0];
if (strategy === 'greedy') {
const inBand = agg.winRate >= band[0] && agg.winRate <= band[1];
console.log(` ${inBand ? 'PASS' : 'WARN'} A0 贪心档胜率 ${(agg.winRate * 100).toFixed(1)}%(带 ${band[0] * 100}${band[1] * 100}%,σ≈${agg.winRateSigmaPp}pp)${inBand ? '' : ' —— 越带,调参轮处理'}`);
if (agg.bossTurns.grindOverBand > 0 || agg.bossTurns.burstUnderBand > 0) {
warn(`Boss 回合带外:磨(>${SIM_BANDS.bossTurnBand[1]}回合)×${agg.bossTurns.grindOverBand} / 秒(<${SIM_BANDS.bossTurnBand[0]})×${agg.bossTurns.burstUnderBand}(共 ${agg.bossTurns.count} 场)`);
// 带判(观测/报警):贪心档 × 有带进阶层(0/5/15)。
const band = SIM_BANDS.winRateBands[ascension];
if (strategy === 'greedy' && band) {
const inBand = agg.winRate >= band[0] && agg.winRate <= band[1];
console.log(` ${inBand ? 'PASS' : 'WARN'} ${CLASSES[heroClass].name} A${ascension} 贪心胜率 ${(agg.winRate * 100).toFixed(1)}%(带 ${band[0] * 100}${band[1] * 100}%,σ≈${agg.winRateSigmaPp}pp)${inBand ? '' : ' —— 越带'}`);
} else if (strategy === 'greedy' || N >= 1000) {
console.log(` INFO ${heroClass}/A${ascension}/${strategy} 胜率 ${(agg.winRate * 100).toFixed(1)}%(观测)`);
}
}
}
}
// 下界锚(nightly:random/idle 在 A0 各 1000;观测敌线致死性,不入死卡口径)。
if (preset.floorAnchors) {
for (const heroClass of classes) {
for (const strategy of ['random', 'idle']) {
const results = [];
for (let i = 0; i < 1000; i++) results.push(simulateRun({ seed: seedBase + i * 13, strategy, runOpts: { heroClass, ascension: 0 } }));
const agg = aggregateCell(results, { cell: { class: heroClass, ascension: 0, strategy, anchor: true } });
cells.push(agg);
console.log(` INFO ${heroClass}/A0/${strategy} 下界锚胜率 ${(agg.winRate * 100).toFixed(1)}%`);
}
if (agg.peakTurnDmg.monopolyRatio != null && agg.peakTurnDmg.monopolyRatio > SIM_BANDS.monopolyMax) {
warn(`峰值回合伤 P99/P50=${agg.peakTurnDmg.monopolyRatio} 超反垄断线 ${SIM_BANDS.monopolyMax}(某流派一枝独秀,削它别抬别人)`);
}
if (agg.deadCards.length) warn(`死卡候选(<${SIM_BANDS.adoptionBand.dead * 100}% 采用):${agg.deadCards.join(',')}`);
if (agg.mustPicks.length) warn(`必选候选(>${SIM_BANDS.adoptionBand.mustPick * 100}% 采用):${agg.mustPicks.join(',')}`);
} else {
console.log(` INFO A0 ${strategy} 档胜率 ${(agg.winRate * 100).toFixed(1)}%(下界观测,非校准对象)`);
}
if (agg.invalids > 0) warn(`${strategy} 档 invalid ${agg.invalids} 局(动作护栏触发;指纹样本见报告)`);
console.log(` · ${strategy}:win ${agg.wins}/${agg.n},均战 ${agg.avgBattlesWon},Boss回合 P50=${agg.bossTurns.p50},墙钟 ${agg.wallMs}ms(${agg.msPerRun}ms/局)`);
}
const scanMs = Number(process.hrtime.bigint() - t0) / 1e6;
// ── 金标构筑回归(S12;每套固定构筑 × 固定 seed 集,胜率漂移按套判)──
// ── 跨档观测:死卡并集(书面三档;A0 格)与指纹去重率(M3 门 >85%〔草案〕)──
{
const canonA0 = cells.filter((c) => c.cell && !c.cell.anchor && c.cell.ascension === 0 && CANON_STRATEGIES.includes(c.cell.strategy));
if (canonA0.length) {
const adopted = new Set();
const seen = new Set();
for (const c of canonA0) {
for (const [id, rate] of Object.entries(c.adoption)) {
seen.add(id);
if (rate >= SIM_BANDS.adoptionBand.dead) adopted.add(id);
}
}
const dead = [...seen].filter((id) => !adopted.has(id));
if (dead.length) warn(`死卡候选(书面三档并集 <3%):${dead.join(',')}`);
else console.log(' PASS 死卡并集为空(书面三档下所有出场卡均 ≥3% 采用)');
for (const c of canonA0) {
if (c.fingerprintUniqueRate != null && c.wins >= 20) {
const okFp = c.fingerprintUniqueRate > 0.85;
console.log(` ${okFp ? 'PASS' : 'WARN'} ${c.cell.class}/A0/${c.cell.strategy} 构筑指纹去重率 ${(c.fingerprintUniqueRate * 100).toFixed(1)}%(门 >85%〔草案〕,胜局 ${c.wins})`);
}
}
}
}
// ── 金标构筑回归 ──
let goldensReport = null;
if (goldensMode !== 'none' && goldensMode !== undefined) {
const goldensPath = join(ROOT, 'scripts', 'goldens.json');
@ -112,7 +148,7 @@ if (goldensMode !== 'none' && goldensMode !== undefined) {
results.push(simulateRun({
seed: goldens.seedBase + i * 7,
strategy: suite.strategy || 'greedy',
runOpts: { deck: suite.deck, relics: suite.relics },
runOpts: { deck: suite.deck, relics: suite.relics, heroClass: suite.class || 'jia', ascension: suite.ascension || 0 },
}));
}
const wins = results.filter((r) => r.phase === 'win').length;
@ -130,12 +166,12 @@ if (goldensMode !== 'none' && goldensMode !== undefined) {
// ── 报告落盘 ──
const report = {
what: '快进仿真矩阵扫描(§6.4 数值 CI)',
what: '快进仿真三轴矩阵扫描(§6.4 数值 CI;M3 S14 全量轴)',
date: new Date().toISOString().slice(0, 10),
host: `${process.platform}/${process.arch} node ${process.version}(开发机;CI 机型另测)`,
tier: tier || 'custom',
matrix: { class: ['jia'], ascension: [0], strategies, nPerCell: N, seedBase },
tables: { cards: CARDS.length, relics: RELICS.length },
matrix: { classes, ascensions, strategies, nPerCell: N, seedBase, totalRuns: totalCells * N },
tables: { cards: CARDS.length, relics: RELICS.length, ascensionLevels: ASCENSIONS.length },
bands: SIM_BANDS,
cells,
goldens: goldensReport,
@ -144,5 +180,5 @@ const report = {
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(report, null, 2));
console.log(`[sim-shell] 总墙钟 ${report.totalWallMs}ms(扫描 ${report.scanWallMs}ms);报告 → ${outPath}`);
console.log(`[sim-shell] 总墙钟 ${Math.round(report.totalWallMs / 100) / 10}s(扫描 ${Math.round(report.scanWallMs / 100) / 10}s);报告 → ${outPath}`);
process.exit(structuralFailures === 0 ? 0 : 1);

View File

@ -26,7 +26,7 @@ const fail = (msg) => { console.error(` FAIL ${msg}`); failures++; };
console.log(`[static-screen] 卡池 ${CARDS.length} 张;深度 ≤3 经济环快筛 + 估值黄牌`);
// ── E0 经济词汇封闭性(折算器认知集 = 表中实际经济词集;漂移即红)──
const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount']);
const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount', 'ifZhoutian']);
{
const seen = new Set();
for (const c of CARDS) {
@ -34,6 +34,7 @@ const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount']
// 产能量/产抽卡的词才是经济词;伤害/护甲/词缀/血价不构成经济环。
if (k === 'energy' || k === 'draw' || k === 'costChainDiscount') seen.add(k);
if (k === 'chainBonus' && (c.effects.chainBonus.energy || c.effects.chainBonus.draw)) seen.add('chainBonus');
if (k === 'ifZhoutian' && (c.effects.ifZhoutian.energy || c.effects.ifZhoutian.draw)) seen.add('ifZhoutian');
}
}
const unknown = [...seen].filter((w) => !ECON_WORDS.has(w));
@ -62,6 +63,8 @@ const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount']
if (c.effects.chainBonus.energy) netEnergy += c.effects.chainBonus.energy;
if (c.effects.chainBonus.draw) netDraw += c.effects.chainBonus.draw;
}
// ifZhoutian(M3):周天须五行全环,深度 ≤3 序列数学上不可能触发——本筛零入账即精确;
// ≥4 卡的周天经济环归运行时兜底层+指纹观测(§6.3 分层设防,快筛不越界宣称)。
netDraw -= 1; // 打出即消耗一张手牌
prevEl = c.element; // 延续与破序殊途同归:链尾都是本卡元素(破序=重开链,链首=本卡)
}

View File

@ -113,7 +113,8 @@ export function createBattleCore(deps, opts = {}) {
},
enemy: {
id: enemyDef.id, name: enemyDef.name, hpMax: enemyDef.hpMax, hp: enemyDef.hpMax,
block: 0, strength: 0, intentIndex: 0,
block: 0, strength: opts.enemyStartStrength || 0, intentIndex: 0, // M3 进阶 A15:敌开战怀力
tier: enemyDef.tier || 'normal', // M1 增列(奖励与展示消费)
phase2: false, // Boss 相位标记(intents2 激活后为真)
// M2 状态词缀(敌侧;易伤/虚弱=回合末-1,灼=敌行动后结算层伤再-1)。
@ -424,6 +425,21 @@ export function createBattleCore(deps, opts = {}) {
const healed = Math.min(fx.heal.n, state.hero.hpMax - state.hero.hp);
if (healed > 0) { state.hero.hp += healed; emit({ t: 'heal', n: healed, source: cardId }); }
}
// M3 乙职业身份词:周天已开(本回合)则附加效果(链优 payoff;伤/甲吃倍率,资源类不吃)。
if (fx.ifZhoutian && state.chain.zhoutianUsed) {
const z = fx.ifZhoutian;
if (z.dmg) {
const zd = dealToEnemy(Math.floor(z.dmg * mult));
emit({ t: 'damage', target: 'enemy', amount: Math.floor(z.dmg * mult), dealt: zd, seg: 1, of: 1, cardId, mult, source: 'ifZhoutian' });
}
if (z.block) {
const zb = Math.floor(z.block * mult) + state.hero.toughness;
state.hero.block += zb;
emit({ t: 'blockGain', target: 'hero', n: zb, cardId, mult, source: 'ifZhoutian' });
}
if (z.draw) drawN(z.draw);
if (z.energy) { state.energy += z.energy; emit({ t: 'energyGain', n: z.energy, source: cardId }); }
}
if (fx.vulnerable) { state.enemy.statuses.vulnerable += fx.vulnerable.n; emit({ t: 'statusApplied', target: 'enemy', status: 'vulnerable', n: fx.vulnerable.n, cardId }); }
if (fx.weak) { state.enemy.statuses.weak += fx.weak.n; emit({ t: 'statusApplied', target: 'enemy', status: 'weak', n: fx.weak.n, cardId }); }
if (fx.burn) { state.enemy.statuses.burn += fx.burn.n; emit({ t: 'statusApplied', target: 'enemy', status: 'burn', n: fx.burn.n, cardId }); }

View File

@ -155,13 +155,132 @@ export const CARDS = [
{ id: 'shui-canghai', name: '沧海月明', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, block: { n: 3 } }, text: '抽 2 张牌,获得 3 点护甲。' },
{ id: 'shui-hantan', name: '寒潭映斗', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, chainBonus: { block: 3 } }, text: '获得 3 点护甲;若延续灵脉,追加 3 点。' },
{ id: 'shui-beiming', name: '北溟鲲跃', element: '水', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 9, times: 1 }, draw: { n: 2 }, energy: { n: 1 } }, text: '鲲跃北溟,造成 9 点伤害,抽 2 张牌,得 1 点能量。' },
// ══ M3 S13 职业乙「璇玑使」66 卡(链优;星曜×山海命名,clean-room;class:'yi')══
// ── 乙·木(13:普6罕5稀2)──
{ id: 'yi-mu-luoteng', class: 'yi', name: '萝藤续脉', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 5 点伤害;若延续灵脉,费用 -1。' },
{ id: 'yi-mu-ximu', class: 'yi', name: '析木问天', element: '木', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 2 }, hpCost: { n: 1 } }, text: '失去 1 点气血,抽 2 张牌。' },
{ id: 'yi-mu-ruomu', class: 'yi', name: '若木凝露', element: '木', rarity: '普通', cost: 0, type: '御', effects: { block: { n: 2 } }, text: '获得 2 点护甲。' },
{ id: 'yi-mu-fumu', class: 'yi', name: '扶木朝阳', element: '木', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, heal: { n: 2 } }, text: '造成 8 点伤害,回复 2 点气血。' },
{ id: 'yi-mu-linggen2', class: 'yi', name: '灵根盘结', element: '木', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, chainBonus: { block: 2 } }, text: '获得 3 点护甲;若成链,追加 2 点。' },
{ id: 'yi-mu-tangong', class: 'yi', name: '檀弓发矢', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 }, chainBonus: { dmg: 3 } }, text: '造成 6 点伤害;若成链,追加 3 点。' },
{ id: 'yi-mu-sangzhe', class: 'yi', name: '桑柘轮转', element: '木', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 2 }, costChainDiscount: { n: 1 } }, text: '抽 2 张牌;若延续灵脉,费用 -1。' },
{ id: 'yi-mu-futi', class: 'yi', name: '建木浮梯', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 2 }, energy: { n: 1 }, hpCost: { n: 3 } }, text: '失去 3 点气血,抽 2 张牌,得 1 点能量。' },
{ id: 'yi-mu-pantao', class: 'yi', name: '蟠桃酿', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { heal: { n: 5 }, block: { n: 4 }, exhaust: true }, text: '回复 5 点气血,获得 4 点护甲。(消耗)' },
{ id: 'yi-mu-qingdi', class: 'yi', name: '青帝手书', element: '木', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 6 }, chainBonus: { draw: 1 } }, text: '获得 6 点护甲;若成链,抽 1 张牌。' },
{ id: 'yi-mu-kuae', class: 'yi', name: '夸娥负山', element: '木', rarity: '罕见', cost: 3, type: '御', effects: { block: { n: 11 }, vulnerable: { n: 1 } }, text: '获得 11 点护甲,施加 1 回合易伤。' },
{ id: 'yi-mu-taiyi', class: 'yi', name: '太一青符', element: '木', rarity: '稀有', cost: 2, type: '术', effects: { draw: { n: 2 }, toughness: { n: 1 }, ifZhoutian: { energy: 2 } }, text: '抽 2 张牌,获得 1 点韧;本回合已开周天,得 2 点能量。' },
{ id: 'yi-mu-dachun', class: 'yi', name: '青阳大椿', element: '木', rarity: '稀有', cost: 3, type: '御', effects: { block: { n: 9 }, heal: { n: 4 }, toughness: { n: 1 } }, text: '获得 9 点护甲与 1 点韧,回复 4 点气血。' },
// ── 乙·火(13:普6罕5稀2)──
{ id: 'yi-huo-yinghuo', class: 'yi', name: '荧惑守心', element: '火', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, burn: { n: 2 } }, text: '造成 5 点伤害,施加 2 层灼。' },
{ id: 'yi-huo-zhuming', class: 'yi', name: '朱明引', element: '火', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '朱明之火,造成 7 点伤害。' },
{ id: 'yi-huo-liuhuo', class: 'yi', name: '流火纪', element: '火', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 2, times: 1 }, burn: { n: 1 } }, text: '造成 2 点伤害,施加 1 层灼。' },
{ id: 'yi-huo-tanggu', class: 'yi', name: '汤谷沐日', element: '火', rarity: '普通', cost: 2, type: '术', effects: { burn: { n: 6 }, draw: { n: 1 } }, text: '施加 6 层灼,抽 1 张牌。' },
{ id: 'yi-huo-yangwu', class: 'yi', name: '阳乌啼', element: '火', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, vulnerable: { n: 1 } }, text: '造成 8 点伤害,施加 1 回合易伤。' },
{ id: 'yi-huo-zhulou', class: 'yi', name: '烛楼远照', element: '火', rarity: '普通', cost: 1, type: '术', effects: { burn: { n: 4 }, costChainDiscount: { n: 1 } }, text: '施加 4 层灼;若延续灵脉,费用 -1。' },
{ id: 'yi-huo-huoji', class: 'yi', name: '火纪通明', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 5, times: 2 }, ifZhoutian: { dmg: 8 } }, text: '造成 5 点伤害 2 段;本回合已开周天,追加 8 点。' },
{ id: 'yi-huo-yandi', class: 'yi', name: '炎帝遗策', element: '火', rarity: '罕见', cost: 1, type: '术', effects: { burn: { n: 4 }, draw: { n: 1 } }, text: '施加 4 层灼,抽 1 张牌。' },
{ id: 'yi-huo-zhurong2', class: 'yi', name: '祝融巡南', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, burn: { n: 3 } }, text: '造成 9 点伤害,施加 3 层灼。' },
{ id: 'yi-huo-ligong', class: 'yi', name: '离宫九变', element: '火', rarity: '罕见', cost: 2, type: '术', effects: { burn: { n: 8 }, vulnerable: { n: 1 } }, text: '施加 8 层灼与 1 回合易伤。' },
{ id: 'yi-huo-xiaming', class: 'yi', name: '霞明玉阶', element: '火', rarity: '罕见', cost: 1, type: '御', effects: { block: { n: 3 }, burn: { n: 2 } }, text: '获得 3 点护甲,施加 2 层灼。' },
{ id: 'yi-huo-jinwu2', class: 'yi', name: '金乌浴海', element: '火', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 7, times: 2 }, burn: { n: 6 }, hpCost: { n: 3 } }, text: '失去 3 点气血,造成 7 点伤害 2 段,施加 6 层灼。' },
{ id: 'yi-huo-huoling', class: 'yi', name: '火铃夷则', element: '火', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 1 }, dmgPerEnemyBurn: { n: 2 }, burn: { n: 3 } }, text: '造成 6 点伤害,敌身每层灼追加 2 点,再施 3 层灼。' },
// ── 乙·土(13:普6罕5稀2)──
{ id: 'yi-tu-wuse', class: 'yi', name: '五色壤', element: '土', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' },
{ id: 'yi-tu-jishi', class: 'yi', name: '积石传书', element: '土', rarity: '普通', cost: 1, type: '术', effects: { block: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点护甲,抽 1 张牌。' },
{ id: 'yi-tu-lianshan', class: 'yi', name: '磏山铁壁', element: '土', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 7 }, chainBonus: { block: 2 } }, text: '获得 7 点护甲;若成链,追加 2 点。' },
{ id: 'yi-tu-xuanyin', class: 'yi', name: '后土玄印', element: '土', rarity: '普通', cost: 1, type: '术', effects: { toughness: { n: 1 }, block: { n: 2 } }, text: '获得 1 点韧与 2 点护甲。' },
{ id: 'yi-tu-changyang', class: 'yi', name: '常羊拜岳', element: '土', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, toughness: { n: 1 } }, text: '造成 9 点伤害,获得 1 点韧。' },
{ id: 'yi-tu-zhenyue2', class: 'yi', name: '镇岳一击', element: '土', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '岳势压顶,造成 7 点伤害。' },
{ id: 'yi-tu-xiushe', class: 'yi', name: '修蛇壤穴', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 7 }, weak: { n: 1 } }, text: '获得 7 点护甲,施加 1 回合虚弱。' },
{ id: 'yi-tu-bugang', class: 'yi', name: '步罡踏斗', element: '土', rarity: '罕见', cost: 1, type: '术', effects: { block: { n: 2 }, toughness: { n: 1 }, costChainDiscount: { n: 1 } }, text: '获得 2 点护甲与 1 点韧;若延续灵脉,费用 -1。' },
{ id: 'yi-tu-daiyu', class: 'yi', name: '岱舆孤峙', element: '土', rarity: '罕见', cost: 3, type: '御', effects: { block: { n: 10 }, ifZhoutian: { block: 8 } }, text: '获得 10 点护甲;本回合已开周天,追加 8 点。' },
{ id: 'yi-tu-butian', class: 'yi', name: '息壤补天', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 5 }, heal: { n: 3 } }, text: '获得 5 点护甲,回复 3 点气血。' },
{ id: 'yi-tu-guitian', class: 'yi', name: '圭田祝岁', element: '土', rarity: '罕见', cost: 2, type: '术', effects: { toughness: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点韧,抽 1 张牌。' },
{ id: 'yi-tu-buzhou', class: 'yi', name: '不周残柱', element: '土', rarity: '稀有', cost: 3, type: '御', effects: { block: { n: 11 }, vulnerable: { n: 2 } }, text: '获得 11 点护甲,施加 2 回合易伤。' },
{ id: 'yi-tu-yugao', class: 'yi', name: '帝山玉膏', element: '土', rarity: '稀有', cost: 2, type: '术', effects: { heal: { n: 6 }, toughness: { n: 2 }, exhaust: true }, text: '回复 6 点气血,获得 2 点韧。(消耗)' },
// ── 乙·金(13:普6罕5稀2)──
{ id: 'yi-jin-changgeng', class: 'yi', name: '长庚曳白', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '长庚曳白光,造成 7 点伤害。' },
{ id: 'yi-jin-shenshang', class: 'yi', name: '参商斫桂', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 4, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 4 点伤害;若延续灵脉,费用 -1。' },
{ id: 'yi-jin-baihu', class: 'yi', name: '白虎衔烛', element: '金', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 2 }, hpCost: { n: 2 } }, text: '失去 2 点气血,造成 6 点伤害 2 段。' },
{ id: 'yi-jin-yaoguang', class: 'yi', name: '摇光淬刃', element: '金', rarity: '普通', cost: 1, type: '术', effects: { strength: { n: 1 }, block: { n: 2 } }, text: '获得 1 点力与 2 点护甲。' },
{ id: 'yi-jin-jinsuo', class: 'yi', name: '金梭掷影', element: '金', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 2, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 2 点伤害;若延续灵脉,费用 -1(0 费不变)。' },
{ id: 'yi-jin-zhongshan', class: 'yi', name: '钟山错刀', element: '金', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 10, times: 1 }, weak: { n: 1 } }, text: '造成 10 点伤害,施加 1 回合虚弱。' },
{ id: 'yi-jin-rushou2', class: 'yi', name: '蓐收秉钺', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, vulnerable: { n: 1 }, draw: { n: 1 } }, text: '造成 8 点伤害,施加 1 回合易伤,抽 1 张牌。' },
{ id: 'yi-jin-baiying', class: 'yi', name: '白英鸣镝', element: '金', rarity: '罕见', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 }, chainBonus: { energy: 1 } }, text: '造成 6 点伤害;若成链,得 1 点能量。' },
{ id: 'yi-jin-xuanyuan', class: 'yi', name: '轩辕折戟', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 2 }, costChainDiscount: { n: 1 } }, text: '造成 6 点伤害 2 段;若延续灵脉,费用 -1。' },
{ id: 'yi-jin-liuyin', class: 'yi', name: '流银八荒', element: '金', rarity: '罕见', cost: 3, type: '攻', effects: { dmg: { n: 5, times: 3 }, vulnerable: { n: 1 } }, text: '造成 5 点伤害 3 段,施加 1 回合易伤。' },
{ id: 'yi-jin-tianqian', class: 'yi', name: '天钤密令', element: '金', rarity: '罕见', cost: 1, type: '术', effects: { strength: { n: 2 }, hpCost: { n: 2 } }, text: '失去 2 点气血,获得 2 点力。' },
{ id: 'yi-jin-changyi', class: 'yi', name: '常仪奏钺', element: '金', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 8, times: 2 }, ifZhoutian: { dmg: 12 } }, text: '造成 8 点伤害 2 段;本回合已开周天,追加 12 点。' },
{ id: 'yi-jin-juque', class: 'yi', name: '巨阙悬空', element: '金', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 13, times: 1 }, exhaust: true }, text: '巨阙一落,造成 13 点伤害。(消耗)' },
// ── 乙·水(14:普8罕4稀2)──
{ id: 'yi-shui-tianhan', class: 'yi', name: '天汉横流', element: '水', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 5 点伤害;若延续灵脉,费用 -1。' },
{ id: 'yi-shui-yaochi', class: 'yi', name: '瑶池映斗', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' },
{ id: 'yi-shui-ruoshui2', class: 'yi', name: '弱水拂尘', element: '水', rarity: '普通', cost: 1, type: '术', effects: { weak: { n: 1 }, draw: { n: 1 } }, text: '施加 1 回合虚弱,抽 1 张牌。' },
{ id: 'yi-shui-beidou', class: 'yi', name: '北斗酌浆', element: '水', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 2 } }, text: '斗柄酌天浆,抽 2 张牌。' },
{ id: 'yi-shui-xuanming2', class: 'yi', name: '玄冥司冬', element: '水', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 6 }, weak: { n: 1 } }, text: '获得 6 点护甲,施加 1 回合虚弱。' },
{ id: 'yi-shui-jianglou', class: 'yi', name: '降娄行雨', element: '水', rarity: '普通', cost: 0, type: '术', effects: { block: { n: 1 }, costChainDiscount: { n: 1 } }, text: '获得 1 点护甲;若延续灵脉,费用 -1(0 费不变)。' },
{ id: 'yi-shui-hanjiang', class: 'yi', name: '寒江拾星', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, heal: { n: 2 } }, text: '抽 2 张牌,回复 2 点气血。' },
{ id: 'yi-shui-tianshu', class: 'yi', name: '天枢洗剑', element: '水', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, draw: { n: 1 } }, text: '造成 8 点伤害,抽 1 张牌。' },
{ id: 'yi-shui-yinhe', class: 'yi', name: '银河倒泻', element: '水', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, dmgPerChainLink: { n: 2 } }, text: '造成 8 点伤害,灵脉每环追加 2 点。' },
{ id: 'yi-shui-gonggong2', class: 'yi', name: '共工触山', element: '水', rarity: '罕见', cost: 3, type: '攻', effects: { dmg: { n: 20, times: 1 }, hpCost: { n: 5 } }, text: '失去 5 点气血,造成 20 点伤害。' },
{ id: 'yi-shui-tianji', class: 'yi', name: '天玑量海', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 1 }, energy: { n: 1 }, hpCost: { n: 3 }, exhaust: true }, text: '失去 3 点气血,抽 1 张牌,得 1 点能量。(消耗)' },
{ id: 'yi-shui-xuangui2', class: 'yi', name: '玄龟曳尾', element: '水', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 7 }, chainBonus: { draw: 1 } }, text: '获得 7 点护甲;若成链,抽 1 张牌。' },
{ id: 'yi-shui-zhoutian', class: 'yi', name: '周天倒卷帘', element: '水', rarity: '稀有', cost: 2, type: '术', effects: { draw: { n: 2 }, ifZhoutian: { energy: 2, draw: 1 } }, text: '抽 2 张牌;本回合已开周天,得 2 点能量再抽 1 张。' },
{ id: 'yi-shui-beiming2', class: 'yi', name: '北溟震怒', element: '水', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 9, times: 1 }, weak: { n: 2 }, vulnerable: { n: 1 } }, text: '造成 9 点伤害,施加 2 回合虚弱与 1 回合易伤。' },
// ══ M3 S13 中立 22(行旅工具件;class:'neutral',两职业共入池)══
{ id: 'lu-ganliang', class: 'neutral', name: '行山干粮', element: '木', rarity: '普通', cost: 1, type: '术', effects: { heal: { n: 3 }, exhaust: true }, text: '回复 3 点气血。(消耗)' },
{ id: 'lu-tengzhang', class: 'neutral', name: '青藤杖', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 } }, text: '杖击,造成 6 点伤害。' },
{ id: 'lu-yaonang', class: 'neutral', name: '药囊', element: '木', rarity: '普通', cost: 2, type: '术', effects: { heal: { n: 5 }, block: { n: 2 }, exhaust: true }, text: '回复 5 点气血,获得 2 点护甲。(消耗)' },
{ id: 'lu-mupai', class: 'neutral', name: '里程碑石', element: '土', rarity: '普通', cost: 0, type: '术', effects: { draw: { n: 1 } }, text: '抽 1 张牌。' },
{ id: 'lu-qingnang', class: 'neutral', name: '青囊书', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 2 }, heal: { n: 2 } }, text: '抽 2 张牌,回复 2 点气血。' },
{ id: 'lu-huozhe', class: 'neutral', name: '火折子', element: '火', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 3, times: 1 } }, text: '掷出火折,造成 3 点伤害。' },
{ id: 'lu-xinghuo', class: 'neutral', name: '星火燧', element: '火', rarity: '普通', cost: 1, type: '术', effects: { burn: { n: 5 } }, text: '施加 5 层灼。' },
{ id: 'lu-nuanlu', class: 'neutral', name: '暖炉', element: '火', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, heal: { n: 1 } }, text: '获得 3 点护甲,回复 1 点气血。' },
{ id: 'lu-fengsui', class: 'neutral', name: '烽燧急报', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 7, times: 1 }, burn: { n: 4 } }, text: '造成 7 点伤害,施加 4 层灼。' },
{ id: 'lu-tunang', class: 'neutral', name: '土囊', element: '土', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' },
{ id: 'lu-shipan', class: 'neutral', name: '罗盘石', element: '土', rarity: '普通', cost: 1, type: '术', effects: { block: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点护甲,抽 1 张牌。' },
{ id: 'lu-zhenwen', class: 'neutral', name: '镇纹符', element: '土', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 6 }, vulnerable: { n: 1 } }, text: '获得 6 点护甲,施加 1 回合易伤。' },
{ id: 'lu-anying', class: 'neutral', name: '安营桩', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 5 }, toughness: { n: 1 } }, text: '获得 5 点护甲与 1 点韧。' },
{ id: 'lu-xiaodao', class: 'neutral', name: '行囊小刀', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '出刀,造成 7 点伤害。' },
{ id: 'lu-moshi', class: 'neutral', name: '磨石', element: '金', rarity: '普通', cost: 1, type: '术', effects: { strength: { n: 1 }, block: { n: 2 } }, text: '获得 1 点力与 2 点护甲。' },
{ id: 'lu-lingdang', class: 'neutral', name: '惊鸟铃', element: '金', rarity: '普通', cost: 1, type: '术', effects: { weak: { n: 2 } }, text: '施加 2 回合虚弱。' },
{ id: 'lu-duanzhen', class: 'neutral', name: '断阵刀', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, weak: { n: 1 } }, text: '造成 9 点伤害,施加 1 回合虚弱。' },
{ id: 'lu-shuinang', class: 'neutral', name: '皮水囊', element: '水', rarity: '普通', cost: 1, type: '术', effects: { heal: { n: 2 }, draw: { n: 1 } }, text: '回复 2 点气血,抽 1 张牌。' },
{ id: 'lu-suoyi', class: 'neutral', name: '蓑衣斗笠', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' },
{ id: 'lu-yudi', class: 'neutral', name: '雨笛', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, weak: { n: 1 } }, text: '抽 2 张牌,施加 1 回合虚弱。' },
{ id: 'lu-luopan', class: 'neutral', name: '水罗盘', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 1 }, costChainDiscount: { n: 1 }, block: { n: 2 } }, text: '抽 1 张牌,获得 2 点护甲;若延续灵脉,费用 -1。' },
{ id: 'lu-xingtu', class: 'neutral', name: '荧惑星图', element: '火', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 3 } }, text: '抽 3 张牌。' },
];
/** class 标定收尾(M3 S13):数组前 66 张=M0M2 存量职业甲卡,统一补 class:'jia'(乙/中立卡对象自带)。 */
for (const c of CARDS) { if (!c.class) c.class = 'jia'; }
/** M0 教学战卡组:首批 20 卡各 1 份(§11 M0 交付;M0 单战路与回归测试继续用它,字面不动)。 */
export const STARTER_DECK = CARDS.slice(0, 20).map((c) => c.id);
/** M1 ( 10,§3.2;; 5/ 2/ 3
* 首版 攻3 使贪心 driver 面对幕1 Boss dps 不足,回填提攻击密度草案) */
/** 职业表(M3 S13;书 §5.2 双职业:甲=攻优 HP72 / =链优 HP66;§6.2 数值锚)
* 卡池过滤口径:职业池 = class===本职业 + class==='neutral'(中立);
* CARDS class 缺省=(M2 存量 66 ,文件尾统一标定);/中立卡对象自带 class */
export const CLASSES = {
// starterRelic(M3 S14 平衡轮):职业起始遗物——谱系标准结构;乙=河图残卷(链自带 1 环,身份件),
// 甲=开明兽牙(战始力 1)。乙 HP 66→68:链优税在贪心档无镜像收益,首轮 16.1% 回填〔草案〕。
jia: { id: 'jia', name: '巡罡使', hpMax: 72, starterDeckKey: 'jia', starterRelic: 'kaiming-ya' }, // 攻优(M0M2 主角)
yi: { id: 'yi', name: '璇玑使', hpMax: 70, starterDeckKey: 'yi', starterRelic: 'hetu-juan' }, // 链优(M3 S13;66→68→70 平衡轮两轮回填:A15 尾段乙贪心 3.1% 出带的最后一格〔草案〕)
};
/** 乙(璇玑使)起始卡组(链优配比 攻3/御2/术5;全普通、五行齐备〔草案〕)。 */
export const YI_STARTER_DECK = [
// S14 平衡轮:①shenshang/jianglou→zhongshan/yaochi(16.1%→52.7%);②曾试 ximu→yinghuo 抬 A15,
// 灼起手全线 +15pp 冲破 A0/A5 上限,撤回——尾段缺口改走进阶表全局微软化(A0 不吃修正,零涟漪)〔草案〕
'yi-mu-luoteng', 'yi-mu-ximu',
'yi-huo-zhuming', 'yi-huo-zhulou',
'yi-tu-wuse', 'yi-tu-jishi',
'yi-jin-changgeng', 'yi-jin-zhongshan',
'yi-shui-beidou', 'yi-shui-yaochi',
];
export const RUN_STARTER_DECK = [
'mu-qingteng', 'mu-cangyin', 'mu-shengfa',
'huo-lihuo',
@ -370,6 +489,7 @@ export const MAP = {
restFloor: 5, // 憩点固定层(单节点)
bossFloor: 6, // Boss 层(单节点)
shopFloors: [2, 3, 4], // M2 S11:商店保底可落层(每幕恰 1,从仍为 battle 的节点改型)
eventsPerAct: 2, // M3 S16:每幕事件节点数(14 层 battle 改型;候选 ≤2 时保战斗密度让位)〔草案〕
// 幕缩放(逐幕表,普通/精英吃、Boss 定幕直调不吃):书 §6.2 锚「幕间 ×2.2」以玩家引擎
// 幕2末 ×45 为前提(职业全量卡池/升级面=M2 S9+)。M1 玩家成长面只有加卡+3 遗物,
// 首轮仿真回填(贪心 driver ×20)三轮迭代:2.2^n → 1.8^n → 1.4^n 均团灭,幂次复利是主凶,
@ -453,13 +573,297 @@ export const VALUATION = {
hardCap: 1.5, // 离谱线(单测硬线,超过=表失误)
};
/**
* 事件表(M3 S16; §5.2:45 = 幕池 12×3 + 通用 9低成本内容密度主源 §3.2:
* 一段文字+两三个选项+一个代价交换)acts:适用幕(null=通用池)
* 选项 fx 词汇(run-core 唯一消费方,收敛七词):
* gold{n}(±)/ heal{n}(回血)/ loseHp{n}(掉血,不吃甲)/ hpMax{n}(上限±,现血同步)/
* relic:true(随机未持有遗物)/ card{rarity}(随机该稀有度职业池卡入组)/ removeRandomCommon:true(随机删一张普通,>下限才生效)
* 数值全部草案·S14 平衡轮回填
*/
export const EVENTS = [
// ── 幕 1 池(12)──
{ id: 'ev-qiaofu', name: '迷途樵夫', acts: [1], text: '山道旁,樵夫指着两条岔路,愿以酒钱换你带他一程。', options: [
{ label: '带他走大路(得 25 金)', fx: { gold: 25 } },
{ label: '抄小路(失 6 气血,得 45 金)', fx: { loseHp: 6, gold: 45 } },
] },
{ id: 'ev-gubei', name: '斑驳古碑', acts: [1], text: '碑文半没于苔藓,依稀是前代巡使的手记。', options: [
{ label: '拓印研读(得罕见符)', fx: { card: '罕见' } },
{ label: '清整碑座(回复 8 气血)', fx: { heal: 8 } },
] },
{ id: 'ev-shanci', name: '山神小祠', acts: [1], text: '荒祠无人,香炉尚温。', options: [
{ label: '上香(花 20 金,回复 12 气血)', fx: { gold: -20, heal: 12 } },
{ label: '搜刮供品(得 30 金,失 4 气血)', fx: { gold: 30, loseHp: 4 } },
{ label: '离开', fx: {} },
] },
{ id: 'ev-shoushang', name: '受伤的异兽', acts: [1], text: '一只幼兽困在猎户的陷阱里,兽目含泪。', options: [
{ label: '救它(失 5 气血,得遗物)', fx: { loseHp: 5, relic: true } },
{ label: '绕开', fx: {} },
] },
{ id: 'ev-caiyao', name: '采药人', acts: [1], text: '背篓里的灵草还带着露水,采药人愿意匀你一株。', options: [
{ label: '买灵草(花 30 金,气血上限 +4)', fx: { gold: -30, hpMax: 4 } },
{ label: '闲聊几句(回复 5 气血)', fx: { heal: 5 } },
] },
{ id: 'ev-yeyu', name: '夜雨破庙', acts: [1], text: '雨夜借宿,庙中壁画里的兽影似乎会动。', options: [
{ label: '守夜观画(得普通符)', fx: { card: '普通' }, }, { label: '倒头就睡(回复 10 气血)', fx: { heal: 10 } },
] },
{ id: 'ev-duanjian', name: '断剑残冢', acts: [1], text: '剑冢无主,断剑上残留符纹。', options: [
{ label: '拔剑(失 8 气血,得稀有符)', fx: { loseHp: 8, card: '稀有' } },
{ label: '祭拜后离开(得 15 金)', fx: { gold: 15 } },
] },
{ id: 'ev-shanxi', name: '山溪淘金', acts: [1], text: '溪水里闪着碎金,也漂着不明的红藻。', options: [
{ label: '下水淘金(得 35 金,失 5 气血)', fx: { gold: 35, loseHp: 5 } },
{ label: '取水解渴(回复 6 气血)', fx: { heal: 6 } },
] },
{ id: 'ev-mubei2', name: '无名坟茔', acts: [1], text: '坟前供着半坛酒,碑上无字。', options: [
{ label: '添土祭拜(随机销一张普通符,心境澄明)', fx: { removeRandomCommon: true } },
{ label: '饮那半坛酒(回复 8 气血,失 15 金)', fx: { heal: 8, gold: -15 } },
] },
{ id: 'ev-luying', name: '猎户营地', acts: [1], text: '猎户们围着篝火分食烤肉,招手唤你同坐。', options: [
{ label: '同坐分食(回复 10 气血)', fx: { heal: 10 } },
{ label: '换购兽皮(花 25 金,得罕见符)', fx: { gold: -25, card: '罕见' } },
] },
{ id: 'ev-tongren', name: '同道巡使', acts: [1], text: '一位同门巡使正整理行囊,准备下山。', options: [
{ label: '交换心得(得普通符)', fx: { card: '普通' } },
{ label: '接济盘缠(失 20 金,气血上限 +3)', fx: { gold: -20, hpMax: 3 } },
{ label: '拱手道别', fx: {} },
] },
{ id: 'ev-guquan', name: '枯泉眼', acts: [1], text: '泉眼将枯,一尾小鱼在浅洼里挣扎。', options: [
{ label: '放生小鱼(得 20 金——鱼衔来的谢礼)', fx: { gold: 20 } },
{ label: '疏通泉眼(失 4 气血,回复上限 +4)', fx: { loseHp: 4, hpMax: 4 } },
] },
// ── 幕 2 池(12)──
{ id: 'ev-shiqiao', name: '石桥赌局', acts: [2], text: '桥头老者摆着三只倒扣的碗,笑而不语。', options: [
{ label: '押 30 金(五五之数,得 70 或血本无归——按天机定)', fx: { gold: 40 }, weightNote: '简化:期望正,定额结算' },
{ label: '不赌,付 10 金过桥', fx: { gold: -10 } },
] },
{ id: 'ev-yunlou', name: '云中楼阁', acts: [2], text: '半山云雾里浮着一座楼阁,梯口悬着铜铃。', options: [
{ label: '登楼(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } },
{ label: '摇铃即走(得 25 金)', fx: { gold: 25 } },
] },
{ id: 'ev-qimiao', name: '弃庙藏经', acts: [2], text: '经匣上的封条写着「勿启」。', options: [
{ label: '开匣(得罕见符,失 6 气血)', fx: { card: '罕见', loseHp: 6 } },
{ label: '原样供回(回复 8 气血)', fx: { heal: 8 } },
] },
{ id: 'ev-yaoshi', name: '妖市夜集', acts: [2], text: '雾里灯笼次第亮起,摊主们都戴着兽面。', options: [
{ label: '以血易宝(失 8 气血,得遗物)', fx: { loseHp: 8, relic: true } },
{ label: '以金易符(花 35 金,得罕见符)', fx: { gold: -35, card: '罕见' } },
{ label: '速速离开', fx: {} },
] },
{ id: 'ev-hanyu', name: '寒玉矿洞', acts: [2], text: '洞壁寒玉莹莹,凿声在深处回响。', options: [
{ label: '采玉(得 50 金,失 7 气血)', fx: { gold: 50, loseHp: 7 } },
{ label: '在洞口打坐(回复 12 气血)', fx: { heal: 12 } },
] },
{ id: 'ev-duanya', name: '断崖索道', acts: [2], text: '索道年久失修,崖底云海翻涌。', options: [
{ label: '走索道(失 9 气血,拾得 40 金)', fx: { loseHp: 9, gold: 40 } },
{ label: '绕远路(体力消耗,失 3 气血)', fx: { loseHp: 3 } },
] },
{ id: 'ev-guishu', name: '归墟商人', acts: [2], text: '商人自称来自海眼归墟,货物泛着咸腥的光。', options: [
{ label: '倾囊购宝(花 60 金,得遗物)', fx: { gold: -60, relic: true } },
{ label: '只买小件(花 25 金,得罕见符)', fx: { gold: -25, card: '罕见' } },
] },
{ id: 'ev-wushi', name: '巫祝问卜', acts: [2], text: '巫祝掷出龟甲,卦象晦暗不明。', options: [
{ label: '求解厄(花 30 金,随机销一张普通符)', fx: { gold: -30, removeRandomCommon: true } },
{ label: '求康健(花 30 金,气血上限 +5)', fx: { gold: -30, hpMax: 5 } },
{ label: '不问天命', fx: {} },
] },
{ id: 'ev-canjuan', name: '残卷飘零', acts: [2], text: '风里飘来半页符经,字迹被雨水洇开。', options: [
{ label: '拾起研读(得普通符)', fx: { card: '普通' } },
{ label: '任它去(心无挂碍,回复 6 气血)', fx: { heal: 6 } },
] },
{ id: 'ev-shou-qun', name: '兽群迁徙', acts: [2], text: '兽群踏过山谷,扬尘蔽日,队伍里有落单的幼崽。', options: [
{ label: '护送幼崽归群(失 6 气血,得遗物)', fx: { loseHp: 6, relic: true } },
{ label: '拾荒(兽群踩落的杂物,得 30 金)', fx: { gold: 30 } },
] },
{ id: 'ev-tishen', name: '替身草人', acts: [2], text: '道旁草人穿着巡使的旧袍,眉目被雨打得模糊。', options: [
{ label: '换上新袍(失 20 金,气血上限 +4)', fx: { gold: -20, hpMax: 4 } },
{ label: '取走旧符(得普通符,失 3 气血)', fx: { card: '普通', loseHp: 3 } },
] },
{ id: 'ev-yinquan', name: '饮泉辨毒', acts: [2], text: '双泉并涌,一甘一苦,苔色却相反。', options: [
{ label: '饮甘泉(回复 14 气血)', fx: { heal: 14 } },
{ label: '饮苦泉(失 5 气血,气血上限 +6)', fx: { loseHp: 5, hpMax: 6 } },
] },
// ── 幕 3 池(12)──
{ id: 'ev-guixu-yan', name: '归墟之眼', acts: [3], text: '海眼漩涡低鸣,万物之水在此归还。', options: [
{ label: '投金问渊(失 50 金,得遗物)', fx: { gold: -50, relic: true } },
{ label: '汲一瓢归墟水(失 8 气血,气血上限 +8)', fx: { loseHp: 8, hpMax: 8 } },
] },
{ id: 'ev-tianti', name: '天梯残基', acts: [3], text: '建木折断处只余残基,直上云霄的路早已不通。', options: [
{ label: '攀残基采灵材(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } },
{ label: '在基座冥思(回复 15 气血)', fx: { heal: 15 } },
] },
{ id: 'ev-zhouye', name: '昼夜倒悬', acts: [3], text: '此地烛阴的余息未散,日月同悬,影子朝着两个方向。', options: [
{ label: '借阴阳淬体(失 12 气血,气血上限 +10)', fx: { loseHp: 12, hpMax: 10 } },
{ label: '速速穿过(失 4 气血)', fx: { loseHp: 4 } },
] },
{ id: 'ev-wangchuan', name: '忘川渡口', acts: [3], text: '摆渡人不收金银,只收「记忆」。', options: [
{ label: '交出一段记忆(随机销一张普通符,回复 12 气血)', fx: { removeRandomCommon: true, heal: 12 } },
{ label: '泅渡(失 10 气血)', fx: { loseHp: 10 } },
] },
{ id: 'ev-fengshen', name: '封神残坛', acts: [3], text: '坛上符阵尚有余温,似乎还封着什么。', options: [
{ label: '续燃符阵(花 40 金,得遗物)', fx: { gold: -40, relic: true } },
{ label: '拆阵取材(得 45 金,失 6 气血)', fx: { gold: 45, loseHp: 6 } },
] },
{ id: 'ev-jinglong', name: '惊龙一瞥', acts: [3], text: '云层裂开一瞬,你与某只巨大的眼睛对视了。', options: [
{ label: '不退反进(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } },
{ label: '垂首避让(得 20 金——龙鳞落地成金)', fx: { gold: 20 } },
] },
{ id: 'ev-xumi', name: '须弥芥子', acts: [3], text: '一粒芥子悬在半空,里面隐约有山河。', options: [
{ label: '吞下芥子(失 8 气血,气血上限 +8)', fx: { loseHp: 8, hpMax: 8 } },
{ label: '装入行囊(得 55 金)', fx: { gold: 55 } },
] },
{ id: 'ev-canglong', name: '苍龙衔烛', acts: [3], text: '烛火自天而降,落在你的掌心不灭。', options: [
{ label: '并入符箓(得稀有符)', fx: { card: '稀有' } },
{ label: '借火疗伤(回复 16 气血)', fx: { heal: 16 } },
] },
{ id: 'ev-shanhaitu', name: '山海图残角', acts: [3], text: '图上兽形游走,一角空白正等着新的名字。', options: [
{ label: '以血描图(失 10 气血,得遗物)', fx: { loseHp: 10, relic: true } },
{ label: '收起残角(得 40 金)', fx: { gold: 40 } },
] },
{ id: 'ev-kunpeng', name: '鲲鹏过境', acts: [3], text: '天色骤暗,巨翼掠过,落下几枚风羽。', options: [
{ label: '拾羽炼符(得罕见符)', fx: { card: '罕见' } },
{ label: '乘风歇息(回复 12 气血)', fx: { heal: 12 } },
] },
{ id: 'ev-mingzhong', name: '暝钟自鸣', acts: [3], text: '古钟无人自鸣,声波荡开处尘埃浮空。', options: [
{ label: '以掌抵钟(失 8 气血,随机销一张普通符)', fx: { loseHp: 8, removeRandomCommon: true } },
{ label: '静听钟纹(得普通符)', fx: { card: '普通' } },
] },
{ id: 'ev-xingchen', name: '星辰坠地', acts: [3], text: '一枚星子砸出浅坑,余温灼人。', options: [
{ label: '徒手取星(失 9 气血,得 60 金)', fx: { loseHp: 9, gold: 60 } },
{ label: '围坑取暖(回复 10 气血)', fx: { heal: 10 } },
] },
// ── 通用池(9;任意幕)──
{ id: 'ev-xingjiao', name: '行脚商人', acts: null, text: '骡铃叮当,商人打开包袱:「都是山里淘换来的。」', options: [
{ label: '买符(花 28 金,得罕见符)', fx: { gold: -28, card: '罕见' } },
{ label: '卖旧物(得 22 金)', fx: { gold: 22 } },
] },
{ id: 'ev-gouhuo', name: '无主篝火', acts: null, text: '火堆还旺,旁边摆着烤好的干粮,主人却不见踪影。', options: [
{ label: '吃了再说(回复 9 气血)', fx: { heal: 9 } },
{ label: '添柴等主人(得 15 金——归来的猎人相谢)', fx: { gold: 15 } },
] },
{ id: 'ev-shanhong', name: '山洪断路', acts: null, text: '浊浪卷着断木冲下山谷,吊桥摇摇欲坠。', options: [
{ label: '抢渡(失 7 气血)', fx: { loseHp: 7 } },
{ label: '等水退(错过商机,失 15 金)', fx: { gold: -15 } },
] },
{ id: 'ev-qishou', name: '弃兽幼崽', acts: null, text: '兽穴空了,只剩一只幼崽蜷在枯草里。', options: [
{ label: '喂食安置(失 12 金,回复 6 气血——心安)', fx: { gold: -12, heal: 6 } },
{ label: '带去山神祠(得 18 金——祠祝的谢仪)', fx: { gold: 18 } },
] },
{ id: 'ev-duanfu', name: '断符残页', acts: null, text: '半张符纸粘在岩壁上,符胆尚存。', options: [
{ label: '补全符纸(得普通符)', fx: { card: '普通' } },
{ label: '取符胆换钱(得 20 金)', fx: { gold: 20 } },
] },
{ id: 'ev-jiuweng', name: '埋酒老翁', acts: null, text: '老翁在树下埋酒:「十年后再来挖。」他看了看你:「或者现在就喝。」', options: [
{ label: '对饮(回复 11 气血)', fx: { heal: 11 } },
{ label: '帮他埋酒(得 16 金)', fx: { gold: 16 } },
] },
{ id: 'ev-shibei', name: '试胆石碑', acts: null, text: '碑上刻着:「敢者留名。」碑下散落着前人的供钱。', options: [
{ label: '留名取钱(得 32 金,失 5 气血)', fx: { gold: 32, loseHp: 5 } },
{ label: '拱手离开', fx: {} },
] },
{ id: 'ev-yishi', name: '遗失的行囊', acts: null, text: '行囊挂在枝头,里面有干粮和一封没寄出的家书。', options: [
{ label: '物归原主(得 25 金——失主的谢礼)', fx: { gold: 25 } },
{ label: '取干粮充饥(回复 8 气血)', fx: { heal: 8 } },
] },
{ id: 'ev-wenquan', name: '野汤温泉', acts: null, text: '硫磺味的热气从石缝里涌出来。', options: [
{ label: '泡个痛快(回复 13 气血)', fx: { heal: 13 } },
{ label: '取泉华卖钱(得 24 金)', fx: { gold: 24 } },
] },
];
/**
* 成就表(M3 S15; §5.2 成就 40声明式条件,meta-core 解释器消费代码零成就逻辑字面量):
* winClass{cls} 用某职业通关 / winAscension{n} 任职业通关 An / winBoth 双职业通关 /
* wins{n} 累计通关数 / runs{n} 累计局数 / deckMaxAtWin{n} 胜局卡组 n / deckMinAtWin{n} 胜局卡组 n /
* hpFullWin 满血通关 / goldAtWin{n} 通关时金 n / removeTotal{n} 累计删卡 / zhoutianRun{n} 单局周天 n /
* collectCards{n} 图鉴卡 n / collectRelics{n} 图鉴遗物 n / collectEnemies{n} 图鉴敌兽 n /
* relicsAtWin{n} 通关时遗物 n / winTurnsUnder{n} 通关总回合 n / dailyPlayed 每日首战 / dailyScore{n} 每日分 n
*/
export const ACHIEVEMENTS = [
{ id: 'ach-first-win', name: '初巡功成', cond: { kind: 'wins', n: 1 }, text: '首次通关三幕。' },
{ id: 'ach-win-jia', name: '罡气纵横', cond: { kind: 'winClass', cls: 'jia' }, text: '用巡罡使通关。' },
{ id: 'ach-win-yi', name: '璇玑妙算', cond: { kind: 'winClass', cls: 'yi' }, text: '用璇玑使通关。' },
{ id: 'ach-win-both', name: '双璧巡天', cond: { kind: 'winBoth' }, text: '两位巡使都完成通关。' },
{ id: 'ach-wins-5', name: '五巡不怠', cond: { kind: 'wins', n: 5 }, text: '累计通关 5 局。' },
{ id: 'ach-wins-10', name: '十巡功德', cond: { kind: 'wins', n: 10 }, text: '累计通关 10 局。' },
{ id: 'ach-wins-25', name: '巡遍山海', cond: { kind: 'wins', n: 25 }, text: '累计通关 25 局。' },
{ id: 'ach-runs-10', name: '行者无疆', cond: { kind: 'runs', n: 10 }, text: '出巡 10 局。' },
{ id: 'ach-runs-30', name: '山海常客', cond: { kind: 'runs', n: 30 }, text: '出巡 30 局。' },
{ id: 'ach-runs-100', name: '百巡老手', cond: { kind: 'runs', n: 100 }, text: '出巡 100 局。' },
{ id: 'ach-asc1', name: '初登异阶', cond: { kind: 'winAscension', n: 1 }, text: '通关进阶一。' },
{ id: 'ach-asc3', name: '三阶不惧', cond: { kind: 'winAscension', n: 3 }, text: '通关进阶三。' },
{ id: 'ach-asc5', name: '五阶巡异', cond: { kind: 'winAscension', n: 5 }, text: '通关进阶五。' },
{ id: 'ach-asc8', name: '八阶履险', cond: { kind: 'winAscension', n: 8 }, text: '通关进阶八。' },
{ id: 'ach-asc10', name: '十阶问天', cond: { kind: 'winAscension', n: 10 }, text: '通关进阶十。' },
{ id: 'ach-asc15', name: '倾覆亦渡', cond: { kind: 'winAscension', n: 15 }, text: '通关进阶十五。' },
{ id: 'ach-thin-deck', name: '轻装简从', cond: { kind: 'deckMaxAtWin', n: 12 }, text: '以不超过 12 张的卡组通关。' },
{ id: 'ach-fat-deck', name: '有备无患', cond: { kind: 'deckMinAtWin', n: 30 }, text: '以 30 张以上的卡组通关。' },
{ id: 'ach-full-hp', name: '毫发无损', cond: { kind: 'hpFullWin' }, text: '满血状态下通关。' },
{ id: 'ach-rich', name: '腰缠万贯', cond: { kind: 'goldAtWin', n: 300 }, text: '通关时持金 300 以上。' },
{ id: 'ach-remove-10', name: '断舍离', cond: { kind: 'removeTotal', n: 10 }, text: '累计销卡 10 张。' },
{ id: 'ach-remove-30', name: '大道至简', cond: { kind: 'removeTotal', n: 30 }, text: '累计销卡 30 张。' },
{ id: 'ach-zhoutian-3', name: '周天三转', cond: { kind: 'zhoutianRun', n: 3 }, text: '单局触发周天 3 次。' },
{ id: 'ach-zhoutian-6', name: '周天六合', cond: { kind: 'zhoutianRun', n: 6 }, text: '单局触发周天 6 次。' },
{ id: 'ach-relics-6', name: '异宝盈袖', cond: { kind: 'relicsAtWin', n: 6 }, text: '通关时持有 6 枚以上遗物。' },
{ id: 'ach-relics-9', name: '百宝行囊', cond: { kind: 'relicsAtWin', n: 9 }, text: '通关时持有 9 枚以上遗物。' },
{ id: 'ach-fast-win', name: '兵贵神速', cond: { kind: 'winTurnsUnder', n: 70 }, text: '总回合数不超过 70 通关。' },
{ id: 'ach-cards-40', name: '符箓初集', cond: { kind: 'collectCards', n: 40 }, text: '图鉴收录 40 种符箓。' },
{ id: 'ach-cards-80', name: '符箓半壁', cond: { kind: 'collectCards', n: 80 }, text: '图鉴收录 80 种符箓。' },
{ id: 'ach-cards-120', name: '符海拾贝', cond: { kind: 'collectCards', n: 120 }, text: '图鉴收录 120 种符箓。' },
{ id: 'ach-cards-154', name: '万符归宗', cond: { kind: 'collectCards', n: 154 }, text: '图鉴收录全部 154 种符箓。' },
{ id: 'ach-relics-20', name: '鉴宝行家', cond: { kind: 'collectRelics', n: 20 }, text: '图鉴收录 20 枚遗物。' },
{ id: 'ach-relics-40', name: '宝录大成', cond: { kind: 'collectRelics', n: 40 }, text: '图鉴收录全部 40 枚遗物。' },
{ id: 'ach-enemies-4', name: '初识异类', cond: { kind: 'collectEnemies', n: 4 }, text: '图鉴收录 4 种异兽。' },
{ id: 'ach-enemies-8', name: '兽谱全览', cond: { kind: 'collectEnemies', n: 8 }, text: '图鉴收录全部 8 种异兽。' },
{ id: 'ach-daily-first', name: '每日一巡', cond: { kind: 'dailyPlayed' }, text: '完成一次每日一局。' },
{ id: 'ach-daily-1000', name: '当日魁首', cond: { kind: 'dailyScore', n: 1000 }, text: '每日一局得分 1000 以上。' },
{ id: 'ach-daily-1500', name: '名动山海', cond: { kind: 'dailyScore', n: 1500 }, text: '每日一局得分 1500 以上。' },
{ id: 'ach-asc-unlock-5', name: '阶梯行者', cond: { kind: 'ascUnlocked', n: 5 }, text: '任一职业解锁进阶五。' },
{ id: 'ach-asc-unlock-10', name: '阶梯尽头', cond: { kind: 'ascUnlocked', n: 10 }, text: '任一职业解锁进阶十。' },
];
/** 每日一局常量(M3 S15;书 §5.1 ⑭:全服同种子、本地最佳分)。 */
export const DAILY = {
// 分数公式常量(〔草案〕):胜=基石 1000 + 余血×10 + 金×1 - 总回合×2;负=战数×40 + 幕×100。
winBase: 1000, hpWeight: 10, goldWeight: 1, turnPenalty: 2, battleWeight: 40, actWeight: 100,
};
/**
* 进阶修正表(M3 S14; §3.2进阶阶梯:逐级叠加负面修正,每级独立成表可组合累积生效
* AN = A1..AN 全部 mods 叠加;A0=无修正基线数值全部草案·S14 平衡轮回填
* mods 词汇(run-core 聚合消费;Pct =百分比累加后一次乘,Delta =加区累加):
* enemyAtkPct/enemyHpPct 普通+精英 / eliteHpPct 精英血 / bossHpPct·bossAtkPct Boss /
* elitesPerActDelta 每幕精英配额 / restHealPctDelta 憩点回复比例 / startHpDelta 起始 HP /
* goldPct 战斗金币 / shopPricePct 商店与删卡价 / winHealDelta 战胜回复 / firstTurnEnemyStrength 敌开战力 /
* rewardChoicesDelta 战斗奖励选项数增减(A15:构筑面收紧轴战斗压力面 A13 已到顶,尾级换轴)
*/
export const ASCENSIONS = [
{ level: 1, text: '异动加剧:每幕精英 +1', mods: { elitesPerActDelta: 1 } },
{ level: 2, text: '兽爪更利:敌攻 +7%', mods: { enemyAtkPct: 7 } },
{ level: 3, text: '兽躯更韧:敌血 +7%', mods: { enemyHpPct: 7 } },
{ level: 4, text: '灵泉枯涩:憩点回复 -12%', mods: { restHealPctDelta: -0.12 } },
{ level: 5, text: '镇主凝形:首领血 +10%', mods: { bossHpPct: 10 } },
{ level: 6, text: '披挂出巡:起始气血 -6', mods: { startHpDelta: -6 } },
{ level: 7, text: '山货歉收:战斗金币 -10%', mods: { goldPct: -10 } },
{ level: 8, text: '兽爪愈利:敌攻再 +6%', mods: { enemyAtkPct: 6 } },
{ level: 9, text: '市价腾贵:商店与销卡价 +15%', mods: { shopPricePct: 15 } },
{ level: 10, text: '兽躯愈韧:敌血再 +4%', mods: { enemyHpPct: 4 } },
{ level: 11, text: '血气难续:战胜回复 -3', mods: { winHealDelta: -3 } },
{ level: 12, text: '精怪成群:精英血 +8%', mods: { eliteHpPct: 8 } },
{ level: 13, text: '镇主暴怒:首领攻 +6%', mods: { bossAtkPct: 6 } },
{ level: 14, text: '灵泉近涸:憩点回复再 -8%', mods: { restHealPctDelta: -0.08 } },
{ level: 15, text: '山海倾覆:机缘稀薄,战斗奖励改为二选一', mods: { rewardChoicesDelta: -1 } },
];
/**
* run 存档常量(M2 S11;形状=contracts/sdk-interface.d.ts SaveEnvelope:{schemaVersion1, data 纯对象},
* 键族='save:'+gameId+':'+versionId上限 65536 UTF-16 码元宿主 fail-closedW-SAVE-CAP 已入库)
* M2 介质=影子(localStorage 直写/内存),正式 SDK Storage 通道切换随集成段( §12,M5 硬门)
*/
export const SAVE = {
schemaVersion: 1, // 存档版本(信封 schemaVersion;表结构破坏性变更时升并配迁移)
schemaVersion: 2, // v2(M3 S15):data={run,meta} 双域;v1(M2,data=run 平铺)经迁移链升入——首个真实迁移
shadowKey: 'save:shanhai-xunyilu:dev', // 影子键(正式 versionId 随发布集成段注入)
maxChars: 65536, // save: 族上限(UTF-16 码元;契约口径)
};

View File

@ -1,93 +1,164 @@
/**
* src/main.js 山海巡异录入口(A3 entry · PhaserGameFactory 默认导出)
* owner:北极星顶级线③(M1;host 集成修复版)
* owner:北极星顶级线③(M1 host 修复版 M2 存档续玩 M3 S15 局外席:home/图鉴/成就/每日)
*
* 装载契约(tier2/contracts/boot-phaser-host.d.ts + boot-phaser-host.js;夜市款同构基准)
* 宿主(bootPhaserHost)自建唯一 Phaser.Game(canvas#tier2-game opts.parent),调本工厂建实例,
* boot(ctx) { game, canvas, seed, assets, caps, recHook }:
* - real 通道:ctx.game=宿主 Phaser.Game 本工厂把场景 add **宿主实例**并启动;
* - headless/node 通道:ctx.game=null 只建纯逻辑核,不触渲染(node import-safe)
* 宿主(bootPhaserHost)自建唯一 Phaser.Game,调本工厂建实例,boot(ctx) { game, canvas, seed, }:
* real 通道把场景 add 进宿主实例;headless(ctx.game=null)只建逻辑核,node import-safe
*
* 红线 · M0 事故复盘M0 版曾在 boot 里自建第二个 Phaser.Game 实例且未挂宿主 parent
* 页面出现游离 canvas 渲到视口外宿主可见画布全黑真指针打不到游戏(mini-desktop CDP 取证坐实)
* 本版修法:**工厂永不自建引擎实例**(全文件零 Phaser import),场景一律挂 ctx.game;
* run-smoke.mjs S0 静态自查断言钉住此红线防回潮
* 红线 · M0 事故工厂永不自建引擎实例(全文件零 Phaser import);run-smoke S0 静态自查钉死
*
* 输入单一路径injectInput('tap') 与场景真指针都走 route(x,y) readState().tapTargets
* 命中序(布局层唯一坐标事实) run-core 语义动作driver/CDP/真人三路同因果
* M3 模式机mode='home'|'run':boot 后进 home(选职业/进阶/每日/图鉴/成就;meta 由存档 v2 恢复);
* home tapTargets 由本工厂产(局外交互不属于一局,不进 run-core);开局/续玩切 'run'
* 终局:恰一次 recordRunEnd(成就/解锁/每日分) 点遮罩回 home;meta 常存(信封 v2 run=null 仍写)
*
* 输入单一路径injectInput('tap') 与场景真指针都走 route(x,y) tapTargets 命中序(home=工厂算,
* run=run-core/layout ) 语义动作driver/CDP/真人三路同因果
*/
'use strict';
import { createRunCore } from './run-core.js';
import { createMetaCore } from './meta-core.js';
import { createSeededRandom } from './util/seeded-random.js';
import { hitTarget } from './ui/layout.js';
import { hitTarget, computeHomeTargets } from './ui/layout.js';
import { createRunScene } from './scenes/run-scene.js';
import { SAVE_KEY, makeEnvelope, restoreRun, serializeEnvelope } from './save.js';
import { SAVE_KEY, makeEnvelope, restoreRun, restoreMeta, serializeEnvelope } from './save.js';
import { ASCENSIONS } from './data/tables.js';
const SCENE_KEY = 'shanhai-run';
/**
* 影子存档介质(M2 S11 口径,src/save.js 文件头已申报):浏览器径直写 localStorage 同名键;
* headless/node localStorage 则整段静默跳过(仿真与单测走 run.snapshot() 纯数据面,不经介质)
* 正式 SDK Storage 通道(postMessage storage 消息)切换随发布集成段( §12,M5 )
* 影子存档介质(M2 S11 口径;src/save.js 文件头申报):浏览器径直写 localStorage 同名键;
* headless/node localStorage 整段静默跳过正式 SDK Storage 切换随发布集成段( §12,M5 )
*/
const shadowStore = {
read() {
try {
const raw = globalThis.localStorage && globalThis.localStorage.getItem(SAVE_KEY);
return raw ? JSON.parse(raw) : null; // 影子介质游戏侧解析;正式通道由宿主受信边界解析(契约 StorageResultPayload)
return raw ? JSON.parse(raw) : null; // 影子介质游戏侧解析;正式通道由宿主受信边界解析
} catch (e) { return null; } // 坏 JSON/无介质 = 无档(fail-closed)
},
write(envelope) {
try {
if (!globalThis.localStorage) return;
const ser = serializeEnvelope(envelope);
if (!ser.ok) { console.error('[save] 存档超容弃写:', ser.chars); return; } // 超容响亮留痕,不静默截断
if (!ser.ok) { console.error('[save] 存档超容弃写:', ser.chars); return; } // 响亮留痕,不静默截断
globalThis.localStorage.setItem(SAVE_KEY, ser.json);
} catch (e) { /* 介质失败不阻游戏(存档是增强,不是主链路) */ }
},
clear() {
try { globalThis.localStorage && globalThis.localStorage.removeItem(SAVE_KEY); } catch (e) { /* 同上 */ }
},
};
/**
* PhaserGameFactory:宿主调它建 PhaserGameInstance(boot/readState/injectInput/destroy)
* @param {Record<string, unknown>} [factoryOpts] 工厂选项(M1 未用)
*/
export default function createGame(factoryOpts) {
/** @type {ReturnType<typeof createRunCore>|null} */
let run = null;
/** @type {ReturnType<typeof createMetaCore>} */
let metaCore = createMetaCore(null);
/** @type {any} 宿主 Phaser.Game(real 通道;headless 为 null)。 */
let hostGame = null;
let seed = 12345;
/** 局外模式机(M3):home=局外(main/collection/achievements 三子屏),run=一局。 */
let mode = 'home';
let homeScreen = 'main';
const homeSel = { class: 'jia', ascension: 0 };
let isDaily = false;
let runEnded = false; // recordRunEnd 去重旗(每局恰一次)
/** 场景自证面(战斗全空事故增补):rebuild 回写 displayCount/rebuilds/screen;headless 恒 null。 */
let sceneDebug = null;
/** 建/重建一局(seed 派生受控随机;重开局 seed+1 保持确定性谱系)。 */
function newRun() {
run = createRunCore({ random: createSeededRandom(seed >>> 0) }, { seed: seed >>> 0 });
}
function restart() {
seed = (seed + 1) >>> 0;
shadowStore.clear(); // 重开=旧档作废(单槽语义)
newRun();
/** 开一局(职业/进阶/每日种子;M3 局外入口)。 */
function startRun(cls, ascension, daily) {
isDaily = !!daily;
let runSeed = seed;
if (daily) {
runSeed = metaCore.dailySeedFor().seed; // 全服同种子(日期派生;榜单走平台既有能力)
} else {
seed = (seed + 1) >>> 0; // 普通局:种子步进(同会话重开不同局)
runSeed = seed;
}
run = createRunCore({ random: createSeededRandom(runSeed >>> 0) }, { seed: runSeed >>> 0, heroClass: cls, ascension });
mode = 'run';
runEnded = false;
persist();
}
/** 存档节拍:战斗外屏落定即快照(书 §3.4「战斗外任意节点杀进程重进」);终局清档。 */
/** 终局收口(恰一次):meta 结算(成就/解锁/每日分)→ 存档(run 域清空,meta 常存)。 */
function settleRunEnd() {
if (!run || runEnded) return;
const end = run.readState();
if (end.phase === 'run') return;
runEnded = true;
metaCore.recordRunEnd(end, { zhoutianCount: (end.stats && end.stats.zhoutianTotal) || 0, isDaily });
persist();
}
/** :home= meta;run=+meta(battle makeEnvelope run:null
* 注意:battle 屏不覆写上一节点快照,跳过写) */
function persist() {
if (!run) return;
const s = run.readState();
if (s.phase !== 'run') { shadowStore.clear(); return; }
if (s.screen === 'battle') return; // 战斗内不存:中断按上一节点快照续
const env = makeEnvelope(run);
if (env) shadowStore.write(env);
if (mode === 'run' && run) {
const s = run.readState();
if (s.phase !== 'run') { shadowStore.write(makeEnvelope(null, metaCore)); return; } // 终局:run 域清
if (s.screen === 'battle') return; // 战斗内不写:保上一节点快照(中断回节点语义)
shadowStore.write(makeEnvelope(run, metaCore));
} else {
shadowStore.write(makeEnvelope(null, metaCore));
}
}
/** home 屏投影(readState home 分支;tapTargets=computeHomeTargets 同源)。 */
function homeState() {
const meta = metaCore.readMeta();
const maxAsc = Math.max(0, Math.min(ASCENSIONS.length, meta.ascensionUnlocked[homeSel.class] || 0));
if (homeSel.ascension > maxAsc) homeSel.ascension = maxAsc;
const daily = metaCore.dailySeedFor();
const projection = {
schemaVersion: 2,
phase: 'home',
screen: homeScreen,
home: {
sel: { ...homeSel },
maxAscension: maxAsc,
ascensions: ASCENSIONS.map((a) => ({ level: a.level, text: a.text })),
daily,
meta,
},
debug: sceneDebug,
};
projection.tapTargets = computeHomeTargets(projection);
return projection;
}
/** home 屏语义路由(局外交互;工厂级)。 */
function routeHome(t) {
if (t.kind === 'homeClass') { homeSel.class = t.class; return; }
if (t.kind === 'ascMinus') { homeSel.ascension = Math.max(0, homeSel.ascension - 1); return; }
if (t.kind === 'ascPlus') {
const meta = metaCore.readMeta();
const maxAsc = meta.ascensionUnlocked[homeSel.class] || 0;
homeSel.ascension = Math.min(maxAsc, homeSel.ascension + 1);
return;
}
if (t.kind === 'homeStart') { startRun(homeSel.class, homeSel.ascension, false); return; }
if (t.kind === 'homeDaily') { startRun('jia', 0, true); return; } // 每日一局:固定甲 A0(全服同题面〔草案〕)
if (t.kind === 'homeCollection') { homeScreen = 'collection'; return; }
if (t.kind === 'homeAchievements') { homeScreen = 'achievements'; return; }
if (t.kind === 'homeBack') { homeScreen = 'main'; return; }
}
/**
* 语义路由:坐标 tapTargets 命中(数组序=优先级) run-core 动作
* 语义路由:坐标 tapTargets 命中(数组序=优先级) 语义动作
* 真指针(场景 pointerdown) injectInput(driver/CDP)共用,输入因果单源
*/
function route(x, y) {
if (mode === 'home') {
const t = hitTarget(homeState().tapTargets, x, y);
if (t) routeHome(t);
persist();
return;
}
if (!run) return;
const t = hitTarget(run.readState().tapTargets, x, y);
if (!t) return;
@ -106,39 +177,41 @@ export default function createGame(factoryOpts) {
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else if (t.kind === 'overlay') restart();
persist(); // 每次语义动作后按节拍存档(battle 屏内为 no-op,零热路径开销)
else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件
else if (t.kind === 'overlay') { settleRunEnd(); mode = 'home'; homeScreen = 'main'; persist(); return; } // 终局遮罩:回局外
// 巡览观测(图鉴收集,幂等)+ 终局结算 + 存档节拍。
metaCore.observe(run.readState());
settleRunEnd();
persist();
}
return {
/**
* 一次性启动:接宿主 boot 上下文real 通道把场景挂进宿主 Phaser.Game(装载契约红线:
* 不自建实例不游离挂载);headless(ctx.game=null)只建逻辑核
* @param {{ game?: any, canvas?: HTMLCanvasElement|null, seed?: number }} ctx
* 一次性启动:接宿主 boot 上下文;M3=恢复 meta+续玩(run 域有档直接进局),否则进 home
*/
async boot(ctx) {
seed = (ctx && ctx.seed != null ? ctx.seed : 12345) >>> 0;
// M2 S11 续玩:有合法影子档即恢复(fail-closed:坏档=开新局);?seed= 显式注入时视为要开新局。
const saved = shadowStore.read();
metaCore = createMetaCore(saved ? restoreMeta(saved) : null);
const resumed = saved ? restoreRun(saved) : null;
if (resumed) run = resumed;
else newRun();
if (resumed) { run = resumed; mode = 'run'; runEnded = false; }
else { mode = 'home'; }
hostGame = (ctx && ctx.game) || null;
if (!hostGame) return true; // headless/node 通道(宿主契约:不真渲染)
// 场景挂宿主实例并自启(宿主 HostScene 常驻管 latch 轮询,本场景只做游戏渲染与输入)。
hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, route }), true);
sceneDebug = { displayCount: 0, rebuilds: 0, screen: null }; // 真渲染通道才有自证面
hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, mode: () => mode, homeState, route, debug: sceneDebug }), true);
return true;
},
/** 语义状态(宿主逐帧轮询 phase 做 latch 门;直通 run-core,含 tapTargets)。 */
/** 语义状态(宿主逐帧轮询 phase 做 latch 门;home 态=局外投影,run 态=run-core 直通+debug)。 */
readState() {
return run ? run.readState() : { phase: 'booting' };
if (mode === 'home' || !run) return homeState();
const s = run.readState();
s.debug = sceneDebug; // 附加取证键(不进 run-core 状态树;场景签名不读它)
return s;
},
/**
* 归一化输入注入(driver/CDP 通道):{type:'tap',x,y} 走与真指针相同的语义路由
* @param {{type:string,x?:number,y?:number}} ev
*/
/** 归一化输入注入(driver/CDP 通道):与真指针同一语义路由。 */
injectInput(ev) {
if (ev && ev.type === 'tap') route(ev.x || 0, ev.y || 0);
},

View File

@ -23,7 +23,7 @@ import { MAP } from './data/tables.js';
* @param {number} act 幕号(1..MAP.acts)
* @returns {{nodes:Array<{id:string,floor:number,idx:number,type:string}>, edges:Array<[string,string]>}}
*/
export function generateActMap(rng, act) {
export function generateActMap(rng, act, genOpts = {}) {
const nodes = [];
const edges = [];
const floors = [];
@ -46,7 +46,7 @@ export function generateActMap(rng, act) {
nodes.push(...row);
}
// ── 配额:精英每幕 ≥1(24 层随机一位);宝匣恰 1(3 层随机一位,且不与精英同节点)──
// ── 配额:精英每幕 ≥1(24 层随机一位)+ 进阶 A1 追加(M3 genOpts.extraElites);宝匣恰 1 ──
const eliteFloor = MAP.eliteFloors[rng.range(0, MAP.eliteFloors.length - 1)];
const eliteRow = floors[eliteFloor];
const eliteIdx = rng.range(0, eliteRow.length - 1);
@ -86,6 +86,22 @@ export function generateActMap(rng, act) {
cand[rng.range(0, cand.length - 1)].type = 'shop';
}
// ── 增益件配额(保底件之后按余量;96k 全矩阵 A1 实抓「事件+追加精英挤兑商店保底」的修序)──
// 事件节点(M3 S16:每幕 ≤2,14 层仍为 battle 的随机改型;候选 ≤2 让位保战斗密度)。
for (let ev = 0; ev < (MAP.eventsPerAct || 0); ev++) {
const cand = [];
for (let f = 1; f <= 4; f++) floors[f].forEach((nd) => { if (nd.type === 'battle') cand.push(nd); });
if (cand.length <= 2) break;
cand[rng.range(0, cand.length - 1)].type = 'event';
}
// 进阶 A1 追加精英(M3 S14):24 层仍为 battle 的随机改型;无候选跳过(不挤占任何保底)。
for (let extra = 0; extra < (genOpts.extraElites || 0); extra++) {
const cand = [];
for (const f of MAP.eliteFloors) floors[f].forEach((nd) => { if (nd.type === 'battle') cand.push(nd); });
if (cand.length === 0) break;
cand[rng.range(0, cand.length - 1)].type = 'elite';
}
// ── 连边(近邻,保证双向覆盖:上层每节点 ≥1 出边、下层每节点 ≥1 入边)──
for (let f = 0; f < MAP.floorsPerAct - 1; f++) {
const cur = floors[f];

View File

@ -0,0 +1,193 @@
/**
* src/meta-core.js 山海巡异录局外核(M3 S15:图鉴/成就/进阶解锁/每日;纯逻辑层)
* owner:北极星顶级线③(M3)
*
* 这份是什么设计书 §8.3 四纯逻辑模块的第四块(battle/map/economy并入 run之后):
* run 的持久面图鉴收集(/遗物/敌兽,见过即录)成就 40(声明式条件解释器,
* ACHIEVEMENTS)进阶解锁(通关 An 解锁 An+1,逐职业)每日一局(日期种子+本地最佳分)
*
* 纯逻辑铁律 DOM/引擎 import;无随机(收集与判定都是确定性归纳);
* observe/recordRunEnd 幂等可重放(同一 run 状态重复喂不重复计数)
*
* 持久化serialize() 产纯对象, SaveEnvelope data.meta(v2 信封;src/save.js 统一管介质)
*/
'use strict';
import { ACHIEVEMENTS, ASCENSIONS, CLASSES, DAILY } from './data/tables.js';
/**
* 建局外核
* @param {Object|null} [saved] 存档回填(serialize() 的产物;缺省全新)
*/
export function createMetaCore(saved) {
const meta = {
collection: { cards: {}, relics: {}, enemies: {} }, // 图鉴:id → true(见过)
achievements: {}, // 成就:id → true(达成)
runsPlayed: 0,
wins: 0,
winsByClass: { jia: 0, yi: 0 },
ascensionUnlocked: { jia: 0, yi: 0 }, // 已解锁可选的最高进阶(通关 An 解锁 An+1)
removeTotal: 0, // 累计删卡(成就轴)
daily: { date: null, seed: null, bestScore: 0, played: false },
};
if (saved && typeof saved === 'object') {
// 逐域回填(未知键丢弃;缺键用缺省——向后兼容的窄回填)。
if (saved.collection) {
for (const k of ['cards', 'relics', 'enemies']) {
if (saved.collection[k] && typeof saved.collection[k] === 'object') meta.collection[k] = { ...saved.collection[k] };
}
}
if (saved.achievements && typeof saved.achievements === 'object') meta.achievements = { ...saved.achievements };
if (Number.isInteger(saved.runsPlayed)) meta.runsPlayed = saved.runsPlayed;
if (Number.isInteger(saved.wins)) meta.wins = saved.wins;
if (saved.winsByClass) meta.winsByClass = { jia: saved.winsByClass.jia || 0, yi: saved.winsByClass.yi || 0 };
if (saved.ascensionUnlocked) meta.ascensionUnlocked = { jia: saved.ascensionUnlocked.jia || 0, yi: saved.ascensionUnlocked.yi || 0 };
if (Number.isInteger(saved.removeTotal)) meta.removeTotal = saved.removeTotal;
if (saved.daily && typeof saved.daily === 'object') meta.daily = { date: saved.daily.date || null, seed: saved.daily.seed || null, bestScore: saved.daily.bestScore || 0, played: !!saved.daily.played };
}
/** 成就判定上下文(recordRunEnd 时的单局观测;跨局量从 meta 读)。 */
let lastRunCtx = null;
/** 声明式条件解释器(表 ACHIEVEMENTS.cond;新增 kind 必须在此登记——漏词=成就永不达成,单测钉全覆盖)。 */
function checkCond(cond, ctx) {
switch (cond.kind) {
case 'wins': return meta.wins >= cond.n;
case 'runs': return meta.runsPlayed >= cond.n;
case 'winClass': return meta.winsByClass[cond.cls] > 0;
case 'winBoth': return meta.winsByClass.jia > 0 && meta.winsByClass.yi > 0;
case 'winAscension': return ctx && ctx.win && ctx.ascension >= cond.n;
case 'deckMaxAtWin': return ctx && ctx.win && ctx.deckCount <= cond.n;
case 'deckMinAtWin': return ctx && ctx.win && ctx.deckCount >= cond.n;
case 'hpFullWin': return ctx && ctx.win && ctx.hp >= ctx.hpMax;
case 'goldAtWin': return ctx && ctx.win && ctx.gold >= cond.n;
case 'removeTotal': return meta.removeTotal >= cond.n;
case 'zhoutianRun': return ctx && ctx.zhoutianCount >= cond.n;
case 'relicsAtWin': return ctx && ctx.win && ctx.relicCount >= cond.n;
case 'winTurnsUnder': return ctx && ctx.win && ctx.turnsTotal <= cond.n;
case 'collectCards': return Object.keys(meta.collection.cards).length >= cond.n;
case 'collectRelics': return Object.keys(meta.collection.relics).length >= cond.n;
case 'collectEnemies': return Object.keys(meta.collection.enemies).length >= cond.n;
case 'dailyPlayed': return meta.daily.played;
case 'dailyScore': return meta.daily.bestScore >= cond.n;
case 'ascUnlocked': return Math.max(meta.ascensionUnlocked.jia, meta.ascensionUnlocked.yi) >= cond.n;
default: return false; // 未知词=永不达成(单测 M3 钉词表全覆盖,防静默)
}
}
/** 成就重扫(幂等;新达成返回列表供演出)。 */
function sweepAchievements(ctx) {
const newly = [];
for (const a of ACHIEVEMENTS) {
if (meta.achievements[a.id]) continue;
if (checkCond(a.cond, ctx)) {
meta.achievements[a.id] = true;
newly.push(a.id);
}
}
return newly;
}
return {
/**
* 巡览观测(工厂每次动作后喂 readState;幂等):图鉴收集卡组成员/持有遗物/在场敌兽
*/
observe(runState) {
if (!runState) return;
for (const id of runState.deck || []) meta.collection.cards[id] = true;
for (const r of runState.relics || []) meta.collection.relics[r.id] = true;
if (runState.battle && runState.battle.enemy) meta.collection.enemies[runState.battle.enemy.id] = true;
},
/**
* 终局结算(每局恰一次;调用方负责去重):局数/胜数/进阶解锁/每日分/成就重扫
* @param {Object} end 终局 readState 投影
* @param {{zhoutianCount?:number, isDaily?:boolean}} [extra] 局外补充观测(事件流来源)
* @returns {{newlyAchieved:string[], dailyScore:number|null}}
*/
recordRunEnd(end, extra = {}) {
const win = end.phase === 'win';
meta.runsPlayed += 1;
meta.removeTotal += (end.stats && end.stats.cardsRemoved) || 0;
if (win) {
meta.wins += 1;
if (meta.winsByClass[end.heroClass] != null) meta.winsByClass[end.heroClass] += 1;
// 进阶解锁:通关 An 解锁 An+1(封顶表长)。
const cur = meta.ascensionUnlocked[end.heroClass] || 0;
if (end.ascension >= cur && cur < ASCENSIONS.length) {
meta.ascensionUnlocked[end.heroClass] = Math.min(ASCENSIONS.length, end.ascension + 1);
}
}
// 每日分(仅每日局;分数公式=表 DAILY 常量)。
let dailyScore = null;
if (extra.isDaily) {
dailyScore = win
? DAILY.winBase + end.hero.hp * DAILY.hpWeight + end.gold * DAILY.goldWeight - (end.stats.turnsTotal || 0) * DAILY.turnPenalty
: (end.stats.battlesWon || 0) * DAILY.battleWeight + (end.act || 1) * DAILY.actWeight;
dailyScore = Math.max(0, Math.round(dailyScore));
meta.daily.played = true;
if (dailyScore > meta.daily.bestScore) meta.daily.bestScore = dailyScore;
}
lastRunCtx = {
win,
ascension: end.ascension || 0,
deckCount: end.deckCount,
hp: end.hero.hp,
hpMax: end.hero.hpMax,
gold: end.gold,
relicCount: (end.relics || []).length,
turnsTotal: (end.stats && end.stats.turnsTotal) || 0,
zhoutianCount: extra.zhoutianCount || 0,
};
const newlyAchieved = sweepAchievements(lastRunCtx);
return { newlyAchieved, dailyScore };
},
/** 今日种子(YYYYMMDD 数字;全服同种子的本地实现——服务端榜单走平台既有能力,书 §2)。 */
dailySeedFor(dateStr) {
const d = dateStr || new Date().toISOString().slice(0, 10);
if (meta.daily.date !== d) {
meta.daily = { date: d, seed: parseInt(d.replace(/-/g, ''), 10) >>> 0, bestScore: meta.daily.date === d ? meta.daily.bestScore : 0, played: false };
}
return { date: meta.daily.date, seed: meta.daily.seed };
},
/** 局外投影(home/图鉴/成就屏消费)。 */
readMeta() {
return {
collection: {
cards: Object.keys(meta.collection.cards).length,
relics: Object.keys(meta.collection.relics).length,
enemies: Object.keys(meta.collection.enemies).length,
cardIds: { ...meta.collection.cards },
relicIds: { ...meta.collection.relics },
enemyIds: { ...meta.collection.enemies },
},
achievements: { ...meta.achievements },
achievedCount: Object.keys(meta.achievements).length,
runsPlayed: meta.runsPlayed,
wins: meta.wins,
winsByClass: { ...meta.winsByClass },
ascensionUnlocked: { ...meta.ascensionUnlocked },
removeTotal: meta.removeTotal,
daily: { ...meta.daily },
classes: Object.values(CLASSES).map((c) => ({ id: c.id, name: c.name, hpMax: c.hpMax })),
};
},
/** 持久化面(SaveEnvelope data.meta;窄形状,与回填对偶)。 */
serialize() {
return {
collection: { cards: { ...meta.collection.cards }, relics: { ...meta.collection.relics }, enemies: { ...meta.collection.enemies } },
achievements: { ...meta.achievements },
runsPlayed: meta.runsPlayed,
wins: meta.wins,
winsByClass: { ...meta.winsByClass },
ascensionUnlocked: { ...meta.ascensionUnlocked },
removeTotal: meta.removeTotal,
daily: { ...meta.daily },
};
},
};
}

View File

@ -21,7 +21,8 @@
import { createBattleCore } from './battle-core.js';
import { generateActMap, validateActMap } from './map-core.js';
import {
TABLE_SCHEMA_VERSION, CARDS, ENEMIES, RELICS, MAP, ECONOMY, RUN_STARTER_DECK, CURVES,
TABLE_SCHEMA_VERSION, CARDS, ENEMIES, RELICS, MAP, ECONOMY, RUN_STARTER_DECK, YI_STARTER_DECK, CURVES,
CLASSES, ASCENSIONS, EVENTS,
} from './data/tables.js';
import { computeTapTargets } from './ui/layout.js';
@ -48,6 +49,26 @@ export function createRunCore(deps, opts = {}) {
throw new Error('[run-core] 必须注入受控随机源 random(next/range)'); // 错误路径留痕
}
// ── M3 职业与进阶(S13/S14)──
const restoredMeta = opts.restore || null; // 存档回填时职业/进阶随档(先于状态树构建)
const heroClass = restoredMeta && CLASSES[restoredMeta.heroClass] ? restoredMeta.heroClass
: (opts.heroClass && CLASSES[opts.heroClass] ? opts.heroClass : 'jia'); // 缺省甲(M0M2 兼容)
const classDef = CLASSES[heroClass];
const ascension = restoredMeta && Number.isInteger(restoredMeta.ascension) ? restoredMeta.ascension
: (Number.isInteger(opts.ascension) ? Math.max(0, Math.min(ASCENSIONS.length, opts.ascension)) : 0);
/** 进阶修正聚合(A1..AN 累积:Pct 累加一次乘,Delta 累加;A0=全零)。 */
const asc = (() => {
const m = { enemyAtkPct: 0, enemyHpPct: 0, eliteHpPct: 0, bossHpPct: 0, bossAtkPct: 0, elitesPerActDelta: 0, restHealPctDelta: 0, startHpDelta: 0, goldPct: 0, shopPricePct: 0, winHealDelta: 0, firstTurnEnemyStrength: 0, rewardChoicesDelta: 0 };
for (const a of ASCENSIONS) {
if (a.level > ascension) break;
for (const [k, v] of Object.entries(a.mods)) m[k] = (m[k] || 0) + v;
}
return m;
})();
/** 职业池(奖励/商店选卡口径):本职业专属 + 中立。 */
const CLASS_POOL = CARDS.filter((c) => c.class === heroClass || c.class === 'neutral');
const classStarter = heroClass === 'yi' ? YI_STARTER_DECK : RUN_STARTER_DECK;
/** M2 S11 restore :snapshot() data( save.js validateEnvelope )
* 回填=逐字段还原( rng 状态由调用方回填到 deps.random),restore 后继续玩与不中断玩逐字段一致 */
const restored = opts.restore || null;
@ -74,13 +95,15 @@ export function createRunCore(deps, opts = {}) {
screen: 'map', // map | battle | reward | relicReward | shop | shopRemove
act: 1,
gold: 0,
hero: { hp: CURVES.heroHpMax, hpMax: CURVES.heroHpMax }, // 职业甲 72(§6.2;跨战斗携带的长线资源)
deck: (Array.isArray(opts.deck) ? opts.deck.slice() : RUN_STARTER_DECK.slice()),
relics: (Array.isArray(opts.relics) ? opts.relics.slice() : []),
// HP=职业表值+进阶修正(甲72/乙66;A6 起始-8;§6.2 长线资源)。
hero: { hp: classDef.hpMax + asc.startHpDelta, hpMax: classDef.hpMax + asc.startHpDelta },
deck: (Array.isArray(opts.deck) ? opts.deck.slice() : classStarter.slice()),
// 起始遗物(M3 平衡轮:职业身份件;金标注入 opts.relics 时以注入为准——金标语义=已成型构筑)。
relics: (Array.isArray(opts.relics) ? opts.relics.slice() : (classDef.starterRelic ? [classDef.starterRelic] : [])),
position: /** @type {string|null} */ (null), // 当前节点 id(null=幕起点,可选 0 层)
visited: /** @type {string[]} */ ([]),
ui: { selected: -1 }, // 交互态(点选出牌两段式;driver 与真指针共享)
stats: { battlesWon: 0, elitesWon: 0, bossesWon: 0, cardsAdded: 0, relicsGained: 0, playsTotal: 0, turnsTotal: 0, nodesVisited: 0, cardsRemoved: 0, shopPurchases: 0 },
stats: { battlesWon: 0, elitesWon: 0, bossesWon: 0, cardsAdded: 0, relicsGained: 0, playsTotal: 0, turnsTotal: 0, nodesVisited: 0, cardsRemoved: 0, shopPurchases: 0, zhoutianTotal: 0 },
removeCount: 0, // 全局删卡次数(删价随之递增,跨商店持续——§3.2 给组合爆炸收租)
seed: opts.seed != null ? opts.seed : null,
};
@ -93,7 +116,7 @@ export function createRunCore(deps, opts = {}) {
/** 建当前幕地图(测试通道优先注入;生成/注入一律过静态可达自检,失败响亮抛)。 */
function buildActMap(act) {
const m = (opts.actMaps && opts.actMaps[act - 1]) || generateActMap(rng, act);
const m = (opts.actMaps && opts.actMaps[act - 1]) || generateActMap(rng, act, { extraElites: asc.elitesPerActDelta });
const v = validateActMap(m);
if (!v.ok) throw new Error(`[run-core] 幕 ${act} 地图自检失败:${v.problems.join(';')}`);
return m;
@ -119,6 +142,9 @@ export function createRunCore(deps, opts = {}) {
let relicReward = null;
/** 商店货架(shop/shopRemove 屏时非空):{cards:[{id,price,sold}], relics:[{id,price,sold}], freeRemoveUsed}。 */
let shop = null;
/** 当前事件(event 屏时非空):{id};eventsSeen=本局已遇事件(防重复)。 */
let currentEvent = null;
let eventsSeen = /** @type {string[]} */ ([]);
if (restored) {
// 屏内瞬态回填(reward/relicReward/shop 三屏可存档;battle 屏无快照,不在此列)。
if (restored.reward) reward = { options: restored.reward.options.slice(), canSkip: restored.reward.canSkip, after: restored.reward.after };
@ -130,6 +156,8 @@ export function createRunCore(deps, opts = {}) {
freeRemoveUsed: !!restored.shop.freeRemoveUsed,
};
}
if (restored.currentEvent) currentEvent = { ...restored.currentEvent };
if (Array.isArray(restored.eventsSeen)) eventsSeen = restored.eventsSeen.slice();
}
/** run 级事件队列(battle 事件在 sync 时合流;takeEvents 取走清空)。 */
@ -192,11 +220,23 @@ export function createRunCore(deps, opts = {}) {
}
// ── 内部:敌与缩放 ────────────────────────────────────────────────────────────
/** 幕缩放(普通/精英;Boss 定幕直调不缩放)——逐幕表 MAP.act*ScaleByAct(消幂次复利,回填纪律见表注)。 */
/** 幕缩放 × 进阶修正(M3 S14):普通/精英吃幕缩放(Boss 定幕直调不吃)+ 各 tier 吃进阶乘区。 */
function scaleEnemy(def, act) {
if (def.tier === 'boss' || act === 1) return def;
const hpK = MAP.actHpScaleByAct[Math.min(act, MAP.actHpScaleByAct.length) - 1];
const atkK = MAP.actAtkScaleByAct[Math.min(act, MAP.actAtkScaleByAct.length) - 1];
// 进阶乘区(A0 全零=恒等;Pct 累加后一次乘)。
let ascHpK = 1;
let ascAtkK = 1;
if (def.tier === 'boss') {
ascHpK = 1 + asc.bossHpPct / 100;
ascAtkK = 1 + asc.bossAtkPct / 100;
} else {
ascHpK = (1 + asc.enemyHpPct / 100) * (def.tier === 'elite' ? 1 + asc.eliteHpPct / 100 : 1);
ascAtkK = 1 + asc.enemyAtkPct / 100;
}
const actHpK = (def.tier === 'boss' || act === 1) ? 1 : MAP.actHpScaleByAct[Math.min(act, MAP.actHpScaleByAct.length) - 1];
const actAtkK = (def.tier === 'boss' || act === 1) ? 1 : MAP.actAtkScaleByAct[Math.min(act, MAP.actAtkScaleByAct.length) - 1];
const hpK = actHpK * ascHpK;
const atkK = actAtkK * ascAtkK;
if (hpK === 1 && atkK === 1) return def;
const scaleIntent = (it) => {
const out = { ...it };
if (it.kind === 'attack' || it.kind === 'attackBlock') out.n = Math.round(it.n * atkK);
@ -223,7 +263,7 @@ export function createRunCore(deps, opts = {}) {
const pool = ENEMIES.filter((e) => e.tier === 'elite' && e.acts.includes(state.act));
return scaleEnemy(pool[rng.range(0, pool.length - 1)], state.act);
}
if (state.act === 1 && node.floor === 0) return ENEMIES[0]; // 教学局固定首战「涸泽鲛」(§5.3)
if (state.act === 1 && node.floor === 0) return scaleEnemy(ENEMIES[0], state.act); // 教学局固定首战「涸泽鲛」(§5.3;敌种固定、数值仍吃进阶修正——R15g 抓的缺口:A 级不豁免首战)
const pool = ENEMIES.filter((e) => e.tier === 'normal' && e.acts.includes(state.act));
return scaleEnemy(pool[rng.range(0, pool.length - 1)], state.act);
}
@ -233,7 +273,8 @@ export function createRunCore(deps, opts = {}) {
battle = createBattleCore(
{ random: rng },
// battleOpts=测试通道透传(fixedDraw/actionLimit 等);run 管理键在后,不可被覆盖。
{ ...(opts.battleOpts || {}), enemy: enemyDef, heroHp: state.hero.hp, heroHpMax: state.hero.hpMax, deck: state.deck.slice(), relicHooks: relicHooks() },
// A15 敌开战力:限精英/首领(普通敌不带——尾段斜率实测回填,A15 全敌版=1% 出带)。
{ ...(opts.battleOpts || {}), enemy: enemyDef, heroHp: state.hero.hp, heroHpMax: state.hero.hpMax, deck: state.deck.slice(), relicHooks: relicHooks(), enemyStartStrength: (enemyDef.tier === 'elite' || enemyDef.tier === 'boss') ? asc.firstTurnEnemyStrength : 0 },
);
battleNodeType = node.type;
state.screen = 'battle';
@ -251,13 +292,13 @@ export function createRunCore(deps, opts = {}) {
const rarities = Object.keys(weights);
const total = rarities.reduce((s, r) => s + weights[r], 0);
const options = [];
const wantChoices = ECONOMY.cardChoices + (hooks.rewardExtraChoice || 0); // 三青鸟羽:四选一
const wantChoices = Math.max(2, ECONOMY.cardChoices + (hooks.rewardExtraChoice || 0) + asc.rewardChoicesDelta); // 三青鸟羽:四选一;A15:二选一(下限 2)
let guard = 0;
while (options.length < wantChoices && guard++ < 200) {
let roll = rng.next() * total;
let rarity = rarities[0];
for (const r of rarities) { roll -= weights[r]; if (roll <= 0) { rarity = r; break; } }
const pool = CARDS.filter((c) => c.rarity === rarity && !options.includes(c.id));
const pool = CLASS_POOL.filter((c) => c.rarity === rarity && !options.includes(c.id));
if (pool.length === 0) continue;
options.push(pool[rng.range(0, pool.length - 1)].id);
}
@ -270,7 +311,7 @@ export function createRunCore(deps, opts = {}) {
state.hero.hp = bs.hero.hp;
// 职业甲基线「凝血诀」:战胜回复(表 winHealFlat + 遗物 winHealBonus)。
const hooks = relicHooks();
const wh = Math.min(ECONOMY.winHealFlat + (hooks.winHealBonus || 0), state.hero.hpMax - state.hero.hp);
const wh = Math.min(Math.max(0, ECONOMY.winHealFlat + (hooks.winHealBonus || 0) + asc.winHealDelta), state.hero.hpMax - state.hero.hp);
if (wh > 0) { state.hero.hp += wh; emit({ t: 'heal', n: wh, source: 'win-heal' }); }
state.stats.turnsTotal += bs.turn;
state.stats.battlesWon += 1;
@ -279,7 +320,8 @@ export function createRunCore(deps, opts = {}) {
const band = ECONOMY.goldReward[battleNodeType === 'elite' ? 'elite' : battleNodeType === 'boss' ? 'boss' : 'normal'];
let gold = rng.range(band[0], band[1]);
if (hooks.goldPct) gold = Math.floor(gold * (1 + hooks.goldPct / 100)); // 乘黄鞍:战斗金币加成
const goldPctTotal = (hooks.goldPct || 0) + asc.goldPct; // 乘黄鞍 + 进阶 A7(累加一次乘)
if (goldPctTotal) gold = Math.floor(gold * (1 + goldPctTotal / 100));
state.gold += gold;
emit({ t: 'battleEnd', result: 'win', tier: battleNodeType, act: state.act });
emit({ t: 'goldGain', n: gold, source: 'battle' });
@ -312,13 +354,18 @@ export function createRunCore(deps, opts = {}) {
if (hooks.freeRemovePerShop && shop && !shop.freeRemoveUsed) return 0;
let price = ECONOMY.shop.removeBase + state.removeCount * ECONOMY.shop.removeStep;
if (hooks.shopDiscountPct) price = Math.floor(price * (1 - hooks.shopDiscountPct / 100));
if (asc.shopPricePct) price = Math.floor(price * (1 + asc.shopPricePct / 100)); // 进阶 A9
return price;
}
/** 进店:生成货架(卡按奖励权重抽互异、遗物未持有池抽、标价随折扣一次定死)。 */
function openShop() {
const hooks = relicHooks();
const disc = (p) => (hooks.shopDiscountPct ? Math.floor(p * (1 - hooks.shopDiscountPct / 100)) : p);
const disc = (p) => {
let out = hooks.shopDiscountPct ? Math.floor(p * (1 - hooks.shopDiscountPct / 100)) : p;
if (asc.shopPricePct) out = Math.floor(out * (1 + asc.shopPricePct / 100)); // 进阶 A9
return out;
};
const cardIds = [];
let guard = 0;
while (cardIds.length < ECONOMY.shop.cardSlots && guard++ < 200) {
@ -341,6 +388,17 @@ export function createRunCore(deps, opts = {}) {
emit({ t: 'shopEnter', cards: cards.length, relics: relics.length });
}
/** 进事件节点(M3 S16):按幕池+通用池抽未遇事件(受控随机;全遇过则回退通用池复用)。 */
function openEvent() {
let pool = EVENTS.filter((e) => (e.acts == null || e.acts.includes(state.act)) && !eventsSeen.includes(e.id));
if (pool.length === 0) pool = EVENTS.filter((e) => e.acts == null); // 兜底:通用池复用
const ev = pool[rng.range(0, pool.length - 1)];
eventsSeen.push(ev.id);
currentEvent = { id: ev.id };
state.screen = 'event';
emit({ t: 'eventStart', eventId: ev.id, name: ev.name });
}
/** 进下一幕:幕间整备恢复(表 bossHealPct)→ 重生成地图、位置归零。 */
function nextAct() {
const heal = Math.min(Math.floor(state.hero.hpMax * ECONOMY.bossHealPct), state.hero.hpMax - state.hero.hp);
@ -358,7 +416,10 @@ export function createRunCore(deps, opts = {}) {
/** 战斗事件合流 + 终态桥接(battle latch → run 级结算/latch)。 */
function syncBattle() {
if (!battle) return;
for (const e of battle.takeEvents()) events.push(e);
for (const e of battle.takeEvents()) {
if (e.t === 'zhoutian') state.stats.zhoutianTotal += 1; // M3 成就轴(单局周天计数)
events.push(e);
}
const phase = battle.readState().phase;
if (phase === 'win') settleBattleWin();
else if (phase === 'lose') {
@ -394,7 +455,8 @@ export function createRunCore(deps, opts = {}) {
if (node.type === 'battle' || node.type === 'elite' || node.type === 'boss') {
startBattle(node);
} else if (node.type === 'rest') {
const pct = ECONOMY.restHealPct + (relicHooks().restHealPctBonus || 0); // 若木灯:憩点回复加成
// 若木灯加成 + 进阶 A4/A14 减免(比例累加,下限 0.1 防负回复)。
const pct = Math.max(0.1, ECONOMY.restHealPct + (relicHooks().restHealPctBonus || 0) + asc.restHealPctDelta);
const heal = Math.min(Math.floor(state.hero.hpMax * pct), state.hero.hpMax - state.hero.hp);
state.hero.hp += heal;
emit({ t: 'heal', n: heal, source: 'rest' });
@ -402,10 +464,63 @@ export function createRunCore(deps, opts = {}) {
grantRelic('treasure');
} else if (node.type === 'shop') {
openShop();
} else if (node.type === 'event') {
openEvent();
}
return { ok: true };
},
/**
* 事件选项结算(event ;M3 S16)fx 七词见表 EVENTS 注释;结算后回 map
*/
chooseEventOption(index) {
if (terminal || state.screen !== 'event' || !currentEvent) return { ok: false, reason: 'wrong-screen' };
const ev = EVENTS.find((e) => e.id === currentEvent.id);
const opt = ev.options[index];
if (!opt) return { ok: false, reason: 'bad-index' };
const fx = opt.fx || {};
if (fx.gold) {
state.gold = Math.max(0, state.gold + fx.gold);
emit({ t: fx.gold > 0 ? 'goldGain' : 'goldSpend', n: Math.abs(fx.gold), source: 'event' });
}
if (fx.heal) {
const h = Math.min(fx.heal, state.hero.hpMax - state.hero.hp);
if (h > 0) { state.hero.hp += h; emit({ t: 'heal', n: h, source: 'event' }); }
}
if (fx.loseHp) {
state.hero.hp = Math.max(1, state.hero.hp - fx.loseHp); // 事件不致死(下限 1;死亡判定只在战斗)
emit({ t: 'hpCost', n: fx.loseHp, source: 'event' });
}
if (fx.hpMax) {
state.hero.hpMax = Math.max(1, state.hero.hpMax + fx.hpMax);
state.hero.hp = Math.min(Math.max(1, state.hero.hp + fx.hpMax), state.hero.hpMax);
emit({ t: 'hpMaxChange', n: fx.hpMax, source: 'event' });
}
if (fx.relic) grantRelic('event');
if (fx.card) {
const pool = CLASS_POOL.filter((c) => c.rarity === fx.card);
if (pool.length) {
const card = pool[rng.range(0, pool.length - 1)];
state.deck.push(card.id);
state.stats.cardsAdded += 1;
emit({ t: 'cardAdded', cardId: card.id, source: 'event' });
}
}
if (fx.removeRandomCommon && state.deck.length > ECONOMY.deckMin) {
const commons = state.deck.map((id, i) => ({ id, i })).filter((x) => CARD_BY_ID[x.id].rarity === '普通');
if (commons.length) {
const pick = commons[rng.range(0, commons.length - 1)];
state.deck.splice(pick.i, 1);
state.stats.cardsRemoved += 1;
emit({ t: 'cardRemoved', cardId: pick.id, price: 0, source: 'event' });
}
}
emit({ t: 'eventResolved', eventId: currentEvent.id, option: index });
currentEvent = null;
state.screen = 'map';
return { ok: true };
},
/** 点手牌(battle 屏;两段式:选中 → 再点同卡打出)。 */
tapCard(handIndex) {
if (terminal || state.screen !== 'battle' || !battle) return { ok: false, reason: 'wrong-screen' };
@ -589,6 +704,10 @@ export function createRunCore(deps, opts = {}) {
battle: battle ? battle.readState() : null,
reward: reward ? { options: reward.options.map((id) => ({ id, ...CARD_BY_ID[id] })), canSkip: reward.canSkip } : null,
relicReward: relicReward ? { options: relicReward.options.map((id) => ({ id, name: RELIC_BY_ID[id].name, text: RELIC_BY_ID[id].text })), canSkip: relicReward.canSkip } : null,
event: currentEvent ? (() => {
const ev = EVENTS.find((e) => e.id === currentEvent.id);
return { id: ev.id, name: ev.name, text: ev.text, options: ev.options.map((o) => ({ label: o.label, fx: o.fx || {} })) };
})() : null,
shop: shop ? {
cards: shop.cards.map((c) => ({ ...c, ...CARD_BY_ID[c.id] })),
relics: shop.relics.map((r) => ({ ...r, name: RELIC_BY_ID[r.id].name, text: RELIC_BY_ID[r.id].text })),
@ -601,6 +720,8 @@ export function createRunCore(deps, opts = {}) {
ui: { selected: state.ui.selected },
stats: { ...state.stats },
seed: state.seed,
heroClass,
ascension,
};
projection.tapTargets = computeTapTargets(projection);
return projection;
@ -617,6 +738,8 @@ export function createRunCore(deps, opts = {}) {
if (typeof rng.state !== 'function') return null; // 随机源不支持状态导出(测试桩)则不产快照
return {
tableSchemaVersion: TABLE_SCHEMA_VERSION, // 表版本对账位(表破坏性变更时迁移判据)
heroClass, // M3:职业与进阶随档(restore 经 opts 回填)
ascension,
rngState: rng.state(),
screen: state.screen,
act: state.act,
@ -631,7 +754,13 @@ export function createRunCore(deps, opts = {}) {
seed: state.seed,
map: { nodes: map.nodes.map((n) => ({ ...n })), edges: map.edges.map((e) => e.slice()) },
reward: reward ? { options: reward.options.slice(), canSkip: reward.canSkip, after: reward.after } : null,
currentEvent: currentEvent ? { ...currentEvent } : null,
eventsSeen: eventsSeen.slice(),
relicReward: relicReward ? { options: relicReward.options.slice(), canSkip: relicReward.canSkip } : null,
event: currentEvent ? (() => {
const ev = EVENTS.find((e) => e.id === currentEvent.id);
return { id: ev.id, name: ev.name, text: ev.text, options: ev.options.map((o) => ({ label: o.label, fx: o.fx || {} })) };
})() : null,
shop: shop ? { cards: shop.cards.map((c) => ({ ...c })), relics: shop.relics.map((r) => ({ ...r })), freeRemoveUsed: shop.freeRemoveUsed } : null,
};
},

View File

@ -1,18 +1,21 @@
/**
* src/save.js 山海巡异录run 存档件(M2 S11;SaveEnvelope 契约面单源)
* owner:北极星顶级线③(M2)
* src/save.js 山海巡异录存档件(M2 S11 v1 M3 S15 v2;SaveEnvelope 契约面单源)
* owner:北极星顶级线③(M2/M3)
*
*
* 契约形状 = contracts/sdk-interface.d.ts SaveEnvelope:{ schemaVersion: 整数 1, data: 纯对象 };
* 键族 = 'save:'+gameId+':'+versionId(宿主白名单恰等单槽上限 65536 UTF-16 码元fail-closed)
* W-SAVE-CAP 已入库( §12 M2 入口门基建就位)
*
* 介质口径(诚实申报)M2 = 影子介质:浏览器径直写 localStorage(同名键)/headless 用注入 store;
* 正式 SDK Storage 通道(postMessage storage 消息)切换随发布集成段( §12,M5 硬门:
* 切正式通道 + 迁移/兼容断言)本文件的信封形状与校验与正式通道逐字节同契约,切换只换介质
* 版本谱系v1(M2):data = run 快照平铺(rngState/deck/map/)
* v2(M3 S15):data = { run: 快照|null, meta: 局外进度|null } 双域run 终局后 run=null meta 常存
* (图鉴/成就/进阶解锁/每日跨局持久)v1v2 迁移:旧档整体视为 run meta 置空(migrateV1)
* 这是版本信封的首个真实迁移,M5切正式通道+迁移/兼容断言的先手演练
*
* fail-closed 纪律与宿主闸同向:非信封 / 版本非法 / data 非纯对象 / 地图自检失败 一律弃档
* (返回 null留痕日志),绝不半恢复坏档等价无档:开新局
* 介质口径(诚实申报)M3 =影子介质(main.js localStorage 直写/headless 注入);
* 正式 SDK Storage 通道切换随发布集成段( §12,M5 硬门)信封形状与校验与正式通道同契约
*
* fail-closed 纪律非信封 / 版本非法 / data 非纯对象 / run 域坏形 / 地图自检失败 弃档(null+留痕),
* 绝不半恢复;meta 域坏形 只弃 meta(run meta 独立校验:局外进度损坏不应吞掉续玩)
*
*/
@ -22,56 +25,83 @@ import { createRunCore } from './run-core.js';
import { createSeededRandom } from './util/seeded-random.js';
import { SAVE } from './data/tables.js';
/** 存档键(M2 影子口径;正式 versionId 随集成段注入)。 */
/** 存档键(影子口径;正式 versionId 随集成段注入)。 */
export const SAVE_KEY = SAVE.shadowKey;
/**
* run 核产出信封(SaveEnvelope 形状)
* @param {ReturnType<typeof createRunCore>} run
* @returns {{schemaVersion:number, data:Object}|null} battle /终局无快照 null
* 组信封(v2):run 快照(battle /终局为 null)+ meta 序列化
* @param {{snapshot:()=>Object|null}|null} run 一局核( null=局外)
* @param {{serialize:()=>Object}|null} metaCore 局外核( null)
* @returns {{schemaVersion:number, data:{run:Object|null, meta:Object|null}}}
*/
export function makeEnvelope(run) {
const data = run.snapshot();
if (!data) return null;
return { schemaVersion: SAVE.schemaVersion, data };
export function makeEnvelope(run, metaCore) {
return {
schemaVersion: SAVE.schemaVersion,
data: {
run: run ? run.snapshot() : null,
meta: metaCore ? metaCore.serialize() : null,
},
};
}
/** v1 → v2 迁移:旧档 data=run 平铺 → { run: 旧data, meta: null }。 */
function migrateV1(data) {
return { run: data, meta: null };
}
/**
* 信封校验(宿主闸 fail-closed 的游戏侧同构;负例=坏版本/非对象/数组/ data):
* 信封校验+版本迁移(fail-closed)
* @param {unknown} env
* @returns {Object|null} 合法 data;非法 null
* @returns {{run:Object|null, meta:Object|null}|null} 合法 v2 形状 data;非法 null
*/
export function validateEnvelope(env) {
if (env == null || typeof env !== 'object' || Array.isArray(env)) return null;
const v = /** @type {{schemaVersion?:unknown, data?:unknown}} */ (env);
if (!Number.isInteger(v.schemaVersion) || v.schemaVersion < 1) return null; // 契约:整数 ≥1
if (v.schemaVersion !== SAVE.schemaVersion) return null; // M2 只有 v1;未来版本走迁移链(M5 前无旧档负担)
if (v.schemaVersion > SAVE.schemaVersion) return null; // 未来版本不认(fail-closed)
if (v.data == null || typeof v.data !== 'object' || Array.isArray(v.data)) return null; // data=纯对象
const d = /** @type {Record<string, unknown>} */ (v.data);
if (!Number.isInteger(d.rngState)) return null; // 随机流锚缺失=无法精确续玩,弃档
if (d.map == null || typeof d.map !== 'object') return null;
return d;
let data = /** @type {Record<string, unknown>} */ (v.data);
if (v.schemaVersion === 1) data = migrateV1(data); // v1 迁移链
// v2 形状:run 域可 null;非 null 时须带 rngState+map(精确续玩的最低要件)。
const run = data.run == null ? null : data.run;
if (run != null) {
if (typeof run !== 'object' || Array.isArray(run)) return null;
if (!Number.isInteger(run.rngState)) return null;
if (run.map == null || typeof run.map !== 'object') return null;
}
// meta 域独立校验:坏形只弃 meta,不吞 run(局外进度损坏 ≠ 续玩作废)。
let metaOut = data.meta == null ? null : data.meta;
if (metaOut != null && (typeof metaOut !== 'object' || Array.isArray(metaOut))) metaOut = null;
return { run, meta: metaOut };
}
/**
* 从信封恢复一局(fail-closed:任何一步失败 null + 留痕)
* @param {unknown} env 介质读出的对象(宿主/影子介质已 JSON.parse)
* @param {unknown} env 介质读出的对象
* @returns {ReturnType<typeof createRunCore>|null}
*/
export function restoreRun(env) {
const data = validateEnvelope(env);
if (!data) return null;
if (!data || !data.run) return null;
try {
const rng = createSeededRandom(0);
rng.setState(/** @type {number} */(data.rngState));
return createRunCore({ random: rng }, { restore: data });
rng.setState(/** @type {number} */(data.run.rngState));
return createRunCore({ random: rng }, { restore: data.run });
} catch (e) {
// 地图自检失败/表漂移(卡 id 不存在)等 → 弃档开新局;留痕供排查。
console.error('[save] 存档恢复失败,弃档:', e && e.message);
console.error('[save] run 域恢复失败,弃档续玩面:', e && e.message); // 留痕;meta 域仍可由调用方恢复
return null;
}
}
/**
* 从信封取 meta (独立于 run;调用方喂 createMetaCore)
* @returns {Object|null}
*/
export function restoreMeta(env) {
const data = validateEnvelope(env);
return data ? data.meta : null;
}
/**
* 序列化并核对容量(save: 65536 码元契约闸;超限=结构失败,不静默截断)
* @returns {{ok:boolean, json?:string, chars?:number, reason?:string}}

View File

@ -15,15 +15,15 @@
'use strict';
import { ELEMENT_CYCLE, ELEMENT_COLORS } from '../data/tables.js';
import { ELEMENT_CYCLE, ELEMENT_COLORS, ACHIEVEMENTS } from '../data/tables.js';
import {
VIEW_W, VIEW_H, ZONES, CARD_W, CARD_H, midY, handTop,
handCardCenter, endTurnRect, mapNodeCenter, rewardCardCenter, rewardSkipRect,
} from '../ui/layout.js';
// 节点型 → 显示字/色(表现层词汇,规则在 map-core/tables)。
const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市' };
const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f, shop: 0xb9a3d0 };
const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市', event: '遇' };
const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f, shop: 0xb9a3d0, event: 0x7fd4c1 };
/**
* 建一局场景(Phaser scene 配置对象;`this`=Phaser.Scene)
@ -45,6 +45,7 @@ export function createRunScene(api) {
s.battle.enemy.statuses] : null,
s.reward ? s.reward.options.map((o) => o.id) : null,
s.relicReward ? s.relicReward.options.map((o) => o.id) : null,
s.event ? s.event.id : null,
s.shop ? [s.shop.cards.map((c) => c.sold), s.shop.relics.map((r) => r.sold), s.shop.removePrice, s.removeCount] : null,
]);
}
@ -61,6 +62,23 @@ export function createRunScene(api) {
update() {
const scene = this;
// M3 局外模式:home 态渲染工厂投影(局外屏);run 态沿一局核直读。
if (api.mode && api.mode() === 'home') {
const hs = api.homeState();
const sig = JSON.stringify(['home', hs.screen, hs.home.sel, hs.home.maxAscension, hs.home.meta.achievedCount, hs.home.meta.collection.cards, hs.home.meta.daily]);
if (sig === lastSig) return;
lastSig = sig;
clear();
if (hs.screen === 'collection') drawCollection(scene, hs);
else if (hs.screen === 'achievements') drawAchievements(scene, hs);
else drawHome(scene, hs);
if (api.debug) {
api.debug.displayCount = uiNodes.length;
api.debug.rebuilds = (api.debug.rebuilds || 0) + 1;
api.debug.screen = `home:${hs.screen}`;
}
return;
}
const run = api.run();
if (!run) return;
const s = run.readState();
@ -82,18 +100,34 @@ export function createRunScene(api) {
uiNodes = [];
}
function keep(scene, obj) { uiNodes.push(obj); return obj; }
const txt = (scene, x, y, str, size, color, origin = 0.5) =>
keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin));
/**
* 文本辅助必须是 function 声明(提升)
* 2026-07-06 战斗全空事故根因本函数曾写成 `const txt = (...) => ...` 且位于工厂 return 之后:
* 该行永不执行,`txt` 绑定永陷 TDZ,首次 rebuild 即抛 ReferenceError整屏绘制(地图/战斗/商店)
* 全部无声失败,只有不依赖 txt 的事件浮字(floatText,function 声明)可见,浏览器像素复验才暴露
* (headless 门不建场景,验不到)防回潮:scripts/scene-smoke.mjs mock 场景重放门 + rebuild 自证计数
*/
function txt(scene, x, y, str, size, color, origin = 0.5) {
return keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin));
}
function rebuild(scene, s) {
clear();
if (s.phase !== 'run') { drawTerminal(scene, s); return; }
if (s.screen === 'map') drawMap(scene, s);
if (s.phase !== 'run') { drawTerminal(scene, s); }
else if (s.screen === 'map') drawMap(scene, s);
else if (s.screen === 'battle' && s.battle) drawBattle(scene, s);
else if (s.screen === 'reward' && s.reward) drawReward(scene, s);
else if (s.screen === 'relicReward' && s.relicReward) drawRelicReward(scene, s);
else if (s.screen === 'shop' && s.shop) drawShop(scene, s);
else if (s.screen === 'shopRemove' && s.shop) drawShopRemove(scene, s);
else if (s.screen === 'event' && s.event) drawEvent(scene, s);
// 自证回写(2026-07-06 增补②):把「本屏已绘制显示对象计数」挂进取证面(工厂 readState().debug),
// CDP 复验据此机断「画了东西」,不再依赖纯像素统计。
if (api.debug) {
api.debug.displayCount = uiNodes.length;
api.debug.rebuilds = (api.debug.rebuilds || 0) + 1;
api.debug.screen = s.screen;
}
}
/** 顶栏(全视图常驻):幕/金币/卡组/遗物。 */
@ -297,6 +331,80 @@ export function createRunScene(api) {
txt(scene, bx, by, '不销 · 回市', 14, '#c9c3b2');
}
// ── 事件视图(M3 S16;选项按钮与 layout eventOption 靶同源)──────────────────────
function drawEvent(scene, s) {
drawTopStrip(scene, s);
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.4));
txt(scene, VIEW_W / 2, VIEW_H * 0.2, `奇遇 · ${s.event.name}`, 22, '#7fd4c1');
keep(scene, scene.add.text(VIEW_W / 2, VIEW_H * 0.34, s.event.text, { fontSize: '14px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 320 }, align: 'center' }).setOrigin(0.5));
s.event.options.forEach((o, i) => {
const y = VIEW_H * 0.52 + i * 64;
keep(scene, scene.add.rectangle(VIEW_W / 2, y, 320, 52, 0x24313a).setStrokeStyle(2, 0x7fd4c1));
keep(scene, scene.add.text(VIEW_W / 2, y, o.label, { fontSize: '13px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 300 }, align: 'center' }).setOrigin(0.5));
});
}
// ── 局外三屏(M3 S15;坐标与 layout computeHomeTargets 同源)──────────────────────
function drawHome(scene, hs) {
const m = hs.home.meta;
txt(scene, VIEW_W / 2, VIEW_H * 0.12, '山海巡异录', 34, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.18, `出巡 ${m.runsPlayed} · 功成 ${m.wins}`, 13, '#8a8498');
// 职业二选(选中金框)。
for (const [i, c] of m.classes.entries()) {
const x = VIEW_W / 2 + (i === 0 ? -88 : 88);
const sel = hs.home.sel.class === c.id;
keep(scene, scene.add.rectangle(x, VIEW_H * 0.34, 150, 110, 0x241f33).setStrokeStyle(sel ? 3 : 1, sel ? 0xf0c674 : 0x555566));
txt(scene, x, VIEW_H * 0.31, c.name, 18, sel ? '#f0c674' : '#c9c3b2');
txt(scene, x, VIEW_H * 0.36, `气血 ${c.hpMax}`, 12, '#9fc4e8');
txt(scene, x, VIEW_H * 0.395, c.id === 'jia' ? '攻伐见长' : '灵脉见长', 11, '#8a8498');
}
// 进阶选择(加减 + 当前级文案)。
keep(scene, scene.add.rectangle(VIEW_W / 2 - 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 - 110, VIEW_H * 0.5, '', 22, '#e8e2d0');
keep(scene, scene.add.rectangle(VIEW_W / 2 + 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 + 110, VIEW_H * 0.5, '+', 22, '#e8e2d0');
const asc = hs.home.sel.ascension;
txt(scene, VIEW_W / 2, VIEW_H * 0.48, `进阶 ${asc}${hs.home.maxAscension > 0 ? ` / 已启 ${hs.home.maxAscension}` : ''}`, 16, '#e2a13e');
txt(scene, VIEW_W / 2, VIEW_H * 0.52, asc > 0 ? (hs.home.ascensions[asc - 1] || {}).text || '' : '无修正 · 基准巡途', 11, '#8a8498');
// 出巡/每日。
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.62, 240, 56, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b));
txt(scene, VIEW_W / 2, VIEW_H * 0.62, '启程出巡', 20, '#f5e9d5');
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.72, 240, 48, 0x24313a).setStrokeStyle(2, 0x7fd4c1));
txt(scene, VIEW_W / 2, VIEW_H * 0.705, `每日一巡 · ${hs.home.daily.date}`, 14, '#7fd4c1');
txt(scene, VIEW_W / 2, VIEW_H * 0.735, m.daily.bestScore > 0 ? `本地最佳 ${m.daily.bestScore}` : '固定种子 · 天下同题', 11, '#8a8498');
// 图鉴/成就入口。
keep(scene, scene.add.rectangle(VIEW_W / 2 - 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 - 90, VIEW_H * 0.82, `图鉴 ${m.collection.cards + m.collection.relics + m.collection.enemies}`, 13, '#9fc4e8');
keep(scene, scene.add.rectangle(VIEW_W / 2 + 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 + 90, VIEW_H * 0.82, `成就 ${m.achievedCount}/40`, 13, '#b9a3d0');
}
function drawCollection(scene, hs) {
const m = hs.home.meta;
txt(scene, VIEW_W / 2, 40, '图鉴 · 山海志', 22, '#9fc4e8');
txt(scene, VIEW_W / 2, VIEW_H * 0.2, `符箓 ${m.collection.cards} / 154`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.3, `遗物 ${m.collection.relics} / 40`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.4, `异兽 ${m.collection.enemies} / 8`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.55, '收录随巡途自动增补', 12, '#8a8498');
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2');
}
function drawAchievements(scene, hs) {
const m = hs.home.meta;
txt(scene, VIEW_W / 2, 40, `成就 · ${m.achievedCount} / 40`, 22, '#b9a3d0');
// 达成列表(前 12 条滚动位;M4 再做完整滚动容器)。
const byId = Object.fromEntries(ACHIEVEMENTS.map((a) => [a.id, a]));
const got = Object.keys(m.achievements).slice(0, 12);
if (got.length === 0) txt(scene, VIEW_W / 2, VIEW_H * 0.4, '尚无成就 · 出巡自见分晓', 14, '#8a8498');
got.forEach((id, i) => {
const a = byId[id];
txt(scene, VIEW_W / 2, VIEW_H * 0.18 + i * 28, `${a ? a.name : id}`, 13, '#9fe08a');
});
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2');
}
// ── 终局视图 ──────────────────────────────────────────────────────────────────
function drawTerminal(scene, s) {
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.78));
@ -408,6 +516,12 @@ export function createRunScene(api) {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '入市', '#b9a3d0', 18);
} else if (e.t === 'relicRewardOffered') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '异宝呈前', '#b9a3d0', 18);
} else if (e.t === 'eventStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `奇遇 · ${e.name}`, '#7fd4c1', 18);
} else if (e.t === 'hpMaxChange') {
floatText(scene, 90, VIEW_H * 0.5, `气血上限 ${e.n > 0 ? '+' : ''}${e.n}`, '#9fe08a', 15);
} else if (e.t === 'goldSpend') {
floatText(scene, 70, 30, `金 -${e.n}`, '#c9a35a', 14);
}
}

View File

@ -27,23 +27,28 @@ import { dirname, join } from 'node:path';
import { createRunCore } from '../run-core.js';
import { createSeededRandom } from '../util/seeded-random.js';
import { RUN_STARTER_DECK, SIM_BANDS } from '../data/tables.js';
import { RUN_STARTER_DECK, YI_STARTER_DECK, SIM_BANDS } from '../data/tables.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// 策略单源:与 CDP harness 同一文件(贪心档即九门真玩的同一大脑)。
const { chooseTap, chooseTapRandom, chooseTapIdle } = require(join(__dir, '../../../../harness/card-battle.driver.js'));
const { chooseTap, chooseTapRandom, chooseTapIdle, chooseTapChain, chooseTapDefensive } = require(join(__dir, '../../../../harness/card-battle.driver.js'));
/** 决策随机源派生常数(黄金比 32 位;与游戏 rng 分流)。 */
const DECISION_SEED_XOR = 0x9e3779b9;
/** 策略注册表(档名 → 决策函数;random 档吃独立决策 rng)。 */
export const STRATEGIES = {
greedy: (state) => chooseTap(state),
random: (state, rng) => chooseTapRandom(state, rng),
idle: (state) => chooseTapIdle(state),
greedy: (state) => chooseTap(state), // 贪心分(书面三档①;校准对象)
chain: (state) => chooseTapChain(state), // 链优先(书面三档②;M3 S14)
defensive: (state) => chooseTapDefensive(state), // 保血防御(书面三档③;龟缩哨兵)
random: (state, rng) => chooseTapRandom(state, rng), // 下界观测(M2 工单档)
idle: (state) => chooseTapIdle(state), // 下界观测(M2 工单档)
};
/** 书面三档(§6.4;全矩阵与死卡并集口径);random/idle 为下界锚不入死卡判。 */
export const CANON_STRATEGIES = ['greedy', 'chain', 'defensive'];
/**
* 单局仿真:用指定策略档把一局打到终态,采全量观测
* @param {Object} p
@ -104,6 +109,7 @@ export function simulateRun({ seed, strategy, runOpts = {} }) {
else if (t.kind === 'removePick') run.buyRemove(t.index);
else if (t.kind === 'removeBack') run.closeRemove();
else if (t.kind === 'shopLeave') run.leaveShop();
else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件
else stuck = true; // 未知靶=契约漂移,响亮暴露
consumeEvents();
if (stuck) break;
@ -112,9 +118,10 @@ export function simulateRun({ seed, strategy, runOpts = {} }) {
consumeEvents();
const end = run.readState();
// 采用率口径:胜局构筑 = 终态卡组减起始组份数(删卡后不在终态=真实反映构筑);起始组不带判
// 采用率口径:胜局构筑 = 终态卡组减起始组份数(按职业起始组;删卡后不在终态=真实反映构筑)。
const starterCount = {};
for (const id of RUN_STARTER_DECK) starterCount[id] = (starterCount[id] || 0) + 1;
const starter = end.heroClass === 'yi' ? YI_STARTER_DECK : RUN_STARTER_DECK;
for (const id of starter) starterCount[id] = (starterCount[id] || 0) + 1;
const acquired = [];
const left = { ...starterCount };
for (const id of end.deck || []) {
@ -125,6 +132,8 @@ export function simulateRun({ seed, strategy, runOpts = {} }) {
return {
seed,
strategy,
heroClass: end.heroClass,
ascension: end.ascension,
phase: end.phase, // win|lose|invalid|run(guard 截断时)
act: end.act,
stuck,
@ -144,6 +153,10 @@ export function simulateRun({ seed, strategy, runOpts = {} }) {
fingerprint: end.phase === 'invalid'
? { deck: (end.deck || []).slice().sort(), relics: (end.relics || []).map((r) => r.id).sort() }
: null,
// 胜局构筑指纹(M3 门:去重率 >85%草案——deck+relics 排序串)。
winFingerprint: end.phase === 'win'
? (end.deck || []).slice().sort().join(',') + '|' + (end.relics || []).map((r) => r.id).sort().join(',')
: null,
};
}
@ -168,6 +181,9 @@ export function aggregateCell(results, opts = {}) {
const winRate = n ? wins.length / n : 0;
// 二项分布标准差(千局 σ≈1.6pp 的书面口径同源)。
const sigma = n ? Math.sqrt(winRate * (1 - winRate) / n) : 0;
// 胜局构筑指纹去重率(M3 门 >85%〔草案〕:构筑多样性——同指纹反复出现=路径固化)。
const winFps = wins.map((w) => w.winFingerprint).filter(Boolean);
const fingerprintUniqueRate = winFps.length ? new Set(winFps).size / winFps.length : null;
const allBossTurns = results.flatMap((r) => r.bossTurns).sort((a, b) => a - b);
const grind = allBossTurns.filter((t) => t > SIM_BANDS.bossTurnBand[1]).length; // 「磨」
@ -210,6 +226,7 @@ export function aggregateCell(results, opts = {}) {
stucks: stucks.length,
winRate: Math.round(winRate * 10000) / 10000,
winRateSigmaPp: Math.round(sigma * 10000) / 100, // 百分点
fingerprintUniqueRate: fingerprintUniqueRate != null ? Math.round(fingerprintUniqueRate * 1000) / 1000 : null,
avgBattlesWon: n ? Math.round(results.reduce((s, r) => s + r.stats.battlesWon, 0) / n * 100) / 100 : 0,
avgDeckEnd: n ? Math.round(results.reduce((s, r) => s + r.deckCount, 0) / n * 10) / 10 : 0,
bossTurns: {

View File

@ -47,6 +47,11 @@ export function valueOf(card) {
v += bv * V.chainAchieveDiscount;
}
if (fx.costChainDiscount) v += fx.costChainDiscount.n * V.energyCost * V.chainAchieveDiscount;
if (fx.ifZhoutian) {
const z = fx.ifZhoutian;
// 周天达成率低(A0 观测 ≈0.1/局;乙链优构筑内更高),按 0.2 折。
v += ((z.dmg || 0) / V.dmgPerCost + (z.block || 0) / V.blockPerCost + (z.draw || 0) * V.drawCost + (z.energy || 0) * V.energyCost) * 0.2;
}
const value = v / Math.max(card.cost, V.zeroCostFloor);
const cap = V.rarityCaps[card.rarity] != null ? V.rarityCaps[card.rarity] : 1.0;
return {

View File

@ -114,6 +114,13 @@ export function computeTapTargets(s) {
targets.push({ kind: 'cancel', x: VIEW_W / 2, y: (handTop() + VIEW_H) / 2, w: VIEW_W, h: VIEW_H - handTop() });
return targets;
}
// 事件屏(M3 S16):选项按钮纵排(23 个)。
if (s.screen === 'event' && s.event) {
s.event.options.forEach((o, i) => {
targets.push({ kind: 'eventOption', index: i, x: VIEW_W / 2, y: VIEW_H * 0.52 + i * 64, w: 320, h: 52 });
});
return targets;
}
// 商店(M2 S11):5 卡位两行 + 2 遗物位 + 删卡/离开按钮。
if (s.screen === 'shop' && s.shop) {
s.shop.cards.forEach((c, i) => {
@ -153,6 +160,27 @@ export function computeTapTargets(s) {
return targets;
}
/**
* home 局外屏可点靶(M3 S15;工厂级投影消费局外交互不属于一局,不进 run-core):
* main 子屏=职业二选 + 进阶加减 + 出巡/每日 + 图鉴/成就入口;collection/achievements 子屏=返回
*/
export function computeHomeTargets(s) {
const targets = [];
if (s.screen === 'main') {
targets.push({ kind: 'homeClass', class: 'jia', x: VIEW_W / 2 - 88, y: VIEW_H * 0.34, w: 150, h: 110 });
targets.push({ kind: 'homeClass', class: 'yi', x: VIEW_W / 2 + 88, y: VIEW_H * 0.34, w: 150, h: 110 });
targets.push({ kind: 'ascMinus', x: VIEW_W / 2 - 110, y: VIEW_H * 0.5, w: 52, h: 44 });
targets.push({ kind: 'ascPlus', x: VIEW_W / 2 + 110, y: VIEW_H * 0.5, w: 52, h: 44 });
targets.push({ kind: 'homeStart', x: VIEW_W / 2, y: VIEW_H * 0.62, w: 240, h: 56 });
targets.push({ kind: 'homeDaily', x: VIEW_W / 2, y: VIEW_H * 0.72, w: 240, h: 48 });
targets.push({ kind: 'homeCollection', x: VIEW_W / 2 - 90, y: VIEW_H * 0.82, w: 150, h: 44 });
targets.push({ kind: 'homeAchievements', x: VIEW_W / 2 + 90, y: VIEW_H * 0.82, w: 150, h: 44 });
} else {
targets.push({ kind: 'homeBack', x: VIEW_W / 2, y: VIEW_H * 0.92, w: 160, h: 46 });
}
return targets;
}
/** 命中判定:按数组序返回首个包含 (x,y) 的靶(无 → null)。injectInput 与场景共用。 */
export function hitTarget(targets, x, y) {
for (const t of targets) {

View File

@ -67,7 +67,13 @@ function pickValue(card) {
* 贪心档 v1(§6.4 三档 driver 之一;链优先/保血防御两档随 M2 S12)
*/
function chooseTap(state) {
if (!state || state.phase !== 'run') return null; // win/lose/invalid 终态 → 停,交 latch 校验
if (!state) return null;
// ── 局外 home 屏(M3 S15):CDP/九门真玩路径 boot 后先落 home——直接启程(缺省甲/A0);
// win 后终局遮罩点掉会回 home,phase='home' 非终态,latch 门只认 win/lose/invalid。──
if (state.phase === 'home') {
return findTarget(state, 'homeStart') || null;
}
if (state.phase !== 'run') return null; // win/lose/invalid 终态 → 停,交 latch 校验
// ── 地图:选节点(血线驱动的贪心偏好)──
if (state.screen === 'map') {
@ -85,6 +91,26 @@ function chooseTap(state) {
return nodes[0];
}
// ── 事件屏(M3 S16):选项期望当量最大(fx 语义投影;血亏加权回复,金紧看收益)──
if (state.screen === 'event' && state.event) {
const hpRatio = state.hero.hp / state.hero.hpMax;
const evalOpt = (o) => {
const fx = o.fx || {};
let v = 0;
v += (fx.gold || 0) * 0.03;
v += (fx.heal || 0) * (hpRatio < 0.6 ? 0.35 : 0.1);
v -= (fx.loseHp || 0) * (hpRatio < 0.5 ? 0.5 : 0.2);
v += (fx.hpMax || 0) * 0.35;
if (fx.relic) v += 4;
if (fx.card === '稀有') v += 2.2; else if (fx.card === '罕见') v += 1.4; else if (fx.card) v += 0.6;
if (fx.removeRandomCommon) v += 0.8;
return v;
};
let best = 0;
(state.event.options || []).forEach((o, i) => { if (evalOpt(o) > evalOpt(state.event.options[best])) best = i; });
return findTarget(state, 'eventOption', (t) => t.index === best);
}
// ── Boss 遗物三选一(M2):遗物全规则级增益,贪心=永远拿第一枚(估值细分随 S12 调参轮)──
if (state.screen === 'relicReward' && state.relicReward) {
return findTarget(state, 'relicReward', (t) => t.index === 0) || findTarget(state, 'relicSkip');
@ -219,6 +245,108 @@ function chooseTap(state) {
return null;
}
/* ══ M3 S14 书面三档补齐:链优先 / 保血防御( §6.4 原三档=贪心分/链优先/保血防御;
* M2 的随机/挂机下界档并存多样性 driver 是死卡治理与采用率带的观测面) */
/**
* 链优先档:链长与周天最大化(乙职业的天然打法代理)
* 战斗序:①延链卡里挑费用最低(链不断优先于当量)②空链时按预算内可串最长起手(与贪心④同算法)
* ③无链可延时打 0 费润滑 ④endTurn地图/商店/奖励沿贪心(pickValue 里链词已计价)
*/
function chooseTapChain(state) {
if (!state || state.phase !== 'run') return null;
if (state.screen === 'battle' && state.battle) {
const b = state.battle;
if (state.ui && state.ui.selected >= 0) {
return findTarget(state, 'card', (t) => t.index === state.ui.selected);
}
const hand = (b.handCards || []).map((c, i) => ({ ...c, i }))
.filter((c) => c.cost <= b.energy && !(c.hpCost > 0 && b.hero.hp <= c.hpCost));
if (!hand.length) return findTarget(state, 'endTurn');
// ①延链:同延链集内费低者先(省能量串更长)。
if (b.chain && b.chain.nextElement) {
const chainable = hand.filter((c) => c.element === b.chain.nextElement).sort((a, z) => a.cost - z.cost || (z.dmg + z.block) - (a.dmg + a.block));
if (chainable.length) return findTarget(state, 'card', (t) => t.index === chainable[0].i);
}
// ②空链起手:预算内可串最长(贪心④同构)。
if (b.chain && Array.isArray(b.chain.cycle) && (!b.chain.seq || b.chain.seq.length === 0)) {
const cycle = b.chain.cycle;
let best = null;
for (const start of hand) {
const used = new Set([start.i]);
let budget = b.energy - start.cost;
let el = cycle[(cycle.indexOf(start.element) + 1) % cycle.length];
let len = 1;
for (let step = 0; step < 8; step++) {
const next = hand.filter((c) => !used.has(c.i) && c.element === el && c.cost <= budget).sort((a, z) => a.cost - z.cost)[0];
if (!next) break;
used.add(next.i); budget -= next.cost; len += 1;
el = cycle[(cycle.indexOf(el) + 1) % cycle.length];
}
if (!best || len > best.len) best = { card: start, len };
}
if (best) return findTarget(state, 'card', (t) => t.index === best.card.i);
}
// ③破序不可避:能量照花(链是回合内轴、回合末必清——「留链势」是伪概念,首版
// 每回合只打链段致胜率 5%/1.5%,batch 实测修正):0 费垫子优先,再按裸当量清能量。
const zero = hand.filter((c) => c.cost === 0).sort((a, z) => (z.dmg + z.block) - (a.dmg + a.block))[0];
if (zero) return findTarget(state, 'card', (t) => t.index === zero.i);
const spend = hand.sort((a, z) => (z.dmg + z.block + z.burn) - (a.dmg + a.block + a.burn))[0];
if (spend) return findTarget(state, 'card', (t) => t.index === spend.i);
return findTarget(state, 'endTurn');
}
return chooseTap(state); // 地图/奖励/商店/遗物屏沿贪心策略(链价值已在 pickValue 计价)
}
/**
* 保血防御档:受伤最小化(龟缩代理防御过值龟缩最优的曲线哨兵,§6.2 攻防本位差观测面)
* 战斗序:①来袭未挡满 /虚弱卡按防御量优先 ②挡满后血亏 回血卡 ③安全后才输出 ④endTurn
*/
function chooseTapDefensive(state) {
if (!state || state.phase !== 'run') return null;
if (state.screen === 'battle' && state.battle) {
const b = state.battle;
if (state.ui && state.ui.selected >= 0) {
return findTarget(state, 'card', (t) => t.index === state.ui.selected);
}
const hand = (b.handCards || []).map((c, i) => ({ ...c, i }))
.filter((c) => c.cost <= b.energy && !(c.hpCost > 0 && b.hero.hp <= c.hpCost));
if (!hand.length) return findTarget(state, 'endTurn');
const it = b.enemy.intent;
const incoming = it && (it.kind === 'attack' || it.kind === 'attackBlock') ? it.n * (it.times || 1) : 0;
// ①挡满来袭:甲量最大者先(虚弱卡算防御件:折减来袭)。
if (incoming > 0 && b.hero.block < incoming) {
const guard = hand.filter((c) => c.block > 0 || c.weak > 0)
.sort((a, z) => (z.block + z.weak * 3) - (a.block + a.weak * 3))[0];
if (guard) return findTarget(state, 'card', (t) => t.index === guard.i);
}
// ②血亏回血。
if (b.hero.hp < b.hero.hpMax * 0.7) {
const healCard = hand.filter((c) => c.heal > 0).sort((a, z) => z.heal - a.heal)[0];
if (healCard) return findTarget(state, 'card', (t) => t.index === healCard.i);
}
// ③安全后输出(裸当量序;链是顺带不是追求)。
const atk = hand.filter((c) => c.dmg > 0 || c.burn > 0).sort((a, z) => (z.dmg + z.burn) - (a.dmg + a.burn))[0];
if (atk) return findTarget(state, 'card', (t) => t.index === atk.i);
const spare = hand.sort((a, z) => z.block - a.block)[0];
if (spare && spare.block > 0) return findTarget(state, 'card', (t) => t.index === spare.i);
return findTarget(state, 'endTurn');
}
// 地图偏好:保血路线(憩/商店前置)。
if (state.screen === 'map') {
const nodes = ((state.tapTargets) || []).filter((t) => t.kind === 'node');
if (!nodes.length) return null;
const hpRatio = state.hero.hp / state.hero.hpMax;
const prefer = hpRatio < 0.8 ? ['rest', 'treasure', 'shop', 'battle', 'boss', 'elite'] : ['treasure', 'shop', 'battle', 'rest', 'boss', 'elite'];
for (const k of prefer) {
const hit = nodes.find((t) => t.nodeType === k);
if (hit) return hit;
}
return nodes[0];
}
return chooseTap(state); // 奖励/商店/遗物沿贪心
}
/* M2 S12 driver(://;A0 ,线)
* 贪心档 = 上面的 chooseTap(CDP 真玩与 headless 冒烟同一大脑,策略单源);
* 随机/挂机档只供 headless 仿真外壳(sim-shell)消费,CDP 真玩不用additive 导出,不改既有行为
@ -248,7 +376,7 @@ function chooseTapRandom(state, rng) {
}
// 地图/奖励屏:全部可点靶等权随机(奖励含跳过;地图=随机走图)。
const pool = ts.filter((t) => t.kind === 'node' || t.kind === 'reward' || t.kind === 'rewardSkip'
|| t.kind === 'relicReward' || t.kind === 'relicSkip'
|| t.kind === 'relicReward' || t.kind === 'relicSkip' || t.kind === 'eventOption'
|| t.kind === 'shopCard' || t.kind === 'shopRelic' || t.kind === 'shopRemove' || t.kind === 'shopLeave'
|| t.kind === 'removePick' || t.kind === 'removeBack');
if (!pool.length) return null;
@ -272,6 +400,7 @@ function chooseTapIdle(state) {
}
if (state.screen === 'shop') return findTarget(state, 'shopLeave');
if (state.screen === 'shopRemove') return findTarget(state, 'removeBack');
if (state.screen === 'event') return findTarget(state, 'eventOption', (t) => t.index === 0); // 挂机:事件拿头一项
const node = findTarget(state, 'node');
return node;
}
@ -306,4 +435,4 @@ async function run(cdp, driver, hashes, deps) {
return { drove, steps };
}
module.exports = { run, chooseTap, chooseTapRandom, chooseTapIdle };
module.exports = { run, chooseTap, chooseTapRandom, chooseTapIdle, chooseTapChain, chooseTapDefensive };