Fix income tax handling, improve player detection, add monop_server and play_bot scripts

This commit is contained in:
Jarvis 2026-02-20 21:39:31 +00:00
parent a02632e2a2
commit 98b4bd26ca
7 changed files with 1385 additions and 78 deletions

View file

@ -4,6 +4,6 @@
"tls": true, "tls": true,
"nick": "monopbot", "nick": "monopbot",
"channel": "#monop-dev", "channel": "#monop-dev",
"game_nick": "monop", "game_nick": "monopoly",
"state_file": "../game-state.json" "state_file": "../site/game-state.json"
} }

View file

@ -66,6 +66,8 @@ class MonopBot:
nick = event.source.nick.lower() if event.source else "" nick = event.source.nick.lower() if event.source else ""
msg = event.arguments[0] if event.arguments 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 # We care about messages from the game bot
if nick == self.game_nick or self._is_game_output(nick, msg): if nick == self.game_nick or self._is_game_output(nick, msg):
self._process_game_line(msg) self._process_game_line(msg)

View file

@ -112,6 +112,24 @@ class MonopParser:
changed = False 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 --- # --- Player setup ---
# "How many players? " -> game init # "How many players? " -> game init
m = re.match(r"How many players\?", line) m = re.match(r"How many players\?", line)

View file

@ -1,103 +1,911 @@
{ {
"lastUpdated": "2026-02-20T21:30:00Z",
"currentPlayer": 1,
"players": [ "players": [
{ {
"name": "Alice", "name": "Alice",
"number": 1, "number": 1,
"money": 920, "money": 1375,
"location": 24, "location": 15,
"inJail": false, "inJail": false,
"jailTurns": 0, "jailTurns": 0,
"goJailFreeCards": 1, "goJailFreeCards": 0,
"properties": [1, 3, 6, 8, 9, 24], "properties": [],
"numRailroads": 0, "numRailroads": 0,
"numUtilities": 0 "numUtilities": 0
}, },
{ {
"name": "Bob", "name": "Bob",
"number": 2, "number": 2,
"money": 1150, "money": 890,
"location": 39, "location": 35,
"inJail": false, "inJail": false,
"jailTurns": 0, "jailTurns": 0,
"goJailFreeCards": 0, "goJailFreeCards": 0,
"properties": [5, 11, 15, 28, 37, 39], "properties": [],
"numRailroads": 2, "numRailroads": 0,
"numUtilities": 1 "numUtilities": 0
}, },
{ {
"name": "Charlie", "name": "Charlie",
"number": 3, "number": 3,
"money": 480, "money": 1255,
"location": 10, "location": 24,
"inJail": true, "inJail": false,
"jailTurns": 2, "jailTurns": 0,
"goJailFreeCards": 0, "goJailFreeCards": 0,
"properties": [16, 18, 19, 25, 31, 32], "properties": [],
"numRailroads": 1, "numRailroads": 0,
"numUtilities": 0 "numUtilities": 0
} }
], ],
"currentPlayer": 2,
"squares": [ "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": 0,
{"id":2,"name":"Community Chest","type":"cc","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "name": "Go",
{"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]}, "type": "safe",
{"id":4,"name":"Income Tax","type":"tax","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "owner": -1,
{"id":5,"name":"Reading Railroad","type":"railroad","owner":1,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, "cost": 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]}, "mortgaged": false,
{"id":7,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "houses": 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]}, "monopoly": false,
{"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]}, "group": null,
{"id":10,"name":"Just Visiting","type":"jail","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "rent": [
{"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]}, 0
{"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": 1,
{"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]}, "name": "Mediterranean Ave.",
{"id":17,"name":"Community Chest","type":"cc","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "type": "property",
{"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]}, "owner": -1,
{"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]}, "cost": 60,
{"id":20,"name":"Free Parking","type":"safe","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "mortgaged": false,
{"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]}, "houses": 0,
{"id":22,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "monopoly": false,
{"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]}, "group": "purple",
{"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]}, "rent": [
{"id":25,"name":"B&O Railroad","type":"railroad","owner":2,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, 2,
{"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]}, 10,
{"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]}, 30,
{"id":28,"name":"Water Works","type":"utility","owner":1,"cost":150,"mortgaged":false,"houses":0,"monopoly":false,"group":"utility","rent":[0]}, 90,
{"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]}, 160,
{"id":30,"name":"Go to Jail","type":"gotojail","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, 250
{"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": 2,
{"id":35,"name":"Short Line Railroad","type":"railroad","owner":-1,"cost":200,"mortgaged":false,"houses":0,"monopoly":false,"group":"railroad","rent":[0]}, "name": "Community Chest",
{"id":36,"name":"Chance","type":"chance","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "type": "cc",
{"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]}, "owner": -1,
{"id":38,"name":"Luxury Tax","type":"tax","owner":-1,"cost":0,"mortgaged":false,"houses":0,"monopoly":false,"group":null,"rent":[0]}, "cost": 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]} "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": [ "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:38:18.018187+00:00",
{"timestamp":"2026-02-20T21:20:06Z","text":"Landed on Illinois Ave.","player":"Alice"}, "text": "Alice joined as player 1",
{"timestamp":"2026-02-20T21:20:10Z","text":"Bought Illinois Ave. for $240","player":"Alice"}, "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:38:18.018200+00:00",
{"timestamp":"2026-02-20T21:21:10Z","text":"roll is 3, 5","player":"Bob"}, "text": "Alice's turn",
{"timestamp":"2026-02-20T21:21:15Z","text":"Paid $750 rent (4 houses)","player":"Bob"}, "player": "Alice"
{"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:38:18.168441+00:00",
{"timestamp":"2026-02-20T21:23:05Z","text":"Landed on Atlantic Ave.","player":"Alice"}, "text": "Bob joined as player 2",
{"timestamp":"2026-02-20T21:24:00Z","text":"roll is 1, 5","player":"Bob"}, "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.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"
} }

230
test/monop_server.py Normal file
View file

@ -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()

150
test/play_bot.py Normal file
View file

@ -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()

99
test/run_monop_irc.sh Executable file
View file

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