1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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.
|