From c94faa3a11eac8cd750a6f21e2286f22a2e0fe7e Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 8 Jun 2026 18:52:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E2=9C=A8=20improve=20move=20vs=20c?= =?UTF-8?q?ombat=20parity=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/src/modules/ai/ai_turn_bridge.gd | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index 35ad0489..83b0af08 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -291,16 +291,30 @@ static func _replay_learned_actions(action_log: Array, player: RefCounted) -> in "to_hex": [ax.x, ax.y], } }) + # Capture pre-dispatch position + whether an enemy occupies the + # target, so we can distinguish a true move (position == target) + # from a move-as-attack (position unchanged, combat fired via + # CombatResolver — `dispatch_move` routes to resolve_move_as_attack + # which does NOT move the attacker). + var u_pre: RefCounted = (index_maps.get("units", {}) as Dictionary).get(uid) + var pre_pos: Vector2i = u_pre.position if u_pre != null else Vector2i(-999, -999) + var was_attack: bool = (kind == "attack") or _enemy_or_city_at_axial(ax, player) if DispatchScript.dispatch_action(move_json, player, index_maps, city_name): applied += 1 # Position-parity proof: read the unit's resulting GDScript - # position and compare to the intended axial target. + # position and label move vs combat. if parity_samples.size() < PARITY_SAMPLE_CAP: var u: RefCounted = (index_maps.get("units", {}) as Dictionary).get(uid) if u != null: - var match_str: String = "OK" if u.position == ax else "MISMATCH" + var label: String + if u.position == ax: + label = "MOVE_OK" + elif was_attack and u.position == pre_pos: + label = "COMBAT(stayed, CombatResolver fired)" + else: + label = "MISMATCH" parity_samples.append( - "u%d→intended%s got%s [%s]" % [uid, str(ax), str(u.position), match_str] + "u%d→tgt%s got%s [%s]" % [uid, str(ax), str(u.position), label] ) "queue": var queue_json: String = JSON.stringify({ @@ -332,6 +346,25 @@ static func _replay_learned_actions(action_log: Array, player: RefCounted) -> in return applied +## True if an ENEMY unit (or enemy city) occupies axial hex `at` from +## `player`'s perspective — used only to LABEL the parity sample as a +## move-as-attack (combat) vs a true move. Read-only. +static func _enemy_or_city_at_axial(at: Vector2i, player: RefCounted) -> bool: + var my_owner: int = int(player.index) + for p: RefCounted in GameState.players: + if p == null: + continue + if int(p.index) == my_owner: + continue + for u: RefCounted in p.units: + if u != null and u.is_alive() and u.position == at: + return true + for c: RefCounted in p.cities: + if c != null and c.position == at: + return true + return false + + ## Resolve the Rust faithful-state city_id (`"_"`) to the GDScript ## index-maps city key (`pi*ID_STRIDE+ci`) that `dispatch_set_production` ## consumes via `resolve_city`. The Rust CityView id is `"{p_idx}_{c_idx}"`.