""" Sopel plugin: jeeves duck hunting game Spawns ducks at random intervals, tracks ammo in memory, stores scores in SQLite. Spawn times, flee timeout, and idle threshold are runtime-configurable via !set. !bang - fire at the current duck !reload - refill ammo (3s cooldown) !score - top 5 for this channel !score clear - clear scores for this channel (op/owner) !hunt - show hunt state !hunt start - start the hunt (op/owner) !hunt stop - stop the hunt (op/owner) !hunt idle - set idle rounds before auto-stop (0=off) (op/owner) """ import os as _os, sys as _sys _d = _os.path.dirname(_os.path.abspath(__file__)) if _d not in _sys.path: _sys.path.insert(0, _d) import jv_core as jv import sqlite3 import random import threading import time from sopel import plugin 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)" RELOAD_CD = 3 def setup(bot): jv.ensure_setup(bot) path = bot.memory["jv_duck_db_path"] try: _os.makedirs(_os.path.dirname(_os.path.abspath(path)), exist_ok=True) except OSError: pass 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() bot.memory.setdefault("dk_channels", {}) bot.memory.setdefault("dk_reload_cd", {}) bot.memory.setdefault("dk_players", {}) bot.memory.setdefault("dk_lock", threading.Lock()) def _connect(bot): return sqlite3.connect(bot.memory["jv_duck_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 _next_spawn(bot, channel): lo = jv.cfg_val(bot, "duck_spawn_min", channel) hi = jv.cfg_val(bot, "duck_spawn_max", channel) return time.time() + random.uniform(lo, hi) def _chan_state(bot, channel): states = bot.memory.setdefault("dk_channels", {}) key = str(channel).lower() if key not in states: states[key] = { "active": False, "spawn_time": 0.0, "next_spawn": _next_spawn(bot, channel), "empty_rounds": 0, "enabled": False, } return states[key] def _player(bot, nick): if nick not in bot.memory["dk_players"]: bot.memory["dk_players"][nick] = {"ammo": 1} return bot.memory["dk_players"][nick] @plugin.interval(5) def _tick(bot): now = time.time() with bot.memory["dk_lock"]: for chan in list(bot.channels): channel = str(chan) st = _chan_state(bot, channel) if not st["enabled"]: continue if st["active"]: flee = jv.cfg_val(bot, "duck_flee_time", channel) if now - st["spawn_time"] >= flee: st["active"] = False st["next_spawn"] = _next_spawn(bot, channel) st["empty_rounds"] += 1 bot.say(FLEE_MSG, channel) idle = jv.cfg_val(bot, "duck_idle_rounds", channel) 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.", channel, ) else: if now >= st["next_spawn"]: st["active"] = True st["spawn_time"] = now bot.say(SPAWN_MSG, channel) @plugin.command("bang") @plugin.require_chanmsg def cmd_bang(bot, trigger): nick = trigger.nick channel = str(trigger.sender) with bot.memory["dk_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["dk_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(bot, channel) 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["dk_lock"]: if not _chan_state(bot, str(trigger.sender))["enabled"]: return nick = trigger.nick now = time.time() cd = bot.memory["dk_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["dk_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_chan = str(trigger.sender).startswith("#") is_owner = jv.is_owner(bot, trigger) parts = arg.split(None, 1) if parts and parts[0].lower() == "clear": if not is_owner and not (in_chan and jv.is_op(bot, trigger)): return target = parts[1].strip() if len(parts) > 1 else None if in_chan 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_chan and is_owner: if not target: bot.notice("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.notice("all scores cleared", trigger.nick) else: with _connect(bot) as conn: conn.execute("DELETE FROM scores WHERE channel = ? COLLATE NOCASE", (target,)) conn.commit() bot.notice(f"scores cleared for {target}", trigger.nick) return if in_chan: 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.notice("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.notice(f"{channel}: {player_str} [total: {total}]", trigger.nick) else: bot.say("use !score in a channel", trigger.nick) @plugin.command("hunt") @plugin.require_chanmsg def cmd_hunt(bot, trigger): channel = str(trigger.sender) arg = (trigger.group(2) or "").strip().lower() sub = arg.split()[0] if arg else "" if sub in ("start", "on"): if not jv.is_authorized(bot, trigger): return with bot.memory["dk_lock"]: st = _chan_state(bot, channel) st["enabled"] = True st["empty_rounds"] = 0 st["next_spawn"] = _next_spawn(bot, channel) st["active"] = False idle = jv.cfg_val(bot, "duck_idle_rounds", channel) idle_str = f"{idle} idle round(s)" if idle > 0 else "no idle limit" bot.say(f"the hunt is on! ({idle_str})", channel) elif sub in ("stop", "off"): if not jv.is_authorized(bot, trigger): return with bot.memory["dk_lock"]: st = _chan_state(bot, channel) st["enabled"] = False st["active"] = False st["next_spawn"] = float("inf") bot.say("the hunt is over.", channel) elif sub == "idle": if not jv.is_authorized(bot, trigger): return parts = arg.split() if len(parts) < 2: bot.say("usage: !hunt idle (0 = no auto-stop)", channel) return try: n = int(parts[1]) if n < 0: raise ValueError except ValueError: bot.say("idle rounds must be a non-negative integer", channel) return with bot.memory["jv_lock"]: ch = jv.chan_db(bot, channel) ch["config"]["duck_idle_rounds"] = n jv.save(bot) idle_str = f"{n} round(s)" if n > 0 else "off" bot.say(f"idle auto-stop set to {idle_str}", channel) else: with bot.memory["dk_lock"]: st = _chan_state(bot, channel) enabled = st["enabled"] active = st["active"] empty = st["empty_rounds"] idle = jv.cfg_val(bot, "duck_idle_rounds", channel) spawn_lo = jv.cfg_val(bot, "duck_spawn_min", channel) spawn_hi = jv.cfg_val(bot, "duck_spawn_max", channel) flee_time = jv.cfg_val(bot, "duck_flee_time", channel) 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} | " f"spawn: {spawn_lo}-{spawn_hi}s | flee: {flee_time}s", channel, ) else: bot.say(f"hunt: {state}", channel) if jv.is_authorized(bot, trigger): bot.notice("use !hunt start|stop|idle | timing via !set duck_spawn_min/max/flee_time", trigger.nick)