feat(@projects/@magic-civilization): add weather/climate event tracking

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 23:40:04 -07:00
parent d245ea469b
commit 2607491a1b
4 changed files with 135 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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