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