| """ |
| 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 |
|
|
| |
| |
| 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]: |
| |
| 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 |
| |
| |
| |
| 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": 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, |
| } |
|
|