diff --git a/site/game-state.json b/site/game-state.json new file mode 100644 index 0000000..56bfe98 --- /dev/null +++ b/site/game-state.json @@ -0,0 +1,103 @@ +{ + "lastUpdated": "2026-02-20T21:30:00Z", + "currentPlayer": 1, + "players": [ + { + "name": "Alice", + "number": 1, + "money": 920, + "location": 24, + "inJail": false, + "jailTurns": 0, + "goJailFreeCards": 1, + "properties": [1, 3, 6, 8, 9, 24], + "numRailroads": 0, + "numUtilities": 0 + }, + { + "name": "Bob", + "number": 2, + "money": 1150, + "location": 39, + "inJail": false, + "jailTurns": 0, + "goJailFreeCards": 0, + "properties": [5, 11, 15, 28, 37, 39], + "numRailroads": 2, + "numUtilities": 1 + }, + { + "name": "Charlie", + "number": 3, + "money": 480, + "location": 10, + "inJail": true, + "jailTurns": 2, + "goJailFreeCards": 0, + "properties": [16, 18, 19, 25, 31, 32], + "numRailroads": 1, + "numUtilities": 0 + } + ], + "squares": [ + {"id":0,"name":"Go","type":"safe","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":1,"name":"Mediterranean Ave.","type":"property","owner":0,"cost":60,"mortgaged":false,"houses":3,"monopoly":true,"group":"purple","rent":[2,10,30,90,160,250]}, + {"id":2,"name":"Community Chest","type":"cc","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":3,"name":"Baltic Ave.","type":"property","owner":0,"cost":60,"mortgaged":false,"houses":2,"monopoly":true,"group":"purple","rent":[4,20,60,180,320,450]}, + {"id":4,"name":"Income Tax","type":"tax","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":5,"name":"Reading Railroad","type":"railroad","owner":1,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, + {"id":6,"name":"Oriental Ave.","type":"property","owner":0,"cost":100,"mortgaged":false,"houses":0,"monopoly":true,"group":"lightblue","rent":[6,30,90,270,400,550]}, + {"id":7,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":8,"name":"Vermont Ave.","type":"property","owner":0,"cost":100,"mortgaged":false,"houses":0,"monopoly":true,"group":"lightblue","rent":[6,30,90,270,400,550]}, + {"id":9,"name":"Connecticut Ave.","type":"property","owner":0,"cost":120,"mortgaged":false,"houses":1,"monopoly":true,"group":"lightblue","rent":[8,40,100,300,450,600]}, + {"id":10,"name":"Just Visiting","type":"jail","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":11,"name":"St. Charles Place","type":"property","owner":1,"cost":140,"mortgaged":false,"houses":0,"monopoly":false,"group":"pink","rent":[10,50,150,450,625,750]}, + {"id":12,"name":"Electric Company","type":"utility","owner":-1,"cost":150,"mortgaged":false,"houses":0,"monopoly":false,"group":"utility","rent":[0]}, + {"id":13,"name":"States Ave.","type":"property","owner":-1,"cost":140,"mortgaged":false,"houses":0,"monopoly":false,"group":"pink","rent":[10,50,150,450,625,750]}, + {"id":14,"name":"Virginia Ave.","type":"property","owner":-1,"cost":160,"mortgaged":false,"houses":0,"monopoly":false,"group":"pink","rent":[12,60,180,500,700,900]}, + {"id":15,"name":"Pennsylvania Railroad","type":"railroad","owner":1,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, + {"id":16,"name":"St. James Place","type":"property","owner":2,"cost":180,"mortgaged":false,"houses":4,"monopoly":true,"group":"orange","rent":[14,70,200,550,750,950]}, + {"id":17,"name":"Community Chest","type":"cc","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":18,"name":"Tennessee Ave.","type":"property","owner":2,"cost":180,"mortgaged":false,"houses":3,"monopoly":true,"group":"orange","rent":[14,70,200,550,750,950]}, + {"id":19,"name":"New York Ave.","type":"property","owner":2,"cost":200,"mortgaged":false,"houses":2,"monopoly":true,"group":"orange","rent":[16,80,220,600,800,1000]}, + {"id":20,"name":"Free Parking","type":"safe","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":21,"name":"Kentucky Ave.","type":"property","owner":-1,"cost":220,"mortgaged":false,"houses":0,"monopoly":false,"group":"red","rent":[18,90,250,700,875,1050]}, + {"id":22,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":23,"name":"Indiana Ave.","type":"property","owner":-1,"cost":220,"mortgaged":false,"houses":0,"monopoly":false,"group":"red","rent":[18,90,250,700,875,1050]}, + {"id":24,"name":"Illinois Ave.","type":"property","owner":0,"cost":240,"mortgaged":true,"houses":0,"monopoly":false,"group":"red","rent":[20,100,300,750,925,1100]}, + {"id":25,"name":"B&O Railroad","type":"railroad","owner":2,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, + {"id":26,"name":"Atlantic Ave.","type":"property","owner":-1,"cost":260,"mortgaged":false,"houses":0,"monopoly":false,"group":"yellow","rent":[22,110,330,800,975,1150]}, + {"id":27,"name":"Ventnor Ave.","type":"property","owner":-1,"cost":260,"mortgaged":false,"houses":0,"monopoly":false,"group":"yellow","rent":[22,110,330,800,975,1150]}, + {"id":28,"name":"Water Works","type":"utility","owner":1,"cost":150,"mortgaged":false,"houses":0,"monopoly":false,"group":"utility","rent":[0]}, + {"id":29,"name":"Marvin Gardens","type":"property","owner":-1,"cost":280,"mortgaged":false,"houses":0,"monopoly":false,"group":"yellow","rent":[24,120,360,850,1025,1200]}, + {"id":30,"name":"Go to Jail","type":"gotojail","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":31,"name":"Pacific Ave.","type":"property","owner":2,"cost":300,"mortgaged":false,"houses":0,"monopoly":false,"group":"green","rent":[26,130,390,900,1100,1275]}, + {"id":32,"name":"North Carolina Ave.","type":"property","owner":2,"cost":300,"mortgaged":false,"houses":0,"monopoly":false,"group":"green","rent":[26,130,390,900,1100,1275]}, + {"id":33,"name":"Community Chest","type":"cc","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":34,"name":"Pennsylvania Ave.","type":"property","owner":-1,"cost":320,"mortgaged":false,"houses":0,"monopoly":false,"group":"green","rent":[28,150,450,1000,1200,1400]}, + {"id":35,"name":"Short Line Railroad","type":"railroad","owner":-1,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, + {"id":36,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":37,"name":"Park Place","type":"property","owner":1,"cost":350,"mortgaged":false,"houses":0,"monopoly":false,"group":"darkblue","rent":[35,175,500,1100,1300,1500]}, + {"id":38,"name":"Luxury Tax","type":"tax","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, + {"id":39,"name":"Boardwalk","type":"property","owner":1,"cost":400,"mortgaged":false,"houses":5,"monopoly":true,"group":"darkblue","rent":[50,200,600,1400,1700,2000]} + ], + "log": [ + {"timestamp":"2026-02-20T21:20:00Z","text":"roll is 5, 6","player":"Alice"}, + {"timestamp":"2026-02-20T21:20:05Z","text":"Passed Go, collected $200","player":"Alice"}, + {"timestamp":"2026-02-20T21:20:06Z","text":"Landed on Illinois Ave.","player":"Alice"}, + {"timestamp":"2026-02-20T21:20:10Z","text":"Bought Illinois Ave. for $240","player":"Alice"}, + {"timestamp":"2026-02-20T21:21:00Z","text":"roll is 6, 6","player":"Bob"}, + {"timestamp":"2026-02-20T21:21:02Z","text":"Bob rolled doubles","player":"Bob"}, + {"timestamp":"2026-02-20T21:21:05Z","text":"Landed on Boardwalk","player":"Bob"}, + {"timestamp":"2026-02-20T21:21:10Z","text":"roll is 3, 5","player":"Bob"}, + {"timestamp":"2026-02-20T21:21:15Z","text":"Paid $750 rent (4 houses)","player":"Bob"}, + {"timestamp":"2026-02-20T21:22:00Z","text":"Charlie's turn","player":"Charlie"}, + {"timestamp":"2026-02-20T21:22:05Z","text":"Still in jail (turn 2)","player":"Charlie"}, + {"timestamp":"2026-02-20T21:23:00Z","text":"roll is 4, 2","player":"Alice"}, + {"timestamp":"2026-02-20T21:23:05Z","text":"Landed on Atlantic Ave.","player":"Alice"}, + {"timestamp":"2026-02-20T21:24:00Z","text":"roll is 1, 5","player":"Bob"}, + {"timestamp":"2026-02-20T21:24:05Z","text":"Passed Go, collected $200","player":"Bob"}, + {"timestamp":"2026-02-20T21:24:06Z","text":"Landed on Oriental Ave.","player":"Bob"}, + {"timestamp":"2026-02-20T21:24:10Z","text":"Paid $12 rent","player":"Bob"} + ] +} diff --git a/test/gen_demo_state.py b/test/gen_demo_state.py new file mode 100644 index 0000000..fcbbdad --- /dev/null +++ b/test/gen_demo_state.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate a realistic game-state.json from a scripted monop session using pexpect.""" + +import os +import sys +import json +import re +import time +import pexpect +from datetime import datetime, timezone + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'bot')) +from board_data import SQUARES, NAME_TO_ID + +STATE_FILE = os.path.join(os.path.dirname(__file__), '..', 'site', 'game-state.json') +MONOP = "/usr/games/monop" +PLAYER_NAMES = ["Alice", "Bob", "Charlie"] + + +def find_square(name): + name = name.strip().lower().rstrip(".") + # Try various lookups + for key in [name, name.split("(")[0].strip()]: + key = key.strip() + if key in NAME_TO_ID: + return NAME_TO_ID[key] + for k, v in NAME_TO_ID.items(): + if key in k or k in key: + return v + return None + + +class GameState: + def __init__(self, names): + self.players = [] + for i, name in enumerate(names): + self.players.append({ + "name": name, "number": i + 1, "money": 1500, + "location": 0, "inJail": False, "jailTurns": 0, + "goJailFreeCards": 0, "properties": [], + "numRailroads": 0, "numUtilities": 0, + }) + self.squares = [] + for sq in SQUARES: + self.squares.append({ + "id": sq["id"], "name": sq["name"], "type": sq.get("type", "safe"), + "owner": -1, "cost": sq.get("cost", 0), "mortgaged": False, + "houses": 0, "monopoly": False, "group": sq.get("group"), + "rent": sq.get("rent", [0]), + }) + self.current_player = 0 + self.log = [] + + def add_log(self, text, player=None): + self.log.append({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "text": text, + "player": player, + }) + + def find_player(self, name): + for i, p in enumerate(self.players): + if p["name"].lower() == name.lower(): + return i + return None + + def cur(self): + return self.players[self.current_player] + + def to_dict(self): + return { + "lastUpdated": datetime.now(timezone.utc).isoformat(), + "currentPlayer": self.current_player, + "players": self.players, + "squares": self.squares, + "log": self.log[-100:], + } + + def save(self): + d = self.to_dict() + tmp = STATE_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(d, f, indent=2) + os.replace(tmp, STATE_FILE) + + +def main(): + gs = GameState(PLAYER_NAMES) + + child = pexpect.spawn(MONOP, encoding='utf-8', timeout=5) + all_output = [] + + def interact(expect_pat, response, timeout=3): + try: + child.expect(expect_pat, timeout=timeout) + text = (child.before or "") + (child.after or "") + all_output.append(text) + process_text(text) + child.sendline(response) + time.sleep(0.15) + return True + except (pexpect.TIMEOUT, pexpect.EOF): + try: + text = child.before or "" + if text: + all_output.append(text) + process_text(text) + except: + pass + return False + + def process_text(text): + for line in text.split("\n"): + line = line.strip().replace("\r", "") + if not line: + continue + + cp = gs.cur() + + # Roll + m = re.match(r"roll is (\d+), (\d+)", line) + if m: + gs.add_log(f"roll is {m.group(1)}, {m.group(2)}", cp["name"]) + + # Movement + m = re.match(r"That puts you on (.+)", line) + if m: + sq_name = m.group(1).strip() + sq_id = find_square(sq_name) + if sq_id is not None: + cp["location"] = sq_id + gs.add_log(f"Landed on {sq_name}", cp["name"]) + + # Pass Go + if "pass" in line.lower() and "$200" in line: + cp["money"] += 200 + gs.add_log("Passed Go, collected $200", cp["name"]) + + # Doubles + m = re.match(r"(.+) rolled doubles", line) + if m: + gs.add_log(f"{m.group(1)} rolled doubles", m.group(1)) + + # Jail + if "GO DIRECTLY TO JAIL" in line or "go to jail" in line.lower(): + cp["location"] = 10 + cp["inJail"] = True + gs.add_log("Go to Jail!", cp["name"]) + + # Rent + m = re.match(r"rent is (\d+)", line) + if m: + rent = int(m.group(1)) + cp["money"] -= rent + sq_id = cp["location"] + owner = gs.squares[sq_id]["owner"] + if 0 <= owner < len(gs.players): + gs.players[owner]["money"] += rent + gs.add_log(f"Paid ${rent} rent", cp["name"]) + + # Tax + if "Luxury tax" in line: + cp["money"] -= 75 + gs.add_log("Paid $75 luxury tax", cp["name"]) + if "Income tax" in line and "%" in line: + cp["money"] -= 200 + gs.add_log("Paid $200 income tax", cp["name"]) + + # Services $25 + if "Receive for Services $25" in line: + cp["money"] += 25 + gs.add_log("Received $25 for services", cp["name"]) + + # Player turn indicator + m = re.match(r"(\w+) \(\d+\) \(cash \$(\d+)\) on (.+)", line) + if m: + name = m.group(1) + money = int(m.group(2)) + idx = gs.find_player(name) + if idx is not None: + gs.current_player = idx + gs.players[idx]["money"] = money + + # Bought detection (after "yes" to buy) + if "-- Loss of $" in line: + m2 = re.search(r"Loss of \$(\d+)", line) + if m2: + cp["money"] -= int(m2.group(1)) + + gs.save() + + # Setup + print("=== Setup ===", flush=True) + interact("How many players\\?", str(len(PLAYER_NAMES))) + for i, name in enumerate(PLAYER_NAMES): + interact(f"Player {i+1}'s name:", name) + + # Handle initial rolls for order + for _ in range(10): + if not interact([r"\(\d+\) rolls \d+", "goes first"], "", timeout=2): + break + + # Play the game + print("\n=== Playing ===", flush=True) + turns = 0 + max_turns = 80 + + while turns < max_turns: + # Try to match common prompts + matched = False + + # Buy prompt + if interact("Do you want to buy\\?", "yes", timeout=2): + # Record the purchase + cp = gs.cur() + sq_id = cp["location"] + if sq_id is not None and gs.squares[sq_id]["owner"] == -1: + sq = gs.squares[sq_id] + if sq["cost"] > 0: + sq["owner"] = gs.current_player + cp["properties"].append(sq_id) + cp["money"] -= sq["cost"] + if sq["type"] == "railroad": + cp["numRailroads"] += 1 + elif sq["type"] == "utility": + cp["numUtilities"] += 1 + gs.add_log(f"Bought {sq['name']} for ${sq['cost']}", cp["name"]) + gs.save() + matched = True + turns += 1 + continue + + # Command prompt - roll + if interact("-- Command:", "", timeout=2): + matched = True + turns += 1 + continue + + # Any other prompt + if interact("\\? ", "", timeout=2): + matched = True + turns += 1 + continue + + if not matched: + # Try just reading output + try: + child.expect(r".+", timeout=1) + text = (child.before or "") + (child.after or "") + if text: + process_text(text) + except (pexpect.TIMEOUT, pexpect.EOF): + child.sendline("") + turns += 1 + + print(f"\n=== Done: {turns} turns ===", flush=True) + gs.save() + child.close() + + for p in gs.players: + props = [gs.squares[pid]["name"] for pid in p["properties"]] + print(f" {p['name']}: ${p['money']}, loc={p['location']}, props={props}") + print(f" Log: {len(gs.log)} entries") + + +if __name__ == "__main__": + main() diff --git a/test/local_sim.py b/test/local_sim.py new file mode 100644 index 0000000..7ca4adb --- /dev/null +++ b/test/local_sim.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Run monop locally with scripted players via pexpect, feed output to parser.""" + +import os +import re +import sys +import time +import json +import pexpect +from datetime import datetime, timezone + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'bot')) +from parser import MonopParser + +STATE_FILE = os.path.join(os.path.dirname(__file__), '..', 'site', 'game-state.json') +MONOP = "/usr/games/monop" +PLAYERS = ["Alice", "Bob", "Charlie"] +MAX_TURNS = 80 + + +def write_state(parser): + state = parser.get_state() + state["lastUpdated"] = datetime.now(timezone.utc).isoformat() + tmp = STATE_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, STATE_FILE) + + +def process_output(parser, text): + """Feed text lines to parser.""" + for line in text.split("\r\n"): + line = line.strip() + if line: + print(f" monop: {line}", flush=True) + parser.parse_line(line) + + +def main(): + parser = MonopParser() + for i, name in enumerate(PLAYERS): + parser.add_player(name, i + 1) + + child = pexpect.spawn(MONOP, encoding='utf-8', timeout=5) + child.logfile_read = None # we'll handle output ourselves + + turn_count = 0 + + def expect_and_respond(patterns_responses, default=""): + """Wait for patterns and respond.""" + nonlocal turn_count + patterns = [p for p, _ in patterns_responses] + try: + idx = child.expect(patterns, timeout=3) + output = child.before + child.after + process_output(parser, output) + write_state(parser) + resp = patterns_responses[idx][1] + if callable(resp): + resp = resp() + print(f" -> {resp!r}", flush=True) + child.sendline(resp) + return True + except pexpect.TIMEOUT: + output = child.before or "" + if output: + process_output(parser, output) + write_state(parser) + return False + except pexpect.EOF: + output = child.before or "" + if output: + process_output(parser, output) + write_state(parser) + return None + + # Phase 1: Setup + print("=== Game Setup ===", flush=True) + expect_and_respond([("How many players\\?", str(len(PLAYERS)))]) + for i, name in enumerate(PLAYERS): + expect_and_respond([(f"Player {i+1}'s name:", name)]) + + # Phase 2: Playing + print("\n=== Game Play ===", flush=True) + consecutive_timeouts = 0 + + while turn_count < MAX_TURNS: + result = expect_and_respond([ + ("Do you want to buy\\?", "yes"), + ("do you wish to ", "yes"), + ("Bid for ", "0"), + ("How much do you", "0"), + ("mortgage\\?", "yes"), + ("Which file", "/dev/null"), + ("do you wish to sell", "no"), + ("Will you buy", "no"), + ("rolled doubles", ""), + ("'s turn", ""), + # The main prompt - just hit enter to roll + ("\\? ", ""), + ("\n", ""), + ]) + + if result is None: + print("Game ended (EOF)", flush=True) + break + elif result is False: + consecutive_timeouts += 1 + if consecutive_timeouts > 5: + # Try sending empty line to nudge + child.sendline("") + consecutive_timeouts = 0 + else: + consecutive_timeouts = 0 + turn_count += 1 + + # Read any remaining output + try: + extra = child.read_nonblocking(4096, timeout=0.3) + if extra: + process_output(parser, extra) + write_state(parser) + except (pexpect.TIMEOUT, pexpect.EOF): + pass + + print(f"\n=== Done: {turn_count} turns ===", flush=True) + write_state(parser) + child.close() + + state = parser.get_state() + for p in state["players"]: + props = [state["squares"][pid]["name"] for pid in p["properties"]] + print(f" {p['name']}: ${p['money']}, loc={p['location']}, props={props}") + print(f" Log entries: {len(state['log'])}") + + +if __name__ == "__main__": + main()