feat: bots — the framework, five kinds, and the operator surface #115

Merged
sickday merged 11 commits from feat/bots into main 2026-08-04 19:28:17 +00:00
Owner

The world gains inhabitants that are not people. First slice of the bot
system: the framework whole, five kinds, and the operator surface — with
everything that needs content this server does not have yet deliberately
left out.

A bot is the engine's fourth seat

The engine already defines a player as whatever owns a World.Player.Host
and answers the tick barrier
, three times over — the socket session, the
test host, the bench fleet. Bot.Session is the fourth: it joins from its
own process, answers {:world_tick, ...} immediately, runs the discarded
encode so masks and buffered requests settle, and leaves through
World.leave/1 with the departure announced first. No engine changes.

The brain lives in the same process, stepped from the encode handler
after the barrier reply is already sent — goal work can never stall the
world, and a goal's view of its body is host.player, free. Goals are pure
state machines: their one effect is emit, which plans a client-shaped
request through World.RoutePlan and feeds it into the session's own
dispatch, so a bot's click is indistinguishable from a packet's and every
gate the engine has applies. Under test, emit collects into a list.

What is in

  • Kinds: wander, travel (bank-to-bank sightseer), chopper and
    miner (one shared GatherAndBank goal — the loop is a shape; the site
    table, the tool family and the gating stat are data), and fighter
    (scans the movement authority, skips creatures whose combat claim names
    somebody else, clicks the cache's own Attack option, and treats dying as
    part of the loop — it carries nothing beyond the kept-items rule).
  • Sites derived from the cache at boot, not typed in: trees from the
    woodcutting table ∩ the placement index, rocks via mining, banks by the
    Bank verb, clustered and paired with the nearest same-plane bank.
  • The real bank: gatherers deposit with one Deposit-All click per
    distinct non-tool id through the side inventory, verify the pack emptied
    (the success path is as silent as the refusals for a socketless player),
    and keep the tool without a withdraw round trip.
  • Persistence: bot_profiles (no account row — a bot's seat keeps
    account.id nil, which every player-persistence path already reads as
    do not save), a full snapshot at spawn, staggered autosave, a final
    save on terminate, boot auto-spawn in batches, and a name reservation in
    both directions — registration, setup renames and the pre-check all
    refuse a name a bot profile holds.
  • Recovery that never destroys: movement-tied stuck detection (a
    resting bot is never stuck), capped reroutes, and a botfix sweep that
    relocates a wedged bot to open ground. Nothing despawns a bot to fix it.
  • 13 commands under a new bot.* group — ::botgen with
    order-independent arguments that refuses an unknown kind outright,
    ::bots/::botinfo/::botprofiles, and the
    kill/delete/clear/cleardb/save/toggle/fix/freeze surface. All mutations
    are casts: a command runs inside a barrier participant.
  • Config-gated like the panel: REVENANT_BOTS=off removes the subtree;
    the fleet cap defaults to 200.

What is deliberately out

PK bots (want PvP, Prayer and eating), traders and the virtual economy
(the real bank does its job here), production goals, fishers (no Fishing
yet), loadout tables (id-heavy, to be re-derived from this cache when
combat archetypes need them), and hibernation (every bot is a fully costed
barrier participant; the cap is the control until a real need arrives).
#111 tracks the unwired log filter; all bot logging is already tagged
domain: [:bot].

Verified

Format, compile with warnings as errors, Credo strict and Dialyzer all
green. World tests drive the real thing end to end: a chopper chops a real
tree, walks to its derived bank, deposits and keeps the axe; a miner does
the same; a fighter engages a spawned goblin and the world's combat claim
names the bot; botfix relocates a bot wedged in a wall; auto-spawn raises
active profiles with their stats and leaves inactive ones down; the
commands drive all of it through a seated session's own dispatch. A full
suite pass is green; the long runs also caught what the targeted ones
could not — a migration version collision with the exchange table (bot
profiles renumbered to 18), and the gather tests learning the same
site-restoration teardown the mining world test documents.

The world gains inhabitants that are not people. First slice of the bot system: the framework whole, five kinds, and the operator surface — with everything that needs content this server does not have yet deliberately left out. ## A bot is the engine's fourth seat The engine already defines a player as *whatever owns a `World.Player.Host` and answers the tick barrier*, three times over — the socket session, the test host, the bench fleet. `Bot.Session` is the fourth: it joins from its own process, answers `{:world_tick, ...}` immediately, runs the discarded encode so masks and buffered requests settle, and leaves through `World.leave/1` with the departure announced first. No engine changes. The brain lives **in the same process**, stepped from the encode handler after the barrier reply is already sent — goal work can never stall the world, and a goal's view of its body is `host.player`, free. Goals are pure state machines: their one effect is `emit`, which plans a client-shaped request through `World.RoutePlan` and feeds it into the session's own dispatch, so a bot's click is indistinguishable from a packet's and every gate the engine has applies. Under test, `emit` collects into a list. ## What is in - **Kinds**: `wander`, `travel` (bank-to-bank sightseer), `chopper` and `miner` (one shared `GatherAndBank` goal — the loop is a shape; the site table, the tool family and the gating stat are data), and `fighter` (scans the movement authority, skips creatures whose combat claim names somebody else, clicks the cache's own Attack option, and treats dying as part of the loop — it carries nothing beyond the kept-items rule). - **Sites derived from the cache at boot**, not typed in: trees from the woodcutting table ∩ the placement index, rocks via mining, banks by the `Bank` verb, clustered and paired with the nearest same-plane bank. - **The real bank**: gatherers deposit with one Deposit-All click per distinct non-tool id through the side inventory, verify the pack emptied (the success path is as silent as the refusals for a socketless player), and keep the tool without a withdraw round trip. - **Persistence**: `bot_profiles` (no account row — a bot's seat keeps `account.id` nil, which every player-persistence path already reads as *do not save*), a full snapshot at spawn, staggered autosave, a final save on terminate, boot auto-spawn in batches, and a name reservation in both directions — registration, setup renames and the pre-check all refuse a name a bot profile holds. - **Recovery that never destroys**: movement-tied stuck detection (a resting bot is never stuck), capped reroutes, and a botfix sweep that relocates a wedged bot to open ground. Nothing despawns a bot to fix it. - **13 commands** under a new `bot.*` group — `::botgen` with order-independent arguments that refuses an unknown kind outright, `::bots`/`::botinfo`/`::botprofiles`, and the kill/delete/clear/cleardb/save/toggle/fix/freeze surface. All mutations are casts: a command runs inside a barrier participant. - Config-gated like the panel: `REVENANT_BOTS=off` removes the subtree; the fleet cap defaults to 200. ## What is deliberately out PK bots (want PvP, Prayer and eating), traders and the virtual economy (the real bank does its job here), production goals, fishers (no Fishing yet), loadout tables (id-heavy, to be re-derived from this cache when combat archetypes need them), and hibernation (every bot is a fully costed barrier participant; the cap is the control until a real need arrives). #111 tracks the unwired log filter; all bot logging is already tagged `domain: [:bot]`. ## Verified Format, compile with warnings as errors, Credo strict and Dialyzer all green. World tests drive the real thing end to end: a chopper chops a real tree, walks to its derived bank, deposits and keeps the axe; a miner does the same; a fighter engages a spawned goblin and the world's combat claim names the bot; botfix relocates a bot wedged in a wall; auto-spawn raises active profiles with their stats and leaves inactive ones down; the commands drive all of it through a seated session's own dispatch. A full suite pass is green; the long runs also caught what the targeted ones could not — a migration version collision with the exchange table (bot profiles renumbered to 18), and the gather tests learning the same site-restoration teardown the mining world test documents.
The goal behaviour contract, human-pacing delay generators, the perception
snapshot, the emit-based action vocabulary, era-flavoured name generation
with a three-way reservation, experience-first stat seeding, appearance
rolls inside the cache's own bounds, and the bot_profiles table with pure
converters both directions.

Names are reserved in both directions: a bot cannot take a live session's
or an account's name, and account registration, setup renames and the
name pre-check all refuse a name a bot profile holds.
A bot is the engine's fourth seat: a GenServer owning a World.Player.Host,
joining from its own process, answering the barrier immediately and running
the discarded encode so masks and buffered requests settle. Body and brain
share one process — the brain will step after the encode, past the barrier
reply, which deletes the reference design's cross-process cast vocabulary
and its mismatch bug class whole.

The manager is cast-only (commands run inside barrier participants), holds
no authority a crash would lose, and never despawns a bot to fix it. The
roster is a public ETS set read lock-free by the commands. The subtree is
config-gated like the panel, REVENANT_BOTS=off included.
The goal runner ported as pure step logic the session drives after each
encode: backoff ladder, five-failure cascade into non-destructive recovery
anchored at the task's hub, death resume through on_death, chain looping.
Travel keeps only what the engine cannot absorb — movement-tied stuck
detection, capped reroutes, long-leg hops — because a single walk request
plans up to 128 steps here.

Sites replace the reference's fourteen hundred hand-typed coordinates with
a boot-time derivation: trees from the woodcutting table, rocks from
mining, banks by the Bank verb, clustered and paired with their nearest
bank. Wander and Travel are the first consumers; a wanderer provably moves
on its own against the real world.
GatherAndBank is a single config-driven goal for woodcutting and mining,
because the loop is the same shape and only the data differs: which site
table, which tool family stays out of the deposit, which stat gates a
node. It acts purely through client-shaped requests — the engine routes,
gates and arms the skilling clock — and watches outcomes, re-clicking
through a grace because every refusal is silent to a socketless bot.

The bank leg opens the site's derived booth, deposits one Deposit-All
click per distinct non-tool id through the side inventory, verifies the
pack emptied (the success path is silent too), and closes. Verified live
both ways: a chopper and a miner each worked a real node, walked to the
derived bank, banked the haul and kept the tool.
HuntNpcs scans the movement authority for neighbours, filters through the
tick snapshot — skipping creatures whose fighting claim names somebody
else, which is cheaper than discovering the silent refusal — resolves the
Attack option index from the cache, and emits the same npc_action a
right-click sends. The engine walks the pursuit and owns the fight; the
goal holds while in combat and re-scans through a grace when nothing
starts. Death walks the respawned bot back to its grounds; fighters are
seeded with nothing beyond the kept-items rule, so dying loses nothing.

Verified live: a fighter found a spawned goblin, the world's combat claim
named the bot, and the goblin bled.
Persistence closes its loop: a full snapshot at spawn, a staggered
autosave through the manager's task supervisor, a final save in the
session's own terminate, and a boot auto-spawn that raises active
profiles in batches — checked against live seats only, because a
persisted bot's own profile row must not read as a name collision. The
botfix sweep relocates in place and never despawns; the config column
learned to carry position tuples and atom values, which the first spawn
snapshot proved JSON alone could not.

Thirteen commands under a new bot.* permission group: botgen with
order-independent arguments that refuses an unknown kind outright, the
roster reads, and the despawn/save/toggle/freeze surface — every
mutation a cast, because a command runs inside a barrier participant.
None of the names collide with the client-intercepted command list.
Dialyzer proves the goal contract's four transitions are the whole type,
so the catch-all could never match — and the tree's own rule is that no
catch-all saves you anyway.
Main grew a migration with the same 20260804000017 version while this
branch was in flight, and Ecto treats an applied version number as an
applied migration — so whichever of the two a database met second was
silently skipped. The bot profiles table takes the next number.
A trip leaves a stump or an empty rock whose respawn is owed to a queue
that outlives the test, and the leftover LocChanges row reaches every
later test that asserts the change count — the exact teardown trap the
mining world test documents. Each gather test now restores its own
site's nodes and empties both the queue and the change table.
The headless host now hands back the joining tick alongside the slot and
position; the commands test matches the new shape.
test(bot): give the gather round trips headroom over the suite ceiling
All checks were successful
ci / gates (pull_request) Successful in 54s
build / image (push) Successful in 31s
ci / gates (push) Successful in 55s
478e4eab2c
Their ninety-second patience sat above ExUnit's sixty-second default, so
a loaded machine could time the test out mid-walk with the loop working
fine. The module states the ceiling its patience implies.
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!115
No description provided.