Add Cardinal plugin for monop state tracking

- Watches channel messages via @event('irc.privmsg')
- Feeds to MonopParser, writes game-state.json on state change
- Commands: .monop [status|players|board|owned]
- Bundled monop_parser.py in plugin dir
- Standalone test passes
This commit is contained in:
Jarvis 2026-02-21 03:54:59 +00:00
parent e604a32233
commit 2b022f15c3
8 changed files with 1521 additions and 0 deletions

View file

View file

@ -0,0 +1,5 @@
{
"bot_nick": "monop",
"channels": ["#monop"],
"state_path": "/path/to/game-state.json"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
"""
Cardinal plugin that watches a monop-irc game and tracks state.
Listens to all messages in the configured channel, feeds them to
MonopParser, and writes game-state.json on every state change.
Provides commands to query the current game state.
"""
import json
import os
import logging
from datetime import datetime, timezone
from cardinal.decorators import command, event, help
# Import parser from same directory
import os
import importlib.util
_parser_path = os.path.join(os.path.dirname(__file__), "monop_parser.py")
_spec = importlib.util.spec_from_file_location("monop_parser", _parser_path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
MonopParser = _mod.MonopParser
logger = logging.getLogger(__name__)
DEFAULT_STATE_PATH = "game-state.json"
DEFAULT_BOT_NICK = "monop"
class MonopPlugin:
def __init__(self, cardinal, config):
self.cardinal = cardinal
self.config = config or {}
self.bot_nick = self.config.get("bot_nick", DEFAULT_BOT_NICK)
self.state_path = self.config.get(
"state_path",
os.path.join(cardinal.storage_path, DEFAULT_STATE_PATH)
)
self.watched_channels = self.config.get("channels", [])
self.parser = MonopParser()
self._last_state_hash = None
logger.info(
"MonopPlugin loaded: watching %s, bot_nick=%s, state_path=%s",
self.watched_channels, self.bot_nick, self.state_path,
)
def _should_watch(self, channel):
"""Check if we should watch this channel."""
if not self.watched_channels:
return True # watch all channels if none configured
return channel.lower() in [c.lower() for c in self.watched_channels]
def _feed_line(self, sender, channel, message):
"""Feed a line to the parser and save state if changed."""
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
log_line = f"{ts}\t{sender}\t{message}"
self.parser.parse_line(log_line)
state = self.parser.get_state()
if state is None:
return
# Only write if state changed
state_hash = json.dumps(state, sort_keys=True)
if state_hash != self._last_state_hash:
self._last_state_hash = state_hash
state["lastUpdated"] = datetime.now(timezone.utc).isoformat()
self._save_state(state)
def _save_state(self, state):
"""Write game state to JSON file."""
try:
with open(self.state_path, "w") as f:
json.dump(state, f, indent=2)
logger.debug("State saved to %s", self.state_path)
except Exception:
logger.exception("Failed to save state to %s", self.state_path)
@event("irc.privmsg")
def on_msg(self, cardinal, user, channel, message):
"""Watch all channel messages and feed to parser."""
if not self._should_watch(channel):
return
self._feed_line(user.nick, channel, message)
@command("monop")
@help("Show current monop game state summary.")
@help("Syntax: .monop [player|board|log]")
def monop_cmd(self, cardinal, user, channel, msg):
parts = msg.split()
subcmd = parts[1].lower() if len(parts) > 1 else "status"
state = self.parser.get_state()
if state is None:
cardinal.sendMsg(channel, "No active monop game being tracked.")
return
if subcmd == "status":
self._show_status(cardinal, channel, state)
elif subcmd == "player" or subcmd == "players":
self._show_players(cardinal, channel, state)
elif subcmd == "board":
self._show_board(cardinal, channel, state)
elif subcmd == "owned":
self._show_owned(cardinal, channel, state)
else:
cardinal.sendMsg(
channel,
"Usage: .monop [status|players|board|owned]"
)
def _show_status(self, cardinal, channel, state):
players = state.get("players", [])
cp = state.get("currentPlayer")
if not players:
cardinal.sendMsg(channel, "No players registered yet.")
return
current_name = "?"
for p in players:
if p["number"] == cp:
current_name = p["name"]
lines = [
f"Monop game: {len(players)} players, "
f"current turn: {current_name}"
]
for p in players:
loc_name = self._location_name(state, p["location"])
jail = " [IN JAIL]" if p.get("inJail") else ""
lines.append(
f" {p['name']} (${p['money']}) on {loc_name}{jail}"
)
for line in lines:
cardinal.sendMsg(channel, line)
def _show_players(self, cardinal, channel, state):
for p in state.get("players", []):
props = []
for sq in state.get("squares", []):
if sq.get("owner") == p["number"]:
name = sq["name"]
if sq.get("mortgaged"):
name += " [M]"
if sq.get("houses", 0) > 0:
h = sq["houses"]
name += f" [{h}H]" if h < 5 else " [Hotel]"
props.append(name)
gojf = p.get("getOutOfJailFreeCards", 0)
gojf_str = f", {gojf} GOJF" if gojf else ""
prop_str = ", ".join(props) if props else "none"
cardinal.sendMsg(
channel,
f"{p['name']} (${p['money']}{gojf_str}): {prop_str}"
)
def _show_board(self, cardinal, channel, state):
"""Show owned properties summary."""
self._show_owned(cardinal, channel, state)
def _show_owned(self, cardinal, channel, state):
"""Show all owned properties grouped by player."""
players = {p["number"]: p["name"] for p in state.get("players", [])}
owned = {}
for sq in state.get("squares", []):
owner = sq.get("owner")
if owner is not None:
owned.setdefault(owner, []).append(sq)
if not owned:
cardinal.sendMsg(channel, "No properties owned yet.")
return
for pnum, squares in sorted(owned.items()):
name = players.get(pnum, f"Player {pnum}")
parts = []
for sq in squares:
s = sq["name"]
if sq.get("mortgaged"):
s += "*"
h = sq.get("houses", 0)
if h == 5:
s += "(H)"
elif h > 0:
s += f"({h})"
parts.append(s)
cardinal.sendMsg(channel, f"{name}: {', '.join(parts)}")
def _location_name(self, state, loc_id):
"""Get square name from location ID."""
squares = state.get("squares", [])
if 0 <= loc_id < len(squares):
return squares[loc_id]["name"]
elif loc_id == 40:
return "JAIL"
return f"square {loc_id}"
def close(self):
"""Save final state on plugin unload."""
state = self.parser.get_state()
if state:
state["lastUpdated"] = datetime.now(timezone.utc).isoformat()
self._save_state(state)
logger.info("MonopPlugin unloaded")
entrypoint = MonopPlugin

View file

@ -0,0 +1,109 @@
"""Quick smoke test for the monop Cardinal plugin without running Cardinal."""
import sys
import os
import json
import tempfile
# Mock cardinal module before importing plugin
import types
cardinal_mod = types.ModuleType('cardinal')
cardinal_dec = types.ModuleType('cardinal.decorators')
def _passthrough(*args, **kwargs):
def wrap(f): return f
return wrap
cardinal_dec.command = _passthrough
cardinal_dec.event = _passthrough
cardinal_dec.help = _passthrough
cardinal_mod.decorators = cardinal_dec
sys.modules['cardinal'] = cardinal_mod
sys.modules['cardinal.decorators'] = cardinal_dec
sys.path.insert(0, os.path.dirname(__file__))
# Minimal mock of cardinal
class MockCardinal:
storage_path = tempfile.mkdtemp()
def get_db(self, name): pass
def sendMsg(self, channel, msg): print(f" [{channel}] {msg}")
class MockUser:
def __init__(self, nick):
self.nick = nick
# Import plugin
from monop import plugin as monop_plugin
# Instantiate
cardinal = MockCardinal()
config = {"bot_nick": "monop", "channels": ["#monop"]}
p = monop_plugin.MonopPlugin(cardinal, config)
# Simulate a game
messages = [
("monop", "How many players?"),
("alice", ".3"),
("monop", "Player 1, say ''me'' please."),
("alice", ".me"),
("monop", "Player 2, say ''me'' please."),
("bob", ".me"),
("monop", "Player 3, say ''me'' please."),
("charlie", ".me"),
("monop", "alice (1) rolls 8"),
("monop", "bob (2) rolls 5"),
("monop", "charlie (3) rolls 6"),
("monop", "alice (1) goes first"),
("monop", "alice (1) (cash $1500) on === GO ==="),
("monop", "-- Command: "),
("alice", "."),
("monop", "roll is 3, 4"),
("monop", "That puts you on Chance i"),
("monop", "bob (2) (cash $1500) on === GO ==="),
("monop", "-- Command: "),
("bob", "."),
("monop", "roll is 2, 4"),
("monop", "That puts you on Oriental ave. (L)"),
("monop", "That would cost $100"),
("monop", "Do you want to buy?"),
("bob", ".y"),
("monop", "charlie (3) (cash $1500) on === GO ==="),
("monop", "-- Command: "),
]
print("=== Feeding messages ===")
for sender, msg in messages:
user = MockUser(sender)
p.on_msg(cardinal, user, "#monop", msg)
# Check state
print("\n=== State ===")
state = p.parser.get_state()
if state:
print(f"Players: {len(state['players'])}")
for pl in state['players']:
print(f" {pl['name']} (${pl['money']}) at sq {pl['location']}")
# Check bob owns Oriental
for sq in state['squares']:
if sq.get('owner'):
print(f" Owned: {sq['name']} by player {sq['owner']}")
print(f"\n=== .monop status ===")
p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop")
print(f"\n=== .monop players ===")
p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop players")
print(f"\n=== .monop owned ===")
p.monop_cmd(cardinal, MockUser("test"), "#monop", ".monop owned")
else:
print("No state!")
# Check file was written
if os.path.exists(p.state_path):
with open(p.state_path) as f:
saved = json.load(f)
print(f"\n=== State file written: {p.state_path} ===")
print(f"Players: {len(saved['players'])}, Squares: {len(saved['squares'])}")
print("PASS")
else:
print("FAIL: state file not written")