From cb451832e0834e04571b30c71ff37d1cab4e7cfa Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 19:31:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20p3-26=20B3=20(1/4)=20=E2=80=94=20improv?= =?UTF-8?q?ement=20subsystem=20state=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the headless improvement subsystem (wired in the next increments): - PlayerState.pending_improvements: Vec (#[serde(default)]) — tile improvements under construction. - GameState.improvement_defs: BTreeMap (#[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) --- src/game/engine/scenes/main/main.gd | 31 ++ .../engine/scenes/menus/loading_screen.gd | 4 +- .../scenes/world_map/observer_controls.gd | 82 +++++ .../engine/scenes/world_map/observer_hud.gd | 336 ++++++++++++++++++ .../engine/scenes/world_map/observer_mode.gd | 56 +++ src/game/engine/scenes/world_map/world_map.gd | 78 ++++ .../scenes/world_map/world_map_arena.gd | 28 +- src/game/engine/src/autoloads/turn_manager.gd | 23 +- .../src/modules/management/observer_pacing.gd | 72 ++++ .../crates/mc-state/src/game_state.rs | 81 +++++ 10 files changed, 783 insertions(+), 8 deletions(-) create mode 100644 src/game/engine/scenes/world_map/observer_controls.gd create mode 100644 src/game/engine/scenes/world_map/observer_hud.gd create mode 100644 src/game/engine/scenes/world_map/observer_mode.gd create mode 100644 src/game/engine/src/modules/management/observer_pacing.gd diff --git a/src/game/engine/scenes/main/main.gd b/src/game/engine/scenes/main/main.gd index 6bc76c62..24629e63 100644 --- a/src/game/engine/scenes/main/main.gd +++ b/src/game/engine/scenes/main/main.gd @@ -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 diff --git a/src/game/engine/scenes/menus/loading_screen.gd b/src/game/engine/scenes/menus/loading_screen.gd index 14649909..8e0a1b63 100644 --- a/src/game/engine/scenes/menus/loading_screen.gd +++ b/src/game/engine/scenes/menus/loading_screen.gd @@ -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 diff --git a/src/game/engine/scenes/world_map/observer_controls.gd b/src/game/engine/scenes/world_map/observer_controls.gd new file mode 100644 index 00000000..fa85dedc --- /dev/null +++ b/src/game/engine/scenes/world_map/observer_controls.gd @@ -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) diff --git a/src/game/engine/scenes/world_map/observer_hud.gd b/src/game/engine/scenes/world_map/observer_hud.gd new file mode 100644 index 00000000..0ebed0c1 --- /dev/null +++ b/src/game/engine/scenes/world_map/observer_hud.gd @@ -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 " — 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") diff --git a/src/game/engine/scenes/world_map/observer_mode.gd b/src/game/engine/scenes/world_map/observer_mode.gd new file mode 100644 index 00000000..2fec511c --- /dev/null +++ b/src/game/engine/scenes/world_map/observer_mode.gd @@ -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) diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 52b63381..29785d69 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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", []) diff --git a/src/game/engine/scenes/world_map/world_map_arena.gd b/src/game/engine/scenes/world_map/world_map_arena.gd index 2f6f2066..528a3aef 100644 --- a/src/game/engine/scenes/world_map/world_map_arena.gd +++ b/src/game/engine/scenes/world_map/world_map_arena.gd @@ -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) diff --git a/src/game/engine/src/autoloads/turn_manager.gd b/src/game/engine/src/autoloads/turn_manager.gd index 58aa5d27..1b5b380c 100644 --- a/src/game/engine/src/autoloads/turn_manager.gd +++ b/src/game/engine/src/autoloads/turn_manager.gd @@ -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: diff --git a/src/game/engine/src/modules/management/observer_pacing.gd b/src/game/engine/src/modules/management/observer_pacing.gd new file mode 100644 index 00000000..b2a03b8d --- /dev/null +++ b/src/game/engine/src/modules/management/observer_pacing.gd @@ -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() diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index fc5b3253..e3d30a73 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -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, /// 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::>(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>, + /// 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, /// Per-city accumulated fauna harassment pressure. Aligned with `cities`. pub city_ecology: Vec, /// 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