Add test scripts and realistic demo game state

This commit is contained in:
Jarvis 2026-02-20 21:23:13 +00:00
parent 6ce682645f
commit a02632e2a2
3 changed files with 508 additions and 0 deletions

103
site/game-state.json Normal file
View file

@ -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"}
]
}

267
test/gen_demo_state.py Normal file
View file

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

138
test/local_sim.py Normal file
View file

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