# daffy - developer documentation ## Overview Daffy is a duck hunting game. Ducks spawn at random intervals in each channel. Players fire with `!bang`, score points, and compete on a per-channel leaderboard. Scores are persistent in SQLite. Game state is in memory only and resets on restart. --- ## Module structure ``` daffy/ daffy.py - all game logic, commands, and admin __init__.py - empty ``` --- ## Per-channel state The original design used global bot.memory keys for game state. This meant all channels shared one duck, one timer, and one game toggle. A duck spawning would broadcast to every channel the bot was in simultaneously. That was wrong. The fix was a per-channel state dict stored in `bot.memory["df_channels"]`, keyed by the lowercased channel name. `_chan_state(bot, channel)` handles lazy initialization and key normalization: ```python key = str(channel).lower() bot.memory["df_channels"][key] = { "active": False, # duck is currently up "spawn_time": 0.0, # when the current duck spawned "next_spawn": ..., # when the next duck will spawn "empty_rounds": 0, # consecutive rounds with no shooter "enabled": True, # hunt is running for this channel "idle_rounds": 3, # auto-stop threshold } ``` The key **must** be lowercased. Sopel's `bot.channels` yields `Identifier` objects whose `__hash__` returns `hash(irc_lower(name))`, while command handlers produce plain `str` from `trigger.sender` whose hash is case-sensitive. Without normalization, `_tick` and command handlers key into different dict entries: the tick sets `active=True` under `Identifier("#Chan")`, but `cmd_bang` looks up `"#Chan"` (different hash), finds no entry, creates a fresh one with `active=False`, and fires MOCK_MSG instead of registering the shot. The same split caused `!hunt start` to never affect the state the tick was reading. State is created on first access (first tick or first command). Channels the bot is in before the first tick may not have state yet. This is fine because the tick runs every 5 seconds and creates state on its first pass. --- ## Game tick `_tick()` runs every 5 seconds via `@plugin.interval(5)`. It holds `df_game_lock` for its entire run and iterates all joined channels. For each: 1. If a duck is active and the flee timeout has passed, the duck flees. 2. If no duck is active and the spawn timer has fired, a new duck spawns. The flee check increments `empty_rounds`. When `empty_rounds` reaches `idle_rounds`, the hunt auto-stops for that channel. Each channel manages this independently. --- ## Threading A single `threading.Lock()` (`df_game_lock`) protects all channel states. Per-channel locks were considered but rejected: the tick iterates all channels under one lock anyway, so per-channel locks would not improve concurrency and would add complexity. `cmd_bang` acquires the lock to check and clear `active`. The ammo check is outside the lock since ammo is per-nick and Sopel's event handlers run in a single thread. Two concurrent bangs from the same nick cannot happen under normal Sopel operation. --- ## Ammo Ammo is tracked in `bot.memory["df_players"]`, keyed by nick, and is not per-channel. A nick carries their loaded/unloaded state across all channels. This is intentional: if you fire in one channel you are empty in all channels until you reload. Reload has a 3-second cooldown tracked by `df_reload_cd`. --- ## Scoring Scores are stored in SQLite with `(nick, channel)` as the primary key. The table uses `ON CONFLICT DO UPDATE` (upsert) so there is no separate select before incrementing. The database path comes from config, defaulting to `~/daffy_scores.db`. The directory is created automatically if it does not exist. --- ## Hunt control `!hunt` requires channel context (`@plugin.require_chanmsg`) after the per-channel refactor. It no longer makes sense in PM since each hunt is tied to a specific channel. `!hunt idle 0` disables auto-stop for that channel. The idle counter still increments but is never compared to a threshold. `!hunt idle N` checks the current `empty_rounds` immediately on set. If the counter already meets the new threshold and no duck is active, the hunt stops right away with an announcement — same behaviour as if the flee had just triggered it. This prevents a silent bypass where setting a lower threshold while `empty_rounds` was already at or above it would have no effect until the next flee, and a single kill in between would reset the counter and escape the threshold entirely. --- ## !score from PM (owner only) The owner can use `!score` from PM to see scores across all channels, or `!score clear all` / `!score clear #channel` to manage them. Regular users get "use !score in a channel" from PM. --- ## Known issues and tradeoffs **No persistence for game state.** On restart, all channels start fresh with `enabled=True` and a new random spawn timer. There is no way to stop the hunt, restart the bot, and have it stay stopped. This is acceptable since the hunt auto-starts silently without bothering anyone until a duck actually appears. **Idle auto-stop is per-restart.** `empty_rounds` resets to 0 on restart because it is in-memory. A channel that was about to auto-stop will get a fresh counter. --- ## Config | Key | Default | Description | |-----|---------|-------------| | `[daffy] db_path` | `~/daffy_scores.db` | SQLite scores database | | `[daffy] idle_rounds` | 3 | Empty rounds before auto-stop (0 = disabled) | | `[daffy] spawn_min` | 60 | Lower bound of spawn interval (seconds) | | `[daffy] spawn_max` | 300 | Upper bound of spawn interval (seconds) | | `[daffy] flee_time` | 45 | Seconds before an unshot duck flees | All three timing values are loaded into `bot.memory` as floats (`df_spawn_min`, `df_spawn_max`, `df_flee_time`) at startup and read from there at runtime, so they fall back to the module-level constants if the config section is missing.