feat(slice): 敌人技能池批收编——废确定性行为环改技能池+开轮摇号(冒烟 263 项全绿)

- 机制:每敌种技能池(池模板/实例池/技能行三层),开轮摇号定拍(behavior_roll 第五流,
  每敌子流 scope=spawn_index×1000000+摇号序,单敌轨迹独立复放);两道闸 = 全技能各自
  冷却(普攻恒 0 锁死)+ 保底 M=4;死亡即不动手改摇定作废不进账;拍结构保留
- 数据:新增 skills.tres+skill_table.gd(五行+保底阈值)/skill_lottery.gd 独立抽签器;
  enemy.tres 六行 pattern→pool(9/1·6/2/2·6/2/2 provisional);status_effects 七→九行
  (empower/fortify——enemy_defense 敌方防御池新词汇);settlement effect_pool_map 增两键
- core:rng_service 增流+open_scope(salt 0x85EBCA7758C24E15,黄金向量 m=42 scope0=
  426657203655691839);unit_container 重写(实例池/冷却/待发/保底);battle_orchestrator
  开轮摇号编排+三乘法链+施放行 cast+敌方防御池乘区;config_db 挂技能表+池跨表校验
- 呈现:快照 pending_* 三字段+敌方增益投影(beat/ring 拆除);敌卡摇定动画位(普攻零
  表示/攻击系⚔/增益系✦);明细具名技能+施放行;状态槽九行全映射
- smoke 211→263:六项验收(回放逐位/单敌独立复放/EV 对锚/死亡作废/空池不可能/加载拒绝)
- 文档:敌人系统 v0.5/战斗结算 v0.11/增益状态 v0.5/难度曲线与Boss v0.6/战斗表现 v0.3/
  HUD框架 v0.4/battle.md 改型注记/registry(skill 新条目+四条目修订+术语七词条)/
  06-待办清单收编;active.md entry 60
- EV 实测(×5000 拍):地精 1.1056(保底闸抬升,登记难度曲线重锚)/熊地精 0.9998/酋长 1.0236
This commit is contained in:
DKChan committed 2026-09-17 02:00:06 +08:00
1 parent d76a023fa9
commit 8ca3da669b
35 files changed
+1309 -356

No files matched your search

@@ -479,3 +479,25 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s
**表实一致注记(对 registry reward_table 条目)**:十一行逐字段一致;两处切片级补全登记——① 负向 heal/blast 行 registry 未定 reward_id,切片取语义名 `heal_backlash` / `ambush_double`;② blast 负向加层行 effect_id 恒空(registry flat 行口径)+ power 校验例外(power 不消费——伏击加层为独立兑付形态,见战斗结算 GDD 兑付形态表)。
**待拍板(本批登记)**:① 关卡奖励点节点内容(功能道具渠道 P1——归 关卡节点地图 定稿批);② 已开格类型徽章与极性视觉(批 2——纯呈现);③ "剩余未探索安全区域"计数呈现口径(切片现无该计数器,随批 2)。
---
## 2026-09-16 敌人技能池批(批 1+2+3 合并落地——设计权威 docs/proposal-skill-pool.md v1.0;文档面 v0.5/v0.11/v0.5/v0.6/v0.3/v0.4 同批)
**机制**:确定性出招剧本(行为环:拍枚举/环指针/同种锁步/heavy_multiplier)整体退役,改技能池 + 开轮摇号——每轮轮次开始排除冷却中技能、按行序逐敌摇定本拍动作(behavior_roll 封闭流,每敌子流 scope = spawn_index×1000000+摇号序),战斗段按技能行兑现(伤害技 = 三乘法链〔基线×倍率→d20 掷点→甲效→伏击后置→池乘区→max(1,·)〕,增益技 = 施放行 cast + 状态授予〔0 伤害不掷不折不兑伏击〕);两道闸 = 全技能各自冷却(普攻恒 0 锁死)+ 保底 M=4(连续 4 拍执行普攻 → 下拍必出特殊技、不受冷却限制);死亡即不动手改"摇定后兑现前死亡 → 作废不进账";拍结构保留(每结算每存活敌恰行动一次/行序/先作用后推进)。
**数据面**:新增 `skills.tres` + `skill_table.gd`(五行:普攻/突刺×1.5 cd2/蓄力 cd3/重击×2 cd3/硬化 cd4;pity_threshold=4;普攻倍率/冷却域校验锁死);`enemy.tres` 六行 pattern/heavy_multiplier → pool(地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2,全部 provisional);`status_effects.tres` 七→九行(empower 蓄力 +100 enemy_output 池 / fortify 硬化 +100 **enemy_defense 敌方防御池新词汇**——duration 2 = 1 个有效增益拍〔先兑付后衰减含施放当轮〕);`settlement.tres` effect_pool_map 增 empower/fortify 两键。
**core 面**:`rng_service.gd` 增 behavior_roll 流(salt 0x85EBCA7758C24E15,黄金向量 m=42 scope0=426657203655691839 见冒烟;open_scope 显式 scope 取号门 + BEHAVIOR_SUBSTREAM_STRIDE);新增 `core/skill_lottery.gd`(独立抽签器——输入可摇条目+子流、输出 skill_id,D9 封装纪律);`unit_container.gd` 重写(实例池快照/冷却递减/待发动作/保底计数;query_beat/query_attack/advance/steps/pattern 退役);`battle_orchestrator.gd` 开轮摇号编排 `_roll_round_actions()`(首轮同样摇、轮内刷新不重摇)+ 战斗段技能行兑现 + 主角攻击增敌方防御池乘区(Σ三分量后 ×(100+clamp(enemy_defense))/100);`status_container.apply_effect` 增 duration_override(技能行 buff_duration 覆盖);`config_db.gd` 挂技能表 + validate_skill_pool_links 跨表校验(悬空致命/每池恰一普攻/增益 ≤1)。
**呈现面(批 2)**:`presentation_snapshot` 敌人数组字段定稿(beat/ring_size/ring_index 拆除 → pending_skill/pending_type/pending_name + effects 敌方增益投影;池与权重不公开);`enemy_card_view` 环点拆除(护甲行常驻)、意图徽标 = 摇定动画占位(普攻零表示 —/攻击系 ⚔/增益系 ✦);`battle_log_view` 施放行 ✦ 格式化、伤害行具名技能;`status_slot_row` 图标键补 empower ⇧ / fortify ▣(九行全映射);`settlement_snapshot` 敌方 beat 键 → pending_skill;damage_probe 拍名重建改 pending_skill 口径。
**smoke(211→263 项)**:行为环断言全部改型(行相位 cast、fresh 池采样、削弱触底/AC12 采样改多种子扫描);新增 `_smoke_skill_pool` 六项验收(proposal §8):数据面(池模板/普攻锁死/空池不可能)、同种子回放逐位复现 + 单敌轨迹独立复放(D3 多实例逐轮记录 vs 只喂该敌子流的独立重放器逐位对照)、EV 抽样对锚(×5000 拍)、死亡打断作废(D2 特殊技种子 + hero_atk 999 先手击杀零行动行 + 不进账)、加载校验拒绝(悬空/池构成/普攻锁死/倍率域/权重域/空池)。
**验证(本批实跑)**:冒烟 **263 项检查 0 失败**;四探针 ui_probe / ui_tune_probe / ui_layout_probe / ui_click_probe(种子 7、23)全 PASS;damage_probe(boss/level 全 PASS 含施放行)、defense_scan、monte_carlo 工具零脚本错误。
**EV 抽样读数(×5000 拍,含闸长期速率 vs 静态锚)**:地精 1.1056(静态 1.05——保底闸把突刺率从自然 10% 抬到 ~22%,登记难度曲线重锚)/ 熊地精 0.9998(结构守恒恒 1)/ 酋长 1.0236。
**文档/registry 同批(批 3)**:敌人系统 v0.5(技能池章节重写)/ 战斗结算 v0.11(随机口径三处/三乘法链/施放行/敌方防御池/AC 措辞四处)/ 增益状态 v0.5(九行 + 敌方防御池;顺手清偿 v0.4 挂起的 sapped 文档同步)/ 难度曲线与Boss v0.6(曲线纪律/Boss 专属池/技能池投值表 + EV 实测登记)/ 战斗表现 v0.3 / HUD框架 v0.4 / 数据配置(敌人表措辞 + 技能表行)/ 03-内容规模 / 02-系统清单 / 关卡节点地图 / ux battle.md(改型注记)/ architecture.md(单位容器 API 面)/ registry(rng/skill 新条目/enemy/status_effect/settlement/术语七词条/修订记录)/ 06-待办清单(改型"单拍行为环体验验证"、收编关闭"行为环废除与蓄力拍退役"、新增 P1 五项 + P2 技能图鉴)。
**实现决策登记(提案未定处的自行拍板,按封装纪律报告)**:① 保底 M 载体 = 技能表全局单字段 pity_threshold(不分敌种,难度曲线单旋钮);② 冷却"施放后连续 N 拍不可摇中"的实现 = 摇号按当前冷却值排除、递减在其后(与提案"递减→排除"的可摇集合逐拍恒等);③ 增益持续与技能冷却两计时器 = apply_effect duration_override 通道;④ 敌方防御池乘区落点 = 主角攻击 Σ三分量后(hero_offense 现行走普通分量直乘的切片现状不动——登记遗留);⑤ 摇号器类名 SkillLottery(core/skill_lottery.gd)。
@@ -4,8 +4,9 @@
# 战斗结算编排中枢:轮次生命周期唯一 owner · 五段结算(授予→增益/减益→战斗→衰减→判定——2026-09-15
# 格面资源批,道具窗口/道具段随可选类删除下线)· 授予段极性路由(「动作 × 格型」矩阵:开安全格/标中旗
# = 正向,开雷格/标错旗 = 负向;结算队列 + 抵消过滤——标旗后被撤旗以轮末在场旗为基线不判)·
# 10 类行协议可回放步骤流(零 await;随机消费 = 开战遭遇骰初始化随机 + 护甲等级掷点回合内随机——
# 均走封闭命名流,2026-09-16 护甲等级掷点批)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# 10 类行协议可回放步骤流(零 await;随机消费三处皆封闭流——开战遭遇骰〔初始化随机〕+ 技能摇号
# behavior_roll〔回合内、开轮时点、步骤流之外——敌人技能池批 2026-09-16〕+ 护甲等级掷点〔回合内、
# 步骤流之内〕;同种子同输入逐位复现)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
@@ -22,7 +23,7 @@ signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态
# 明细流水行型协议(战斗结算 Core Rule;10 类——2026-09-15 格面资源批由 15 类收拢,
# 清单改动须同步 battle_log_view 的 _format_row 与本文档头注):
# 效果行 effect · 伤害行 damage · 治疗行 heal · 溢出未生效行 overflow ·
# 奖励未生效行 reward_ineffective · 标旗未中行 flag_miss · 蓄力行 charge · 衰减行 decay ·
# 奖励未生效行 reward_ineffective · 标旗未中行 flag_miss · 施放行 cast(敌人技能池批由蓄力行 charge 改型)· 衰减行 decay ·
# 判定行 verdict · 高光行 highlight
# 行来源断言豁免集合(行型层)——判定段 / 衰减段本就无棋盘来源,勿一刀切误炸
const SOURCE_EXEMPT_TYPES := ["decay", "verdict"]
@@ -122,6 +123,7 @@ func begin_battle(enc: Dictionary) -> bool:
_fatal("行动经济 首轮 notify_round_start 被拒")
return false
round_index = 1
_roll_round_actions() # 开轮摇号(敌人技能池批——首轮同样摇;先于 round_started,挖掘期动画位可读)
round_started.emit(round_index)
return true
@@ -218,7 +220,7 @@ func _settle() -> void:
# ② 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——增益状态 义务⑦)
_emit_composition_effects("hero", ri)
_emit_composition_effects("enemy", ri)
# ③ 战斗段(调用序钉死 query_attack → apply_damage → advance;hero hp=0 短路)
# ③ 战斗段(调用序:掷点判定 → apply_damage → commit_action;hero hp=0 短路)
_combat_segment(ri)
# ④ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var k: int = _status_effects.decay_round()
@@ -396,6 +398,26 @@ func _grant_clear_identity(snap: Dictionary, ri: int, suffix: String) -> bool:
return _apply_flat_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix) == "redeemed"
# ---- 开轮摇号(敌人技能池批——共识点 B:技能选择前置到轮次开始,数值计算留结算段)----
# 每实例:保底判定(连续执行普攻 ≥ M → 必出特殊技、特殊技内按权重、不受冷却限制)→ 排除冷却中技能
# (中签率 = 权重 ÷ 可摇权重合计——份额归一)→ 按行序逐敌从 behavior_roll 独立子流摇定本拍动作。
# 随机消费只在换算层(本方法 + SkillLottery);单位容器收确定值(零随机纪律——armor_roll 先例同构)。
# 子流 scope = spawn_index × BEHAVIOR_SUBSTREAM_STRIDE + 单敌摇号序(registry 派生向量)——
# 单敌出招轨迹可独立复放、敌间互不穿插。摇定后、兑现前死亡 → 本拍技能作废(死亡即不动手)。
func _roll_round_actions() -> void:
var pity_m: int = int(db.skill.pity_threshold)
for u in _enemy_system.query_alive("enemy"):
var entries: Array = _enemy_system.rollable_skills(u)
if _enemy_system.basic_streak(u) >= pity_m:
var specials: Array = _enemy_system.special_skills(u)
if not specials.is_empty():
entries = specials # 保底命中——普攻恒可摇 → 非保底路径池恒非空,此为唯一替代集合
var scope: int = int(u.get("handle_id", 0)) * RngService.BEHAVIOR_SUBSTREAM_STRIDE + int(u.get("roll_seq", 0))
var stream: RandomNumberGenerator = rng.open_scope("behavior_roll", scope)
_enemy_system.set_pending_action(u, SkillLottery.draw(entries, stream))
_enemy_system.tick_cooldowns() # 递减在摇号读值之后——"施放后连续 N 拍不可摇中"的等价实现(单位容器 注记)
# ---- 战斗段 ----
func _combat_segment(ri: int) -> void:
@@ -433,33 +455,50 @@ func _combat_segment(ri: int) -> void:
payload["tier"] = discount
if crit:
payload["crit"] = true
# 增益池乘区·敌方防御池(敌人技能池批——硬化技载体):Σ三分量后 ×(100+clamp(enemy_defense))/100。
# 四池对称:敌打主角 = hero_defense×enemy_output(_resolve_damage);主角打敌 = hero_offense×enemy_defense
# (hero_offense 现行走普通分量 attack_up 直乘的切片现状——登记遗留,本批不动)。
d_hero = DamageMath.pool_damage(d_hero, 0, _clamped_pool("enemy_defense"))
var d_landed: int = maxi(int(db.settlement.damage_floor), d_hero)
_enemy_system.apply_damage(hero_target, d_landed)
payload["killed"] = not hero_target.alive # 击杀标记在扣血后取(原语义不变)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, _attack_label("主角攻击", crit, discount), d_landed, payload)
# 敌方行动(敌人技能池批——本拍动作开轮已摇定,此处按技能行兑现;行序 = 生成序)。
# 死亡即不动手:摇定后、兑现前死亡的敌不在存活序中——本拍技能作废,不 commit、不进冷却。
for u in _enemy_system.query_alive("enemy"):
if int(_enemy_system.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
var beat: String = _enemy_system.query_beat(u)
var atk: Dictionary = _enemy_system.query_attack(u, L, _m_amb) # 调用序①兑付读值
if beat == "charge":
_emit_row("charge", "enemy_action", Vector2i(-1, -1), ri, "%s 蓄力(本轮踩雷白喂)" % u.display_name, 0, {"target": "hero"})
else:
# 基线部分吃掷点判定(含暴击)+ 伏击附加(必中全额)→ 合计 → 增益池乘区 → max(1, ·)(伤害下限)
var roll_read_e: Dictionary = _armor_roll()
var threshold_e: int = _defender_ac(_enemy_system.hero_unit()) + int(db.settlement.attack_bonus)
var crit_e: bool = bool(roll_read_e.crit)
var discount_e: int = _roll_discount(int(roll_read_e.roll), threshold_e)
var rolled_base: int = DamageMath.rolled_component(float(int(atk.baseline)), discount_e, crit_e, int(db.settlement.crit_multiplier), 100)
var d_tick := _resolve_damage(rolled_base + int(atk.attach))
d_tick = maxi(int(db.settlement.damage_floor), d_tick)
_enemy_system.apply_damage(_enemy_system.hero_unit(), d_tick) # 调用序②受击上报
var payload_e: Dictionary = {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach),
"roll": int(roll_read_e.roll), "ac": threshold_e, "tier": discount_e}
if crit_e:
payload_e["crit"] = true
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, _attack_label("%s 攻击" % u.display_name, crit_e, discount_e), d_tick, payload_e)
_enemy_system.advance(u) # 调用序③推进——每结算每存活敌恰一次(hero 不在推进域)
var skill_id := _enemy_system.pending_action(u)
var srow: Dictionary = db.skill_row(skill_id)
if srow.is_empty():
_fatal("敌实例待发动作悬空: %s" % skill_id)
continue
if String(srow.type) == "buff":
# 增益技 = 0 伤害施放行:不兑付伏击(旧蓄力拍"零兑付"语义由施放行继承——本轮喂的伏击
# 逢施放拍不兑现的读怪奖励保留);状态授予即数值兑现,技能冷却与增益持续两计时器分账。
var dur: int = int(srow.get("buff_duration", 1))
_status_effects.apply_effect(String(srow.buff_ref), {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": ri}, dur)
_emit_row("cast", "enemy_action", Vector2i(-1, -1), ri, "%s 施放 %s" % [u.display_name, String(srow.display_name)], 0, {"skill_id": skill_id, "effect_id": String(srow.buff_ref), "duration": dur})
_enemy_system.commit_action(u)
continue
# 伤害技(含普攻——倍率恒 1):三乘法链 ① A = 攻击基线 × 技能倍率(判定对象 = 技能基础伤害分量)
# → ② d20 vs 主角 AC(nat20 暴击 ≠ 技能重击,两层严格区分)→ ③ ×甲效(敌打主角无甲轴恒中性)
# → 伏击附加(后置加法、必中全额、不吃 AC)→ 增益池乘区 → 伤害下限 max(1,·)。
var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0))))
var roll_read_e: Dictionary = _armor_roll()
var threshold_e: int = _defender_ac(_enemy_system.hero_unit()) + int(db.settlement.attack_bonus)
var crit_e: bool = bool(roll_read_e.crit)
var discount_e: int = _roll_discount(int(roll_read_e.roll), threshold_e)
var rolled_base: int = DamageMath.rolled_component(float(baseline), discount_e, crit_e, int(db.settlement.crit_multiplier), 100)
var d_tick := _resolve_damage(rolled_base + L * _m_amb)
d_tick = maxi(int(db.settlement.damage_floor), d_tick)
_enemy_system.apply_damage(_enemy_system.hero_unit(), d_tick)
var payload_e: Dictionary = {"target": "hero", "skill_id": skill_id, "baseline": baseline, "attach": L * _m_amb,
"roll": int(roll_read_e.roll), "ac": threshold_e, "tier": discount_e}
if crit_e:
payload_e["crit"] = true
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, _attack_label("%s %s" % [u.display_name, String(srow.display_name)], crit_e, discount_e), d_tick, payload_e)
_enemy_system.commit_action(u) # 调用序③执行上报——技能进冷却 + 保底计数(每结算每存活敌恰一次)
# 攻击行显示名(掷点档位随行文本可读——2026-09-16 护甲等级掷点批;零值行语义已退役,重档折扣 = "被压制")
@@ -569,7 +608,7 @@ func _emit_composition_effects(side: String, ri: int) -> void:
func _emit_settlement_snapshot(verdict: String) -> void:
var enemies: Array = []
for u in _enemy_system.query_alive("enemy"):
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "beat": query_beat_of(u), "armor": String(u.get("armor_type", "light")), "armor_class": int(u.get("armor_class", 0))})
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "pending_skill": _enemy_system.pending_action(u), "armor": String(u.get("armor_type", "light")), "armor_class": int(u.get("armor_class", 0))})
var buffs: Array = []
for eff in _status_effects.query_active("hero"):
buffs.append({"id": eff.effect_id, "side": "hero", "layers": eff.layers, "left": eff.remaining_settlements})
@@ -606,10 +645,6 @@ func _emit_settlement_snapshot(verdict: String) -> void:
})
func query_beat_of(u: Dictionary) -> String:
return _enemy_system.query_beat(u)
# ---- 出口分发与战斗结束序列 ----
func _exit_distribution() -> void:
@@ -627,6 +662,7 @@ func _exit_distribution() -> void:
_reset_round_facts()
_refresh_used_this_round = 0
round_index += 1
_roll_round_actions() # 开轮摇号——每轮恰摇一次(轮内刷新换图不重摇:refresh 不走本通道)
round_started.emit(round_index)
@@ -669,16 +705,24 @@ func _battle_over(result: String) -> void:
# 协作对象(_enemy_system/_status_effects/_reward_economy)私有化后,跨层读取一律经本快照或下列命名面(diagnostic_snapshot 同款模式,
# 禁再链式穿透 settlement.enemy_system.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_request_refresh / hero{hp,max_hp,atk,alive,armor_class} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,armor_class,beat,ring_size,ring_index,ambush_layers,alive}] /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,armor_class,pending_skill,pending_type,pending_name,effects[],ambush_layers,alive}] /
# (敌人技能池批批 2 定稿:beat/ring_size/ring_index 拆除 → pending_* 三字段 + 敌方增益 effects 投影;池与权重不公开)
# ambush_layers / hero_effects[](增益状态 query_active 投影)
func presentation_snapshot() -> Dictionary:
var enemies: Array = []
var all: Array = _enemy_system.all_enemies()
var ambush: int = _status_effects.pool_layers("ambush")
# 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走等 percent 行;伏击单独走 ambush_layers)
var enemy_effects: Array = []
for eff in _status_effects.query_active("enemy"):
var erow: Dictionary = db.status_effect_row(eff.effect_id)
if not erow.is_empty() and String(erow.unit) == "percent" and String(eff.effect_id) != "ambush":
enemy_effects.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)})
for i in all.size():
var u: Dictionary = all[i]
var alive: bool = bool(u.get("alive", false))
var ring: Array = u.get("pattern", [])
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary = db.skill_row(pending)
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
@@ -686,9 +730,12 @@ func presentation_snapshot() -> Dictionary:
"enemy_id": String(u.enemy_id),
"armor_type": String(u.get("armor_type", "light")),
"armor_class": int(u.get("armor_class", 0)),
"beat": String(_enemy_system.query_beat(u)) if alive else "",
"ring_size": ring.size(),
"ring_index": int(u.get("steps", 0)) % maxi(ring.size(), 1),
# 敌人技能池批(批 2 定稿):摇定动作三字段——普通攻击零表示 / 特殊技类型级动画占位。
# 池与权重不公开(proposal D4/D5)——实例池的权重/冷却不投影,实例池可见面 = 摇定结果本身。
"pending_skill": pending,
"pending_type": String(pending_row.get("type", "")) if not pending_row.is_empty() else "",
"pending_name": String(pending_row.get("display_name", "")) if not pending_row.is_empty() else "",
"effects": enemy_effects,
"ambush_layers": ambush,
"alive": alive,
})
@@ -14,6 +14,7 @@ const TABLE_MANIFEST := {
"status_effects": "status_effects.tres",
"board_gen": "board_gen.tres",
"enemy": "enemy.tres",
"skill": "skills.tres",
"hero": "hero.tres",
"reward": "reward.tres",
"settlement": "settlement.tres",
@@ -26,6 +27,7 @@ var variation: VariationTable
var status_effects: StatusEffectTable
var board_gen: BoardGenTable
var enemy: EnemyTable
var skill: SkillTable
var hero: HeroTable
var reward: RewardTable
var settlement: SettlementTable
@@ -95,6 +97,7 @@ func load_tables(root: String, collect_only: bool) -> bool:
status_effects = loaded.get("status_effects")
board_gen = loaded.get("board_gen")
enemy = loaded.get("enemy")
skill = loaded.get("skill")
hero = loaded.get("hero")
reward = loaded.get("reward")
settlement = loaded.get("settlement")
@@ -150,6 +153,39 @@ func _cross_validate(loaded: Dictionary) -> void:
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
if not en_keys.has(key):
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 enemy 无精确行" % [li, t, key])
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
var skt = loaded.get("skill")
if skt != null and en != null:
for e in validate_skill_pool_links(skt, en):
push_diag("error", e.category, e.message)
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1——proposal §2.1 结构保证)。
func validate_skill_pool_links(skill_table, enemy_table) -> Array:
var errs: Array = []
var skill_types := {}
for row in skill_table.rows:
skill_types[String(row.get("skill_id", ""))] = String(row.get("type", ""))
for row in enemy_table.rows:
var eid: String = String(row.get("enemy_id", ""))
var basic_n := 0
var buff_n := 0
for entry in row.get("pool", []):
var sid: String = String(entry.get("skill_id", ""))
if not skill_types.has(sid):
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
continue
match skill_types[sid]:
"basic":
basic_n += 1
"buff":
buff_n += 1
if basic_n != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
if buff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
return errs
# ---- R1 难度行解析(floor + warning 四元组去重;请求越域致命)----
@@ -259,6 +295,11 @@ func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面)
func skill_row(skill_id: String) -> Dictionary:
return skill.row_by_id(skill_id)
# ---- 数据配置 EC8:debug 热重载门(战斗结算 唯一调用方;切片实现 = 门标志 + 拒绝语义)----
func set_reload_gate(gated: bool) -> void:
@@ -7,7 +7,7 @@
# 取号语义:全新实例首个 open_stream = scope 0(地图战斗资源 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批,用户拍板——回合内随机、逐次掷逐次取号,registry rng_service 条目同步)
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批)+ behavior_roll(敌人技能池批——开轮摇号,每敌派生独立子流,registry rng_service 条目同步)
# 分段表达式书写(数据配置 R2 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
@@ -15,6 +15,10 @@ const SALT_BOARD_GEN := (0xA0761D64 << 32) | 0x78BD642F # 0xA0761D6478BD642F
const SALT_REWARD_ROLL := (0xE7037ED1 << 32) | 0xA0B428DB # 0xE7037ED1A0B428DB
const SALT_ENCOUNTER := (0x8EBC6AF0 << 32) | 0x9C88C6E3 # 0x8EBC6AF09C88C6E3(遭遇骰——registry stream_seed_derivation 权威值;旧值 0x5DEECE66D<<32 超 int64 被截断,黄金向量漂移)
const SALT_ARMOR_ROLL := (0xC2B2AE3D << 32) | 0x27D4EB2F # 0xC2B2AE3D27D4EB2F(护甲掷点流——互异奇常数 ≠ 乘数;黄金向量见冒烟/registry 同批登记)
const SALT_BEHAVIOR_ROLL := (0x85EBCA77 << 32) | 0x58C24E15 # 0x85EBCA7758C24E15(技能摇号流——互异奇常数 ≠ 乘数;黄金向量见冒烟/registry 同批登记)
# behavior_roll 派生向量(registry 同批登记):子流 scope = spawn_index × STRIDE + 单敌摇号序(0 起)。
# 每敌消费自己的 scope 段 → 单敌出招轨迹可独立复放、敌间互不穿插(proposal 约束二)。
const BEHAVIOR_SUBSTREAM_STRIDE := 1000000
const OUT_MASK := 0x7FFFFFFFFFFFFFFF
const INT64_MIN := -9223372036854775808
@@ -23,7 +27,7 @@ const INT64_MIN := -9223372036854775808
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0}
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0, "behavior_roll": 0}
func _salt_for(stream: String) -> int:
@@ -36,6 +40,8 @@ func _salt_for(stream: String) -> int:
return SALT_ENCOUNTER
"armor_roll":
return SALT_ARMOR_ROLL
"behavior_roll":
return SALT_BEHAVIOR_ROLL
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(流集合封闭,EC12)")
return 0
@@ -57,7 +63,7 @@ func set_master_seed(seed_val: int) -> void:
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0 and _counters["behavior_roll"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
_master_seed = seed_val
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
@@ -69,6 +75,7 @@ func new_adventure(seed_val: int) -> void:
_counters["reward_roll"] = 0
_counters["encounter"] = 0
_counters["armor_roll"] = 0
_counters["behavior_roll"] = 0
set_master_seed(seed_val)
@@ -87,6 +94,23 @@ func open_stream(stream: String) -> RandomNumberGenerator:
return rg
# 显式 scope 取号门(behavior_roll 每敌子流专用——armor_roll 式顺序取号不适用:子流 scope =
# spawn_index × BEHAVIOR_SUBSTREAM_STRIDE + 单敌摇号序,见派生向量注记)。计数器仍单调 +1
# (总摇号次数可观测);"首个 open_stream = scope 0"语义不变(不同流不同门,互不干扰)。
func open_scope(stream: String, scope: int) -> RandomNumberGenerator:
if not STREAM_NAMES.has(stream):
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(EC12)")
assert(scope >= 0, "负 scope 与 scope+2^63 经输出掩码别名(禁止)")
if _master_seed < 0:
push_error("[RngService] 未设 master_seed 即取数(EC7)")
assert(false, "未播种流取数致命(EC7)")
_counters[stream] = _counters[stream] + 1
var rg := RandomNumberGenerator.new()
rg.seed = derive_at(stream, scope)
return rg
# 只读计数器(存档序列化 快照契约的观测面)
func counter(stream: String) -> int:
return _counters[stream]
@@ -0,0 +1,28 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-16
# 敌人系统·技能摇号器(proposal-skill-pool D9 封装纪律):独立服务类——输入 实例池可摇条目 + 随机子流,
# 输出 skill_id。权重份额归一:中签率 = 该技能权重 ÷ 可摇权重合计(排除谁,谁的份额按比例摊给其余技能)。
# 不碰血量、不碰表、不碰容器——过滤与冷却状态由调用方(战斗结算换算层)注入确定值。
# 零随机纪律:随机消费只在子流参数(behavior_roll 派生流),本类无自建随机源。
# 实现 GDD:design/gdd/systems/战斗循环/敌人系统.md(registry 追溯锚,命名规范见 CLAUDE.md)
class_name SkillLottery
extends RefCounted
# 权重摇号(整数份额,行序累计走查——同份额前行优先,确定性);entries = [{skill_id, weight}](非空)
static func draw(entries: Array, stream: RandomNumberGenerator) -> String:
assert(not entries.is_empty(), "[技能摇号] 空可摇集合(普攻冷却恒 0 → 池恒非空——结构违例即致命)")
var total := 0
for e in entries:
var w: int = int(e.get("weight", 0))
assert(w > 0, "[技能摇号] 池条目权重须 > 0: %s" % str(e))
total += w
assert(total > 0, "[技能摇号] 可摇权重合计为 0")
var r: int = stream.randi_range(1, total)
var acc := 0
for e in entries:
acc += int(e.weight)
if r <= acc:
return String(e.skill_id)
return String(entries[entries.size() - 1].skill_id) # 数值防御分支(不可达——r ≤ total 恒有落点)
@@ -0,0 +1 @@
uid://dvj4nqsa6juu
@@ -26,8 +26,10 @@ func notify_battle_start() -> bool:
return true
# 唯一授予入口(挖掘期零直写——战斗结算 结算编排期统一调用)
func apply_effect(effect_id: String, source: Dictionary) -> bool:
# 唯一授予入口(挖掘期零直写——战斗结算 结算编排期统一调用)。
# duration_override(敌人技能池批——增益技施放):技能行 buff_duration 覆盖状态行时长(-1 = 状态表值)。
# 施放是持续量的 owner(proposal §2.2 字段词汇表"buff_duration 增益持续回合数");其他授予路径传缺省。
func apply_effect(effect_id: String, source: Dictionary, duration_override: int = -1) -> bool:
if not _open:
push_warning("[增益状态] 域闭授予(EC1)")
return false
@@ -36,7 +38,7 @@ func apply_effect(effect_id: String, source: Dictionary) -> bool:
push_warning("[增益状态] 未知 effect_id(EC2): %s" % effect_id)
return false
var policy: String = row.stack_policy
var dur: int = int(row.duration_settlements)
var dur: int = int(row.duration_settlements) if duration_override < 1 else duration_override
var max_stack: int = int(row.max_stack)
# 授予时快照(抗表重载——EC9)
var snap_m: int = int(row.magnitude)
@@ -1,17 +1,24 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 敌人系统·战斗单位容器:hero/enemy 统一受伤死亡接口 · 行为环确定性轮转(死亡即不动手/先作用后推进)·
# 行值快照抗热重载 · side-wide 伏击池读值由调用方注入(敌人系统 无池写面)· 编排被动零事件。
# 切片登记接口漂移:heal() 为切片新增(战斗结算 治疗 clamp 需要写入口;敌人系统 API 封闭枚举原本无治疗面——
# 正式侧应走 敌人系统 GDD 修订,切片以头注登记)。
# 实现 GDD:design/archive/gdd-2026-09-14/b2-enemy-system.md(系统 ID B2·敌人系统——registry 追溯锚,命名规范见 CLAUDE.md)
# Date: 2026-09-16
# 敌人系统·战斗单位容器:hero/enemy 统一受伤死亡接口 · 实例池状态机(敌人技能池批——2026-09-16 用户拍板,
# 设计权威 docs/proposal-skill-pool.md v1.0)· 行值快照抗热重载 · 编排被动零事件。
# 实例池三层对象(proposal §2.2):定义层技能行(SkillTable)· 定义层池模板(EnemyTable.pool)·
# 本类持实例层——spawn 把模板快照为各实例池(冷却全零),此后冷却递减/待发动作/保底计数各自独立。
# 零随机零事件纪律(proposal 约束二):随机消费只在 战斗结算换算层抽签器(SkillLottery + behavior_roll 子流);
# 本类只收确定结果(set_pending_action)并管理确定性状态(冷却递减、执行上报)。旧行为环
# (pattern 环数组 + steps 指针 + heavy_multiplier)随技能池整体退役——拍结构保留(每结算每存活敌恰行动一次、
# 行序、先作用后推进),"轮到什么"改由开轮摇号决定(死亡即不动手:摇定后兑现前死亡 → 本拍作废、不 commit)。
# 切片登记接口漂移:heal() 为切片新增(战斗结算 治疗 clamp 需要写入口);spawn 签名 +3 参数(E4/区间批/
# 遭遇骰——正式侧 GDD/registry 修订随对应批)。
# 实现 GDD:design/gdd/systems/战斗循环/敌人系统.md(registry 追溯锚,命名规范见 CLAUDE.md)
class_name UnitContainer
extends RefCounted
var db
# 单位容器私有(E3 封装收口 2026-09-13):hero/enemies 原始数组不再外露——读取经 hero_unit()/
# all_enemies() 投影面(死亡残位查询包含在内),写入只经 apply_damage/heal/advance/spawn/despawn。
# all_enemies() 投影面(死亡残位查询包含在内),写入只经 apply_damage/heal/set_pending_action/commit_action/
# tick_cooldowns/spawn/despawn。
# 句柄语义不变:投影返回的 Dictionary 引用即单位句柄(同 query_alive 先例)。
var _hero: Dictionary = {} # {hp, max_hp, atk, is_hero: true}
var _enemies: Array = [] # 行主序生成序;元素 = 单位 Dictionary
@@ -25,9 +32,8 @@ func _init(db_ref) -> void:
# E4 收口(2026-09-13):加成值经参数显式传入(atk_override/max_hp_override,-1 = 表值兜底)——
# 此前调用方写 db.hero 表值再由本类读表 = 共享可变单例传参(双写叠加 6→18→54 教训根源)。
# 接口漂移登记:spawn 签名 +2 参数(敌人系统 GDD API 封闭枚举——正式侧 GDD/registry 修订批随 ADR-0009)。
# enemy_atk_override(2026-09-14 敌方攻击区间批):战斗结算 遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult——修饰语义不变)。敌人系统 零随机纪律保持。
# enemy_atk_override(2026-09-14 敌方攻击区间批):遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。
func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override: int = -1,
atk_override: int = -1, max_hp_override: int = -1, enemy_atk_override: int = -1) -> bool:
if _spawned:
@@ -45,7 +51,7 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override:
return false
# max_hp = 生效上限(非入参 hero_hp)——2026-09-11 关卡扩展批修复:跨战斗携带 HP 时上限不得压扁
# (恢复点/治疗包的满血守卫与治疗上限随之失效——切片登记接口语义澄清)
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": "探险者", "steps": 0, "pattern": [], "enemy_id": "hero"}
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": "探险者", "enemy_id": "hero"}
_enemies.clear() # 难度曲线与Boss DifficultyTable 修饰(2026-09-12 结构位兑现):施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10)
var mod: Dictionary = db.resolve_difficulty_modifiers(level_index, node_depth)
var hp_mult: float = float(mod.get("hp_mult", 1.0))
@@ -57,6 +63,14 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override:
return false
for i in range(count):
var eff_row_atk: int = enemy_atk_override if enemy_atk_override >= 0 else int(row.atk)
# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条快照纪律)
var pool_snap: Array = []
for entry in row.get("pool", []):
pool_snap.append({
"skill_id": String(entry.get("skill_id", "")),
"weight": int(entry.get("weight", 0)),
"cooldown_left": 0,
})
_enemies.append({
"handle_id": i,
"enemy_id": String(row.enemy_id),
@@ -64,12 +78,13 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override:
"hp": maxi(1, int(round(float(row.hp) * hp_mult))),
"max_hp": maxi(1, int(round(float(row.hp) * hp_mult))),
"atk": maxi(0, int(round(float(eff_row_atk) * atk_mult))),
"heavy_multiplier": int(row.heavy_multiplier),
"pattern": Array(row.pattern), # 行值快照(拷贝)
"pool": pool_snap, # 实例池(模板快照 + 冷却状态——独立摇号互不干涉)
"pending_skill": "", # 本拍摇定动作(开轮摇号写入;兑现前死亡 = 作废不 commit)
"roll_seq": 0, # 单敌摇号序(behavior_roll 子流 scope 派生向量——registry 登记)
"basic_streak": 0, # 保底计数:连续执行普攻的拍数(执行上报驱动)
"advantage_per_round_cap": int(row.advantage_per_round_cap),
"armor_type": String(row.get("armor_type", "light")), # 护甲类型快照(克制矩阵消费面——战斗结算·战斗段;2026-09-15 命名统一批:light/ward/heavy)
"armor_class": int(row.get("armor_class", 0)), # 护甲等级快照(掷点判定阈值基数——战斗结算·战斗段;2026-09-16 护甲等级掷点批)
"steps": 0,
"alive": true,
"is_hero": false,
})
@@ -91,30 +106,92 @@ func query_alive(side: String) -> Array:
return out
# 当前拍(Dead 句柄保留只读可查——冻结不变式观测量;hero 句柄拒绝)
func query_beat(u: Dictionary) -> String:
# ---- 实例池状态机(敌人技能池批——全部确定性状态,随机在 战斗结算换算层)----
# 可摇集合:冷却已尽的池条目投影 [{skill_id, weight}]。普通攻击冷却恒 0 → 恒非空(空池不可能的结构前提,
# 加载校验"每池恰一行普攻"兜底)。hero/失效句柄拒绝。
func rollable_skills(u: Dictionary) -> Array:
if u.is_empty() or _despawned or u.get("is_hero", false):
_diag("rollable_skills 失效句柄")
return []
var out: Array = []
for e in u.pool:
if int(e.cooldown_left) <= 0:
out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)})
return out
# 特殊技集合(保底专用——proposal §2.1:必出特殊技、特殊技内按权重、不受冷却限制):type ≠ 普攻的全部池条目。
func special_skills(u: Dictionary) -> Array:
if u.is_empty() or _despawned or u.get("is_hero", false):
_diag("special_skills 失效句柄")
return []
var out: Array = []
for e in u.pool:
var srow: Dictionary = db.skill_row(String(e.skill_id))
if not srow.is_empty() and String(srow.type) != "basic":
out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)})
return out
# 摇定结果写入(战斗结算换算层调用——确定值;roll_seq 递增 = 子流 scope 派生向量的取号序)
func set_pending_action(u: Dictionary, skill_id: String) -> void:
if u.is_empty() or _despawned or u.get("is_hero", false):
_diag("set_pending_action 失效句柄")
return
u.pending_skill = skill_id
u.roll_seq = int(u.roll_seq) + 1
# 本拍摇定动作(未摇 = "";死亡残位保留可查——作废观测量)
func pending_action(u: Dictionary) -> String:
if u.is_empty() or _despawned:
_diag("query_beat 失效句柄")
_diag("pending_action 失效句柄")
return ""
if u.get("is_hero", false):
_diag("hero 无行为环(环接口对主角句柄拒绝)")
return ""
var p: Array = u.pattern
return String(p[u.steps % p.size()])
return String(u.get("pending_skill", ""))
# F2 分量供给面:{baseline, attach, total};L/m_amb 由调用方注入(判别探针,不校验游戏态域)
func query_attack(u: Dictionary, L: int, m_amb: int) -> Dictionary:
if u.is_empty() or _despawned or not u.alive:
_diag("query_attack 失效/死亡句柄")
return {"baseline": 0, "attach": 0, "total": 0}
var beat := query_beat(u)
if beat == "charge":
return {"baseline": 0, "attach": 0, "total": 0} # charge 拍不读伏击池——空拍零兑付
var baseline: int = u.atk
if beat == "heavy":
baseline = u.atk * int(u.heavy_multiplier)
return {"baseline": baseline, "attach": L * m_amb, "total": baseline + L * m_amb}
# 保底计数读数(连续执行普攻拍数——战斗结算开轮摇号时与 SkillTable.pity_threshold 比较)
func basic_streak(u: Dictionary) -> int:
if u.is_empty() or _despawned:
_diag("basic_streak 失效句柄")
return 0
return int(u.get("basic_streak", 0))
# 执行上报(战斗段兑现时调用):该技能进冷却(施放后连续 N 拍不可摇中——含增溢技,技能冷却与增益持续
# 是两个计时器互不相干)+ 保底计数更新(普攻 +1 / 特殊技归零)。摇定后兑现前死亡不经过本方法
# = 作废不进账(无冷却无计数——死亡即不动手)。
func commit_action(u: Dictionary) -> void:
if u.is_empty() or _despawned or u.get("is_hero", false):
_diag("commit_action 失效句柄")
return
var sid := String(u.get("pending_skill", ""))
if sid == "":
_diag("commit_action 无待发动作(兑现序违例)")
return
var srow: Dictionary = db.skill_row(sid)
var cd: int = int(srow.get("cooldown_beats", 0)) if not srow.is_empty() else 0
for e in u.pool:
if String(e.skill_id) == sid:
e.cooldown_left = cd
if not srow.is_empty() and String(srow.type) == "basic":
u.basic_streak = int(u.basic_streak) + 1
else:
u.basic_streak = 0
u.pending_skill = ""
# 开轮冷却递减(战斗结算开轮摇号编排调用,摇号读值之后全体恰一次):floor 0。
# 时序口径 = 摇号按当前 cooldown_left 排除(>0 冷却中),递减在其后——等价实现"施放后连续 N 拍
# 不可摇中"(施放拍置 N → 后续 N 个开轮各排除一次);与 proposal §2.1"冷却递减 → 排除"的
# 可摇集合逐拍恒等(init N+1 的同构变换)。
func tick_cooldowns() -> void:
if _despawned:
return
for u in _enemies:
for e in u.pool:
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1)
# 受击上报:hp 递减 floor 0 → Dead;n ≤ 0 拒绝
@@ -141,20 +218,6 @@ func heal(u: Dictionary, n: int) -> int:
return actual
# 推进:存活才推进;false 两级——预期冻结(无诊断)/ 失效句柄(诊断);hero 拒绝
func advance(u: Dictionary) -> bool:
if u.is_empty() or _despawned:
_diag("advance 失效句柄")
return false
if u.get("is_hero", false):
_diag("hero 不在推进域(主角无行为环)")
return false
if not u.alive:
return false # 轮到前被杀——预期冻结(死亡即不动手),无诊断
u.steps += 1
return true
# 固定顺位:行主序生成序最前存活敌;死亡后顺延;空 → null
func default_target():
for u in _enemies:
@@ -2,14 +2,15 @@
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 数据配置 EnemyTable / HeroTable schema(敌人系统 回填值)。EnemyTable 键 (L,D,enemy_id),每行集恰一敌种;
# HeroTable 单行全局(Rule 4 豁免)。依据 design/archive/gdd-2026-09-14/b2-enemy-system.md Schema 节。
# HeroTable 单行全局(Rule 4 豁免)。依据 design/gdd/systems/战斗循环/敌人系统.md Schema 节。
# 敌人技能池批(2026-09-16 用户拍板,设计权威 docs/proposal-skill-pool.md v1.0):pattern 行为环数组与
# heavy_multiplier 退役,改池模板 pool = [{skill_id, weight}](定义层——实例池快照与冷却状态在 单位容器)。
# 池构成纪律(每池恰一行普攻、增益 ≤1、skill_id 悬空致命)需跨表视野,校验在 ConfigDB 跨表通道。
class_name EnemyTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const PATTERN_MVP := ["attack", "charge", "heavy"]
const PATTERN_RESERVED := ["guard", "boost", "hinder", "summon"]
# 护甲类型封闭枚举(攻击类型 × 护甲克制批——克制矩阵本体在 SettlementTable;
# 2026-09-15 命名统一批:位置键 {none, a, b} → 语义键 {light, ward, heavy} = 轻甲 / 魔法护甲 / 重甲)
const ARMOR_TYPES := ["light", "ward", "heavy"]
@@ -64,17 +65,27 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
var hm: int = int(row.get("heavy_multiplier", 0))
if hm < 1 or hm > 16:
errs.append({"category": "domain", "message": "%s heavy_multiplier 越域 [1,16]" % eid})
var pattern: Array = row.get("pattern", [])
if pattern.size() < 1 or pattern.size() > 8:
errs.append({"category": "domain", "message": "%s pattern 环长越域 [1,8]" % eid})
for beat in pattern:
if PATTERN_RESERVED.has(beat):
errs.append({"category": "enum", "message": "%s pattern 预留值拒绝(MVP 校验域): %s" % [eid, beat]})
elif not PATTERN_MVP.has(beat):
errs.append({"category": "enum", "message": "%s pattern 未知枚举: %s" % [eid, beat]})
# 池模板(敌人技能池批——单表结构校验;跨表构成纪律在 ConfigDB):
# 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。
# 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。
var pool: Array = row.get("pool", [])
if pool.is_empty():
errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid})
var pool_ids := {}
for entry_v in pool:
if not (entry_v is Dictionary):
errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]})
continue
var entry: Dictionary = entry_v
var sid: String = String(entry.get("skill_id", ""))
if sid == "":
errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid})
elif pool_ids.has(sid):
errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]})
pool_ids[sid] = true
var w: int = int(entry.get("weight", 0))
if w <= 0 or w > 9999:
errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]})
var cap: int = int(row.get("advantage_per_round_cap", -1))
if cap < 0 or cap > 16:
errs.append({"category": "domain", "message": "%s advantage_per_round_cap 越域 [0,16]" % eid})
@@ -0,0 +1,85 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-16
# 数据配置 SkillTable schema(敌人技能池批——定义层"技能行",敌人系统 回填值;行 = Dictionary,键 skill_id;Rule 4 豁免)。
# 设计权威:docs/proposal-skill-pool.md v1.0 §2.2 字段词汇表。
# 三层对象:技能行(本表,多敌种共享)· 池模板(EnemyTable.pool:{skill_id, weight})· 实例池(单位容器 spawn 快照)。
# 池构成纪律的加载校验在 ConfigDB 跨表通道(每池恰一行普攻、增益 ≤1、skill_id 悬空致命——本表无池视野)。
class_name SkillTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
# 保底 M(provisional 归 难度曲线与Boss):连续 M 拍执行的都是普通攻击 → 下拍必出特殊技
# (特殊技内按权重、不受冷却限制——proposal §2.1 两道闸之二)。全局单旋钮(不分敌种)。
@export var pity_threshold: int = 4
const SKILL_TYPES := ["basic", "damage", "buff"] # 普通攻击 / 伤害 / 增益(封闭枚举——新类型走文档修订)
const MULT_DOMAIN_MIN := 1.1 # damage_mult 域 [1.1, 16] provisional(proposal §2.2)
const MULT_DOMAIN_MAX := 16.0
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "SkillTable 为空"})
return errs
var seen := {}
var has_basic := false
for row in rows:
var sid: String = row.get("skill_id", "")
if sid == "":
errs.append({"category": "domain", "message": "skill_id 缺失/空"})
continue
if seen.has(sid):
errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid})
seen[sid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % sid})
var type: String = row.get("type", "")
if not SKILL_TYPES.has(type):
errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]})
continue
# 冷却域(全类型):施放后连续 N 拍不可摇中
var cd: int = int(row.get("cooldown_beats", -1))
if cd < 0 or cd > 99:
errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]})
# 普通攻击两条域校验锁死特殊性:倍率恒 1、冷却恒 0——想配的就是伤害技(proposal §2.2)
if type == "basic":
has_basic = true
if row.has("damage_mult") and not is_equal_approx(float(row.damage_mult), 1.0):
errs.append({"category": "cross_field", "message": "%s 普攻 damage_mult ≠ 1(禁配——要配就是伤害技): %s" % [sid, str(row.damage_mult)]})
if row.has("cooldown_beats") and cd != 0:
errs.append({"category": "cross_field", "message": "%s 普攻 cooldown_beats ≠ 0(禁配——池恒非空的结构前提): %d" % [sid, cd]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 普攻不带增益字段" % sid})
# 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘)
if type == "damage":
if not row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid})
else:
var m: float = float(row.damage_mult)
if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX:
errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid})
# 增益技:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底
if type == "buff":
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid})
var bd: int = int(row.get("buff_duration", 0))
if bd < 1 or bd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]})
if not has_basic:
errs.append({"category": "structural", "message": "SkillTable 无普攻行(basic)——池构成纪律的结构前提"})
if pity_threshold < 1 or pity_threshold > 99:
errs.append({"category": "domain", "message": "pity_threshold 越域 [1,99]: %d" % pity_threshold})
return errs
func row_by_id(skill_id: String) -> Dictionary:
for row in rows:
if row.get("skill_id", "") == skill_id:
return row
return {}
@@ -0,0 +1 @@
uid://b05eabwqt7yer
@@ -15,8 +15,13 @@ rows = [{
"atk": 7,
"atk_min": 4,
"atk_max": 10,
"heavy_multiplier": 2,
"pattern": ["attack"],
"pool": [{
"skill_id": "attack",
"weight": 9
}, {
"skill_id": "lunge",
"weight": 1
}],
"advantage_per_round_cap": 4,
"armor_type": "light",
"armor_class": 0
@@ -31,8 +36,13 @@ rows = [{
"atk": 5,
"atk_min": 2,
"atk_max": 8,
"heavy_multiplier": 2,
"pattern": ["attack"],
"pool": [{
"skill_id": "attack",
"weight": 9
}, {
"skill_id": "lunge",
"weight": 1
}],
"advantage_per_round_cap": 4,
"armor_type": "light",
"armor_class": 0
@@ -46,8 +56,16 @@ rows = [{
"atk": 9,
"atk_min": 6,
"atk_max": 12,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "charge",
"weight": 2
}, {
"skill_id": "heavy",
"weight": 2
}],
"advantage_per_round_cap": 4,
"armor_type": "ward",
"armor_class": 0
@@ -62,8 +80,16 @@ rows = [{
"atk": 6,
"atk_min": 3,
"atk_max": 9,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "charge",
"weight": 2
}, {
"skill_id": "heavy",
"weight": 2
}],
"advantage_per_round_cap": 4,
"armor_type": "ward",
"armor_class": 0
@@ -77,8 +103,16 @@ rows = [{
"atk": 9,
"atk_min": 6,
"atk_max": 12,
"heavy_multiplier": 2,
"pattern": ["attack", "attack", "charge", "heavy"],
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "harden",
"weight": 2
}],
"advantage_per_round_cap": 4,
"armor_type": "heavy",
"armor_class": 0
@@ -92,8 +126,16 @@ rows = [{
"atk": 7,
"atk_min": 4,
"atk_max": 10,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "charge",
"weight": 2
}, {
"skill_id": "heavy",
"weight": 2
}],
"advantage_per_round_cap": 4,
"armor_type": "ward",
"armor_class": 0
@@ -122,3 +164,11 @@ rows = [{
; 护甲等级掷点批(2026-09-16 用户拍板):armor_class 字段入列(缺省 0 向后兼容)——防守方掷点判定阈值基数,
; 与 armor_type(克制属性轴)两概念严格分离、不做映射。首关六行暂定 0(正式行值归怪物/敌人系统设定批——
; 登记待办:第一关怪物 AC 相对较低,值待怪物设定投值;投值所有权归 难度曲线与Boss)。
; 敌人技能池批(2026-09-16 用户拍板——设计权威 docs/proposal-skill-pool.md v1.0):
; pattern 行为环数组 + heavy_multiplier 整体退役,改池模板 pool = [{skill_id, weight}](技能行本体在 skills.tres;
; 实例池快照与冷却状态在 单位容器 spawn)。建议池(全部 provisional,投值归 难度曲线与Boss;EV 锚 = proposal §2.5):
; 地精 普攻 9/突刺 1(每拍 EV 1.05——突刺倍率 >1 必然抬 EV,+5% 偏移显式登记不硬凑);
; 熊地精 普攻 6/蓄力 2/重击 2(EV 1.00 中性);食人魔酋长 普攻 6/重击 2/硬化 2(EV 1.00 中性;
; 硬化 = Boss 池独占增益技——增益 ≤1 纪律下旧蓄力拍退役不进 Boss 池)。
; 含闸长期速率(冷却/保底)EV 锚按实施批 EV 抽样复核登记(proposal §2.5)。
@@ -22,7 +22,9 @@ effect_pool_map = {
"ambush": "flat_attach",
"enrage": "enemy_output",
"off_guard": "hero_defense",
"sapped": "hero_offense"
"sapped": "hero_offense",
"empower": "enemy_output",
"fortify": "enemy_defense"
}
pool_clamp_lo = -100
pool_clamp_hi = 100
@@ -88,3 +90,8 @@ armor_roll_stream = "armor_roll"
; EV 参考数(按判定式复算):AC10 期望减免 21.25% / AC11 25% / AC12 28.75%;微调旋钮 = bounds/discounts,不动 AC 值。
; effect_pool_map 同批删 defense_up 键(减伤百分比行整体退役、无双轨)——ac_up 是 AC 阈值轴效果,不入任何池;
; off_guard(hero_defense 池)保留不动,池结构与乘区公式零改动。
; 敌人技能池批(2026-09-16 用户拍板):effect_pool_map 增 empower→enemy_output(蓄力技载体——敌方输出 ×2
; 经既有 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] 共用。
@@ -0,0 +1,50 @@
[gd_resource type="Resource" script_class="SkillTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/skill_table.gd" id="1"]
[resource]
script = ExtResource("1")
pity_threshold = 4
rows = [{
"skill_id": "attack",
"display_name": "普通攻击",
"type": "basic",
"cooldown_beats": 0
}, {
"skill_id": "lunge",
"display_name": "突刺",
"type": "damage",
"damage_mult": 1.5,
"cooldown_beats": 2
}, {
"skill_id": "charge",
"display_name": "蓄力",
"type": "buff",
"buff_ref": "empower",
"buff_duration": 2,
"cooldown_beats": 3
}, {
"skill_id": "heavy",
"display_name": "重击",
"type": "damage",
"damage_mult": 2.0,
"cooldown_beats": 3
}, {
"skill_id": "harden",
"display_name": "硬化",
"type": "buff",
"buff_ref": "fortify",
"buff_duration": 2,
"cooldown_beats": 4
}]
; 敌人技能池批(2026-09-16 用户拍板——设计权威 docs/proposal-skill-pool.md v1.0,全值 provisional 投值归 难度曲线与Boss):
; 五行 = 普通攻击 / 突刺(新行:伤害 ×1.5 冷却 2)/ 蓄力(增益技)/ 重击 / 硬化(新行:独占增益技——Boss 专属池载体)。
; 普通攻击两条域校验锁死:倍率恒 1、冷却恒 0(schema 拒绝配置)——池恒非空的结构前提。
; 蓄力 = 纯"攻击力增加"增益技(0 伤害、施加 empower 状态),与重击无任何组合语义(纠偏二——旧"蓄力→猛击"
; 固定配对随行为环退役);增益与倍率同拍只是两层乘法共存(×2 遇 ×2 = ×4)。
; buff_duration = 2 的口径:容器时限纲 = remaining_settlements(先兑付后衰减、授予当轮衰减段即 −1)——
; 施放发生在第 N 结算战斗段,需存活到第 N+1 结算战斗段才兑付("下一拍"),故持续 1 个有效结算 ⇔ 表值 2
; (提案"攻击×2 持续 1 结算"的容器语义落地;有效增益拍恰 1 拍,归 增益状态/难度曲线 复核)。
; 硬化 = 自防增益(fortify 状态 → 敌方防御池),Boss 池独占——增益 ≤1 纪律下旧蓄力拍不进 Boss 池(proposal §2.5)。
; 保底 M = pity_threshold = 4:连续 4 拍执行普攻 → 下拍必出特殊技(特殊技内按权重、不受冷却限制)。
@@ -67,6 +67,24 @@ rows = [{
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}, {
"effect_id": "empower",
"display_name": "蓄力",
"target_side": "enemy",
"magnitude": 100,
"unit": "percent",
"duration_settlements": 2,
"stack_policy": "refresh",
"max_stack": 1
}, {
"effect_id": "fortify",
"display_name": "硬化",
"target_side": "enemy",
"magnitude": 100,
"unit": "percent",
"duration_settlements": 2,
"stack_policy": "refresh",
"max_stack": 1
}]
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发——defense_scan 实测墙轮 39%、
@@ -82,3 +100,10 @@ rows = [{
; unit=ac 新量纲(AC 阈值轴效果,不入 effect_pool_map 任何池),消费面 = 战斗结算掷点判定
; (防守方 AC = 主角表值 armor_class + Σac 型效果层数×幅度);off_guard(失衡 +25 percent,hero_defense 池)
; 保留不动,其余六行零改动——状态表仍七行(一行换装)。
; 敌人技能池批(2026-09-16 用户拍板——增益技新状态行,七行→九行;敌方增益走既有增益状态容器):
; empower(蓄力:敌方攻击 +100 percent,enemy_output 池——蓄力技 buff_ref 载体)与
; fortify(硬化:敌方防御 +100 percent,enemy_defense 池——敌方防御池新词汇载体,Boss 池独占硬化技)。
; 两行 duration = 2 的口径:先兑付后衰减含施放当轮 → 表值 2 ⇔ 有效增益拍 1 拍(施放后下一结算兑付,
; 提案"持续 1 结算"的容器语义落地——详见 skills.tres 批注);refresh ×1 层(冷却闸下不可能自叠加)。
; enemy 侧池语义 = side-wide(registry 既有口径):蓄力 +100 入 enemy_output 与 weaken/enrage 共池 clamp;
; 硬化 +100 入 enemy_defense 新池(乘区消费面 = 战斗结算 主角攻击落地段)。值全部 provisional 归 难度曲线与Boss。
@@ -5,7 +5,7 @@
# 静态无状态——呈现不持久化任何核心句柄。
# E1 封装收口(2026-09-13):核心状态读取统一经 战斗结算.presentation_snapshot() 成文快照面——
# 禁再链式穿透 settlement.enemy_system.hero / settlement.enemy_system.enemies / settlement.status_effects.query_active 一类路径(战斗结算 协作对象已私有化,
# 敌人含死亡残位的投影由快照 enemies[] 承载,含 alive/beat/ring 字段)。
# 敌人含死亡残位的投影由快照 enemies[] 承载,含 alive/pending_*/effects 字段——2026-09-16 敌人技能池批批 2 定稿)。
# 更新触发注记(battle.md Data Requirements):旗/拔/移动成功反馈零事件外发(排雷玩法 发射面封闭)——
# 经 ActionOutcome 返回值命令式驱动;AP/RAP 无推送信号——组合根每次调用返回后重查;
# 增益状态 纯被动容器零信号——状态栏刷新绑 战斗结算 结算编排完成时点。
@@ -5,7 +5,8 @@
# highlight 金框〔flag_hit/clear_reward_h2/perfect_broken〕、击杀标记)。
# Date: 2026-09-12(基础 = 2026-09-11 13 类行协议格式化版;2026-09-15 格面资源批 15→10 类:
# 删 item / consumed_ineffective / use_rejected / window_open / window_skip 五类——可选类道具与窗口下线,
# 战斗结算 行协议清单同步;2026-09-16 护甲等级掷点批:伤害行补掷点读数/档位随行标注 + nat20 金框高亮)
# 战斗结算 行协议清单同步;2026-09-16 护甲等级掷点批:伤害行补掷点读数/档位随行标注 + nat20 金框高亮;
# 同日 敌人技能池批:施放行 = 蓄力行 charge 改型〔增益技 0 伤害施放、具名技能〕)
# 高内聚边界:战斗结算 步骤流 → 玩家文案的全部格式化规则内聚于此;来源文案映射来自 SettlementTable。
class_name BattleLogView
extends PanelContainer
@@ -200,8 +201,9 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
"flag_miss":
var body := "✗ %s%s" % [name_, "(本板完美排雷已破——归因此旗)" if hl == "perfect_broken" else ""]
return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#9a8a72]%s[/color]" % body)
"charge":
return prefix + "[color=#9a8a72]%s%s[/color]" % [name_, suffix]
"cast":
# 施放行(敌人技能池批——蓄力行 charge 改型):增益技 0 伤害施放,具名技能
return prefix + "[color=#b09a72]✦ %s%s[/color]" % [name_, suffix]
"decay":
return prefix + "[color=#8a9ab0]状态衰减 ×%d[/color]" % v
"verdict":
@@ -1,8 +1,9 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(B 区敌人卡 ×1–3——敌像占位、血条、顺位徽标、意图徽标〔常驻:
# 挖掘期全程可见,映射 = enemy_id 恒定、环点 = 拍数直觉编码,敌人系统 数据源 = query_beat 当前拍〕、
# 实现 GDD:design/ux/battle.md(B 区敌人卡 ×1–3——敌像占位、血条、顺位徽标、意图徽标〔常驻:挖掘期全程可见〕、
# 状态槽〔敌方 side 池口径:伏击层数全体共享单值各卡同值——战斗结算 流水经 增益状态 投影〕)。
# Date: 2026-09-12
# Date: 2026-09-12(2026-09-16 敌人技能池批改型:行为环环点拆除〔proposal §2.4〕——
# 意图徽标语义 = 本拍摇定结果的动画占位:普通攻击零表示 / 特殊技类型级提示(攻击系 ⚔ / 增益系 ✦),
# 池与权重不公开(D4/D5);环点行退役,护甲类型 + AC 常驻;敌方增益(蓄力/硬化等)入状态行)
# 敌人死亡呈现(敌人系统 资产下限同等 blocking):血条归零 → 卡体灰化 + 碎裂退场,禁瞬消;
# 卡体保留灰化残位——定长卡槽,死亡动画不回流布局。
# 意图线索映射目录(敌人系统×4① 成文占位——资产定稿归 战斗表现,本处按 enemy_id 程序化占位)。
@@ -10,8 +11,14 @@ class_name EnemyCardView
extends PanelContainer
const COL_INK := Color8(51, 38, 27)
const COL_BEAT := {"attack": Color8(198, 61, 34), "charge": Color8(90, 108, 140), "heavy": Color8(143, 47, 106)}
const BEAT_NAMES := {"attack": "攻击", "charge": "蓄力", "heavy": "重击"}
# 摇定动作呈现映射(敌人技能池批 §2.4 三档可见性):键 = pending_type(快照投影)。
# 普通攻击零表示("—" 中性色——完全不可预测);伤害特殊技 = 攻击系 ⚔(朱红);
# 增益特殊技 = 防御/增益系 ✦(蓝)。nat20 暴击无前置表示(掷点层,与技能重击严格区分)。
const PENDING_STYLE := {
"basic": ["—", Color8(120, 106, 88)],
"damage": ["⚔", Color8(198, 61, 34)],
"buff": ["✦", Color8(46, 110, 142)],
}
# 护甲类型呈现(攻击类型×护甲克制批——可读性前提:玩家须能看出该用穿刺还是魔法打它)
const ARMOR_NAMES := {"light": "轻甲", "ward": "魔法护甲", "heavy": "重甲"}
@@ -19,10 +26,10 @@ var _portrait: Label
var _name_label: Label
var _hp_bar: ProgressBar
var _intent_badge: Label
var _ring: Label
var _armor_line: Label
var _status_label: Label
var _armor := "light" # 护甲类型快照(set_data 写入、set_intent 渲染——环点行常驻标签)
var _armor_class := 0 # 护甲等级快照(2026-09-16 护甲等级掷点批——环点行常驻;与护甲类型两轴分立)
var _armor := "light" # 护甲类型快照(set_data 写入、set_pending 渲染——护甲行常驻标签)
var _armor_class := 0 # 护甲等级快照(2026-09-16 护甲等级掷点批——护甲行常驻;与护甲类型两轴分立)
var _dead := false
@@ -60,13 +67,14 @@ func _init() -> void:
_intent_badge = UiKit.label("意图 —", 13, true)
_intent_badge.add_theme_color_override("font_color", COL_INK)
col.add_child(_intent_badge)
_ring = UiKit.label("", 12, false)
col.add_child(_ring)
_armor_line = UiKit.label("", 12, false)
col.add_child(_armor_line)
_status_label = _fixed_label(30)
col.add_child(_status_label)
# data = 增益状态/敌人系统 投影:{order, display_name, hp, max_hp, enemy_id, beat, ring_size, ring_index, ambush_layers}
# data = 战斗结算 presentation_snapshot 投影:{order, display_name, hp, max_hp, enemy_id,
# pending_skill/pending_type/pending_name(摇定动作三字段), effects[](敌方增益), ambush_layers}
func set_data(d: Dictionary) -> void:
_name_label.text = "%s" % String(d.get("display_name", "敌人"))
_hp_bar.max_value = maxi(1, int(d.get("max_hp", 1)))
@@ -74,9 +82,15 @@ func set_data(d: Dictionary) -> void:
_set_order(int(d.get("order", 1)))
_armor = String(d.get("armor_type", "light"))
_armor_class = int(d.get("armor_class", 0))
set_intent(String(d.get("beat", "")), int(d.get("ring_size", 0)), int(d.get("ring_index", 0)))
set_pending(String(d.get("pending_type", "")), String(d.get("pending_name", "")))
# 状态行:伏击池(flat 共享池,单独通道)+ 敌方增益 percent 状态(蓄力/硬化/削弱/暴走——side-wide)
var parts: Array = []
var ambush: int = int(d.get("ambush_layers", 0))
_status_label.text = "伏击池 ×%d(全体共享)" % ambush if ambush > 0 else ""
if ambush > 0:
parts.append("伏击池 ×%d(全体共享)" % ambush)
for eff in d.get("effects", []):
parts.append("%s ×%d" % [String(eff.get("display_name", "")), int(eff.get("layers", 1))])
_status_label.text = " · ".join(parts)
_portrait.text = _portrait_glyph(String(d.get("enemy_id", "")))
if _dead and int(d.get("hp", 0)) > 0:
_revive()
@@ -88,29 +102,20 @@ func _set_order(i: int) -> void:
_name_label.text = "%s %s" % [String("①②③"[clampi(i - 1, 0, 2)]), base]
# beat = 敌人系统 行为环词表原键(attack/charge/heavy,战斗结算 query_beat 直出——presenter 不翻译);
# 名(BEAT_NAMES)/色(COL_BEAT)/重击标记(▼)渲染映射唯一定义处 = 本类(code-review 2026-09-12:
# 双翻译曾使色相与重击通道永久失效)。信号/入参契约:set_data 每结算行刷新、set_intent 每拍刷新。
func set_intent(beat: String, ring_size: int, ring_index: int) -> void:
# 摇定动作渲染(敌人技能池批——presenter 不翻译,渲染映射唯一定义处归本类〔code-review 2026-09-12 先例〕):
# pending_type = 战斗结算 快照投影(basic/damage/buff;"" = 未摇定/死亡残位)。
# 信号/入参契约:set_data 每结算行刷新、set_pending 每轮开轮刷新(开轮摇号时点)。
func set_pending(ptype: String, pname: String) -> void:
if _dead:
return
var label := String(BEAT_NAMES.get(beat, beat))
# 强度档位编码:重击 = 垂直延伸体量(占位:▼)+ 色相双通道(色弱冗余 = 文字常驻)
var beat_col: Color = COL_BEAT.get(beat, Color8(90, 78, 66))
_intent_badge.add_theme_color_override("font_color", beat_col)
if label == "":
_intent_badge.text = "意图:—"
var style: Array = PENDING_STYLE.get(ptype, ["—", Color8(120, 106, 88)])
_intent_badge.add_theme_color_override("font_color", style[1])
if ptype == "" or ptype == "basic":
_intent_badge.text = "意图:—" # 普通攻击零表示(不可预测——§2.4)
else:
_intent_badge.text = "意图:" + label + ("▼" if beat == "heavy" else "")
# 环点 = 拍数直觉编码(● 已到拍 / ○ 未到)+ 护甲类型标签(克制对策的可读前提——恒常驻非悬停)
# + 护甲等级 AC(掷点折扣阈值——2026-09-16 护甲等级掷点批;与护甲类型两轴分立可读)
if ring_size > 0:
var dots := ""
for i in ring_size:
dots += "●" if i == ring_index else "○"
_ring.text = "%s · AC %d · 环 %s(%d 拍)" % [_armor_tag(_armor), _armor_class, dots, ring_size]
else:
_ring.text = "%s · AC %d" % [_armor_tag(_armor), _armor_class]
_intent_badge.text = "意图:%s %s" % [String(style[0]), pname] # 特殊技类型级占位(纯提醒,不可撤销)
# 护甲行(环点拆除——护甲类型 + AC 恒常驻,克制对策与掷点阈值的可读前提)
_armor_line.text = "%s · AC %d" % [_armor_tag(_armor), _armor_class]
func _armor_tag(armor: String) -> String:
@@ -130,7 +135,7 @@ func mark_dead() -> void:
_portrait.text = "✕"
_intent_badge.text = "—— 击破 ——"
_intent_badge.add_theme_color_override("font_color", Color8(120, 110, 98))
_ring.text = ""
_armor_line.text = ""
_status_label.text = ""
_hp_bar.value = 0
@@ -10,10 +10,11 @@ extends HBoxContainer
const MAX_SLOTS := 4
# 极性/语义映射(数据源 = SettlementTable.effect_pool_map 效果语义 + StatusEffectTable display_name):
# 益 = ▲/◆/▼(金/蓝族);害 = ⚡/⚔/✕/☾(旗朱族)——形状+色相双通道,文字名常驻第三通道。
# 状态表七行全映射(attack_up/ac_up/weaken/ambush/enrage/off_guard/sapped)——HUD框架 图标键契约
# "一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"(2026-09-15 格面资源批补 sapped 键;
# 2026-09-16 护甲等级掷点批:defense_up → ac_up 换装)。
# 益 = ▲/◆/▼/◇(金/蓝族);害 = ⚡/⚔/✕/☾/⇧/▣(旗朱族;⇧/▣ = 敌方增益威胁纹样)——形状+色相双通道,
# 文字名常驻第三通道。状态表九行全映射(attack_up/ac_up/weaken/ambush/enrage/off_guard/sapped/empower/fortify)
# ——HUD框架 图标键契约"一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"
# (2026-09-15 格面资源批补 sapped;2026-09-16 护甲等级掷点批 defense_up → ac_up 换装;
# 同日 敌人技能池批补 empower/fortify 敌方增益两键——敌方增益显示 = 敌卡状态行文本 + 本图标键词汇)。
const EFFECT_ICON := {
"attack_up": ["▲", Color8(176, 122, 20), "益"],
"ac_up": ["◇", Color8(46, 110, 142), "益"],
@@ -22,6 +23,8 @@ const EFFECT_ICON := {
"enrage": ["⚔", Color8(198, 61, 34), "害"],
"off_guard": ["✕", Color8(198, 61, 34), "害"],
"sapped": ["☾", Color8(198, 61, 34), "害"],
"empower": ["⇧", Color8(198, 61, 34), "害"],
"fortify": ["▣", Color8(120, 106, 88), "害"],
}
const EFFECT_SEMANTIC := {
"attack_up": "主角攻击加成(hero_offense 池加算)",
@@ -31,6 +34,8 @@ const EFFECT_SEMANTIC := {
"enrage": "敌方输出加成(enemy_output 池加算——attack 型负向)",
"off_guard": "主角承伤加深(hero_defense 池加算——defense 型负向)",
"sapped": "主角输出降低(hero_offense 池加算——weaken 型负向)",
"empower": "敌方输出 ×2(enemy_output 池加算——蓄力技施放;持续 1 个有效结算)",
"fortify": "敌方防御 +100(enemy_defense 池——硬化技施放,主角攻击折半;持续 1 个有效结算)",
}
const SOURCE_NAMES := {"safe_cell": "安全格", "flag_hit": "标中旗", "mine_hit": "踩雷", "clear_reward": "清图奖励"}
# 未知图标键的占位纹样(HUD框架:占位也要可见,禁静默空图标)
@@ -199,8 +199,8 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
var ticks: Array = []
for row in rows:
var t := String(row.get("type", ""))
if t == "charge":
lines.append("拍=charge(蓄力拍零兑付——设计语义,不读池)")
if t == "cast":
lines.append("拍=cast(施放行零兑付——增益技不读池,技能池批由蓄力行改型)")
elif t == "damage" and String(row.get("source_class", "")) == "enemy_action":
var baseline: int = int(row.get("baseline", 0))
var attach: int = int(row.get("attach", 0))
@@ -224,19 +224,15 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
return {"pd": pd, "pe": pe, "grants": grants, "lines": lines, "ticks": ticks}
# 拍名重建:round r 的已打拍 = 快照 r-1 的敌人 beat 字段(快照取在 advance 之后 = 下一拍预付);
# R1 = 行 pattern 首元素(steps 从 0 起)。快照 enemies 空时(敌死亡轮)回退 "?"——该轮敌已无行动行。
# 拍名重建(敌人技能池批):pending_skill 字段 = 该结算开轮摇定的动作 id(快照取在结算末尾)——
# round r 的拍 = 快照 r 的 enemies[0].pending_skill。快照 enemies 空时(敌死亡轮)回退 "?"——该轮敌已无行动行。
func _played_beats(run: Dictionary, db) -> Dictionary:
var beats := {}
var erow: Dictionary = db.resolve_enemy_rowset(1, 4)
var pat: Array = erow.get("pattern", [])
if pat.size() > 0:
beats[1] = String(pat[0])
for r in range(2, run.snaps.size() + 1):
var s: Dictionary = run.snaps[r - 2]
for r in range(1, run.snaps.size() + 1):
var s: Dictionary = run.snaps[r - 1]
var en: Array = s.get("enemies", [])
if en.size() > 0:
beats[r] = String(en[0].get("beat", "?"))
beats[r] = String(en[0].get("pending_skill", "?"))
return beats
@@ -39,6 +39,7 @@ func _initialize() -> void:
check(rng.derive_at("reward_roll", 0) == 7422916049646135537, "R2 向量 reward_roll m=42 scope=0")
check(rng.derive_at("encounter", 0) == 1061841193803499209, "R2 向量 encounter m=42 scope=0(registry salt_encounter=0x8EBC6AF09C88C6E3 权威值)")
check(rng.derive_at("armor_roll", 0) == 4806095330042243845, "R2 向量 armor_roll m=42 scope=0(护甲等级掷点批 2026-09-16——registry salt_armor_roll=0xC2B2AE3D27D4EB2F 权威值)")
check(rng.derive_at("behavior_roll", 0) == 426657203655691839, "R2 向量 behavior_roll m=42 scope=0(敌人技能池批 2026-09-16——registry salt_behavior_roll=0x85EBCA7758C24E15 权威值)")
check(rng.derive_at("board_gen", 0) == rng.derive_at("board_gen", 0), "derive_at 纯函数恒等")
# scope 取号语义:首个 open_stream = scope 0;计数器单调
var s0 = rng.open_stream("board_gen")
@@ -83,6 +84,7 @@ func _run_core_smoke(db, rng) -> void:
_smoke_negative_dictionary()
_smoke_resource_surface()
_smoke_settlement_segments()
_smoke_skill_pool()
# ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)----
@@ -346,7 +348,8 @@ func _first_hero_attack_rows(seed_val: int) -> Dictionary:
return {}
# 削弱 ×2(池 enemy_output 触底 ×0)下的敌方攻击伤害行采样(D2 熊地精——首拍攻击)
# 削弱 ×2(池 enemy_output 触底 ×0)下的敌方攻击伤害行采样(D2 熊地精)。
# 敌人技能池批:开轮摇号下首拍可能是施放行(零伤害)——逐轮重授削弱并停手,直到采到伤害行为止。
func _weaken_zero_round_rows() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260916)
@@ -356,36 +359,47 @@ func _weaken_zero_round_rows() -> Array:
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return rows
# 测试通道:直授削弱 ×2(来源格取真实坐标——行来源断言要求;时效 1 当轮结算内有效)
for i in range(2):
settlement._status_effects.apply_effect("weaken", {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": 1})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var out: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
out.append(row)
var guard := 8
while out.is_empty() and guard > 0 and settlement.battle_result == "":
guard -= 1
rows.clear()
for i in range(2):
settlement._status_effects.apply_effect("weaken", {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
out.append(row)
return out
# AC12 折扣档采样:每停手轮前直授 ac_up ×2(时效 1 逐轮衰减)——Boss 行攻击/重击拍逐拍判定留档
# AC12 折扣档采样:每停手轮前直授 ac_up ×2(时效 1 逐轮衰减)——敌拍逐拍判定留档。
# 敌人技能池批:拍序改为摇号制,原 AC 批定影种子不再保证档位多样性——扫描种子取首个 ≥2 档样本集。
func _ac12_tier_samples() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return []
var guard := 12
while settlement.battle_result == "" and guard > 0:
guard -= 1
for i in range(2):
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var tiers: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
tiers.append(int(row.get("tier", -1)))
return tiers
for seed_offset in range(20):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917 + seed_offset)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
continue
var guard := 12
while settlement.battle_result == "" and guard > 0:
guard -= 1
for i in range(2):
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var tiers: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
tiers.append(int(row.get("tier", -1)))
var distinct := {}
for t in tiers:
distinct[int(t)] = true
if tiers.size() >= 3 and distinct.size() >= 2:
return tiers
return []
func _smoke_board_generator() -> void:
@@ -960,12 +974,12 @@ func _smoke_attack_chain_fresh_pool() -> void:
ticks.append({"baseline": int(row.baseline), "attach": int(row.attach), "roll": int(row.roll),
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss pattern 前 4 拍 = attack/attack/charge/heavy
for i in range(4):
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计)
for i in range(6):
if settlement.battle_result != "":
break
settlement.minesweeper.settle_batch([{"action": "stop"}])
check(ticks.size() >= 2, "非蓄力攻击拍采样充足(%d 拍——charge 拍零兑付为设计语义不计)" % ticks.size())
check(ticks.size() >= 2, "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size())
var all_full := true
for t in ticks:
var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0)
@@ -1383,7 +1397,7 @@ func _row_phase(row: Dictionary, ri: int) -> int:
return 3
"verdict":
return 4
"charge":
"cast":
return 2
"damage":
if String(row.get("source_class", "")) in ["hero_attack", "enemy_action"]:
@@ -1473,3 +1487,285 @@ func check_silent(cond: bool) -> void:
if not cond:
_failures += 1
print(" [FAIL] (silent check)")
# ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ====================
# 六项验收断言(docs/proposal-skill-pool.md §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 /
# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。
func _smoke_skill_pool() -> void:
print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---")
_smoke_skill_pool_data()
_smoke_skill_pool_replay_independence()
_smoke_skill_pool_ev_anchor()
_smoke_skill_pool_death_void()
_smoke_skill_pool_validation_reject()
# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言)
func _smoke_skill_pool_data() -> void:
check(_db.skill.rows.size() == 5, "技能表五行(普攻/突刺/蓄力/重击/硬化)")
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)),
"普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)")
var lunge: Dictionary = _db.skill.row_by_id("lunge")
check(not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2,
"突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)")
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 expect_pools := {
"goblin": {"attack": 9, "lunge": 1},
"bugbear": {"attack": 6, "charge": 2, "heavy": 2},
"ogre_chief": {"attack": 6, "heavy": 2, "harden": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
var eid := String(row.enemy_id)
if not expect_pools.has(eid):
pools_ok = false
continue
var got := {}
for entry in row.pool:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[eid]:
pools_ok = false
check(pools_ok, "六行池模板 = 建议池行值(地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2——全部 provisional)")
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
if not srow.is_empty() and String(srow.type) == "basic":
n += 1
if n != 1:
basic_count_ok = false
check(basic_count_ok, "每池恰一行普攻(空池不可能的加载校验同款)")
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)
for u in es.all_enemies():
if es.rollable_skills(u).is_empty():
rollable_ok = false
check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言)")
# 状态表新行 + 敌方防御池收编
var empower: Dictionary = _db.status_effect_row("empower")
var fortify: Dictionary = _db.status_effect_row("fortify")
check(not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100,
"蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)")
check(not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == 100,
"硬化状态行 fortify(enemy +100 percent——敌方防御池新词汇载体)")
check(String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \
and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense",
"effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)")
# ② 同种子回放逐位复现 + ③ 单敌轨迹独立复放(D3 熊地精双雄——多实例同种)
func _smoke_skill_pool_replay_independence() -> void:
var seed_val := -1
for s in range(1, 60):
var seqs := _record_pool_trajectories(s, 1)
if not seqs.is_empty() and int(seqs.count) >= 2:
seed_val = s
break
check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val)
var rounds := 10
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),
"同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例)" % [rounds, int(run1.count)])
var indep_ok := true
var detail := ""
var units: Array = _db.resolve_enemy_rowset(1, 3).pool
for i in range(int(run1.count)):
var replayed := _replay_single_trajectory(seed_val, units, rounds, i)
if JSON.stringify(replayed) != JSON.stringify(run1.seqs[i]):
indep_ok = false
detail += " #%d" % i
check(indep_ok, "单敌轨迹独立复放(只喂该敌子流 scope=spawn_index×%d+序 即可逐位重建%s——互不穿插)" % [load("res://core/rng_service.gd").BEHAVIOR_SUBSTREAM_STRIDE, detail])
# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数)
func _record_pool_trajectories(seed_val: int, rounds: int) -> Dictionary:
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)
if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}):
return {}
var seqs: Array = []
for u in settlement._enemy_system.all_enemies():
seqs.append([])
for r in rounds:
var handles: Array = settlement._enemy_system.all_enemies()
for i in handles.size():
seqs[i].append(settlement._enemy_system.pending_action(handles[i]))
settlement.minesweeper.settle_batch([{"action": "stop"}])
if settlement.battle_result != "":
return {}
return {"count": seqs.size(), "seqs": seqs}
# 独立重放器(镜像 开轮摇号 + 容器冷却/保底规则——只消费目标实例自己的子流 scope 段)
func _replay_single_trajectory(seed_val: int, pool_template: Array, rounds: int, spawn_index: int) -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var stride: int = load("res://core/rng_service.gd").BEHAVIOR_SUBSTREAM_STRIDE
var pool: Array = []
for entry in pool_template:
pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0})
var streak := 0
var pity_m: int = int(_db.skill.pity_threshold)
var out: Array = []
for k in rounds:
var entries: Array = []
for e in pool:
if int(e.cooldown_left) <= 0:
entries.append({"skill_id": e.skill_id, "weight": e.weight})
if streak >= pity_m:
var specials: Array = []
for e in pool:
var sr: Dictionary = _db.skill_row(String(e.skill_id))
if not sr.is_empty() and String(sr.type) != "basic":
specials.append({"skill_id": e.skill_id, "weight": e.weight})
if not specials.is_empty():
entries = specials
var sid := SkillLottery.draw(entries, rng.open_scope("behavior_roll", spawn_index * stride + k))
out.append(sid)
var srow: Dictionary = _db.skill_row(sid)
for e in pool:
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1) # 开轮递减(摇号读值之后——与容器同序)
if String(e.skill_id) == sid:
e.cooldown_left = int(srow.cooldown_beats) # 执行上报置冷却(战斗段时点)
streak = streak + 1 if String(srow.type) == "basic" else 0
return out
# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。
# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带;
# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。
func _smoke_skill_pool_ev_anchor() -> void:
var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [0.97, 1.03]}
var species := ["goblin", "bugbear", "ogre_chief"]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260921)
var stride: int = load("res://core/rng_service.gd").BEHAVIOR_SUBSTREAM_STRIDE
var n_beats := 5000
var pity_m: int = int(_db.skill.pity_threshold)
for si in species.size():
var eid: String = species[si]
var template: Array = []
for row in _db.enemy.rows:
if String(row.enemy_id) == eid:
template = row.pool
break
var pool: Array = []
for entry in template:
pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0})
var streak := 0
var ev_sum := 0.0
for k in n_beats:
var entries: Array = []
for e in pool:
if int(e.cooldown_left) <= 0:
entries.append({"skill_id": e.skill_id, "weight": e.weight})
if streak >= pity_m:
var specials: Array = []
for e in pool:
var sr: Dictionary = _db.skill_row(String(e.skill_id))
if not sr.is_empty() and String(sr.type) != "basic":
specials.append({"skill_id": e.skill_id, "weight": e.weight})
if not specials.is_empty():
entries = specials
var sid := SkillLottery.draw(entries, rng.open_scope("behavior_roll", si * stride + k))
var srow: Dictionary = _db.skill_row(sid)
var mult := 0.0
if String(srow.type) == "basic":
mult = 1.0
elif String(srow.type) == "damage":
mult = float(srow.get("damage_mult", 1.0))
ev_sum += mult
for e in pool:
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1)
if String(e.skill_id) == sid:
e.cooldown_left = int(srow.cooldown_beats)
streak = streak + 1 if String(srow.type) == "basic" else 0
var measured: float = ev_sum / float(n_beats)
var band: Array = bands[eid]
var in_band: bool = measured >= float(band[0]) and measured <= float(band[1])
check(in_band, "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats])
# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账)
func _smoke_skill_pool_death_void() -> void:
var void_seed := -1
for s in range(1, 400):
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var erow: Dictionary = _db.resolve_enemy_rowset(1, 2)
var entries: Array = []
for entry in erow.pool:
entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)})
var sid := SkillLottery.draw(entries, rg.open_scope("behavior_roll", 0))
if String(_db.skill_row(sid).type) != "basic":
void_seed = s
break
check(void_seed > 0, "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(void_seed)
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}),
"死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)")
var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读)
var pending: String = settlement._enemy_system.pending_action(u)
check(pending != "", "开轮已摇定本拍动作(待发 = %s)" % pending)
settlement.minesweeper.settle_batch([{"action": "stop"}])
var enemy_rows := 0
for row in rows:
if String(row.get("source_class", "")) == "enemy_action":
enemy_rows += 1
check(enemy_rows == 0, "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)")
var cd_sum := 0
for e in u.pool:
cd_sum += int(e.cooldown_left)
check(cd_sum == 0 and int(u.basic_streak) == 0,
"作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)")
# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律)
func _smoke_skill_pool_validation_reject() -> void:
var skill_table = load("res://data/schemas/skill_table.gd").new()
var enemy_table = load("res://data/schemas/enemy_table.gd").new()
enemy_table.rows = [{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}]
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"),
"加载校验拒绝:池条目 skill_id 悬空致命")
skill_table.rows = [{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}]
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)")
skill_table.rows = [
{"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3},
{"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}]
enemy_table.rows = [{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}]
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)")
skill_table.rows = [{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}]
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)")
skill_table.rows = [{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}]
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)")
skill_table.rows = [{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}]
check(not skill_table.validate().is_empty(), "加载校验拒绝:damage_mult 越域 [1.1,16]")
var et2 = load("res://data/schemas/enemy_table.gd").new()
et2.rows = [{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}]
check(not et2.validate().is_empty(), "加载校验拒绝:池权重 ≤ 0")
et2.rows = [{"enemy_id": "x", "display_name": "x", "pool": []}]
check(not et2.validate().is_empty(), "加载校验拒绝:空池(禁)")
func _has_err_category(errs: Array, category: String) -> bool:
for e in errs:
if String(e.get("category", "")) == category:
return true
return false