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 00000000..1c1f3ff4 Binary files /dev/null and b/prototypes/dungeon-sweeper-vertical-slice/route_overlay_preview.png differ diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd index eb7db809..5dd0ade0 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd @@ -8,6 +8,9 @@ # 2026-09-15 格面资源批:道具窗口选择与可选类获取计数随可选类道具删除下线。 # 2026-09-14 增完美策略档(policy="perfect",默认 "normal" 零行为变更——既有调用方签名兼容): # 零盲挖/全标旗/卡死刷新——上界测量仪,求解器与动作语义见 auto_player.gd 完美策略档节。 +# 2026-09-17 分支节点图批:选路适配(零自动推进)——兑付确认/战斗胜利后 runner 进选路态, +# 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大—— +# 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。 # 运行前提:db 已 load_tables;每个 run_level 全新 RngService(同种子 ⇒ 全程逐步可回放)。 # 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。 extends RefCounted @@ -15,13 +18,13 @@ extends RefCounted const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现 -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") -> 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()