| """ |
| 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) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| 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": |
| |
| |
| 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: |
| if card == "signal_failure" and g.phase == "peak": |
| target = "all" |
| elif here: |
| target = lane(here[0]) |
| 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}]") |
|
|
|
|
| |
| 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"] |
|
|
| |
| |
| |
| 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"]) |
| |
| 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: |
| 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) |
| |
| for s in g.stations: |
| if s.name in MAJORS and s.pct() > 80: |
| add(type="clear_platform_priority", at=s.name) |
| |
| add(type="issue_announcement", scope="line") |
| return A |
|
|