feat(entities): make Unit a hybrid proxy over the Rust presentation_units slot

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 01:54:35 -04:00
parent c428402698
commit 2ad4b7bed6
2 changed files with 666 additions and 72 deletions

View file

@ -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:

View file

@ -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<EquippedItem>` 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