diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 7cc514f7..3f715f65 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -118,12 +118,9 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: # proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。 # status_table 显式入参:装载序上本校验先于成员赋值执行(self.status_effects 尚为 Nil)——装载通道传 loaded 表, # 冒烟直验传 _db.status_effects;传 null 时 side 守卫跳过(其他校验不受影响)。 -func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array: +# 施放类 side 守卫(buff → 敌方侧状态 / debuff → 主角侧状态,防反向挂接) +func _pool_cast_side_errs(skill_table: SkillTable, status_table: StatusEffectTable) -> Array[Dictionary]: var errs: Array[Dictionary] = [] - var skill_types := {} - for row in skill_table.rows: - skill_types[String(row.get("skill_id", ""))] = String(row.get("type", "")) - # 施放类 side 守卫:buff → 敌方侧状态 / debuff → 主角侧状态,防反向挂接 for row in skill_table.rows: var st: String = String(row.get("type", "")) if st != "buff" and st != "debuff": @@ -133,6 +130,15 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, var srow: Dictionary[Variant, Variant] = status_table.row_by_id(eff) if status_table != null else {} if not srow.is_empty() and String(srow.get("target_side", "")) != want_side: errs.append({"category": "cast_side", "message": "SkillTable %s(%s)引用 %s 的 target_side=%s ≠ %s(施放类 side 守卫)" % [String(row.get("skill_id", "")), st, eff, String(srow.get("target_side", "")), want_side]}) + return errs + + +func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array: + var errs: Array[Dictionary] = [] + var skill_types := {} + for row in skill_table.rows: + skill_types[String(row.get("skill_id", ""))] = String(row.get("type", "")) + errs.append_array(_pool_cast_side_errs(skill_table, status_table)) # 施放类 side 守卫(buff→敌方侧/debuff→主角侧,防反向挂接——全表恰一次) for row in enemy_table.rows: var eid: String = String(row.get("enemy_id", "")) var basic_n := 0 diff --git a/game/core/curio_deck.gd b/game/core/curio_deck.gd index 47257bbc..c69c4092 100644 --- a/game/core/curio_deck.gd +++ b/game/core/curio_deck.gd @@ -106,7 +106,7 @@ func cap_ok(kind: String, feat_id: String = "") -> bool: return int(taken.get(kind, 0)) < cap -# 专长凭证展示判定(四口径:一局限购一次/罕见只在概率不在价格/计步保底/触发清零。 +# 专长凭证展示判定(四口径:一局限购一次/稀有只在概率不在价格/计步保底/触发清零。 # 调用即一次展示判定与记账——展示次数计 pity、配额与限购双闸) func feat_ticket_consider(rg: RandomNumberGenerator) -> bool: if ticket_sold >= int(db.adventure.feat_ticket_limit): diff --git a/game/core/effect_context.gd b/game/core/effect_context.gd index e0fd9361..24dd509d 100644 --- a/game/core/effect_context.gd +++ b/game/core/effect_context.gd @@ -1,6 +1,6 @@ ## EffectContext——效果引擎四桶计数器引用束(无状态引擎的外部状态承载)。 ## 四桶按生命周期分:round(轮始整桶清空)/battle(随编排器生灭)/level(随 LevelRunner 生灭)/ -## adventure(随冒险应用层生灭——拆件批桶归宿主字典)。本类零方法零行为,只持引用;桶容器由宿主对象 new,本类只挂引用。 +## adventure(随冒险应用层生灭——桶归宿主字典)。本类零方法零行为,只持引用;桶容器由宿主对象 new,本类只挂引用。 class_name EffectContext extends RefCounted diff --git a/game/core/unit_container.gd b/game/core/unit_container.gd index 50fc639a..066f5ca0 100644 --- a/game/core/unit_container.gd +++ b/game/core/unit_container.gd @@ -80,22 +80,7 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides var eovr: int = int(enemy_atk_overrides[row_i]) if row_i < enemy_atk_overrides.size() else -1 for i in range(per_row_counts[row_i]): var eff_row_atk: int = eovr if eovr >= 0 else int(row.atk) - # 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条快照纪律) - var pool_snap: Array[Dictionary] = [] - for entry in row.get("pool", []): - var snap_entry := { - "skill_id": String(entry.get("skill_id", "")), - "weight": int(entry.get("weight", 0)), - "cooldown_left": 0, - } - # 触发条件(拆件批随迁:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记 - # trigger_latched 初始锁定;缺省无 trigger=无条件技) - var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", ""))) - if srow_snap.has("trigger"): - snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true) - if bool((snap_entry["trigger"] as Dictionary).get("latch", false)): - snap_entry["trigger_latched"] = false - pool_snap.append(snap_entry) + var pool_snap: Array[Dictionary] = _pool_snapshot(row) # 实例池快照(开战模板快照——纪律见助手头注) _enemies.append({ "handle_id": handle, "enemy_id": String(row.enemy_id), @@ -119,6 +104,26 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides return true +# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条 +# 快照纪律。触发条件:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记 +# trigger_latched 初始锁定;缺省无 trigger=无条件技) +func _pool_snapshot(row: Dictionary) -> Array[Dictionary]: + var pool_snap: Array[Dictionary] = [] + for entry in row.get("pool", []): + var snap_entry := { + "skill_id": String(entry.get("skill_id", "")), + "weight": int(entry.get("weight", 0)), + "cooldown_left": 0, + } + var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", ""))) + if srow_snap.has("trigger"): + snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true) + if bool((snap_entry["trigger"] as Dictionary).get("latch", false)): + snap_entry["trigger_latched"] = false + pool_snap.append(snap_entry) + return pool_snap + + func query_alive(side: String) -> Array: if _despawned: _diag("Despawned 句柄查询(域闭)") @@ -142,20 +147,12 @@ func _entry_blocked(e: Dictionary, u: Dictionary, facts: Dictionary) -> bool: return false if e.has("trigger_latched") and bool(e.get("trigger_latched", false)): return false # 闩已过——不再拦(治疗抬血不回锁) - var t: Dictionary = e.get("trigger", {}) + var t: Dictionary[Variant, Variant] = e.get("trigger", {}) var fact := String(t.get("fact", "")) var op := String(t.get("op", "")) var value: Variant = t.get("value", 0) - # 自类百分比事实(容器内直读——阈值式折算) - if fact == "self_hp_pct": - var threshold: int = int(round(float(int(u.max_hp)) * float(int(value)) / 100.0)) - return not _compare(int(u.hp), op, threshold) - if fact == "hero_hp_pct": - var hrow: Dictionary[Variant, Variant] = _hero - if hrow.is_empty(): - return true # 无主角行——条件假(fail-safe) - var hthreshold: int = int(round(float(int(hrow.max_hp)) * float(int(value)) / 100.0)) - return not _compare(int(hrow.hp), op, hthreshold) + if fact == "self_hp_pct" or fact == "hero_hp_pct": + return _pct_fact_blocked(fact, value, op, u) # 自类/主角百分比事实(阈值式折算——v1.2) # 全局事实(编排器组包——缺键=拦) if not facts.has(fact): return true @@ -184,6 +181,20 @@ func _compare_str(lhs: String, op: String, rhs: String) -> bool: # 可摇集合:冷却已尽的池条目投影 [{skill_id, weight}]。普通攻击冷却恒 0 → 恒非空(空池不可能的结构前提, # 加载校验"每池恰一行普攻"兜底)。hero/失效句柄拒绝。锁定条目(半血未解锁)双面过滤不入。 +# 百分比事实阈值判定(阈值式 v1.2:先折算绝对阈值 T=round(对应 max_hp × value/100)、 +# 再以 hp op T 比较——六算子统一在该阈值上定义。self=容器内直读单位行;hero=主角行, +# 无主角行=条件假(拦——fail-safe)) +func _pct_fact_blocked(fact: String, value: Variant, op: String, u: Dictionary) -> bool: + if fact == "self_hp_pct": + var threshold: int = int(round(float(int(u.max_hp)) * float(int(value)) / 100.0)) + return not _compare(int(u.hp), op, threshold) + var hrow: Dictionary[Variant, Variant] = _hero + if hrow.is_empty(): + return true # 无主角行——条件假(fail-safe) + var hthreshold: int = int(round(float(int(hrow.max_hp)) * float(int(value)) / 100.0)) + return not _compare(int(hrow.hp), op, hthreshold) + + func rollable_skills(u: Dictionary, facts: Dictionary = {}) -> Array: if u.is_empty() or _despawned or u.get("is_hero", false): _diag("rollable_skills 失效句柄") diff --git a/game/data/schemas/skill_table.gd b/game/data/schemas/skill_table.gd index 2150c77e..335d9664 100644 --- a/game/data/schemas/skill_table.gd +++ b/game/data/schemas/skill_table.gd @@ -44,36 +44,7 @@ func validate() -> Array: if not SKILL_TYPES.has(type): errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]}) continue - # trigger(触发条件,可省略——敌人系统 v1.2:四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) - if row.has("trigger"): - var trig: Variant = row.get("trigger", null) - if not (trig is Dictionary): - errs.append({"category": "domain", "message": "%s trigger 须为 {fact,op,value,latch} 字典" % sid}) - else: - var t: Dictionary = trig - for tk in t.keys(): - if not TRIGGER_KEYS.has(String(tk)): - errs.append({"category": "domain", "message": "%s trigger 键白名单外: %s" % [sid, str(tk)]}) - var tf := String(t.get("fact", "")) - var top := String(t.get("op", "")) - if not TRIGGER_FACTS.has(tf): - errs.append({"category": "enum", "message": "%s trigger fact 出域: %s" % [sid, tf]}) - if not TRIGGER_OPS.has(top): - errs.append({"category": "enum", "message": "%s trigger op 出域: %s" % [sid, top]}) - if TRIGGER_ENUM_FACTS.has(tf) and not (top == "eq" or top == "neq"): - errs.append({"category": "domain", "message": "%s trigger 枚举事实 %s 算子限域 eq/neq: %s" % [sid, tf, top]}) - if tf != "" and TRIGGER_ENUM_FACTS.has(tf): - var ev = t.get("value", "") - if not (ev is String) or not TRIGGER_ENUM_VALUES.has(String(ev)): - errs.append({"category": "domain", "message": "%s trigger 枚举事实 value 越域: %s(域 %s)" % [sid, str(ev), str(TRIGGER_ENUM_VALUES)]}) - elif TRIGGER_PCT_FACTS.has(tf): - var pv := int(t.get("value", 0)) - if pv < 1 or pv > 99: - errs.append({"category": "domain", "message": "%s trigger 百分比事实 value 越域 [1,99]: %d" % [sid, pv]}) - elif tf != "" and not (t.get("value", 0) is int): - errs.append({"category": "domain", "message": "%s trigger 数值事实 value 须整数: %s" % [sid, str(t.get("value", 0))]}) - if t.has("latch") and not (t.get("latch", false) is bool): - errs.append({"category": "domain", "message": "%s trigger latch 须布尔: %s" % [sid, str(t.get("latch", false))]}) + _validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) if type == "basic" and row.has("trigger"): errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid}) # 冷却域(全类型):施放后连续 N 拍不可摇中 @@ -83,14 +54,7 @@ func validate() -> Array: # 普通攻击两条域校验锁死特殊性:倍率恒 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 row.has("rider_mult"): - errs.append({"category": "cross_field", "message": "%s 普攻不带必中附加分量(rider_mult 仅伤害技)" % sid}) + _validate_basic_row(sid, row, cd, errs) # 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘); # 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2) if type == "damage": @@ -136,6 +100,64 @@ func validate() -> Array: return errs +# trigger 域校验(敌人系统 v1.2 触发条件:四键白名单/词表出域/类型分派——枚举事实算子限域 eq/neq +# 且 value 限枚举串、百分比事实 value 域 [1,99]、数值事实 value 须整数、latch 须布尔。行缺 trigger +# 直返——可省略字段) +func _validate_trigger(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if not row.has("trigger"): + return + var trig: Variant = row.get("trigger", null) + if not (trig is Dictionary): + errs.append({"category": "domain", "message": "%s trigger 须为 {fact,op,value,latch} 字典" % sid}) + return + var t: Dictionary[Variant, Variant] = trig + for tk in t.keys(): + if not TRIGGER_KEYS.has(String(tk)): + errs.append({"category": "domain", "message": "%s trigger 键白名单外: %s" % [sid, str(tk)]}) + _validate_trigger_domain(t, sid, errs) + + +# trigger fact/op/value/latch 域校验(词表出域与类型分派——报错顺序=报告顺序,分派段单列) +func _validate_trigger_domain(t: Dictionary[Variant, Variant], sid: String, errs: Array[Dictionary]) -> void: + var tf := String(t.get("fact", "")) + var top := String(t.get("op", "")) + if not TRIGGER_FACTS.has(tf): + errs.append({"category": "enum", "message": "%s trigger fact 出域: %s" % [sid, tf]}) + if not TRIGGER_OPS.has(top): + errs.append({"category": "enum", "message": "%s trigger op 出域: %s" % [sid, top]}) + if TRIGGER_ENUM_FACTS.has(tf) and not (top == "eq" or top == "neq"): + errs.append({"category": "domain", "message": "%s trigger 枚举事实 %s 算子限域 eq/neq: %s" % [sid, tf, top]}) + _validate_trigger_value(sid, t, tf, errs) + if t.has("latch") and not (t.get("latch", false) is bool): + errs.append({"category": "domain", "message": "%s trigger latch 须布尔: %s" % [sid, str(t.get("latch", false))]}) + + +# trigger value 类型分派校验(枚举事实 value 限枚举串/百分比事实 value 域 [1,99]/数值事实 value 须整数) +func _validate_trigger_value(sid: String, t: Dictionary[Variant, Variant], tf: String, errs: Array[Dictionary]) -> void: + if tf != "" and TRIGGER_ENUM_FACTS.has(tf): + var ev = t.get("value", "") + if not (ev is String) or not TRIGGER_ENUM_VALUES.has(String(ev)): + errs.append({"category": "domain", "message": "%s trigger 枚举事实 value 越域: %s(域 %s)" % [sid, str(ev), str(TRIGGER_ENUM_VALUES)]}) + elif TRIGGER_PCT_FACTS.has(tf): + var pv := int(t.get("value", 0)) + if pv < 1 or pv > 99: + errs.append({"category": "domain", "message": "%s trigger 百分比事实 value 越域 [1,99]: %d" % [sid, pv]}) + elif tf != "" and not (t.get("value", 0) is int): + errs.append({"category": "domain", "message": "%s trigger 数值事实 value 须整数: %s" % [sid, str(t.get("value", 0))]}) + + +# 普攻行禁配校验(倍率恒 1、冷却恒 0、不带增益/rider 字段——「池恒非空」的结构前提) +func _validate_basic_row(sid: String, row: Dictionary, cd: int, errs: Array[Dictionary]) -> void: + 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 row.has("rider_mult"): + errs.append({"category": "cross_field", "message": "%s 普攻不带必中附加分量(rider_mult 仅伤害技)" % sid}) + + func row_by_id(skill_id: String) -> Dictionary: for row in rows: if row.get("skill_id", "") == skill_id: diff --git a/game/data/tables/enemy.tres b/game/data/tables/enemy.tres index 9581a20e..2e549a1b 100644 --- a/game/data/tables/enemy.tres +++ b/game/data/tables/enemy.tres @@ -755,7 +755,7 @@ rows = [{ }] ; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照): -; 试玩反馈 = 基线满血全通关(模拟 80%/真人 100%)——难度严重不足,主杠杆 = atk 曲线上调(承伤侧): +; 试玩反馈 = 基线满血全冒险成功(模拟 80%/真人 100%)——难度严重不足,主杠杆 = atk 曲线上调(承伤侧): ; 4/4/6/4/7 → 7/5/9/6/9(约 +50%);HP 结构调整(D0 24→20 缩短战斗、Boss 130→150 用户拍板可上调)。 ; D0 注:20 HP 会被首轮盲挖粉尘秒杀(12+9)——回 24(4 轮战斗/承伤 42%,有过程有痛感)。 ; DND 命名(用户指令):goblin 地精(单拍杂鱼)→ bugbear 熊地精(三拍伏击环——DND 原型即伏击手)→ @@ -895,7 +895,7 @@ rows = [{ ; ; 数值修复批·调参二(2026-10-01 新尺 MC 驱动):L1 系八行 atk 2→1(行 15/35/54/77/99/121/144/166; ; 行 234 巨蜘蛛巢群主体=L2 域维持)——新尺读数:普通档 v3.4 死因=L1 四连战无治疗夹缝(雷伤 6/8/8 -; + 杂兵垫刀双压,L1 条件通关 1-2%,0/100 出第一关);完美档战役 50%(L1 93%)。刀位语义= +; + 杂兵垫刀双压,L1 条件关卡完成率 1-2%,0/100 出第一关);完美档战役 50%(L1 93%)。刀位语义= ; 「危险来自雷,不来自杂兵」:杂兵垫刀减半,雷直伤 6/8/8 维持痛感;L2/L3(atk 3 系)本刀不动。 ; 全旧带读数随新尺作废重测归本批。 ; 调参二补充:上述八行 atk 区间 1-3→0-2(中点恒等期望当量锚不变式——atk=1 需区间中点同步 1;0=掷点空刀,保随机手感)。 diff --git a/game/data/tables/status_effects.tres b/game/data/tables/status_effects.tres index 2787ec55..91f46885 100644 --- a/game/data/tables/status_effects.tres +++ b/game/data/tables/status_effects.tres @@ -97,7 +97,7 @@ rows = [{ }] ; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发——defense_scan 实测墙轮 39%、 -; 真人局 12/12 轮零承伤满血通关):defense_up magnitude -50 → -30——叠满两层 = -60 减伤帽 +; 真人局 12/12 轮零承伤满血冒险成功):defense_up magnitude -50 → -30——叠满两层 = -60 减伤帽 ; (原拟 pool_clamp_lo=-60 被 schema 冻结域拦截,杠杆移至本表);石肤权重 24→16 随批(reward.tres)。 ; 承伤带对 C3 F4(≤20%/普通场、≤50%/Boss 场)复验责任随批;B3 GDD "-50" 文字同步随下次 B3 修订批。 ; 格面资源批(2026-09-15):新增 sapped(乏力——weaken 型负向,hero_offense 池,attack_up 镜像; diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index 6bf19d7a..bae08513 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -572,7 +572,7 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void # ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错, # 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × NORMAL_MINE_SLIP_P(目标完美+2/板) # ②安全挖跳过:把「挖已证安全格」动作转成本轮停手(少挖几个正确的雷——信息与推进双损失) -# ③卡死赌挖:完美档无已证动作时刷新/停手拖轮次吃敌方垫刀(实证=完美档 41% 通关全败于慢玩 +# ③卡死赌挖:完美档无已证动作时刷新/停手拖轮次吃敌方垫刀(实证=完美档 41% 冒险成功;全败于慢玩 # 垫刀非踩雷)——真人会按概率赌一个边界格(用户实证口径「有些地方因为我看不到数字,也是盲挖 # 的」);普通档不刷新(用户自报全程没用刷新),赌挖替代刷新/停手 # 失误流 = st.prng(策略流 RNG,种子派生自局种子——同种子逐位可回放;核心七流零消费零扰动)