From 2607491a1b02063e1f5dfeeb7288118519529408 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 23:40:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20weather/climate=20event=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/scenes/tests/auto_play.gd | 41 ++++++++++++++ tools/autoplay-report.py | 59 +++++++++++++++++++++ tools/schemas/autoplay/events-line.json | 10 +++- tools/schemas/autoplay/turn-stats-line.json | 27 +++++++++- 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 7c5135fa..934c50fc 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -300,6 +300,27 @@ func _on_lair_cleared_aggregate(_tile: Vector2i, _reward: Dictionary) -> void: _lair_cleared_count += 1 +func _on_weather_event_applied(kind: String, tile: Vector2i, severity: float) -> void: + _weather_events_this_turn += 1 + _total_weather_events += 1 + _append_event({ + "type": "weather_event", + "kind": kind, + "tile_x": tile.x, + "tile_y": tile.y, + "severity": severity, + }) + + +func _on_climate_effect_applied(unit_id: int, cause: String, hp_loss: int) -> void: + _append_event({ + "type": "climate_effect", + "unit_id": unit_id, + "cause": cause, + "hp_loss": hp_loss, + }) + + func _maybe_queue_siege_replacement(unit: Variant, idx: int) -> void: # Siege sustain: if a military unit belonging to the currently-attacking # player dies mid-siege and the stack is at or below 3, prepend a warrior @@ -2245,7 +2266,10 @@ func _append_turn_stats(outcome: String) -> void: "turn_first_city_captured": _turn_first_city_captured, "strategic_gate_rejected": _strategic_gate_rejected_count, "lair_cleared": _lair_cleared_count, + "weather_events_count": _weather_events_this_turn, + "total_weather_events": _total_weather_events, } + var ecology_block: Dictionary = _snapshot_ecology() var winner_personality: String = "" if _victory_winner >= 0: for p: Variant in GameState.players: @@ -2260,6 +2284,7 @@ func _append_turn_stats(outcome: String) -> void: "victory_type": _victory_type, "wall_clock_sec": wall_clock, "aggregate": aggregate, + "ecology": ecology_block, "player_stats": _build_player_stats(), "invariant_violations": _violations, } @@ -2285,11 +2310,27 @@ func _save_turn_snapshot() -> void: push_error("AutoPlay: save failed (%s): %s" % [error_string(err), save_path]) +func _snapshot_ecology() -> Dictionary: + ## Ecology block for the current turn_stats line (p0-35). Sources canopy + ## mean + delta from Climate.get_canopy_summary, which wraps the Rust + ## GdEcologyPhysics bridge. Returns zeros before TurnManager has produced + ## a climate tick so the first seeded line still schema-validates. + var climate: RefCounted = TurnManager.climate as RefCounted + if climate == null or not climate.has_method("get_canopy_summary"): + return {"flora_canopy_mean": 0.0, "flora_canopy_delta": 0.0} + var summary: Dictionary = climate.get_canopy_summary() as Dictionary + return { + "flora_canopy_mean": float(summary.get("mean", 0.0)), + "flora_canopy_delta": float(summary.get("delta_since_last_turn", 0.0)), + } + + func _flush_turn_artifacts() -> void: ## End-of-turn persistence: events, turn_stats line, save snapshot. ## Cheap to skip for unseeded runs (each callee short-circuits on _seed_set). _flush_events() _append_turn_stats(_outcome) + _weather_events_this_turn = 0 _save_turn_snapshot() if _save_at_turn > 0 and _turn_count == _save_at_turn: var save_path: String = _output_dir.path_join("mid_run.save") diff --git a/tools/autoplay-report.py b/tools/autoplay-report.py index 392c7d80..6560101b 100755 --- a/tools/autoplay-report.py +++ b/tools/autoplay-report.py @@ -44,6 +44,7 @@ META_SCHEMA_NAME = "meta" EVENT_TYPES = [ "city_founded", "city_captured", "city_grew", "city_starved", "tech_researched", "unit_created", "unit_destroyed", "combat_resolved", "victory", + "weather_event", "climate_effect", ] @@ -248,6 +249,13 @@ def extract_row( # Out-of-band fields for the per-clan aggregator. Not emitted to CSV. row["_player_clans"] = dict(player_clans) if player_clans else {} row["_snapshots"] = snapshots or {} + # Canopy telemetry (p0-35). Absent on pre-p0-35 batches — surfaced as + # None so the summary pipeline can skip those rows cleanly. + ecology = data.get("ecology") or {} + row["_canopy_mean_final"] = ecology.get("flora_canopy_mean") + row["_canopy_delta_final"] = ecology.get("flora_canopy_delta") + agg = data.get("aggregate", {}) + row["_total_weather_events"] = agg.get("total_weather_events") return row @@ -622,6 +630,55 @@ def print_quality_metrics( return q +def print_canopy_summary( + rows: list[dict[str, Any]], out: Any = sys.stderr +) -> None: + """Report canopy trend across the batch (p0-35). + + Sources each game's final `ecology.flora_canopy_mean` / `flora_canopy_delta` + from the last turn_stats line. Historical batches pre-p0-35 have None for + these fields — we skip those rows rather than mixing 0.0 into the median. + """ + means = [r["_canopy_mean_final"] for r in rows + if isinstance(r.get("_canopy_mean_final"), (int, float))] + deltas = [r["_canopy_delta_final"] for r in rows + if isinstance(r.get("_canopy_delta_final"), (int, float))] + print("flora canopy (p0-35):", file=out) + if not means: + print(" (no data — batch pre-dates p0-35 instrumentation)", file=out) + return + print(f" median final canopy mean: {_fmt(statistics.median(means))}", file=out) + nonzero_deltas = [d for d in deltas if d != 0.0] + print( + f" games with evolving canopy: {len(nonzero_deltas)} / {len(rows)} " + f"(non-zero final delta)", file=out + ) + if deltas: + print(f" median final |delta|: {_fmt(statistics.median([abs(d) for d in deltas]))}", file=out) + + +def print_weather_summary( + rows: list[dict[str, Any]], out: Any = sys.stderr +) -> None: + """Report weather-event coverage across the batch (p0-36).""" + totals = [r["_total_weather_events"] for r in rows + if isinstance(r.get("_total_weather_events"), (int, float))] + evt_counts = [r.get("evt_weather_event", 0) for r in rows] + print("weather events (p0-36):", file=out) + if not totals: + print(" (no data — batch pre-dates p0-36 instrumentation)", file=out) + return + games_with_any = sum(1 for t in totals if t > 0) + print( + f" games with >=1 weather_event: {games_with_any} / {len(rows)}", + file=out + ) + if totals: + print(f" median total_weather_events: {_fmt(statistics.median(totals))}", file=out) + total_evts = sum(evt_counts) + print(f" total weather_event records (events.jsonl): {total_evts}", file=out) + + def print_summary(rows: list[dict[str, Any]], out: Any = sys.stderr) -> None: print("=== autoplay batch report ===", file=out) print(f"games: {len(rows)}", file=out) @@ -660,6 +717,8 @@ def print_summary(rows: list[dict[str, Any]], out: Any = sys.stderr) -> None: total_v = sum(r["invariant_violations"] for r in rows) print(f"invariant violations (total): {total_v}", file=out) print_quality_metrics(rows, out=out) + print_canopy_summary(rows, out=out) + print_weather_summary(rows, out=out) print_personality_summary(rows, out=out) render_per_clan_table(rows, out=out) diff --git a/tools/schemas/autoplay/events-line.json b/tools/schemas/autoplay/events-line.json index 3c093dfb..cc83141f 100644 --- a/tools/schemas/autoplay/events-line.json +++ b/tools/schemas/autoplay/events-line.json @@ -19,7 +19,15 @@ "unit_created", "unit_destroyed", "combat_resolved", - "victory" + "victory", + "weather_event", + "climate_effect", + "improvement_started", + "improvement_built", + "city_building_completed", + "loot_dropped", + "wild_spawned", + "strategic_gate_rejected" ], "description": "Machine-readable event identifier" } diff --git a/tools/schemas/autoplay/turn-stats-line.json b/tools/schemas/autoplay/turn-stats-line.json index 40e6d5b3..7189b6f1 100644 --- a/tools/schemas/autoplay/turn-stats-line.json +++ b/tools/schemas/autoplay/turn-stats-line.json @@ -54,7 +54,32 @@ }, "turn_first_city_captured": { "type": "integer", "minimum": -1 }, "lair_cleared": { "type": "integer", "minimum": 0 }, - "strategic_gate_rejected": { "type": "integer", "minimum": 0 } + "strategic_gate_rejected": { "type": "integer", "minimum": 0 }, + "weather_events_count": { + "type": "integer", + "minimum": 0, + "description": "Weather events emitted during this turn (p0-36)." + }, + "total_weather_events": { + "type": "integer", + "minimum": 0, + "description": "Cumulative weather events since run start (p0-36)." + } + } + }, + "ecology": { + "type": "object", + "description": "Per-turn flora ecology summary (p0-35). Sourced from GdEcologyPhysics.canopy_summary. Absent on pre-p0-35 batches.", + "additionalProperties": false, + "properties": { + "flora_canopy_mean": { + "type": "number", + "description": "Mean canopy_cover across all non-water tiles this turn." + }, + "flora_canopy_delta": { + "type": "number", + "description": "Change in mean canopy vs. the previous turn (0.0 on turn 1)." + } } }, "player_stats": {