diff --git a/src/game/engine/scenes/hud/unit_panel.gd b/src/game/engine/scenes/hud/unit_panel.gd index a5031304..e568e4e0 100644 --- a/src/game/engine/scenes/hud/unit_panel.gd +++ b/src/game/engine/scenes/hud/unit_panel.gd @@ -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). diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index 5718d7e7..84988e75 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -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) diff --git a/src/game/engine/src/modules/units/escort_controller.gd b/src/game/engine/src/modules/units/escort_controller.gd new file mode 100644 index 00000000..7f2ddd3f --- /dev/null +++ b/src/game/engine/src/modules/units/escort_controller.gd @@ -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 diff --git a/src/game/engine/src/modules/units/escort_controller.gd.uid b/src/game/engine/src/modules/units/escort_controller.gd.uid new file mode 100644 index 00000000..e821eeb2 --- /dev/null +++ b/src/game/engine/src/modules/units/escort_controller.gd.uid @@ -0,0 +1 @@ +uid://bmcwr1ovmj3s8