From d245ea469bd0ac68021ad36476c96bba138b98b8 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 23:35:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects):=20=E2=9C=A8=20add=20parallel?= =?UTF-8?q?=20execution=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .env.example | 18 +++ scripts/apricot-run.sh | 40 +++++- src/game/engine/scenes/tests/auto_play.gd | 6 + src/game/engine/scenes/world_map/world_map.gd | 79 +++++++++++ src/game/engine/src/autoloads/event_bus.gd | 7 + src/game/engine/src/entities/city.gd | 19 +++ .../engine/src/modules/climate/climate.gd | 25 ++++ .../src/modules/climate/climate_effects.gd | 5 + .../engine/src/modules/climate/weather.gd | 10 +- .../rendering/prologue_overlay_renderer.gd | 74 ++++++++++ .../engine/tests/unit/test_prologue_driver.gd | 132 ++++++++++++++++++ src/simulator/api-gdext/src/lib.rs | 39 ++++++ 12 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 src/game/engine/src/rendering/prologue_overlay_renderer.gd create mode 100644 src/game/engine/tests/unit/test_prologue_driver.gd diff --git a/.env.example b/.env.example index ac4e5b1b..6c571d48 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,21 @@ GUIDE_CLIMATE_PARAMS=public/resources/worlds/earth/climate_params.json # only for per-developer experimentation. # FORCE_DISABLE_FOGOFWAR=false # FORCE_UNLIMITED_RESEARCH=false + +# ── Batch runner resource policy (scripts/apricot-run.sh) ────────── +# USE_MAX_CORES=true → PARALLEL defaults to `nproc` on the RUN host +# (saturates CPU). Games still respect their own +# per-instance single-core cost; this controls +# how many games run concurrently. +# USE_MAX_CORES=false → PARALLEL defaults to MIN_CORES below (safe for +# shared / laptop hosts). +# An explicit PARALLEL=N in the caller's environment ALWAYS wins over +# both of the above. +USE_MAX_CORES=false +MIN_CORES=4 + +# AI_GPU_ROLLOUT=true routes MCTS rollouts through the WGSL compute +# kernel when an adapter is available. Smoke/clan batches flip this on +# by default in scripts/apricot-run.sh. `gpu-walltime` mode overrides +# per iteration (explicitly runs both true and false for comparison). +# AI_GPU_ROLLOUT=true diff --git a/scripts/apricot-run.sh b/scripts/apricot-run.sh index 061aa532..92ba399e 100755 --- a/scripts/apricot-run.sh +++ b/scripts/apricot-run.sh @@ -26,6 +26,30 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" APRICOT="${APRICOT_SSH_ALIAS:-apricot}" STAMP="${STAMP:-$(date +%Y%m%d_%H%M%S)}" + +# ── Load .env / .env.local so USE_MAX_CORES + MIN_CORES + AI_GPU_ROLLOUT +# propagate into the resource policy below. .env.local wins over .env. +for envfile in "${PROJECT_DIR}/.env" "${PROJECT_DIR}/.env.local"; do + if [[ -f "${envfile}" ]]; then + set -a; source "${envfile}"; set +a + fi +done + +# ── Resource policy for PARALLEL ───────────────────────────────────── +# Precedence: explicit PARALLEL env > USE_MAX_CORES=true (nproc on RUN host) +# > MIN_CORES from .env (default 4). Games are single-core each; +# this controls how many run concurrently. +if [[ -n "${PARALLEL:-}" ]]; then + PARALLEL_EFFECTIVE="${PARALLEL}" + PARALLEL_SOURCE="env override" +elif [[ "${USE_MAX_CORES:-false}" == "true" ]]; then + PARALLEL_EFFECTIVE="$(ssh "${APRICOT}" nproc 2>/dev/null || echo "${MIN_CORES:-4}")" + PARALLEL_SOURCE="USE_MAX_CORES=true → nproc" +else + PARALLEL_EFFECTIVE="${MIN_CORES:-4}" + PARALLEL_SOURCE="MIN_CORES default" +fi +export PARALLEL="${PARALLEL_EFFECTIVE}" # Source + build scratch lives under $HOME/.cache (flatpak-visible via # --filesystem=home). /tmp was tried first but flatpak's sandbox can't see # /tmp, so Godot rejected the --path argument with "Invalid project path". @@ -47,6 +71,8 @@ echo " EDIT host: $(hostname)" echo " RUN host: ${APRICOT}" echo " SCRATCH: ${SCRATCH_ABS} (per-run source + build scratch)" echo " RESULTS: ${RESULTS_ABS} (persistent batch output)" +echo " PARALLEL: ${PARALLEL_EFFECTIVE} (source: ${PARALLEL_SOURCE})" +echo " AI_GPU_ROLLOUT: ${AI_GPU_ROLLOUT:-true (default on for smoke/clan)}" echo "============================================================" # ── Step 1: rsync EDIT → SCRATCH ───────────────────────────────────────────── @@ -101,17 +127,21 @@ ssh "${APRICOT}" "mkdir -p ${RESULTS_ABS}" case "${MODE}" in smoke) SEEDS="${1:-10}"; TURNS="${2:-300}" - echo "[$(date +%H:%M:%S)] smoke batch: ${SEEDS} seeds T${TURNS}" + # Default: use the GPU when available (MCTS rollouts through WGSL kernel). + # gpu-walltime mode overrides this explicitly to true/false per iteration. + GPU_ENV="AI_GPU_ROLLOUT=${AI_GPU_ROLLOUT:-true}" + echo "[$(date +%H:%M:%S)] smoke batch: ${SEEDS} seeds T${TURNS} PARALLEL=${PARALLEL} ${GPU_ENV}" ssh "${APRICOT}" "set -euo pipefail; cd '${SCRATCH_ABS}' && \ - AI_USE_MCTS=true PARALLEL=${PARALLEL:-16} \ + AI_USE_MCTS=true ${GPU_ENV} PARALLEL=${PARALLEL} \ bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/smoke 2>&1 | tail -30" ;; clan) CLAN="${1:?usage: apricot-run.sh clan [seeds] [turns]}" SEEDS="${2:-10}"; TURNS="${3:-300}" - echo "[$(date +%H:%M:%S)] clan=${CLAN} batch: ${SEEDS} seeds T${TURNS}" + GPU_ENV="AI_GPU_ROLLOUT=${AI_GPU_ROLLOUT:-true}" + echo "[$(date +%H:%M:%S)] clan=${CLAN} batch: ${SEEDS} seeds T${TURNS} PARALLEL=${PARALLEL} ${GPU_ENV}" ssh "${APRICOT}" "set -euo pipefail; cd '${SCRATCH_ABS}' && \ - AI_USE_MCTS=true AI_PIN_PERSONALITY='${CLAN}' PARALLEL=${PARALLEL:-16} \ + AI_USE_MCTS=true AI_PIN_PERSONALITY='${CLAN}' ${GPU_ENV} PARALLEL=${PARALLEL} \ bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/clan-${CLAN} 2>&1 | tail -30" ;; gpu-walltime) @@ -120,7 +150,7 @@ case "${MODE}" in for GPU in true false; do echo " --- AI_GPU_ROLLOUT=${GPU} ---" ssh "${APRICOT}" "set -euo pipefail; cd '${SCRATCH_ABS}' && \ - AI_USE_MCTS=true AI_GPU_ROLLOUT=${GPU} PARALLEL=${PARALLEL:-16} \ + AI_USE_MCTS=true AI_GPU_ROLLOUT=${GPU} PARALLEL=${PARALLEL} \ bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/gpu-${GPU} 2>&1 | tail -10" done ;; diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 9c812144..7c5135fa 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -76,6 +76,10 @@ var _final_line_written: bool = false var _result_written: bool = false var _strategic_gate_rejected_count: int = 0 var _lair_cleared_count: int = 0 +# Weather telemetry (p0-36). Per-turn counter reset in _flush_turn_artifacts; +# cumulative counter rolls up for the lifetime of the run. +var _weather_events_this_turn: int = 0 +var _total_weather_events: int = 0 # Save-resume test vars: AUTO_PLAY_SAVE_AT=N writes autosave at turn N and quits. # AUTO_PLAY_LOAD_AUTOSAVE= overrides GameState from that save after world loads. var _save_at_turn: int = -1 @@ -142,6 +146,8 @@ func _ready() -> void: EventBus.wild_creature_spawned.connect(_on_wild_creature_spawned) EventBus.strategic_gate_rejected.connect(_on_strategic_gate_rejected) EventBus.lair_cleared.connect(_on_lair_cleared_aggregate) + EventBus.weather_event_applied.connect(_on_weather_event_applied) + EventBus.climate_effect_applied.connect(_on_climate_effect_applied) _improvement_manager = ImprovementManagerScript.new() diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 90342a3d..6fa0d157 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -39,6 +39,9 @@ const WorldMapHoverScript: GDScript = preload( const PrologueDriverScript: GDScript = preload( "res://engine/src/modules/management/prologue_driver.gd" ) +const PrologueOverlayRendererScript: GDScript = preload( + "res://engine/src/rendering/prologue_overlay_renderer.gd" +) var _hex_renderer: Node2D = null var _unit_renderer: Node2D = null @@ -53,6 +56,9 @@ var _city_actions: RefCounted = null var _units_helper: RefCounted = null var _arena: RefCounted = null var _hover: RefCounted = null +## p0-34 draw-first overlay: paints wanderer + tribe glyphs during the +## prologue. Hidden and cleared once PrologueState == Normal. +var _prologue_overlay: Node2D = null var _selected_unit: RefCounted = null var _reachable_hexes: Dictionary = {} @@ -94,6 +100,9 @@ func _setup_renderers() -> void: _fog_renderer = FogRendererScript.new() _fog_renderer.name = "FogRenderer" $FogLayer.add_child(_fog_renderer) + _prologue_overlay = PrologueOverlayRendererScript.new() + _prologue_overlay.name = "PrologueOverlayRenderer" + $OverlayLayer.add_child(_prologue_overlay) if not _arena_mode: _city_screen = CityScreenScene.instantiate() add_child(_city_screen) @@ -129,6 +138,7 @@ func _connect_signals() -> void: EventBus.turn_ended.connect(_on_turn_ended) EventBus.prologue_state_changed.connect(_on_prologue_state_changed) EventBus.capital_founded.connect(_on_prologue_capital_founded) + EventBus.tribe_converged.connect(_on_prologue_tribe_converged) EventBus.gold_changed.connect(_on_gold_changed) EventBus.happiness_changed.connect(_on_happiness_changed) EventBus.village_discovered.connect(_on_village_discovered) @@ -328,6 +338,8 @@ func _populate_grid_biomes(grid: RefCounted, game_map: RefCounted) -> void: func _on_prologue_state_changed(state: int, turn: int) -> void: + if _prologue_overlay != null: + _prologue_overlay.queue_redraw() if _arena_mode: return _hud.update_turn(turn) @@ -351,10 +363,40 @@ func _on_prologue_capital_founded(_player_id: int, _position: Vector2i, _pop: in # City founding from prologue mutates GameState.players[].cities; refresh # unit + city renderers so the new capital sprite appears. _sync_units() + if _prologue_overlay != null: + _prologue_overlay.queue_redraw() if not _arena_mode: _update_hud() +## p0-34: on end-of-turn-0 convergence, spawn a GDScript `dwarf_tribe` Unit +## at the centroid for the relevant player. Mirrors the Rust-side +## ConvergenceOutcome: the wanderers are already marked merged_into_tribe in +## GdPrologue state, and a live Unit is what the UI needs to let the player +## click Found Capital. +func _on_prologue_tribe_converged( + player_id: int, centroid: Vector2i, _ancestors_merged: int, _founding_pop: int +) -> void: + var player: RefCounted = GameState.get_player(player_id) + if player == null: + return + var tribe_unit: RefCounted = _units_helper.create_unit( + "dwarf_tribe", player_id, centroid + ) + # Movement is kept at 1 so `_show_unit_panel`'s + # `unit.can_found_city and unit.movement_remaining > 0` gate flips the + # Found City button visible. The tribe cannot actually move because + # hex-click remains gated during the prologue; the single MP simply + # unlocks the action-bar path. Rust dwarf_tribe_allowed_actions already + # enforces Found-Capital-only on the simulation side. + tribe_unit.movement_remaining = 1 + _units_helper.register_unit(tribe_unit, player) + EventBus.unit_created.emit(tribe_unit, player_id) + _sync_units() + if _prologue_overlay != null: + _prologue_overlay.queue_redraw() + + func _update_fog(player: RefCounted, game_map: RefCounted) -> void: var arrays: Array = WorldMapVisionScript.build_fog_arrays(player, game_map) _hex_renderer.update_fog(arrays[0], arrays[1]) @@ -656,9 +698,46 @@ func _on_village_discovered(tile_pos: Vector2i, reward: Dictionary) -> void: func _on_found_city_pressed() -> void: + # p0-34 Dwarf Tribe branch: the prologue founds the capital with the + # mode-specific override_pop from PrologueDriver.found_capital, not the + # legacy pop-1 path used by ordinary Founders. + if _selected_unit != null and _selected_unit.type_id == "dwarf_tribe": + _found_capital_from_tribe(_selected_unit) + return (_city_actions as WorldMapCityActionsScript).on_found_city_pressed(_selected_unit) +func _found_capital_from_tribe(tribe_unit: RefCounted) -> void: + if TurnManager.prologue == null: + push_warning("WorldMap: dwarf_tribe unit present but prologue is null") + return + var pid: int = int(tribe_unit.owner) + var result: Dictionary = (TurnManager.prologue as PrologueDriverScript).found_capital(pid) + if result.is_empty(): + push_warning("WorldMap: PrologueDriver.found_capital returned empty for player %d" % pid) + return + var pop: int = int(result.get("population", 1)) + var q: int = int(result.get("q", tribe_unit.position.x)) + var r: int = int(result.get("r", tribe_unit.position.y)) + var player: RefCounted = GameState.get_player(pid) + if player == null: + return + var city: RefCounted = CityScript.new() + city.owner = pid + var city_name: String = "%s Hold" % str(player.player_name) if str(player.player_name) != "" else "Capital" + city.found_with_population(city_name, q, r, true, 1, pop) + player.cities.append(city) + # Consume the Dwarf Tribe unit and forward chronicle events. + _units_helper.remove_unit(tribe_unit, player) + EventBus.unit_destroyed.emit(tribe_unit, null) + EventBus.city_founded.emit(city, pid) + PrologueDriverScript.dispatch_chronicle_events( + result.get("chronicle_events", []) as Array + ) + _deselect_unit() + _sync_units() + + func _on_city_tile_double_clicked(pos: Vector2i) -> void: var player: RefCounted = GameState.get_current_player() if player == null: diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index 3702d926..58f29f1d 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -90,6 +90,13 @@ signal terrain_transformed(tile: Variant, old_type: String, new_type: String) signal quality_changed(tile: Variant, old_quality: int, new_quality: int) signal wind_recalculated signal climate_damage_applied(unit: Variant, damage_type: String, amount: int) +## Emitted once per weather event derived by WeatherScript.process_turn. +## Drives the telemetry pipeline (auto_play.gd appends to events.jsonl) and +## any future analytics consumers. Tile is axial (q, r). +signal weather_event_applied(kind: String, tile: Vector2i, severity: float) +## Emitted once per unit that takes climate-effects damage this turn. `cause` +## mirrors the Rust `cause` field on the unit-effect payload (e.g. "heat_wave"). +signal climate_effect_applied(unit_id: int, cause: String, hp_loss: int) # -- Village/Lair signals -- signal village_discovered(tile: Vector2i, reward: Dictionary) diff --git a/src/game/engine/src/entities/city.gd b/src/game/engine/src/entities/city.gd index 52ace793..ce644746 100644 --- a/src/game/engine/src/entities/city.gd +++ b/src/game/engine/src/entities/city.gd @@ -148,6 +148,25 @@ func found(name: String, col: int, row: int, capital: bool, turn: int) -> void: id = _gd_city.call("get_id") +## Found a new city with an explicit starting population override (p0-34 +## Dwarf Tribe path). Ordinary settlers should keep calling `found(...)`. +func found_with_population( + name: String, col: int, row: int, capital: bool, turn: int, pop: int +) -> void: + city_name = name + is_capital = capital + if capital and original_capital_owner < 0: + original_capital_owner = owner + turn_founded = turn + position = Vector2i(col, row) + if _gd_city == null: + _warn_missing_extension() + id = "city_%d_%d_%d" % [col, row, turn] + return + _gd_city.call("found_with_population", name, col, row, capital, turn, pop) + id = _gd_city.call("get_id") + + func rename_city(new_name: String) -> void: city_name = new_name if _gd_city != null: diff --git a/src/game/engine/src/modules/climate/climate.gd b/src/game/engine/src/modules/climate/climate.gd index e96ca9a9..60f84e7d 100644 --- a/src/game/engine/src/modules/climate/climate.gd +++ b/src/game/engine/src/modules/climate/climate.gd @@ -24,6 +24,12 @@ var global_avg_temp: float = 0.5 var ocean_dead_fraction: float = 0.0 var _rust: GdClimatePhysics +## Ecology tick — runs flora succession (canopy / undergrowth / fungi) each turn +## so mc_climate::ecology::EcologyPhysics is the canonical source per Rail-1. +## Typed as RefCounted (not GdEcologyPhysics) to avoid the GDExtension-class +## parse-order trap: extension classes only register at Scene level, after +## GDScript's class_name resolution. Same pattern as weather.gd / economy.gd. +var _ecology: RefCounted = null var _grid: GdGridState var _spec_json: String = "" var _spec: Dictionary = {} @@ -85,6 +91,15 @@ func process_turn(game_map: RefCounted, turn: int = 0, seed: int = 42) -> void: # Run the core physics in Rust _rust.process_step(_grid, turn, seed, 1.0) + # Flora succession — canopy / undergrowth / fungi evolution. + # Kept separate from GdClimatePhysics so ecology stays swappable for + # physics_features.ecology=false worlds (future Game 2 biomes). + if _physics_flags.get("ecology", true): + if _ecology == null and ClassDB.class_exists("GdEcologyPhysics"): + _ecology = ClassDB.instantiate("GdEcologyPhysics") as RefCounted + if _ecology != null: + _ecology.process_step(_grid, 1.0) + # Sync results back to GDScript game_map tiles _sync_grid_to_tiles(game_map) @@ -182,6 +197,16 @@ func _sync_grid_to_tiles(game_map: RefCounted) -> void: tile.habitat_suitability = d.get("habitat_suitability", 0.0) +func get_canopy_summary() -> Dictionary: + ## Return `{mean: float, delta_since_last_turn: float}` for flora canopy + ## across non-water tiles. Tracks internal state on `_ecology` so the + ## delta reflects change since the previous call. Returns zeros when + ## ecology is disabled or the grid hasn't been populated yet. + if _ecology == null or _grid == null: + return {"mean": 0.0, "delta_since_last_turn": 0.0} + return _ecology.canopy_summary(_grid) as Dictionary + + func get_phase_label() -> String: ## Cosmetic climate phase name derived from global average temperature. const THRESHOLDS: Array[float] = [0.15, 0.30, 0.45, 0.75] diff --git a/src/game/engine/src/modules/climate/climate_effects.gd b/src/game/engine/src/modules/climate/climate_effects.gd index 5d23f386..23a20191 100644 --- a/src/game/engine/src/modules/climate/climate_effects.gd +++ b/src/game/engine/src/modules/climate/climate_effects.gd @@ -127,5 +127,10 @@ func _apply_unit_effects(effects: Array, handle_to_unit: Dictionary) -> void: push_warning("[ClimateEffects] %s damaged unit %s for %d HP" % [ cause, String(unit.unit_id), hp_loss ]) + # Telemetry signal (p0-36) — one per damaged unit. `handle` is the + # int assigned in _gather_units; downstream analytics key on it + # rather than the stringy unit_id because that's what events.jsonl + # schemas type as int. + EventBus.climate_effect_applied.emit(handle, cause, hp_loss) if unit.hp <= 0: EventBus.unit_destroyed.emit(unit, null) diff --git a/src/game/engine/src/modules/climate/weather.gd b/src/game/engine/src/modules/climate/weather.gd index 2e179f45..5f6e83c3 100644 --- a/src/game/engine/src/modules/climate/weather.gd +++ b/src/game/engine/src/modules/climate/weather.gd @@ -67,7 +67,15 @@ func process_turn(_game_map: RefCounted) -> void: var parsed: Array = _parse_events_array(events_json) _active_events.clear() for ev: Dictionary in parsed: - _active_events.append(_normalize_event(ev)) + var normalized: Dictionary = _normalize_event(ev) + _active_events.append(normalized) + # Fire one telemetry signal per derived event so the events.jsonl + # log (p0-36) captures weather occurrences. UI already listens to + # weather_effects_updated below, so this does not replace that. + var kind: String = String(normalized.get("kind", "")) + var axial: Vector2i = normalized.get("axial", Vector2i.ZERO) as Vector2i + var severity: float = float(normalized.get("severity", 0.0)) + EventBus.weather_event_applied.emit(kind, axial, severity) EventBus.weather_effects_updated.emit(_active_events) diff --git a/src/game/engine/src/rendering/prologue_overlay_renderer.gd b/src/game/engine/src/rendering/prologue_overlay_renderer.gd new file mode 100644 index 00000000..002968ae --- /dev/null +++ b/src/game/engine/src/rendering/prologue_overlay_renderer.gd @@ -0,0 +1,74 @@ +class_name PrologueOverlayRenderer +extends Node2D +## Draw-first overlay for the Freepeople tribe-founding prologue (p0-34). +## Paints a circle + letter glyph for each wanderer ('W') and for the +## convergence centroid (when relevant). Matches the placeholder pattern +## introduced by p0-23 ahead of asset-sprite's real sprite authoring. +## +## Reads state from TurnManager.prologue (a PrologueDriver). Hidden when +## the prologue has resolved (state == Normal) or when the driver is null. + +const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") +const PrologueDriverScript: GDScript = preload( + "res://engine/src/modules/management/prologue_driver.gd" +) + +const WANDERER_RADIUS: float = 10.0 +const TRIBE_RADIUS: float = 16.0 +const WANDERER_COLOR: Color = Color(0.85, 0.75, 0.55, 0.9) +const WANDERER_BORDER: Color = Color(0.25, 0.18, 0.08, 1.0) +const TRIBE_COLOR: Color = Color(0.9, 0.5, 0.2, 0.95) +const TRIBE_BORDER: Color = Color(0.2, 0.12, 0.05, 1.0) +const GLYPH_COLOR: Color = Color.BLACK + +var _font: Font = null + + +func _ready() -> void: + z_index = 12 # above hex + fog, below modal overlays + _font = ThemeDB.fallback_font + + +func _draw() -> void: + if TurnManager.prologue == null: + return + var driver: PrologueDriverScript = TurnManager.prologue as PrologueDriverScript + if not driver.is_available(): + return + # Drawing happens even on Normal so the capital-founded transition looks + # clean (empty overlay). Wanderers + tribes are queried per known player. + for player_var: Variant in GameState.players: + if player_var == null: + continue + var pid: int = int(player_var.index) + _draw_wanderers(driver, pid) + _draw_tribe_marker(driver, pid) + + +func _draw_wanderers(driver: PrologueDriverScript, player_id: int) -> void: + var flat: PackedInt32Array = driver.wanderers_for(player_id) + var i: int = 0 + while i + 1 < flat.size(): + var q: int = flat[i] + var r: int = flat[i + 1] + var pixel: Vector2 = HexUtilsScript.axial_to_pixel(Vector2i(q, r)) + draw_circle(pixel, WANDERER_RADIUS, WANDERER_COLOR) + draw_arc(pixel, WANDERER_RADIUS, 0.0, TAU, 24, WANDERER_BORDER, 1.5) + if _font != null: + var offset: Vector2 = Vector2(-4.0, 5.0) + draw_string(_font, pixel + offset, "W", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, GLYPH_COLOR) + i += 2 + + +func _draw_tribe_marker(driver: PrologueDriverScript, player_id: int) -> void: + var tribe: Dictionary = driver.dwarf_tribe(player_id) + if tribe.is_empty(): + return + var q: int = int(tribe.get("q", 0)) + var r: int = int(tribe.get("r", 0)) + var pixel: Vector2 = HexUtilsScript.axial_to_pixel(Vector2i(q, r)) + draw_circle(pixel, TRIBE_RADIUS, TRIBE_COLOR) + draw_arc(pixel, TRIBE_RADIUS, 0.0, TAU, 28, TRIBE_BORDER, 2.0) + if _font != null: + var offset: Vector2 = Vector2(-5.0, 6.0) + draw_string(_font, pixel + offset, "T", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, GLYPH_COLOR) diff --git a/src/game/engine/tests/unit/test_prologue_driver.gd b/src/game/engine/tests/unit/test_prologue_driver.gd new file mode 100644 index 00000000..cafe50c3 --- /dev/null +++ b/src/game/engine/tests/unit/test_prologue_driver.gd @@ -0,0 +1,132 @@ +extends GutTest +## p0-34 — PrologueDriver GDExtension bridge smoke test. +## +## When GdPrologue is registered (apricot + macOS export builds), walk the +## -1 → 0 → 1 state machine and verify: +## - state() + display_turn() report the canonical sequence. +## - is_prologue() flips false on Normal. +## - advance() returns Dictionary with new_state / new_turn / chronicle_events. +## - tribe_converged fires on the 0 → 1 edge. +## - capital_founded fires on found_capital(). +## +## When the extension isn't loaded (pure-GDScript headless), the PrologueDriver +## stub's is_available() == false path is exercised instead. + +const PrologueDriverScript: GDScript = preload( + "res://engine/src/modules/management/prologue_driver.gd" +) + +var _converged_events: Array[Dictionary] = [] +var _founded_events: Array[Dictionary] = [] + + +func before_each() -> void: + _converged_events.clear() + _founded_events.clear() + if EventBus.tribe_converged.is_connected(_on_tribe_converged): + EventBus.tribe_converged.disconnect(_on_tribe_converged) + EventBus.tribe_converged.connect(_on_tribe_converged) + if EventBus.capital_founded.is_connected(_on_capital_founded): + EventBus.capital_founded.disconnect(_on_capital_founded) + EventBus.capital_founded.connect(_on_capital_founded) + + +func after_each() -> void: + if EventBus.tribe_converged.is_connected(_on_tribe_converged): + EventBus.tribe_converged.disconnect(_on_tribe_converged) + if EventBus.capital_founded.is_connected(_on_capital_founded): + EventBus.capital_founded.disconnect(_on_capital_founded) + + +func _on_tribe_converged( + player_id: int, centroid: Vector2i, ancestors_merged: int, founding_pop: int +) -> void: + _converged_events.append({ + "player_id": player_id, + "centroid": centroid, + "ancestors_merged": ancestors_merged, + "founding_pop": founding_pop, + }) + + +func _on_capital_founded(player_id: int, position: Vector2i, pop: int) -> void: + _founded_events.append({ + "player_id": player_id, + "position": position, + "pop": pop, + }) + + +func test_driver_constructs() -> void: + var driver: RefCounted = PrologueDriverScript.new() + assert_not_null(driver, "PrologueDriver instantiates") + + +func test_stub_mode_returns_normal_state_when_extension_missing() -> void: + # When the GdPrologue GDExtension isn't loaded (headless w/o .so), the + # driver reports is_available() == false and falls back to stub values: + # state() == STATE_NORMAL, is_prologue() == false, display_turn() == 1. + var driver: PrologueDriverScript = PrologueDriverScript.new() as PrologueDriverScript + if driver.is_available(): + pending("GdPrologue extension is registered — stub path skipped") + return + assert_eq(driver.state(), PrologueDriverScript.STATE_NORMAL, "stub state == Normal") + assert_false(driver.is_prologue(), "stub is_prologue() == false") + assert_eq(driver.display_turn(), 1, "stub display_turn() == 1") + var adv: Dictionary = driver.advance() + assert_true(adv.is_empty(), "stub advance() returns empty dict") + + +func test_full_prologue_sequence_when_extension_available() -> void: + var driver: PrologueDriverScript = PrologueDriverScript.new() as PrologueDriverScript + if not driver.is_available(): + pending("GdPrologue extension not loaded — skipping live-flow test") + return + assert_eq(driver.state(), PrologueDriverScript.STATE_TURN_MINUS_ONE, "starts on TurnMinusOne") + assert_eq(driver.display_turn(), -1, "starts on turn -1") + assert_true(driver.is_prologue(), "is_prologue() true on start") + + # Advance -1 → 0: rolls happen, no chronicle entries yet. No registered + # players in this test so the advance is a pure state-machine probe. + var adv_to_zero: Dictionary = driver.advance() + assert_eq(int(adv_to_zero.get("new_state", -1)), PrologueDriverScript.STATE_TURN_ZERO) + assert_eq(int(adv_to_zero.get("new_turn", -99)), 0, "after 1st advance, turn == 0") + + # Advance 0 → 1: with no registered players converge is a no-op; state + # transitions to Normal. + var adv_to_one: Dictionary = driver.advance() + assert_eq(int(adv_to_one.get("new_state", -1)), PrologueDriverScript.STATE_NORMAL) + assert_eq(int(adv_to_one.get("new_turn", -99)), 1, "after 2nd advance, turn == 1") + assert_false(driver.is_prologue(), "is_prologue() false on Normal") + + +func test_dispatch_chronicle_events_routes_to_eventbus() -> void: + var fake_events: Array = [ + { + "event": "tribe_converged", + "turn": 0, + "player_id": 2, + "q": 4, + "r": -1, + "ancestors_merged": 5, + "founding_pop": 2, + }, + { + "event": "capital_founded", + "turn": 1, + "player_id": 2, + "q": 4, + "r": -1, + "pop": 2, + }, + ] + PrologueDriverScript.dispatch_chronicle_events(fake_events) + assert_eq(_converged_events.size(), 1, "tribe_converged fired once") + assert_eq(_converged_events[0]["player_id"], 2) + assert_eq(_converged_events[0]["centroid"], Vector2i(4, -1)) + assert_eq(_converged_events[0]["ancestors_merged"], 5) + assert_eq(_converged_events[0]["founding_pop"], 2) + assert_eq(_founded_events.size(), 1, "capital_founded fired once") + assert_eq(_founded_events[0]["player_id"], 2) + assert_eq(_founded_events[0]["position"], Vector2i(4, -1)) + assert_eq(_founded_events[0]["pop"], 2) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 3121edb2..577eb65d 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -196,6 +196,12 @@ impl GdClimatePhysics { #[class(base=RefCounted)] pub struct GdEcologyPhysics { inner: EcologyPhysics, + // Rolling mean of canopy_cover across non-water tiles, captured on the + // most recent canopy_summary() call. Used to derive a per-turn delta that + // the telemetry pipeline (auto_play.gd::_snapshot_turn_stats) can emit + // into turn_stats.jsonl without the GDScript side holding prior state. + // f32::NAN sentinel = no prior sample yet (delta returns 0.0 on first call). + last_canopy_mean: f32, base: Base, } @@ -204,6 +210,7 @@ impl IRefCounted for GdEcologyPhysics { fn init(base: Base) -> Self { Self { inner: EcologyPhysics::new(), + last_canopy_mean: f32::NAN, base, } } @@ -216,6 +223,38 @@ impl GdEcologyPhysics { fn process_step(&mut self, mut grid: Gd, dt: f64) { self.inner.process_step(&mut grid.bind_mut().inner, dt as f32); } + + /// Summarize current flora canopy across all non-water tiles. + /// + /// Returns `{ mean: f64, delta_since_last_turn: f64 }`. The delta is + /// computed against the mean captured on the previous call (NaN sentinel + /// on the first call yields delta=0.0). Used by telemetry to surface + /// canopy evolution per turn without requiring tile-level snapshots. + #[func] + fn canopy_summary(&mut self, grid: Gd) -> Dictionary { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let g = grid.bind(); + let mut sum: f64 = 0.0; + let mut count: u64 = 0; + for tile in &g.inner.tiles { + if has_tag(&tile.biome_id, BiomeTag::IsWater) { + continue; + } + sum += tile.canopy_cover as f64; + count += 1; + } + let mean: f32 = if count > 0 { (sum / count as f64) as f32 } else { 0.0 }; + let delta: f32 = if self.last_canopy_mean.is_nan() { + 0.0 + } else { + mean - self.last_canopy_mean + }; + self.last_canopy_mean = mean; + let mut out = Dictionary::new(); + out.set("mean", mean as f64); + out.set("delta_since_last_turn", delta as f64); + out + } } // ── GdAtmosphericChemistry ──────────────────────────────────────────────