From 2ad4b7bed6b2efabe55e263345b53370f0d6b192 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 28 Jun 2026 01:54:35 -0400 Subject: [PATCH] feat(entities): make Unit a hybrid proxy over the Rust presentation_units slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail-1 Phase-1 increment 1 — the units analogue of City's proxy over presentation_cities. Unit's authoritative gameplay surface (position, hp, max_hp, attack, defense, movement_remaining, xp/experience, the posture flags, promotions) now lives in the Rust `presentation_units` slot, reached via `GameState.get_gd_state().unit_*(_pi, _resolve_ui())`. The slot is positional, so the view keys on a stable u32 `rust_id` and re-resolves its row index on every access — a death (remove_unit shifts indices) or capture (transfer_unit changes both _pi and _ui) never leaves a getter reading the wrong unit. - Slot-backed fields become property getter/setter pairs that route to the slot when spawned, falling back to `_local_*` mirrors when unspawned / no dylib, so bare `Unit.new(...)` (arena tests, early construction) keeps working. - DUAL ID: `id: String` stays the renderer/debug key; `rust_id: int` is the Rust-backing key. unit_slot::spawn trusts the JSON-borne id (unlike City::found which derives it), so GameState owns id assignment via a new monotonic `next_unit_id()` counter (serialized; restored above loaded ids). - Wild creatures (owner -1) land in a dedicated wilds row at `players.size()` (`wilds_pi`/`unit_slot_pi`) so they never collide with player 0. - spawn_into_slot / remove_from_slot / transfer_to_owner are the slot lifecycle; from_save_dict reattaches a restored unit to a fresh slot entry keyed on its saved rust_id (rust_id 0 = never-slotted synthetic unit, left on its mirror so the save dict round-trips byte-identically). - deserialize / reset drain the unit slot (incl. the wilds row), mirroring the city-slot drain, so units do not accumulate across loads. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/game/engine/src/autoloads/game_state.gd | 59 ++ src/game/engine/src/entities/unit.gd | 679 +++++++++++++++++--- 2 files changed, 666 insertions(+), 72 deletions(-) diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 25ad3992..681ec6d1 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -119,6 +119,15 @@ var trade_ledger_json: String = "" ## go through `npc_buildings_all_views()` / `get_npc_buildings_at()`, which ## build ephemeral `BuildingScript` views (or dicts) on demand from the mirror. +## Monotonic counter for the stable `MapUnit.id` (u32) assigned to every unit at +## spawn. Mirrors the bench `mc_state::GameState::next_unit_id` contract: the +## live game owns id assignment because `unit_slot::spawn` trusts the JSON-borne +## id (it does NOT assign one, unlike `City::found` which derives the City.id +## from the name). Each `UnitScript.spawn_into_slot` claims the next value via +## `next_unit_id()`. Starts at 1 so 0 is never a live id (0 = "unassigned"). +## Serialized so loaded games keep issuing fresh, non-colliding ids. +var _next_unit_id: int = 1 + ## p2-72a Stage 4 — canonical Rust state handle. The long-lived GdGameState ## that renderers / UI / save-load read through. Created in `initialize_game` ## and `_ready`. Until view conversion lands (Waves 2-3), GDScript entity @@ -281,6 +290,44 @@ func _drain_city_slot(old_count: int) -> void: _gd_state.remove_city(pi, 0) +## Claim the next stable `MapUnit.id` (u32) and advance the counter. Called by +## `UnitScript.spawn_into_slot` so every live unit lands in `presentation_units` +## with a unique, monotonic id the proxy re-resolves its slot index from. +func next_unit_id() -> int: + var id: int = _next_unit_id + _next_unit_id += 1 + return id + + +## Slot row index used for owner-less units (wild creatures, `owner == -1`). The +## `presentation_units` slot is positional per player row; mapping wild units to +## `players.size()` keeps them in a dedicated row past every player so a -1 owner +## never collides with player 0's units. Stable for the life of a game (the +## player roster is fixed after setup). +func wilds_pi() -> int: + return players.size() + + +## Translate a unit `owner` (-1 = wild) into its `presentation_units` row index. +func unit_slot_pi(owner: int) -> int: + return owner if owner >= 0 else wilds_pi() + + +## Clear the parallel unit slot before a load / new game so it does not +## accumulate stale units across sessions (mirrors `_drain_city_slot`). Drains +## every player row plus the wilds row. +func _drain_unit_slot(old_count: int) -> void: + if _gd_state == null: + return + if not _gd_state.has_method("presentation_unit_count") \ + or not _gd_state.has_method("remove_unit"): + return + # +1 to also drain the wilds row that sits at `players.size()`. + for pi: int in range(old_count + 1): + while int(_gd_state.presentation_unit_count(pi)) > 0: + _gd_state.remove_unit(pi, 0) + + func initialize_game(settings: Dictionary) -> void: game_settings = DEFAULT_SETTINGS.duplicate() for key: String in settings: @@ -296,6 +343,8 @@ func initialize_game(settings: Dictionary) -> void: current_player_index = 0 turn_order = [] _drain_city_slot(players.size()) + _drain_unit_slot(players.size()) + _next_unit_id = 1 players = [] layers = [] transit_nodes = [] @@ -676,6 +725,7 @@ func serialize() -> Dictionary: "npc_buildings": _serialize_npc_buildings(), "diplomacy": diplomacy.duplicate(), "trade_ledger_json": trade_ledger_json, + "next_unit_id": _next_unit_id, } for player: Variant in players: @@ -718,6 +768,10 @@ func deserialize(data: Dictionary) -> void: # `City.from_save_dict` → `spawn_city`. _ensure_gd_state() _drain_city_slot(players.size()) + # Mirror for the parallel unit slot: drain stale units (old player count + + # the wilds row) before player units rehydrate through + # `Unit.from_save_dict` → `spawn_into_slot`. + _drain_unit_slot(players.size()) if _gd_state != null: _load_city_items_into(_gd_state) @@ -728,6 +782,11 @@ func deserialize(data: Dictionary) -> void: player.deserialize(player_data) players.append(player) + # Restore the unit-id counter LAST: it must sit above every loaded unit's id + # so freshly spawned units never collide with restored ones. Saves predating + # this key fall back to 1 (older saves had no parallel unit slot anyway). + _next_unit_id = int(data.get("next_unit_id", 1)) + layers = [] for layer_data: Variant in data.get("layers", []): if layer_data is Dictionary: diff --git a/src/game/engine/src/entities/unit.gd b/src/game/engine/src/entities/unit.gd index 189fc69e..2833fd95 100644 --- a/src/game/engine/src/entities/unit.gd +++ b/src/game/engine/src/entities/unit.gd @@ -1,24 +1,49 @@ class_name Unit extends RefCounted -## Game-side unit entity. Holds runtime state for one military or civilian -## unit on the world map. Stats originate from JSON unit data via DataLoader; -## this class tracks the per-instance mutable runtime state (position, hp, -## promotions, infusions, movement budget, fortification, XP/level). +## Game-side unit entity — hybrid id-keyed thin view over the shared +## parallel-slot `mc_state::MapUnit` held on the `GdGameState` singleton at +## `GameState._gd_state.presentation_units[_pi][_ui]`. The units analogue of +## `City` (`city.gd`) over `presentation_cities`. ## -## Restored in iter 7i from a 2-line stub. The field set is the union of -## every Unit field/method the existing GDScript codebase reads or writes -## (verified by grep across engine/src/ before authoring this class). +## Rail-1 Phase-1 increment 1 (SOT flip for units): the authoritative gameplay +## surface — position, hp/max_hp, attack/defense, movement_remaining, xp +## (Rust `experience`), the posture flags (is_fortified / is_sentrying / +## is_deployed / is_embarked), and promotions — now lives in the Rust slot and +## is reached via `_gd_state.unit_*(_pi, _resolve_ui(), …)`. The slot is +## positional, so this view keys on the STABLE `rust_id` (u32) and re-resolves +## the row index on every access — a death (`remove_unit` shifts later indices) +## or a capture (`transfer_unit` changes both `_pi` and `_ui`) never leaves a +## getter reading the wrong unit. ## -## The bridge to mc_turn::MapUnit lives in `to_bridge_dict()` and the -## inverse `apply_bridge_dict()` — that's the iter 7h dict adapter. +## HYBRID — not a pure thin view. Units carry presentation/runtime residue with +## no `MapUnit` field in Game-1 scope (`display_name`, `unit_type`, `domain`, +## `vision`, `keywords`, the D20 attribute stubs, `equipped_items`, `infusions`, +## `channeled_*`, `fortified_turns`, `has_attacked`, `level`). Those stay plain +## GDScript fields; only the Rust-backed surface swapped its backing store. +## +## DUAL ID: `id: String` (e.g. "unit_p0_3_4_2", "wild_1234") remains the display +## / debug / renderer key — `unit_renderer.gd::_resolve_unit_id` reads it. The +## Rust-backing key is `rust_id: int` (the stable monotonic u32 from +## `GameState.next_unit_id`), because `unit_slot::spawn` trusts the JSON-borne +## id (unlike `City::found`, which derives the City.id from the name). +## +## NOT-SPAWNED / NO-EXTENSION FALLBACK: until `spawn_into_slot` runs (or when the +## GDExtension dylib is absent, e.g. some headless paths), every slot-backed +## accessor falls back to the `_local_*` mirror, so a bare `Unit.new(...)` keeps +## working for arena tests and early construction exactly as before. const UnitStatHelpers: GDScript = preload( "res://engine/src/entities/unit_stat_helpers.gd" ) # ── Identity ─────────────────────────────────────────────────────────── -## Unique instance identifier, e.g. "unit_0_3". Set by the spawner. +## Display / debug / renderer id, e.g. "unit_0_3". Set by the spawner. NOT the +## Rust-backing key — that is `rust_id`. var id: String = "" +## Stable monotonic Rust-slot id (u32), claimed from `GameState.next_unit_id` +## at `spawn_into_slot`. 0 = not yet spawned into the slot. The key the view +## re-resolves `_ui` from on every access. +var rust_id: int = 0 ## DataLoader unit type id, e.g. "dwarf_warrior". var unit_id: String = "" ## Alias for unit_id — used by renderers and UI panels. @@ -40,20 +65,57 @@ var can_found_city: bool = false var can_build_improvements: bool = false # ── Position & ownership ────────────────────────────────────────────── -## Axial position on the world map. -var position: Vector2i = Vector2i.ZERO -## Owning player index. -1 = wild creature, 0..n = player slot. -var owner: int = -1 +## Owning player index. -1 = wild creature, 0..n = player slot. Mirrors into +## `_pi` (the slot owner row): -1 maps to the wilds row via +## `GameState.unit_slot_pi`, so re-stamping `owner` after a capture re-points +## the view. NOTE: the slot is NOT relocated by this setter — capture must call +## `transfer_to_owner`, which moves the slot entry and then re-stamps `owner`. +var owner: int = -1: + set(v): + owner = v + _pi = GameState.unit_slot_pi(v) if _has_game_state() else maxi(v, 0) + +## Axial position on the world map. Slot-backed (Rust `col`/`row`). +var position: Vector2i = Vector2i.ZERO: + get: + return _get_position() + set(v): + _set_position(v) # ── Combat stats ────────────────────────────────────────────────────── -var hp: int = 1 -var max_hp: int = 1 -var attack: int = 0 -var defense: int = 0 +## Current HP. Slot-backed. +var hp: int = 1: + get: + return _get_hp() + set(v): + _set_hp(v) +## Max HP. Slot-backed. +var max_hp: int = 1: + get: + return _get_max_hp() + set(v): + _set_max_hp(v) +## Attack. Slot-backed (read); local-only when unspawned. +var attack: int = 0: + get: + return _get_attack() + set(v): + _set_attack(v) +## Defense. Slot-backed (read); local-only when unspawned. +var defense: int = 0: + get: + return _get_defense() + set(v): + _set_defense(v) var ranged_attack: int = 0 # ── Movement ────────────────────────────────────────────────────────── -var movement_remaining: int = 2 +## Remaining movement points this turn. Slot-backed. +var movement_remaining: int = 2: + get: + return _get_movement_remaining() + set(v): + _set_movement_remaining(v) var max_movement: int = 2 # ── Vision ──────────────────────────────────────────────────────────── @@ -62,19 +124,43 @@ var vision: int = 2 # ── Per-turn state flags ────────────────────────────────────────────── var has_attacked: bool = false -var is_fortified: bool = false +## Fortified posture. Slot-backed (Rust `is_fortified`). +var is_fortified: bool = false: + get: + return _get_is_fortified() + set(v): + _set_is_fortified(v) var fortified_turns: int = 0 ## True when the unit is in sentry posture (wakes on enemy within 2 hex). ## Distinct from is_fortified (no stat bonus, different wake condition). -var is_sentrying: bool = false +## Slot-backed (Rust `is_sentrying`). +var is_sentrying: bool = false: + get: + return _get_is_sentrying() + set(v): + _set_is_sentrying(v) ## True when a siege unit is in deployed posture (cannot move, can Bombard). -var is_deployed: bool = false -## True when a land unit is embarked on a water tile (p3-18). Mirrors the Rust -## MapUnit::is_embarked; round-tripped for save/load. -var is_embarked: bool = false +## Slot-backed (Rust `is_deployed`). +var is_deployed: bool = false: + get: + return _get_is_deployed() + set(v): + _set_is_deployed(v) +## True when a land unit is embarked on a water tile (p3-18). Slot-backed +## (Rust `is_embarked`); round-tripped for save/load. +var is_embarked: bool = false: + get: + return _get_is_embarked() + set(v): + _set_is_embarked(v) # ── Veterancy ───────────────────────────────────────────────────────── -var xp: int = 0 +## Experience points. Slot-backed (Rust `experience`). +var xp: int = 0: + get: + return _get_xp() + set(v): + _set_xp(v) var level: int = 1 # ── D20 attribute stubs ─────────────────────────────────────────────── @@ -97,9 +183,13 @@ var formation_count: int = 1 var stimulant_penalty: int = 0 # ── Promotions, items, magic ────────────────────────────────────────── +## Promotion ids. Slot-backed (Rust `promotions`); the local mirror is kept in +## sync so typed-array consumers (UnitStatHelpers) read an Array[String]. var promo_ids: Array[String] = [] ## Equipped items: each entry is {item_id: String, charges_remaining: int}. ## Must stay strongly typed — GdItemSystem's FFI rejects untyped arrays. +## GDScript-only residue in Game-1 (the slot's `equipped` is a richer +## `Vec` not yet routed through this view). var equipped_items: Array[Dictionary] = [] ## Currently active enchantment IDs (non-channeled). var infusions: Array[String] = [] @@ -108,23 +198,55 @@ var channeled_infusion: String = "" ## Turns remaining before the channeled infusion fades after disconnection. var channeled_fade_turns: int = 0 +# ── Slot backing ────────────────────────────────────────────────────── +## Shared GdGameState bridge (the slot owner). Lazily acquired; `null` when the +## GDExtension is absent (headless / early boot) — every accessor null-guards +## and falls back to the `_local_*` mirror. +var _gd_state: RefCounted = null +## Owner row index into `presentation_units`. Kept in sync with `owner` by the +## `owner` setter; re-stamped explicitly on capture / locate. +var _pi: int = -1 +## Last resolved unit index within `presentation_units[_pi]`. A CACHE only — +## `_resolve_ui()` re-derives it from the stable `rust_id` on every access. +var _ui_cache: int = -1 +## Warn-once latch for the missing-extension path. +var _warned: bool = false + +# ── Local mirrors (fallback when unspawned / no extension) ──────────── +## Backing for the slot-backed properties when `rust_id == 0` (not yet spawned) +## or the extension is absent. Spawn copies these into the slot; thereafter the +## slot is authoritative and these are unused for live games. +var _local_position: Vector2i = Vector2i.ZERO +var _local_hp: int = 1 +var _local_max_hp: int = 1 +var _local_attack: int = 0 +var _local_defense: int = 0 +var _local_movement_remaining: int = 2 +var _local_xp: int = 0 +var _local_is_fortified: bool = false +var _local_is_sentrying: bool = false +var _local_is_deployed: bool = false +var _local_is_embarked: bool = false + # ── Lifecycle ───────────────────────────────────────────────────────── ## Construct a Unit with the given identity. Stats are populated from the -## DataLoader entry if `populate_from_data` is true. +## DataLoader entry if `populate_from_data` is true. Does NOT spawn into the +## slot — call `spawn_into_slot()` once identity + position are final. func _init(p_unit_id: String = "", p_owner: int = -1, p_position: Vector2i = Vector2i.ZERO, populate_from_data: bool = true) -> void: unit_id = p_unit_id type_id = p_unit_id owner = p_owner - position = p_position + _local_position = p_position if populate_from_data and unit_id != "": _populate_from_data() ## Read base stats from `DataLoader.get_unit(unit_id)` and apply them. -## Safe to call repeatedly — overwrites mutable stat fields. +## Safe to call repeatedly — overwrites mutable stat fields. Writes through the +## property setters so a spawned unit's stats land in the slot. func _populate_from_data() -> void: var data: Dictionary = DataLoader.get_unit(unit_id) if data.is_empty(): @@ -139,12 +261,8 @@ func _populate_from_data() -> void: vision = data.get("vision", 2) # JSON data authored for Age of Dwarves uses `unit_type` for the role # ("military"/"civilian"/"support") and `combat_type` for the weapon - # profile ("melee"/"ranged"/"siege"). Earlier iterations read - # `combat_type` into this field, which silently produced an empty - # string for every dwarf unit and broke downstream consumers like - # the pathfinder's unit_type gate. Read both fields now — prefer - # `combat_type` when present (some reference units still use it) and - # fall back to `unit_type`, so arena units always report a non-empty + # profile ("melee"/"ranged"/"siege"). Prefer `combat_type` when present + # and fall back to `unit_type`, so arena units always report a non-empty # role string. `domain` carries the pathfinder-facing passability tag. var combat_type: String = String(data.get("combat_type", "")) if combat_type.is_empty(): @@ -161,6 +279,364 @@ func _populate_from_data() -> void: can_build_improvements = "worker" in keywords +## Spawn this unit into the parallel `presentation_units` slot. Claims a stable +## `rust_id`, packs the current (local) field values into the `MapUnit` JSON +## shape, pushes it under the owner row (`_pi`), and caches `_ui`. Idempotent: +## a second call (already spawned) is a no-op. Returns the slot index, or -1 +## when the extension is absent (the unit stays on its `_local_*` mirror). +func spawn_into_slot() -> int: + if rust_id != 0: + return _resolve_ui() + var state: RefCounted = _ensure_state() + if state == null: + return -1 + rust_id = int(GameState.next_unit_id()) + _pi = GameState.unit_slot_pi(owner) + var ui: int = int(state.spawn_unit(_pi, _to_spawn_json())) + if ui < 0: + # Parse failure (logged Rust-side). Roll back the id so the unit stays on + # the local mirror rather than silently dropping into a phantom slot. + rust_id = 0 + return -1 + _ui_cache = ui + return ui + + +## Pack the current local field values into the `mc_state::MapUnit` JSON the +## slot's `spawn_unit` parses. Includes every no-serde-default field +## (col, row, hp, max_hp, attack, defense, is_fortified, unit_id) plus the +## movement / xp / posture / promotions surface this view backs. +func _to_spawn_json() -> String: + var promos: Array = [] + for p: String in promo_ids: + promos.append(p) + var d: Dictionary = { + "id": rust_id, + "col": _local_position.x, + "row": _local_position.y, + "hp": _local_hp, + "max_hp": _local_max_hp, + "attack": _local_attack, + "defense": _local_defense, + "is_fortified": _local_is_fortified, + "is_sentrying": _local_is_sentrying, + "is_deployed": _local_is_deployed, + "is_embarked": _local_is_embarked, + "unit_id": unit_id, + "base_moves": max_movement, + "movement_remaining": _local_movement_remaining, + "experience": _local_xp, + "promotions": promos, + } + return JSON.stringify(d) + + +## Acquire (once) the shared GdGameState bridge. Returns `null` and warns once +## when the GDExtension is not loaded. +func _ensure_state() -> RefCounted: + if _gd_state != null: + return _gd_state + if _has_game_state(): + _gd_state = GameState.get_gd_state() + if _gd_state == null: + _warn_missing() + return _gd_state + + +func _has_game_state() -> bool: + if Engine.has_singleton("GameState"): + return true + var tree: SceneTree = Engine.get_main_loop() as SceneTree + return tree != null and tree.root != null and tree.root.has_node("GameState") + + +func _warn_missing() -> void: + if _warned: + return + _warned = true + push_warning( + "Unit: GdGameState slot is unavailable. " + + "Unit methods use the local mirror until the extension is loaded." + ) + + +## Re-resolve the unit's slot index from its stable `rust_id`. Returns -1 when +## the unit is not in the slot (never spawned / destroyed / extension absent); +## callers then fall back to the local mirror. +func _resolve_ui() -> int: + if rust_id == 0: + return -1 + var state: RefCounted = _ensure_state() + if state == null: + return -1 + # Fast path: same owner row. + var ui: int = int(state.unit_index_by_id(_pi, rust_id)) + if ui >= 0: + _ui_cache = ui + return ui + # Cross-player fallback: a capture moved the unit between rows. Re-stamp via + # the `owner` setter so `owner`/`_pi` stay in sync, then cache the new index. + var loc: Vector2i = state.unit_locate_by_id(rust_id) + if loc.x >= 0: + _pi = loc.x + _ui_cache = loc.y + return loc.y + return -1 + + +## Fetch the full slot projection for this unit once. Empty Dictionary when the +## unit cannot be resolved (unspawned / destroyed / extension absent). +func _dict() -> Dictionary: + var state: RefCounted = _ensure_state() + if state == null: + return {} + var ui: int = _resolve_ui() + if ui < 0: + return {} + return state.unit_dict(_pi, ui) + + +# ── Slot-backed accessors (position) ────────────────────────────────── + +func _get_position() -> Vector2i: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_position + return d.get("position", _local_position) + + +func _set_position(v: Vector2i) -> void: + _local_position = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui >= 0: + state.move_unit_slot(_pi, ui, v.x, v.y) + + +# ── Slot-backed accessors (hp / max_hp) ─────────────────────────────── + +func _get_hp() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_hp + return int(d.get("hp", _local_hp)) + + +func _set_hp(v: int) -> void: + _local_hp = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui >= 0: + state.unit_set_hp(_pi, ui, v) + + +func _get_max_hp() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_max_hp + return int(d.get("max_hp", _local_max_hp)) + + +func _set_max_hp(v: int) -> void: + _local_max_hp = v + # No dedicated `unit_set_max_hp` FFI in the documented surface; max_hp is set + # at spawn (from JSON) and is otherwise immutable in Game-1. The local mirror + # keeps `get_max_hp()` correct for the rare unspawned path; for a spawned + # unit the slot's spawn-time max_hp is authoritative. + + +# ── Slot-backed accessors (attack / defense) ────────────────────────── + +func _get_attack() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_attack + return int(d.get("attack", _local_attack)) + + +func _set_attack(v: int) -> void: + _local_attack = v + + +func _get_defense() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_defense + return int(d.get("defense", _local_defense)) + + +func _set_defense(v: int) -> void: + _local_defense = v + + +# ── Slot-backed accessors (movement_remaining) ──────────────────────── + +func _get_movement_remaining() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_movement_remaining + return int(d.get("movement_remaining", _local_movement_remaining)) + + +func _set_movement_remaining(v: int) -> void: + _local_movement_remaining = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui >= 0: + state.unit_set_movement_remaining(_pi, ui, v) + + +# ── Slot-backed accessors (xp / experience) ─────────────────────────── + +func _get_xp() -> int: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_xp + return int(d.get("experience", _local_xp)) + + +func _set_xp(v: int) -> void: + _local_xp = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui < 0: + return + # No dedicated `unit_set_experience` FFI; round-trip through the unit JSON so + # the slot's experience stays the source of truth (rare write path — + # promotions/xp gains funnel here). + var cur: Dictionary = state.unit_dict(_pi, ui) + if cur.is_empty(): + return + var j: Dictionary = JSON.parse_string(String(state.unit_to_json(_pi, ui))) + if j == null: + return + j["experience"] = v + state.unit_load_from_json(_pi, ui, JSON.stringify(j)) + + +# ── Slot-backed accessors (posture flags) ───────────────────────────── + +func _get_is_fortified() -> bool: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_is_fortified + return bool(d.get("is_fortified", _local_is_fortified)) + + +func _set_is_fortified(v: bool) -> void: + _local_is_fortified = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui >= 0: + state.unit_set_fortified(_pi, ui, v) + + +func _get_is_sentrying() -> bool: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_is_sentrying + return bool(d.get("is_sentrying", _local_is_sentrying)) + + +func _set_is_sentrying(v: bool) -> void: + _local_is_sentrying = v + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui >= 0: + state.unit_set_sentrying(_pi, ui, v) + + +func _get_is_deployed() -> bool: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_is_deployed + return bool(d.get("is_deployed", _local_is_deployed)) + + +func _set_is_deployed(v: bool) -> void: + # No dedicated posture FFI for deploy in the documented surface; the slot's + # `is_deployed` is toggled by the dispatch path (mc-turn deploy/pack siege). + # This view mirrors the read; the write keeps the local mirror coherent for + # the unspawned path. + _local_is_deployed = v + + +func _get_is_embarked() -> bool: + var d: Dictionary = _dict() + if d.is_empty(): + return _local_is_embarked + return bool(d.get("is_embarked", _local_is_embarked)) + + +func _set_is_embarked(v: bool) -> void: + # Slot `is_embarked` is set automatically by `mc-turn::process_one_move` + # from the destination tile (no explicit embark action). This view mirrors + # the read; the write keeps the local mirror coherent for the unspawned path. + _local_is_embarked = v + + +# ── Capture / ownership transfer ────────────────────────────────────── + +## Move this unit's slot entry to `new_owner`'s row and re-stamp the view. The +## unit-side analogue of `combat_utils.capture_city` → `move_city`. There is no +## live unit-capture path in Game-1 (units die, they are not captured), so this +## is provided for parity / future use and is exercised only by tests. Returns +## the new slot index, or -1 if the transfer failed. +func transfer_to_owner(new_owner: int) -> int: + var state: RefCounted = _ensure_state() + if state == null: + owner = new_owner + return -1 + var old_pi: int = _pi + var old_ui: int = _resolve_ui() + if old_ui < 0: + owner = new_owner + return -1 + var to_pi: int = GameState.unit_slot_pi(new_owner) + var new_ui: int = int(state.transfer_unit(old_pi, old_ui, to_pi)) + owner = new_owner + _pi = to_pi + _ui_cache = new_ui + return new_ui + + +# ── Death / removal ─────────────────────────────────────────────────── + +## Remove this unit's entry from the parallel slot (death / unit-loss). The +## unit-side analogue of `remove_city`. Index-shift-safe: survivors stay +## addressable by their stable `rust_id`. After this the view no longer resolves +## (getters fall back to the frozen local mirror, which the dying-unit signal +## handlers may still read for loot / chronicle). +func remove_from_slot() -> void: + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui < 0: + return + # Snapshot the slot's final values into the local mirror so post-death reads + # (loot rolls, kill chronicle) see the unit as it was, not safe defaults. + var d: Dictionary = state.unit_dict(_pi, ui) + if not d.is_empty(): + _local_position = d.get("position", _local_position) + _local_hp = int(d.get("hp", _local_hp)) + state.remove_unit(_pi, ui) + rust_id = 0 + _ui_cache = -1 + + # ── Per-turn refresh (called by turn_processor.gd) ──────────────────── ## Accumulate experience points from combat or events. Called by @@ -184,11 +660,9 @@ func can_promote() -> bool: ## (`scenes/combat/promotion_picker.gd`) and the AI auto-pick path ## (`ai_turn_bridge_dispatch.gd::dispatch_promotion_picked`, p2-44). ## -## Idempotent: an empty id or a duplicate is a no-op (we never want a -## second-instance dispatch to crash the turn). Emits no signal — the -## caller is responsible for `EventBus.unit_promoted.emit(self, promo_id)` -## so audio / chronicle / throne-room subscribers fire exactly once per -## promotion regardless of which path applied it. +## Idempotent: an empty id or a duplicate is a no-op. Emits no signal — the +## caller fires `EventBus.unit_promoted.emit(self, promo_id)` so audio / +## chronicle / throne-room subscribers fire exactly once per promotion. func promote(promo_id: String) -> void: if promo_id.is_empty() or promo_ids.has(promo_id): return @@ -196,6 +670,28 @@ func promote(promo_id: String) -> void: veteran_level += 1 # Mirror mc_combat::heal_on_promote: heal 50% of max HP, capped at max. hp = mini(max_hp, hp + int(round(float(max_hp) * 0.5))) + # Mirror the new promotion into the slot's `promotions` so the Rust-backed + # projection stays authoritative. + _sync_promotions_to_slot() + + +## Push the local `promo_ids` into the slot's `promotions` via the unit JSON. +## No-op when unspawned / extension absent. +func _sync_promotions_to_slot() -> void: + var state: RefCounted = _ensure_state() + if state == null: + return + var ui: int = _resolve_ui() + if ui < 0: + return + var j: Dictionary = JSON.parse_string(String(state.unit_to_json(_pi, ui))) + if j == null: + return + var promos: Array = [] + for p: String in promo_ids: + promos.append(p) + j["promotions"] = promos + state.unit_load_from_json(_pi, ui, JSON.stringify(j)) ## Reset per-turn state flags. Called at the top of each player turn. @@ -255,9 +751,8 @@ func get_defense() -> int: ## Defensive bonus earned by fortifying in place. Capped at 2 turns of -## accumulation (same formula that was inlined in `get_defense`). Read by -## CombatResolver._build_unit_dict when packing the attacker/defender -## dict for the Rust combat FFI. +## accumulation. Read by CombatResolver._build_unit_dict when packing the +## attacker/defender dict for the Rust combat FFI. func get_fortification_bonus() -> int: if not is_fortified: return 0 @@ -346,15 +841,30 @@ func get_movement() -> int: return maxi(1, total) -## Apply incoming damage. Returns true if the unit is now dead. +## Apply incoming damage. Returns true if the unit is now dead. Routes through +## the slot (`unit_take_damage` clamps at 0) when spawned; the read-back keeps +## the local mirror and return value correct on either path. func take_damage(amount: int) -> bool: - hp = maxi(0, hp - amount) + var state: RefCounted = _ensure_state() + var ui: int = _resolve_ui() if state != null else -1 + if ui >= 0: + state.unit_take_damage(_pi, ui, amount) + _local_hp = _get_hp() + else: + _local_hp = maxi(0, _local_hp - amount) return hp <= 0 -## Apply healing, capped at max_hp. +## Apply healing, capped at max_hp. Routes through the slot (`unit_heal`) when +## spawned. func heal(amount: int) -> void: - hp = mini(max_hp, hp + amount) + var state: RefCounted = _ensure_state() + var ui: int = _resolve_ui() if state != null else -1 + if ui >= 0: + state.unit_heal(_pi, ui, amount) + _local_hp = _get_hp() + else: + _local_hp = mini(_local_max_hp, _local_hp + amount) # ── Iter 7h bridge adapter ──────────────────────────────────────────── @@ -383,15 +893,11 @@ func apply_bridge_dict(d: Dictionary) -> void: # ── Save / load ─────────────────────────────────────────────────────── -# Note (p2-11b tracking): the dict shape below is GDScript scaffolding -# while Unit lives in GDScript. The Rail-1 long-term move mirrors p1-55 / -# p1-56 (PlayerState / CityState into mc-turn): once Unit migrates to the -# `mc-entities` crate, serde there becomes the source of truth and these -# methods become thin forwarders. Tracked by objective p2-11b. -## Pack every gameplay-meaningful field into a JSON-safe Dictionary. -## Keys are emitted in the order declared so re-saves are byte-identical; -## sorted serialisation in SaveManager will further canonicalise. +## Pack every gameplay-meaningful field into a JSON-safe Dictionary. Reads the +## slot-backed fields through their property getters, so a spawned unit's +## save reflects the authoritative Rust slot. `rust_id` is persisted so the +## reattached proxy resolves the same `MapUnit` after load. func to_save_dict() -> Dictionary: # Strongly-typed array fields are duplicated into untyped Arrays of # primitives so JSON.stringify cannot trip over typed-array internals. @@ -409,6 +915,7 @@ func to_save_dict() -> Dictionary: equipped_data.append(entry.duplicate(true)) return { "id": id, + "rust_id": rust_id, "unit_id": unit_id, "type_id": type_id, "display_name": display_name, @@ -456,11 +963,13 @@ func to_save_dict() -> Dictionary: } -## Restore every gameplay-meaningful field from a `to_save_dict()` snapshot. -## Missing keys fall back to the current field value so partial / older -## saves load cleanly. Typed-array fields are restored via clear()+append() -## so the Array[T] constraint survives — `field = data["..."]` would silently -## drop the type and break every typed-array consumer downstream. +## Restore every gameplay-meaningful field from a `to_save_dict()` snapshot, +## then spawn the unit into the parallel slot so the proxy reattaches to a live +## `MapUnit`. The load restores `_local_*` mirrors FIRST (writing the property +## fields populates the mirrors while `rust_id == 0`), then `spawn_into_slot` +## pushes those into a fresh slot entry keyed on the restored `rust_id`. +## Missing keys fall back to the current field value so partial / older saves +## load cleanly. func from_save_dict(data: Dictionary) -> void: id = str(data.get("id", id)) unit_id = str(data.get("unit_id", unit_id)) @@ -474,25 +983,28 @@ func from_save_dict(data: Dictionary) -> void: keywords.append(String(k)) can_found_city = bool(data.get("can_found_city", can_found_city)) can_build_improvements = bool(data.get("can_build_improvements", can_build_improvements)) + # owner is set BEFORE the slot fields so `_pi` resolves to the correct row + # (the wilds row for owner == -1) before `spawn_into_slot`. + owner = int(data.get("owner", owner)) var pos_raw: Array = data.get("position", []) as Array if pos_raw.size() == 2: - position = Vector2i(int(pos_raw[0]), int(pos_raw[1])) - owner = int(data.get("owner", owner)) - hp = int(data.get("hp", hp)) - max_hp = int(data.get("max_hp", max_hp)) - attack = int(data.get("attack", attack)) - defense = int(data.get("defense", defense)) + _local_position = Vector2i(int(pos_raw[0]), int(pos_raw[1])) + # Stat / posture fields land in the local mirror (rust_id is still 0 here). + _local_hp = int(data.get("hp", _local_hp)) + _local_max_hp = int(data.get("max_hp", _local_max_hp)) + _local_attack = int(data.get("attack", _local_attack)) + _local_defense = int(data.get("defense", _local_defense)) ranged_attack = int(data.get("ranged_attack", ranged_attack)) - movement_remaining = int(data.get("movement_remaining", movement_remaining)) + _local_movement_remaining = int(data.get("movement_remaining", _local_movement_remaining)) max_movement = int(data.get("max_movement", max_movement)) vision = int(data.get("vision", vision)) has_attacked = bool(data.get("has_attacked", has_attacked)) - is_fortified = bool(data.get("is_fortified", is_fortified)) + _local_is_fortified = bool(data.get("is_fortified", _local_is_fortified)) fortified_turns = int(data.get("fortified_turns", fortified_turns)) - is_sentrying = bool(data.get("is_sentrying", is_sentrying)) - is_deployed = bool(data.get("is_deployed", is_deployed)) - is_embarked = bool(data.get("is_embarked", is_embarked)) - xp = int(data.get("xp", xp)) + _local_is_sentrying = bool(data.get("is_sentrying", _local_is_sentrying)) + _local_is_deployed = bool(data.get("is_deployed", _local_is_deployed)) + _local_is_embarked = bool(data.get("is_embarked", _local_is_embarked)) + _local_xp = int(data.get("xp", _local_xp)) level = int(data.get("level", level)) base_str = int(data.get("base_str", base_str)) base_dex = int(data.get("base_dex", base_dex)) @@ -522,3 +1034,26 @@ func from_save_dict(data: Dictionary) -> void: infusions.append(String(i)) channeled_infusion = str(data.get("channeled_infusion", channeled_infusion)) channeled_fade_turns = int(data.get("channeled_fade_turns", channeled_fade_turns)) + # Reattach to the parallel slot using the SAVED rust_id (so the proxy + # resolves the same MapUnit the save serialized). A saved id of 0 means the + # unit was never slotted (a synthetic / test unit that never entered the + # world) — leave it on its local mirror untouched so the save dict + # round-trips byte-identically. A live save always carries rust_id > 0. + var saved_rust_id: int = int(data.get("rust_id", 0)) + if saved_rust_id > 0: + _spawn_into_slot_with_id(saved_rust_id) + + +## Spawn into the slot with an explicit (restored) `rust_id` rather than claiming +## a fresh one from the counter. Used by `from_save_dict`. +func _spawn_into_slot_with_id(restored_id: int) -> void: + var state: RefCounted = _ensure_state() + if state == null: + return + rust_id = restored_id + _pi = GameState.unit_slot_pi(owner) + var ui: int = int(state.spawn_unit(_pi, _to_spawn_json())) + if ui < 0: + rust_id = 0 + return + _ui_cache = ui