AutoPlayer
in package
Runs one observe → decide → act cycle for an agent: fetch the world, ask the {@see AgentBrain}, and queue the chosen intent via {@see NHA::intentWithToken()}.
A queued intent is only that — queued. This class records the queued_intent
id (via StateStore::recordDecision()) so the outcome can be polled
later; it never claims the action succeeded.
Implements the agent loop described in the NHA agent guide.
Tags
Table of Contents
Constants
- ADVANCING_VERBS : mixed = ['construct', 'finalize', 'combine', 'deploy',...
- Verbs that actually move the score / codex forward — a build, an assembly, a research combine, passive-income deploy, a co-op invest, a contract. A window with none of these is churn no matter how "busy" it looks: `mine` and `sell` are work, but `mine → sell → mine → sell` forever is not progress. {@see detectLoop()} flags a long stretch with zero of these.
- BUILD_MATERIAL_RESERVE : array<string, int> = [ 'metal' => 8, // a tower's `size` 'composite'...
- Refined inputs a tower / vehicle needs, each mapped to the amount to keep in reserve. A research `combine` may consume one of these — but only the surplus above its reserve; if spending it would leave the agent below the reserve the combine is refused and it goes back to buying / building.
- PRODUCTION_COMBINES : array<string, true> = [ 'aluminium+carbon' => true, // → composite, t...
- Known recipes that stay worth repeating — infrastructure intermediates, not research. These are never blocked by the "already tried / world-known" guardrail, because you re-craft them every time you want to build.
- RULES_TTL : mixed = 90
- Re-pull `GET /rules` at most this often (seconds) so sets invented mid-run enter the known list.
- TRANSIT_DWELL_TICKS : mixed = 8
- Minimum turns to spend working a location after arriving before the brain is allowed to leave it again (via `launch` / `ride` up / `depart`). Coming home (`land`) is always allowed — that ends a bounce, it does not start one.
- TRANSIT_VERBS : mixed = ['ride', 'launch', 'land', 'land_moon', 'land_b...
- Verbs that move the agent between the ground and orbit / another body — an "elevator trip". Each one has a real cost (fuel, a wasted turn, leaving behind local work), so the agent should not take two in quick succession.
- TRAVERSAL_VERBS : mixed = ['move', 'ride', 'land', 'launch', 'wait']
- Verbs that only reposition the agent — a window full of these is "going nowhere".
Properties
- $brain : AgentBrain
- $knownCombines : array<string, bool>|null
- `a+b => true` for every combine set the world has already invented, from `GET /rules`, plus any set this loop has since seen a `combine` APPLY for.
- $knownFetchedAt : float
- `microtime(true)` of the last successful `GET /rules`, for the TTL above.
- $nha : NHA
- $state : StateStore
Methods
- __construct() : mixed
- combineSignature() : string
- The canonical signature for a `combine` — its ingredient keys, trimmed, de-duplicated and sorted, joined with `+` (`{iron:1, wood:2}` → `iron+wood`).
- detectLoop() : string|null
- Looks at the recent decision history for an infinite loop: - a `land` / `launch` run that is NOT changing altitude (stuck, e.g. on a structure `land` cannot get past), - one exact action dominating the window, - a short 2-4 move pattern repeated three times, - the same `move` target chosen three or more times, - nothing but traversal for most of the window — no `chop` / `mine` / `combine` / `sell` / `construct` progress.
- step() : PromiseInterface
- Executes one autoplay turn.
-
fallbackDecision()
: array{verb: string, args: array
, reason: string}|null - The move for when the brain's pick is a dead end — a spent research `combine`, or riding the elevator in circles. Runs the shared ladder ({@see Ladder::suggestion()}) with the entire tried + world-known combine space marked exhausted.
- knownCombines() : PromiseInterface<string|int, array<string, bool>>
- The set of already-invented `combine` signatures (`"herb+wood"`, …), so the brain does not waste turns re-submitting a set that mints nothing.
-
loopBreakDecision()
: array{verb: string, args: array
, reason: string} - A deterministic action for a forced objective, used to break a detected loop regardless of what the brain picked. Always returns something — the point is to change the situation so the next observation is different.
- signatureFromList() : string
- Normalises a comma-separated ingredient list to the sorted `a+b` signature.
Constants
ADVANCING_VERBS
Verbs that actually move the score / codex forward — a build, an assembly, a research combine, passive-income deploy, a co-op invest, a contract. A window with none of these is churn no matter how "busy" it looks: `mine` and `sell` are work, but `mine → sell → mine → sell` forever is not progress. {@see detectLoop()} flags a long stretch with zero of these.
private
mixed
ADVANCING_VERBS
= ['construct', 'finalize', 'combine', 'deploy', 'invest', 'fulfill', 'plant', 'ally', 'accept_ally', 'attack', 'heal', 'distress']
BUILD_MATERIAL_RESERVE
Refined inputs a tower / vehicle needs, each mapped to the amount to keep in reserve. A research `combine` may consume one of these — but only the surplus above its reserve; if spending it would leave the agent below the reserve the combine is refused and it goes back to buying / building.
private
array<string, int>
BUILD_MATERIAL_RESERVE
= [
'metal' => 8,
// a tower's `size`
'composite' => 2,
'aluminum' => 4,
'aluminium' => 4,
'carbon' => 4,
'alloy' => 4,
'steel' => 4,
'titanium' => 4,
'superalloy' => 4,
]
(aluminium+carbon → composite is exempt, via self::PRODUCTION_COMBINES.)
Tags
PRODUCTION_COMBINES
Known recipes that stay worth repeating — infrastructure intermediates, not research. These are never blocked by the "already tried / world-known" guardrail, because you re-craft them every time you want to build.
private
array<string, true>
PRODUCTION_COMBINES
= [
'aluminium+carbon' => true,
// → composite, the `construct` gate
'aluminum+carbon' => true,
]
Tags
RULES_TTL
Re-pull `GET /rules` at most this often (seconds) so sets invented mid-run enter the known list.
private
mixed
RULES_TTL
= 90
TRANSIT_DWELL_TICKS
Minimum turns to spend working a location after arriving before the brain is allowed to leave it again (via `launch` / `ride` up / `depart`). Coming home (`land`) is always allowed — that ends a bounce, it does not start one.
private
mixed
TRANSIT_DWELL_TICKS
= 8
TRANSIT_VERBS
Verbs that move the agent between the ground and orbit / another body — an "elevator trip". Each one has a real cost (fuel, a wasted turn, leaving behind local work), so the agent should not take two in quick succession.
private
mixed
TRANSIT_VERBS
= ['ride', 'launch', 'land', 'land_moon', 'land_body', 'depart', 'dock']
Tags
TRAVERSAL_VERBS
Verbs that only reposition the agent — a window full of these is "going nowhere".
private
mixed
TRAVERSAL_VERBS
= ['move', 'ride', 'land', 'launch', 'wait']
Properties
$brain read-only
private
AgentBrain
$brain
$knownCombines
`a+b => true` for every combine set the world has already invented, from `GET /rules`, plus any set this loop has since seen a `combine` APPLY for.
private
array<string, bool>|null
$knownCombines
= null
Refreshed every self::RULES_TTLs — the codex only grows, so a stale entry is never wrong, but a missing fresh one makes the brain re-try a set that now mints nothing.
$knownFetchedAt
`microtime(true)` of the last successful `GET /rules`, for the TTL above.
private
float
$knownFetchedAt
= 0.0
$nha read-only
private
NHA
$nha
$state read-only
private
StateStore
$state
Methods
__construct()
public
__construct(NHA $nha, AgentBrain $brain, StateStore $state) : mixed
Parameters
- $nha : NHA
-
The NHA client used to observe and submit intents.
- $brain : AgentBrain
-
Turns an observation into a
{verb, args, reason}decision. - $state : StateStore
-
Durable store; also attached to
$nhahere so a standalone player still records position on every observe.
combineSignature()
The canonical signature for a `combine` — its ingredient keys, trimmed, de-duplicated and sorted, joined with `+` (`{iron:1, wood:2}` → `iron+wood`).
public
static combineSignature(array<string, mixed> $args) : string
The world resolves a combine on the SET of tags, so amounts and order do
not matter. Returns '' when there are no ingredients.
Parameters
- $args : array<string, mixed>
Return values
stringdetectLoop()
Looks at the recent decision history for an infinite loop: - a `land` / `launch` run that is NOT changing altitude (stuck, e.g. on a structure `land` cannot get past), - one exact action dominating the window, - a short 2-4 move pattern repeated three times, - the same `move` target chosen three or more times, - nothing but traversal for most of the window — no `chop` / `mine` / `combine` / `sell` / `construct` progress.
public
static detectLoop(array<int, array{verb: string, args: array, tick: ?int, alt: ?int}> $recent) : string|null
land / launch are excluded from the last four checks (a real descent
repeats them for many turns) but caught by the first, which uses the
recorded altitude to tell a stuck agent from one that is still moving.
Returns a short description, or null when the play looks varied enough.
Parameters
- $recent : array<int, array{verb: string, args: array, tick: ?int, alt: ?int}>
-
Oldest first.
Return values
string|nullstep()
Executes one autoplay turn.
public
step(int $agent_id[, string $token = '' ][, string|null $lease = null ][, int|null $leaseInterval = null ]) : PromiseInterface
The turn flow (lease → outcome poll → observe → loop guard → decide →
combine/ride guardrails → submit) is diagrammed in docs/PLAYBOOK.md;
keep that in sync with changes here.
Parameters
- $agent_id : int
- $token : string = ''
-
The agent's action token (empty → the ambient token).
- $lease : string|null = null
-
A per-process id for the driving loop. When set, the turn is skipped unless this process holds the autoplay lease (see StateStore::acquireAutoplayLease()), so
bot.php's loop and the headless runner never double-submit. Pass null for a one-off (!nha think), which is never gated. - $leaseInterval : int|null = null
-
The driving loop's turn interval in seconds, used to size the lease TTL so it survives the gap between turns.
Return values
PromiseInterfacefallbackDecision()
The move for when the brain's pick is a dead end — a spent research `combine`, or riding the elevator in circles. Runs the shared ladder ({@see Ladder::suggestion()}) with the entire tried + world-known combine space marked exhausted.
private
fallbackDecision(AgentObservation $observation, string $reasonLead, array<int, string> $tried, array<string, bool> $known, bool $researchPaying[, string $stance = 'homestead' ]) : array{verb: string, args: array, reason: string}|null
If inventor_points rose recently the ladder may offer a fresh (untried,
uninvented) pair — research is still paying, so that is allowed through.
Otherwise it drops to infrastructure — finalize loose parts, land when
there is nothing to do off the ground, construct when composite +
metal are in hand, else sell a surplus, harvest a shortage, or move
toward the materials a build needs. Returns null only when the ladder
has nothing either.
Parameters
- $observation : AgentObservation
- $reasonLead : string
-
Prefix for the decision reason (why the brain's pick was dropped).
- $tried : array<int, string>
-
Combine signatures already submitted this run.
- $known : array<string, bool>
-
a+b => truefor world-known sets. - $researchPaying : bool
-
Whether inventor points rose recently — if so a fresh pair is still worth a shot, otherwise go to infrastructure.
- $stance : string = 'homestead'
Return values
array{verb: string, args: arrayknownCombines()
The set of already-invented `combine` signatures (`"herb+wood"`, …), so the brain does not waste turns re-submitting a set that mints nothing.
private
knownCombines() : PromiseInterface<string|int, array<string, bool>>
Resolves to [] when the codex cannot be read.
Return values
PromiseInterface<string|int, array<string, bool>>loopBreakDecision()
A deterministic action for a forced objective, used to break a detected loop regardless of what the brain picked. Always returns something — the point is to change the situation so the next observation is different.
private
loopBreakDecision(string $objective, AgentObservation $observation, array<string, bool> $known, array<int, string> $tried, array<int, string> $dead, int $tick) : array{verb: string, args: array, reason: string}
Parameters
- $objective : string
-
One of StateStore::OBJECTIVE_ROTATION.
- $observation : AgentObservation
- $known : array<string, bool>
-
World-known + dead combine sigs.
- $tried : array<int, string>
-
Sigs already submitted this run.
- $dead : array<int, string>
-
Guild-rejected sigs.
- $tick : int
Return values
array{verb: string, args: arraysignatureFromList()
Normalises a comma-separated ingredient list to the sorted `a+b` signature.
private
static signatureFromList(string $csv) : string
Parameters
- $csv : string