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:
parent
c428402698
commit
2ad4b7bed6
2 changed files with 666 additions and 72 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue