From 98b4bd26cacdeac70bed6d06e7acafcd57687126 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 20 Feb 2026 21:39:31 +0000 Subject: [PATCH] Fix income tax handling, improve player detection, add monop_server and play_bot scripts --- bot/config.json | 4 +- bot/monopbot.py | 2 + bot/parser.py | 18 + site/game-state.json | 960 ++++++++++++++++++++++++++++++++++++++---- test/monop_server.py | 230 ++++++++++ test/play_bot.py | 150 +++++++ test/run_monop_irc.sh | 99 +++++ 7 files changed, 1385 insertions(+), 78 deletions(-) create mode 100644 test/monop_server.py create mode 100644 test/play_bot.py create mode 100755 test/run_monop_irc.sh diff --git a/bot/config.json b/bot/config.json index e15a6af..2055aef 100644 --- a/bot/config.json +++ b/bot/config.json @@ -4,6 +4,6 @@ "tls": true, "nick": "monopbot", "channel": "#monop-dev", - "game_nick": "monop", - "state_file": "../game-state.json" + "game_nick": "monopoly", + "state_file": "../site/game-state.json" } diff --git a/bot/monopbot.py b/bot/monopbot.py index 779dd98..0de4279 100644 --- a/bot/monopbot.py +++ b/bot/monopbot.py @@ -66,6 +66,8 @@ class MonopBot: nick = event.source.nick.lower() if event.source else "" msg = event.arguments[0] if event.arguments else "" + print(f"[MSG] <{nick}> {msg[:80]}", flush=True) + # We care about messages from the game bot if nick == self.game_nick or self._is_game_output(nick, msg): self._process_game_line(msg) diff --git a/bot/parser.py b/bot/parser.py index 1dfc20b..db44ac0 100644 --- a/bot/parser.py +++ b/bot/parser.py @@ -112,6 +112,24 @@ class MonopParser: changed = False + # --- Player detection from status lines --- + # "Alice (1) (cash $1500) on === GO ===" or "Alice (1) rolls 10" + m = re.match(r"(\w+) \((\d+)\) (?:rolls \d+|\(cash \$(\d+)\) on (.+))", line) + if m: + name = m.group(1) + num = int(m.group(2)) + idx, player = self._find_player_by_name(name) + if idx is None: + # Auto-add player + self.add_player(name, num) + idx = len(self.players) - 1 + player = self.players[idx] + if m.group(3): + player["money"] = int(m.group(3)) + self.current_player_idx = idx + self._add_log(f"{name}'s turn", name) + return True + # --- Player setup --- # "How many players? " -> game init m = re.match(r"How many players\?", line) diff --git a/site/game-state.json b/site/game-state.json index 56bfe98..d3f05a7 100644 --- a/site/game-state.json +++ b/site/game-state.json @@ -1,103 +1,911 @@ { - "lastUpdated": "2026-02-20T21:30:00Z", - "currentPlayer": 1, "players": [ { "name": "Alice", "number": 1, - "money": 920, - "location": 24, + "money": 1375, + "location": 15, "inJail": false, "jailTurns": 0, - "goJailFreeCards": 1, - "properties": [1, 3, 6, 8, 9, 24], + "goJailFreeCards": 0, + "properties": [], "numRailroads": 0, "numUtilities": 0 }, { "name": "Bob", "number": 2, - "money": 1150, - "location": 39, + "money": 890, + "location": 35, "inJail": false, "jailTurns": 0, "goJailFreeCards": 0, - "properties": [5, 11, 15, 28, 37, 39], - "numRailroads": 2, - "numUtilities": 1 + "properties": [], + "numRailroads": 0, + "numUtilities": 0 }, { "name": "Charlie", "number": 3, - "money": 480, - "location": 10, - "inJail": true, - "jailTurns": 2, + "money": 1255, + "location": 24, + "inJail": false, + "jailTurns": 0, "goJailFreeCards": 0, - "properties": [16, 18, 19, 25, 31, 32], - "numRailroads": 1, + "properties": [], + "numRailroads": 0, "numUtilities": 0 } ], + "currentPlayer": 2, "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]} + { + "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": -1, + "cost": 60, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 60, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 100, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 100, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "group": "lightblue", + "rent": [ + 6, + 30, + 90, + 270, + 400, + 550 + ] + }, + { + "id": 9, + "name": "Connecticut Ave.", + "type": "property", + "owner": -1, + "cost": 120, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 180, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 180, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "group": "orange", + "rent": [ + 14, + 70, + 200, + 550, + 750, + 950 + ] + }, + { + "id": 19, + "name": "New York Ave.", + "type": "property", + "owner": -1, + "cost": 200, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "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": -1, + "cost": 240, + "mortgaged": false, + "houses": 0, + "monopoly": false, + "group": "red", + "rent": [ + 20, + 100, + 300, + 750, + 925, + 1100 + ] + }, + { + "id": 25, + "name": "B&O Railroad", + "type": "railroad", + "owner": -1, + "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": -1, + "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": -1, + "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": 0, + "monopoly": false, + "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"} - ] -} + { + "timestamp": "2026-02-20T21:38:18.018187+00:00", + "text": "Alice joined as player 1", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:38:18.018200+00:00", + "text": "Alice's turn", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:38:18.168441+00:00", + "text": "Bob joined as player 2", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:18.168455+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:18.318956+00:00", + "text": "Charlie joined as player 3", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:18.318975+00:00", + "text": "Charlie's turn", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:18.619242+00:00", + "text": "Charlie's turn", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:23.326156+00:00", + "text": "roll is 3, 6", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:23.476516+00:00", + "text": "Landed on Connecticut ave. (L)", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:25.129353+00:00", + "text": "Alice's turn", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:38:29.936248+00:00", + "text": "roll is 2, 5", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:38:30.086109+00:00", + "text": "Landed on Chance i", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:38:34.091033+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:36.095336+00:00", + "text": "roll is 5, 5", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:37.097140+00:00", + "text": "Landed on Just Visiting", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:39.438836+00:00", + "text": "Bob rolled doubles", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:40.015566+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:42.169426+00:00", + "text": "roll is 4, 4", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:43.353040+00:00", + "text": "Landed on Tennessee ave. (O)", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:47.168359+00:00", + "text": "Bob rolled doubles", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:48.253823+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:51.108736+00:00", + "text": "roll is 1, 2", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:52.051144+00:00", + "text": "Landed on Kentucky ave. (R)", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:38:56.080168+00:00", + "text": "Charlie's turn", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:38:59.017009+00:00", + "text": "roll is 5, 1", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:39:00.039361+00:00", + "text": "Landed on Pennsylvania RR", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:39:04.016897+00:00", + "text": "Alice's turn", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:39:07.641072+00:00", + "text": "roll is 6, 2", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:39:08.092792+00:00", + "text": "Landed on Pennsylvania RR", + "player": "Alice" + }, + { + "timestamp": "2026-02-20T21:39:11.364880+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:13.048717+00:00", + "text": "roll is 3, 3", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:14.130462+00:00", + "text": "Landed on Ventnor ave. (Y)", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:18.037025+00:00", + "text": "Bob rolled doubles", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:19.034618+00:00", + "text": "Bob's turn", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:22.779902+00:00", + "text": "roll is 6, 2", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:23.123928+00:00", + "text": "Landed on Short Line RR", + "player": "Bob" + }, + { + "timestamp": "2026-02-20T21:39:27.279901+00:00", + "text": "Charlie's turn", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:39:30.019353+00:00", + "text": "roll is 3, 6", + "player": "Charlie" + }, + { + "timestamp": "2026-02-20T21:39:31.074039+00:00", + "text": "Landed on Illinois ave. (R)", + "player": "Charlie" + } + ], + "lastUpdated": "2026-02-20T21:39:31.074058+00:00" +} \ No newline at end of file diff --git a/test/monop_server.py b/test/monop_server.py new file mode 100644 index 0000000..7a8b44b --- /dev/null +++ b/test/monop_server.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Monop IRC game server: runs monop in a subprocess, bridges stdin/stdout to IRC. +Players prefix commands with a dot (.) in the channel. +Also includes scripted AI players for testing. +""" + +import os +import re +import ssl +import sys +import time +import pexpect +import irc.client +import irc.connection + +SERVER = "irc.darkscience.net" +PORT = 6697 +CHANNEL = "#monop-dev" +NICK = "monopoly" +MONOP = "/usr/games/monop" +NUM_PLAYERS = 3 +PLAYER_NAMES = ["Alice", "Bob", "Charlie"] + + +class MonopServer: + def __init__(self): + self.connection = None + self.child = None + self.joined = False + self.game_started = False + self.setup_phase = True + self.setup_step = 0 # 0=waiting, 1=num_players sent, 2+=names + self.output_queue = [] + self.last_output_time = 0 + self.auto_play = True + self.turn_count = 0 + + def start_monop(self): + """Start the monop process.""" + self.child = pexpect.spawn(MONOP, encoding='utf-8', timeout=2) + print("monop process started", flush=True) + + def read_monop(self): + """Read any available output from monop.""" + lines = [] + try: + while True: + # Read one line at a time + self.child.expect('\r?\n', timeout=0.3) + text = self.child.before.strip() + if text: + lines.append(text) + except pexpect.TIMEOUT: + # Also grab any partial prompt + if self.child.before: + partial = self.child.before.strip() + if partial and partial not in lines: + lines.append(partial) + except pexpect.EOF: + pass + return lines + + def send_to_monop(self, text): + """Send a line to monop stdin.""" + if self.child and self.child.isalive(): + print(f" >> monop: {text!r}", flush=True) + self.child.sendline(text) + time.sleep(0.2) + + def send_to_irc(self, text): + """Send a line to IRC channel.""" + if self.connection and text.strip(): + # Truncate long lines + for line in text.split('\n'): + line = line.strip()[:450] + if line: + try: + self.connection.privmsg(CHANNEL, line) + time.sleep(0.15) + except Exception as e: + print(f"IRC send error: {e}", flush=True) + + def on_connect(self, conn, event): + print(f"Connected to IRC", flush=True) + self.connection = conn + time.sleep(2) # wait before joining + conn.join(CHANNEL) + + def on_join(self, conn, event): + if event.source.nick == NICK: + print(f"Joined {CHANNEL}", flush=True) + self.joined = True + self.connection.privmsg(CHANNEL, "🎲 Monopoly game starting! Setting up with AI players...") + time.sleep(1) + self.start_monop() + self.run_setup() + + def on_pubmsg(self, conn, event): + """Handle player commands (dot-prefixed).""" + nick = event.source.nick if event.source else "" + msg = event.arguments[0] if event.arguments else "" + + if nick == NICK: + return # ignore own messages + + if msg.startswith("."): + cmd = msg[1:].strip() + print(f" Player command from {nick}: {cmd!r}", flush=True) + self.send_to_monop(cmd) + time.sleep(0.5) + self.flush_and_send() + + def run_setup(self): + """Automated game setup: set player count and names.""" + time.sleep(1) + lines = self.read_monop() + for l in lines: + print(f" monop: {l}", flush=True) + self.send_to_irc(l) + + # Send number of players + self.send_to_monop(str(NUM_PLAYERS)) + time.sleep(0.5) + self.flush_and_send() + + # Send player names + for name in PLAYER_NAMES: + time.sleep(0.5) + self.send_to_monop(name) + time.sleep(0.5) + self.flush_and_send() + + # Read initial rolls + time.sleep(1) + self.flush_and_send() + time.sleep(1) + self.flush_and_send() + + self.setup_phase = False + self.game_started = True + self.connection.privmsg(CHANNEL, "Game is set up! Auto-playing turns now...") + + def flush_and_send(self): + """Read monop output and relay to IRC.""" + lines = self.read_monop() + for l in lines: + print(f" monop: {l}", flush=True) + self.send_to_irc(l) + return lines + + def auto_turn(self): + """Play a turn automatically.""" + if not self.game_started or not self.child or not self.child.isalive(): + return + + # Send empty line (= roll / default action) + self.send_to_monop("") + time.sleep(0.8) + lines = self.flush_and_send() + + # Handle prompts + full = "\n".join(lines) + if "Do you want to buy?" in full: + self.send_to_monop("yes") + time.sleep(0.5) + self.flush_and_send() + elif "lose 10%" in full or "10%% of your total" in full: + # Income tax - choose $200 flat + self.send_to_monop("$200") + time.sleep(0.5) + self.flush_and_send() + elif "mortgage?" in full.lower() or "do you wish to" in full.lower(): + self.send_to_monop("yes") + time.sleep(0.5) + self.flush_and_send() + elif "Bid for" in full: + self.send_to_monop("0") + time.sleep(0.5) + self.flush_and_send() + elif "How much" in full: + self.send_to_monop("0") + time.sleep(0.5) + self.flush_and_send() + elif "Illegal response" in full: + # Try different responses + self.send_to_monop("$200") + time.sleep(0.5) + self.flush_and_send() + + self.turn_count += 1 + + def run(self): + reactor = irc.client.Reactor() + ssl_ctx = ssl.create_default_context() + factory = irc.connection.Factory( + wrapper=lambda s: ssl_ctx.wrap_socket(s, server_hostname=SERVER)) + + server = reactor.server() + server.connect(SERVER, PORT, NICK, connect_factory=factory) + server.add_global_handler("welcome", self.on_connect) + server.add_global_handler("join", self.on_join) + server.add_global_handler("pubmsg", self.on_pubmsg) + + print(f"Connecting to {SERVER}:{PORT} as {NICK}...", flush=True) + + start = time.time() + last_auto = time.time() + max_turns = 50 + + while time.time() - start < 300: # 5 min timeout + reactor.process_once(timeout=0.5) + + # Auto-play turns every 3 seconds + if self.auto_play and self.game_started and time.time() - last_auto > 3: + if self.turn_count < max_turns: + self.auto_turn() + last_auto = time.time() + elif self.turn_count == max_turns: + self.connection.privmsg(CHANNEL, f"--- Auto-play complete ({max_turns} turns). Game state saved. ---") + self.turn_count += 1 # prevent repeating + + print("Server shutting down", flush=True) + if self.connection: + self.connection.quit("Game over!") + + +if __name__ == "__main__": + ms = MonopServer() + ms.run() diff --git a/test/play_bot.py b/test/play_bot.py new file mode 100644 index 0000000..43a704c --- /dev/null +++ b/test/play_bot.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Simple IRC bot that plays monop by sending dot-prefixed commands.""" + +import ssl +import sys +import time +import random +import irc.client +import irc.connection + +SERVER = "irc.darkscience.net" +PORT = 6697 +CHANNEL = "#monop-dev" + +class PlayerBot: + def __init__(self, nick, commands): + self.nick = nick + self.commands = list(commands) # list of (delay, command) tuples + self.cmd_idx = 0 + self.connection = None + self.joined = False + self.last_cmd_time = 0 + + def on_connect(self, conn, event): + print(f"[{self.nick}] Connected, joining {CHANNEL}", flush=True) + self.connection = conn + conn.join(CHANNEL) + + def on_join(self, conn, event): + if event.source.nick == self.nick: + print(f"[{self.nick}] Joined {CHANNEL}", flush=True) + self.joined = True + self.last_cmd_time = time.time() + + def on_pubmsg(self, conn, event): + msg = event.arguments[0] if event.arguments else "" + nick = event.source.nick if event.source else "" + # React to game prompts + if nick == "monopoly" and self.joined: + if "Do you want to buy?" in msg: + time.sleep(0.5) + conn.privmsg(CHANNEL, ".yes") + elif "How many players?" in msg: + time.sleep(0.5) + conn.privmsg(CHANNEL, ".3") + + def send_next_command(self): + if self.cmd_idx < len(self.commands) and self.joined: + delay, cmd = self.commands[self.cmd_idx] + if time.time() - self.last_cmd_time >= delay: + print(f"[{self.nick}] Sending: {cmd}", flush=True) + self.connection.privmsg(CHANNEL, cmd) + self.last_cmd_time = time.time() + self.cmd_idx += 1 + return True + return False + + +def main(): + # Script the game setup and several turns + # Player 1 starts the game with ".3" (3 players) + # Then each player provides their name + # Then they take turns rolling (just press enter = ".roll" or ".") + + alice_cmds = [ + (3, ".3"), # start 3 player game + (2, ".Alice"), # enter name + (8, "."), # roll (first turn after setup) + (4, "."), # action/roll + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + ] + + bob_cmds = [ + (6, ".Bob"), # enter name + (10, "."), # roll + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + ] + + charlie_cmds = [ + (8, ".Charlie"), # enter name + (12, "."), # roll + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + (6, "."), + ] + + bots = [ + PlayerBot("alice_m", alice_cmds), + PlayerBot("bob_m", bob_cmds), + PlayerBot("charlie_m", charlie_cmds), + ] + + reactor = irc.client.Reactor() + ssl_ctx = ssl.create_default_context() + + for bot in bots: + factory = irc.connection.Factory( + wrapper=lambda s: ssl_ctx.wrap_socket(s, server_hostname=SERVER)) + server = reactor.server() + server.connect(SERVER, PORT, bot.nick, connect_factory=factory) + server.add_global_handler("welcome", bot.on_connect) + server.add_global_handler("join", bot.on_join) + server.add_global_handler("pubmsg", bot.on_pubmsg) + time.sleep(1) # stagger connections + + print("All bots connecting...", flush=True) + + start = time.time() + timeout = 180 # 3 minutes + + while time.time() - start < timeout: + reactor.process_once(timeout=0.5) + for bot in bots: + bot.send_next_command() + + # Check if all bots are done + if all(b.cmd_idx >= len(b.commands) for b in bots): + print("All commands sent. Waiting 10s for final output...", flush=True) + end = time.time() + 10 + while time.time() < end: + reactor.process_once(timeout=0.5) + break + + print("Done!", flush=True) + for bot in bots: + if bot.connection: + bot.connection.quit("Game over!") + + +if __name__ == "__main__": + main() diff --git a/test/run_monop_irc.sh b/test/run_monop_irc.sh new file mode 100755 index 0000000..d69c881 --- /dev/null +++ b/test/run_monop_irc.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Run monop-irc game server on IRC via ii + socat TLS proxy +set -e + +SERVER=irc.darkscience.net +PORT=6697 +CHANNEL="#monop-dev" +NICK="monopoly" +PREFIX=/tmp/monop-irc-session +MONOP=/usr/games/monop +SOCAT_PORT=16667 + +cleanup() { + echo "Cleaning up..." + kill %1 %2 %3 2>/dev/null + killall -q ii 2>/dev/null + rm -rf "$PREFIX" + kill $(cat /tmp/socat-irc.pid 2>/dev/null) 2>/dev/null + exit 0 +} +trap cleanup INT TERM EXIT + +# Clean previous +killall -q ii 2>/dev/null || true +kill $(cat /tmp/socat-irc.pid 2>/dev/null) 2>/dev/null || true +rm -rf "$PREFIX" +mkdir -p "$PREFIX" + +# Start TLS proxy with socat +echo "Starting TLS proxy on localhost:$SOCAT_PORT -> $SERVER:$PORT" +socat TCP-LISTEN:$SOCAT_PORT,fork,reuseaddr OPENSSL:$SERVER:$PORT,verify=0 & +echo $! > /tmp/socat-irc.pid +sleep 1 + +# Start ii connecting to local socat proxy +echo "Starting ii..." +ii -s 127.0.0.1 -p $SOCAT_PORT -n "$NICK" -i "$PREFIX" & +sleep 3 + +# Wait for connection +IN="$PREFIX/127.0.0.1/in" +echo "Waiting for ii connection..." +for i in $(seq 1 30); do + [ -p "$IN" ] && break + sleep 1 +done +if [ ! -p "$IN" ]; then + echo "ERROR: ii failed to connect" + exit 1 +fi +echo "Connected!" + +# Join channel +echo "/j $CHANNEL" > "$IN" +sleep 2 + +# Wait for channel +CHAN_DIR="$PREFIX/127.0.0.1/${CHANNEL}" +INCHAN="$CHAN_DIR/in" +OUTCHAN="$CHAN_DIR/out" + +echo "Waiting for channel..." +for i in $(seq 1 20); do + [ -p "$INCHAN" ] && break + sleep 1 +done +if [ ! -p "$INCHAN" ]; then + echo "ERROR: failed to join channel" + ls -la "$PREFIX/127.0.0.1/" 2>/dev/null + exit 1 +fi +echo "Joined $CHANNEL" + +# Send welcome message +echo "🎲 Monopoly IRC Game Server ready! Prefix commands with a dot (.) — e.g. type '.roll' to roll dice." > "$INCHAN" +echo "Type '.3' to start a 3-player game, or '.N' for N players." > "$INCHAN" + +# Wait for out file +for i in $(seq 1 10); do + [ -f "$OUTCHAN" ] && break + sleep 1 +done + +echo "Game server running. Watching for commands..." + +# The pipeline: +# 1. tail -f channel output (IRC messages) +# 2. sed extracts lines where users prefix with "." (e.g. ".roll" -> "username roll") +# 3. Filter out our own messages +# 4. Pipe to monop stdin; monop stdout goes back to channel +tail -f "$OUTCHAN" | \ + sed -u -En 's/^[0-9-]+ [0-9:]+ <([a-zA-Z0-9_]+)> \.(.*)$/\2/p' | \ + grep --line-buffered -v "^$" | \ + $MONOP 2>&1 | \ + while IFS= read -r line; do + echo "$line" > "$INCHAN" + # Small delay to avoid flood + sleep 0.1 + done