feat(escort-controller): Introduce EscortController class with escort mechanics, update unit_panel.gd for escort status display, and add event bus bindings for escort events

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 04:29:08 -07:00
parent c2629d5653
commit 516c4abdd2
4 changed files with 107 additions and 0 deletions

View file

@ -33,6 +33,11 @@ signal set_formation_shape_pressed(formation_id: int, shape: String)
signal set_formation_command_pressed(formation_id: int, command: String)
signal exit_formation_pressed(unit_id: String)
signal auto_join_toggled(unit_id: String, enabled: bool)
## p2-59 pioneer-escort verb signals. Surfaced for vulnerable protectees
## (civilian / founder) via the EscortAssign/EscortRelease action vocabulary.
## The controller routes these to the player-API escort dispatch.
signal escort_assign_pressed
signal escort_release_pressed
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const ActionButtonScene: PackedScene = preload("res://engine/scenes/hud/action_button.tscn")
@ -117,6 +122,9 @@ const _KIND_TO_SIGNAL: Dictionary = {
"supply_aura": "archetype_action_pressed",
"light_beacon": "archetype_action_pressed",
"claim_territory": "archetype_action_pressed",
# p2-59 pioneer escort
"escort_assign": "escort_assign_pressed",
"escort_release": "escort_release_pressed",
}
## The currently selected unit (UnitScript or Dictionary). Typed as RefCounted
@ -440,6 +448,10 @@ func _on_action_button_pressed(signal_name: String, kind: String = "") -> void:
disembark_pressed.emit()
"archetype_action_pressed":
archetype_action_pressed.emit(kind)
"escort_assign_pressed":
escort_assign_pressed.emit()
"escort_release_pressed":
escort_release_pressed.emit()
## Fallback button set when GdUnitActions is not loaded (editor / headless).

View file

@ -32,6 +32,17 @@ signal unit_destroyed(unit: Variant, killer: Variant)
signal unit_promoted(unit: Variant, promotion: String)
signal unit_healed(unit: Variant, amount: int)
# -- Escort signals (p2-59) --
## Emitted when a pioneer-escort link is OBSERVED to have formed in the
## simulator (the link materialises when the TurnProcessor drains
## `pending_escort_requests` at end-of-turn, so this fires off the post-step
## link table — never optimistically on button-press). `protectee_id` /
## `escort_id` are `MapUnit::id` integer handles.
signal escort_assigned(protectee_id: int, escort_id: int)
## Emitted when a previously-formed escort link is observed to have been
## dropped (via EscortRelease, or because a linked unit died / was captured).
signal escort_released(protectee_id: int)
# -- City signals --
signal city_founded(city: Variant, player_index: int)
signal city_captured(city: Variant, old_owner: int, new_owner: int)

View file

@ -0,0 +1,83 @@
class_name EscortController
extends RefCounted
## p2-59 — bridges the pioneer-escort UI verbs to the player-API escort
## dispatch and re-emits the EventBus escort signals off OBSERVED simulator
## state.
##
## The escort link is NOT a same-frame mutation: `EscortAssign` queues an
## `EscortRequest` that the Rust `TurnProcessor` drains when it processes the
## turn (`process_escort_requests`, inside `apply_end_turn`'s `step`). So this
## controller never emits `escort_assigned` optimistically on button-press —
## it re-reads `escort_links` from the live state via `sync_links()` after the
## turn resolves and emits the EventBus signal only for links that actually
## formed (or dropped). `escort_links` is keyed `protected_unit_id ->
## escort_unit_id` (both `MapUnit::id`).
##
## `_api` is a `GdPlayerApi` (the Claude / headless player interface — the
## path that actually drains escort requests). The live human-game turn loop
## (`world_map` + `GdTurnProcessor::step_encounters_only`) does not yet drain
## escort requests; that interactive bridge is tracked separately from p2-59.
## The held GdPlayerApi bridge (RefCounted at the GDExtension boundary).
var _api: RefCounted = null
## Snapshot of the last-observed link table (`protectee_id:int -> escort_id:int`)
## so `sync_links()` can diff and emit only on change.
var _known_links: Dictionary = {}
func _init(api: RefCounted) -> void:
_api = api
## Queue an escort-assign for `protectee_unit_id` (a vulnerable civilian /
## founder). Returns the parsed response envelope; the link materialises only
## after the turn resolves — call `sync_links()` then to surface it.
func assign(player_slot: int, protectee_unit_id: int) -> Dictionary:
return _dispatch(player_slot, "escort_assign", protectee_unit_id)
## Queue an escort-release for `protectee_unit_id`.
func release(player_slot: int, protectee_unit_id: int) -> Dictionary:
return _dispatch(player_slot, "escort_release", protectee_unit_id)
func _dispatch(player_slot: int, action_type: String, protectee_unit_id: int) -> Dictionary:
if _api == null:
return {}
var action: Dictionary = {"type": action_type, "unit_id": str(protectee_unit_id)}
var envelope_str: String = String(_api.apply_action_json(player_slot, JSON.stringify(action)))
var envelope: Dictionary = JSON.parse_string(envelope_str) as Dictionary
if envelope == null:
return {}
return envelope
## Re-read `escort_links` from the live state and emit EventBus signals for any
## link that appeared (`escort_assigned`) or disappeared (`escort_released`)
## since the previous sync. Call once after each turn resolves.
func sync_links() -> void:
if _api == null:
return
var dump: Dictionary = JSON.parse_string(String(_api.dump_state_json())) as Dictionary
if dump == null:
return
var links: Dictionary = dump.get("escort_links", {})
# Normalise the JSON link table (string keys/values) into int->int.
var current: Dictionary = {}
for key: String in links:
current[int(key)] = int(links[key])
# Newly-formed or re-targeted links → escort_assigned.
for protectee_id: int in current:
var escort_id: int = current[protectee_id]
if not _known_links.has(protectee_id) or int(_known_links[protectee_id]) != escort_id:
EventBus.escort_assigned.emit(protectee_id, escort_id)
# Dropped links → escort_released.
for protectee_id: int in _known_links:
if not current.has(protectee_id):
EventBus.escort_released.emit(protectee_id)
_known_links = current

View file

@ -0,0 +1 @@
uid://bmcwr1ovmj3s8