""" render.py — turn engine state into the terse prompt strings the templates expect. Pure view layer: reads state + the turn's legal actions, returns strings. No model calls, no rules decisions. Keeps the LLM contract in one place so prompt tweaks don't leak into the engine. """ from __future__ import annotations import os from engine import STATIONS, MAJORS, INTERCHANGES, CARDS, B from rules import legal_cards, card_available PROMPT_DIR = os.path.join(os.path.dirname(__file__), "prompts") def load_prompts(prompt_dir=PROMPT_DIR): # MLP_DISPATCHER_SYSTEM lets a run swap in a variant system prompt without editing the file. disp_sys = os.environ.get("MLP_DISPATCHER_SYSTEM", "dispatcher_system.txt") def rd(name): path = name if os.path.isabs(name) else os.path.join(prompt_dir, name) with open(path, encoding="utf-8") as f: return f.read() return { "dispatcher_system": rd(disp_sys), "dispatcher_user": rd("dispatcher_user_template.txt"), "chaos_system": rd("chaos_system.txt"), "chaos_user": rd("chaos_user_template.txt"), } def _dir(d): return "DOWN" if d == +1 else "UP" # +1 increases index toward Virar = DOWN def _fill(template, mapping): out = template for k, v in mapping.items(): out = out.replace("{{" + k + "}}", str(v)) return "\n".join(l for l in out.splitlines() if not l.lstrip().startswith("#")) # ---------------------------------------------------------------- action description def describe_action(a): t = a["type"] if t.startswith("deploy_"): return f"{t} {a['unit']} -> {a['to']} ({a['target']}, resolves in {a['resolves_in']}t)" if t == "move_train": return f"move_train {a['train']} -> {a['to']}" if t == "hold_train": return f"hold_train {a['train']} @{a['at']}" if t in ("switch_to_fast", "switch_to_slow"): return f"{t} {a['train']} @{a['at']}" if t == "prioritize_train": return f"prioritize_train {a['train']}" if t == "clear_platform_priority": return f"clear_platform_priority @{a['at']}" if t == "issue_announcement": return f"issue_announcement {a.get('scope', 'line')}-wide" extra = " ".join(f"{k}={v}" for k, v in a.items() if k not in ("action_id", "type")) return f"{t} {extra}".rstrip() # ---------------------------------------------------------------- blocks def _stations_block(g): out = [] for s in g.stations: pct = s.pct() flag = "!!" if pct > 100 else "!" if pct > 85 else "" tag = " [MAJOR]" if s.name in MAJORS else "" out.append(f"- {s.name}{tag} {int(s.crowd)}/{s.cap} ({pct:.0f}%{flag})") return "\n".join(out) def _incidents_block(g): if not g.incidents: return "- none" return "\n".join(f"- [{i.itype}] {i.location} {i.track} sev{i.severity} age{i.age}t" + (" (unit en route)" if i.assigned else "") for i in g.incidents) def _trains_block(g): return "\n".join(f'- {t.id} "{t.name}" [{t.ttype}] @{STATIONS[t.pos]} {_dir(t.direction)} {t.track} ' f'delay+{t.delay} ' + ("held" if t.held else (f"stuck{t.stuck_turns}t" if t.stuck_turns else "running")) for t in g.trains) def _personnel_block(g): return "\n".join(f"- {u.id} {u.utype} @{STATIONS[u.loc]} {'busy' if u.busy else 'available'} " f"used {u.used}/{B['personnel_max']}" for u in g.units) def _legal_actions_block(A): return "\n".join(f"{a['action_id']} {describe_action(a)}" for a in A) def _legal_cards_block(g): cards = legal_cards(g) if not cards: return "- (no playable cards — hoard energy)" return "\n".join(f"{c['card']} cost {c['cost']}" for c in cards) def _peak_banner(g): if g.phase != "peak": return "" return (">>> PEAK RUSH (the escalator): inflow +40%, anger growth +30%, a SIGNAL FAILURE now " "blocks ALL tracks, TRAIN TROUBLE card unlocks (+2 trains), monsoon flood drops to 8, " "rain won't quit <<<") # ---------------------------------------------------------------- public renderers def render_dispatcher_user(template, g, A): return _fill(template, { "turn": g.turn + 1, "total_turns": B["turns_to_win"], "phase": g.phase, "peak_rush_banner": _peak_banner(g), "score": f"{g.score:.0f}", "delay": f"{g.delay:.0f}", "safety": f"{g.safety:.0f}", "anger": f"{g.anger:.0f}", "pressure": f"{g.pressure:.0f}", "patience": f"{g.patience:.0f}", "incidents_block": _incidents_block(g), "stations_block": _stations_block(g), "trains_block": _trains_block(g), "personnel_block": _personnel_block(g), "legal_actions_block": _legal_actions_block(A), }) def render_chaos_user(template, g): return _fill(template, { "turn": g.turn + 1, "total_turns": B["turns_to_win"], "phase": g.phase, "peak_rush_banner": _peak_banner(g), "safety": f"{g.safety:.0f}", "anger": f"{g.anger:.0f}", "pressure": f"{g.pressure:.0f}", "delay": f"{g.delay:.0f}", "stations_block": _stations_block(g), "incidents_block": _incidents_block(g), "energy": g.energy, "regen": B["chaos_energy_regen"], "legal_cards_block": _legal_cards_block(g), })