""" Sopel plugin: daffy - duck hunting game Spawns ducks at random intervals, tracks ammo in memory, and stores scores in SQLite with per-channel differentiation. Auto-stops after a configurable number of idle rounds. !bang - fire at the current duck !reload - refill ammo (3s cooldown) !score - top 5 for this channel !score clear - clear scores for this channel (owner/op) !hunt - show hunt state for this channel !hunt start - start the hunt (owner/op) !hunt stop - stop the hunt (owner/op) !hunt idle - set empty-round auto-stop threshold (owner/op) !join / !part / !chans / !help - owner commands """ import os import random import sqlite3 import threading import time from sopel import plugin from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute SPAWN_MSG = "\x02** A duck appears! **\x02 Quick, type !bang to shoot it!" FLEE_MSG = "\x02** The duck got away! **\x02 Too slow." EMPTY_MSG = "*click* -- chamber is empty. Use !reload first." MOCK_MSG = "BLAM! ...at what? There's no duck, {nick}. You waste your shot." KILL_MSG = "\x02BLAM!\x02 {nick} shot the duck in \x02{elapsed:.2f}s\x02! (+1 pt)" SPAWN_MIN = 60 SPAWN_MAX = 300 FLEE_TIMEOUT = 45 RELOAD_CD = 3 class DaffySection(StaticSection): db_path = FilenameAttribute("db_path", relative=False, default=None) idle_rounds = ValidatedAttribute("idle_rounds", default="3") spawn_min = ValidatedAttribute("spawn_min", default="60") spawn_max = ValidatedAttribute("spawn_max", default="300") flee_time = ValidatedAttribute("flee_time", default="45") def _next_spawn_time(bot=None): lo = bot.memory.get("df_spawn_min", SPAWN_MIN) if bot else SPAWN_MIN hi = bot.memory.get("df_spawn_max", SPAWN_MAX) if bot else SPAWN_MAX return time.time() + random.uniform(lo, hi) def _connect(bot): return sqlite3.connect(bot.memory["df_db_path"]) def _add_score(bot, nick, channel): with _connect(bot) as conn: conn.execute( "INSERT INTO scores (nick, channel, score) VALUES (?, ?, 1)" " ON CONFLICT(nick, channel) DO UPDATE SET score = score + 1", (nick, channel), ) conn.commit() def _chan_state(bot, channel): """Return per-channel game state dict, creating it on first access.""" states = bot.memory.setdefault("df_channels", {}) key = str(channel).lower() if key not in states: idle = bot.memory.get("df_idle_rounds", 3) states[key] = { "active": False, "spawn_time": 0.0, "next_spawn": _next_spawn_time(), "empty_rounds": 0, "enabled": True, "idle_rounds": idle, } return states[key] def setup(bot): bot.config.define_section("daffy", DaffySection, validate=False) try: configured = bot.config.daffy.db_path except Exception: configured = None path = configured or os.path.join(bot.config.core.homedir, "daffy_scores.db") try: os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) except OSError: path = os.path.join(bot.config.core.homedir, "daffy_scores.db") bot.memory["df_db_path"] = path with sqlite3.connect(path) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS scores ( nick TEXT NOT NULL, channel TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (nick, channel) ) """) conn.commit() try: idle_rounds = int(bot.config.daffy.idle_rounds) except Exception: idle_rounds = 3 try: spawn_min = float(bot.config.daffy.spawn_min) except Exception: spawn_min = float(SPAWN_MIN) try: spawn_max = float(bot.config.daffy.spawn_max) except Exception: spawn_max = float(SPAWN_MAX) try: flee_time = float(bot.config.daffy.flee_time) except Exception: flee_time = float(FLEE_TIMEOUT) bot.memory["df_idle_rounds"] = idle_rounds bot.memory["df_spawn_min"] = spawn_min bot.memory["df_spawn_max"] = spawn_max bot.memory["df_flee_time"] = flee_time bot.memory["df_channels"] = {} bot.memory["df_reload_cd"] = {} bot.memory["df_game_lock"] = threading.Lock() bot.memory["df_players"] = {} def _player(bot, nick): if nick not in bot.memory["df_players"]: bot.memory["df_players"][nick] = {"ammo": 1} return bot.memory["df_players"][nick] def _is_owner(bot, trigger): return trigger.nick == bot.settings.core.owner def _is_op(bot, trigger): chan = bot.channels.get(str(trigger.sender)) if not chan: return False return chan.privileges.get(trigger.nick, 0) >= plugin.OP def _is_authorized(bot, trigger): return _is_owner(bot, trigger) or _is_op(bot, trigger) # --- Game tick --- @plugin.interval(5) def _tick(bot): now = time.time() with bot.memory["df_game_lock"]: for chan in list(bot.channels): st = _chan_state(bot, chan) if not st["enabled"]: continue if st["active"]: flee = bot.memory.get("df_flee_time", FLEE_TIMEOUT) if now - st["spawn_time"] >= flee: st["active"] = False st["next_spawn"] = _next_spawn_time(bot) st["empty_rounds"] += 1 bot.say(FLEE_MSG, chan) idle = st["idle_rounds"] if idle > 0 and st["empty_rounds"] >= idle: st["enabled"] = False st["next_spawn"] = float("inf") bot.say( f"Hunt over - no players for {idle} round(s). " f"Type !hunt start to begin again.", chan, ) else: if now >= st["next_spawn"]: st["active"] = True st["spawn_time"] = now bot.say(SPAWN_MSG, chan) # --- Game commands --- @plugin.command("bang") @plugin.require_chanmsg def cmd_bang(bot, trigger): nick = trigger.nick channel = str(trigger.sender) with bot.memory["df_game_lock"]: if not _chan_state(bot, channel)["enabled"]: return p = _player(bot, nick) if p["ammo"] < 1: bot.say(EMPTY_MSG, channel) return with bot.memory["df_game_lock"]: st = _chan_state(bot, channel) if not st["active"]: p["ammo"] = 0 bot.say(MOCK_MSG.format(nick=nick), channel) return elapsed = time.time() - st["spawn_time"] st["active"] = False st["next_spawn"] = _next_spawn_time(bot) st["empty_rounds"] = 0 p["ammo"] = 0 bot.say(KILL_MSG.format(nick=nick, elapsed=elapsed), channel) _add_score(bot, nick, channel) @plugin.command("reload") @plugin.require_chanmsg def cmd_reload(bot, trigger): with bot.memory["df_game_lock"]: if not _chan_state(bot, str(trigger.sender))["enabled"]: return nick = trigger.nick now = time.time() cd = bot.memory["df_reload_cd"].get(nick, 0) if now - cd < RELOAD_CD: remaining = RELOAD_CD - (now - cd) bot.say(f"{nick}: reload on cooldown ({remaining:.1f}s remaining)", trigger.sender) return bot.memory["df_reload_cd"][nick] = now _player(bot, nick)["ammo"] = 1 bot.say(f"{nick}: reloaded. One shell in the chamber.", trigger.sender) @plugin.command("score") def cmd_score(bot, trigger): arg = (trigger.group(2) or "").strip() in_channel = str(trigger.sender).startswith("#") is_owner = _is_owner(bot, trigger) parts = arg.split(None, 1) if parts and parts[0].lower() == "clear": if not is_owner and not (in_channel and _is_op(bot, trigger)): return target = parts[1].strip() if len(parts) > 1 else None if in_channel and not target: channel = str(trigger.sender) with _connect(bot) as conn: conn.execute( "DELETE FROM scores WHERE channel = ? COLLATE NOCASE", (channel,), ) conn.commit() bot.say(f"scores cleared for {channel}", trigger.sender) elif not in_channel and is_owner: if not target: bot.say("usage: !score clear all or !score clear #channel", trigger.nick) elif target.lower() == "all": with _connect(bot) as conn: conn.execute("DELETE FROM scores") conn.commit() bot.say("all scores cleared", trigger.nick) else: with _connect(bot) as conn: conn.execute( "DELETE FROM scores WHERE channel = ? COLLATE NOCASE", (target,), ) conn.commit() bot.say(f"scores cleared for {target}", trigger.nick) return if in_channel: channel = str(trigger.sender) with _connect(bot) as conn: rows = conn.execute( "SELECT nick, score FROM scores" " WHERE channel = ? COLLATE NOCASE" " ORDER BY score DESC LIMIT 5", (channel,), ).fetchall() if not rows: bot.say("no scores yet", trigger.sender) return line = " | ".join(f"{i+1}. {nick} ({score})" for i, (nick, score) in enumerate(rows)) bot.say(line, trigger.sender) elif is_owner: with _connect(bot) as conn: rows = conn.execute( "SELECT channel, nick, score FROM scores ORDER BY channel, score DESC" ).fetchall() if not rows: bot.say("no scores yet", trigger.nick) return by_channel = {} for channel, nick, score in rows: by_channel.setdefault(channel, []).append((nick, score)) for channel, players in sorted(by_channel.items()): total = sum(s for _, s in players) player_str = " | ".join(f"{n} ({s})" for n, s in players[:10]) bot.say(f"{channel}: {player_str} [total: {total} duck(s)]", trigger.nick) else: bot.say("use !score in a channel", trigger.nick) # --- Hunt control --- @plugin.command("hunt") @plugin.require_chanmsg def cmd_hunt(bot, trigger): channel = str(trigger.sender) arg = (trigger.group(2) or "").strip().lower() args = arg.split(None, 1) sub = args[0] if args else "" if sub in ("start", "on"): if not _is_authorized(bot, trigger): return with bot.memory["df_game_lock"]: st = _chan_state(bot, channel) st["enabled"] = True st["empty_rounds"] = 0 st["next_spawn"] = _next_spawn_time(bot) st["active"] = False idle = st["idle_rounds"] idle_str = f"{idle} idle round(s)" if idle > 0 else "no idle limit" bot.say(f"the hunt is on! ({idle_str})") elif sub in ("stop", "off"): if not _is_authorized(bot, trigger): return with bot.memory["df_game_lock"]: st = _chan_state(bot, channel) st["enabled"] = False st["active"] = False st["next_spawn"] = float("inf") bot.say("the hunt is over.") elif sub == "idle": if not _is_authorized(bot, trigger): return if len(args) < 2: with bot.memory["df_game_lock"]: current = _chan_state(bot, channel)["idle_rounds"] bot.say(f"idle rounds: {current} (0 = never auto-stop). usage: !hunt idle ") return try: n = max(0, int(args[1])) except ValueError: bot.say("usage: !hunt idle (N = 0 to disable auto-stop)") return with bot.memory["df_game_lock"]: st = _chan_state(bot, channel) st["idle_rounds"] = n if n > 0 and st["empty_rounds"] >= n and not st["active"]: st["enabled"] = False st["next_spawn"] = float("inf") bot.say( f"Hunt over - idle limit now {n}, already at " f"{st['empty_rounds']} empty round(s). " f"Type !hunt start to begin again.", channel, ) return if n == 0: bot.say("idle auto-stop disabled") else: bot.say(f"hunt will auto-stop after {n} round(s) with no players") else: with bot.memory["df_game_lock"]: st = _chan_state(bot, channel) enabled = st["enabled"] active = st["active"] idle = st["idle_rounds"] empty = st["empty_rounds"] if not enabled: state = "off" elif active: state = "active (duck is up!)" else: state = "on (waiting for next duck)" if enabled: idle_str = f"{empty}/{idle}" if idle > 0 else f"{empty}/off" bot.say(f"hunt: {state} | empty rounds: {idle_str}") else: bot.say(f"hunt: {state}") if _is_authorized(bot, trigger): bot.say("usage: !hunt on/start | !hunt off/stop | !hunt idle ") # --- Admin --- @plugin.command("join") def cmd_join(bot, trigger): if not _is_owner(bot, trigger): return channel = (trigger.group(2) or "").strip() if not channel.startswith("#"): bot.say("usage: !join #channel", trigger.nick) return bot.join(channel) @plugin.command("part") def cmd_part(bot, trigger): if not _is_owner(bot, trigger): return channel = (trigger.group(2) or "").strip() or str(trigger.sender) bot.part(channel) @plugin.command("chans") def cmd_chans(bot, trigger): if not _is_owner(bot, trigger): return chans = sorted(str(c) for c in bot.channels) bot.say(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) @plugin.command("help") def cmd_help(bot, trigger): lines = [ "!bang - fire at the current duck", "!reload - refill ammo (3s cooldown)", "!score - top 5 players for this channel", "!score clear - clear scores for this channel (owner/op)", ] if _is_authorized(bot, trigger): lines += [ "!hunt - show hunt state for this channel", "!hunt start - start the hunt", "!hunt stop - stop the hunt", "!hunt idle - set empty-round auto-stop (0 = never)", ] if _is_owner(bot, trigger): lines += [ "!join #channel - join a channel", "!part [#channel] - leave a channel", "!chans - list channels", "!score clear all - clear all scores (owner PM)", "!score clear #channel - clear a specific channel (owner PM)", ] lines.append("!help - this message") for line in lines: bot.say(line, trigger.nick)