| """ |
| engine.py — Mumbai Local Panic (4-track edition): the canonical DATA MODEL + setup. |
| |
| Defines the map, the four tracks, the train roster, the state dataclasses, the balance loader, |
| and new_game(). Pure data + construction only: |
| - rules / legality / chaos effects live in rules.py |
| - the per-turn simulation (movement, meters, win/loss) lives in simulation.py |
| - the reference dispatcher lives in doctrine.py |
| |
| Import-safe, headless, deterministic: same seed => same starting state. |
| |
| THE LINE (8 stations, south -> north). UP = toward Churchgate (direction -1). |
| DOWN = toward Virar (direction +1). Majors are overflow-checked; interchanges are the only |
| places a train may cross between the slow and fast tracks (or turn around at a terminus). |
| """ |
| from __future__ import annotations |
| import json, os, random |
| from dataclasses import dataclass, field |
|
|
|
|
| |
| def load_balance(path=None, profile=None): |
| path = path or os.path.join(os.path.dirname(__file__), "data", "balance.json") |
| with open(path, encoding="utf-8") as f: |
| cfg = json.load(f) |
| return cfg[profile or cfg.get("_active", "canon")] |
|
|
|
|
| B = load_balance() |
|
|
| |
| STATIONS = ["Churchgate", "Mumbai Central", "Dadar", "Bandra", "Andheri", "Borivali", "Vasai Road", "Virar"] |
| IDX = {s: i for i, s in enumerate(STATIONS)} |
| MAJORS = {"Dadar", "Andheri", "Borivali", "Churchgate"} |
| INTERCHANGES = set(B["interchanges"]) |
| BASE_INFLOW = {"Churchgate": 150, "Mumbai Central": 80, "Dadar": 180, "Bandra": 90, |
| "Andheri": 160, "Borivali": 140, "Vasai Road": 70, "Virar": 50} |
|
|
| |
| LANES = ("slow_up", "slow_down", "fast_up", "fast_down") |
| def lane_of(track, direction): return f"{track}_{'up' if direction < 0 else 'down'}" |
| def opposing_lane(track, direction): return f"{track}_{'down' if direction < 0 else 'up'}" |
|
|
| |
| |
| |
| CARDS = {"monsoon_flood": 10, "signal_failure": 8, "vip_special": 12, "track_cow": 6, |
| "festival_crowd": 9, "traintrouble": 10} |
|
|
|
|
| def card_cost(g, card): |
| """A card's CURRENT chaos-energy cost. Base from CARDS, overridden by balance 'peak_card_cost' |
| during PEAK (e.g. monsoon_flood drops to 8 in the rush). The single source of truth for cost — |
| legal_cards / play_card / staged-apply all read this so display and charge never disagree.""" |
| base = CARDS[card] |
| if g.phase == "peak": |
| return B.get("peak_card_cost", {}).get(card, base) |
| return base |
| INCIDENT_FIX = {"signal_failure": "signal_engineer", "monsoon_flood": "cleaner", |
| "track_cow": "cow_team", "festival_crowd": "police"} |
| SENSITIVE = {"ladies_special", "virar_express"} |
|
|
|
|
| |
| @dataclass |
| class Station: |
| name: str; cap: int; crowd: float = 0.0; anger: float = 0.0 |
| def pct(self): return 100.0 * self.crowd / self.cap |
|
|
|
|
| @dataclass |
| class Train: |
| id: str; name: str; ttype: str; direction: int; pos: int; track: str = "slow" |
| delay: int = 0; held: bool = False; stuck_turns: int = 0; just_switched: bool = False |
| def stops_at(self, sname): |
| return True if self.ttype in ("slow", "ladies", "cargo") else (sname in MAJORS) |
|
|
|
|
| @dataclass |
| class Incident: |
| |
| |
| id: str; itype: str; location: str; track: str; severity: int; age: int = 0 |
| assigned: str | None = None; arriving_in: int = 0; resolving_in: int = 0; duration: int = 3 |
|
|
|
|
| @dataclass |
| class Unit: |
| id: str; utype: str; loc: int; used: int = 0; busy: bool = False; assigned: str | None = None |
|
|
|
|
| @dataclass |
| class Game: |
| seed: int |
| turn: int = 0; phase: str = "normal" |
| score: float = 0.0; delay: int = 0; safety: float = 100.0 |
| anger: float = 10.0; pressure: float = 0.0; patience: float = 100.0 |
| energy: int = 12 |
| stations: list = field(default_factory=list) |
| trains: list = field(default_factory=list) |
| incidents: list = field(default_factory=list) |
| units: list = field(default_factory=list) |
| inc_counter: int = 0; card_plays: dict = field(default_factory=dict); log: list = field(default_factory=list) |
| over: bool = False; won: bool = False; reason: str = "" |
| collisions_avoided: int = 0; crossover_blocks: int = 0 |
| consec: dict = field(default_factory=dict); played_this_turn: set = field(default_factory=set) |
|
|
|
|
| def lane(t): |
| """The lane a train currently occupies, e.g. 'slow_up'.""" |
| return lane_of(t.track, t.direction) |
|
|
|
|
| |
| |
| |
| |
| |
| ROSTER = [ |
| ("slow_local", "Slow Local", "slow", +1, "Churchgate", "slow"), |
| ("fast_842", "8:42 Fast", "fast", -1, "Virar", "fast"), |
| ("ladies_special", "Ladies Special", "ladies", +1, "Dadar", "slow"), |
| ("virar_express", "Virar Express", "fast", +1, "Mumbai Central", "fast"), |
| ("cargo_dabbawala", "Dabbawala Cargo", "cargo", -1, "Andheri", "slow"), |
| ("fast_borivali", "Borivali Fast", "fast", -1, "Borivali", "fast"), |
| ("ladies_peak", "Ladies Relief", "ladies", -1, "Virar", "slow"), |
| ("cargo_peak", "Dabbawala Relief","cargo", +1, "Churchgate", "slow"), |
| ] |
|
|
|
|
| def new_game(seed): |
| """Construct a fresh, seeded game. Deterministic: same seed => identical start.""" |
| g = Game(seed=seed, energy=B["chaos_energy_start"], safety=B["safety_start"]) |
| for s in STATIONS: |
| cap = B["cap_major"] if s in MAJORS else B["cap_minor"] |
| g.stations.append(Station(s, cap, crowd=cap * random.Random(seed + IDX[s]).uniform(.25, .5))) |
| for tid, name, ttype, d, start, track in ROSTER[:int(B.get("n_trains", 6))]: |
| g.trains.append(Train(tid, name, ttype, d, IDX[start], track)) |
| |
| |
| g.units = [Unit("police_1", "police", IDX["Dadar"]), Unit("police_2", "police", IDX["Andheri"]), |
| Unit("cleaner_1", "cleaner", IDX["Andheri"]), Unit("cleaner_2", "cleaner", IDX["Borivali"]), |
| Unit("cow_1", "cow_team", IDX["Borivali"]), Unit("cow_2", "cow_team", IDX["Vasai Road"]), |
| Unit("signal_1", "signal_engineer", IDX["Dadar"]), Unit("signal_2", "signal_engineer", IDX["Borivali"])] |
| return g |
|
|