Files
gplan-2/game/core/effect_engine.gd
T

456 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 效果引擎:内容行 on/when/do 的发布订阅解释器——加一件「已有效果类型」的专长/秘宝只写 .tres 数据行零代码(垂直切片验证:内容数据驱动可复现)。
## 全同步调用、零 RNG、零信号时序依赖;命中序=装载声明序(feats→treasure→树规则)写死保种子复放。
## 事件/效果/事实/算子四词表封闭在本文件常量——新词先登记,行内出域=加载期校验致命、dispatch 未知词 fail-fast。
class_name EffectEngine
extends RefCounted
## 事件词汇(封闭枚举——dig_stop 停手挂点〔弃图兑换〕、battle_won 战斗胜利〔战地包扎〕、
## seal_missed 标错判定〔危险直觉·守挂点——_judge_seal 去重闸后发射,契约 12 触发主语=
## 当回合标错动作;命名避让 _emit_row 在册 source_class 名 seal_miss〕、board_installed
## 装板完成〔巢穴预告——每板恰一次,战斗板生成/重建发射点〕)
const EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "attack_commit", "clamp_check",
"armor_roll", "kill", "damage_taken", "enemy_hp_threshold", "round_start", "seal_settle",
"round_end", "reward_roll", "treasure_used", "dig_stop", "battle_won", "seal_missed",
"board_installed",
# 换图系双事件(refresh_request=玩家请求刷新〔省力周转 apply_discount 订阅面——折扣只改门槛
# 与扣费不改拒绝判据〕;board_refreshed=成功刷新事实〔换图系七件订阅主挂点——装新板后才发射〕)
"refresh_request", "board_refreshed"]
## 扫雷侧事件集(🔴 流派扫雷触发铁律新形态:每流派 ≥1 行订阅本集事件——schema 加载校验。
## 原 MINE_HOOKS 的同义迁移:挖掘/封印/轮末结算面事件+轮内刷新面〔换图系〕)
const MINE_EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "seal_settle", "round_end", "reward_roll",
"refresh_request", "board_refreshed"]
## 效果类型词汇(解释器 switch 闭集——词表实数以 EFFECT_KINDS 常量为准,含共享行拆型:
## grant_gp/bias_wallet、counter_add/counter_spend、reveal_tiles/remove_mines/refresh_pick、
## bias_rewards/preview。需求卡片标题计 23 系笔误,落码按行内容全收——commit 留痕)
const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open_ticket",
"attack_bonus", "yield_mult", "spawn_spell", "grant_material",
"grant_ap", "grant_gp", "bias_wallet", "shield", "apply_status", "counter_add",
"counter_spend", "reveal_tiles", "remove_mines", "refresh_pick",
"bias_rewards", "preview",
# 六型效果(奇物.md 基础件表 27 件声明清单):
# heal=回复生命〔血蜜/战地包扎〕;ac_bonus=护甲加值累积器〔封印桩本轮/石肤本场 cap〕;
# damage_reduce=受伤减免〔背水/安全帽〕;bonus_delta=冒险加算区增量〔血税 双向——battle 末汇入
# runner.bonus〕;blast_scaled=雷罚标定群伤〔陷阱爆发——值=事件伤害×mult〕;
# preview_area=资源类型预告〔示警烟火——3×3 邻域资源类型,不揭安危位置〕;
# target_attack_bonus=单位标记加伤〔处刑台——敌血量阈值挂单位字典,主角攻击面通用消费〕
"heal", "ac_bonus", "damage_reduce", "bonus_delta", "blast_scaled", "preview_area",
"target_attack_bonus",
# mine_count_preview=危险格数量预告(巢穴预告——board_installed
# 订阅 do;只报数量不标位置、不碰危险格分布=棋盘事实不可变契约;参数面常时 kind 不入本词表——
# 它们无订阅行,schema 行内 effect_kinds 校验走 treasure_item_table 自有词表)
"mine_count_preview",
# forgive_seal_miss=完美保险(本场首次封印落空不计入完美
# 封印判定——订阅 seal_missed 的豁免效果,消费点在授予段;feats.tres 首例 domain=battle 行)
"forgive_seal_miss",
# 换图系两效果(grant_reserve_ap=矿脉储能·付费挖格产 RAP〔消费点在行动经济 dig_paid
# 受理后一次性入池〕;apply_discount=省力周转·刷新成本折扣〔mode=refresh cost_mult 百分比,
# floor 取整——只改门槛与扣费、不改拒绝判据〕)
"grant_reserve_ap", "apply_discount"]
## fact 条件词汇(封闭枚举——payload 同名键直读面〔fact 取值序 payload
## 优先——effect_engine._fact_value 既有机制〕。new_resource_type=首开资源类型〔地质学家〕;
## is_edge_cell=边缘格〔边陲矿工〕;is_blank_zero=空白连锁〔跳格采掘〕;dug_under_seal=被挖格
## 当时有封印〔破封一击〕;ap_exhausted_last=上轮 AP 耗尽〔一滴不剩〕;hp_ratio=生命比例〔背水
## 06 前置条兑付〕;amount=单次伤害量〔保险丝〕;ap_left=停手剩余 AP〔弃图兑换〕)
const FACTS := ["matchup", "is_first_dig_of_battle", "terrain_kind", "target_armor_class",
"seals_count", "nest_count", "round_index", "counter",
"new_resource_type", "is_edge_cell", "is_blank_zero", "dug_under_seal",
"ap_exhausted_last", "hp_ratio", "amount", "ap_left", "pct",
"battle_index"] # 关卡域战斗序(1 基,enc 携带;开局姿态组「每关首轮窗」条件面)
## when 算子词汇(封闭——fact 查询面落法:mod_zero=计数器取模判满、
## advantageous=克制矩阵正面判定〔淬毒刃纹〕、gte_ref/lt_ref=与另一计数器比较(计数器配额面)、
## ends_with=后缀匹配〔对某甲族的泛条件〕、mod_eq=取模余数判定〔双管镐每第 2 次——value=模数
## ref=余数;dispatch 预评估口径〔when 于命中前读计数器现值〕下「每第 N 次」系用 ref=N-1 表达〕。
## 均系映射表现状条件装进 fact 的查询面落法,白名单覆盖)
const OPS := ["eq", "neq", "gt", "gte", "lt", "lte", "ends_with", "has", "mod_zero",
"advantageous", "gte_ref", "lt_ref", "mod_eq"]
## when 条件键白名单(trigger 行 when 数组元素键)
const WHEN_KEYS := ["fact", "op", "value", "id", "ref", "value_from"]
## do 效果键白名单(trigger 行 do 字典键——零裸 flat 同族护栏:kind 外键全部参数引用面。
## value_event=事件载荷取值键名〔爆破会计——counter_add 值=本轮挖格花费
## payload.ap_cost〕;value_counter=计数器取值键〔疼痛记忆/弃图兑换——attack rider 值=账面层值〕;
## 归属由 counter_id 前缀表达(round_/lvl_/adv_/无前缀=battle),旧 domain/reset_at 键删除;
## axis=bonus_delta 加算区轴向)
const DO_KEYS := ["kind", "value", "value_from", "delta", "delta_from", "mult", "mult_from",
"cap", "cap_from", "cost_mult", "per", "divisor", "dtype", "mode", "channel", "counter_id",
"set", "status_id", "label", "fallback", "target_kinds",
"value_event", "value_counter", "axis"]
## 主动件分发通道(不经事件订阅——刷新/显影/移除是玩家主动动作;use_treasure 按 kind 分发)
## safe_dig 安全格直开(天眼——3×3 内安全格按普通挖格规则全额兑付
## 含连锁展开与格面资源,危险格仅真视显示不移除——棋盘事实不可变契约;免行动点即效果本身)
const ACTIVE_KINDS := ["reveal_tiles", "preview", "grant_ap", "remove_mines", "refresh_pick", "safe_dig"]
# ---- 订阅集(编译产物=程序,非状态;计数器四桶经 ctx 外置宿主持有)----
var _triggers: Array[Variant] = [] # [{source, id, on, when, do}](装载序=声明序,重建时定死)
var db: ConfigDb # 矩阵/表读取(advantageous 算子消费)——可空(纯计数器用法零依赖)
func _init(db_ref: ConfigDb = null) -> void:
db = db_ref
## 订阅装载(每战 begin_battle 调用——重建时机 v1.1):feats 表序→treasure 表序(各按持有/装备
## 集过滤)→树规则合成行。同一数据行的多个 trigger 按行内数组序。计数器四桶由宿主持有、随宿主生灭,
## 本方法只重建订阅集(运行时可变状态=零;battle/level 桶的清零由宿主 new 天然承载,round 桶清零
## 在 dispatch 的 round_start 特判内整桶 clear,adventure 桶随宿主字典引用跨战持久——无副本搬运)。
func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Dictionary[Variant, Variant] = {}) -> void:
_triggers.clear()
_load_feat_triggers(feat_ids)
_load_attune_triggers(item_ids)
_push_tree_rules(tree_rules)
## 特性订阅装载域(feats 表序→按持有集过滤→行内数组序——装载声明序第一段)
func _load_feat_triggers(feat_ids: Array) -> void:
if db != null and db.crossrun.feats != null:
for row in db.crossrun.feats.rows:
var fid := String(row.get("feat_id", ""))
if not feat_ids.has(fid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("feat", fid, tr, row)
## 同调秘宝订阅装载域(treasure 同调行序→按装备集过滤→行内数组序——装载声明序第二段)
func _load_attune_triggers(item_ids: Array) -> void:
if db != null:
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
if not item_ids.has(iid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("attune", iid, tr, row)
## 树规则合成订阅域(六键按 v0.8 三组双态——装载声明序第三段):posture 两键=「每关首轮窗」语义
## 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕;
## sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同
## 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕;
## economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。
func _push_tree_rules(tree_rules: Dictionary[Variant, Variant]) -> void:
for rk in tree_rules:
_push_tree_rule(String(rk), tree_rules[rk] as Dictionary)
## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动;参数全读规则键参数字典)
func _push_tree_rule(rk: String, params: Dictionary[Variant, Variant]) -> void:
match rk:
"posture_first_strike":
_push_trigger("tree", rk, {"on": "round_start",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "grant_ap", "mode": "queue", "value": int(params["ap_bonus"])}}, {})
"posture_bulwark":
_push_trigger("tree", rk, {"on": "damage_taken",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": int(params["shield"])}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与挖掘惩罚减半同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(params["cap_per_level"])}],
"do": {"kind": "damage_mult", "value": int(params["halve_pct"])}}, {})
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(params["cap_per_level"])}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used",
"value": 1}}, {})
"sense_guard":
# 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后
# 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立)
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(params["cap_per_level"])}],
"do": {"kind": "grant_ap", "mode": "queue", "value": int(params["ap_refund"])}}, {})
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(params["cap_per_level"])}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used",
"value": 1}}, {})
func _push_trigger(source: String, id: String, tr: Dictionary, row: Dictionary) -> void:
var on := String(tr.get("on", ""))
assert(EVENTS.has(on), "[效果引擎] 订阅事件出域: %s(%s %s)" % [on, source, id])
var do_d: Dictionary[Variant, Variant] = (tr.get("do", {}) as Dictionary).duplicate(true)
# 树合成行 counter_id 前缀校验(与 validate_trigger_row 同源判据;写错前缀调试期致命)
if do_d.has("counter_id") and not counter_id_legal(String(do_d.counter_id)):
assert(false, "[效果引擎] 合成行 counter_id 前缀非法: %s(%s %s)" % [String(do_d.counter_id), source, id])
# *_from 解析(rebuild 时把行字段引用定影为值——value_from:"magnitude" → value=行 magnitude)
for pair in [["value_from", "value"], ["delta_from", "delta"], ["mult_from", "mult"], ["cap_from", "cap"]]:
if do_d.has(pair[0]):
do_d[pair[1]] = int(row.get(String(do_d[pair[0]]), 0))
do_d.erase(pair[0])
# when value_from 同面解析(行字段引用条件——dawn_order effective_rounds 先例:字面量出行=数值
# 单一事实源破面,行引用随 rebuild 定影)
var when_d: Array[Variant] = (tr.get("when", []) as Array).duplicate(true)
for c_v in when_d:
var c: Dictionary[Variant, Variant] = c_v as Dictionary
if c.has("value_from"):
c["value"] = int(row.get(String(c["value_from"]), 0))
c.erase("value_from")
var t := {"source": source, "id": id, "on": on,
"when": when_d,
"do": do_d}
_triggers.append(t)
## 事件分发(发射点唯一入口):返回命中效果列表(装载序=声明序;零命中=空数组零扰动)。
## ctx = 四桶引用束(引擎不自持状态,计数器读写全经 ctx);
## payload = 事件现场(fact 取值第一优先源);facts = 补充事实面(运行态读数 Callable)。
func dispatch(ctx: EffectContext, event: String, payload: Dictionary, facts: Callable = Callable()) -> Array:
assert(EVENTS.has(event), "[效果引擎] 未知事件: %s(发射点出域)" % event)
if event == "round_start":
ctx.round_bucket.clear() # 整桶就地清空(禁 ={} 重赋值断引用;round_ 前缀键集≡旧名单,逐位等价)
var hits: Array[Variant] = []
for tr in _triggers:
if String(tr.on) != event:
continue
if not _when_ok(ctx, tr.when, payload, facts):
continue
hits.append(tr.do)
return hits
## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶;
## 前缀判定序 round→lvl→adv→battle 缺省逐字)
static func bucket_of(ctx: EffectContext, counter_id: String) -> Dictionary[Variant, Variant]:
if counter_id.begins_with("round_"):
return ctx.round_bucket
if counter_id.begins_with("lvl_"):
return ctx.level_bucket
if counter_id.begins_with("adv_"):
return ctx.adventure_bucket
return ctx.battle_bucket
## counter_id 前缀合法性(加载期校验判定单点):round_/lvl_/adv_ 合法、无前缀落 battle 合法、meta_ 非法
## (引擎不接存档级);其余未知前缀合法(落 battle——封闭词表方案误伤全部既有 battle id,见卡② §12 裁点②)。
static func counter_id_legal(counter_id: String) -> bool:
if counter_id.begins_with("meta_"):
return false
return true
## 计数器读数(fact counter 与发射点配额面共用——纯查询读 ctx,引擎不自持)。
static func counter(ctx: EffectContext, counter_id: String) -> int:
return int(bucket_of(ctx, counter_id).get(counter_id, 0))
## 计数器累加/覆写(静态纯函数——写口语义单点,7 施加点共用;算术逐字承自旧实例方法)。
static func counter_add(ctx: EffectContext, counter_id: String, value: int, set_mode: bool = false) -> void:
var bucket := bucket_of(ctx, counter_id)
if set_mode:
bucket[counter_id] = value
else:
bucket[counter_id] = int(bucket.get(counter_id, 0)) + value
## 计数器消耗(counter_spend 组合规则——按订阅序消耗共享资源,耗尽即止):
## 返回 true=消耗成功;false=资源不足(调用方按 reason 通道拒绝)。
static func counter_spend(ctx: EffectContext, counter_id: String, value: int) -> bool:
var bucket := bucket_of(ctx, counter_id)
var cur := int(bucket.get(counter_id, 0))
if cur < value:
return false
bucket[counter_id] = cur - value
return true
# ---- when 判定(条件谓词——零随机全确定;计数器读经 ctx) ----
func _when_ok(ctx: EffectContext, when: Array, payload: Dictionary, facts: Callable) -> bool:
for c_v in when:
if not _cond_ok(ctx, c_v as Dictionary, payload, facts):
return false
return true
func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Callable) -> bool:
var fact := String(c.get("fact", ""))
var op := String(c.get("op", "eq"))
var val = c.get("value", null)
var actual = _fact_value(ctx, fact, c, payload, facts)
match op:
"eq", "neq", "gt", "gte", "lt", "lte":
return _cmp_ok(op, actual, val)
"ends_with":
return _ends_with_ok(actual, val)
"has":
return _has_ok(c, actual)
"mod_zero", "mod_eq", "advantageous", "gte_ref", "lt_ref":
return _counter_op_ok(ctx, c, op, actual)
assert(false, "[效果引擎] when 算子出域: %s" % op)
return false
## 比较算子域(eq/neq/gt/gte/lt/lte 六比较——str 逐字与 int 投影语义逐字)
func _cmp_ok(op: String, actual: Variant, val: Variant) -> bool:
var out := false
match op:
"eq":
out = str(actual) == str(val)
"neq":
out = str(actual) != str(val)
"gt":
out = int(actual) > int(val)
"gte":
out = int(actual) >= int(val)
"lt":
out = int(actual) < int(val)
"lte":
out = int(actual) <= int(val)
return out
## 后缀匹配域(ends_with——值数组任一命中即真)
func _ends_with_ok(actual: Variant, val: Variant) -> bool:
if val is Array:
for v in (val as Array):
if String(actual).ends_with(String(v)):
return true
return false
return String(actual).ends_with(String(val))
## 在场判定域(has——fact 查询面落法,白名单覆盖):value 缺省=非空判定
## (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场);
## value 给定=数组包含判定(保留泛用面)
func _has_ok(c: Dictionary, actual: Variant) -> bool:
if c.has("value"):
if actual is Array:
return (actual as Array).has(c.get("value"))
return false
if actual is Array:
return not (actual as Array).is_empty()
return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错)
## 计数器与矩阵算子域(mod_zero/mod_eq 取模、advantageous 克制矩阵、gte_ref/lt_ref 计数器比较——读经 ctx)
func _counter_op_ok(ctx: EffectContext, c: Dictionary, op: String, actual: Variant) -> bool:
match op:
"mod_zero":
var m := int(c.get("value", null))
return m > 0 and int(actual) % m == 0
"mod_eq":
var me_m := int(c.get("value", null))
return me_m > 0 and int(actual) % me_m == int(c.get("ref", 0))
"advantageous":
# 克制矩阵正面判定(matchup = "{attack_type}_vs_{armor}"——矩阵值 > 0 = 克制成立)
return _counter_delta_of(String(actual)) > 0
"gte_ref":
return int(actual) >= counter(ctx, String(c.get("ref", "")))
"lt_ref":
return int(actual) < counter(ctx, String(c.get("ref", "")))
return false
func _fact_value(ctx: EffectContext, fact: String, c: Dictionary, payload: Dictionary, facts: Callable) -> Variant:
match fact:
"counter":
return counter(ctx, String(c.get("id", "")))
_:
# fact 取值序:payload 同名字段优先 → 补充事实面(发射点注入的运行态读数)
if payload.has(fact):
return payload[fact]
if facts.is_valid():
return facts.call(fact)
return null
## 克制矩阵读值(advantageous 算子消费——与伤害控制器 _counter_delta 同源同表)
func _counter_delta_of(matchup: String) -> int:
if db == null or matchup == "" or not matchup.contains("_vs_"):
return 0
var parts := matchup.split("_vs_")
if parts.size() != 2:
return 0
var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness
var row: Dictionary[Variant, Variant] = tbl.get(String(parts[0]), {})
return int(row.get(String(parts[1]), 0))
## ---- 加载期校验(schema 侧共用——单一事实源;悬空引用/词表出域=致命) ----
## trigger 行校验:返回该行是否含扫雷侧事件订阅(流派铁律聚合面)。
## row 用于 value_from 类引用的字段存在性判定(悬空=加载致命)。
static func validate_trigger_row(rid: String, row: Dictionary, triggers: Array, errs: Array) -> bool:
var mine_covered := false
for tr_v in triggers:
var tr: Dictionary[Variant, Variant] = tr_v as Dictionary
for k in tr.keys():
if not ["on", "when", "do"].has(String(k)):
errs.append({"category": "zero_flat", "message": "%s trigger 键出白名单: %s" % [rid, String(k)]})
var on := String(tr.get("on", ""))
if not EVENTS.has(on):
errs.append({"category": "enum", "message": "%s 订阅事件出域(事件词汇封闭): %s" % [rid, on]})
elif MINE_EVENTS.has(on):
mine_covered = true
_vtr_when_keys(rid, tr, errs)
_vtr_do_keys(rid, tr, row, errs)
return mine_covered
## when 键域校验:键白名单/fact 词表/算子词表(追加序承原位——when 键→fact→op)
static func _vtr_when_keys(rid: String, tr: Dictionary, errs: Array) -> void:
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary
for k in c.keys():
if not WHEN_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]})
var fact := String(c.get("fact", ""))
if not FACTS.has(fact):
errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]})
if not OPS.has(String(c.get("op", "eq"))):
errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]})
## do 键域校验:键白名单(零裸 flat 护栏)/效果类型闭集/计数器前缀合法性+value_from 引用悬空域
static func _vtr_do_keys(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for k in do_d.keys():
if not DO_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]})
var kind := String(do_d.get("kind", ""))
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]})
# 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源)
if do_d.has("counter_id"):
var cid := String(do_d.counter_id)
if not counter_id_legal(cid):
errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]})
_vtr_from_refs(rid, tr, row, errs)
## value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)——
## do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例)
static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]:
if do_d.has(vf_k):
var fld := String(do_d.get(vf_k, ""))
if fld != "" and not row.has(fld):
errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]})
for c_v in (tr.get("when", []) as Array):
var c_w: Dictionary[Variant, Variant] = c_v as Dictionary
if c_w.has("value_from"):
var wfld := String(c_w.get("value_from", ""))
if wfld != "" and not row.has(wfld):
errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]})
## 组合规则(需求卡片 §3.4 六族——效果类型的必填属性,发射点施加面引用)
## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap、
## counter_add、shield——发射点逐条累加收集器。
## 乘法族(收集后一次施加,正负分桶):yield_mult、damage_mult。
## 任意真族(任一命中即生效):spawn_spell。
## 按序改写族(收集→单点按声明序施加):modify_ticket 及一切改写窗口效果。
## 按序消耗族(按订阅序消耗共享资源,耗尽即止;可选 fallback):counter_spend。
## 即时执行族(每条命中各自执行一次,执行序=声明序):open_ticket、
## rewrite_board、apply_status——发射点逐条执行。