""" 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