aboutsummaryrefslogtreecommitdiffstats
path: root/jeeves/duck.py
diff options
context:
space:
mode:
Diffstat (limited to 'jeeves/duck.py')
-rw-r--r--jeeves/duck.py314
1 files changed, 314 insertions, 0 deletions
diff --git a/jeeves/duck.py b/jeeves/duck.py
new file mode 100644
index 0000000..be998b1
--- /dev/null
+++ b/jeeves/duck.py
@@ -0,0 +1,314 @@
+"""
+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)
+"""
+
+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)
+
+ 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 | !hunt stop to control | timing via !set duck_spawn_min/max/flee_time", trigger.nick)