feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (1/4) — improvement subsystem state model
Groundwork for the headless improvement subsystem (wired in the next increments): - PlayerState.pending_improvements: Vec<PendingImprovement> (#[serde(default)]) — tile improvements under construction. - GameState.improvement_defs: BTreeMap<String, ImprovementDef> (#[serde(skip)] boot) + load_improvement_defs_json (parses public/resources/improvements/*.json → build_turns + food/production yields). - ImprovementDef + PendingImprovement structs. mc-state 14/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
57725d0088
commit
cb451832e0
10 changed files with 783 additions and 8 deletions
|
|
@ -51,6 +51,12 @@ func _ready() -> void:
|
|||
_start_arena_session()
|
||||
return
|
||||
|
||||
# OBSERVER=1 boots straight into an all-AI clan game in caster/observer mode
|
||||
# (spectator HUD + per-clan fog flip + pacing) for casting and demoing.
|
||||
if EnvConfig.get_bool("OBSERVER"):
|
||||
_start_observer_session()
|
||||
return
|
||||
|
||||
# MC_AUTO_START=1 boots straight into an interactive seeded game, skipping
|
||||
# the menu/setup flow. Used by the rendered MCP driver (p2-86) and by
|
||||
# proof-capture tooling so screenshots reach in-game screens without a human
|
||||
|
|
@ -87,6 +93,31 @@ func _start_autostart_session() -> void:
|
|||
change_scene(LOADING_SCREEN_PATH)
|
||||
|
||||
|
||||
func _start_observer_session() -> void:
|
||||
## Boot directly into an all-AI clan match for casting/demoing (OBSERVER mode).
|
||||
## Mirrors the arena boot but seats the full clan roster (default 5) on a
|
||||
## larger map with the turn limit disabled so the cast runs to a natural
|
||||
## victory (or until the caster stops it). loading_screen.gd treats OBSERVER
|
||||
## like arena — every slot is AI and gets its clan personality + name.
|
||||
var seed: int = EnvConfig.get_int("OBSERVER_SEED", randi())
|
||||
var players: int = EnvConfig.get_int("OBSERVER_PLAYERS", 5)
|
||||
var settings: Dictionary = {
|
||||
"map_size": EnvConfig.get_var("OBSERVER_MAP_SIZE", "small"),
|
||||
"map_type": "pangaea",
|
||||
"map_wrap": "sphere",
|
||||
"difficulty": "normal",
|
||||
"game_speed": "standard",
|
||||
"num_players": players,
|
||||
"turn_limit": 0,
|
||||
"turn_limit_enabled": false,
|
||||
"seed": seed,
|
||||
"era_difficulty_correlation": true,
|
||||
}
|
||||
GameState.initialize_game(settings)
|
||||
GameState.map_seed = seed
|
||||
change_scene(LOADING_SCREEN_PATH)
|
||||
|
||||
|
||||
func _start_arena_session() -> void:
|
||||
## Skip the main menu / game-setup flow when running in spectator arena
|
||||
## mode. The orchestrator script provides seed, turn limit, and player
|
||||
|
|
|
|||
|
|
@ -97,7 +97,9 @@ func _stage(label: String, from_pct: float, to_pct: float) -> void:
|
|||
await get_tree().create_timer(TICK_DELAY).timeout
|
||||
|
||||
func _create_players() -> void:
|
||||
var arena_mode: bool = EnvConfig.get_bool("AI_ARENA")
|
||||
# OBSERVER (caster mode) seats every slot as AI exactly like arena, so each
|
||||
# clan gets its personality + flavour name for the cast.
|
||||
var arena_mode: bool = EnvConfig.get_bool("AI_ARENA") or EnvConfig.get_bool("OBSERVER")
|
||||
# Prefer the game pack's declared default_race; fall back to the first
|
||||
# loaded race only when not declared. The global resources dir may load
|
||||
# non-game-1 races (beastmen, elves…) that sort alphabetically before
|
||||
|
|
|
|||
82
src/game/engine/scenes/world_map/observer_controls.gd
Normal file
82
src/game/engine/scenes/world_map/observer_controls.gd
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
class_name ObserverControls
|
||||
extends Node
|
||||
## Caster input handler for observer/spectator mode. Mounted as a child of the
|
||||
## world map AFTER arena teardown disables world_map._unhandled_input, so these
|
||||
## keys are the only live input in a cast:
|
||||
##
|
||||
## 0 god view (omniscient)
|
||||
## 1 - 5 that clan's fog-of-war view
|
||||
## space pause / resume continuous play
|
||||
## . step one turn (while paused)
|
||||
## + / - faster / slower
|
||||
## F toggle the follow-action camera
|
||||
##
|
||||
## Pure presentation: drives world_map.observer_set_view / observer_set_follow
|
||||
## and TurnManager pacing; mutates no simulation state.
|
||||
|
||||
const SPEED_STEPS: Array[float] = [0.25, 0.5, 1.0, 2.0, 4.0]
|
||||
|
||||
var _world_map: Node = null
|
||||
var _hud: RefCounted = null # ObserverHud
|
||||
var _speed_index: int = 2
|
||||
var _follow_enabled: bool = true
|
||||
|
||||
|
||||
func setup(world_map: Node, hud: RefCounted) -> void:
|
||||
_world_map = world_map
|
||||
_hud = hud
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||||
return
|
||||
var key: int = (event as InputEventKey).keycode
|
||||
match key:
|
||||
KEY_0, KEY_KP_0:
|
||||
_set_view(-1)
|
||||
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
|
||||
_set_view(key - KEY_1)
|
||||
KEY_KP_1, KEY_KP_2, KEY_KP_3, KEY_KP_4, KEY_KP_5:
|
||||
_set_view(key - KEY_KP_1)
|
||||
KEY_SPACE:
|
||||
_toggle_pause()
|
||||
KEY_PERIOD:
|
||||
TurnManager.observer_pacing.step()
|
||||
KEY_EQUAL, KEY_PLUS, KEY_KP_ADD:
|
||||
_change_speed(1)
|
||||
KEY_MINUS, KEY_KP_SUBTRACT:
|
||||
_change_speed(-1)
|
||||
KEY_F:
|
||||
_toggle_follow()
|
||||
_:
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _set_view(view_index: int) -> void:
|
||||
if view_index >= GameState.players.size():
|
||||
return
|
||||
if _world_map != null and _world_map.has_method("observer_set_view"):
|
||||
_world_map.observer_set_view(view_index)
|
||||
if _hud != null:
|
||||
_hud.set_view_label(view_index)
|
||||
|
||||
|
||||
func _toggle_pause() -> void:
|
||||
TurnManager.observer_pacing.toggle_paused()
|
||||
if _hud != null:
|
||||
_hud.set_pacing(TurnManager.observer_pacing.paused, TurnManager.observer_pacing.speed)
|
||||
|
||||
|
||||
func _change_speed(direction: int) -> void:
|
||||
_speed_index = clampi(_speed_index + direction, 0, SPEED_STEPS.size() - 1)
|
||||
TurnManager.observer_pacing.set_speed(SPEED_STEPS[_speed_index])
|
||||
if _hud != null:
|
||||
_hud.set_pacing(TurnManager.observer_pacing.paused, TurnManager.observer_pacing.speed)
|
||||
|
||||
|
||||
func _toggle_follow() -> void:
|
||||
_follow_enabled = not _follow_enabled
|
||||
if _world_map != null and _world_map.has_method("observer_set_follow"):
|
||||
_world_map.observer_set_follow(_follow_enabled)
|
||||
336
src/game/engine/scenes/world_map/observer_hud.gd
Normal file
336
src/game/engine/scenes/world_map/observer_hud.gd
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
class_name ObserverHud
|
||||
extends RefCounted
|
||||
## Caster overlay for observer/spectator mode. Builds a CanvasLayer with:
|
||||
## - a current-view label (top-left): "god view" or "<clan> — fog"
|
||||
## - a pacing readout (top-right): turn counter + play/pause + speed
|
||||
## - a standings ladder (right): the clans ranked by score, in player colors
|
||||
## - an event ticker (bottom-left): high-signal EventBus lines for commentary
|
||||
## - a persistent hotkey strip (bottom)
|
||||
## - a victory banner (center, hidden until a clan wins)
|
||||
##
|
||||
## Pure presentation: reads StatsTracker / GameState / EventBus, mutates no
|
||||
## simulation state. Built programmatically (no .tscn) like arena_overlay.gd and
|
||||
## hotseat_handoff.gd. Approved against the A0 caster-overlay mockup.
|
||||
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const MAX_TICKER_LINES: int = 4
|
||||
const PANEL_ALPHA: float = 0.88
|
||||
|
||||
var _layer: CanvasLayer = null
|
||||
var _view_swatch: ColorRect = null
|
||||
var _view_label: Label = null
|
||||
var _turn_label: Label = null
|
||||
var _pacing_label: Label = null
|
||||
var _standings_box: VBoxContainer = null
|
||||
var _ticker_box: VBoxContainer = null
|
||||
var _banner: Label = null
|
||||
var _panel_bg: Color
|
||||
var _text_color: Color
|
||||
var _muted_color: Color
|
||||
|
||||
|
||||
func build(world_map: Node) -> void:
|
||||
_panel_bg = ThemeAssets.color("background.deepest")
|
||||
_panel_bg.a = PANEL_ALPHA
|
||||
_text_color = ThemeAssets.color("text.primary")
|
||||
_muted_color = ThemeAssets.color("text.secondary")
|
||||
|
||||
_layer = CanvasLayer.new()
|
||||
_layer.name = "ObserverHud"
|
||||
_layer.layer = 28
|
||||
|
||||
var root: Control = Control.new()
|
||||
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_layer.add_child(root)
|
||||
|
||||
_build_view_label(root)
|
||||
_build_pacing(root)
|
||||
_build_standings(root)
|
||||
_build_ticker(root)
|
||||
_build_hotkeys(root)
|
||||
_build_banner(root)
|
||||
|
||||
world_map.add_child(_layer)
|
||||
_connect_events()
|
||||
set_view_label(-1)
|
||||
set_turn(GameState.turn_number)
|
||||
set_pacing(false, 1.0)
|
||||
update_standings()
|
||||
|
||||
|
||||
# ── Construction ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func _dark_panel() -> PanelContainer:
|
||||
var panel: PanelContainer = PanelContainer.new()
|
||||
var sb: StyleBoxFlat = StyleBoxFlat.new()
|
||||
sb.bg_color = _panel_bg
|
||||
sb.corner_radius_top_left = 6
|
||||
sb.corner_radius_top_right = 6
|
||||
sb.corner_radius_bottom_left = 6
|
||||
sb.corner_radius_bottom_right = 6
|
||||
sb.content_margin_left = 10
|
||||
sb.content_margin_right = 10
|
||||
sb.content_margin_top = 6
|
||||
sb.content_margin_bottom = 6
|
||||
panel.add_theme_stylebox_override("panel", sb)
|
||||
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
return panel
|
||||
|
||||
|
||||
func _build_view_label(root: Control) -> void:
|
||||
var panel: PanelContainer = _dark_panel()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
panel.position = Vector2(12, 12)
|
||||
var row: HBoxContainer = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
_view_swatch = ColorRect.new()
|
||||
_view_swatch.custom_minimum_size = Vector2(12, 12)
|
||||
_view_swatch.color = Color(1, 1, 1, 0)
|
||||
var center: CenterContainer = CenterContainer.new()
|
||||
center.add_child(_view_swatch)
|
||||
_view_label = Label.new()
|
||||
_view_label.add_theme_font_size_override("font_size", 15)
|
||||
_view_label.add_theme_color_override("font_color", _text_color)
|
||||
row.add_child(center)
|
||||
row.add_child(_view_label)
|
||||
panel.add_child(row)
|
||||
root.add_child(panel)
|
||||
|
||||
|
||||
func _build_pacing(root: Control) -> void:
|
||||
var panel: PanelContainer = _dark_panel()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
panel.position = Vector2(-12, 12)
|
||||
var row: HBoxContainer = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
_turn_label = Label.new()
|
||||
_turn_label.add_theme_font_size_override("font_size", 14)
|
||||
_turn_label.add_theme_color_override("font_color", _text_color)
|
||||
_pacing_label = Label.new()
|
||||
_pacing_label.add_theme_font_size_override("font_size", 14)
|
||||
_pacing_label.add_theme_color_override("font_color", _text_color)
|
||||
row.add_child(_turn_label)
|
||||
row.add_child(_pacing_label)
|
||||
panel.add_child(row)
|
||||
root.add_child(panel)
|
||||
|
||||
|
||||
func _build_standings(root: Control) -> void:
|
||||
var panel: PanelContainer = _dark_panel()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
panel.position = Vector2(-12, 56)
|
||||
panel.custom_minimum_size = Vector2(230, 0)
|
||||
var vbox: VBoxContainer = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
var header: Label = Label.new()
|
||||
header.text = "standings · by score"
|
||||
header.add_theme_font_size_override("font_size", 12)
|
||||
header.add_theme_color_override("font_color", _muted_color)
|
||||
vbox.add_child(header)
|
||||
_standings_box = VBoxContainer.new()
|
||||
_standings_box.add_theme_constant_override("separation", 4)
|
||||
vbox.add_child(_standings_box)
|
||||
panel.add_child(vbox)
|
||||
root.add_child(panel)
|
||||
|
||||
|
||||
func _build_ticker(root: Control) -> void:
|
||||
var panel: PanelContainer = _dark_panel()
|
||||
panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
panel.position = Vector2(12, -40)
|
||||
panel.custom_minimum_size = Vector2(420, 0)
|
||||
var vbox: VBoxContainer = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 3)
|
||||
var header: Label = Label.new()
|
||||
header.text = "live · event feed"
|
||||
header.add_theme_font_size_override("font_size", 11)
|
||||
header.add_theme_color_override("font_color", ThemeAssets.color("semantic.negative"))
|
||||
vbox.add_child(header)
|
||||
_ticker_box = VBoxContainer.new()
|
||||
_ticker_box.add_theme_constant_override("separation", 2)
|
||||
vbox.add_child(_ticker_box)
|
||||
panel.add_child(vbox)
|
||||
root.add_child(panel)
|
||||
|
||||
|
||||
func _build_hotkeys(root: Control) -> void:
|
||||
var label: Label = Label.new()
|
||||
label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
label.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
label.position = Vector2(12, -16)
|
||||
label.add_theme_font_size_override("font_size", 11)
|
||||
label.add_theme_color_override("font_color", _muted_color)
|
||||
label.text = "0 god 1-5 clan fog space pause . step +/- speed F follow"
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
root.add_child(label)
|
||||
|
||||
|
||||
func _build_banner(root: Control) -> void:
|
||||
_banner = Label.new()
|
||||
_banner.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_banner.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
_banner.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_banner.add_theme_font_size_override("font_size", 32)
|
||||
_banner.add_theme_color_override("font_color", _text_color)
|
||||
_banner.add_theme_color_override("font_outline_color", _panel_bg)
|
||||
_banner.add_theme_constant_override("outline_size", 8)
|
||||
_banner.visible = false
|
||||
_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
root.add_child(_banner)
|
||||
|
||||
|
||||
# ── Updates ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func set_view_label(view_index: int) -> void:
|
||||
if _view_label == null:
|
||||
return
|
||||
if view_index < 0:
|
||||
_view_label.text = "god view — all clans"
|
||||
_view_swatch.color = Color(1, 1, 1, 0)
|
||||
else:
|
||||
_view_label.text = "%s — fog of war" % _player_name(view_index)
|
||||
_view_swatch.color = _player_color(view_index)
|
||||
|
||||
|
||||
func set_turn(turn_number: int) -> void:
|
||||
if _turn_label != null:
|
||||
_turn_label.text = "turn %d" % turn_number
|
||||
|
||||
|
||||
func set_pacing(paused: bool, speed: float) -> void:
|
||||
if _pacing_label == null:
|
||||
return
|
||||
_pacing_label.text = ("paused" if paused else "%s %.2g×" % ["play", speed])
|
||||
|
||||
|
||||
func update_standings() -> void:
|
||||
if _standings_box == null:
|
||||
return
|
||||
for child: Node in _standings_box.get_children():
|
||||
child.queue_free()
|
||||
var rankings: Array = StatsTracker.get_rankings("score")
|
||||
if rankings.is_empty():
|
||||
for p: Variant in GameState.players:
|
||||
if p is PlayerScript:
|
||||
rankings.append({"index": int((p as PlayerScript).index), "value": 0})
|
||||
for entry: Dictionary in rankings:
|
||||
_standings_box.add_child(_standings_row(entry))
|
||||
|
||||
|
||||
func _standings_row(entry: Dictionary) -> HBoxContainer:
|
||||
var idx: int = int(entry.get("index", 0))
|
||||
var rank: int = int(entry.get("rank", 0))
|
||||
var row: HBoxContainer = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var rank_label: Label = Label.new()
|
||||
rank_label.text = ("%d" % rank) if rank > 0 else "·"
|
||||
rank_label.custom_minimum_size = Vector2(14, 0)
|
||||
rank_label.add_theme_font_size_override("font_size", 13)
|
||||
rank_label.add_theme_color_override("font_color", _muted_color)
|
||||
var swatch: ColorRect = ColorRect.new()
|
||||
swatch.custom_minimum_size = Vector2(10, 10)
|
||||
swatch.color = _player_color(idx)
|
||||
var swatch_center: CenterContainer = CenterContainer.new()
|
||||
swatch_center.add_child(swatch)
|
||||
var name_label: Label = Label.new()
|
||||
name_label.text = _player_name(idx)
|
||||
name_label.add_theme_font_size_override("font_size", 13)
|
||||
name_label.add_theme_color_override("font_color", _text_color)
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var score_label: Label = Label.new()
|
||||
score_label.text = "%d" % int(entry.get("value", 0))
|
||||
score_label.add_theme_font_size_override("font_size", 13)
|
||||
score_label.add_theme_color_override("font_color", _text_color)
|
||||
row.add_child(rank_label)
|
||||
row.add_child(swatch_center)
|
||||
row.add_child(name_label)
|
||||
row.add_child(score_label)
|
||||
return row
|
||||
|
||||
|
||||
func push_event(text: String, color: Color) -> void:
|
||||
if _ticker_box == null:
|
||||
return
|
||||
var label: Label = Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", color)
|
||||
_ticker_box.add_child(label)
|
||||
while _ticker_box.get_child_count() > MAX_TICKER_LINES:
|
||||
var oldest: Node = _ticker_box.get_child(0)
|
||||
_ticker_box.remove_child(oldest)
|
||||
oldest.queue_free()
|
||||
|
||||
|
||||
func show_winner(player_index: int, victory_type: String) -> void:
|
||||
if _banner == null:
|
||||
return
|
||||
_banner.text = "%s wins — %s victory" % [_player_name(player_index), victory_type]
|
||||
_banner.add_theme_color_override("font_color", _player_color(player_index))
|
||||
_banner.visible = true
|
||||
|
||||
|
||||
# ── Event wiring ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func _connect_events() -> void:
|
||||
EventBus.war_declared.connect(_on_war_declared)
|
||||
EventBus.city_captured.connect(_on_city_captured)
|
||||
EventBus.city_founded.connect(_on_city_founded)
|
||||
EventBus.wonder_built.connect(_on_wonder_built)
|
||||
EventBus.victory_achieved.connect(_on_victory_achieved)
|
||||
|
||||
|
||||
func _on_war_declared(by_player: int, against_player: int) -> void:
|
||||
push_event(
|
||||
"%s declares war on %s" % [_player_name(by_player), _player_name(against_player)],
|
||||
ThemeAssets.color("semantic.negative"),
|
||||
)
|
||||
|
||||
|
||||
func _on_city_captured(_city: Variant, old_owner: int, new_owner: int) -> void:
|
||||
push_event(
|
||||
"%s captures a city from %s" % [_player_name(new_owner), _player_name(old_owner)],
|
||||
_player_color(new_owner),
|
||||
)
|
||||
|
||||
|
||||
func _on_city_founded(_city: Variant, player_index: int) -> void:
|
||||
push_event("%s founds a new city" % _player_name(player_index), _player_color(player_index))
|
||||
|
||||
|
||||
func _on_wonder_built(wonder_id: String, player_index: int) -> void:
|
||||
push_event(
|
||||
"%s completes %s" % [_player_name(player_index), wonder_id],
|
||||
_player_color(player_index),
|
||||
)
|
||||
|
||||
|
||||
func _on_victory_achieved(player_index: int, victory_type: String) -> void:
|
||||
show_winner(player_index, victory_type)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func _player_name(idx: int) -> String:
|
||||
var p: RefCounted = GameState.get_player(idx)
|
||||
if p != null and not str(p.player_name).is_empty():
|
||||
return str(p.player_name)
|
||||
return "Clan %d" % (idx + 1)
|
||||
|
||||
|
||||
func _player_color(idx: int) -> Color:
|
||||
var p: RefCounted = GameState.get_player(idx)
|
||||
if p != null and p.get("color") != null:
|
||||
return p.color
|
||||
if idx >= 0 and idx < GameState.PLAYER_COLORS.size():
|
||||
return GameState.PLAYER_COLORS[idx]
|
||||
return ThemeAssets.color("text.primary")
|
||||
56
src/game/engine/scenes/world_map/observer_mode.gd
Normal file
56
src/game/engine/scenes/world_map/observer_mode.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
class_name ObserverMode
|
||||
extends RefCounted
|
||||
## Observer/caster mode orchestrator (OBSERVER env). Instantiated by world_map
|
||||
## alongside the arena helper: the arena helper handles teardown / camera-fit /
|
||||
## action playback (reused verbatim), and this module adds the casting layer —
|
||||
## the caster HUD (standings + ticker + view label + pacing), the caster input
|
||||
## handler, and the per-turn live updates.
|
||||
##
|
||||
## Pure presentation: it reads StatsTracker / GameState and listens on EventBus;
|
||||
## it never mutates simulation state. Pacing lives in TurnManager (the turn loop
|
||||
## owns its own gate); this module only wires the caster's keys to it.
|
||||
|
||||
const ObserverHudScript: GDScript = preload("res://engine/scenes/world_map/observer_hud.gd")
|
||||
const ObserverControlsScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/observer_controls.gd"
|
||||
)
|
||||
|
||||
var _world_map: Node = null
|
||||
var _hud: RefCounted = null # ObserverHud
|
||||
var _controls: Node = null # ObserverControls
|
||||
|
||||
|
||||
func setup(world_map: Node) -> void:
|
||||
_world_map = world_map
|
||||
|
||||
_hud = ObserverHudScript.new()
|
||||
_hud.build(world_map)
|
||||
|
||||
_controls = ObserverControlsScript.new()
|
||||
_controls.name = "ObserverControls"
|
||||
world_map.add_child(_controls)
|
||||
_controls.setup(world_map, _hud)
|
||||
|
||||
EventBus.turn_started.connect(_on_turn_started)
|
||||
EventBus.turn_ended.connect(_on_turn_ended)
|
||||
EventBus.victory_achieved.connect(_on_victory)
|
||||
|
||||
|
||||
func _on_turn_started(turn_number: int, _player_index: int) -> void:
|
||||
if _hud != null:
|
||||
_hud.set_turn(turn_number)
|
||||
|
||||
|
||||
func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
|
||||
if _hud != null:
|
||||
_hud.update_standings()
|
||||
# Keep the watched clan's fog current as it explores during play. No-op while
|
||||
# the caster is in god view.
|
||||
if _world_map != null and _world_map.has_method("observer_refresh_view"):
|
||||
_world_map.observer_refresh_view()
|
||||
|
||||
|
||||
func _on_victory(_player_index: int, _victory_type: String) -> void:
|
||||
# Halt the cast on a win so the final board + winner banner hold on screen
|
||||
# (the HUD shows the banner via its own victory_achieved subscription).
|
||||
TurnManager.observer_pacing.set_paused(true)
|
||||
|
|
@ -33,6 +33,9 @@ const WorldMapVisionScript: GDScript = preload(
|
|||
const WorldMapArenaScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/world_map_arena.gd"
|
||||
)
|
||||
const ObserverModeScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/observer_mode.gd"
|
||||
)
|
||||
const WorldMapUnitsScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/world_map_units.gd"
|
||||
)
|
||||
|
|
@ -84,6 +87,13 @@ var _selected_unit: RefCounted = null
|
|||
var _reachable_hexes: Dictionary = {}
|
||||
var _bombard_city: RefCounted = null
|
||||
var _arena_mode: bool = false
|
||||
## Observer/caster mode (OBSERVER env). A superset of arena spectating: reuses
|
||||
## the arena teardown/camera/playback but adds a caster HUD, per-clan fog flip,
|
||||
## and pacing. `_observer_view` is the currently-cast perspective: -1 = god view
|
||||
## (omniscient, fog off), 0..N = that clan's actual fog-of-war.
|
||||
var _observer_mode: bool = false
|
||||
var _observer_view: int = -1
|
||||
var _observer: RefCounted = null
|
||||
## p0-35 movement-mode state. When `_movement_mode == true` the next
|
||||
## right-click confirms the move along the previewed path; ESC / left-click
|
||||
## cancel back to selection. KEY_M toggles entry.
|
||||
|
|
@ -154,11 +164,19 @@ var _formation_move_command: String = "advance"
|
|||
|
||||
func _ready() -> void:
|
||||
_arena_mode = EnvConfig.get_bool("AI_ARENA")
|
||||
_observer_mode = EnvConfig.get_bool("OBSERVER")
|
||||
# Observer mode reuses every arena rendering branch (fog off, omniscient
|
||||
# default view, no human HUD/input, camera fit, action playback).
|
||||
if _observer_mode:
|
||||
_arena_mode = true
|
||||
_setup_renderers()
|
||||
_connect_signals()
|
||||
if _arena_mode:
|
||||
_arena = WorldMapArenaScript.new()
|
||||
(_arena as WorldMapArenaScript).setup(self)
|
||||
if _observer_mode:
|
||||
_observer = ObserverModeScript.new()
|
||||
(_observer as ObserverModeScript).setup(self)
|
||||
_start_game()
|
||||
|
||||
|
||||
|
|
@ -632,6 +650,66 @@ func _set_view_player(player_index: int) -> void:
|
|||
cam.center_on_hex(centroid)
|
||||
|
||||
|
||||
## Observer/caster view switch. `view_index` < 0 = god view (omniscient, fog
|
||||
## off, whole map revealed); 0..N = that clan's actual fog-of-war (the hotseat
|
||||
## per-player render path, but driven by the caster instead of turn order).
|
||||
## Called by observer_controls on a hotkey and re-applied each turn_ended while a
|
||||
## clan is being watched so its fog updates live as it explores.
|
||||
func observer_set_view(view_index: int) -> void:
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
if view_index >= GameState.players.size():
|
||||
view_index = -1
|
||||
_observer_view = view_index
|
||||
if view_index < 0:
|
||||
_fog_renderer.fog_disabled = true
|
||||
_fog_renderer.initialize(game_map, -1)
|
||||
_unit_renderer.setup_visibility(-1, game_map)
|
||||
(_city_renderer as CityRenderer).setup_visibility(-1, game_map)
|
||||
if _minimap != null and _minimap.has_method("set_local_player"):
|
||||
_minimap.set_local_player(-1)
|
||||
var all_positions: Array[Vector2i] = []
|
||||
for pos: Vector2i in game_map.tiles:
|
||||
all_positions.append(pos)
|
||||
_hex_renderer.update_fog(all_positions, [])
|
||||
_fog_renderer.update_all(game_map)
|
||||
else:
|
||||
var player: RefCounted = GameState.get_player(view_index)
|
||||
if player == null:
|
||||
return
|
||||
_fog_renderer.fog_disabled = false
|
||||
_fog_renderer.initialize(game_map, view_index)
|
||||
_unit_renderer.setup_visibility(view_index, game_map)
|
||||
(_city_renderer as CityRenderer).setup_visibility(view_index, game_map)
|
||||
if _minimap != null and _minimap.has_method("set_local_player"):
|
||||
_minimap.set_local_player(view_index)
|
||||
WorldMapVisionScript.recalculate_vision(player, game_map)
|
||||
WorldMapVisionScript.record_observations(player, game_map)
|
||||
_update_fog(player, game_map)
|
||||
_sync_units()
|
||||
_sync_cities()
|
||||
|
||||
|
||||
## The caster's current perspective (-1 god, else clan index).
|
||||
func observer_view() -> int:
|
||||
return _observer_view
|
||||
|
||||
|
||||
## Re-apply the watched clan's fog so it tracks live exploration. No-op in god
|
||||
## view (the whole map is already revealed).
|
||||
func observer_refresh_view() -> void:
|
||||
if _observer_view >= 0:
|
||||
observer_set_view(_observer_view)
|
||||
|
||||
|
||||
## Toggle the arena follow-action camera (caster `F` key). When off, the camera
|
||||
## stops auto-panning to the active player so the caster can free-pan.
|
||||
func observer_set_follow(enabled: bool) -> void:
|
||||
if _arena != null:
|
||||
(_arena as WorldMapArenaScript).follow_enabled = enabled
|
||||
|
||||
|
||||
func _sync_units() -> void:
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var units: Array = primary.get("units", [])
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ const ARENA_ZOOM: float = 0.25
|
|||
const QUIT_HOLD_SECONDS: float = 2.0
|
||||
const WALL_CLOCK_LIMIT_MSEC: int = 600_000 ## 10 minutes — hard safety net
|
||||
|
||||
## Follow-action camera toggle (caster `F`). When false, _on_turn_started stops
|
||||
## auto-panning the camera to the active player so the caster can free-pan.
|
||||
var follow_enabled: bool = true
|
||||
|
||||
var _world_map: Node = null
|
||||
var _turn_label: Label = null
|
||||
var _start_ticks_msec: int = 0
|
||||
|
|
@ -37,10 +41,16 @@ var _finished: bool = false
|
|||
var _screenshot_taken: bool = false
|
||||
var _playback: RefCounted = null
|
||||
var _overlay: RefCounted = null
|
||||
## Observer/caster sub-mode (OBSERVER env). Reuses arena teardown/camera/playback
|
||||
## but skips the tournament harness: no match-result JSON, no auto-quit, no
|
||||
## periodic screenshot, no turn-limit/wall-clock auto-finish. The caster HUD
|
||||
## (observer_mode.gd) replaces the bare label overlay.
|
||||
var _observer: bool = false
|
||||
|
||||
|
||||
func setup(world_map: Node) -> void:
|
||||
_world_map = world_map
|
||||
_observer = EnvConfig.get_bool("OBSERVER")
|
||||
_match_id = EnvConfig.get_var("AI_ARENA_MATCH_ID", "match_?")
|
||||
_seed = EnvConfig.get_int("AI_ARENA_SEED", 0)
|
||||
_turn_limit = EnvConfig.get_int("AI_ARENA_TURN_LIMIT", 150)
|
||||
|
|
@ -50,7 +60,10 @@ func setup(world_map: Node) -> void:
|
|||
|
||||
_disable_human_hud()
|
||||
_disable_human_input()
|
||||
_build_label_overlay()
|
||||
# Observer mounts its own caster HUD (standings + ticker + view label) via
|
||||
# observer_mode.gd; skip the bare match/seed/turn label overlay.
|
||||
if not _observer:
|
||||
_build_label_overlay()
|
||||
# world_map._start_game runs AFTER setup() and calls bg_camera.center_on_hex
|
||||
# with zoom 0.45 (calibrated for 1920x1080). Override it via call_deferred so
|
||||
# our camera fit happens AFTER that setup, not before — otherwise we get
|
||||
|
|
@ -189,7 +202,8 @@ func _build_label_overlay() -> void:
|
|||
func _on_turn_started(_turn_number: int, player_index: int) -> void:
|
||||
if _finished:
|
||||
return
|
||||
ArenaCameraScript.follow_player(_world_map, player_index, ARENA_ZOOM)
|
||||
if follow_enabled:
|
||||
ArenaCameraScript.follow_player(_world_map, player_index, ARENA_ZOOM)
|
||||
if _overlay != null:
|
||||
_overlay.set_active_player(player_index)
|
||||
|
||||
|
|
@ -200,6 +214,12 @@ func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
|
|||
if _turn_label != null:
|
||||
_turn_label.text = "turn %d / %d" % [GameState.turn_number, _turn_limit]
|
||||
|
||||
# Observer is a free-running cast: no tournament screenshot, no turn-limit /
|
||||
# wall-clock auto-finish. The caster ends the session; victory is shown in
|
||||
# the HUD, not written to a result file.
|
||||
if _observer:
|
||||
return
|
||||
|
||||
# Capture a viewport PNG at a configurable turn so the orchestrator can
|
||||
# composite the 4 per-match screenshots into a quad-grid image — host
|
||||
# screenshot tools don't see native Wayland windows via x11grab, so we
|
||||
|
|
@ -274,6 +294,10 @@ func _capture_viewport_screenshot_async() -> void:
|
|||
func _on_victory(player_index: int, victory_type: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
# Observer never writes a result file or quits — observer_mode.gd pauses the
|
||||
# loop and the caster HUD shows the winner.
|
||||
if _observer:
|
||||
return
|
||||
_finish(player_index, victory_type)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ const TurnProcessorScript: GDScript = preload(
|
|||
const AiTurnBridgeScript: GDScript = preload(
|
||||
"res://engine/src/modules/ai/ai_turn_bridge.gd"
|
||||
)
|
||||
const ObserverPacingScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/observer_pacing.gd"
|
||||
)
|
||||
const PrologueDriverScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/prologue_driver.gd"
|
||||
)
|
||||
|
|
@ -43,6 +46,10 @@ var _processing_end_turn: bool = false
|
|||
## safety-timer fire and the EventBus.arena_playback_finished don't both
|
||||
## trigger start_turn() (which would double-advance turns). -1 means no gate.
|
||||
var _arena_gate_turn: int = -1
|
||||
## Observer/caster pacing (OBSERVER env). Owns pause/step/speed for the spectator
|
||||
## turn loop; the gate below calls observer_pacing.advance() instead of an
|
||||
## immediate start_turn(). Logic lives in its own module to keep this file lean.
|
||||
var observer_pacing: RefCounted = ObserverPacingScript.new() # ObserverPacing
|
||||
var _unit_manager: RefCounted = UnitManagerScript.new() # UnitManager
|
||||
var _wild_ai: RefCounted = null # WildCreatureAI — set by WorldMap after spawn
|
||||
var tech_web: RefCounted = TechWebScript.new() # TechWeb — built on first use
|
||||
|
|
@ -63,6 +70,7 @@ var prologue: RefCounted = null # PrologueDriver
|
|||
func _ready() -> void:
|
||||
EventBus.tech_research_started.connect(_on_tech_research_started)
|
||||
EventBus.deposit_discovered.connect(_on_deposit_discovered)
|
||||
observer_pacing.set_turn_manager(self)
|
||||
_processor = TurnProcessorScript.new()
|
||||
var proc: TurnProcessorScript = _processor as TurnProcessorScript
|
||||
proc.unit_manager = _unit_manager
|
||||
|
|
@ -350,9 +358,9 @@ func next_player() -> void:
|
|||
# Check victory conditions after all players have moved
|
||||
var vm: VictoryManagerScript = _victory_manager as VictoryManagerScript
|
||||
vm.check_all(GameState.get_game_map())
|
||||
# Auto-save at end of every full game turn (skipped in arena mode to
|
||||
# avoid hundreds of autosaves per spectator match).
|
||||
if not EnvConfig.get_bool("AI_ARENA"):
|
||||
# Auto-save at end of every full game turn (skipped in arena/observer
|
||||
# spectator modes to avoid hundreds of autosaves per match).
|
||||
if not EnvConfig.get_bool("AI_ARENA") and not EnvConfig.get_bool("OBSERVER"):
|
||||
SaveManagerScript.autosave()
|
||||
else:
|
||||
# p2-83: use bridge to advance within-round player phase (updates current + emits).
|
||||
|
|
@ -368,7 +376,7 @@ func next_player() -> void:
|
|||
# replay with camera pans and animation before the next round. A
|
||||
# safety timer fires if playback isn't wired or stalls so we never
|
||||
# deadlock. Normal human-vs-AI gameplay keeps the synchronous path.
|
||||
if EnvConfig.get_bool("AI_ARENA"):
|
||||
if EnvConfig.get_bool("AI_ARENA") or EnvConfig.get_bool("OBSERVER"):
|
||||
_arena_gate_turn = GameState.turn_number
|
||||
EventBus.arena_playback_finished.connect(
|
||||
_on_arena_playback_finished, CONNECT_ONE_SHOT
|
||||
|
|
@ -393,7 +401,12 @@ func _on_arena_playback_finished(turn_number: int) -> void:
|
|||
_arena_gate_turn = -1
|
||||
if EventBus.arena_playback_finished.is_connected(_on_arena_playback_finished):
|
||||
EventBus.arena_playback_finished.disconnect(_on_arena_playback_finished)
|
||||
start_turn()
|
||||
# Observer mode hands the gate to its pacing module (pause/step/speed); plain
|
||||
# arena resumes immediately.
|
||||
if EnvConfig.get_bool("OBSERVER"):
|
||||
observer_pacing.advance(turn_number)
|
||||
else:
|
||||
start_turn()
|
||||
|
||||
|
||||
func get_phase_name() -> String:
|
||||
|
|
|
|||
72
src/game/engine/src/modules/management/observer_pacing.gd
Normal file
72
src/game/engine/src/modules/management/observer_pacing.gd
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
class_name ObserverPacing
|
||||
extends RefCounted
|
||||
## Pacing gate for observer/caster mode (OBSERVER env). Owns the pause / step /
|
||||
## speed state for the spectator turn loop. TurnManager calls advance() at the
|
||||
## action-playback gate instead of re-entering start_turn() directly, so the cast
|
||||
## can hold between turns and the inter-turn delay can scale with caster speed.
|
||||
##
|
||||
## Separated from turn_manager.gd so the turn loop stays focused on resolution,
|
||||
## not presentation pacing. Pure presentation timing: it only governs WHEN
|
||||
## start_turn() fires, never the turn's resolution or outcome.
|
||||
|
||||
const INTER_TURN_BASE_SEC: float = 0.6
|
||||
|
||||
var paused: bool = false
|
||||
var speed: float = 1.0
|
||||
var _tm: Node = null
|
||||
var _waiting_turn: int = -1
|
||||
var _step_armed: bool = false
|
||||
|
||||
|
||||
func set_turn_manager(tm: Node) -> void:
|
||||
_tm = tm
|
||||
|
||||
|
||||
## Called by TurnManager once the action playback for `turn_number` finishes.
|
||||
## Holds the loop when paused (unless a step is armed), otherwise advances after
|
||||
## a speed-scaled delay.
|
||||
func advance(turn_number: int) -> void:
|
||||
if paused and not _step_armed:
|
||||
_waiting_turn = turn_number
|
||||
return
|
||||
_step_armed = false
|
||||
var delay: float = INTER_TURN_BASE_SEC / maxf(speed, 0.05)
|
||||
if delay <= 0.0 or _tm == null:
|
||||
_start()
|
||||
else:
|
||||
_tm.get_tree().create_timer(delay).timeout.connect(_start, CONNECT_ONE_SHOT)
|
||||
|
||||
|
||||
func set_paused(value: bool) -> void:
|
||||
paused = value
|
||||
if not value:
|
||||
_release()
|
||||
|
||||
|
||||
func toggle_paused() -> void:
|
||||
set_paused(not paused)
|
||||
|
||||
|
||||
## Advance exactly one player-turn while paused. Releases a turn already held at
|
||||
## the gate, or arms the next gate to pass through once.
|
||||
func step() -> void:
|
||||
if _waiting_turn >= 0:
|
||||
_release()
|
||||
else:
|
||||
_step_armed = true
|
||||
|
||||
|
||||
func set_speed(value: float) -> void:
|
||||
speed = clampf(value, 0.25, 4.0)
|
||||
|
||||
|
||||
func _release() -> void:
|
||||
if _waiting_turn < 0:
|
||||
return
|
||||
_waiting_turn = -1
|
||||
_start()
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
if _tm != null:
|
||||
_tm.start_turn()
|
||||
|
|
@ -432,6 +432,13 @@ pub struct GameState {
|
|||
/// until the first ecology tick seeds the world.
|
||||
#[serde(default)]
|
||||
pub worldsim_state_json: String,
|
||||
/// p3-26 B3: improvement definitions (`id → {build_turns, food, production}`),
|
||||
/// boot-loaded from `public/resources/improvements/*.json`. `#[serde(skip)]`
|
||||
/// static content like the other catalogs; drives both the build-tick
|
||||
/// (`build_turns`) and `process_improvement_yields` (food/production). Empty →
|
||||
/// no improvements build or yield (safe no-op).
|
||||
#[serde(skip)]
|
||||
pub improvement_defs: BTreeMap<String, ImprovementDef>,
|
||||
/// p2-71: tactical-AI view of the producible-unit catalog. Mirrors
|
||||
/// `TacticalState::unit_catalog` and is populated once at harness boot
|
||||
/// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests).
|
||||
|
|
@ -732,6 +739,43 @@ impl GameState {
|
|||
}
|
||||
}
|
||||
|
||||
/// p3-26 B3: load improvement definitions from a JSON array of improvement
|
||||
/// objects (`[{id, build_turns, yields:{food,production}}, …]`, the shape
|
||||
/// `public/resources/improvements/*.json` carry). Returns the count loaded;
|
||||
/// malformed input leaves the table empty (improvements no-op). Called once
|
||||
/// at boot (the field is `#[serde(skip)]`).
|
||||
pub fn load_improvement_defs_json(&mut self, json_array: &str) -> usize {
|
||||
let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(json_array) else {
|
||||
return 0;
|
||||
};
|
||||
let mut n = 0;
|
||||
for v in &arr {
|
||||
let Some(id) = v.get("id").and_then(|x| x.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let build_turns = v.get("build_turns").and_then(|x| x.as_u64()).unwrap_or(1) as u32;
|
||||
let yields = v.get("yields");
|
||||
let food = yields
|
||||
.and_then(|y| y.get("food"))
|
||||
.and_then(|x| x.as_i64())
|
||||
.unwrap_or(0) as i32;
|
||||
let production = yields
|
||||
.and_then(|y| y.get("production"))
|
||||
.and_then(|x| x.as_i64())
|
||||
.unwrap_or(0) as i32;
|
||||
self.improvement_defs.insert(
|
||||
id.to_string(),
|
||||
ImprovementDef {
|
||||
build_turns,
|
||||
food,
|
||||
production,
|
||||
},
|
||||
);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// p2-65 Phase7 test helper: construct a GameState whose combat_balance
|
||||
/// (and future SimConfig fields) are pre-populated without touching the
|
||||
/// global RwLock singleton. Callers that need isolated config for
|
||||
|
|
@ -993,6 +1037,12 @@ pub struct PlayerState {
|
|||
/// GDScript; bench tests set this directly.
|
||||
#[serde(default)]
|
||||
pub city_improvements: Vec<Vec<String>>,
|
||||
/// p3-26 B3: tile improvements under construction. Each ticks down
|
||||
/// `turns_remaining` per turn (the improvement build-tick phase); at 0 the
|
||||
/// improvement id is appended to `city_improvements[city_idx]` so it starts
|
||||
/// yielding. Mirrors the live `player.pending_improvements`.
|
||||
#[serde(default)]
|
||||
pub pending_improvements: Vec<PendingImprovement>,
|
||||
/// Per-city accumulated fauna harassment pressure. Aligned with `cities`.
|
||||
pub city_ecology: Vec<CityEcology>,
|
||||
/// Optional research state. `None` = no research simulated.
|
||||
|
|
@ -1177,6 +1227,37 @@ pub struct PlayerState {
|
|||
pub building_happiness: i32,
|
||||
}
|
||||
|
||||
/// p3-26 B3: an improvement definition (boot-loaded, keyed by id on
|
||||
/// `GameState::improvement_defs`). `build_turns` drives the build-tick; `food`
|
||||
/// and `production` feed `process_improvement_yields` once the improvement
|
||||
/// completes onto a city.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImprovementDef {
|
||||
/// Turns of worker construction before the improvement completes.
|
||||
pub build_turns: u32,
|
||||
/// Per-turn food bonus to the owning city once complete.
|
||||
pub food: i32,
|
||||
/// Per-turn production bonus to the owning city once complete.
|
||||
pub production: i32,
|
||||
}
|
||||
|
||||
/// p3-26 B3: a tile improvement under construction. Ticks down `turns_remaining`
|
||||
/// each turn; at 0 the `improvement_id` is appended to the owning city's
|
||||
/// `city_improvements` list so it begins yielding.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PendingImprovement {
|
||||
/// Tile column the improvement is being built on.
|
||||
pub col: i32,
|
||||
/// Tile row the improvement is being built on.
|
||||
pub row: i32,
|
||||
/// Improvement id (e.g. `"farm"`).
|
||||
pub improvement_id: String,
|
||||
/// Index into the owning player's `cities` / `city_improvements` to credit.
|
||||
pub city_idx: usize,
|
||||
/// Turns left until completion.
|
||||
pub turns_remaining: i32,
|
||||
}
|
||||
|
||||
/// Standing order for units that arrive at a rally point.
|
||||
///
|
||||
/// Backward-compat serde: any unrecognised string value (e.g. old saves with
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue