fix/long-legs-and-unreachable-banks #250

Merged
sickday merged 3 commits from fix/long-legs-and-unreachable-banks into main 2026-08-19 16:45:41 +00:00
Owner
No description provided.
A level-up box is the only screen in this world that arrives with no click
behind it, and `runnable?/1` let it hold every `:normal` timer -- which is
every gathering loop and nothing else. For a body with nobody to press
Continue that is permanent: a fly fisher stood for eight minutes with its
clock pinned at `0`, nothing animating, and `Timers.has?(.., Content.Fish)`
still answering true about a spot that had relocated out from under it.

The gate is wrong on its own terms, and the argument is short enough to check.
Every `:normal` timer in this world is a skilling loop -- `Chop`, `Mine`,
`Fish`, `Farm`, `Light` and `Production` arm one and nothing else does -- and
every client input reaches `interrupt/1`, which *halts* them outright. So by
the time any screen a player opened is up, the loop it interrupted is already
gone: the modal half of the gate can only ever be holding a loop the player
never stopped, and there is exactly one screen that arrives that way.

Which is the screen `interrupt/1` goes out of its way not to end the action
on -- it takes the `interrupt_queue/1` path because ending the loop "would
take a roll away as a reward for earning one" -- and then this held the loop
at zero and took every roll in the window anyway. Pausing a gathering loop is
not a softer version of stopping it; over a screen nobody dismisses it is the
same thing for longer, and it is also how a loop outlives its own target,
because a held timer cannot notice that the thing it was working has gone.

`begin_tick/1` had to move with it, and that coupling is the trap rather than
the tidy-up. The skilling clock is held under the same condition and was
written as *any* modal. 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 `held?/1` now, and `World.SkillingTest` asserts the cadence on either
side of it: `[3, 3, 3, 3]` under a bank screen, `[2, 1, 0, 3]` under the box.

Three tests had encoded the old rule as a property and had to be rewritten,
which is the honest signal that this was behaviour rather than an oversight.
The new one that matters is driven through `run_clocks/2` rather than
`Fish.run/2`: 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` stays and is still needed. The box holds a `:normal`
*queue* entry, which is untouched and correct: a level-up clears the weak
entries on the way in and a strong one is entitled to wait for the Continue.

Closes #244.
fix(bots): chart a long leg, and pair a site with a bank it can walk to
Some checks failed
ci / gates (pull_request) Failing after 22s
5b33207ab6
An eighth of a 75-bot fishing fleet spent eighteen minutes filling a pack,
setting off for the bank, failing the leg, being re-initialised, and walking
back to the water to do it again. `c01f507` gave the goal a `spurned` set so
it stops re-picking the ground it just failed at, and measured that one of the
three grounds was fixed and two were not. Asked of the world rather than of
the log, those two are not the same fault at all.

    {0, 2874, 3341} -> Falador west  :closed at 2,478 tiles   5ms
    {0, 3239, 3251} -> Al Kharid     found, 267 steps        64ms

The Falador pair are on an **island**, with no bank on it, paired across open
sea. The other is reachable and simply further than one walk request can plan.

`@max_search` is a budget, not a reach, and that is the second fault whole.
`World.Movement.route/4` stops at `104 * 104` visited tiles, justified as the
client's 104-tile scene -- and a breadth-first sweep visiting N tiles reaches
`sqrt(N)`, so the real reach is **52**. Both halves of that reasoning are true
and the conclusion is wrong. Worse than refusing, it falls back to the closest
tile to the target it happened to see, which is greedy: simulating the obvious
fix, re-issuing to the true destination advanced a bot 94 steps and the next
request came back **empty**, every reachable tile being further from the bank
by straight line than the one it stood on. It does not converge; it parks.

So `Bot.Route` plans the whole thing. A* rather than a flood fill, because on
an eight-connected uniform grid Chebyshev distance is the *exact* remaining
step count over open ground rather than merely admissible: 18,336 visits
against a plain sweep's 62,498 for the same 267-step path, and 23,593 against
81,069 for a 384-step one. An ordinary leg is one to three thousand visits and
two milliseconds. `:reach` makes the goal any tile within n of the
destination, which is what lets it succeed against a spot in the water or a
booth.

`Bot.Travel` charts on the first step of a leg and never again -- a brain step
runs in the bot's own process after the barrier, so the eighty milliseconds a
worst-case plan costs are the bot's own, but the tick behind it is one the
barrier waits for. It walks `waypoints/2`, cut every forty steps **of route**
rather than of map, because that is what keeps each one inside a single
request. `aim/2` is what the leg is walking to now, and the re-issue, the
reachability question and the door all aim at that. The blind straight-line
hop is gone; it was clicking at the water. The leg's timeout moved with it:
`@max_steps` was a constant sized for a short leg, and a charted one gets
`@max_steps + steps / 2`, so the walk's own length is the allowance.

Only `:unreachable` gives up, and immediately. `:too_far` and `:off_plane`
walk uncharted, exactly as before -- the second is load-bearing rather than
lenient, because a destination up a staircase is a leg only
`Bot.Obstacle.between/2`'s `climb/2` clause can finish and it is reached
through the stuck ladder.

`Bot.Sites` had half this rule already: `enclosure/1` fills from each **bank**,
so a bank in a pocket only pairs inside it. The mirror image was open -- a
bank in the open world pairs with anything in range, island included. The
pairing is settled by walking there now, and it drops 148 tree sites, 8 rock
sites and 2 fishing grounds of ~700, every one a place a gatherer could only
wedge. Derivation goes 606ms to ~5.7s, concurrent, once at boot.

Moving the search origin from the cluster anchor to `standable/1`'s tile
*added* a site, which is the opposite of what a stricter rule should do, and
the reason is worth more than the site: `site/2` asked the enclosure
`MapSet.member?` of the **anchor**, and a tree's tile is solid, so it is in no
flood fill anywhere. Every enclosed bank had been excluding every gather site
unconditionally for the table's whole life. `fishing_ground/3` passed the
stand and was always right, which is why the case the rule was written for
worked and nobody looked. A set-membership test asked of a tile that can never
be in the set is not a filter, it is a constant -- and it fails in the safe
direction, so it reads as the rule working. The witness was in the suite
already: a test asserting arctic pine derives no site "because Neitiznot's
bank is shut in", when the pines walk to it in 26 steps and
`World.Movement.route/4` finds the same leg in 29.

Sat with six fishers over three grounds for fourteen minutes: deposits of
5, 4, 4, 3, 2 and 1, `spurned=0` and `fails=0` throughout, nobody shuttling.
On these grounds the measurement before this was nine bots banking nothing at
all in eighteen minutes and reaching `opening` zero times between them. The
383-step leg is walked twice, northward to the Lumbridge bridge first, which
is the charted route rather than the straight line the hop drew.

A shut door still reads as rock, so a site behind one is dropped rather than
paired. Nothing is lost to that yet -- the same door stops the walk, so a bot
could not have banked from those sites before either -- but it is why a
door-aware planner is the natural next thing.

Closes #247.
fix(bots): drop the spurn clause the compiler proved unreachable
All checks were successful
ci / gates (pull_request) Successful in 3m24s
build / image (push) Successful in 38s
ci / gates (push) Successful in 3m36s
1ce017305a
CI failed the compile gate on this branch, and `origin/main` fails it too:
`c01f507` added a defensive `defp spurn(%{ground: nil} = state), do: state` to
both gatherers, and the forge's Elixir infers enough to report the clause as
never used. Under `--warnings-as-errors` that is a build failure rather than a
warning, so the gate has been red upstream while every local one was green.

The compiler is right. Both branches that spurn are in `travel_to_ground` and
`travel_to_bank`, and the only way into either is out of `choosing_ground`,
which sets a ground -- so there is no path on which the guard fires. Keeping
unreachable defensive code is what the gate exists to catch, and the invariant
that makes it unreachable is now written down where the reasoning about
spurning already lives.

Worth knowing for the next branch: the flake pins 1.18.4 and the forge runs
something newer, so `mix compile --force --warnings-as-errors` passing here is
not the gate that runs there. Reproduced with

    MIX_BUILD_ROOT=/tmp/scratch MIX_ENV=test \
      mix compile --force --warnings-as-errors

outside `nix develop`, which is also how `main` was confirmed red independently
of anything on this branch.
Sign in to join this conversation.
No reviewers
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!250
No description provided.