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:
Natalie 2026-06-26 19:31:26 -04:00
parent 57725d0088
commit cb451832e0
10 changed files with 783 additions and 8 deletions

View file

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

View file

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

View 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)

View 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")

View 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)

View file

@ -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", [])

View file

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

View file

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

View 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()

View file

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