mumbai-local / frontend /server /render_state.py
geekwrestler's picture
Deploy Mumbai Local Panic — Nemotron(ZeroGPU) dispatcher + VoxCPM2 voice
d4a1b20 verified
Raw
History Blame Contribute Delete
5.3 kB
"""
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,
}