feat(@projects): add parallel execution config

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 23:35:00 -07:00
parent 65572ecebd
commit d245ea469b
12 changed files with 448 additions and 6 deletions

View file

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

View file

@ -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 <clan_id> [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
;;

View file

@ -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=<abs_path> 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<RefCounted>,
}
@ -204,6 +210,7 @@ impl IRefCounted for GdEcologyPhysics {
fn init(base: Base<RefCounted>) -> 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<GdGridState>, 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<GdGridState>) -> 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 ──────────────────────────────────────────────