A retired fishing spot leaves its Content.Fish timer running for good #244

Closed
opened 2026-08-19 10:17:59 +00:00 by sickday · 1 comment
Owner

When a fishing spot retires under a player, its World.Content.Fish timer is
left running on that player for good. The loop never fires again, the skilling
clock stays pinned at 0 — permanently due — nothing animates, and
Timers.has?(player.timers, World.Content.Fish) answers true forever.

Measured

A fly fisher at Barbarian Village, polled every four seconds:

:working slots=25 clock=0 feathers=977 anim=nil
         timers=[HealthRegen, Wilderness, Fish, SpecialRegen, Darkness,
                 StatRegen, StatBoost]
         delay=%World.Delay{ticks: 0, active?: false, moved: 0, skilling: 0}
         target={17914, 1}  npc=nil

World.TickSnapshot.npc(17914) is nil: World.FishSpots had retired that
spot and stood its replacement at another tile under a new index. The state
above then held, unchanged, for eight minutes until the sitting was
stopped.

Why it should not be possible

World.Content.Fish.pass/3's first clause is not standing?(action) -> halt(player), and halt/1 reaches stop/1, which is
Player.clear_timer(player, World.Content.Fish). A loop that fired even once
after the spot went would have cleared its own timer within a tick.

So the loop is not firing. The clock sitting at 0 says the same thing from
the other side: Contract.Skilling.due?/1 is clock == 0, so had pass/3
run it would have taken the due? branch, rolled, and either landed a fish or
been refused — and every refusal path halts. Nothing rolled, and nothing
animated, which resume/2 would have done.

The suspicion is that the timer is driven from the pending interaction and the
engine drops that interaction when the NPC is retired, leaving the timer with
nothing to run it. Not confirmed — that is the first thing to check.

Why it matters beyond fishing

skilling? on the bot snapshot is exactly
Timers.has?(player.timers, Content.Fish) and friends, so a stale timer is a
body that reports working while doing nothing. Bot.Goals.Fish and
Bot.Goals.GatherAndBank no longer depend on the answer — both now ask
whether the target is still there before believing skilling? — but that
is a consumer working around it, not a fix. Anything else that reads
skilling?, now or later, inherits the lie.

The same shape should be checked for World.Content.Chop and
World.Content.Mine, whose nodes go by changing id rather than by vanishing
from an index; it is not known whether their teardown has the same hole.

When a fishing spot retires under a player, its `World.Content.Fish` timer is left running on that player for good. The loop never fires again, the skilling clock stays pinned at `0` — permanently *due* — nothing animates, and `Timers.has?(player.timers, World.Content.Fish)` answers `true` forever. ## Measured A fly fisher at Barbarian Village, polled every four seconds: :working slots=25 clock=0 feathers=977 anim=nil timers=[HealthRegen, Wilderness, Fish, SpecialRegen, Darkness, StatRegen, StatBoost] delay=%World.Delay{ticks: 0, active?: false, moved: 0, skilling: 0} target={17914, 1} npc=nil `World.TickSnapshot.npc(17914)` is `nil`: `World.FishSpots` had retired that spot and stood its replacement at another tile under a new index. The state above then held, unchanged, for **eight minutes** until the sitting was stopped. ## Why it should not be possible `World.Content.Fish.pass/3`'s first clause is `not standing?(action) -> halt(player)`, and `halt/1` reaches `stop/1`, which is `Player.clear_timer(player, World.Content.Fish)`. A loop that fired even once after the spot went would have cleared its own timer within a tick. So the loop is not firing. The clock sitting at `0` says the same thing from the other side: `Contract.Skilling.due?/1` is `clock == 0`, so had `pass/3` run it would have taken the `due?` branch, rolled, and either landed a fish or been refused — and every refusal path halts. Nothing rolled, and nothing animated, which `resume/2` would have done. The suspicion is that the timer is driven from the pending interaction and the engine drops that interaction when the NPC is retired, leaving the timer with nothing to run it. Not confirmed — that is the first thing to check. ## Why it matters beyond fishing `skilling?` on the bot snapshot is exactly `Timers.has?(player.timers, Content.Fish)` and friends, so a stale timer is a body that reports *working* while doing nothing. `Bot.Goals.Fish` and `Bot.Goals.GatherAndBank` no longer depend on the answer — both now ask whether the target is still there **before** believing `skilling?` — but that is a consumer working around it, not a fix. Anything else that reads `skilling?`, now or later, inherits the lie. The same shape should be checked for `World.Content.Chop` and `World.Content.Mine`, whose nodes go by changing id rather than by vanishing from an index; it is not known whether their teardown has the same hole.
Author
Owner

Fixed at the engine on fix/long-legs-and-unreachable-banks (dea398d). The diagnosis in the issue was half right — the timer was left standing, but not because the interaction was dropped.

The cause is the level-up box. World.Player.runnable?/1 refused to run a :normal timer while any modal was open, and interface 233 is the one screen that arrives with no click behind it, so a body with nobody to press Continue held its gathering loop at zero for good. That also explains the clock: begin_tick/1 holds the skilling clock under the same condition, which is why it read as permanently due rather than idle.

The gate was wrong on its own terms. Every :normal timer in this world is a skilling loop — Chop, Mine, Fish, Farm, Light, Production arm one and nothing else does — and every client input reaches interrupt/1, which halts them. So by the time any screen a player opened is up, the loop it interrupted is already gone: the modal half of the gate could only ever be holding a loop the player never stopped, and the level-up box is the one screen that reaches it. Which is the screen interrupt/1 deliberately does not end the action on — "it would take a roll away as a reward for earning one" — and then the gate took every roll in the window anyway. Pausing a gathering loop is not a softer version of stopping it.

So an unbidden modal no longer holds a timer. A delay still does, and so does every screen the player opened — the case that cannot arise.

The trap worth recording: begin_tick/1 had to move with it. Left behind, the timer would fire every tick against a clock nothing was spending — the loop animates, due? is never true, and the body fishes and catches nothing, which is a worse bug that looks like working. Both ask one predicate now, and World.SkillingTest asserts the cadence either side of it: [3, 3, 3, 3] under a bank screen, [2, 1, 0, 3] under the box.

Chop and Mine, which the issue asked about: identical teardown (not standing?(..) -> halt), identical :normal timer, so they had the same hole and this fix covers them. No separate defect.

Three existing tests had encoded the old rule as a property and were rewritten — the honest signal that this was behaviour rather than an oversight. The new end-to-end one is driven through run_clocks/2 rather than Fish.run/2 on purpose: the loop has always halted itself the first time it runs against an empty slot, so calling the script directly passes against the bug. The hole was in the caller.

Bot.Session.dismiss/1 (#242) stays and is still needed: the box holds a :normal queue entry, which is untouched and correct.

Fixed at the engine on `fix/long-legs-and-unreachable-banks` (`dea398d`). The diagnosis in the issue was half right — the timer *was* left standing, but not because the interaction was dropped. **The cause is the level-up box.** `World.Player.runnable?/1` refused to run a `:normal` timer while any modal was open, and interface `233` is the one screen that arrives with no click behind it, so a body with nobody to press Continue held its gathering loop at zero for good. That also explains the clock: `begin_tick/1` holds the skilling clock under the same condition, which is why it read as *permanently due* rather than idle. **The gate was wrong on its own terms.** Every `:normal` timer in this world is a skilling loop — `Chop`, `Mine`, `Fish`, `Farm`, `Light`, `Production` arm one and nothing else does — and every client input reaches `interrupt/1`, which *halts* them. So by the time any screen a player opened is up, the loop it interrupted is already gone: the modal half of the gate could only ever be holding a loop the player never stopped, and the level-up box is the one screen that reaches it. Which is the screen `interrupt/1` deliberately does **not** end the action on — *"it would take a roll away as a reward for earning one"* — and then the gate took every roll in the window anyway. Pausing a gathering loop is not a softer version of stopping it. So an unbidden modal no longer holds a timer. A delay still does, and so does every screen the player opened — the case that cannot arise. **The trap worth recording**: `begin_tick/1` had to move with it. Left behind, the timer would fire every tick against a clock nothing was spending — the loop animates, `due?` is never true, and the body fishes and catches nothing, which is a worse bug that *looks* like working. Both ask one predicate now, and `World.SkillingTest` asserts the cadence either side of it: `[3, 3, 3, 3]` under a bank screen, `[2, 1, 0, 3]` under the box. **Chop and Mine**, which the issue asked about: identical teardown (`not standing?(..) -> halt`), identical `:normal` timer, so they had the same hole and this fix covers them. No separate defect. Three existing tests had encoded the old rule as a property and were rewritten — the honest signal that this was behaviour rather than an oversight. The new end-to-end one is driven through `run_clocks/2` rather than `Fish.run/2` on purpose: the loop has always halted itself the first time it runs against an empty slot, so calling the script directly passes against the bug. The hole was in the caller. `Bot.Session.dismiss/1` (#242) stays and is still needed: the box holds a `:normal` *queue* entry, which is untouched and correct.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Revenant/Server#244
No description provided.