From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- daffy/__init__.py | 0 daffy/daffy.cfg | 23 +++ daffy/daffy.py | 473 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 daffy/__init__.py create mode 100644 daffy/daffy.cfg create mode 100644 daffy/daffy.py (limited to 'daffy') diff --git a/daffy/__init__.py b/daffy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daffy/daffy.cfg b/daffy/daffy.cfg new file mode 100644 index 0000000..3908c58 --- /dev/null +++ b/daffy/daffy.cfg @@ -0,0 +1,23 @@ +[core] +nick = daffy +user = daffy +name = daffy +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/daffy +enable = + daffy +logdir = /var/log/daffy +logging_level = INFO +timeout = 120 + +[daffy] +db_path = /var/daffy/scores.db +idle_rounds = 3 +spawn_min = 60 +spawn_max = 300 +flee_time = 45 diff --git a/daffy/daffy.py b/daffy/daffy.py new file mode 100644 index 0000000..dc7388f --- /dev/null +++ b/daffy/daffy.py @@ -0,0 +1,473 @@ +""" +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) -- cgit v1.2.3