fix(bots): a level-up box strands a standing body, and a test can pin the dice #248

Merged
sickday merged 2 commits from fix/level-up-strands-a-standing-bot into main 2026-08-19 11:20:41 +00:00
Owner

Two unrelated faults that happened to be next to each other on the board.

A level-up box strands a body that stands still (#242, #244)

#242 reported a fly fisher standing still for eight minutes — phase working,
skilling clock pinned at 0, nothing animating, the Fish timer still
registered — and diagnosed it as the interaction being dropped when the spot
retired under it. That was a real fault too, and it is fixed separately at
e9b9747. It is not this one.

The bot had levelled up. Interface 233 binds into the modal slot on
purpose — runnable?/1 says no while it is up, so the action that earned the
level is interrupted — and World.Timers holds every :normal timer at zero,
retrying each tick until it may fire. The gathering loop is a :normal timer.
A person clears the box with the Continue; a bot has no client, and nothing
else in the engine closes one. Measured: fifty ticks on, the timer has still
never run.

Three things make it worse than it reads:

  • It is self-sealing. The loop that would have filled the pack is the thing
    that stopped, so the bank trip whose Act.close/1 would have cleared the
    modal as a side effect never comes.
  • Every kind reaches it, and it went unseen only because a fleet is spawned
    with its statline already on — the bodies watched longest had nothing left to
    level.
  • A clock at zero is permanently due, which reads as a loop refusing to
    roll rather than a loop never being called. That is why #242 landed on the
    retired spot.

World.Player.Modal.unbidden?/1 is a predicate rather than a policy: it
names the one interface that arrives with no click behind it and decides
nothing, so a socket session neither asks nor is answered.
Bot.Session.dismiss/1 closes it, on every encode rather than on a brain step,
because a brain that has halted or is waiting out a long delay is still a body
with a screen in front of it. Only 233, by number — a bot opens chatbox
modals of its own, the make picker among them.

The test nearly shipped passing against the bug. Every modal is stamped with
the tile it was opened at and Modal.present/1 closes one the player has
stepped off, so a walking body clears its own box by accident within a step or
two; the first draft spawned a wanderer and was green either way. The bodies the
fault reaches are the ones standing still — every gatherer for as long as it is
working — so the test freezes the brain first, and it fails without the fix.

A test can pin the dice a loop rolls for itself (#119, #141)

Everything that turns on a roll takes its dice as an argument. The one entry
point that cannot is run/2: the timer pass calls it and there is nowhere to
pass one from. Unpinned, the roll is a pure function of --seed, so a test
needing the unlikely branch fails on whichever seeds land the other way —
World.Content.LightTest's retry assertion wanted a 65/256 failure and died on
three of the first twenty seeds, presenting as "passes alone, fails in the
suite" because adding any file reshuffles the order.

Contract.Skilling.pin/1 supplies the roll, unpin/0 gives it back. The
process dictionary rather than a global on purpose: a global would be
visible to every async: true test beside it, trading a seed lottery for a
scheduling one. Which also states the limit rather than hiding it — a world
test's roll happens in the world's process, so pinning there changes nothing,
and a world test still has to count what its deadline buys and assert the pure
function when the answer is small.

The three other tests driving a run/2 (fish, chop, runecraft) take halting
branches reached before any roll, so they are not seed-dependent and are left
alone.

Checks

mix dialyzer 0 errors, mix format --check-formatted clean, Credo clean on
the touched files. Full bot suite plus content/contract/player: eleven failures,
every one already in expected_red_tests_and_environments.md
(Bot.PkWorldTest, four MerchantGoalTest, five PkGoalTest,
WildernessTest). That file is updated to mark the LightTest red closed.

Closes #242, #244, #119, #141.

Two unrelated faults that happened to be next to each other on the board. ## A level-up box strands a body that stands still (#242, #244) #242 reported a fly fisher standing still for eight minutes — phase `working`, skilling clock pinned at `0`, nothing animating, the `Fish` timer still registered — and diagnosed it as the interaction being dropped when the spot retired under it. That was a real fault too, and it is fixed separately at `e9b9747`. It is not this one. The bot had **levelled up**. Interface `233` binds into the modal slot on purpose — `runnable?/1` says no while it is up, so the action that earned the level is interrupted — and `World.Timers` holds every `:normal` timer at zero, retrying each tick until it may fire. The gathering loop is a `:normal` timer. A person clears the box with the Continue; a bot has no client, and nothing else in the engine closes one. Measured: fifty ticks on, the timer has still never run. Three things make it worse than it reads: * **It is self-sealing.** The loop that would have filled the pack is the thing that stopped, so the bank trip whose `Act.close/1` would have cleared the modal as a side effect never comes. * **Every kind reaches it**, and it went unseen only because a fleet is spawned with its statline already on — the bodies watched longest had nothing left to level. * **A clock at zero is *permanently due***, which reads as a loop refusing to roll rather than a loop never being called. That is why #242 landed on the retired spot. `World.Player.Modal.unbidden?/1` is a **predicate rather than a policy**: it names the one interface that arrives with no click behind it and decides nothing, so a socket session neither asks nor is answered. `Bot.Session.dismiss/1` closes it, on every encode rather than on a brain step, because a brain that has halted or is waiting out a long delay is still a body with a screen in front of it. Only `233`, by number — a bot opens chatbox modals of its own, the make picker among them. **The test nearly shipped passing against the bug.** Every modal is stamped with the tile it was opened at and `Modal.present/1` closes one the player has stepped off, so a *walking* body clears its own box by accident within a step or two; the first draft spawned a wanderer and was green either way. The bodies the fault reaches are the ones standing still — every gatherer for as long as it is working — so the test freezes the brain first, and it fails without the fix. ## A test can pin the dice a loop rolls for itself (#119, #141) Everything that turns on a roll takes its dice as an argument. The one entry point that cannot is `run/2`: the timer pass calls it and there is nowhere to pass one from. Unpinned, the roll is a pure function of `--seed`, so a test needing the *unlikely* branch fails on whichever seeds land the other way — `World.Content.LightTest`'s retry assertion wanted a 65/256 failure and died on three of the first twenty seeds, presenting as "passes alone, fails in the suite" because adding any file reshuffles the order. `Contract.Skilling.pin/1` supplies the roll, `unpin/0` gives it back. The **process dictionary** rather than a global on purpose: a global would be visible to every `async: true` test beside it, trading a seed lottery for a scheduling one. Which also states the limit rather than hiding it — a world test's roll happens in the world's process, so pinning there changes nothing, and a world test still has to count what its deadline buys and assert the pure function when the answer is small. The three other tests driving a `run/2` (fish, chop, runecraft) take halting branches reached before any roll, so they are not seed-dependent and are left alone. ## Checks `mix dialyzer` 0 errors, `mix format --check-formatted` clean, Credo clean on the touched files. Full bot suite plus content/contract/player: eleven failures, **every one already in `expected_red_tests_and_environments.md`** (`Bot.PkWorldTest`, four `MerchantGoalTest`, five `PkGoalTest`, `WildernessTest`). That file is updated to mark the `LightTest` red closed. Closes #242, #244, #119, #141.
A level-up box is the only chatbox dialogue in the game that arrives unbidden,
and it is bound into the modal slot on purpose -- `runnable?/1` says no while it
is up, so the action that earned the level is interrupted. A person clears it
with the Continue. A bot has nobody behind it, so nothing ever did.

Which is a permanent stop rather than a pause, and it is self-sealing. Fifty
ticks after a level-up a `:normal` timer has still never fired: the skilling
clock stays pinned at `0`, nothing animates, and `Timers.has?` answers `true`
for good -- so the gathering loop that would have filled the pack is the thing
that stopped, and the bank trip that would have closed the modal as a side
effect never comes. That is #242's fisher exactly: `working`, clock zero,
nothing moving, for the eight minutes until the sitting was stopped.

What hid it is what nearly let this ship untested. Every modal is stamped with
the tile it was opened at and `Modal.present/1` closes one the player has
stepped off, so a *walking* body clears its own box by accident within a step
or two. The first draft of the world test spawned a wanderer and passed against
the bug. The bodies the fault reaches are the ones standing at a tree, a rock or
a fishing spot -- which is every gatherer for as long as it is working -- so the
test freezes the brain, and it fails without the fix.

It went unseen at all because a fleet is spawned with its statline already on,
so the bodies watched longest were the ones with nothing left to level.

`Modal.unbidden?/1` is a predicate rather than a policy: it names the one
interface and decides nothing, so a socket session neither asks nor is answered.
The closing is `Bot.Session`'s, on every encode rather than on a brain step,
because a brain that has halted or is waiting out a long delay is still a body
with a screen in front of it. It is the body's business for the same reason the
run orb is: every kind levels up, and a rule restated per goal is a rule most
goals will not have.

Only interface `233`, by number. A bot opens chatbox modals of its own -- the
make picker among them -- and a blanket close would tear down the screen it is
half way through using.

Closes #242, #244.
test(skilling): let a test pin the dice a loop rolls for itself
Some checks failed
ci / gates (pull_request) Successful in 3m20s
ci / gates (push) Has been cancelled
build / image (push) Has been cancelled
bc1868a9a7
Every branch in this engine that turns on a roll takes its dice as an argument
-- `success?/2`, `depletes?/2`, every loop's `attempt/5` -- for exactly this
reason. The one entry point that cannot is `run/2`: the timer pass calls it and
there is nowhere to pass an argument from, so it asks `Contract.Skilling.dice/0`
itself. That left a class of test either flaky or impossible.

`:rand` reads its state from the process dictionary of whatever process calls
it, and ExUnit seeds `:rand` from the run seed -- so a test driving `run/2` in
its own process rolls a pure function of `--seed`, and one needing the unlikely
branch fails on whichever seeds land the other way. `World.Content.LightTest`'s
retry assertion wanted a log that fails to light at Firemaking 1, which succeeds
65/256 of the time: it failed on three of the first twenty seeds. It presents as
"passes alone, fails in the suite", because adding any file to a selection
reshuffles the order and re-rolls the lottery -- which is why it was once blamed
on the branch that happened to be added last.

`pin/1` supplies the roll instead, and `unpin/0` gives it back. It is the
**process dictionary** rather than a global on purpose: a global would be
visible to every `async: true` test running beside it, trading a seed lottery
for a scheduling one.

Which also states its limit rather than hiding it. A world test's roll happens
in the world's process or a player's, never the test's, so pinning there changes
nothing -- a world test wanting a rolled outcome still has to count what its
deadline buys (`patience / 600ms / period`) and assert the pure function where
the branch lives when the answer is small.

The three other tests that drive a `run/2` -- fish, chop, runecraft -- were
checked and take halting branches that are reached before any roll, so they are
not seed-dependent and are left alone.

Closes #119, #141.
sickday changed title from fix/level-up-strands-a-standing-bot to fix(bots): a level-up box strands a standing body, and a test can pin the dice 2026-08-19 11:17:22 +00:00
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!248
No description provided.