## SpellEvaluator — 法术虚拟机（Autoload: SpellEvaluator）
## S1：compile_wand(LINEAR)＋execute_compiled(ACTION)
## S2：MODIFIER 分支
## S3：_flatten_linear 处理 TRIGGER、execute_sub、multicast_count、actions_remaining
## S5：LOGIC 分支（条件门 + LOOP）、跨帧寄存器持久化（PERSISTENT_MEMORY Core）
extends Node

const MAX_OPS_PER_CPU:   int = 40
const MAX_TRIGGER_DEPTH: int = 3

## LOGIC 求值返回信号
const _LOGIC_CONTINUE:  int = 0  # 条件通过 / 无副作用 → 继续执行后续节点
const _LOGIC_SKIP_REST: int = 1  # 条件不成立 → 跳过本次施法后续全部节点

## 跨帧持久寄存器上下文：caster_id → SpellContext（不入池，registers 跨施法保留）
## 仅 Core 带 CoreFeatureTag.PERSISTENT_MEMORY 时使用；compile_wand 时清空（换杖即重置记忆）
var _persistent_ctx: Dictionary = {}

## 共鸣配方表（§3.4.C）：纯数据驱动，唯一来源 res://data/resonance.json
## 字段：pattern[2]、match("adjacent"/"anywhere_in_deck")、result_spell_id、consume_inputs
const RESONANCE_JSON: String = "res://data/resonance.json"
var _resonance_recipes: Array = []

func _ready() -> void:
	if FileAccess.file_exists(RESONANCE_JSON):
		var d = JSON.parse_string(FileAccess.get_file_as_string(RESONANCE_JSON))
		if d is Array:
			_resonance_recipes = d
		else:
			push_error("SpellEvaluator: resonance.json 格式错误（应为数组）")
	else:
		push_error("SpellEvaluator: 缺失 res://data/resonance.json（共鸣系统无配方）")

## compile_wand(core, raw_nodes) → CompiledDeck
## core 在前（P6-N70）
func compile_wand(core: CoreDefinition, raw_nodes: Array) -> CompiledDeck:
	SubPayloadRegistry.clear_for_compile()
	_persistent_ctx.clear()  # 换杖/重编译即重置跨帧寄存器记忆
	# P6-N3 修复：若 Core 带 PERSISTENT_MEMORY，在非热路径的 compile_wand 阶段预分配 SpellContext，
	# 避免首次施法时在 _physics_process 热路径内 SpellContext.new()
	if core.feature_tags == CoreFeatureTag.PERSISTENT_MEMORY:
		var prewarm := SpellContext.new()
		prewarm.caster_id = 0           # 玩家 caster_id，下次 acquire 时覆写
		_persistent_ctx[0] = prewarm    # 预热 id=0（玩家唯一 id）
	var deck := CompiledDeck.new()
	deck.topology_type = core.topology
	deck.feature_tags  = core.feature_tags  # 执行期据此判断 PERSISTENT_MEMORY 等
	match core.topology:
		CoreDefinition.Topology.CIRCUIT:
			_flatten_circuit(raw_nodes, core, deck)
		CoreDefinition.Topology.MATRIX:
			_flatten_matrix(raw_nodes, core, deck)
		_:
			_flatten_linear(raw_nodes, deck)
	# 拓扑扁平化后做共鸣模式匹配（§3.4.C），可能注入结果节点并标记 consume 掩码
	_check_resonance(deck)
	deck.checksum = _calc_checksum(core, deck.nodes)
	# 战斗开始时 CombatManager 调用 lock_for_battle()
	return deck

## 扣除算法：处理 MODIFIER / TRIGGER / ACTION
## TRIGGER 节点消耗后续节点作为子荷载
func _flatten_linear(raw_nodes: Array, deck: CompiledDeck) -> void:
	var i: int = 0
	while i < raw_nodes.size():
		var n: SpellNode = raw_nodes[i]
		if not (n is SpellNode):
			i += 1
			continue
		if n.type == SpellNode.SpellType.TRIGGER:
			# 消耗 i+1 起的节点为子荷载（ACTION 节点也属于子荷载范围）
			# 仅遇到下一个同级 TRIGGER 时才停止（不跨 TRIGGER 作用域）
			var sub_nodes: Array = []
			var j: int = i + 1
			while j < raw_nodes.size():
				var sn: SpellNode = raw_nodes[j]
				if sn.type == SpellNode.SpellType.TRIGGER:
					break  # 下一个 TRIGGER 是独立作用域
				sub_nodes.append(sn)  # MODIFIER / ACTION 均归入子荷载
				j += 1
			# 注册子荷载
			var payload_id: int = SubPayloadRegistry.register(sub_nodes)
			# 克隆 TRIGGER 节点并注入 sub_payload_id
			var trigger_clone: SpellNode = SpellNode.new()
			trigger_clone.id           = n.id
			trigger_clone.type         = n.type
			trigger_clone.display_name = n.display_name
			trigger_clone.description  = n.description
			trigger_clone.meta         = n.meta.duplicate()
			trigger_clone.meta["sub_payload_id"] = payload_id
			deck.nodes.append(trigger_clone)
			deck.sub_payload_ids.append(payload_id)
			i = j  # 跳过已消耗节点
		else:
			deck.nodes.append(n)
			i += 1

func _calc_checksum(core: CoreDefinition, nodes: Array) -> int:
	var h: int = core.topology ^ nodes.size() ^ hash(core.id)
	for i in nodes.size():
		h = h * 31 ^ hash(nodes[i].id)
	return h

# ── CIRCUIT 拓扑扁平化（architecture_design.md §3.4.A，P6-N57/63/64/66）─────
## 读 core.edges，对有向图做 Kahn 拓扑排序；分叉点（出度>1）展开为 SubPayload + LOGIC_FORK
## edges 为空 → 降级 LINEAR。结果写入 deck.nodes；分支 payload id 记入 deck.sub_payload_ids
func _flatten_circuit(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void:
	var edges: Array = core.edges
	if edges.is_empty():
		push_warning("_flatten_circuit: core.edges 为空，降级 LINEAR（Core=%s）" % core.id)
		_flatten_linear(raw_nodes, deck)
		return
	var n: int = core.slot_count
	# 1. 构建邻接表 + 入度
	var in_degree: Array = []
	var adj: Array       = []
	in_degree.resize(n)
	adj.resize(n)
	for i in n:
		in_degree[i] = 0
		adj[i] = []
	for e in edges:
		var ep: Vector2i = _edge_endpoints(e)
		if ep.x < 0 or ep.x >= n or ep.y < 0 or ep.y >= n:
			push_warning("_flatten_circuit: 非法边 %s（slot_count=%d）跳过" % [str(e), n])
			continue
		adj[ep.x].append(ep.y)
		in_degree[ep.y] += 1
	# P6-N63：Kahn 前保存原始入度快照，供 _collect_branch_path 判断汇聚点
	var orig_in_degree: Array = in_degree.duplicate()
	# 2. Kahn BFS 拓扑排序
	var queue: Array = []
	for i in n:
		if in_degree[i] == 0:
			queue.append(i)
	var topo_order: Array = []
	while not queue.is_empty():
		var cur: int = queue.pop_front()
		topo_order.append(cur)
		for nxt in adj[cur]:
			in_degree[nxt] -= 1
			if in_degree[nxt] == 0:
				queue.append(nxt)
	if topo_order.size() != n:
		push_error("_flatten_circuit: 检测到环路，无法线性化！Core=%s" % core.id)
		return  # deck.nodes 保持空，该法杖无法施法但不崩溃
	# 3. 按拓扑序输出；P6-N64：in_branch_payload 防止分支节点被主链重复执行
	var in_branch_payload: Dictionary = {}
	for slot_idx in topo_order:
		if in_branch_payload.has(slot_idx):
			continue  # 已被某分支 SubPayload 收纳
		var node: SpellNode = raw_nodes[slot_idx] if slot_idx < raw_nodes.size() else null
		if adj[slot_idx].size() > 1:
			# 分叉点（先于 null 守卫判断：分叉槽通常为空 splitter）
			if node != null:
				deck.nodes.append(node)  # 分叉槽自身节点先执行（通常空，允许 MODIFIER/ACTION）
			var branch_ids: Array = []
			for neighbor_idx in adj[slot_idx]:
				var bp: Array = _collect_branch_path(neighbor_idx, adj, orig_in_degree, raw_nodes, in_branch_payload)
				if not bp.is_empty():
					var pid: int = SubPayloadRegistry.register(bp)
					branch_ids.append(pid)
					deck.sub_payload_ids.append(pid)
			if not branch_ids.is_empty():
				deck.nodes.append(_make_fork_node(branch_ids))
		elif node != null:
			deck.nodes.append(node)
		# else：空的非分叉槽，跳过

## 从 start_idx 沿单出边收集分支节点序列（P6-N66 嵌套分叉递归）
## 终止：叶节点（出度0）/ 汇聚点（orig_in_degree>1，交主链处理）/ 嵌套分叉（出度>1，递归+插 LOGIC_FORK）
func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array,
		raw_nodes: Array, in_branch_payload: Dictionary) -> Array:
	var path: Array = []
	var cur: int = start_idx
	var visited: Dictionary = {}
	while cur >= 0 and not visited.has(cur):
		visited[cur] = true
		in_branch_payload[cur] = true  # 登记已纳入 SubPayload，主链循环跳过
		if cur < raw_nodes.size() and raw_nodes[cur] != null:
			path.append(raw_nodes[cur])
		if adj[cur].size() > 1:
			# 嵌套分叉：每条子出边递归收集并注册，插入嵌套 LOGIC_FORK 后本路径结束
			var nested_ids: Array = []
			for nxt_idx in adj[cur]:
				var np: Array = _collect_branch_path(nxt_idx, adj, orig_in_degree, raw_nodes, in_branch_payload)
				if not np.is_empty():
					nested_ids.append(SubPayloadRegistry.register(np))
			if not nested_ids.is_empty():
				path.append(_make_fork_node(nested_ids))
			break
		elif adj[cur].size() == 1:
			var nxt: int = adj[cur][0]
			if orig_in_degree[nxt] <= 1:
				cur = nxt          # 单入边，继续延伸
			else:
				break              # 汇聚点（多入边），交主链处理
		else:
			break                  # 叶节点
	return path

func _make_fork_node(branch_ids: Array) -> SpellNode:
	var fork := SpellNode.new()
	fork.type = SpellNode.SpellType.LOGIC
	fork.id   = "LOGIC_FORK"
	fork.meta = {"fork_branch_ids": branch_ids}
	return fork

## 解析单条边为 (from, to)；兼容 {"from","to"} 字典（P6-N42 权威）与 [from, to] 数组对
func _edge_endpoints(e) -> Vector2i:
	if e is Dictionary:
		return Vector2i(int(e.get("from", -1)), int(e.get("to", -1)))
	elif e is Array and e.size() >= 2:
		return Vector2i(int(e[0]), int(e[1]))
	return Vector2i(-1, -1)

# ── MATRIX 拓扑扁平化（architecture_design.md §3.4.A，邻接加成 P6-N13/P6-N20）────
## 仅 Row A（前 grid_cols 槽）进入执行序列；Row B 仅提供邻接加成，不独立执行（P6-N20）
func _flatten_matrix(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void:
	var cols: int = max(1, core.grid_cols)
	for i in cols:
		var adj_idx: int = i + cols  # Row B 中与 slot[i] 竖向对齐的槽
		var row_a: SpellNode = raw_nodes[i] if i < raw_nodes.size() else null
		var row_b: SpellNode = raw_nodes[adj_idx] if adj_idx < raw_nodes.size() else null
		if row_a == null:
			continue  # 空槽不传递邻接（P6-N13）
		if row_b != null and _has_adjacency_bonus(row_a, row_b):
			deck.nodes.append(_make_adjacency_mod(row_a, row_b))  # 注入隐式 MODIFIER（在 Row A 前）
		deck.nodes.append(row_a)  # 仅追加 Row A 节点

## 邻接加成是否成立（P6-N13；LOGIC / 空槽不参与）
func _has_adjacency_bonus(a: SpellNode, b: SpellNode) -> bool:
	if a == null or b == null:
		return false
	if a.type == SpellNode.SpellType.LOGIC or b.type == SpellNode.SpellType.LOGIC:
		return false
	var A := SpellNode.SpellType
	if a.type == A.ACTION and b.type == A.ACTION:
		return a.id == b.id  # 同 ID ACTION 对齐 → 强化同类弹
	if a.type == A.ACTION and b.type == A.MODIFIER:
		return true          # 增幅器加倍
	if a.type == A.MODIFIER and b.type == A.MODIFIER:
		return a.id == b.id  # 同 ID MODIFIER 对齐 → 共鸣修正
	return false

## 生成隐式邻接 MODIFIER 节点（P6-N13 效果表）
func _make_adjacency_mod(a: SpellNode, b: SpellNode) -> SpellNode:
	var m := SpellNode.new()
	m.type = SpellNode.SpellType.MODIFIER
	m.id   = "implicit_adjacency"
	m.display_name = "邻接加成"
	var A := SpellNode.SpellType
	if a.type == A.ACTION and b.type == A.ACTION:
		m.meta = {"damage_mult": 1.5}                 # 同类弹 ×1.5
	elif a.type == A.MODIFIER and b.type == A.MODIFIER:
		# 同 ID MODIFIER 对齐 → 数值 ×2（平直叠加）：复制 b 的可加字段
		m.meta = b.meta.duplicate()
	else:
		# ACTION + MODIFIER：注入 B 的修正效果（作用于对齐的 Row A 动作）
		m.meta = b.meta.duplicate()
	return m

# ── 共鸣系统（§3.4.C，_consumed 掩码 P6-N29）─────────────────────
## 在扁平化后的 deck.nodes 上做模式匹配；命中 adjacent 配方时注入结果节点并标记 consume
func _check_resonance(deck: CompiledDeck) -> void:
	if _resonance_recipes.is_empty() or deck.nodes.is_empty():
		return
	var nodes: Array = deck.nodes
	var consumed: PackedByteArray = PackedByteArray()
	consumed.resize(nodes.size())
	for recipe in _resonance_recipes:
		if String(recipe.get("match", "adjacent")) != "adjacent":
			continue  # anywhere_in_deck 留 stub（仅传说配方）
		var pattern: Array = recipe.get("pattern", [])
		if pattern.size() < 2:
			continue
		var i: int = 0
		while i < nodes.size():
			if consumed[i] != 0 or not _node_has_tag(nodes[i], pattern[0]):
				i += 1
				continue
			# 向右扫描 i+1..i+2（跳过 MODIFIER），寻找 pattern[1]
			var hit_j: int = -1
			for j in range(i + 1, min(i + 3, nodes.size())):
				if consumed[j] != 0:
					continue
				if nodes[j].type == SpellNode.SpellType.MODIFIER:
					continue  # MODIFIER 不参与匹配，但不中断扫描
				if _node_has_tag(nodes[j], pattern[1]):
					hit_j = j
				break  # 遇到非 MODIFIER 非目标节点即停（不跨 ACTION/TRIGGER）
			if hit_j < 0:
				i += 1
				continue
			var result: SpellNode = SpellRegistry.get_spell(String(recipe.get("result_spell_id", "")))
			if result == null:
				i += 1
				continue
			# 在 i 位置插入共鸣结果，同步扩展 consumed
			nodes.insert(i, result)
			consumed.insert(i, 0)
			if bool(recipe.get("consume_inputs", false)):
				consumed[i + 1] = int(1)        # 原 pattern[0]（现 i+1）
				consumed[hit_j + 1] = int(1)    # 原 pattern[1]（现 hit_j+1）
			i += 2  # 跳过刚插入的结果与已消费的 pattern[0]
	# 收集 consume 索引供运行时 SpellDeck 跳过
	for k in consumed.size():
		if consumed[k] != 0:
			deck.consumed_indices.append(k)

## 标签匹配：tag:xxx 查 element_tags；否则按 id 精确匹配
func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
	if node == null:
		return false
	if tag_pattern.begins_with("tag:"):
		return tag_pattern in node.element_tags
	return node.id == tag_pattern

## execute_compiled(compiled, caster_id, spawn_pos)
## 每次施法可执行的 ACTION 数量 = 1 + multicast_count
func execute_compiled(compiled: CompiledDeck, caster_id: int, spawn_pos: Vector2) -> void:
	if compiled == null or compiled.is_empty():
		return
	var persistent: bool = compiled.feature_tags == CoreFeatureTag.PERSISTENT_MEMORY
	var ctx: SpellContext = _acquire_ctx(caster_id, persistent)
	ctx.hp_cost_accum = 0.0
	ctx.mana_spent_this_cast = false
	var infinite: bool = compiled.feature_tags == CoreFeatureTag.INFINITE_SPELLS
	var heat_mult: float = 1.0 + PlayerStats.mana_heat
	var deck: SpellDeck   = compiled.make_runtime_deck()
	var ops_count:         int = 0
	var max_ops:           int = MAX_OPS_PER_CPU * 5
	var actions_remaining: int = 1  # 初始为 1，由 multicast_count 加成
	while deck.has_next() and ops_count < max_ops:
		ops_count += 1
		var node: SpellNode = deck.pop()
		var mana_cost: float = float(node.meta.get("mana_cost", 0.0))
		if not _charge_mana(mana_cost, ctx, infinite, heat_mult):
			break  # 蓝不足且非 infinite → 停施
		match node.type:
			SpellNode.SpellType.ACTION:
				_push_projectile(node, ctx, spawn_pos, -1)
				actions_remaining -= 1
				if actions_remaining <= 0:
					break
			SpellNode.SpellType.MODIFIER:
				_apply_modifier(node, ctx)
				actions_remaining = 1 + ctx.stats.multicast_count
			SpellNode.SpellType.TRIGGER:
				_push_trigger(node, ctx, spawn_pos)
				actions_remaining -= 1
				if actions_remaining <= 0:
					break
			SpellNode.SpellType.LOGIC:
				if node.meta.has("fork_branch_ids"):
					for bid in node.meta["fork_branch_ids"]:
						_run_branch_payload(int(bid), ctx, spawn_pos, infinite, heat_mult)
				elif String(node.meta.get("logic_op", "")) == "loop":
					ops_count = _run_logic_loop(node, ctx, spawn_pos, deck, ops_count, max_ops, infinite, heat_mult)
					break  # LOOP 已消耗剩余节点
				elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST:
					break
	if ctx.hp_cost_accum > 0.0:
		PlayerStats.spend_hp_cost(minf(ctx.hp_cost_accum, PlayerStats.hp_max * SettingsManager.hp_cost_cap_ratio()))
	if ctx.mana_spent_this_cast:
		PlayerStats.mana_heat = minf(SettingsManager.mana_heat_max(), PlayerStats.mana_heat + SettingsManager.mana_heat_per_cast())
	if ops_count >= max_ops:
		push_warning("SpellEvaluator: MAX_OPS reached (caster=%d)" % caster_id)
	_release_ctx(ctx, persistent)

## execute_sub — 子荷载执行（MAX_TRIGGER_DEPTH 限制）
## depth 表示当前嵌套深度（0=顶层子荷载）
func execute_sub(payload_id: int, hit_pos: Vector2, owner_id: int, depth: int) -> void:
	if depth >= MAX_TRIGGER_DEPTH:
		EventBus.emit(EventID.SPELL_DEPTH_EXCEEDED, {"owner_id": owner_id, "depth": depth})
		return
	var nodes: Array = SubPayloadRegistry.get_nodes(payload_id)
	if nodes.is_empty():
		return
	var ctx: SpellContext = SpellContextPool.acquire(owner_id)  # P-S3-04: 从池取用
	var ops: int = 0
	var actions_remaining: int = 1
	for node in nodes:
		ops += 1
		if ops > MAX_OPS_PER_CPU * 2:
			break
		match node.type:
			SpellNode.SpellType.ACTION:
				_push_projectile(node, ctx, hit_pos, depth)
				actions_remaining -= 1
				if actions_remaining <= 0:
					break
			SpellNode.SpellType.MODIFIER:
				_apply_modifier(node, ctx)
				actions_remaining = 1 + ctx.stats.multicast_count
			SpellNode.SpellType.TRIGGER:
				_push_trigger(node, ctx, hit_pos, depth)
				actions_remaining -= 1
				if actions_remaining <= 0:
					break
	SpellContextPool.release(ctx)

# ── 内部方法 ──────────────────────────────────────────────

func _push_projectile(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int) -> void:
	# ACTION 分派：zone（地面效果）/ summon（召唤物）/ 默认弹道
	match String(node.meta.get("action_kind", "")):
		"zone":
			_spawn_zone_action(node, ctx, spawn_pos)
			return
		"summon":
			_spawn_minion_action(node, ctx, spawn_pos)
			return
	var meta:     Dictionary = node.meta
	var speed:    float = float(meta.get("speed",       300.0))
	var lifetime: float = float(meta.get("lifetime",    4.0))
	var radius:   float = float(meta.get("radius",      6.0))
	var base_dmg: float = float(meta.get("base_damage", 3.0))
	var dtype:    int   = int(meta.get("damage_type",   0))
	var spread:   int   = max(1, ctx.stats.spread_count)
	var aim_dir:  Vector2 = _get_aim_direction(spawn_pos)
	for i in spread:
		var angle_offset: float = 0.0
		if spread > 1:
			angle_offset = (float(i) / float(spread - 1) - 0.5) * 0.5
		var vel:        Vector2 = aim_dir.rotated(angle_offset) * speed * ctx.stats.speed_mult
		var actual_dmg: float   = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult
		var actual_r:   float   = radius * ctx.stats.radius_mult
		var pierce:     int     = int(meta.get("pierce", 0)) + ctx.stats.pierce_add
		var cold: Dictionary = {}
		if pierce > 0:
			cold["pierce_remaining"] = pierce
		# S3: 将 trigger_depth 写入冷数据供子弹命中时使用
		if trigger_depth >= 0:
			cold["trigger_depth"] = trigger_depth
		if meta.has("apply_status_id"):
			cold["apply_status_id"] = int(meta["apply_status_id"])
		if meta.get("apply_combo_mark", false):
			cold["apply_combo_mark"] = true
		BulletManager.spawn_bullet(
			spawn_pos, vel,
			lifetime + ctx.stats.lifetime,
			actual_r, actual_dmg, 1.0, dtype, ctx.caster_id,
			0, 0.0, cold
		)

## ACTION(zone)：在施法点生成地面效果区域（action_poison_pool 等）
func _spawn_zone_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void:
	var meta: Dictionary = node.meta
	var radius: float = float(meta.get("radius", 200.0)) * ctx.stats.radius_mult
	ZoneManager.spawn_zone(
		spawn_pos.x, spawn_pos.y, radius,
		int(meta.get("status_id", StatusID.POISON)),
		float(meta.get("duration", 5.0)) + ctx.stats.lifetime,
		float(meta.get("tick_interval", 1.0)),
		ctx.caster_id)

## ACTION(summon)：在施法点召唤一个炮台（action_summon_turret 等）
func _spawn_minion_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void:
	var meta: Dictionary = node.meta
	var def: Dictionary = {
		"lifetime":      float(meta.get("lifetime", 20.0)),
		"range":         float(meta.get("range", 350.0)),
		"fire_interval": float(meta.get("fire_interval", 0.8)),
		"damage":        float(meta.get("base_damage", 4.0)) + ctx.stats.damage_add,
		"bullet_speed":  float(meta.get("speed", 360.0)),
		"damage_type":   int(meta.get("damage_type", 0)),
	}
	MinionManager.spawn_minion(def, ctx.caster_id, spawn_pos)

## TRIGGER 节点：发射子弹＋子荷载参数
func _push_trigger(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int = 0) -> void:
	var payload_id: int = int(node.meta.get("sub_payload_id", -1))
	if payload_id < 0 or not SubPayloadRegistry.has_payload(payload_id):
		return
	var meta:     Dictionary = node.meta
	var speed:    float = float(meta.get("speed",       250.0))
	var lifetime: float = float(meta.get("lifetime",    4.0))
	var radius:   float = float(meta.get("radius",      6.0))
	var base_dmg: float = float(meta.get("base_damage", 3.0))
	var dtype:    int   = int(meta.get("damage_type",   0))
	var aim_dir:  Vector2 = _get_aim_direction(spawn_pos)
	var vel:      Vector2 = aim_dir * speed
	var actual_dmg: float = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult
	var actual_r:   float = radius * ctx.stats.radius_mult
	var cold: Dictionary = {
		"on_hit_payload_id": payload_id,
		"trigger_parent_depth": trigger_depth,
	}
	BulletManager.spawn_bullet(
		spawn_pos, vel,
		lifetime, actual_r, actual_dmg, 1.0, dtype, ctx.caster_id,
		0, 0.0, cold
	)

## 统一扣蓝：base_cost>0 时按热值乘算扣蓝；infinite Core 蓝空转累加 HP 代价并继续
## 返回 true=可执行该节点；false=蓝不足且非 infinite → 停施
func _charge_mana(base_cost: float, ctx: SpellContext, infinite: bool, heat_mult: float) -> bool:
	if base_cost <= 0.0:
		return true
	if PlayerStats.spend_mana(base_cost * heat_mult):
		ctx.mana_spent_this_cast = true  # 真实扣蓝才记（infinite 卖血不算）→ heat 只对真实施法上升
		return true
	if infinite:
		ctx.hp_cost_accum += SettingsManager.infinite_hp_per_shot()
		return true
	return false

## MODIFIER 分支
func _apply_modifier(node: SpellNode, ctx: SpellContext) -> void:
	var meta: Dictionary = node.meta
	if meta.has("damage_add"):
		ctx.stats.damage_add    += float(meta["damage_add"])
	if meta.has("damage_mult"):
		ctx.stats.damage_mult   *= float(meta["damage_mult"])
	if meta.has("spread_add"):
		ctx.stats.spread_count  += int(meta["spread_add"])
	if meta.has("speed_mult"):
		ctx.stats.speed_mult    *= float(meta["speed_mult"])
	if meta.has("lifetime_add"):
		ctx.stats.lifetime      += float(meta["lifetime_add"])
	if meta.has("multicast"):
		ctx.stats.multicast_count += int(meta["multicast"])
	if meta.has("pierce"):
		ctx.stats.pierce_add    += int(meta["pierce"])
	if meta.has("heavy_cost"):
		ctx.hp_cost_accum += float(meta["heavy_cost"])

## ── 上下文获取（区分跨帧持久 / 池化）─────────────────────────
func _acquire_ctx(caster_id: int, persistent: bool) -> SpellContext:
	if persistent:
		var ctx: SpellContext = _persistent_ctx.get(caster_id, null)
		if ctx == null:
			ctx = SpellContext.new()
			_persistent_ctx[caster_id] = ctx
		ctx.reset()            # 重置 stats，registers 保留（reset() 不触碰 registers）
		ctx.caster_id = caster_id
		return ctx
	return SpellContextPool.acquire(caster_id, false)

func _release_ctx(ctx: SpellContext, persistent: bool) -> void:
	if not persistent:
		SpellContextPool.release(ctx)
	# 持久上下文留在 _persistent_ctx 中，registers 跨施法保留

## ── LOGIC 条件门求值 ────────────────────────────────────────
## 返回 _LOGIC_CONTINUE（执行后续）或 _LOGIC_SKIP_REST（跳过后续）
## EVERY_N_SHOTS 读写 ctx.registers（P6-N37）；持久化由 PERSISTENT_MEMORY Core 保证
func _eval_logic(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> int:
	var meta: Dictionary = node.meta
	var op: String = String(meta.get("logic_op", ""))
	match op:
		"every_n_shots":
			var n:   int = max(1, int(meta.get("n", 3)))
			var reg: int = clampi(int(meta.get("reg", 0)), 0, 3)
			var count: int = int(ctx.registers[reg]) + 1
			ctx.registers[reg] = float(count)
			return _LOGIC_CONTINUE if (count % n == 0) else _LOGIC_SKIP_REST
		"if_hp_below":
			var threshold: float = float(meta.get("threshold", 0.5))
			var frac: float = PlayerStats.hp / maxf(1.0, PlayerStats.hp_max)
			return _LOGIC_CONTINUE if (frac < threshold) else _LOGIC_SKIP_REST
		"if_enemy_nearby":
			# 用 SpatialGrid 查询范围内是否有敌方实体（plan: LOGIC_IF_ENEMY_NEARBY）
			# 注意：query_circle 为格子粒度，略宽于精确圆，作为条件门可接受
			var rng: float = float(meta.get("range", 200.0))
			var near: bool = SpatialGrid.query_circle(spawn_pos, rng).size() > 0
			return _LOGIC_CONTINUE if near else _LOGIC_SKIP_REST
		_:
			push_warning("SpellEvaluator: 未知 logic_op '%s'" % op)
			return _LOGIC_CONTINUE

## ── LOGIC_LOOP：将后续剩余节点重复执行 count 次 ──────────────
## 简化实现：LOOP 体内不再处理嵌套 LOGIC（顺延后续迭代）；尊重 ops 预算
## 返回更新后的 ops_count
func _run_logic_loop(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2,
		deck: SpellDeck, ops_count: int, max_ops: int, infinite: bool, heat_mult: float) -> int:
	var count: int = clampi(int(node.meta.get("count", 2)), 1, 16)
	var body: Array = []
	while deck.has_next():
		body.append(deck.pop())
	for _rep in count:
		for n in body:
			ops_count += 1
			if ops_count >= max_ops:
				return ops_count
			var n_cost: float = float(n.meta.get("mana_cost", 0.0))
			if not _charge_mana(n_cost, ctx, infinite, heat_mult):
				return ops_count
			match n.type:
				SpellNode.SpellType.ACTION:
					_push_projectile(n, ctx, spawn_pos, -1)
				SpellNode.SpellType.MODIFIER:
					_apply_modifier(n, ctx)
				SpellNode.SpellType.TRIGGER:
					_push_trigger(n, ctx, spawn_pos)
				# LOGIC：LOOP 体内嵌套逻辑顺延（S5 后续迭代）
	return ops_count

## ── CIRCUIT 分支执行（LOGIC_FORK 触发）──────────────────────
## 在施法点立即执行某分支 SubPayload 的节点；分支内 MODIFIER 作用域隔离（不泄漏给兄弟分支/主链）
## 嵌套 LOGIC_FORK 递归展开（P6-N66）
func _run_branch_payload(payload_id: int, ctx: SpellContext, spawn_pos: Vector2, infinite: bool, heat_mult: float) -> void:
	var nodes: Array = SubPayloadRegistry.get_nodes(payload_id)
	if nodes.is_empty():
		return
	# 快照分支前 CastStats（8 字段，无分配），分支结束后还原 → 分支局部 MODIFIER 不外泄
	var s: CastStats = ctx.stats
	var b_dadd: float = s.damage_add
	var b_dmul: float = s.damage_mult
	var b_spr:  int   = s.spread_count
	var b_mc:   int   = s.multicast_count
	var b_life: float = s.lifetime
	var b_spd:  float = s.speed_mult
	var b_rad:  float = s.radius_mult
	var b_crit: float = s.crit_chance
	for node in nodes:
		var node_cost: float = float(node.meta.get("mana_cost", 0.0))
		if not _charge_mana(node_cost, ctx, infinite, heat_mult):
			break
		match node.type:
			SpellNode.SpellType.ACTION:
				_push_projectile(node, ctx, spawn_pos, -1)
			SpellNode.SpellType.MODIFIER:
				_apply_modifier(node, ctx)
			SpellNode.SpellType.TRIGGER:
				_push_trigger(node, ctx, spawn_pos)
			SpellNode.SpellType.LOGIC:
				if node.meta.has("fork_branch_ids"):
					for bid in node.meta["fork_branch_ids"]:
						_run_branch_payload(int(bid), ctx, spawn_pos, infinite, heat_mult)
				elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST:
					break
	# 还原 stats
	s.damage_add      = b_dadd
	s.damage_mult     = b_dmul
	s.spread_count    = b_spr
	s.multicast_count = b_mc
	s.lifetime        = b_life
	s.speed_mult      = b_spd
	s.radius_mult     = b_rad
	s.crit_chance     = b_crit

func _get_aim_direction(from_pos: Vector2) -> Vector2:
	var target: Vector2 = EnemyManager.get_nearest_pos(from_pos, 9999.0)
	if target.distance_to(from_pos) < 1.0:
		return Vector2.RIGHT
	return (target - from_pos).normalized()

func get_pool_stats() -> String:
	return "SpellCtx: " + str(SpellContextPool.get_available_count()) + "/" + str(SpellContextPool.POOL_SIZE)
