aboutsummaryrefslogtreecommitdiffstats
path: root/dev-docs/daffy.md
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /dev-docs/daffy.md
init: mostly vibed
Diffstat (limited to 'dev-docs/daffy.md')
-rw-r--r--dev-docs/daffy.md157
1 files changed, 157 insertions, 0 deletions
diff --git a/dev-docs/daffy.md b/dev-docs/daffy.md
new file mode 100644
index 0000000..5ac7815
--- /dev/null
+++ b/dev-docs/daffy.md
@@ -0,0 +1,157 @@
+# 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.