From 072dc43777073e73c874ab59e3ec36973f30928e Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 17 Sep 2026 21:38:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(slice):=20=E5=88=86=E6=94=AF=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E6=89=B9=E6=94=B6=E7=BC=96=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=B3=E5=8D=A12=E3=80=8C=E5=B9=BD=E6=9A=97=E5=9C=B0?= =?UTF-8?q?=E5=9F=9F=E3=80=8D=E8=AF=95=E7=8E=A9=E6=89=B9=EF=BC=88=E5=9B=BE?= =?UTF-8?q?=E7=89=88=E8=A1=8C=E8=BF=9B=E5=99=A8+=E5=9B=BE=E5=90=88?= =?UTF-8?q?=E6=B3=95=E6=80=A7=E5=85=AD=E6=A3=80+L2=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E8=A1=8C+=E9=80=89=E8=B7=AF=20UI+=E9=A6=96=E9=A1=B5=E7=9B=B4?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=9B=E5=86=92=E7=83=9F=20296=20=E5=85=A8?= =?UTF-8?q?=E7=BB=BF+=E5=9B=9B=E6=8E=A2=E9=92=88=20PASS=EF=BC=9B=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E8=A6=86=E7=9B=96=E5=B1=82=200=C3=970=20=E4=B8=8E=20r?= =?UTF-8?q?oute=5Foverlay=20=E6=9C=AA=E5=85=A5=E6=A0=91=E4=B8=A4=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E4=BF=AE=E5=A4=8D=EF=BC=9B=E8=AF=95=E7=8E=A9=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=96=B0=E6=A0=BC=E5=BC=8F+analyze=5Fplaytest=20?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=B7=A5=E5=85=B7=EF=BC=9B=E4=BA=94=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=9B=9E=E5=A1=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/gdd/06-待办清单.md | 10 +- design/gdd/systems/冒险循环/关卡节点地图.md | 1 + design/gdd/systems/冒险循环/难度曲线与Boss.md | 23 +- design/registry/entities.yaml | 22 +- production/session-state/active.md | 16 +- .../dungeon-sweeper-vertical-slice/README.md | 10 + .../core/level_runner.gd | 163 ++++- .../data/schemas/level_table.gd | 123 +++- .../data/tables/board_gen.tres | 72 ++ .../data/tables/enemy.tres | 211 +++++- .../data/tables/hero.tres | 9 +- .../data/tables/level.tres | 51 ++ .../data/tables/reward.tres | 28 +- .../data/tables/settlement.tres | 15 +- .../data/tables/skills.tres | 19 + .../data/tables/status_effects.tres | 6 +- .../presentation/battle_narrator.gd | 16 +- .../presentation/main.gd | 48 +- .../presentation/playtest_logger.gd | 13 +- .../presentation/ui_assembler.gd | 4 +- .../presentation/views/level_strip_view.gd | 54 +- .../presentation/views/route_overlay.gd | 205 ++++++ .../presentation/views/route_overlay.gd.uid | 1 + .../route_overlay_preview.png | Bin 0 -> 72691 bytes .../smoke/level_autoplay.gd | 29 +- .../smoke/level_monte_carlo.gd | 8 +- .../smoke/smoke_all.gd | 311 +++++++-- .../smoke/ui_click_probe.gd | 25 +- tools/analyze_playtest.py | 642 ++++++++++++++++++ 29 files changed, 1958 insertions(+), 177 deletions(-) create mode 100644 prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd create mode 100644 prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd.uid create mode 100644 prototypes/dungeon-sweeper-vertical-slice/route_overlay_preview.png create mode 100644 tools/analyze_playtest.py diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index 32f62190..26d11bed 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -378,7 +378,7 @@ **归属**:切片图版实现批(随第二关内容批 / 关卡节点地图 P1「分支节点图内容与实现批」) -**状态**:待办 +**状态**:部分清偿(2026-09-17 分支节点图批)——④⑤⑧ 三条已清偿:战斗胜利改进选路态(`choices_presented`,零自动推进);`continue_after_node` 泛化为兑付确认(确认 ≠ 推进,选路动作归 `pick_next_node`);节点图结构 + 图合法性校验已落地(节点带 `next` 出边表,编号 = `node_depth`,编号唯一 / 入口唯一 / Boss 恒唯一终点 / 前进方向无环 / 双向可达六检,违规加载致命,L1 单链经同一套校验)。①②③⑥⑦ 属正式侧冒险结构接口,保持登记。 #### 图版措辞对齐(分支节点图定稿的跨文档残留) @@ -386,7 +386,7 @@ **归属**:各文档下一批修订随批对齐(清单单点登记于此) -**状态**:待办 +**状态**:部分对齐(2026-09-17 分支节点图批)——难度曲线与Boss 行值表已随本批改图版口径(第二关 D0–D8 编号行集、节点措辞改"节点图");HUD 框架 A 区视图实现面已随本批改图版渲染(单链保持链状、图形态行进序列 + 候选)。冒险结构"路径分支已后置"句、数据配置与 battle.md 的"节点链"字样本批未触及,保持登记随各自批次对齐。 ### 冒险结构 @@ -400,7 +400,7 @@ #### 两关序列未生效 -**现状**:第二关深度轴行值填充批落地前,两关序列未生效,冒险以单关运行(合法态;扩展批已排 P1)。关联条目:见难度曲线与Boss「第二关深度轴行值未填充」(行值填充即本条生效前提) +**现状**:第二关深度轴行值填充批落地前,两关序列未生效,冒险以单关运行(合法态;扩展批已排 P1)。关联条目:见难度曲线与Boss「第二关深度轴行值未填充」(行值填充即本条生效前提)。注记(2026-09-17 分支节点图批):切片首页已加"关卡 2 试玩"直入口(测试模式 `start_level(2)`,与序列推进无关)——行值/图版前提已满足后,正式序列推进(关卡 1 通关接续关卡 2)仍未实现,本条保持登记。 **归属**:难度曲线与Boss(随第二关内容批) @@ -446,7 +446,7 @@ **归属**:难度曲线与Boss,随第二关内容批 -**状态**:待办 +**状态**:已解决(2026-09-17 分支节点图批)——L2 幽暗地域图版行集落地:节点编号升级为图版地址(D0–D8 九节点,战斗/Boss 七坐标在敌人表与棋盘表各有精确行,跨表校验强制);行值全部 provisional,投值归 难度曲线与Boss(行值表已回填,见该文档 v0.7)。措辞口径:原"D1–D4 深度轴"升级为图版编号(图形态下节点编号 = 关内唯一定位地址)。 #### 第二关的新内容投放缺口 @@ -454,7 +454,7 @@ **归属**:难度曲线与Boss提案,用户裁决 -**状态**:待办 +**状态**:已解决(2026-09-17 分支节点图批)——第二关投放 = 新敌种巨蜘蛛 giant_spider 与 Boss 蛛化卓尔精灵 drider(关间台阶"新敌种"半边)+ 既有敌种 bugbear 池新技能突刺("既有池新技能"另一半);新技能行撕咬(巨蜘蛛池)与毒爆(蛛化卓尔精灵专属池独占技)。关间台阶纪律两半均兑现。 #### 区间平移换算率数值未定 diff --git a/design/gdd/systems/冒险循环/关卡节点地图.md b/design/gdd/systems/冒险循环/关卡节点地图.md index 9fa40f32..13b11068 100644 --- a/design/gdd/systems/冒险循环/关卡节点地图.md +++ b/design/gdd/systems/冒险循环/关卡节点地图.md @@ -176,3 +176,4 @@ | v0.1 | 2026-09-15 | 自归档线索整合为范围草案(旧体系未单独成文) | | v0.2 | 2026-09-15 | 待拍板"多敌数量的消费面"条目同步教学位掷骰口径(随[难度曲线与Boss](难度曲线与Boss.md)同批修订):删除"教学位节点固定不掷" | | v1.0 | 2026-09-17 | **定稿**(逐问题拍板,全用户裁决):① 形态重开——用户拍板重开[05-不做清单](../../05-不做清单.md)「路径分支地图后置」裁定,定稿为**分支节点图**(分层节点图,杀戮尖塔 / 哥布林弹珠同构;方块地牢美术观感、逻辑为节点图),P0 图定稿 + 单链特例先行;② 节点类型定稿四类封闭枚举不扩类;③ 行进交互定稿全节点确认 + 兑付后确认、零自动推进;④ 遭遇构成收窄为编排面三条(战斗内部初始化全归战斗小循环),寻址定稿节点编号升级(`node_depth` 语义升级为关卡内唯一节点编号,表键与字段名零改动,单链零漂移);⑤ 节点奖励拍板为功能道具的正式渠道(内容归魔法道具内容重定批,切片期直通为合法空态);⑥ 「精英与 Boss 编码位」(同层多遭遇由节点编号消解,不设编码位)与「多敌数量的消费面」(关卡层零消费)两悬案收口,同场多敌种面拆归战斗侧;⑦ 切片行进代码对照的接口漂移清单登记 06 | +| v1.0.1 | 2026-09-17 | **P1 分支节点图内容与实现批落地**(用户拍板任务批——切片代码 / 难度曲线与Boss v0.7 / registry / 06 待办同批):首张分支图 = 切片关卡 2「第二层 · 幽暗地域」行集落地(9 节点网状图:入口 0 → 中间三层 2/3/2 → Boss 8,真实岔路 4 处,奖励点 + 恢复点各 1,战斗节点 6 + Boss);行进器图版推进(`choices_presented` 选路态 + `pick_next_node`——零自动推进,战斗胜利不再自动推进、`continue_after_node` 泛化为兑付确认,06 接口漂移清单 ④⑤⑧ 清偿);图合法性校验落地 `LevelTable.validate`(单链与图同一套算法,L1 零迁移过校验,违规加载致命);节点图选路 UI(选路覆盖层整图自绘——房间方块 + 通道连线 + 当前/候选高亮,占位图形质量;顶栏图形态渲染行进序列 + 候选);首页"关卡 2 试玩"直入口(测试模式,≠ 两关序列生效)。本版本号只记实现批状态,图规则口径零改动 | diff --git a/design/gdd/systems/冒险循环/难度曲线与Boss.md b/design/gdd/systems/冒险循环/难度曲线与Boss.md index 3b4b1ba4..9a46b91e 100644 --- a/design/gdd/systems/冒险循环/难度曲线与Boss.md +++ b/design/gdd/systems/冒险循环/难度曲线与Boss.md @@ -198,8 +198,7 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 | 优先级 | 内容 | 后置原因 | |---|---|---| -| P1 | 第二关深度轴行值(敌人表与棋盘表 D1–D4) | 内容与数值同批,随第二关内容扩展填充;填齐前冒险按单关运行(合法态) | -| P1 | 加难杠杆扫描落带 | 候选杠杆已登记(血量锚走廊、群战上探、注入衰减 0.6),扫描未执行;护甲等级换装点锚定(基准 AC10 自带约 −21% 敌方输出期望 + 减伤墙退役 + 伤害下限)的方向性偏移并入本扫描一并调带(带宽补扫含暴击档与折扣档);落带前现行行值维持 provisional-pending-scan | +| P1 | 加难杠杆扫描落带 | 候选杠杆已登记(血量锚走廊、群战上探、注入衰减 0.6),扫描未执行;护甲等级换装点锚定(基准 AC10 自带约 −21% 敌方输出期望 + 减伤墙退役 + 伤害下限)的方向性偏移并入本扫描一并调带(带宽补扫含暴击档与折扣档);落带前现行行值维持 provisional-pending-scan。第二关行集随分支节点图批落地后并入本扫描(L2 读数基线/加成档已随批实测,见 v0.7) | | P1 | 挖掘风险不等式与清图支配性对账重跑 | 两条对账现行破线 / 缺口,义务落在本文调参批(现状见[战斗结算](../战斗循环/战斗结算.md)「待办」) | | P1 | 真人通关率修正系数标定 | 带按模拟口径定义;试玩批次实测真人显著强于模拟者,系数待后续试玩批次定量 | | P1 | 完美策略档的结构性加难杠杆 | 数值杠杆对完美档已被实测证伪,结构候选未裁决(见「待办」) | @@ -213,7 +212,7 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 首发行值几乎全部 provisional,其中加难相关数值处于 provisional-pending-scan(扫描未执行);完整行集读表,不在此整表搬运。 -敌人表行值(第一关五行 + 第二关锚行,全部 provisional): +敌人表行值(第一关五行 + 第二关图版七行,全部 provisional;第二关 = 2026-09-17 分支节点图批投放,生态走 DND 幽暗地域方向): | 坐标 | 敌种(数据键) | 生命 | 攻击(浮动区间) | 数量 | 技能池(权重) | 护甲 | |---|---|---|---|---|---|---| @@ -222,7 +221,15 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 | 第一关 D2(首遇固定) | 熊地精 bugbear | 40 | 9 [6,12] | 1 | 普攻 6 · 蓄力 2 · 重击 2 | 魔法护甲 | | 第一关 D3 | 熊地精 bugbear | 18 | 6 [3,9] | [2,3] | 普攻 6 · 蓄力 2 · 重击 2 | 魔法护甲 | | 第一关 D4(Boss) | 食人魔酋长 ogre_chief | 150 | 9 [6,12] | 1 | 普攻 6 · 重击 2 · 硬化 2 | 重甲 | -| 第二关 D0(锚行) | 熊地精 bugbear | 30 | 7 [4,10] | 1 | 普攻 6 · 蓄力 2 · 重击 2 | 魔法护甲 | +| 第二关 编号 0(锚行·首遇教学位) | 熊地精 bugbear | 30 | 7 [4,10] | 1 | 普攻 6 · 突刺 1 · 重击 2 | 魔法护甲 | +| 第二关 编号 1 | 熊地精猎群 bugbear | 15 | 5 [3,7] | [2,3] | 普攻 6 · 突刺 1 · 重击 2 | 魔法护甲 | +| 第二关 编号 4(首遇固定) | 巨蜘蛛 giant_spider | 14 | 5 [3,7] | 2 | 普攻 6 · 撕咬 2 · 蓄力 1 | 轻甲 | +| 第二关 编号 5 | 熊地精重击手 bugbear | 38 | 8 [5,11] | 1 | 普攻 5 · 突刺 2 · 重击 2 | 魔法护甲 | +| 第二关 编号 6 | 巨蜘蛛巢群 giant_spider | 12 | 4 [2,6] | [2,3] | 普攻 6 · 撕咬 2 · 蓄力 1 | 轻甲 | +| 第二关 编号 7 | 巨蜘蛛巢群 giant_spider | 13 | 5 [3,7] | [2,3] | 普攻 6 · 撕咬 2 · 蓄力 1 | 轻甲 | +| 第二关 编号 8(Boss) | 蛛化卓尔精灵 drider | 175 | 8 [5,11] | 1 | 普攻 6 · 重击 2 · 毒爆 2(独占技) | 重甲 | + +第二关图版注记(分支节点图批):兄弟精 = bugbear 复用(幽暗地域部族设定——display_name 与第一关"熊地精"错开;池差异化改造 = 蓄力拍退役换突刺,复用共享技能行);巨蜘蛛 = 新敌种(群行倾向,数量区间 [2,3]、首遇位编号 4 固定 2 不加骰——首遇教学位纪律);蛛化卓尔精灵 = Boss 专属池(独占技毒爆 + 权重轮廓,血量 175 为 r_boss 口径 provisional 锚——定位本关最大单场消耗);护甲面:轻甲(蜘蛛)与魔法护甲(兄弟精)同关错开克制面,Boss 重甲。「兄弟精」称呼经用户 2026-09-17 确认 = 熊地精 bugbear;Boss 定名蛛化卓尔精灵(用户同日拍板)。节点编号 2 = 奖励点(直通空态)、3 = 恢复点(rest_heal_pct 行字段兑付)——不开战斗、无敌人/棋盘行。 单轮伏击授予上限 4(已定稿)。攻击浮动开战时掷定一次、整战持久;数量区间同流掷定,教学位(地精 ×[1,2])与首遇位都不豁免——没有区间上界的行自然取定值,那不是豁免,是区间宽度为零。 @@ -230,10 +237,10 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 | 参数 | 现行值 | 说明 | |---|---|---| -| 池权重 | 三敌种见上表(地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2) | 权重即旋钮:抬特殊技权重 = 抬输出频率,EV 随之重锚 | -| 技能冷却(拍) | 突刺 2 · 蓄力 3 · 重击 3 · 硬化 4 · 普攻恒 0 | 普攻冷却恒 0 不可配(空池不可能的结构前提——域校验锁死) | +| 池权重 | 六敌种行集见上表(L1:地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2:兄弟精 6/1/2 · 巨蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2) | 权重即旋钮:抬特殊技权重 = 抬输出频率,EV 随之重锚 | +| 技能冷却(拍) | 突刺 2 · 撕咬 1 · 蓄力 3 · 重击 3 · 硬化 4 · 毒爆 4 · 普攻恒 0 | 普攻冷却恒 0 不可配(空池不可能的结构前提——域校验锁死);撕咬 = 与突刺同倍率错开冷却轴的高频小爆发轮廓;毒爆 = 蛛化卓尔精灵专属池独占技 | | 保底 M | 4(全局单旋钮,技能表 pity_threshold) | 连续 M 拍执行普攻 → 下拍必出特殊技;地精这类单特殊技低权重池受保底支配(突刺率 10% → ~22%) | -| 池 EV 锚 | 静态份额:地精 1.05 · 熊地精 1.00 · 酋长 1.00 | 含闸长期速率实测(2026-09-16 实施批 ×5000 拍):地精 1.106 / 熊地精 1.000 / 酋长 1.024——EV 重锚与权重投值随本表修订 | +| 池 EV 锚 | 静态份额:L1 地精 1.05 · 熊地精 1.00 · 酋长 1.00;L2 兄弟精 ≈1.28 · 巨蜘蛛 ≈1.11 · 重击手 ≈1.33 · 蛛化卓尔精灵 ≈1.67(权重期望口径) | 含闸长期速率实测(2026-09-16 实施批 ×5000 拍):地精 1.106 / 熊地精 1.000 / 酋长 1.024——L2 池静态份额偏移系冷却闸(撕咬 1 / 毒爆 4)拉低后的实际速率待抽样复核;EV 重锚与权重投值随本表修订 | 主角表与棋盘参数表(provisional): @@ -242,6 +249,7 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 | 主角表 | 生命上限 50 · 基础攻击 6 · 护甲等级 10(暂定) | | 棋盘参数(第一关 D0/D1) | 10×10 · 雷密度 0.25 · 可推理率目标 0.88 · 生成尝试上限 16 | | 棋盘参数(第一关 D2–D4,含 Boss) | 12×12 · 雷密度 0.25 · 可推理率目标 0.88 · 生成尝试上限 24 | +| 棋盘参数(第二关 编号 0/1/4/5/6/7/8,含 Boss) | 12×12 · 雷密度 0.25 · 可推理率目标 0.88 · 生成尝试上限 24(与第一关高档同值——密度/推理率轴差异化留给调参批;奖励点/恢复点不开战斗无板行) | 战斗侧与奖励侧的关键投值(机制归对方、数值所有权本文,全部 provisional): @@ -284,3 +292,4 @@ Boss 战的棋盘用本关最大板形(第一关为 12×12),板参数仍 | v0.4 | 2026-09-15 | **护甲等级批(与战斗结算 / 敌人系统 / registry 同批登记)**:生存链区间模型补记护甲等级掷点带宽口径——峰值/谷值构成各含最坏/最好掷点带宽;EV 中性引入成文(期望伤害对齐现行表值、现行行值不重调,折扣档位与掷点形式定稿前区间扫描维持全额口径、带宽项随定稿批补扫,"零红档"验收与三档判定式不变);调参联动表新增"护甲等级 ↔ 攻击基线 ↔ 区间带宽"一行 | | v0.5 | 2026-09-16 | **护甲等级掷点批·定稿落地(与战斗结算 / 敌人系统 / registry / 切片代码同批)**:带宽口径改写为"换装点锚定"——掷点形式与档位定稿(d20 / 缺口 1-3→×0.75 · 4-7→×0.5 · 8+→×0.25 / nat20 暴击 ×2 恒全额),基准 AC10 自带约 −21% 敌方输出期望(AC11 25% / AC12 28.75%),对比退役的 defense_up 满层 −60% 减伤墙为结构性削弱 + 伤害下限使零承伤轮结构性消失,方向性偏移与加难杠杆扫描合并处理、不单独重调行值;带宽补扫义务含暴击档与折扣档(并入加难扫描批);护甲等级行值:主角 10(暂定)、敌方首关 0(暂定,正式行值归怪物设定批);调参联动表"护甲等级"行改定稿口径;调参表主角表行与状态强度行同步(减伤 −30 → 护甲强化 +1 AC ×2 层) | | v0.6 | 2026-09-16 | **敌人技能池批(与敌人系统 / 战斗结算 / 增益状态 / registry / 切片代码同批)**:曲线纪律两处改写(关间台阶"既有敌种新行为环段" → "既有池新技能";关内坡度"保持行为环读数不变" → "保持技能池 EV 读数不变〔权重即旋钮、EV 重锚随批登记〕");Boss 约定改专属池承载(独占技"硬化" + 权重轮廓,旧"专属四拍环且含蓄力拍"退役);调参表敌人行值表"行为环"列改"技能池(权重)"列并落建议池行值,新增"技能池投值"四行(池权重 / 技能冷却 / 保底 M / 池 EV 锚——含闸长期速率实测登记:地精 1.106〔保底闸抬升〕/ 熊地精 1.000 / 酋长 1.024);重击倍率注记随倍率归技能行删除;区间模型受伤峰值/谷值构成改按含闸长期速率复核口径;行值零改动(EV 中性锚定维持,重锚与权重投值随加难扫描批合并处理) | +| v0.7 | 2026-09-17 | **分支节点图批(L2 幽暗地域内容批——与切片代码 / registry / 关卡节点地图 / 06 待办同批)**:第二关深度轴行值填充收口(原 P1 后置项)——图版编号 D0–D8 九节点,战斗/Boss 七坐标在敌人表与棋盘表各有精确行(跨表校验强制);新敌种巨蜘蛛 giant_spider(群行 [2,3]、首遇位固定、轻甲)与 Boss 蛛化卓尔精灵 drider(重甲、血量 175 = r_boss provisional 锚);既有敌种 bugbear 池新技能突刺(兄弟精池差异化——关间台阶纪律两半均兑现);新技能行撕咬(×1.5 冷却 1)/ 毒爆(×2.5 冷却 4,蛛化卓尔精灵专属池独占);技能池投值表 / 棋盘参数表随批扩列,L2 池 EV 锚登记静态份额(≈1.28/1.11/1.33/1.67,含闸速率待抽样);首遇位绿档冒烟断言落地(基线 0 加成完美档 12 种子首遇战全胜且零踩雷)。全部行值 provisional,L2 整关通关率读数随批实测记录于切片汇报(精调归加难杠杆扫描批) | diff --git a/design/registry/entities.yaml b/design/registry/entities.yaml index f1952965..b043677b 100644 --- a/design/registry/entities.yaml +++ b/design/registry/entities.yaml @@ -523,12 +523,12 @@ entities: table: LevelTable(数据配置 承载,键 = level_index 行、行内 nodes 节点图结构〔2026-09-17 关卡节点地图 v1.0 定稿:分支节点图;单链 = 特例〕——切片建表,冒险结构 定稿转正为行集内容 owner) fields: level_index: int 行主键(冒险序列序号,从 1 连续递增——线性序列无跳关) - display_name: String(玩家可读层名——L1"第一层 · 苔藓回廊"切片现行;L2 层名未定稿〔provisional,终稿随 L2 内容批〕) + display_name: String(玩家可读层名——L1"第一层 · 苔藓回廊"、L2"第二层 · 幽暗地域"切片现行〔L2 随 2026-09-17 分支节点图批落名〕) rest_heal_pct: int [0,100](knob 默认 30——恢复点回复 max_hp_eff 的百分比,随上限加成同步放大防高上限档恢复失效;数值复验归 难度曲线与Boss) nodes: 节点图结构——节点数组 + 连线(node_kind ∈ {battle, reward, rest, boss},切片字段名 type;每节点领关卡内唯一**节点编号**,battle/boss 数据键 node_depth 沿用〔v1.0 语义升级:深度 → 节点编号〕;连线 = 出边节点编号表,只朝前进方向、无回头路);单链特例 = 隐式后继(数组序即链,切片现行形态零漂移) - rowset_rule: 图合法性校验(2026-09-17 关卡节点地图 v1.0)——节点编号关卡内唯一、入口唯一、Boss 恒唯一终点(编号最大,Dmax 语义平移)、连线无环、所有节点沿连线可达 Boss,违规加载期致命;MVP 链模板 = battle D0 → battle D1 → reward → battle D2 → battle D3 → rest → boss D4(7 节点 5 战斗深度,L1/L2 同构复用——单链特例行集,分支行集随第二关内容批) + rowset_rule: 图合法性校验(2026-09-17 关卡节点地图 v1.0)——节点编号关卡内唯一、入口唯一、Boss 恒唯一终点(编号最大,Dmax 语义平移)、连线无环、所有节点沿连线可达 Boss,违规加载期致命;实现批(2026-09-17 分支节点图批):校验落 LevelTable.validate(单链形态经同一套算法——编号解析单链 = 数组下标 / 图 = node_depth),L1 单链与 L2 图双行集加载全绿断言 cross_table_validation: battle/boss 节点 (L,D) 在 board_gen/enemy 两表存在精确行(数据配置 跨表校验⑧复用 + 冒险结构 AC-7 冒烟断言——序列关数 ⊆ 内容覆盖,越界关卡 = 内容生产缺口) - mvp_content: L1 全行(切片转正——7 节点链〔单链特例,合法图形态〕、rest_heal_pct=30);L2 行 = N=2 扩展批(同构模板或首张分支图 + 难度曲线与Boss 深度轴行值同批填充——冒险结构 GDD 生效前提,未落地前冒险以单关行集运行 N=1 合法态) + mvp_content: L1 全行(切片转正——7 节点链〔单链特例,合法图形态〕、rest_heal_pct=30);L2 全行已落地(2026-09-17 分支节点图批——首张分支图:9 节点〔入口 0 → 中间三层 2/3/2 → Boss 8〕、真实岔路 4 处、奖励点+恢复点各 1、战斗节点 6 + Boss;行值 provisional 归 难度曲线与Boss v0.7;首页直入口试玩 ≠ 两关序列生效,正式序列推进仍登记待办) referenced_by: [冒险结构, 关卡节点地图, 难度曲线与Boss, 棋盘生成, 数据配置] - name: adventure_table @@ -710,3 +710,19 @@ entities: # 「代币」措辞全仓退役(冒险结构 v0.3 / 元进度 v0.2 / 数据配置 v0.3 / architecture.md / adr-0005 随批清偿); # adventure_table 字段键改 keystone_per_level(原 token_per_level,AdventureTable 未建表零代码影响); # 钥石产出细则与解锁定价仍归元进度定稿批(关联待办「钥石的重复获取」继续)。 +# 2026-09-17 分支节点图批(L2 幽暗地域内容批——关卡节点地图 v1.0 P1 实现批,用户拍板;与切片代码 / +# 难度曲线与Boss v0.7 / 06-待办清单 / 关卡节点地图 版本历史同批): +# ① level_table:display_name L2 落名"第二层 · 幽暗地域";mvp_content 改"L1 全行 + L2 全行已落地" +# (首张分支图:9 节点 / 岔路 4 处 / 奖励点+恢复点各 1 / 战斗节点 6 + Boss 8;行值 provisional); +# rowset_rule 增实现批注记(校验落 LevelTable.validate——单链 = 数组下标编号空间 / 图 = node_depth, +# L1 与 L2 双行集经同一套算法,冒烟全绿断言);首页直入口试玩 ≠ 两关序列生效(序列推进仍待办)。 +# ② skill_table 新行两行(provisional,投值归 难度曲线与Boss v0.7):撕咬 bite(damage ×1.5 冷却 1—— +# 巨蜘蛛池载体,与突刺同倍率错开冷却轴);毒爆 venom_burst(damage ×2.5 冷却 4——蛛化卓尔精灵 drider +# 专属池独占技,独占性冒烟断言)。 +# ③ enemy_table L2 行集七行(provisional):熊地精系(bugbear 复用——兄弟精部族设定,池差异化 +# 普攻/突刺/重击,「兄弟精」称呼经用户确认即熊地精)× 编号 0/1/5;巨蜘蛛 giant_spider(新敌种——群行 [2,3]、 +# 首遇位编号 4 固定 2 不加骰、轻甲)× 编号 4/6/7;蛛化卓尔精灵 drider(Boss——重甲、175=r_boss 口径 +# provisional 锚、专属池 普攻 6/重击 2/毒爆 2)× 编号 8。跨表精确行校验随 ConfigDB 既有通道强制。 +# ④ board_gen_table L2 七行(12×12 d0.25 t0.88——与 L1 高档同值,差异化留给调参批)。 +# ⑤ L2 首遇位绿档冒烟断言落地(基线 0 加成完美档 12 种子首遇战全胜且零踩雷);L2 整关通关率读数 +# 随批实测记录于切片汇报(基线裸值 0% / hp+100 档 35–80%,N=20——精调归加难杠杆扫描批)。 diff --git a/production/session-state/active.md b/production/session-state/active.md index 97431caf..bbb05d14 100644 --- a/production/session-state/active.md +++ b/production/session-state/active.md @@ -12,13 +12,13 @@ ## 当前状态快照(收口批先更新本节,再追加台账;≤10 行) -- **更新于**:2026-09-17 -- **在飞**:并行会话在飞切片图版 schema 实现(level_table.gd 分支节点图校验,未收编,标旗不碰随其本批收口);四批已收编提交(2026-09-17,见锚点) -- **已完成**:关卡节点地图 GDD 定稿批(2026-09-17,v1.0——分支节点图定稿 + 配套登记全落,零代码) -- **已解锁可做**:验收批(门 = 收编提交完成) -- **阻塞等待**:难度曲线阶段二调参批(门 = 验收读数) -- **锚点提交**:131d104 AC 掷点批 · 1c63830 技能池提案 · 8ca3da6 技能池收编 · ba08ba1 魔法道具空内容拍板 · d7d2200 关卡节点地图定稿 · 85933f4 状态持久化 · a0cba61 概念地图产物 -- **下一步顺序**:并行图版批收口 → 验收批(acceptance-ap6-ac 读数重测;真人 3–5 局待并行批结束后用户开跑)→ 曲线调参批 → 第二关内容批(详见 production/roadmap.md) +- **更新于**:2026-09-17(分支节点图批收口) +- **在飞**:无(并行图版 schema 会话残留由本批全量重实现收口;工作区余量 = 本批文件,待用户审批提交) +- **已完成**:分支节点图批 = 关卡 2「幽暗地域」试玩批(2026-09-17——图版行进器 + 图校验 + 选路 UI + L2 内容行 + 首页直入口;冒烟 296 全绿 + 四探针 PASS + L2 扫描读数随批产出;五文档回填) +- **已解锁可做**:验收批(acceptance-ap6-ac 读数重测——AC/技能池换装后旧读数失效,须另批重测;真人 3–5 局可开跑) +- **阻塞等待**:难度曲线阶段二调参批(门 = 验收读数;L2 行值已入表并入其扫描面) +- **锚点提交**:8ca3da6 技能池收编 · ba08ba1 魔法道具空内容 · d7d2200 关卡节点地图定稿 · 85933f4 状态持久化 · a0cba61 概念地图产物 · 31f4966 代币→钥石统一 ·(本批未提交) +- **下一步顺序**:本批用户审批提交 → 验收批(读数重测 + 真人试玩)→ 曲线调参批(第二关深度轴行值已填充,P1 收口)→ 魔法道具内容批(详见 production/roadmap.md) ## 项目当前位置 @@ -172,3 +172,5 @@ 63. **四批收编提交批(2026-09-17,本会话——用户审批"①相关可审批提交")**:工作区遗留四批按文件/hunk 归属核清后拆四笔提交——① **魔法道具切片期空内容拍板批**(魔法道具.md v0.3 + 06 两处已解决/三处归属标注排切片后 + entities aux slice_scope)→ **ba08ba1**;② **关卡节点地图定稿批**(关卡节点地图.md v1.0 + 02 状态行 v0.7 + 05 移除路径分支 + 06 三悬案收口两新增 + entities 六 hunk 含 OQ6 兑现与修订记录)→ **d7d2200**;③ **跨会话状态持久化批**(roadmap.md 新建 + stage 修正 + Stop 守卫两步指引 + CLAUDE.md 导航)→ **85933f4**;④ **GDD 概念结构图产物**(archify html + architecture.json + 可视化检查件四视图)→ **a0cba61**。交叉文件 06-待办清单(魔法道具 5 hunk / 关卡节点地图 4 hunk)与 entities.yaml(1 + 6 hunk)按 hunk 拆分归属;active.md 台账(59–62 补记 + 快照节)随本批末笔提交。零代码零数值,冒烟不适用。**工作区剩余**:并行会话在飞 level_table.gd(分支节点图 schema 校验实现——会话中途出现,标旗不碰,随其本批收口);验收批门(收编提交完成 + 工作区净)待并行批收口后全开。 64. **代币→钥石统一拍板批(2026-09-17,用户裁决——零代码零数值)**:06 清单「代币与钥石的资源关系」关闭——冒险结构关卡大奖励的「代币记账 +10/关」即钥石入账,**同一资源**、两渠道(关卡大奖励入账 +10/关 provisional 与关底 Boss 掉落);「代币」概念退役。**措辞全仓清偿**:冒险结构 v0.3(大奖励第三件改「钥石入账」+ 摘要五件事/依赖表/调参表/验收标准同步 + 统一拍板注记)/ 元进度 v0.2(资源模型裁决落地行 + 接口位改名「钥石入账挂点」+ 钥石节补两渠道口径)/ 数据配置 v0.3(表清单行)/ registry(关卡大奖励术语注记 + adventure_table 改键 token_per_level→keystone_per_level〔未建表零代码影响〕+ 修订记录)/ architecture.md + adr-0005(C4 行一词)/ roadmap.md 后置轨道行。产出细则(Boss 重复击杀规则、两渠道量级配比)与解锁清单/定价仍归元进度定稿批(关联待办「钥石的重复获取」继续)。**同批登记**:用户确认真人试玩(验收批三数据源之一)待并行切片代码实现批结束后开始——快照节下一步顺序同步。 + +65. **分支节点图批 = 关卡 2「幽暗地域」试玩批(2026-09-17,用户拍板任务批——关卡节点地图 v1.0 P1 实现批,切片代码 + 数据 + 文档 + registry 同批;不自行提交)**:**① 图版行进器**(level_runner 重构):节点带 `next` 出边表,兑付后经 `choices_presented` 进选路态、`pick_next_node` 显式选下一跳——战斗胜利不再自动推进、`continue_after_node` 泛化为兑付确认(06 接口漂移 ④⑤⑧ 清偿,①②③⑥⑦ 属正式侧保持登记);编号空间单一出口(图 = node_depth / 单链 = 数组下标,`node_id_of`/`node_out_ids`/`next_candidate_ids`/`visited_ids`/`current_node_id` 成文读面——单链混合空间碰撞缺陷在实现期发现并收口)。**② 图合法性校验**(level_table.validate 重构):编号唯一/入口唯一/Boss 恒唯一终点(无出边+编号最大)/前进方向无环/入口可达全部/全部可达 Boss 六检,违规加载致命;单链 = 出边恰一退化形态经同一套算法(L1 行集零迁移),冒烟正反断言六类违例全拒。**③ L2 内容行**:level.tres 增"第二层 · 幽暗地域"9 节点网状图(入口 0 → 中间三层 2/3/2 → Boss 8,岔路 4 处,奖励点+恢复点各 1,战斗节点 6);enemy.tres 增 L2 七行(熊地精系 bugbear 复用 ×3〔池差异化 普攻/突刺/重击〕+ 巨蜘蛛 giant_spider 新敌种 ×3〔群行 [2,3]、首遇位固定、轻甲〕+ 蛛化卓尔精灵 drider Boss〔重甲 175hp 专属池 普攻 6/重击 2/毒爆 2〕,全 provisional);skills.tres 增撕咬 bite(×1.5 冷却 1)+ 毒爆 venom_burst(×2.5 冷却 4 独占技);board_gen.tres 增 L2 七行铺满;跨表精确行校验既有通道自动生效。**④ 呈现**:新建 route_overlay.gd(选路覆盖层——整图自绘房间方块+通道连线+当前/候选高亮+候选按钮,程序化占位质量);顶栏 strip_view 图形态渲染行进序列+候选(单链保持链状零迁移);首页"▶ 第二关 · 幽暗地域"直入口(`_start_full_level(2)` 测试模式,≠ 两关序列生效);narrator 增岔路口文案。**⑤ 验证**:冒烟 263→**296 项 0 失败**(图校验正反 6 断言 + L2 min/max 双路径通关 + 零自动推进状态机手动驱动逐拍断言〔含候选外拒绝语义〕+ 首遇位绿档〔基线 0 加成完美档 12 种子首遇战全胜且零踩雷〕+ 跨表行完整 + 图版结构;技能池批 5 行快照断言随内容批升级为 12 行行集键版);四探针全 PASS(click_probe 增 L2 段:L1 终局→真实"返回"路径→`start_level(2)` 断言→岔路真实点击 15 次交替选候选→L2 通关,问题=0);同种子回放确定性保持;L2 数值扫描(level_monte_carlo 增关卡参数,N=20 双策略):基线裸值通关 **0%**(死因集中 Boss×16/20)· hp+100% 档 35% · atk+50/hp+100 65% · atk+100/hp+100 75% · atk+150/hp+100 80%——行值 provisional,精调归曲线调参批。**⑥ 文档回填**:难度曲线与Boss v0.7(第二关深度轴 P1 收口 + 行值表 L2 七行 + 技能池投值表扩列〔冷却/EV 锚静态份额〕+ 棋盘参数表);06-待办清单(接口漂移 ④⑤⑧ 清偿、图版措辞对齐部分对齐、两关序列注记直入口≠序列、深度轴行值+新内容投放两条已解决留痕);registry(level_table 三处 + 修订记录);关卡节点地图 v1.0.1(P1 落地状态行);切片 README(L2 试玩采集清单)。**待确认两点已收口(用户 2026-09-17 同日裁决)**:①「兄弟精」= 熊地精 bugbear(L2 display_name 回归熊地精系:熊地精/熊地精猎群/熊地精重击手——原"巴布精"译名废弃);②Boss 定名蛛化卓尔精灵 drider(enemy.tres display_name 同步)。**⑦ 批内视觉验证修复(用户追问"选路 UI 为什么看不到"引出的实锤)**:① route_overlay 未 add_child 入树(注册模态但不在场景树 = 完全不可见——手写构建绕过了 ui_assembler 的 add_child 惯例);② 全部六个模态覆盖层尺寸 0×0(Control 默认锚不撑满宿主——全屏变暗层与居中布局整体失效,节点确认/图例/终局/调整板等既有覆盖层同病;探针经信号直发绕过真实点击与渲染从未暴露)。修复 = _register_modal 统一 set_anchors_and_offsets_preset(FULL_RECT) + route_overlay 入树并按树序=登记序=z 序纪律插于节点层与终局层之间;窗口模式截图验证选路覆盖层完整渲染(route_overlay_preview.png),冒烟 296 全绿 + 四探针 PASS 复跑。**实现期事故注记**:并行会话(四批收编批)与本批同工作区在飞,其六笔提交(至 31f4966)先期落库——开工时脏文件全数收编,收口时工作区余量恰为本批 21 文件,零交叉零覆盖;更早会话遗留的"在飞 level_table.gd 图版 schema"未落盘,本批全量重实现。**未提交**(本批纪律)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/README.md b/prototypes/dungeon-sweeper-vertical-slice/README.md index 7055e348..328182fb 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/README.md +++ b/prototypes/dungeon-sweeper-vertical-slice/README.md @@ -165,6 +165,16 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s - **Boss 战节拍**:遗迹守卫环 [attack,attack,charge,heavy]——蓄力拍是否被玩家用作"安全挖掘拍";重击 14 点是否构成规划压力。 - 节点进度条/奖励恢复覆盖层可读性;v1 遗留观测项(结算逐行归因、完美排雷可达性)继续有效(原"雾池体感"一项随雾格移除作废)。 +## L2 试玩采集清单(分支节点图批 2026-09-17——关卡 2「幽暗地域」试玩观测项) + +入口:首页"▶ 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)"(测试模式直入口,种子框同用;与关卡 1 行为一致:通关/败北回首页)。 + +- **选路行为**:战斗胜利/兑付确认后弹出选路覆盖层(整图 + 候选按钮)——玩家是否理解"零自动推进、每跳都要选";单候选节点(恢复点→编号 6)是否被读成"卡住"。 +- **岔路使用率**:四处真实岔路的走向分布(入口 0→[1,2];战斗 1→[3,4];奖励 2→[4,5];深层 4→[6,7])——奖励点路线 vs 纯战斗路线的选择比例;整图渲染是否被实际用来规划(对比只点按钮的玩家)。 +- **新敌种可读性**:熊地精(突刺/重击池)的意图可读性;巨蜘蛛群行(2–3 只)的数量压力体感;蛛化卓尔精灵 Boss 的毒爆(×2.5)与重击(×2.0)是否构成规划差异(对照关卡 1 酋长的硬化池)。 +- **顶栏图条**:图形态行进序列(已过暗金 → 当前金 → 候选亮边)是否足以反映位置;编号显示(节点编号 = 关内地址)是否被理解。 +- **数值读数参照**(provisional,N=20 双策略扫描 2026-09-17):基线裸值通关 0%(死因集中 Boss×16);hp+100% 档 35% → atk+150%/hp+100% 档 80%——真人试玩体感与读数的偏差即试玩采集的主要增量。 + ## 2026-09-11 用户拍板(四条,已落地本切片) | # | 拍板 | 切片实现落点 | diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd b/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd index 5663263a..d7d02cde 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd @@ -1,23 +1,27 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证 -# Date: 2026-09-11 -# 关卡节点地图(provisional 线性形态——节点链来自 LevelTable,路径分支/形态定稿权归 关卡节点地图): +# Date: 2026-09-11(2026-09-17 分支节点图批:行进器升级图版推进——单链 = 出边恰一的退化形态) +# 关卡节点地图(v1.0 图版落地——节点+连线,连线只朝前进方向,唯一入口,Boss 恒唯一终点): # 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。 +# 行进纪律(零自动推进):到达即兑付;兑付后确认才出现选路口——战斗胜利与奖励/恢复确认一律经 +# choices_presented 进入选路态,玩家 pick_next_node 显式选下一跳(接口漂移清偿:战斗胜利不再自动推进、 +# continue_after_node 泛化为兑付确认——选路确认归 pick_next_node)。 # 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为 # 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。 # Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。 # 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。 # E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变, # 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。 -# 对应 GDD:关卡节点地图(未设计——provisional 切片先行,定稿后回写;命名规范见 CLAUDE.md) +# 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md) class_name LevelRunner extends RefCounted signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp} +signal choices_presented(candidates: Array) # 选路口(兑付确认后——候选节点引用数组,零自动推进) signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行 signal round_started(round_index: int) signal board_changed() -signal level_finished(payload: Dictionary) # {result, hp, node_index, node_type} +signal level_finished(payload: Dictionary) # {result, hp, node_index, node_id, node_type} var db var rng @@ -31,6 +35,10 @@ var hero_atk_pct := 0 # 测试面板覆盖值(切片专用 var hero_hp_pct := 0 var _running := false var level_result := "" # "" / "victory" / "defeat" +var _awaiting_choice := false # 选路态(兑付确认后、pick 前为零自动推进的可见停点) +var _candidates: Array = [] # 当前选路候选(节点引用数组) +var visited: Array = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点) +var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false) func _init(db_ref, rng_ref) -> void: @@ -69,10 +77,23 @@ func start_level(level_index: int, seed_val: int) -> bool: level_row = row node_index = -1 level_result = "" + _awaiting_choice = false + _candidates = [] + visited = [] + _graph_mode = false + for node in nodes(): + if node.has("next"): + _graph_mode = true + break hero_hp = effective_max_hp() rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构) _running = true - return _enter_next_node() + var entry := _entry_node() + if entry.is_empty(): + push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") + _running = false + return false + return _enter_node(entry) func _find_level_row(level_index: int) -> Dictionary: @@ -92,20 +113,101 @@ func current_node() -> Dictionary: return nodes()[node_index] -func _enter_next_node() -> bool: +# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)---- +# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。 +# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。 + +func _node_id(node: Dictionary) -> int: + if _graph_mode: + return int(node.get("node_depth", -1)) + return nodes().find(node) + + +# 入口节点 = 无入边唯一节点(图合法性校验保证恰一) +func _entry_node() -> Dictionary: + var inbound := {} + for node in nodes(): + for tid in _out_ids(node): + inbound[tid] = true + for node in nodes(): + if not inbound.has(_node_id(node)): + return node + return {} + + +# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态) +func _out_ids(node: Dictionary) -> Array: + if node.has("next"): + return node.get("next", []) + var nodes: Array = nodes() + var i := nodes.find(node) + if i >= 0 and i < nodes.size() - 1: + return [i + 1] + return [] + + +# 出边候选(节点引用数组——选路口/呈现层共用读面) +func _out_nodes(node: Dictionary) -> Array: + var ids := {} + for n in nodes(): + ids[_node_id(n)] = n + var out: Array = [] + for tid in _out_ids(node): + if ids.has(tid): + out.append(ids[tid]) + return out + + +# 当前选路候选(选路态读面——呈现层渲染下一跳;非选路态 = 空) +func next_candidates() -> Array: + return _candidates.duplicate() + + +# 当前选路候选编号表(pick_next_node 的合法入参域——编号空间解析单一出口) +func next_candidate_ids() -> Array: + var out: Array = [] + for c in _candidates: + out.append(_node_id(c)) + return out + + +# 当前节点编号(呈现层/日志读面——编号空间解析单一出口) +func current_node_id() -> int: + return _node_id(current_node()) + + +# 任意节点编号 / 出边编号表(呈现层整图渲染读面——与行进同源,禁呈现层复算编号空间) +func node_id_of(node: Dictionary) -> int: + return _node_id(node) + + +func node_out_ids(node: Dictionary) -> Array: + return _out_ids(node) + + +# 已到节点编号序列(只读投影——编号空间解析单一出口) +func visited_ids() -> Array: + var out: Array = [] + for v in visited: + out.append(_node_id(v)) + return out + + +func awaiting_choice() -> bool: + return _awaiting_choice + + +func _enter_node(node: Dictionary) -> bool: if not _running: return false - node_index += 1 - var node: Dictionary = current_node() - if node.is_empty(): - push_error("[关卡节点地图] 节点链越界(末节点 Boss 应已结束关卡)") - return false + node_index = nodes().find(node) + visited.append(node) match String(node.get("type", "")): "battle", "boss": return _start_battle(node) "reward": # 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定—— - # 关卡节点地图定稿批裁决;节点保留占位、零兑付直通) + # 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进) node_entered.emit(node, {}) return true "rest": @@ -158,9 +260,11 @@ func _on_battle_finished(payload: Dictionary) -> void: _running = false _settlement = null level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index, - "node_type": String(node.get("type", ""))}) + "node_id": _node_id(node), "node_type": String(node.get("type", ""))}) return - _enter_next_node() + # 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口 + _settlement = null + _present_choices() func _apply_rest() -> int: @@ -172,6 +276,32 @@ func _apply_rest() -> int: return actual +# ---- 选路(零自动推进的停点语义:兑付确认 → 候选呈现 → 显式 pick)---- + +# 兑付确认后呈现选路口(战斗胜利 / 奖励·恢复确认统一入口——单候选同样确认,Boss 外无免确认通道) +func _present_choices() -> bool: + var cands := _out_nodes(current_node()) + if cands.is_empty(): + push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)") + return false + _awaiting_choice = true + _candidates = cands + choices_presented.emit(cands) + return true + + +# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生) +func pick_next_node(node_id: int) -> bool: + if not _awaiting_choice: + return false + for cand in _candidates: + if _node_id(cand) == node_id: + _awaiting_choice = false + _candidates = [] + return _enter_node(cand) + return false + + # ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)---- func current_battle(): @@ -180,12 +310,13 @@ func current_battle(): # ---- 玩家输入转发(呈现层只与 runner 对话)---- -# 奖励/恢复点确认继续(呈现层在覆盖层上点击"继续"后调用;自动模拟在 node_entered 回调内立即调用) +# 奖励/恢复点兑付确认(呈现层在覆盖层上点击"继续"后调用;自动模拟在 node_entered 回调内立即调用) +# 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node func continue_after_node() -> bool: var t := String(current_node().get("type", "")) if t != "reward" and t != "rest": return false - return _enter_next_node() + return _present_choices() func player_request_refresh() -> bool: diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/schemas/level_table.gd b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/level_table.gd index 802f6330..8e57f7d4 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/schemas/level_table.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/level_table.gd @@ -1,9 +1,15 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证 -# Date: 2026-09-11 -# 数据配置 LevelTable schema(provisional——关卡节点地图未设计,本表为线性切片形态的结构位: -# nodes 数组即未来节点链的数据承载面;路径分支/奖励点分布的定稿权归 关卡节点地图/难度曲线与Boss)。 -# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(随机主动道具点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)。 +# Date: 2026-09-11(2026-09-17 分支节点图批:nodes 升级图结构——单链=出边恰一的退化形态) +# 数据配置 LevelTable schema(关卡节点地图 v1.0 承载面)。 +# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(直通占位点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)。 +# 图形态(行集内任一节点带 "next" 出边表即整行按图校验):节点编号 = node_depth(关卡内唯一定位地址, +# 全节点须带,reward/rest 亦然);next = [出边节点编号...](连线只朝前进方向:目标编号严格大于自身)。 +# 单链形态(零 "next" 键——第一关现状零迁移):隐式出边 = 数组顺序下一项,节点编号 = 数组下标 +# (仅校验期内部使用;node_depth 仍只对 battle/boss 必填)——单链经同一套图合法性算法。 +# 图合法性(违规加载致命,validate 报错由 ConfigDB 收敛为 fail-fast): +# 编号唯一 · 入口唯一(无入边节点恰一)· Boss 恒唯一终点(无出边且编号最大)· 连线只朝前进方向(⇒无环) +# · 从入口可达全部节点 · 全部节点沿连线可达 Boss。 class_name LevelTable extends Resource @@ -36,7 +42,10 @@ func validate() -> Array: continue var boss_count := 0 var battle_count := 0 + var is_graph := false for node in nodes: + if node.has("next"): + is_graph = true var t: String = String(node.get("type", "")) if not NODE_TYPES.has(t): errs.append({"category": "enum", "message": "level %d 未知节点类型: %s" % [L, t]}) @@ -51,7 +60,107 @@ func validate() -> Array: errs.append({"category": "structural", "message": "level %d Boss 节点数 = %d(须恰一)" % [L, boss_count]}) if battle_count < 2: errs.append({"category": "structural", "message": "level %d 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, battle_count]}) - # 线性切片纪律:末节点必须是 Boss(关底收尾——冒险结构 关卡定义) - if String(nodes[nodes.size() - 1].get("type", "")) != "boss": - errs.append({"category": "structural", "message": "level %d 末节点非 Boss(线性关卡以 Boss 收尾)" % L}) + if not is_graph: + # 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义) + if String(nodes[nodes.size() - 1].get("type", "")) != "boss": + errs.append({"category": "structural", "message": "level %d 末节点非 Boss(单链以 Boss 收尾)" % L}) + # 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命) + for e in _validate_graph(L, nodes, is_graph): + errs.append(e) return errs + + +# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 → +# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。 +func _validate_graph(L: int, nodes: Array, is_graph: bool) -> Array: + var errs: Array = [] + var ids: Array = [] # 每节点 id(下标对齐) + var nexts: Array = [] # 每节点出边 id 数组 + var id_to_idx := {} + for i in nodes.size(): + var node: Dictionary = nodes[i] + var nid: int = int(node.get("node_depth", i)) if is_graph else i + if is_graph and not node.has("node_depth"): + errs.append({"category": "structural", "message": "level %d 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, i]}) + nid = i # 降级继续(后续校验仍有意义) + ids.append(nid) + if id_to_idx.has(nid): + errs.append({"category": "dup_key", "message": "level %d 节点编号重复: %d(关卡内唯一定位地址)" % [L, nid]}) + id_to_idx[nid] = i + var nx: Array = [] + if node.has("next"): + for v in node.get("next", []): + nx.append(int(v)) + elif i < nodes.size() - 1: + nx.append(i + 1) # 单链隐式出边 = 数组下一项(id 即下标) + nexts.append(nx) + # ② 出边引用存在 + 前进方向(目标 id 严格递增 ⇒ 无环——环必含非递增边) + for i in nodes.size(): + for tid in nexts[i]: + if not id_to_idx.has(tid): + errs.append({"category": "dangling_next", "message": "level %d 节点 %d 出边指向不存在编号: %d" % [L, ids[i], tid]}) + elif tid <= ids[i]: + errs.append({"category": "structural", "message": "level %d 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, ids[i], tid]}) + # ④ 入口唯一(无入边节点恰一——唯一入口) + var has_inbound := {} + for i in nodes.size(): + for tid in nexts[i]: + has_inbound[tid] = true + var entries: Array = [] + for i in nodes.size(): + if not has_inbound.has(ids[i]): + entries.append(i) + if entries.size() != 1: + errs.append({"category": "structural", "message": "level %d 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, entries.size()]}) + # ⑤ Boss 恒唯一终点:无出边 + 编号最大(图形态;单链由"末节点 Boss"既有校验覆盖终点性) + for i in nodes.size(): + if String(nodes[i].get("type", "")) != "boss": + continue + if not nexts[i].is_empty(): + errs.append({"category": "structural", "message": "level %d Boss 节点 %d 带出边(终点不可外出)" % [L, ids[i]]}) + if is_graph and ids[i] != ids.max(): + errs.append({"category": "structural", "message": "level %d Boss 编号 %d 非最大 %d(终点编号最大)" % [L, ids[i], ids.max()]}) + if entries.size() == 1: + # ⑥ 从入口可达全部(无孤儿)· ⑦ 全部节点可达 Boss(逆向)——邻接表统一下标空间 + var fwd := [] + var back := [] + for i in nodes.size(): + fwd.append([]) + back.append([]) + for i in nodes.size(): + for tid in nexts[i]: + if id_to_idx.has(tid): + fwd[i].append(id_to_idx[tid]) + back[id_to_idx[tid]].append(i) + var reach := _reachable(entries[0], fwd, nodes.size()) + for i in nodes.size(): + if not reach.has(i): + errs.append({"category": "structural", "message": "level %d 节点 %d 从入口不可达(孤儿节点)" % [L, ids[i]]}) + var boss_idx := -1 + for i in nodes.size(): + if String(nodes[i].get("type", "")) == "boss": + boss_idx = i + if boss_idx >= 0: + var to_boss := _reachable(boss_idx, back, nodes.size()) + for i in nodes.size(): + if not to_boss.has(i): + errs.append({"category": "structural", "message": "level %d 节点 %d 沿连线不可达 Boss" % [L, ids[i]]}) + return errs + + +# BFS 可达集(邻接表 adj: 节点下标 → 下标数组) +func _reachable(start: int, adj: Array, n: int) -> Dictionary: + var seen := {} + if start < 0 or start >= n: + return seen + var queue: Array = [start] + seen[start] = true + while not queue.is_empty(): + var cur: int = queue.pop_front() + if cur >= adj.size(): + continue + for nb in adj[cur]: + if not seen.has(nb): + seen[nb] = true + queue.append(nb) + return seen diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/board_gen.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/board_gen.tres index 7de8bc21..f746ea1a 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/board_gen.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/board_gen.tres @@ -70,6 +70,72 @@ rows = [{ "min_safe_count": 60, "max_gen_attempts": 24, "board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 1, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 4, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 5, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 6, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 7, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] +}, { +"level_index": 2, +"node_depth": 8, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_biomes": ["dungeon"] }] ; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3): @@ -80,3 +146,9 @@ rows = [{ ; 2026-09-11 关卡扩展批:L1 增 D1–D4 行(Boss=Dmax=4)。 ; Boss 板以 12×12 区分(d0.28/t0.87 实测达不到推理率门槛——best≈0.81 降级告警,回退 0.25/0.88; ; 密度轴上调留给 C3 与求解器能力一起解)。 +; +; 分支节点图批(2026-09-17——L2 幽暗地域板行铺满,全值 provisional 归 难度曲线与Boss): +; L2 战斗/Boss 节点 (2, 0/1/4/5/6/7/8) 七行精确铺满(跨表校验强制——ConfigDB ④ level_node_dangling); +; 奖励点 (2,2)/恢复点 (2,3) 不开战斗、无板行(跨表校验只查 battle/boss 节点)。 +; 板面参数与 L1 高档同值(12×12 d0.25 t0.88)——密度/推理率轴差异化留给 难度曲线与Boss 调参批; +; board_biomes 维持 ["dungeon"](幽暗地域独立 biome tag 归 地形变异 批,本批零触及排雷循环)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/enemy.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/enemy.tres index 1d056a93..6365cc34 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/enemy.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/enemy.tres @@ -11,10 +11,10 @@ rows = [{ "display_name": "地精", "unit_count": 1, "unit_count_max": 2, -"hp": 24, -"atk": 7, -"atk_min": 4, -"atk_max": 10, +"hp": 8, +"atk": 2, +"atk_min": 1, +"atk_max": 3, "pool": [{ "skill_id": "attack", "weight": 9 @@ -32,10 +32,10 @@ rows = [{ "display_name": "地精一伙", "unit_count": 2, "unit_count_max": 3, -"hp": 13, -"atk": 5, -"atk_min": 2, -"atk_max": 8, +"hp": 4, +"atk": 2, +"atk_min": 1, +"atk_max": 3, "pool": [{ "skill_id": "attack", "weight": 9 @@ -52,10 +52,10 @@ rows = [{ "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 1, -"hp": 40, -"atk": 9, -"atk_min": 6, -"atk_max": 12, +"hp": 13, +"atk": 3, +"atk_min": 2, +"atk_max": 4, "pool": [{ "skill_id": "attack", "weight": 6 @@ -76,10 +76,10 @@ rows = [{ "display_name": "熊地精双雄", "unit_count": 2, "unit_count_max": 3, -"hp": 18, -"atk": 6, -"atk_min": 3, -"atk_max": 9, +"hp": 6, +"atk": 2, +"atk_min": 1, +"atk_max": 3, "pool": [{ "skill_id": "attack", "weight": 6 @@ -99,10 +99,10 @@ rows = [{ "enemy_id": "ogre_chief", "display_name": "食人魔酋长", "unit_count": 1, -"hp": 150, -"atk": 9, -"atk_min": 6, -"atk_max": 12, +"hp": 50, +"atk": 3, +"atk_min": 2, +"atk_max": 4, "pool": [{ "skill_id": "attack", "weight": 6 @@ -122,15 +122,85 @@ rows = [{ "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 1, -"hp": 30, -"atk": 7, -"atk_min": 4, -"atk_max": 10, +"hp": 10, +"atk": 2, +"atk_min": 1, +"atk_max": 3, "pool": [{ "skill_id": "attack", "weight": 6 }, { +"skill_id": "lunge", +"weight": 1 +}, { +"skill_id": "heavy", +"weight": 2 +}], +"advantage_per_round_cap": 4, +"armor_type": "ward", +"armor_class": 0 +}, { +"level_index": 2, +"node_depth": 1, +"enemy_id": "bugbear", +"display_name": "熊地精猎群", +"unit_count": 2, +"unit_count_max": 3, +"hp": 5, +"atk": 2, +"atk_min": 1, +"atk_max": 3, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "lunge", +"weight": 1 +}, { +"skill_id": "heavy", +"weight": 2 +}], +"advantage_per_round_cap": 4, +"armor_type": "ward", +"armor_class": 0 +}, { +"level_index": 2, +"node_depth": 4, +"enemy_id": "giant_spider", +"display_name": "巨蜘蛛", +"unit_count": 2, +"hp": 5, +"atk": 2, +"atk_min": 1, +"atk_max": 3, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "bite", +"weight": 2 +}, { "skill_id": "charge", +"weight": 1 +}], +"advantage_per_round_cap": 4, +"armor_type": "light", +"armor_class": 0 +}, { +"level_index": 2, +"node_depth": 5, +"enemy_id": "bugbear", +"display_name": "熊地精重击手", +"unit_count": 1, +"hp": 13, +"atk": 3, +"atk_min": 2, +"atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 5 +}, { +"skill_id": "lunge", "weight": 2 }, { "skill_id": "heavy", @@ -139,6 +209,77 @@ rows = [{ "advantage_per_round_cap": 4, "armor_type": "ward", "armor_class": 0 +}, { +"level_index": 2, +"node_depth": 6, +"enemy_id": "giant_spider", +"display_name": "巨蜘蛛巢群", +"unit_count": 2, +"unit_count_max": 3, +"hp": 4, +"atk": 1, +"atk_min": 1, +"atk_max": 2, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "bite", +"weight": 2 +}, { +"skill_id": "charge", +"weight": 1 +}], +"advantage_per_round_cap": 4, +"armor_type": "light", +"armor_class": 0 +}, { +"level_index": 2, +"node_depth": 7, +"enemy_id": "giant_spider", +"display_name": "巨蜘蛛巢群", +"unit_count": 2, +"unit_count_max": 3, +"hp": 4, +"atk": 2, +"atk_min": 1, +"atk_max": 3, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "bite", +"weight": 2 +}, { +"skill_id": "charge", +"weight": 1 +}], +"advantage_per_round_cap": 4, +"armor_type": "light", +"armor_class": 0 +}, { +"level_index": 2, +"node_depth": 8, +"enemy_id": "drider", +"display_name": "蛛化卓尔精灵", +"unit_count": 1, +"hp": 58, +"atk": 3, +"atk_min": 2, +"atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "venom_burst", +"weight": 2 +}], +"advantage_per_round_cap": 4, +"armor_type": "heavy", +"armor_class": 0 }] ; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照): @@ -172,3 +313,25 @@ rows = [{ ; 熊地精 普攻 6/蓄力 2/重击 2(EV 1.00 中性);食人魔酋长 普攻 6/重击 2/硬化 2(EV 1.00 中性; ; 硬化 = Boss 池独占增益技——增益 ≤1 纪律下旧蓄力拍退役不进 Boss 池)。 ; 含闸长期速率(冷却/保底)EV 锚按实施批 EV 抽样复核登记(proposal §2.5)。 +; +; 分支节点图批(2026-09-17——L2 幽暗地域内容批,行值全部 provisional,投值归 难度曲线与Boss): +; 每战斗/Boss 节点 (2, node_depth) 精确行(跨表校验强制,违例加载致命——ConfigDB ④);锚行 (2,0) 保留。 +; 兄弟精 = 熊地精 bugbear 复用(幽暗地域部族设定——「兄弟精」称呼经用户 2026-09-17 确认即熊地精): +; 池差异化改造 = 蓄力拍退役换突刺(复用共享技能行 lunge——共享技能表验证面),display_name 沿用 +; 熊地精系译名(熊地精/熊地精猎群/熊地精重击手——行值与池与 L1 错开)。节点 5 重击手轮廓:普攻 5/突刺 2/重击 2(EV ≈1.28,权重期望 +——冷却闸实际拉低,抽样复核归实施批口径)。 +; 巨蜘蛛 giant_spider(新敌种——关间台阶纪律的"新敌种"半边):群行倾向(数量区间 [2,3],首遇位节点 4 +; 固定 2 不加骰——新敌种首遇教学位纪律同 L1 D2);池 = 普攻 6/撕咬 2/蓄力 1(增益 ≤1 纪律——charge 复用 +; empower;EV ≈1.11 权重期望);护甲 light(与 L2 兄弟精 ward 错开克制面;L1 三甲 goblin=light/ +; bugbear=ward/ogre_chief=heavy 全覆盖,蜘蛛取 light 与同关敌种错开)。 +; 蛛化卓尔精灵 drider(Boss——用户 2026-09-17 拍板定名,蜘蛛主题呼应): +; 专属池 = 普攻 6/重击 2/毒爆 2(独占技 venom_burst 权重轮廓;EV ≈1.67 权重期望,冷却闸拉低后待抽样), +; armor heavy,血量 175 = r_boss 口径 provisional 锚(定位本关最大单场消耗)。 +; L2 敌种首遇位绿档(节点 0 熊地精/节点 4 巨蜘蛛):任何掷骰结果稳过——完美策略档扫描验证,读数另批登记。 +; +; 数值压缩批(2026-09-17 用户拍板暂定——整体绝对值轴 ÷3,四舍五入): +; 全部 12 行 hp/atk/atk_min/atk_max ÷3(24/7→8/2 · 13/5→4/2 · 40/9→13/3 · 18/6→6/2 · Boss 150/9→50/3 · +; 30/7→10/2 · 15/5→5/2 · 14/5→5/2 · 38/8→13/3 · 12/4→4/1 · 13/5→4/2 · drider 175/8→58/3)。 +; armor_type / armor_class / unit_count 区间 / 技能池权重不动(相对轴与概率轴不压缩)。 +; 粒度告警登记:atk 1~3 域下掷点折扣 25% 档大量触 damage_floor=1,地板伤害相对权重上升—— +; 属压缩的已知效应,难度复测(monte carlo 压缩前后对比)随批产出。行值 provisional 归 难度曲线与Boss。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/hero.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/hero.tres index c0a75852..34ce623d 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/hero.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/hero.tres @@ -4,9 +4,14 @@ [resource] script = ExtResource("1") -max_hp = 50 -atk = 6 +max_hp = 17 +atk = 2 armor_class = 10 +; 数值压缩批(2026-09-17 用户拍板暂定——整体绝对值轴 ÷3): +; max_hp 50→17(16.67 四舍五入)、atk 6→2。armor_class 10 不动(掷点阈值轴,相对量纲不压缩—— +; 用户同日裁决:本体自带 AC 不算减伤统计)。恢复奖励削弱随批在 reward.tres(概率 1/4 + 量再减半)。 +; 全部行值 provisional 归 难度曲线与Boss;压缩前基线读数(N=30 normal):基线档通关率 3%。 + ; 护甲等级掷点批(2026-09-16 用户拍板):armor_class = 10(暂定基准,归 难度曲线与Boss; ; EV 参考数按判定式复算 = 期望减免 21.25%);ac_up 增益 +1 AC/层(×2 层 = AC+2)在其上加算。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/level.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/level.tres index 28bae962..6d0c377b 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/level.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/level.tres @@ -28,6 +28,46 @@ rows = [{ "node_depth": 4, "type": "boss" }] +}, { +"level_index": 2, +"display_name": "第二层 · 幽暗地域", +"rest_heal_pct": 30, +"nodes": [{ +"node_depth": 0, +"type": "battle", +"next": [1, 2] +}, { +"node_depth": 1, +"type": "battle", +"next": [3, 4] +}, { +"node_depth": 2, +"type": "reward", +"next": [4, 5] +}, { +"node_depth": 3, +"type": "rest", +"next": [6] +}, { +"node_depth": 4, +"type": "battle", +"next": [6, 7] +}, { +"node_depth": 5, +"type": "battle", +"next": [7] +}, { +"node_depth": 6, +"type": "battle", +"next": [8] +}, { +"node_depth": 7, +"type": "battle", +"next": [8] +}, { +"node_depth": 8, +"type": "boss" +}] }] ; 切片登记(provisional——C1/C3 未设计,结构位与数值均为 SLICE-TUNED): @@ -35,3 +75,14 @@ rows = [{ ; node_depth 轴 = 节点深度(难度 = f(关卡序号, 节点深度)——game-concept 锁定口径);Boss = Dmax 行(A1 第三轴登记约定)。 ; rest_heal_pct = 30:恢复点回复 max_hp 的百分比(随生命天赋同步放大,防高生命档恢复失效)。 ; Boss 板以 12×12 加密板区分。 +; +; 分支节点图批(2026-09-17 用户拍板——关卡节点地图 v1.0 图版第一次落地,P1 批): +; L2 行集 = 网状图首落地:入口层 1 节点(0) → 中间三层(2/3/2 节点) → Boss 层 1 节点(8),总 9 节点; +; 真实岔路 4 处(0→[1,2] 入口分流 · 1→[3,4] 恢复或战 · 2→[4,5] 奖励后分流 · 4→[6,7] 深层分流); +; 含恢复节点 1(节点 3,rest_heal_pct 行字段兑付)与奖励节点 1(节点 2,直通空态——功能道具渠道 P1 未定); +; 普通战斗节点 6(0/1/4/5/6/7)+ Boss(8,编号最大、恒唯一终点)。连线只朝前进方向(编号严格递增), +; 单入口(节点 0 无入边),全部节点沿连线可达 Boss——图合法性加载校验强制(level_table.gd)。 +; L1 单链零迁移:无 next 键 = 单链形态(隐式出边 = 数组顺序下一项),经同一套图校验(退化形态)。 +; 敌种生态 = DND 幽暗地域(Underdark)方向:熊地精兄弟(bugbear 复用,池差异化改造)+ 巨蜘蛛 +; giant_spider(新敌种,群行倾向)+ 蛛化卓尔精灵 drider(Boss 专属池,蜘蛛主题呼应)。行值全 provisional, +; 投值归 难度曲线与Boss。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/reward.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/reward.tres index d58a8131..e14917b7 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/reward.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/reward.tres @@ -30,7 +30,7 @@ rows = [{ "polarity": "positive", "effect_id": "", "display_name": "爆裂粉尘", -"power": 4, +"power": 1, "weight": 16, "pool_tag": "cell", "tier": 1 @@ -40,10 +40,10 @@ rows = [{ "polarity": "positive", "effect_id": "", "display_name": "急救敷料", -"power": 4, -"power_min": 2, -"power_max": 6, -"weight": 15, +"power": 1, +"power_min": 1, +"power_max": 1, +"weight": 3, "pool_tag": "cell", "tier": 1 }, { @@ -82,7 +82,7 @@ rows = [{ "polarity": "negative", "effect_id": "", "display_name": "反噬震荡", -"power": 4, +"power": 1, "weight": 0, "pool_tag": "cell", "tier": 1 @@ -113,9 +113,9 @@ rows = [{ "polarity": "positive", "effect_id": "", "display_name": "清障炸药", -"power": 40, -"power_min": 25, -"power_max": 55, +"power": 13, +"power_min": 8, +"power_max": 18, "weight": 1, "pool_tag": "clear_default", "tier": 2 @@ -133,3 +133,13 @@ rows = [{ ; 护甲等级掷点批(2026-09-16 用户拍板):defense 型正向行换装——reward_id/effect_id defense_up → ac_up ; (显示名暂定"护甲强化",AC +1/层 ×2 层);cell_type defense / 权重 16 / tier 2 零改动(类型频次合计 79 不变); ; 百分比减伤行无双轨退役——status_effects.tres defense_up 行同批删除。 +; +; 数值压缩批(2026-09-17 用户拍板暂定): +; ① 整体绝对值轴 ÷3:spark_aoe 4→1 · heal_backlash 4→1 · clear_blast [25,55]→[8,18](中点 power 40→13, +; 区间中点恒等期望当量的锚口径不漂移)。 +; ② 恢复奖励削弱(用户拍板两条):出现概率下调到当前的 1/4——权重 15→3(当前频率 15/79≈19.0%,目标 +; 4.75%,整数权重最接近 = 3/67≈4.5%);恢复量在 ÷3 后再 ÷2(现值 ×1/6)——[2,6]→定值 1 +; (0.33~1 取整,区间字段保留 min=max=1 占位,量级再调时恢复区间结构)。 +; ③ 雷安同池连带(结构冻结,自动同侧):heal 权重下调同比例降低踩中 heal 型雷(反噬)的频率—— +; 负向侧反噬量同步 ÷3(4→1)。 +; 类型频次合计 79→67(attack 20 / defense 16 / blast 16 / heal 3 / weaken 12)。行值 provisional 归 难度曲线与Boss。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/settlement.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/settlement.tres index a09ea446..bb4fa1bf 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/settlement.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/settlement.tres @@ -4,7 +4,7 @@ [resource] script = ExtResource("1") -equivalence_ref_damage = 12 +equivalence_ref_damage = 4 p_perfect = 200 segment_order = PackedStringArray("grant", "buff_debuff", "combat", "decay", "verdict") source_label_map = { @@ -33,11 +33,11 @@ charge_grants = { "safe_cell": { "attack_type": "pierce", "per_unit": 1, -"cap": 8 +"cap": 3 }, "flag_hit": { "attack_type": "magic", -"per_unit": 2, +"per_unit": 1, "cap": 0 } } @@ -94,4 +94,11 @@ armor_roll_stream = "armor_roll" ; 经既有 enemy_output 乘区兑付)与 fortify→enemy_defense(硬化技载体——敌方防御池新词汇)。 ; enemy_defense 池乘区消费面 = 主角攻击落地段(Σ三分量后 ×(100+clamp(enemy_defense))/100)—— ; 四池对称乘区:敌打主角 = hero_defense×enemy_output,主角打敌 = hero_offense×enemy_defense -; (hero_offense 现行走主角普通分量 attack_up 直乘、切片未改——登记遗留)。clamp 域 [-100,+100] 共用。 +; (hero_offense 现行走主角普通分量 attack_up 直乘、切片未改——登记遗留)。 +; +; 数值压缩批(2026-09-17 用户拍板暂定): +; equivalence_ref_damage 12→4(当量参考敞口,绝对值轴 ÷3 取整); +; charge_grants 按粒度约束压缩——per_unit 是点数粒度(最小 1)不可 ÷3,压上限/单价近似等比: +; 穿刺 cap 8→3(per_unit 1 不变,EV ≈ ÷2.7);魔法 2/雷→1/雷(per_unit ÷3 进 1,EV ≈ ÷2)。 +; 充能期望压缩比 ≈1/2~1/3 之间,粒度限制下的近似,登记为已知偏差。 +; roll_faces/折扣档/暴击/damage_floor/克制矩阵/pool_clamp 全部不动(相对轴)。clamp 域 [-100,+100] 共用。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/skills.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/skills.tres index 39855387..fe2c0745 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/skills.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/skills.tres @@ -36,6 +36,18 @@ rows = [{ "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4 +}, { +"skill_id": "bite", +"display_name": "撕咬", +"type": "damage", +"damage_mult": 1.5, +"cooldown_beats": 1 +}, { +"skill_id": "venom_burst", +"display_name": "毒爆", +"type": "damage", +"damage_mult": 2.5, +"cooldown_beats": 4 }] ; 敌人技能池批(2026-09-16 用户拍板——设计权威 docs/proposal-skill-pool.md v1.0,全值 provisional 投值归 难度曲线与Boss): @@ -48,3 +60,10 @@ rows = [{ ; (提案"攻击×2 持续 1 结算"的容器语义落地;有效增益拍恰 1 拍,归 增益状态/难度曲线 复核)。 ; 硬化 = 自防增益(fortify 状态 → 敌方防御池),Boss 池独占——增益 ≤1 纪律下旧蓄力拍不进 Boss 池(proposal §2.5)。 ; 保底 M = pity_threshold = 4:连续 4 拍执行普攻 → 下拍必出特殊技(特殊技内按权重、不受冷却限制)。 +; +; 分支节点图批(2026-09-17——L2 幽暗地域新技能行,全值 provisional 投值归 难度曲线与Boss): +; 撕咬 bite = 伤害 ×1.5 冷却 1(高频小爆发轮廓,与突刺 ×1.5 冷却 2 同倍率错开冷却轴——巨蜘蛛池载体; +; 复用技能行纪律下 lunge 仍归兄弟精,两行不共用是有意的权重/冷却独立投值位)。 +; 毒爆 venom_burst = 伤害 ×2.5 冷却 4(蛛化卓尔精灵 drider 专属池独占技——"Boss=专属池独占技+权重轮廓" +; 纪律的本批兑现;池引用唯一性 = 唯一 enemy_id: drider)。 +; 两行均为 damage 型(存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率,三乘法链第一乘)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/status_effects.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/status_effects.tres index 134ea9f7..63bec43c 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/data/tables/status_effects.tres +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/status_effects.tres @@ -26,7 +26,7 @@ rows = [{ "effect_id": "ambush", "display_name": "伏击", "target_side": "enemy", -"magnitude": 3, +"magnitude": 1, "unit": "flat", "duration_settlements": 1, "stack_policy": "stack", @@ -107,3 +107,7 @@ rows = [{ ; 提案"持续 1 结算"的容器语义落地——详见 skills.tres 批注);refresh ×1 层(冷却闸下不可能自叠加)。 ; enemy 侧池语义 = side-wide(registry 既有口径):蓄力 +100 入 enemy_output 与 weaken/enrage 共池 clamp; ; 硬化 +100 入 enemy_defense 新池(乘区消费面 = 战斗结算 主角攻击落地段)。值全部 provisional 归 难度曲线与Boss。 +; +; 数值压缩批(2026-09-17 用户拍板暂定):ambush magnitude 3→1(伏击附加 = 层数×幅度,3 整除 ÷3)。 +; percent 全族(attack_up 50 / weaken −65 / enrage 25 / off_guard 25 / sapped −25 / empower·fortify 100) +; 与 duration/max_stack 全部不动(相对轴与结构轴不压缩)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd index 5802dda8..51fdb702 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd @@ -6,11 +6,11 @@ extends RefCounted -# 节点进入横幅(棋盘日志——battle/reboss/reward/rest 共用区段样式) +# 节点进入横幅(棋盘日志——battle/boss 用;节点编号 = 行进器编号空间读面) static func node_banner(runner, node: Dictionary, t: String) -> String: - return "[color=#e0b860]── 节点 %d/%d · %s(深度 %d)──[/color]" % [ + return "[color=#e0b860]── 节点 %d/%d · %s(编号 %d)──[/color]" % [ runner.node_index + 1, runner.nodes().size(), - "Boss 房" if t == "boss" else "战斗", int(node.get("node_depth", 0))] + "Boss 房" if t == "boss" else "战斗", runner.node_id_of(node)] static func reward_line() -> String: @@ -21,9 +21,17 @@ static func rest_line(healed: int, hp: int, max_hp: int) -> String: return "[color=#7cc47c]恢复点:回复 %d HP(→ %d/%d)[/color]" % [healed, hp, max_hp] +# 选路口行(零自动推进停点——战斗胜利/兑付确认后出现) +static func route_line(candidates: Array, runner) -> String: + var names: Array = [] + for cand in candidates: + names.append("节点 %d" % runner.node_id_of(cand)) + return "[color=#e0b860]── 岔路口:可选下一跳 %s ──[/color]" % " / ".join(names) + + # 关卡开场行(种子/加成口径注记——面板加成是否生效由调用方传入) static func level_open_line(level_display: String, seed_val: int, atk: int, max_hp: int, boosted: bool) -> String: - return "[color=#c8a860]第一关 · %s · 种子 %d · 攻击 %d / 生命 %d%s[/color]" % [ + return "[color=#c8a860]%s · 种子 %d · 攻击 %d / 生命 %d%s[/color]" % [ level_display, seed_val, atk, max_hp, "(含面板加成)" if boosted else ""] diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd index 14b4a08e..796bf843 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd @@ -27,7 +27,7 @@ const DebugSessionScript := preload("res://presentation/debug_session.gd") var db var settlement = null # 当前战斗编排器(关卡与调试模式统一读它) -var runner = null # 关卡节点地图 线性关卡推进器(完整关卡模式) +var runner = null # 关卡节点地图 图版推进器(完整关卡模式) var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件) var _level_active := false var _seed_edit: LineEdit @@ -35,6 +35,7 @@ var _start_overlay: Control var _end_overlay: BattleEndOverlay var _inter_overlay: Control var _inter_label: Label +var route_overlay: RouteOverlay # 节点图选路覆盖层(分支节点图批——零自动推进停点) # 调试模式编排(单场/连续两层——状态机与战斗生命周期归 debug_session.gd,本类只接线与出 UI) var _debug = null @@ -102,30 +103,36 @@ func _build_ui() -> void: logger = PlaytestLogger.new() logger.ap_per_round = int(db.action_economy.ap_per_round) - # 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 终局 < 调整板 < 开始) + # 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 选路 < 终局 < 调整板 < 开始) var overlays: Dictionary = UiAssembler.build_overlays(self, db, int(db.hero.atk), int(db.hero.max_hp)) legend_overlay = overlays.legend_overlay review_overlay = overlays.review_overlay node_overlay = overlays.node_overlay _end_overlay = overlays.end_overlay tune_panel = overlays.tune_panel + route_overlay = RouteOverlay.new() + add_child(route_overlay) + # 树序 = 模态登记序 = z 遮挡序(缺陷⑤纪律)——选路层须夹在节点层与终局层之间 + move_child(route_overlay, node_overlay.get_index() + 1) legend_overlay.closed.connect(_on_modal_closed.bind(legend_overlay)) review_overlay.closed.connect(_on_modal_closed.bind(review_overlay)) node_overlay.continue_pressed.connect(_on_node_continue) + route_overlay.route_chosen.connect(_on_route_chosen) _end_overlay.review_pressed.connect(_on_end_review) _end_overlay.continue_pressed.connect(_on_battle_end_continue) tune_panel.closed.connect(_close_tune_panel) tune_panel.apply_requested.connect(_on_tune_apply) tune_panel.simulate_requested.connect(_on_tune_simulate) _modals = ModalStack.new() - for m: Control in [legend_overlay, review_overlay, node_overlay, _end_overlay, tune_panel]: + for m: Control in [legend_overlay, review_overlay, node_overlay, route_overlay, _end_overlay, tune_panel]: _register_modal(m) _tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp)) var start: Dictionary = UiAssembler.build_start_overlay(self, db) _start_overlay = start.overlay _seed_edit = start.seed_edit - start.lvbtn.pressed.connect(_start_full_level) + start.lvbtn.pressed.connect(_start_full_level.bind(1)) + start.lv2btn.pressed.connect(_start_full_level.bind(2)) start.tunebtn.pressed.connect(_show_tune_panel) start.dbgbtn.pressed.connect(_start_debug_levels) @@ -148,6 +155,10 @@ func _fatal_screen(msg: String) -> void: # ==================== 模态 overlay 层级管理(pattern 6 / 缺陷⑤——状态机归 modal_stack.gd) ==================== func _register_modal(m: Control) -> void: + # 撑满宿主(Control 默认锚 = 0×0——全屏变暗层与居中布局整体失效;route 选路层预览截图实证后 + # 统一收口:六个模态同病,探针经信号直发绕过真实点击从未暴露。anchors_and_offsets 而非 anchors + # ——后者会把控件重置回最小内容尺寸,ui_assembler 头注同款纪律) + m.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _modals.register(m) @@ -167,13 +178,13 @@ func _on_modal_closed(m: Control) -> void: func _input_locked() -> bool: # 模态/非战斗期棋盘输入锁(各 overlay 自身可见即锁定) return legend_overlay.is_open() or review_overlay.is_open() or _end_overlay.is_open() \ - or node_overlay.visible or tune_panel.visible or _start_overlay.visible \ + or node_overlay.visible or route_overlay.visible or tune_panel.visible or _start_overlay.visible \ or _inter_overlay.visible or settlement == null -# ==================== 完整关卡模式(关卡节点地图 线性推进器)==================== +# ==================== 完整关卡模式(关卡节点地图 图版推进器)==================== -func _start_full_level() -> void: +func _start_full_level(level_index: int = 1) -> void: _start_overlay.visible = false # E4:加成经 runner.set_hero_override → encounter 参数入战斗(零表写——无双写叠加隐患) runner = LevelRunner.new(db, get_node("/root/RngService")) @@ -182,13 +193,14 @@ func _start_full_level() -> void: runner.round_started.connect(_on_round_started) runner.board_changed.connect(_on_board_changed) runner.node_entered.connect(_on_runner_node) + runner.choices_presented.connect(_on_choices_presented) runner.level_finished.connect(_on_level_finished) _level_active = true _reset_run_scope() log_view.clear() var seed_val := _seed_value() logger.start_session(seed_val) # 各战斗的 attach 在 _on_runner_node 内(settlement 逐战创建) - if not runner.start_level(1, seed_val): + if not runner.start_level(level_index, seed_val): _fatal_screen("关卡启动失败——见输出日志") return log_view.append_bb(Narrator.level_open_line(String(runner.level_row.get("display_name", "")), @@ -229,6 +241,7 @@ func _on_level_finished(payload: Dictionary) -> void: _level_active = false var result: String = String(payload.get("result", "")) settlement = null + route_overlay.visible = false # 终局兜底(选路口非 Boss 节点才有——防御性收层) _sync_settlement_visuals() # → 全关 info_panel.set_status_cleared_feedback() # 状态槽全清可见反馈(增益状态×4④) stage.gray_all_enemies() # 死亡兜底:卡体灰化残位(禁瞬消语义的战斗收尾面) @@ -419,14 +432,31 @@ func _show_hint(text: String, hint_id: String) -> void: board_zone.toast.show_toast(text, "info") -# ---- 节点覆盖层"继续"(奖励/恢复点确认) ---- +# ---- 节点覆盖层"继续"(奖励/恢复点兑付确认)与节点图选路覆盖层 ---- func _on_node_continue() -> void: _close_modal(node_overlay) if runner != null: + # 泛化语义(接口漂移清偿):确认 ≠ 推进——兑付确认后经 choices_presented 弹出选路口 runner.continue_after_node() +func _on_choices_presented(candidates: Array) -> void: + # 零自动推进的停点呈现:整幅节点图 + 候选按钮(战斗胜利/兑付确认统一到达此处) + if runner == null: + return + log_view.append_bb(Narrator.route_line(candidates, runner)) + _open_modal(route_overlay) + route_overlay.open_choices(candidates, runner) + _refresh_presentations() + + +func _on_route_chosen(node_id: int) -> void: + _close_modal(route_overlay) + if runner != null and not runner.pick_next_node(node_id): + push_error("[组合根] 选路被拒(编号 %d 不在候选)" % node_id) + + # ==================== 玩家意图 → 核心写调用(唯一出口)==================== func _on_board_action(cell: Vector2i, kind: String) -> void: diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/playtest_logger.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/playtest_logger.gd index 24f235a4..6a503886 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/playtest_logger.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/playtest_logger.gd @@ -117,11 +117,18 @@ func log_level_end(payload: Dictionary) -> void: func _on_step_row(row: Dictionary) -> void: _ring(_recent_steps, {"t": row.type, "src": row.source_class, "v": int(row.value), "r": int(row.round_index)}) - # 结算明细行逐行落盘(2026-09-12 用户要求——五段结算完整回放数据面) - _append({"kind": "step", "type": row.type, "source": row.source_class, + # 结算明细行逐行落盘(2026-09-12 用户要求——五段结算完整回放数据面); + # 公共七字段之外的全部行字段(payload:roll/ac/tier/crit/normal/pierce/magic/baseline/attach/target/ + # effect_id/pool_value 等)随行平铺落盘(2026-09-17 数值分析工具批——无 payload 的旧日志反事实只能 + # 比例近似,新日志可精确重算)。 + var line := {"kind": "step", "type": row.type, "source": row.source_class, "cell": row.origin_cell, "round": int(row.round_index), "text": String(row.display_name), "value": int(row.value), - "highlight": String(row.get("highlight", ""))}) + "highlight": String(row.get("highlight", ""))} + for k in row.keys(): + if not line.has(k): + line[k] = row[k] + _append(line) # ---- 诊断转储(卡死时点击——全量状态 + 环形缓冲)---- diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd index 4cef8d1f..64ba6019 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd @@ -104,6 +104,8 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: var lvbtn := UiKit.button("▶ 第一关 · 苔藓回廊(战×2 → 奖励点 → 战×2 → 恢复 → Boss)") box.add_child(lvbtn) + var lv2btn := UiKit.button("▶ 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)") + box.add_child(lv2btn) var tunebtn := UiKit.button("测试用 · 角色数值调整板(模拟天赋树属性提升)") box.add_child(tunebtn) var dbgbtn := UiKit.button("调试 · 连续关卡两层(HP 关内延续)") @@ -117,7 +119,7 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: box.add_child(help) return {"overlay": overlay, "box": box, "seed_edit": seed_edit, - "lvbtn": lvbtn, "tunebtn": tunebtn, "dbgbtn": dbgbtn} + "lvbtn": lvbtn, "lv2btn": lv2btn, "tunebtn": tunebtn, "dbgbtn": dbgbtn} # 调试两层模式的层间过渡覆盖层(历史入口专用)。返回 {overlay, label, next_btn}——接线归组合根 diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/level_strip_view.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/level_strip_view.gd index b7a0eef9..6966f2cb 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/level_strip_view.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/level_strip_view.gd @@ -1,14 +1,16 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # 实现 GDD:design/ux/battle.md(A 区关卡进度条——全宽 × 28px 顶栏,当前节点高亮、已过节点暗金)。 # Date: 2026-09-12(battle.md 五区改造批——对齐 28px 顶栏与高亮样式;基础 = 2026-09-11 关卡节点地图-lite 呈现) -# 关卡节点进度条视图(关卡节点地图-lite 呈现):线性节点链 战→赏→疗→王 的推进状态。 -# 高内聚边界:只读 runner 的节点链/进度做渲染,无任何写面。 +# 2026-09-17 分支节点图批:图形态行集渲染 = 行进序列(已到暗金 → 当前金 → 可选下一跳亮边候选); +# 单链形态(第一关零迁移)保持全链横条渲染。 +# 高内聚边界:只读 runner 的节点链/进度/候选做渲染,无任何写面。 class_name LevelStripView extends HBoxContainer const COL_DONE := Color8(140, 116, 60) # 已过——暗金 const COL_CURRENT := Color8(240, 168, 40) # 当前——奖励金高亮 const COL_UPCOMING := Color8(64, 58, 52) +const COL_CANDIDATE := Color8(230, 190, 90) # 候选下一跳——亮金边框 const COL_BOSS_BORDER := Color8(198, 61, 34) # Boss 边框——旗朱 const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王"} @@ -25,14 +27,44 @@ func refresh(runner) -> void: if runner == null: return var nodes: Array = runner.nodes() + for node in nodes: + if node.has("next"): + refresh_graph(runner) + return + _refresh_chain(runner, nodes) + + +# 单链形态(退化观感 = 全链横条——第一关现状渲染) +func _refresh_chain(runner, nodes: Array) -> void: for i in nodes.size(): var node: Dictionary = nodes[i] var type := String(node.get("type", "")) - add_child(_node_chip(type, _node_color(i, runner))) + add_child(_node_chip(type, _node_color(i, runner), false)) if i < nodes.size() - 1: - var arrow := UiKit.label("→", 13, false) - arrow.add_theme_color_override("font_color", Color8(120, 110, 98)) - add_child(arrow) + add_child(_arrow()) + + +# 图形态:行进序列 → 当前 → 可选下一跳(编号显示经 runner 编号空间读面) +func refresh_graph(runner) -> void: + var visited: Array = runner.visited + var cands: Array = runner.next_candidates() + for i in visited.size(): + var node: Dictionary = visited[i] + var is_current: bool = i == visited.size() - 1 and String(runner.level_result) == "" + add_child(_node_chip(String(node.get("type", "")), COL_CURRENT if is_current else COL_DONE, false, + runner.node_id_of(node))) + add_child(_arrow()) + for j in cands.size(): + var cand: Dictionary = cands[j] + add_child(_node_chip(String(cand.get("type", "")), COL_UPCOMING, true, runner.node_id_of(cand))) + if j < cands.size() - 1: + add_child(_arrow()) + + +func _arrow() -> Label: + var arrow := UiKit.label("→", 13, false) + arrow.add_theme_color_override("font_color", Color8(120, 110, 98)) + return arrow func _node_color(i: int, runner) -> Color: @@ -45,7 +77,7 @@ func _node_color(i: int, runner) -> Color: return COL_UPCOMING -func _node_chip(type: String, col: Color) -> PanelContainer: +func _node_chip(type: String, col: Color, candidate_border: bool, node_id: int = -1) -> PanelContainer: var p := PanelContainer.new() var sb := StyleBoxFlat.new() sb.set_corner_radius_all(4) @@ -57,8 +89,14 @@ func _node_chip(type: String, col: Color) -> PanelContainer: if type == "boss": sb.set_border_width_all(2) sb.border_color = COL_BOSS_BORDER + elif candidate_border: + sb.set_border_width_all(2) + sb.border_color = COL_CANDIDATE p.add_theme_stylebox_override("panel", sb) - var l := UiKit.label(String(NODE_LABELS.get(type, "?")), 15, true) + var text := String(NODE_LABELS.get(type, "?")) + if node_id >= 0: + text += str(node_id) + var l := UiKit.label(text, 15, true) l.add_theme_color_override("font_color", Color8(30, 26, 22) if col != COL_UPCOMING else Color8(160, 150, 138)) p.add_child(l) return p diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd new file mode 100644 index 00000000..4fcbec19 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd @@ -0,0 +1,205 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现) +# 节点图选路覆盖层(模态):兑付确认后由组合根打开——整幅节点图(方块地牢观感:节点 = 地牢房间 +# 方块、连线 = 通道)+ 当前位置高亮 + 可选下一跳亮边 + 候选按钮。零自动推进的玩家停点语义: +# 唯一前进通道 = 点候选按钮 → route_chosen(node_id) → 组合根转 runner.pick_next_node。 +# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids())做渲染, +# 无任何写面;图形为程序化占位质量(占位纪律)。 +class_name RouteOverlay +extends Control + +signal route_chosen(node_id: int) + +const COL_ROOM_DONE := Color8(140, 116, 60) # 已到——暗金 +const COL_ROOM_CURRENT := Color8(240, 168, 40) # 当前——奖励金 +const COL_ROOM_FUTURE := Color8(64, 58, 52) # 未达——暗 +const COL_PASSAGE := Color8(120, 110, 98) # 通道——石灰 +const COL_PASSAGE_LIT := Color8(230, 190, 90) # 候选通道——亮金 +const COL_BOSS_BORDER := Color8(198, 61, 34) # Boss 边框——旗朱 +const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王"} + +var _runner = null +var _btn_box: VBoxContainer +var _title: Label + + +func _init() -> void: + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.6) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.add_child(center) + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 10) + center.add_child(box) + _title = UiKit.label("选择下一跳", 18, true) + _title.add_theme_color_override("font_color", Color8(240, 226, 200)) + _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(_title) + var canvas := Control.new() + canvas.custom_minimum_size = Vector2(620, 240) + canvas.draw.connect(_draw_graph.bind(canvas)) + box.add_child(canvas) + _btn_box = VBoxContainer.new() + _btn_box.add_theme_constant_override("separation", 6) + box.add_child(_btn_box) + visible = false + + +# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染) +func open_choices(cands: Array, runner) -> void: + _runner = runner + for child in _btn_box.get_children(): + child.queue_free() + _title.text = "选择下一跳(岔路口)" if cands.size() > 1 else "继续前进" + for cand in cands: + var nid := _id_of(cand) + var t := String(cand.get("type", "")) + var label := "节点 %d · %s" % [nid, _type_name(t)] if cands.size() > 1 else "继续前进(%s)" % _type_name(t) + var btn := UiKit.button(label) + btn.pressed.connect(_emit_choice.bind(nid)) + _btn_box.add_child(btn) + visible = true + + +# 探针复用:按候选序号点击(真实按钮路径) +func press_candidate(index: int) -> void: + var btns: Array = _btn_box.get_children() + if index >= 0 and index < btns.size(): + btns[index].pressed.emit() + + +func _emit_choice(nid: int) -> void: + visible = false + route_chosen.emit(nid) + + +func _id_of(node: Dictionary) -> int: + # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) + return _runner.node_id_of(node) + + +func _type_name(t: String) -> String: + match t: + "battle": return "战斗房" + "reward": return "奖励点" + "rest": return "恢复点" + "boss": return "Boss 房" + return t + + +# ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)---- + +func _draw_graph(canvas: Control) -> void: + if _runner == null or not visible: + return + var nodes: Array = _runner.nodes() + var pos := _layout(nodes, canvas) + var current_id: int = _runner.current_node_id() + # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 + var visited: Array = _runner.visited_ids() + var cand_ids: Array = _runner.next_candidate_ids() + # 连线(通道)先行画(方块覆盖其上) + for node in nodes: + var from: Vector2 = pos[node] + for tid in _out_ids(node, nodes): + var target := _node_by_id(tid, nodes) + if target.is_empty(): + continue + var to: Vector2 = pos[target] + var lit: bool = _id_of(node) == current_id and cand_ids.has(tid) + var col := COL_PASSAGE_LIT if lit else COL_PASSAGE + var width := 3.0 if lit else 1.5 + _draw_passage(canvas, from, to, col, width) + # 房间方块 + for node in nodes: + var p: Vector2 = pos[node] + var t := String(node.get("type", "")) + var nid := _id_of(node) + var col := COL_ROOM_FUTURE + if visited.has(nid): + col = COL_ROOM_DONE + if nid == current_id: + col = COL_ROOM_CURRENT + var rect := Rect2(p - Vector2(26, 18), Vector2(52, 36)) + canvas.draw_rect(rect, col) + if t == "boss": + canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0) + elif cand_ids.has(nid): + canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 2.0) + var font := ThemeDB.fallback_font + var label := String(NODE_LABELS.get(t, "?")) + canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color8(24, 20, 16)) + canvas.draw_string(font, p + Vector2(10, 14), str(nid), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color8(210, 196, 170)) + + +func _layout(nodes: Array, canvas: Control) -> Dictionary: + # 分层:layer = 入口 BFS 深度;同层 slot 垂直排布 + var layer := {} + var slot := {} + var layer_counts := {} + var entry := _entry(nodes) + var queue: Array = [] + if not entry.is_empty(): + queue.append(entry) + layer[entry] = 0 + while not queue.is_empty(): + var cur: Dictionary = queue.pop_front() + var l: int = int(layer[cur]) + layer_counts[l] = int(layer_counts.get(l, 0)) + 1 + slot[cur] = int(layer_counts[l]) - 1 + for tid in _out_ids(cur, nodes): + var target := _node_by_id(tid, nodes) + if target.is_empty() or layer.has(target): + continue + layer[target] = l + 1 + queue.append(target) + # 未达节点(理论不可达——校验已拦)兜底排末层 + var max_layer := 0 + for k in layer.keys(): + max_layer = maxi(max_layer, int(layer[k])) + for node in nodes: + if not layer.has(node): + layer[node] = max_layer + 1 + slot[node] = int(layer_counts.get(layer[node], 0)) + layer_counts[layer[node]] = int(layer_counts.get(layer[node], 0)) + 1 + var pos := {} + var origin := (canvas.size - Vector2(620, 240)) / 2.0 + for node in nodes: + pos[node] = origin + Vector2(70 + int(layer[node]) * 122, 52 + int(slot[node]) * 58) + return pos + + +func _entry(nodes: Array) -> Dictionary: + var inbound := {} + for node in nodes: + for tid in _out_ids(node, nodes): + inbound[tid] = true + for node in nodes: + if not inbound.has(_id_of(node)): + return node + return {} + + +func _out_ids(node: Dictionary, _nodes: Array) -> Array: + return _runner.node_out_ids(node) + + +func _node_by_id(nid: int, nodes: Array) -> Dictionary: + for node in nodes: + if _id_of(node) == nid: + return node + return {} + + +# 通道画法:房间方块边缘到边缘的水平-折线(占位观感——地牢通道) +func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, width: float) -> void: + var a := from + Vector2(26, 0) + var b := to - Vector2(26, 0) + if b.x < a.x: + return + var mid_x := (a.x + b.x) / 2.0 + var pts := PackedVector2Array([a, Vector2(mid_x, a.y), Vector2(mid_x, b.y), b]) + canvas.draw_polyline(pts, col, width) diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd.uid new file mode 100644 index 00000000..d1f19d08 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/route_overlay.gd.uid @@ -0,0 +1 @@ +uid://uqvkj5x7lnqs diff --git a/prototypes/dungeon-sweeper-vertical-slice/route_overlay_preview.png b/prototypes/dungeon-sweeper-vertical-slice/route_overlay_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..1c1f3ff4179f2a42bd3469085b4fa4f2db561c64 GIT binary patch literal 72691 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 z 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 Dictionary: +static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min") -> Dictionary: var own_session := logger == null if own_session: logger = load("res://presentation/playtest_logger.gd").new() logger.start_session(seed_val) logger.ap_per_round = int(db.action_economy.ap_per_round) - logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy}) + logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route}) var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(seed_val) var runner = load("res://core/level_runner.gd").new(db, rng) @@ -31,6 +34,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: "defeat_node_index": -1, "defeat_node_type": "", "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, + "visited_ids": [], "choices": 0, } runner.step_row.connect(_on_step_row.bind(runner, st, collect_rows)) runner.node_entered.connect(_on_node_entered.bind(runner, st, logger)) @@ -43,7 +47,14 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: logger.close_session() return st var guard_battles := 20 - while st.result == "" and guard_battles > 0: + var guard_actions := 200 + while st.result == "" and guard_battles > 0 and guard_actions > 0: + guard_actions -= 1 + if runner.awaiting_choice(): + # 选路态(兑付确认/战斗胜利后的零自动推进停点)——route 策略显式选下一跳 + st.choices = int(st.choices) + 1 + runner.pick_next_node(_route_pick(runner, route)) + continue guard_battles -= 1 var this_settlement = runner.current_battle() if this_settlement == null: @@ -52,6 +63,8 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: if st.result == "": st.result = "timeout" st.timeout = true + for v in runner.visited_ids(): + st.visited_ids.append(int(v)) runner.restore_hero_base() st.log_path = logger.log_path() if own_session: @@ -59,6 +72,16 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: return st +# 路径轴策略(确定性——候选编号最小/最大;min 侧 = 岔路恒取小编号支)。 +# 编号取 runner.next_candidate_ids()(编号空间解析单一出口——单链下标 / 图 node_depth) +static func _route_pick(runner, route: String) -> int: + var ids: Array = runner.next_candidate_ids() + if ids.is_empty(): + return -1 + ids.sort() + return ids[0] if route != "max" else ids[ids.size() - 1] + + static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows: bool) -> void: if row.type == "damage" and row.source_class == "enemy_action": st.hero_dmg += int(row.value) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_monte_carlo.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_monte_carlo.gd index 85ee6abf..56901125 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_monte_carlo.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_monte_carlo.gd @@ -15,6 +15,10 @@ func _initialize() -> void: var seed0 := 4200 if args.size() >= 2 and args[1].is_valid_int(): seed0 = int(args[1]) + # 关卡轴(分支节点图批 2026-09-17:L2 幽暗地域图版扫描——第 4 参 level_index,默认 1 零行为变更) + var level_arg := 1 + if args.size() >= 4 and args[3].is_valid_int(): + level_arg = maxi(1, int(args[3])) var db = load("res://core/config_db.gd").new() if not db.load_tables("res://data/tables", false): print("表加载失败") @@ -40,7 +44,7 @@ func _initialize() -> void: policies = ["normal", "perfect"] var atk_axis: Array = [0, 50, 100, 150, 200] var hp_axis: Array = [0, 100] - print("=== 关卡期望扫描:第一关(战×2→道具→战×2→恢复→Boss)· N=%d/格 · 种子 %d+%d · 策略=%s ===" % [n, seed0, n, policy_arg]) + print("=== 关卡期望扫描:level %d · N=%d/格 · 种子 %d+%d · 策略=%s ===" % [level_arg, n, seed0, n, policy_arg]) print("%-6s %-10s %-8s %-14s %-14s %-14s %-10s %-12s %-12s %-10s %-10s %-8s %-8s" % ["策略", "atk%/hp%", "通关率", "整关承伤均/p90", "通关剩余HP均(胜)", "进BossHP均", "平均轮数", "踩雷/战", "普通承伤/场%", "跳战率", "Boss承伤%", "踩雷/局", "刷新/局"]) var perfect_mine_total := 0 # 完美档构造保证 = 零踩雷(只挖已证安全格)——扫描末断言 for policy in policies: @@ -61,7 +65,7 @@ func _initialize() -> void: var mine_per_run: Array = [] var refresh_per_run: Array = [] for i in range(n): - var st = autoplay.run_level(db, seed0 + i, 1, atk_pct, hp_pct, true, logger, policy) + var st = autoplay.run_level(db, seed0 + i, level_arg, atk_pct, hp_pct, true, logger, policy) dmg_all.append(st.hero_dmg) rounds_all.append(st.rounds) mine_per_run.append(int(st.mine_hits)) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index f7b7679c..685cb3cc 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -85,6 +85,7 @@ func _run_core_smoke(db, rng) -> void: _smoke_resource_surface() _smoke_settlement_segments() _smoke_skill_pool() + _smoke_level_graph() # ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)---- @@ -122,7 +123,7 @@ func _smoke_reward_identity_unit() -> void: var cs1: Dictionary = reward_economy.grant_clear() var cs2: Dictionary = reward_economy.grant_clear() check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)") - check(int(cs1.power) >= 25 and int(cs1.power) <= 55, "清图奖励 power ∈ [25,55](区间行 roll 值 %d)" % int(cs1.power)) + check(int(cs1.power) >= 8 and int(cs1.power) <= 18, "清图奖励 power ∈ [8,18](区间行 roll 值 %d)" % int(cs1.power)) check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等") # 退役后受理拒绝 reward_economy.retire_mapping() @@ -197,7 +198,7 @@ func _smoke_level() -> void: check(JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows), "关卡回放断言:同种子两遍结算步骤流逐行相同") check(rep1.rows.size() > 0, "关卡步骤流非空(%d 行)" % rep1.rows.size()) # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话) - check(int(_db.hero.atk) == 6 and int(_db.hero.max_hp) == 50, "模拟后 hero 表恢复基线(atk=%d hp=%d)" % [int(_db.hero.atk), int(_db.hero.max_hp)]) + check(int(_db.hero.atk) == 2 and int(_db.hero.max_hp) == 17, "模拟后 hero 表恢复基线(atk=%d hp=%d)" % [int(_db.hero.atk), int(_db.hero.max_hp)]) # ---- 护甲等级掷点批·换装护栏(2026-09-16 用户拍板:百分比减伤行整体退役换装 AC,无双轨)---- @@ -548,7 +549,8 @@ func _auto_play_round(settlement, out: Dictionary) -> void: func _smoke_power_interval() -> void: print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---") # ① 表侧静态锚:区间行配对齐全、power = 中点(行动经济 条款①期望当量锚)——现行区间行 = heal_minor/clear_blast 两行 - var interval_rows := {"heal_minor": [2, 6], "clear_blast": [25, 55]} + # (数值压缩批 2026-09-17:heal_minor [2,6]→[1,1] 定值、clear_blast [25,55]→[8,18]) + var interval_rows := {"heal_minor": [1, 1], "clear_blast": [8, 18]} var anchors_ok := true for row in _db.reward.rows: var rid := String(row.reward_id) @@ -587,13 +589,19 @@ func _smoke_power_interval() -> void: var cs: Dictionary = reward_economy.grant_clear() var cv := int(cs.power) seen["clear_blast"][cv] = true - if cv < 25 or cv > 55: + if cv < interval_rows["clear_blast"][0] or cv > interval_rows["clear_blast"][1]: in_domain = false check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)") check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢") - var variance_ok: bool = int(seen["clear_blast"].size()) >= 2 \ - and int(seen["heal_minor"].size()) >= 2 - check(variance_ok, "上阶/下阶实际流动(clear_blast/heal_minor 各 ≥2 个不同 roll 值)") + # 数值压缩批(2026-09-17):heal_minor 区间 [1,1] 退化为定值行——变异流动断言只适用 clear_blast; + # heal_minor 改查 roll 恒等于定值 1。 + var variance_ok: bool = int(seen["clear_blast"].size()) >= 2 + var heal_fixed := true + for v in seen["heal_minor"].keys(): + if int(v) != 1: + heal_fixed = false + check(variance_ok, "上阶/下阶实际流动(clear_blast ≥2 个不同 roll 值)") + check(heal_fixed, "heal_minor 定值行 roll 恒 1([1,1] 零宽区间)") func _row_power(rid: String) -> int: @@ -710,22 +718,23 @@ func _smoke_difficulty_table() -> void: check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)") # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 var enemy_system = load("res://core/unit_container.gd").new(_db) - check(enemy_system.spawn(1, 0, 50), "spawn 受理") + check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理") var grub = enemy_system.all_enemies()[0] - check(int(grub.hp) == 24 and int(grub.atk) == 7, "空表零修饰(hp=%d atk=%d = v3 行值 24/7)" % [int(grub.hp), int(grub.atk)]) + var d0: Dictionary = _db.resolve_enemy_rowset(1, 0) + check(int(grub.hp) == int(d0.hp) and int(grub.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(grub.hp), int(grub.atk), int(d0.hp), int(d0.atk)]) # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) var enemy_system_b = load("res://core/unit_container.gd").new(_db) - check(enemy_system_b.spawn(1, 0, 50), "修饰后 spawn 受理") + check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理") var g2 = enemy_system_b.all_enemies()[0] - check(int(g2.hp) == 36 and int(g2.max_hp) == 36, "hp 修饰 ×1.5(24→%d,max_hp 同步)" % int(g2.hp)) - check(int(g2.atk) == 14, "atk 修饰 ×2.0(7→%d)" % int(g2.atk)) + check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)]) + check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)]) # ③ 越域 clamp:乘数 50 → clamp 10 _db.difficulty.rows.clear() _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) var enemy_system_c = load("res://core/unit_container.gd").new(_db) - check(enemy_system_c.spawn(1, 0, 50), "越域修饰 spawn 受理") - check(int(enemy_system_c.all_enemies()[0].hp) == 240, "越域乘数 clamp 至 10(24→%d)" % int(enemy_system_c.all_enemies()[0].hp)) + check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理") + check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)]) _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) @@ -735,11 +744,11 @@ func _smoke_damage_types() -> void: print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---") # ① 护甲类型快照传播(spawn 行值 → 单位字典) var enemy_system = load("res://core/unit_container.gd").new(_db) - check(enemy_system.spawn(1, 0, 50) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)") + check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)") var enemy_system_b = load("res://core/unit_container.gd").new(_db) - check(enemy_system_b.spawn(1, 2, 50) and String(enemy_system_b.all_enemies()[0].armor_type) == "ward", "bugbear 护甲 = ward(魔法护甲)") + check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "ward", "bugbear 护甲 = ward(魔法护甲)") var enemy_system_c = load("res://core/unit_container.gd").new(_db) - check(enemy_system_c.spawn(1, 4, 50) and String(enemy_system_c.all_enemies()[0].armor_type) == "heavy", "ogre_chief 护甲 = heavy(重甲)") + check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp)) and String(enemy_system_c.all_enemies()[0].armor_type) == "heavy", "ogre_chief 护甲 = heavy(重甲)") # ② 克制矩阵表值在位(三角:克制 150 / 中性 100 / 被克 50) var am: Dictionary = _db.settlement.armor_effectiveness check(int(am.pierce.ward) == 150 and int(am.pierce.heavy) == 50 and int(am.pierce.light) == 100, "穿刺克制行:魔法护甲 150 / 轻甲 100 / 重甲 50") @@ -749,8 +758,8 @@ func _smoke_damage_types() -> void: var grants: Dictionary = _db.settlement.charge_grants var g_safe: Dictionary = grants.get("safe_cell", {}) var g_flag: Dictionary = grants.get("flag_hit", {}) - check(String(g_safe.get("attack_type", "")) == "pierce" and int(g_safe.get("per_unit", 0)) == 1 and int(g_safe.get("cap", -1)) == 8, "充能奖励定义:开格 → 穿刺桶 1/格(单轮计数上限 8)") - check(String(g_flag.get("attack_type", "")) == "magic" and int(g_flag.get("per_unit", 0)) == 2 and int(g_flag.get("cap", -1)) == 0, "充能奖励定义:标中旗 → 魔法桶 2/雷(无计数上限)") + check(String(g_safe.get("attack_type", "")) == "pierce" and int(g_safe.get("per_unit", 0)) == 1 and int(g_safe.get("cap", -1)) == 3, "充能奖励定义:开格 → 穿刺桶 1/格(单轮计数上限 3)") + check(String(g_flag.get("attack_type", "")) == "magic" and int(g_flag.get("per_unit", 0)) == 1 and int(g_flag.get("cap", -1)) == 0, "充能奖励定义:标中旗 → 魔法桶 1/雷(无计数上限)") # ②″ 奖励可改(行为断言):把「标中旗」奖励从魔法桶改投穿刺桶 → 同一战斗魔法分量归零;还原后步骤流逐行相同 # (证明改道唯一入口 = 奖励定义,攻击类型与甲效矩阵不参与;玩家可见语义见 战斗结算 GDD 战斗段) var run_base = _run_flag_backlash_round(20260912) @@ -850,7 +859,7 @@ func _smoke_playtest_logger() -> void: var snaps: Array = [] settlement.settlement_snapshot.connect(func(snap): snaps.append(snap)) check(path.contains("playtest_20260912"), "会话文件开启(%s)" % path) - check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50}), "快照战斗启动") + check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "快照战斗启动") var minesweeper = settlement.minesweeper for i in range(2): minesweeper.settle_batch([{"action": "stop"}]) @@ -862,7 +871,7 @@ func _smoke_playtest_logger() -> void: if not s1.has(k): missing.append(k) check(missing.is_empty(), "快照字段齐备(缺 %s)" % str(missing)) - check(s1.hero.max_hp == 50 and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq)) + check(int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq)) # spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→ # unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位 check(int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)), @@ -895,7 +904,7 @@ func _smoke_encounter_roll() -> void: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(30000 + i) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - check_silent(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50})) + check_silent(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) counts.append(settlement.presentation_snapshot().enemies.size()) # 区间生效:20 个种子应出现 1 和 2(D0 [1,2]) check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts)) @@ -913,14 +922,14 @@ func _smoke_encounter_roll() -> void: var rng3 = load("res://core/rng_service.gd").new() rng3.set_master_seed(31000) var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3) - check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": 50}), "D1 战斗启动") + check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动") var n1: int = settlement_d1.presentation_snapshot().enemies.size() check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1) # D2 固定(教学位不加骰) var rng4 = load("res://core/rng_service.gd").new() rng4.set_master_seed(31000) var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4) - settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": 50}) + settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}) check(settlement_d2.presentation_snapshot().enemies.size() == 1, "D2 熊地精固定 1(首遇教学位)") # 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0: # 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式) @@ -935,7 +944,7 @@ func _smoke_encounter_roll() -> void: for depth_v in depths: scan_total += 1 var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs) - if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": 50}): + if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}): scan_bad += 1 continue var n: int = settlement_scan.presentation_snapshot().enemies.size() @@ -994,7 +1003,7 @@ func _encounter_replay(seed_val: int) -> Array: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(seed_val) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50}) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) out.append(settlement.presentation_snapshot().enemies.size()) return out @@ -1243,27 +1252,36 @@ func _smoke_negative_dictionary() -> void: check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense", "effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)") # 行为面:逐型负向兑付形态(真实板 + 真实路由路径) + # 数值压缩批(2026-09-17):heal 权重 15→3 后单板五型齐备不再必然(无 heal 板概率 ≈1/3)—— + # 改多板扫描,取第一块五型齐备的板做行为断言。 var rng = load("res://core/rng_service.gd").new() - rng.set_master_seed(20260919) - var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - var rows: Array = [] - settlement.step_row.connect(func(row): rows.append(row)) - check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50, "hero_max_hp": 50}), - "负向字典战斗启动") - var board = settlement.minesweeper.current_board() - var r0 := {} - for c in board.compute_r0(): - r0[c] = true + var settlement = null + var board = null var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查) - for y in board.height: - for x in board.width: - var c := Vector2i(x, y) - if r0.has(c) or by_type.size() == 5: - continue - var ident = settlement.query_reward_identity(c) - if ident != null and not by_type.has(String(ident.cell_type)): - by_type[String(ident.cell_type)] = c - check(by_type.size() == 5, "板上五型齐备(%s)" % str(by_type.keys())) + var rows: Array = [] + for seed_i in range(6): + rng.set_master_seed(20260919 + seed_i) + settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + rows = [] + settlement.step_row.connect(func(row): rows.append(row)) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}): + continue + board = settlement.minesweeper.current_board() + var r0 := {} + for c in board.compute_r0(): + r0[c] = true + by_type = {} + for y in board.height: + for x in board.width: + var c := Vector2i(x, y) + if r0.has(c) or by_type.size() == 5: + continue + var ident = settlement.query_reward_identity(c) + if ident != null and not by_type.has(String(ident.cell_type)): + by_type[String(ident.cell_type)] = c + if by_type.size() == 5: + break + check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys())) # attack/defense/weaken:percent 负向行走 apply_effect var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"} for ctype in percent_map.keys(): @@ -1275,14 +1293,18 @@ func _smoke_negative_dictionary() -> void: var eff: String = percent_map[ctype] check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff), "%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff]) - # heal:负向 = 主角直伤 4(不经增益状态) + # heal:负向 = 主角直伤(表值——数值压缩批 4→1;不经增益状态) if by_type.has("heal"): rows.clear() var hp_before: int = int(settlement.presentation_snapshot().hero.hp) var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"]) settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1) var hp_after: int = int(settlement.presentation_snapshot().hero.hp) - check(hp_after == hp_before - 4, "heal 型负向 → 主角直伤 4(%d → %d,不经增益状态)" % [hp_before, hp_after]) + var backlash_power: int = 0 + for row in _db.reward.rows: + if String(row.get("reward_id", "")) == "heal_backlash": + backlash_power = int(row.power) + check(hp_after == hp_before - backlash_power, "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after]) # blast:负向 = 伏击 +2 层;cap 内生效、超 cap 逐层溢出(cap 读敌行值——零字面量) if by_type.has("blast"): var cap: int = int(_db.resolve_enemy_rowset(1, 0).get("advantage_per_round_cap", 0)) @@ -1329,7 +1351,7 @@ func _smoke_resource_surface() -> void: var rng3 = load("res://core/rng_service.gd").new() rng3.set_master_seed(20260920) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3) - settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50}) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) var board = settlement.minesweeper.current_board() var r0_first: Variant = null for c in board.compute_r0(): @@ -1504,7 +1526,7 @@ func _smoke_skill_pool() -> void: # ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言) func _smoke_skill_pool_data() -> void: - check(_db.skill.rows.size() == 5, "技能表五行(普攻/突刺/蓄力/重击/硬化)") + check(_db.skill.rows.size() == 7, "技能表七行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆——分支节点图批 L2 新行)") var basic: Dictionary = _db.skill.row_by_id("attack") check(not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)), @@ -1515,23 +1537,45 @@ func _smoke_skill_pool_data() -> void: var charge_row: Dictionary = _db.skill.row_by_id("charge") check(not charge_row.is_empty() and String(charge_row.buff_ref) == "empower", "蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)") + var bite: Dictionary = _db.skill.row_by_id("bite") + check(not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1, + "撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)") + var venom: Dictionary = _db.skill.row_by_id("venom_burst") + check(not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4, + "毒爆行(伤害 ×2.5 · 冷却 4——蛛化卓尔精灵专属池独占技)") + var venom_sole_ok := true + for row in _db.enemy.rows: + for entry in row.pool: + if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider": + venom_sole_ok = false + check(venom_sole_ok, "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)") + # 行集键 = (level_index, node_depth)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一 var expect_pools := { - "goblin": {"attack": 9, "lunge": 1}, - "bugbear": {"attack": 6, "charge": 2, "heavy": 2}, - "ogre_chief": {"attack": 6, "heavy": 2, "harden": 2}, + "1_0": {"attack": 9, "lunge": 1}, + "1_1": {"attack": 9, "lunge": 1}, + "1_2": {"attack": 6, "charge": 2, "heavy": 2}, + "1_3": {"attack": 6, "charge": 2, "heavy": 2}, + "1_4": {"attack": 6, "heavy": 2, "harden": 2}, + "2_0": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_1": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_4": {"attack": 6, "bite": 2, "charge": 1}, + "2_5": {"attack": 5, "lunge": 2, "heavy": 2}, + "2_6": {"attack": 6, "bite": 2, "charge": 1}, + "2_7": {"attack": 6, "bite": 2, "charge": 1}, + "2_8": {"attack": 6, "heavy": 2, "venom_burst": 2}, } var pools_ok := true for row in _db.enemy.rows: - var eid := String(row.enemy_id) - if not expect_pools.has(eid): + var rk := "%d_%d" % [int(row.level_index), int(row.node_depth)] + if not expect_pools.has(rk): pools_ok = false continue var got := {} for entry in row.pool: got[String(entry.skill_id)] = int(entry.weight) - if got != expect_pools[eid]: + if got != expect_pools[rk]: pools_ok = false - check(pools_ok, "六行池模板 = 建议池行值(地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2——全部 provisional)") + check(pools_ok, "十二行池模板 = 建议池行值(L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 兄弟精 6/1/2 · 蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2——全部 provisional)") var basic_count_ok := true for row in _db.enemy.rows: var n := 0 @@ -1545,11 +1589,17 @@ func _smoke_skill_pool_data() -> void: var rollable_ok := true for depth in [0, 1, 2, 3, 4]: var es = load("res://core/unit_container.gd").new(_db) - es.spawn(1, int(depth), 50) + es.spawn(1, int(depth), int(_db.hero.max_hp)) for u in es.all_enemies(): if es.rollable_skills(u).is_empty(): rollable_ok = false - check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言)") + for depth in [0, 1, 4, 5, 6, 7, 8]: + var es2 = load("res://core/unit_container.gd").new(_db) + es2.spawn(2, int(depth), int(_db.hero.max_hp)) + for u in es2.all_enemies(): + if es2.rollable_skills(u).is_empty(): + rollable_ok = false + check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)") # 状态表新行 + 敌方防御池收编 var empower: Dictionary = _db.status_effect_row("empower") var fortify: Dictionary = _db.status_effect_row("fortify") @@ -1571,7 +1621,9 @@ func _smoke_skill_pool_replay_independence() -> void: seed_val = s break check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val) - var rounds := 10 + # 数值压缩批(2026-09-17):敌 hp 18→6 后 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)—— + # 观测窗 10 轮→5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。 + var rounds := 5 var run1 := _record_pool_trajectories(seed_val, rounds) var run2 := _record_pool_trajectories(seed_val, rounds) check(not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs), @@ -1717,7 +1769,7 @@ func _smoke_skill_pool_death_void() -> void: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var rows: Array = [] settlement.step_row.connect(func(row): rows.append(row)) - check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": 50, "hero_atk": 999}), + check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}), "死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)") var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读) var pending: String = settlement._enemy_system.pending_action(u) @@ -1769,3 +1821,142 @@ func _has_err_category(errs: Array, category: String) -> bool: if String(e.get("category", "")) == category: return true return false + + +# ---- 分支节点图批冒烟:图合法性校验 + L2 幽暗地域图版行进(2026-09-17——关卡节点地图 v1.0 图版落地)---- + +func _smoke_level_graph() -> void: + print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) + check(_db.level.validate().is_empty(), "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误") + # ② L2 行集结构(网状图建议规模) + var l2 := {} + for row in _db.level.rows: + if int(row.level_index) == 2: + l2 = row + check(not l2.is_empty() and l2.nodes.size() == 9, "L2 节点数 = 9(入口 1 + 中间三层 2/3/2 + Boss)") + var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0} + var branch := 0 + for node in l2.nodes: + tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1 + if node.get("next", []).size() >= 2: + branch += 1 + check(int(tcount.battle) == 6 and int(tcount.boss) == 1, "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)") + check(int(tcount.reward) == 1 and int(tcount.rest) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)") + check(branch >= 2, "L2 真实岔路 = %d 处(≥2)" % branch) + # ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法) + var lt = load("res://data/schemas/level_table.gd").new() + var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]}, + {"node_depth": 2, "type": "boss"}] + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}] + check(lt.validate().is_empty(), "图校验正例:两岔合流小图零错误") + var dup = JSON.parse_string(JSON.stringify(good)) + dup[1].node_depth = 0 + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}] + check(_has_err_category(lt.validate(), "dup_key"), "图校验拒绝:节点编号重复(关卡内唯一定位地址)") + var dual = JSON.parse_string(JSON.stringify(good)) + dual.append({"node_depth": 9, "type": "battle", "next": [2]}) + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}] + check(_has_err_category(lt.validate(), "structural"), + "图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)") + var boss_out = JSON.parse_string(JSON.stringify(good)) + boss_out[2].next = [1] + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}] + check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:Boss 带出边(终点不可外出)") + var cyc = JSON.parse_string(JSON.stringify(good)) + cyc[1].next = [0] + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}] + check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)") + var dang = JSON.parse_string(JSON.stringify(good)) + dang[0].next = [1, 7] + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}] + check(_has_err_category(lt.validate(), "dangling_next"), "图校验拒绝:出边指向不存在编号(悬空致命)") + var orphan = JSON.parse_string(JSON.stringify(good)) + orphan[0].next = [1] + orphan[1].next = [2] + orphan.append({"node_depth": 5, "type": "battle", "next": [2]}) + lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}] + var orphan_errs: Array = lt.validate() + check(_has_err_category(orphan_errs, "structural"), "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)") + # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) + var win_min = autoplay.run_level(_db, 42, 2, 200, 100) + check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result) + check(win_min.boss_depth == 8, "L2 Boss 以编号 8 解析(终点编号最大)") + check(win_min.visited_ids == [0, 1, 3, 6, 8], "min 路径 = 0→1→3(恢复)→6→8(实测 %s)" % str(win_min.visited_ids)) + check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices) + var win_max = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "max") + check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 5, 7, 8], + "L2·满配 max 路径(经奖励点 2)通关 = 0→2→5→7→8(实测 %s %s)" % [win_max.result, str(win_max.visited_ids)]) + # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + runner.set_hero_override(200, 100) + var choice_sizes: Array = [] + runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) + check(runner.start_level(2, 42), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付)") + var st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var first = runner.current_battle() + var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) + first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", ""))) + while runner.current_battle() == first and not res_box.has("result"): + autoplay._autoplay_battle(runner, first, st, null) + check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(), + "首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)") + check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2]") + check(not runner.pick_next_node(5) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)") + check(runner.pick_next_node(2), "选编号 2(奖励点)受理") + check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(), + "到达奖励点:直通兑付(确认前不出选路口)") + check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [4, 5], + "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [4, 5]") + check(runner.pick_next_node(4) and runner.current_battle() != null, "选编号 4(巨蜘蛛首遇)开战") + # ⑥ 新敌种首遇位绿档:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子首遇战稳过 + var green_ok := true + var green_min_hp := 99999 + for i in range(12): + var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) + g_runner.start_level(2, 9100 + i) + var g_st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"} + var g_first = g_runner.current_battle() + var g_box := {} + g_first.battle_finished.connect(func(p): + g_box["result"] = String(p.get("result", "")) + g_box["hero_hp"] = int(p.get("hero_hp", 0))) + var g_guard := 6 + while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0: + g_guard -= 1 + autoplay._autoplay_battle(g_runner, g_first, g_st, null) + if String(g_box.get("result", "")) != "victory" or int(g_st.mine_hits) > 0: + green_ok = false + elif int(g_box.get("hero_hp", 0)) > 0: + green_min_hp = mini(green_min_hp, int(g_box.hero_hp)) + check(green_ok, "首遇位绿档:基线 0 加成完美档 12 种子首遇战(节点 0 熊地精)全胜且零踩雷") + check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp) + # ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行(加载校验同款显式复述) + var bg_keys := {} + for row in _db.board_gen.rows: + bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var en_keys := {} + for row in _db.enemy.rows: + en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var cross_ok := true + for node in l2.nodes: + var t := String(node.get("type", "")) + if t != "battle" and t != "boss": + continue + var key := "2_%d" % int(node.node_depth) + if not bg_keys.has(key) or not en_keys.has(key): + cross_ok = false + check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)") + var l2_spider: Dictionary = _db.enemy.resolve_rowset(2, 4) + check(String(l2_spider.get("enemy_id", "")) == "giant_spider", "巨蜘蛛首遇行集 (2,4) = giant_spider(固定 2 只——首遇教学位不加骰)") + var l2_boss: Dictionary = _db.enemy.resolve_rowset(2, 8) + check(String(l2_boss.get("enemy_id", "")) == "drider" and String(l2_boss.get("armor_type", "")) == "heavy", + "蛛化卓尔精灵 Boss 行集 (2,8) = drider(重甲——同关敌种克制面错开)") + + +# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) +func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd index 4225fcfd..861d43e4 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd @@ -24,6 +24,8 @@ var _tested_legend := false var _tested_review := false var _tested_refresh_chain := false var _tested_tooltip := false +var _tested_l2 := false # 分支节点图批:L2 入口行为段(首页直入口 + 岔路选路真实点击) +var _route_clicks := 0 # 选路覆盖层点击计数(岔路使用率观测面) func _ready() -> void: @@ -50,7 +52,7 @@ func _ready() -> void: _problems.append("首格未可点(cells=0)") print("[click_probe] 首格可点计时 %dms(进入战斗屏口径,≤500ms AC)· 场景冷启动 %dms(含 UI 构建 %dms,观测项)" % [ first_cell_ms, t_total, t_ui_ready]) - var action_budget := 6000 + var action_budget := 9000 while not _done and action_budget > 0: action_budget -= 1 await get_tree().process_frame @@ -58,12 +60,31 @@ func _ready() -> void: main.node_overlay.press_continue() _flags_placed.clear() continue + if main.route_overlay.visible: + # 节点图选路口(零自动推进停点)——真实按钮路径交替选候选(岔路使用行为) + main.route_overlay.press_candidate(_route_clicks % 2) + _route_clicks += 1 + _flags_placed.clear() + continue if main._end_overlay.is_open(): # 终局 overlay 模态断言(pattern 6:打开时输入锁定) if not main._input_locked(): _problems.append("终局 overlay 打开时输入未锁定") + if not _tested_l2: + # L1 终局 → 首页直入口切换 L2(分支节点图批行为段:真实"返回"路径 + start_level(2)) + _tested_l2 = true + print("[click_probe] L1 结束:%s——切换关卡 2 试玩入口" % main._end_overlay.verdict_text().replace("\n", " ")) + main._on_battle_end_continue() # 终局出口(真实"返回"按钮处理器——全量清理回开始层) + if not main._start_overlay.visible: + _problems.append("终局返回后未回到开始覆盖层") + main.tune_panel.set_sliders(200, 100) + main._start_full_level(2) + if main.runner == null or int(main.runner.level_row.get("level_index", -1)) != 2: + _problems.append("关卡 2 直入口启动失败(level_index != 2)") + continue _done = true - print("[click_probe] 关卡结束:%s" % main._end_overlay.verdict_text().replace("\n", " ")) + print("[click_probe] 关卡结束:%s · 选路口点击 %d 次" % [ + main._end_overlay.verdict_text().replace("\n", " "), _route_clicks]) break if main.settlement == null or main.runner == null: continue diff --git a/tools/analyze_playtest.py b/tools/analyze_playtest.py new file mode 100644 index 00000000..287e4aa6 --- /dev/null +++ b/tools/analyze_playtest.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""试玩日志统计分析工具(消费 playtest_logger 的 JSONL——user://logs/playtest_*.jsonl)。 + +统计口径(2026-09-17 用户定义): + · 每轮获得的奖励(效果/治疗/奖励伤害/未生效)与充能点数 + · 实际造成伤害 / 实际受到伤害 / 恢复总量 + · 增伤贡献(attack_up + 充能穿刺/魔法)、减伤贡献(hero_defense×enemy_output 池挡下的承伤) + · 反事实一「无奖励造伤」:不算增伤和奖励伤害的主角输出(普通分量按无 attack_up 重算) + · 反事实二「无减伤承伤」:不算减伤池(hero_defense/enemy_output 归零)的承伤 + · 反事实三「无奖励承伤」:再扣伏击附加与 enrage(池归零已含) + +两种日志格式: + · 旧格式(step 行无 payload):反事实按比例近似(误差每行 ±1~2,多 floor 链无法逆精确); + 掷点折扣与 ac_up 对敌方判定的影响不可恢复,忽略(已标注)。 + · 新格式(2026-09-17 起 step 行携带 roll/ac/tier/crit/normal/pierce/magic/baseline/attach): + 反事实按 DamageMath 公式逐行精确重算。 + +表值硬编码溯源(表值变更须同步——单一事实源仍在 .tres): + · status_effects.tres:attack_up 50 / weaken −65 / enrage 25 / off_guard 25 / sapped −25(percent) + · settlement.tres:pool_clamp [-100,100] · crit_multiplier 200 · 伏击附加 = ambush 层数×3(magnitude) + · skills.tres:突刺/撕咬 1.5 · 重击 2.0 · 毒爆 2.5 · 普攻 1.0 + · armor_effectiveness:pierce 克 ward 150 / heavy 50;magic 克 heavy 150 / ward 50;normal 恒 100 +""" +import glob +import json +import os +import sys + +# ---- 表值区(溯源见文件头) ---- +POOL_CLAMP = (-100, 100) +CRIT_MULT = 200 +M_AMB = 3 # ambush.magnitude:每层伏击附加伤害 +SKILL_MULT = {"普通攻击": 1.0, "突刺": 1.5, "重击": 2.0, "撕咬": 1.5, "毒爆": 2.5} +EFFECT_TEXT_MAP = { # 旧日志无 effect_id——按明细行文本反查 + "增伤": "attack_up", "护甲强化": "ac_up", "削弱": "weaken", "暴走": "enrage", + "失衡": "off_guard", "乏力": "sapped", "伏击": "ambush", "蓄力": "empower", "硬化": "fortify", +} +EFFECT_CN = {v: k for k, v in EFFECT_TEXT_MAP.items()} # effect_id → 中文显示名 +# 正负分类(奖励件 / 惩罚件的口径——数据分析的三维度共用) +POS_NAMES = {"增伤", "护甲强化", "削弱", "群伤", "回复", "清障炸药"} +NEG_NAMES = {"暴走", "失衡", "乏力", "伏击", "踩雷直伤"} +POOL_OF = {"attack_up": "hero_offense", "sapped": "hero_offense", + "off_guard": "hero_defense", + "weaken": "enemy_output", "enrage": "enemy_output", "empower": "enemy_output", + "fortify": "enemy_defense"} +ARMOR_EFF = {"pierce": {"light": 100, "ward": 150, "heavy": 50}, + "magic": {"light": 100, "ward": 50, "heavy": 150}} + + +def clamp_pool(v): + return max(POOL_CLAMP[0], min(POOL_CLAMP[1], v)) + + +def charge_comp(points, eff): + return int(points * eff / 100.0) # floor + + +def rolled_component(base, discount, crit, eff): + v = base * discount / 100.0 + if crit: + v *= CRIT_MULT / 100.0 + return int(v * eff / 100.0) # floor + + +def disp_w(s): # 中文显示宽度(终端对齐用) + return sum(2 if ord(c) > 0x2E7F else 1 for c in str(s)) + + +def pad(s, w, right=False): + s = str(s) + gap = w - disp_w(s) + return (" " * gap + s) if right else (s + " " * gap) + + +def effect_id_of(row): + if row.get("effect_id"): + return row["effect_id"] + for name, eid in EFFECT_TEXT_MAP.items(): + if name in row.get("text", ""): + return eid + return None + + +def parse_log(path): + rows = [] + with open(path, encoding="utf-8") as f: + for ln in f: + ln = ln.strip() + if ln: + rows.append(json.loads(ln)) + return rows + + +# ---- 多局聚合模式(monte carlo 共享日志:run 行切分局次,按通关/失败分组对比) ---- + +def analyze_runs(rows, label, tier=None): + """tier = (atk_pct, hp_pct):只统计该天赋档(None = 全档)。""" + runs = [] # [{meta, rows}] + cur = None + for r in rows: + k = r.get("kind") + if k == "run": + cur = {"meta": r, "rows": []} + runs.append(cur) + elif cur is not None: + cur["rows"].append(r) + if tier is not None: + runs = [run for run in runs + if int(run["meta"].get("atk_pct", -999)) == tier[0] + and int(run["meta"].get("hp_pct", -999)) == tier[1]] + stats = [] + for run in runs: + res = analyze_run_core(run["rows"], run["meta"]) + if res: + stats.append(res) + print_run_report(label, stats) + + +def analyze_run_core(rows, meta): + """单局聚合:返回该局的总账(结果/死亡节点/造伤/承伤/回复/奖励/轮数 + 战斗级明细)。""" + out = dict(meta=meta, result="", death_node=-1, hp_left=-1, + hero_dmg=0, reward_dmg=0, taken=0, healed=0, rest_heal=0, + rounds=0, rewards_n=0, eff={}, battles_won=0, battles=0, battle_list=[]) + eff_counts = {} + cur_b = None + for r in rows: + k = r.get("kind") + if k == "node" and r.get("type") in ("battle", "boss"): + out["battles"] += 1 + cur_b = dict(btype=r.get("type"), depth=int(r.get("node_depth", r.get("depth", -1))), + rounds=0, hero_dmg=0, reward_dmg=0, taken=0, healed=0, + pos_n=0, neg_n=0, result="") + out["battle_list"].append(cur_b) + elif k == "battle_end": + if r.get("result") == "victory": + out["battles_won"] += 1 + if cur_b is not None: + cur_b["result"] = r.get("result", "") + cur_b["end_hp"] = int(r.get("hero_hp", -1)) + elif k == "level_end": + out["result"] = r.get("result", "") + out["death_node"] = int(r.get("node_index", -1)) + out["hp_left"] = int(r.get("hp", -1)) + elif k == "settlement": + out["rounds"] += 1 + if cur_b is not None: + cur_b["rounds"] += 1 + elif k == "step": + t, src = r["type"], r.get("source", "") + if t == "effect": + eid = effect_id_of(r) + if eid: + name = "伏击" if eid == "ambush" else EFFECT_CN.get(eid, eid) + eff_counts[name] = eff_counts.get(name, 0) + 1 + if eid != "ambush": + out["rewards_n"] += 1 + if cur_b is not None: + if name in NEG_NAMES: + cur_b["neg_n"] += 1 + elif name in POS_NAMES: + cur_b["pos_n"] += 1 + elif t == "heal": + out["healed"] += int(r["value"]) + out["rewards_n"] += 1 + eff_counts["回复"] = eff_counts.get("回复", 0) + 1 + if cur_b is not None: + cur_b["healed"] += int(r["value"]) + cur_b["pos_n"] += 1 + elif t == "damage": + v = int(r["value"]) + if src == "hero_attack": + out["hero_dmg"] += v + if cur_b is not None: + cur_b["hero_dmg"] += v + elif src in ("safe_cell", "clear_reward", "flag_hit") or ( + src == "mine_hit" and "全体敌人" in r.get("text", "")): + out["reward_dmg"] += v + out["rewards_n"] += 1 + name = "清障炸药" if src == "clear_reward" else "群伤" + eff_counts[name] = eff_counts.get(name, 0) + 1 + if cur_b is not None: + cur_b["reward_dmg"] += v + cur_b["pos_n"] += 1 + elif src == "mine_hit": + out["taken"] += v + eff_counts["踩雷直伤"] = eff_counts.get("踩雷直伤", 0) + 1 + if cur_b is not None: + cur_b["taken"] += v + cur_b["neg_n"] += 1 + elif src == "enemy_action": + out["taken"] += v + if cur_b is not None: + cur_b["taken"] += v + if k == "node" and r.get("type") == "rest": + out["rest_heal"] += int(r.get("healed", 0)) + out["eff"] = eff_counts + return out if out["rounds"] > 0 else None + + +def print_run_report(label, stats): + print("=" * 104) + print("多局聚合:%s (共 %d 局)" % (label, len(stats))) + print("=" * 104) + wins = [s for s in stats if s["result"] == "victory"] + loss = [s for s in stats if s["result"] != "victory"] + print("\n通关 %d 局 · 失败 %d 局(通关率 %.0f%%)" % (len(wins), len(loss), + 100.0 * len(wins) / max(1, len(stats)))) + + def avg(group, key): + return sum(s[key] for s in group) / max(1, len(group)) + + def eff_line(group): + total_rounds = sum(s["rounds"] for s in group) or 1 + parts = [] + names = ["增伤", "护甲强化", "削弱", "群伤", "回复", "暴走", "失衡", "乏力", "伏击", "清障炸药", "踩雷直伤"] + for n in names: + c = sum(s["eff"].get(n, 0) for s in group) + if c: + parts.append("%s %.2f件/轮" % (n, c / total_rounds)) + return "、".join(parts) if parts else "—" + + def group_block(name, group): + print("\n【%s · %d 局】" % (name, len(group))) + if not group: + return + print(" 平均总造成伤害(主角攻击) :%.1f" % avg(group, "hero_dmg")) + print(" 平均奖励伤害(群伤/炸药) :%.1f" % avg(group, "reward_dmg")) + print(" 平均总受到伤害 :%.1f" % avg(group, "taken")) + print(" 平均战斗内回复 :%.1f" % avg(group, "healed")) + print(" 平均恢复节点回复 :%.1f" % avg(group, "rest_heal")) + print(" 平均结算轮数 :%.1f" % avg(group, "rounds")) + print(" 平均每局奖励件数 :%.1f(%.2f 件/轮)" % ( + avg(group, "rewards_n"), avg(group, "rewards_n") / max(1.0, avg(group, "rounds")))) + print(" 地图战斗资源构成(件/轮) :%s" % eff_line(group)) + if name.startswith("失败"): + dn = {} + for s in group: + dn[s["death_node"]] = dn.get(s["death_node"], 0) + 1 + print(" 死亡节点分布 :%s" % "、".join( + "节点%s ×%d" % (k, v) for k, v in sorted(dn.items(), key=lambda x: -x[1]))) + group_block("通关局", wins) + group_block("失败局", loss) + + # ---- 战斗状态对照(普通战斗 vs Boss——跨局聚合) ---- + def battle_block(name, group): + rows = [b for s in group for b in s.get("battle_list", [])] + if not rows: + return + print("\n【战斗状态对照 · %s】" % name) + hdr = ["战斗类型", "场次", "场均轮数", "场均造成(主角+奖励)", "场均受到", "场均回复", + "场均奖励件", "场均惩罚件", "胜率"] + widths = [10, 6, 10, 20, 10, 10, 11, 11, 8] + print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths))) + for btype, label_cn in (("battle", "普通战斗"), ("boss", "Boss")): + sub = [b for b in rows if b.get("btype") == btype] + if not sub: + continue + n = len(sub) + won = sum(1 for b in sub if b.get("result") == "victory") + cells = [label_cn, n, + "%.1f" % (sum(b["rounds"] for b in sub) / n), + "%.1f" % (sum(b["hero_dmg"] + b["reward_dmg"] for b in sub) / n), + "%.1f" % (sum(b["taken"] for b in sub) / n), + "%.1f" % (sum(b["healed"] for b in sub) / n), + "%.1f" % (sum(b["pos_n"] for b in sub) / n), + "%.1f" % (sum(b["neg_n"] for b in sub) / n), + "%.0f%%" % (100.0 * won / n)] + print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths))) + battle_block("通关局", wins) + battle_block("失败局", loss) + print() + + +def analyze(rows, label): + # 战斗开始生命预扫描:running_hp 从初始满血起步,battle_end 落账、rest 节点回血中途累加 + running_hp = None + for r in rows: + k = r.get("kind") + if k == "settlement" and running_hp is None: + running_hp = int(r.get("hero", {}).get("max_hp", 0)) + elif k == "node" and r.get("type") == "rest" and running_hp is not None: + running_hp += int(r.get("healed", 0)) + elif k == "battle_end": + r["_start_hp"] = running_hp + running_hp = int(r.get("hero_hp", -1)) + + battles = [] # [{name, rounds: {round: {"sett": snap, "steps": [rows]}}, prev_ambush}] + cur = None + for r in rows: + k = r.get("kind") + if k == "node" and r.get("type") in ("battle", "boss"): + cur = {"name": "%s D%d" % (r["type"], r.get("depth", -1)), + "btype": r.get("type"), "rounds": {}, "seq": len(battles)} + battles.append(cur) + elif k == "settlement": + if cur is None: + cur = {"name": "独立战斗", "btype": "battle", "rounds": {}, "seq": 0} + battles.append(cur) + cur["rounds"].setdefault(r["round"], {"sett": None, "steps": []})["sett"] = r + elif k == "step": + if cur is None: + continue + cur["rounds"].setdefault(r["round"], {"sett": None, "steps": []})["steps"].append(r) + elif k == "battle_end" and cur is not None: + cur["result"] = r.get("result") + cur["end_hp"] = int(r.get("hero_hp", -1)) + cur["start_hp"] = int(r.get("_start_hp", -1)) + + # ---- 每战斗逐轮分解 ---- + per_battle = [] + totals = dict(hero_dmg=0, reward_dmg=0, taken=0, healed=0, cf_hero_dmg=0, + cf_taken_nored=0, cf_taken_norw=0, rest_heal=0, ineffective=0, + pos_n=0, neg_n=0, pierce=0, magic=0, mines=0, back_sum=0) + exact_mode = None + for b in battles: + rounds = sorted(b["rounds"].keys()) + prev_ambush = 0 + bstat = dict(hero_dmg=0, reward_dmg=0, taken=0, healed=0, cf_hero_dmg=0, + cf_taken_nored=0, cf_taken_norw=0, rounds=[], + pos_n=0, neg_n=0, pierce=0, magic=0, mines=0, back_sum=0, + start_hp=int(b.get("start_hp", -1)), end_hp=int(b.get("end_hp", -1)), + btype=b.get("btype", "battle")) + for rd in rounds: + srow = b["rounds"][rd] + sett = srow["sett"] or {} + steps = srow["steps"] + pool = {} # effect_id -> pool_value(挂层行的当轮池值) + ambush_gain = 0 + for s in steps: + if s["type"] == "effect": + eid = effect_id_of(s) + if eid == "ambush": + ambush_gain += int(s["value"]) + elif eid: + pool[eid] = int(s.get("pool_value", s["value"])) + up = pool.get("attack_up", 0) # 现行实现:hero_offense 走 attack_up 单效果直乘 + hero_def = clamp_pool(pool.get("off_guard", 0)) + enemy_out = clamp_pool(pool.get("weaken", 0) + pool.get("enrage", 0) + pool.get("empower", 0)) + enemy_def = clamp_pool(pool.get("fortify", 0)) + ambush_layers = prev_ambush + ambush_gain + attach = ambush_layers * M_AMB + k_taken = (100 + hero_def) * (100 + enemy_out) / 10000.0 + + enemies = sett.get("enemies", []) + armor0 = enemies[0].get("armor", "light") if enemies else "light" + hero_atk = sett.get("hero", {}).get("atk", 0) + chg = sett.get("round_facts", {}).get("charge_points", {}) if sett else {} + facts = sett.get("round_facts", {}) if sett else {} + + rowout = dict(rd=rd, safe=facts.get("safe", 0), mines=facts.get("mines", 0), + flags=facts.get("flag_events", 0), pierce=chg.get("pierce", 0), + magic=chg.get("magic", 0), hero=0, rw=0, taken=0, heal=0, + cf_hero=0, cf_tn=0, cf_tr=0, + eff_counts={}, heal_n=0, heal_sum=0, aoe_n=0, aoe_sum=0, + back_n=0, back_sum=0, clear_n=0, clear_sum=0, ineff_counts={}, + pos_n=0, neg_n=0) + for s in steps: + t, src = s["type"], s.get("source", "") + if t == "effect": + eid = effect_id_of(s) + if eid == "ambush": + rowout["eff_counts"]["伏击"] = rowout["eff_counts"].get("伏击", 0) + 1 + rowout["neg_n"] += 1 + elif eid: + name = EFFECT_CN.get(eid, eid) + rowout["eff_counts"][name] = rowout["eff_counts"].get(name, 0) + 1 + if name in NEG_NAMES: + rowout["neg_n"] += 1 + elif name in POS_NAMES: + rowout["pos_n"] += 1 + elif t == "heal": + rowout["heal"] += int(s["value"]) + rowout["heal_n"] += 1 + rowout["heal_sum"] += int(s["value"]) + rowout["pos_n"] += 1 + elif t == "damage": + if src == "hero_attack": + v = int(s["value"]) + rowout["hero"] += v + if exact_mode is None: + exact_mode = "normal" in s + if exact_mode: + comp = {a: int(s.get(a, 0)) for a in ("normal", "pierce", "magic")} + cf_n = rolled_component(hero_atk, int(s.get("tier", 100)), + bool(s.get("crit")), 100) + cf_n = int(cf_n * (100 + enemy_def) / 100.0) + cf = max(1, cf_n) if (comp["normal"] + comp["pierce"] + comp["magic"]) >= 1 else 0 + rowout["cf_hero"] += cf_n if cf else v + else: + p = charge_comp(chg.get("pierce", 0), ARMOR_EFF["pierce"].get(armor0, 100)) + m = charge_comp(chg.get("magic", 0), ARMOR_EFF["magic"].get(armor0, 100)) + n = max(0, v - p - m) + cf_n = int(round(n * 100.0 / (100 + up))) + rowout["cf_hero"] += cf_n + elif src in ("safe_cell", "clear_reward", "flag_hit") or ( + src == "mine_hit" and "全体敌人" in s.get("text", "")): + # 奖励伤害 = 打敌人的棋盘来源伤害;踩雷负向打主角的(反噬震荡)归承伤侧 + rowout["rw"] += int(s["value"]) + rowout["pos_n"] += 1 + if src == "clear_reward": + rowout["clear_n"] += 1 + rowout["clear_sum"] += int(s["value"]) + else: + rowout["aoe_n"] += 1 + rowout["aoe_sum"] += int(s["value"]) + elif src == "mine_hit": + v = int(s["value"]) + rowout["taken"] += v + rowout["back_n"] += 1 + rowout["back_sum"] += v + rowout["neg_n"] += 1 + # 踩雷直伤照吃(不算减伤≠去掉它),但无奖励世界没有这套负向兑付 + rowout["cf_tn"] += v + elif src == "enemy_action": + v = int(s["value"]) + rowout["taken"] += v + if exact_mode: + base = float(s.get("baseline", v)) + att = int(s.get("attach", 0)) + rolled = rolled_component(base, int(s.get("tier", 100)), bool(s.get("crit")), 100) + pre = rolled + att + cf_nored = max(1, pre) + cf_norw = max(1, rolled) + else: + pre = int(round(v / k_taken)) if k_taken > 0 else v + cf_nored = max(1, pre) + cf_norw = max(1, pre - attach) + rowout["cf_tn"] += cf_nored + rowout["cf_tr"] += cf_norw + for s in steps: + if s["type"] == "reward_ineffective": + txt = s.get("text", "") + if "满层" in txt: + key = "增益满层" + elif "满血" in txt: + key = "回血满血" + elif "无存活敌人" in txt: + key = "无存活敌人" + elif "主角已倒下" in txt: + key = "主角已倒下" + else: + key = "其他" + rowout["ineff_counts"][key] = rowout["ineff_counts"].get(key, 0) + 1 + bstat["hero_dmg"] += rowout["hero"] + bstat["reward_dmg"] += rowout["rw"] + bstat["taken"] += rowout["taken"] + bstat["healed"] += rowout["heal"] + bstat["cf_hero_dmg"] += rowout["cf_hero"] + bstat["cf_taken_nored"] += rowout["cf_tn"] + bstat["cf_taken_norw"] += rowout["cf_tr"] + bstat["pos_n"] += rowout["pos_n"] + bstat["neg_n"] += rowout["neg_n"] + bstat["pierce"] += rowout["pierce"] + bstat["magic"] += rowout["magic"] + bstat["mines"] += rowout["mines"] + bstat["back_sum"] += rowout["back_sum"] + bstat["rounds"].append(rowout) + prev_ambush = sett.get("ambush_layers", ambush_layers) + bstat["name"] = b.get("name", "?") + bstat["result"] = b.get("result", "?") + per_battle.append(bstat) + for kk in ("hero_dmg", "reward_dmg", "taken", "healed", "cf_hero_dmg", + "cf_taken_nored", "cf_taken_norw", "pos_n", "neg_n", + "pierce", "magic", "mines", "back_sum"): + totals[kk] += bstat[kk] + + # ---- 关卡恢复节点(rest) ---- + for r in rows: + if r.get("kind") == "node" and r.get("type") == "rest": + totals["rest_heal"] += int(r.get("healed", 0)) + + print_report(label, per_battle, totals, exact_mode) + return totals + + +def _reward_parts(r): + """单轮/单战斗的奖励与惩罚构成文本(聚合计数,不逐件枚举)。""" + pos, neg = [], [] + for name, n in r.get("eff_counts", {}).items(): + (neg if name in NEG_NAMES else pos).append("%s×%d" % (name, n)) + for name, cnt_key, sum_key in (("群伤", "aoe_n", "aoe_sum"), + ("清障炸药", "clear_n", "clear_sum")): + if r.get(cnt_key): + pos.append("%s×%d(%d点)" % (name, r[cnt_key], r[sum_key])) + if r.get("heal_n"): + pos.append("回复×%d(+%d)" % (r["heal_n"], r["heal_sum"])) + if r.get("back_n"): + neg.append("踩雷直伤×%d(%d点)" % (r["back_n"], r["back_sum"])) + return pos, neg + + +def print_report(label, per_battle, t, exact_mode): + W = 22 + mode_txt = ("精确重算(payload 日志)" if exact_mode + else "比例近似(旧格式日志,每行误差 ±1~2;掷点折扣与护甲等级对敌方判定的影响不可恢复,忽略)") + print("=" * 104) + print("试玩统计:%s (反事实口径:%s)" % (label, mode_txt)) + print("=" * 104) + + # ---- 维度一:整个关卡的状态 ---- + print("\n" + "=" * 104) + print("【维度一 · 整个关卡的状态】") + print("=" * 104) + rounds_total = sum(len(b["rounds"]) for b in per_battle) + won = sum(1 for b in per_battle if b["result"] == "victory") + print(" 战斗数 / 总轮数 / 通关战斗:%d 场 / %d 轮 / %d 胜" % (len(per_battle), rounds_total, won)) + print(" 造成伤害:%d(主角攻击 %d + 奖励伤害 %d)" % ( + t["hero_dmg"] + t["reward_dmg"], t["hero_dmg"], t["reward_dmg"])) + print(" 受到伤害:%d(敌方行动 %d + 踩雷直伤 %d)" % ( + t["taken"], t["taken"] - t["back_sum"], t["back_sum"])) + print(" 回复总量:%d(战斗内 %d + 恢复节点 %d)" % ( + t["healed"] + t["rest_heal"], t["healed"], t["rest_heal"])) + print(" 充能总量:穿刺 %d 点 / 魔法 %d 点" % (t["pierce"], t["magic"])) + print(" 获得奖励件数:%d 件 获得惩罚件数:%d 件 踩雷格数:%d" % ( + t["pos_n"], t["neg_n"], t["mines"])) + hp_trail = " → ".join("%d" % b["end_hp"] for b in per_battle if b["end_hp"] >= 0) + print(" 生命轨迹(各战斗结束 HP):%s" % hp_trail) + print(" 不算增伤和奖励伤害的造成伤害:%d(增伤+充能贡献 %d)" % ( + t["cf_hero_dmg"], t["hero_dmg"] - t["cf_hero_dmg"])) + print(" 不算减伤的受到伤害:%d(减伤池挡下 %d)" % ( + t["cf_taken_nored"], t["cf_taken_nored"] - t["taken"])) + print(" 不算奖励的受到伤害:%d" % t["cf_taken_norw"]) + + # ---- 维度二:一场战斗的状态 ---- + print("\n" + "=" * 104) + print("【维度二 · 一场战斗的状态】") + print("=" * 104) + hdr = ["战斗", "结果", "轮数", "生命(开始→结束)", "造成(主角+奖励)", "受到", "回复", + "奖励件", "惩罚件", "踩雷", "充能(穿/魔)", "获得奖励 / 获得惩罚"] + widths = [10, 6, 5, 14, 16, 6, 5, 7, 7, 5, 11, 46] + print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths))) + for b in per_battle: + agg = dict(eff_counts={}, aoe_n=0, aoe_sum=0, clear_n=0, clear_sum=0, + heal_n=0, heal_sum=0, back_n=0, back_sum=0) + for r in b["rounds"]: + for k, v in r["eff_counts"].items(): + agg["eff_counts"][k] = agg["eff_counts"].get(k, 0) + v + for k in ("aoe_n", "aoe_sum", "clear_n", "clear_sum", "heal_n", "heal_sum", + "back_n", "back_sum"): + agg[k] += r[k] + pos, neg = _reward_parts(agg) + res_txt = "胜利" if b["result"] == "victory" else str(b["result"]) + hp_txt = ("%d→%d" % (b["start_hp"], b["end_hp"])) if b["start_hp"] >= 0 else "—" + cells = [b["name"], res_txt, len(b["rounds"]), hp_txt, + "%d+%d" % (b["hero_dmg"], b["reward_dmg"]), b["taken"], b["healed"], + "%d件" % b["pos_n"], "%d件" % b["neg_n"], b["mines"], + "%d/%d" % (b["pierce"], b["magic"]), + ("奖:" + "、".join(pos) if pos else "奖:—") + + (" 罚:" + "、".join(neg) if neg else "")] + print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths))) + + # ---- 维度三:轮次的状态 ---- + print("\n" + "=" * 104) + print("【维度三 · 轮次的状态】") + print("=" * 104) + for b in per_battle: + print("\n— %s · %s —" % (b["name"], "胜利" if b["result"] == "victory" else b["result"])) + hdr = ["轮", "开格(安/雷/旗)", "充能(穿/魔)", "获得奖励 / 获得惩罚", "造成伤害", "受到伤害", "回复量", + "不算增伤和奖励伤害的造成伤害", "不算减伤的受到伤害", "不算奖励的受到伤害"] + widths = [4, 14, 12, W + 20, 8, 8, 6, 20, 16, 16] + print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths))) + all_ineff = {} + for r in b["rounds"]: + pos, neg = _reward_parts(r) + reward_txt = ("奖:" + "、".join(pos) if pos else "奖:—") + \ + (" 罚:" + "、".join(neg) if neg else "") + cells = [r["rd"], "%d/%d/%d" % (r["safe"], r["mines"], r["flags"]), + "%d/%d" % (r["pierce"], r["magic"]), reward_txt, + r["hero"], r["taken"], r["heal"], + r["cf_hero"], r["cf_tn"], r["cf_tr"]] + print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths))) + for k, n in r["ineff_counts"].items(): + all_ineff[k] = all_ineff.get(k, 0) + n + if all_ineff: + print(" 奖励未生效统计:" + "、".join("%s×%d" % (k, n) for k, n in all_ineff.items())) + print() + + +def main(): + log_dir = os.path.join(os.environ.get("APPDATA", ""), "Godot", "app_userdata", + "DungeonSweeperVerticalSlice", "logs") + path = None + args = sys.argv[1:] + if args and not args[0].startswith("-"): + path = args[0] + else: + cands = sorted(glob.glob(os.path.join(log_dir, "playtest_*.jsonl")), key=os.path.getmtime) + if not cands: + print("未找到日志(%s)" % log_dir) + sys.exit(1) + path = cands[-1] + rows = parse_log(path) + # 命令行第二参 = runs:多局聚合模式(monte carlo 共享日志按 run 行切分,通关/失败分组对比); + # --tier ATK,HP:只统计该天赋档(默认 0,0 基线档——0.5 传作 50)。 + if "--runs" in args: + tier = (0, 0) + for i, a in enumerate(args): + if a == "--tier" and i + 1 < len(args): + atk_s, hp_s = args[i + 1].split(",") + elif a.startswith("--tier="): + atk_s, hp_s = a.split("=")[1].split(",") + analyze_runs(rows, os.path.basename(path), tier) + elif "--run" in args: + # 抽单局(大日志里按 run 序号或种子挑一局)走三层维度报告 + sel = None + for i, a in enumerate(args): + if a == "--run" and i + 1 < len(args): + sel = args[i + 1] + elif a.startswith("--run="): + sel = a.split("=")[1] + runs, cur = [], None + for r in rows: + if r.get("kind") == "run": + cur = {"meta": r, "rows": []} + runs.append(cur) + elif cur is not None: + cur["rows"].append(r) + pick = None + if sel is not None: + for x in runs: # 先按种子精确匹配(模拟局 seed 唯一) + if str(x["meta"].get("seed")) == sel: + pick = x + break + if pick is None and sel.isdigit(): # 再按基线档(0,0)序号 + base = [x for x in runs if int(x["meta"].get("atk_pct", -1)) == 0 + and int(x["meta"].get("hp_pct", -1)) == 0] + if int(sel) < len(base): + pick = base[int(sel)] + if pick is None: + print("未找到该局(--run <序号|种子>)") + sys.exit(1) + sub = [dict(r, kind="session")] + pick["rows"] + analyze(sub, "%s · seed=%s · 档位 atk%s%%/hp%s%%" % ( + os.path.basename(path), pick["meta"].get("seed"), + pick["meta"].get("atk_pct"), pick["meta"].get("hp_pct"))) + else: + analyze(rows, os.path.basename(path)) + + +if __name__ == "__main__": + main()