mumbai-local / backend /rules.py
geekwrestler's picture
Deploy Mumbai Local Panic — Nemotron(ZeroGPU) dispatcher + VoxCPM2 voice
d4a1b20 verified
Raw
History Blame Contribute Delete
7.62 kB
"""
rules.py — the pure RULES layer: what blocks what, what the chaos side may play, how a chaos
card lands, and the dispatcher's legal-action menu. No simulation/advance (see simulation.py);
no model/IO calls (golden rule). All functions take state in and return data or mutate state
deterministically.
"""
from __future__ import annotations
from engine import (B, STATIONS, IDX, MAJORS, INTERCHANGES, LANES, CARDS, INCIDENT_FIX,
ROSTER, Incident, Train, lane, opposing_lane)
def dist(a, b):
return abs(a - b)
# --------------------------------------------------------------- incident blocking (4-track)
def incident_blocks(inc, t):
"""Does incident `inc` block train `t` (t assumed to be AT inc.location)?
cow / signal : one specific lane ("all" during PEAK = every lane)
flood : both directions of one service (slow OR fast)
vip : both fast lanes
festival : never (crowd surge only)
"""
if inc.itype in ("track_cow", "signal_failure"):
return inc.track == "all" or inc.track == lane(t)
if inc.itype == "monsoon_flood":
return inc.track == t.track
if inc.itype == "vip_special":
return t.track == "fast"
return False
def _blocked(g, t):
sname = STATIONS[t.pos]
return any(inc.location == sname and incident_blocks(inc, t) for inc in g.incidents)
# --------------------------------------------------------------- chaos legality
def card_available(g, card):
"""Caps layered on top of affordability:
- unlock round : flood from R8, festival from R15
- per-game max : flood 3, festival 1, vip 1
- consecutive cap: cow / signal max 2 in a row, then a forced 1-round rest
"""
unlock = B.get("card_unlock_round", {}).get(card)
if unlock is not None and (g.turn + 1) < unlock:
return False
mx = B.get("card_max_plays", {}).get(card)
if mx is not None and g.card_plays.get(card, 0) >= mx:
return False
cap = B.get("consecutive_cap", {}).get(card)
if cap is not None and g.consec.get(card, 0) >= cap:
return False
return True
def station_free(g, loc):
"""One active incident per station — no stacking until the first wears off."""
return not any(i.location == loc for i in g.incidents)
def legal_cards(g):
"""Affordable AND rule-allowed chaos cards, cheapest-first. The single source of truth for
what the chaos side (human or bot) may play this turn."""
return [{"card": c, "cost": cost} for c, cost in sorted(CARDS.items(), key=lambda kv: kv[1])
if cost <= g.energy and card_available(g, c)]
def _trains_at(g, li):
return [t for t in g.trains if t.pos == li]
def apply_chaos(g, card, loc, rng=None):
"""Authoritatively land a chaos card. The chooser picks (card, station); the ENGINE decides
which lane(s) it blocks. Callers must already have checked legality (energy / card_available /
station_free). Deterministic. `rng` is accepted for interface symmetry; unused."""
li = IDX[loc]; g.inc_counter += 1; iid = f"incident_{g.inc_counter}"
g.card_plays[card] = g.card_plays.get(card, 0) + 1
g.played_this_turn.add(card)
if card in B.get("consecutive_cap", {}):
g.consec[card] = g.consec.get(card, 0) + 1
if card == "traintrouble":
# NOT an incident — drops the reinforcement trains (ROSTER slots after n_trains) onto the
# line at their termini for the dispatcher to juggle. Once-only (card_max_plays), peak-only.
n = int(B.get("n_trains", 6))
for tid, name, ttype, d, start, track in ROSTER[n:n + int(B.get("peak_extra_trains", 0))]:
if not any(t.id == tid for t in g.trains):
g.trains.append(Train(tid, name, ttype, d, IDX[start], track))
g.log.append(f" CHAOS: traintrouble @ {loc} [+{int(B.get('peak_extra_trains', 0))} trains]")
return
here = _trains_at(g, li)
if card == "festival_crowd":
g.stations[li].crowd += B["festival_spike"]
g.incidents.append(Incident(iid, "festival_crowd", loc, "-", severity=2, duration=3))
elif card == "vip_special":
g.incidents.append(Incident(iid, "vip_special", loc, "fast", severity=1, duration=2))
elif card == "monsoon_flood":
svc = "fast" if sum(t.track == "fast" for t in here) > sum(t.track == "slow" for t in here) else "slow"
dur = B["incident_duration"].get(card, B["incident_default_duration"])
g.incidents.append(Incident(iid, "monsoon_flood", loc, svc, severity=2, duration=dur))
else: # track_cow / signal_failure
if card == "signal_failure" and g.phase == "peak":
target = "all" # PEAK signal failure blocks ALL four tracks
elif here:
target = lane(here[0]) # block the lane of a present train
elif g.trains:
target = min(LANES, key=lambda L: -sum(1 for t in g.trains if lane(t) == L))
else:
target = "slow_up"
sev = {"signal_failure": 3, "track_cow": 1}[card]
dur = B["incident_duration"].get(card, B["incident_default_duration"])
if card in B["freeze_fatigue_cards"] and g.card_plays[card] >= B["freeze_fatigue_after"]:
dur = B["freeze_fatigue_duration"]
g.incidents.append(Incident(iid, card, loc, target, severity=sev, duration=dur))
g.log.append(f" CHAOS: {card} @ {loc} [{g.incidents[-1].track}]")
# --------------------------------------------------------------- dispatcher's legal menu
def legal_actions(g):
"""Every concrete action the dispatcher may pick this turn, as numbered {action_id, type, ...}."""
A = []; aid = 0
def add(**kw):
nonlocal aid; aid += 1; kw["action_id"] = f"a{aid}"; A.append(kw); return kw["action_id"]
# personnel deploys for unassigned, fixable incidents within 2 stations of an available unit.
# Deployments are capped PER TYPE, shared across that type's units, and the cap TIGHTENS in
# peak: personnel_max (3) normally, personnel_max_peak (2) during the rush.
pcap = B.get("personnel_max_peak", B["personnel_max"]) if g.phase == "peak" else B["personnel_max"]
type_used = {}
for u in g.units:
type_used[u.utype] = type_used.get(u.utype, 0) + u.used
for inc in g.incidents:
need = INCIDENT_FIX.get(inc.itype)
if not need or inc.assigned:
continue
if type_used.get(need, 0) >= pcap:
continue
for u in g.units:
if u.utype == need and not u.busy and dist(u.loc, IDX[inc.location]) <= 2:
add(type=f"deploy_{u.utype}", unit=u.id, to=inc.location, target=inc.id,
resolves_in=dist(u.loc, IDX[inc.location]) + B["personnel_resolve_turns"])
# train operations
for t in g.trains:
sname = STATIONS[t.pos]
add(type="hold_train", train=t.id, at=sname)
if not _blocked(g, t):
add(type="move_train", train=t.id, to=STATIONS[max(0, min(len(STATIONS) - 1, t.pos + t.direction))])
if sname in INTERCHANGES: # crossover (service switch) only at interchanges
add(type=("switch_to_slow" if t.track == "fast" else "switch_to_fast"), train=t.id, at=sname)
add(type="prioritize_train", train=t.id)
# platform clearing at overcrowded majors
for s in g.stations:
if s.name in MAJORS and s.pct() > 80:
add(type="clear_platform_priority", at=s.name)
# communication
add(type="issue_announcement", scope="line")
return A