From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- jeeves/duck.py | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 jeeves/duck.py (limited to 'jeeves/duck.py') 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) -- cgit v1.2.3