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:
parent
e604a32233
commit
2b022f15c3
8 changed files with 1521 additions and 0 deletions
0
cardinal-plugin/monop/__init__.py
Normal file
0
cardinal-plugin/monop/__init__.py
Normal file
BIN
cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
cardinal-plugin/monop/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc
Normal file
BIN
cardinal-plugin/monop/__pycache__/monop_parser.cpython-310.pyc
Normal file
Binary file not shown.
BIN
cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc
Normal file
BIN
cardinal-plugin/monop/__pycache__/plugin.cpython-310.pyc
Normal file
Binary file not shown.
5
cardinal-plugin/monop/config.example.json
Normal file
5
cardinal-plugin/monop/config.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"bot_nick": "monop",
|
||||
"channels": ["#monop"],
|
||||
"state_path": "/path/to/game-state.json"
|
||||
}
|
||||
1191
cardinal-plugin/monop/monop_parser.py
Normal file
1191
cardinal-plugin/monop/monop_parser.py
Normal file
File diff suppressed because it is too large
Load diff
216
cardinal-plugin/monop/plugin.py
Normal file
216
cardinal-plugin/monop/plugin.py
Normal 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
|
||||
109
cardinal-plugin/test_plugin.py
Normal file
109
cardinal-plugin/test_plugin.py
Normal 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")
|
||||
Loading…
Reference in a new issue