aboutsummaryrefslogtreecommitdiffstats
path: root/daffy
diff options
context:
space:
mode:
Diffstat (limited to 'daffy')
-rw-r--r--daffy/__init__.py0
-rw-r--r--daffy/daffy.cfg23
-rw-r--r--daffy/daffy.py473
3 files changed, 496 insertions, 0 deletions
diff --git a/daffy/__init__.py b/daffy/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/daffy/__init__.py
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 <N> - 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 <N>")
+ return
+ try:
+ n = max(0, int(args[1]))
+ except ValueError:
+ bot.say("usage: !hunt idle <N> (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 <N>")
+
+
+# --- 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 <N> - 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)