""" render_state.py — build the RenderState dict (claude-handoff/schemas/render_state.schema.json) from the authoritative engine state. PURE VIEW: reads state, returns a plain dict. No rules decisions, no model calls, no mutation. The schema is a CLOSED contract — emit exactly its fields, nothing extra. """ from __future__ import annotations import os import sys _SERVER_DIR = os.path.dirname(os.path.abspath(__file__)) _BACKEND_DIR = os.path.abspath(os.path.join(_SERVER_DIR, "..", "..", "backend")) for _p in (_BACKEND_DIR, _SERVER_DIR): if _p not in sys.path: sys.path.insert(0, _p) import engine import rules import render # User-canon visual timings (2026-06-12): night from round 7; rain only while a # monsoon_flood incident is active (round-based rain tanked frontend perf). NIGHT_FROM_ROUND = 7 EMPTY_AI = {"priority": "", "actions": [], "announcement": "", "noop": False, "confidence": None} def _station_flag(pct: float) -> str: return "crush" if pct > 170 else "over" if pct > 100 else "warn" if pct > 85 else "" def _train_status(g, t) -> str: if rules._blocked(g, t): return "frozen" if t.held: return "held" if t.stuck_turns: return "stuck" return "running" def _card_reason(g, card: str, cost: int) -> str: """Mirror play.py cards_line: unlock-round / max-plays / consecutive-rest / affordability.""" if not rules.card_available(g, card): unlock = engine.B.get("card_unlock_round", {}).get(card) mx = engine.B.get("card_max_plays", {}).get(card) if unlock is not None and (g.turn + 1) < unlock: return f"locked until R{unlock}" if mx is not None and g.card_plays.get(card, 0) >= mx: return "used up" return "resting" if cost > g.energy: return "too expensive" return "" def legal_cards(g) -> list[dict]: # Stable order (by base cost); displayed cost is the CURRENT cost (peak discounts via card_cost). out = [] for card, _base in sorted(engine.CARDS.items(), key=lambda kv: kv[1]): cost = engine.card_cost(g, card) available = rules.card_available(g, card) and cost <= g.energy out.append({"card": card, "cost": cost, "available": available, "reason": _card_reason(g, card, cost)}) return out def build_state(g, ai: dict | None = None, chaos_last: list | None = None, notifications: list | None = None) -> dict: """Engine state -> RenderState dict. `ai` / `chaos_last` / `notifications` describe the turn that just resolved (empty for a fresh game).""" rnd = g.turn + 1 # personnel deployments are capped PER TYPE (shared across each type's units) and the cap # tightens in peak — surface the type-total + current cap as each unit's used/max so the HUD # reflects the real remaining budget. type_used: dict[str, int] = {} for u in g.units: type_used[u.utype] = type_used.get(u.utype, 0) + u.used pcap = engine.B.get("personnel_max_peak", engine.B["personnel_max"]) if g.phase == "peak" else engine.B["personnel_max"] return { "round": rnd, "total_rounds": engine.B["turns_to_win"], "phase": g.phase, "is_peak": rnd >= engine.B["peak_rush_turn"], "meters": { "anger": g.anger, "safety": g.safety, "pressure": g.pressure, "patience": g.patience, "energy": g.energy, "score": g.score, "delay": g.delay, }, "visual": { "is_night": rnd >= NIGHT_FROM_ROUND, # rain pours while a flood is active OR throughout PEAK (user-canon: the rush keeps # raining). Persistent peak rain runs the full particle pool — accepted perf cost. "rain": g.phase == "peak" or any(i.itype == "monsoon_flood" for i in g.incidents), "peak_banner": render._peak_banner(g), }, "stations": [ {"name": s.name, "crowd": s.crowd, "cap": s.cap, "pct": s.pct(), "is_major": s.name in engine.MAJORS, "flag": _station_flag(s.pct())} for s in g.stations ], "trains": [ {"id": t.id, "name": t.name, "ttype": t.ttype, "station": engine.STATIONS[t.pos], "pos": t.pos, "lane": engine.lane(t), "direction": "UP" if t.direction < 0 else "DOWN", "status": _train_status(g, t), "delay": t.delay} for t in g.trains ], "incidents": [ {"id": i.id, "type": i.itype, "location": i.location, "track": i.track, "age": i.age, "duration": i.duration, "assigned": i.assigned} for i in g.incidents ], "personnel": [ {"id": u.id, "type": u.utype, "station": engine.STATIONS[u.loc], "available": not u.busy, "used": type_used[u.utype], "max": pcap} for u in g.units ], "legal_cards": legal_cards(g), "ai": dict(ai) if ai is not None else dict(EMPTY_AI), "chaos_last": list(chaos_last or []), "notifications": list(notifications or []), "collisions_avoided": g.collisions_avoided, "crossover_blocks": g.crossover_blocks, "over": g.over, "won": g.won if g.over else None, "reason": g.reason, }