DiscordPHP-NHA Documentation

StateStore
in package
uses AutoplayLeaseTrait, CombineMemoryTrait, DecisionLogTrait, IdentityStateTrait, LoopStrategyStateTrait, PositionStateTrait

Tiny JSON-file backed store for data that must survive a bot restart: the default agent id + token, the per-Discord-user identity map, each agent's last-known world position, the autoplay flag + driver lease, the brain's decision log, its `combine` / loop-guard / stance memory, and the registered slash-command signatures. Volatile per-tick world state (market, scene, feed, …) is deliberately NOT stored here — it is re-fetched live every time. Writes are atomic (temp file + rename).

The accessors are grouped into cohesive traits under State; this class is just the JSON file — load, the shared $data array, and the atomic save() they all call. No NHA API schema backs it; it is local bot state only. Attached to the client via NHA::setStateStore() so NHA::observe() can snapshot position for every caller.

Tags
since
3.0.0

Table of Contents

Constants

OBJECTIVE_ROTATION  : mixed = ['explore', 'wealth', 'build', 'research']
Objectives cycled through, in order, each time the agent is caught looping.
DEAD_COMBINES_CAP  : mixed = 5000
Cap on the confirmed-dead list per agent.
DECISION_LOG_CAP  : mixed = 24
How many past decisions the rolling history keeps.
FORCED_OBJECTIVE_TTL_TICKS  : mixed = 45
A forced objective sticks for this many world ticks after a loop break.
LOOP_BREAK_COOLDOWN_TICKS  : mixed = 24
After a loop break, do not break again for this many ticks — let it play out.
RESEARCH_PAYING_WINDOW  : mixed = 900
Research counts as "paying" for this long after the last inventor-point gain.
TRIED_COMBINES_CAP  : mixed = 2000
Cap on the "every set tried" list per agent.

Properties

$data  : array<string|int, mixed>
$path  : string

Methods

__construct()  : mixed
Loads the store from `$path` (an empty state when the file is missing) and sweeps any `.tmp` file left behind by a crash mid-{@see save()}.
acquireAutoplayLease()  : bool
Takes (or renews) the autoplay driver lease for `$holder`.
autoplayLeaseHolder()  : string|null
The id of the process currently holding a live autoplay lease, or null.
bumpForcedObjective()  : string
Advances the agent's forced-objective cursor one step through {@see self::OBJECTIVE_ROTATION} and returns the new objective. Called by {@see \NHA\Brain\AutoPlayer} when it detects the agent is stuck in a loop, so each successive loop break tries a *different* kind of goal.
clearQueuedIntent()  : void
Drops the stored `queued_intent` id from an agent's last decision once its outcome is settled (`applied` / `rejected`) or it has aged out (`gone`), so the autoplay loop stops issuing `GET /intent/{id}` for it every turn.
getAgentPosition()  : array{x: int, y: int, tick?: int, updated_at: int}|null
Gets an agent's last-known position, if one has been recorded.
getCommandSignatures()  : array<string, string>
The signature of the last successfully registered slash command by name.
getDeadCombines()  : array<int, string>
Every `combine` signature the Guild has rejected for this agent — sets proven to make nothing. Oldest first.
getDefaultAgent()  : int|null
The stored default agent id, or null when none has been registered.
getDefaultAgentToken()  : string|null
Gets the NHA action token for the default agent, if known.
getDiscordUserAgent()  : array{agent_id: int, name: string, token: string}|null
Gets the NHA identity assigned to a Discord user.
getForcedObjective()  : string|null
The objective forced by the most recent loop break, or `null` once it has aged out ({@see self::FORCED_OBJECTIVE_TTL_TICKS} ticks) — after which the agent is back on the normal ladder.
getLastDecision()  : array{verb: string, args: array, reason: string, queued_intent: ?int, tick: ?int, at: int}|null
Gets the brain's last recorded decision for an agent, if any.
getRecentDecisions()  : array<int, array{verb: string, args: array, tick: ?int}>
The agent's last few decisions, oldest first — a rolling window so the brain can detect a loop or an already-tried `combine` pair. Each entry is `{verb, args, tick}`; older/other fields are not kept here.
getStance()  : array{stance: string, tick: int}
The agent's persisted strategic stance and the tick it last changed (`{stance, tick}`). Defaults to `homestead` at tick 0.
getTriedCombineSignatures()  : array<int, string>
Every `combine` signature this agent has already submitted, oldest first.
isAutoplayEnabled()  : bool
Whether the autonomous LLM play loop is currently enabled. Persisted so a `!nha autoplay on` survives a restart.
leaseTtlForInterval()  : int
The lease TTL for a given autoplay interval: three intervals, floored at 45 seconds. A fixed TTL shorter than the interval expires in the gap between turns, so lease ownership ping-pongs between the two runners (harmless flapping, but noisy). Deriving it from the interval keeps the lease alive across the quiet stretch.
loopBreakCooldownActive()  : bool
Whether a loop break happened too recently to break again — the forced objective (and the brain turns after it) need a few ticks to actually change the situation before the loop detector is allowed to fire once more.
noteInventorPoints()  : bool
Records the agent's current lifetime `inventor_points` and reports whether research is paying *right now* — the score rose this turn, or rose within the last {@see self::RESEARCH_PAYING_WINDOW} seconds.
peekNextForcedObjective()  : string
The objective {@see bumpForcedObjective()} WOULD return next, without advancing the cursor or arming the cooldown — so the loop guard can name the objective in the brain prompt and only commit it once the turn actually applies it.
recordCombineSignature()  : void
Records that a `combine` set — identified by its sorted `"a+b"` signature — has been submitted for this agent, so {@see \NHA\Brain\AutoPlayer} can refuse to resubmit it (the world mints nothing for a repeat). Kept as a capped, de-duplicated list that also survives a restart.
recordDeadCombine()  : void
Records a `combine` set the world has PROVEN cannot make anything — the Inventors' Guild rejected the submission. Unlike {@see recordCombineSignature()} (every set the agent tried), this is the confirmed-dead subset: it is never worth another intent, ever, so {@see \NHA\Brain\AutoPlayer} treats it like a world-known recipe and never lets it through — not even a production recipe.
recordDecision()  : void
Records the brain's most recent decision for an agent (verb, args, the one-line rationale and the `queued_intent` id it produced), so a later command can show "what did the bot last do, and did it land?".
recordObservation()  : void
Snapshots the position + tick from a fresh observation. No-op when the payload carries no position. Called for every {@see \NHA\NHA::observe()} once the store is attached via {@see \NHA\NHA::setStateStore()}.
releaseAutoplayLease()  : void
Drops the lease if `$holder` currently holds it (call on clean shutdown).
setAgentPosition()  : void
Records an agent's last-known world position (from `GET /observe/:id`), so a later turn can show it without a fresh fetch or detect that the agent has moved. Written under `agent_positions` keyed by agent id.
setAutoplay()  : void
Turns the autonomous LLM play loop on or off.
setCommandSignature()  : void
Records that `$name` was registered with definition signature `$hash`.
setDefaultAgent()  : void
Sets the default agent id and, when known, its NHA action token.
setDiscordUserAgent()  : void
Saves a Discord user's NHA identity for later turns.
setStance()  : void
Records the agent's stance. `$tick` is only stamped when the stance actually changes, so it marks the last *switch* for the dwell timer.
save()  : void
Atomically persists the current state: writes a sibling temp file then renames it over the target, so a crash (or SIGTERM) mid-write can never leave a truncated file. Silently no-ops if the temp write fails.
withLeaseLock()  : T
Runs `$fn` while holding an exclusive OS lock on a sibling `.lease.lock` file, with `$this->data` first re-read from disk so `$fn` sees the lease exactly as other processes last left it — and, on {@see save()}, does not clobber unrelated keys another process wrote in the meantime.

Constants

OBJECTIVE_ROTATION

Objectives cycled through, in order, each time the agent is caught looping.

public mixed OBJECTIVE_ROTATION = ['explore', 'wealth', 'build', 'research']

DEAD_COMBINES_CAP

Cap on the confirmed-dead list per agent.

private mixed DEAD_COMBINES_CAP = 5000

DECISION_LOG_CAP

How many past decisions the rolling history keeps.

private mixed DECISION_LOG_CAP = 24

FORCED_OBJECTIVE_TTL_TICKS

A forced objective sticks for this many world ticks after a loop break.

private mixed FORCED_OBJECTIVE_TTL_TICKS = 45

LOOP_BREAK_COOLDOWN_TICKS

After a loop break, do not break again for this many ticks — let it play out.

private mixed LOOP_BREAK_COOLDOWN_TICKS = 24

RESEARCH_PAYING_WINDOW

Research counts as "paying" for this long after the last inventor-point gain.

private mixed RESEARCH_PAYING_WINDOW = 900

TRIED_COMBINES_CAP

Cap on the "every set tried" list per agent.

private mixed TRIED_COMBINES_CAP = 2000

Properties

Methods

__construct()

Loads the store from `$path` (an empty state when the file is missing) and sweeps any `.tmp` file left behind by a crash mid-{@see save()}.

public __construct(string $path) : mixed
Parameters
$path : string

Absolute path to the JSON state file.

acquireAutoplayLease()

Takes (or renews) the autoplay driver lease for `$holder`.

public acquireAutoplayLease(string $holder[, int|null $interval = null ]) : bool

Only one process should drive an agent's autoplay loop at a time. Each loop passes a stable per-process holder id; the first to acquire the lease drives, the others skip their turn until it expires (a crashed driver frees it within the TTL).

The acquire/release is done under an exclusive OS lock (see withLeaseLock()), so two runners starting at the same instant cannot both observe "no lease" and both claim it — it is a real compare-and-swap, not just an atomic file write.

Parameters
$holder : string

A stable id for the calling loop, e.g. "bot.php:1234".

$interval : int|null = null

The loop's turn interval in seconds. The lease TTL is derived from it (leaseTtlForInterval()) so the lease outlives the gap between turns; null → the 45s floor.

Return values
bool

True when $holder now holds the lease (it was free, expired, or already theirs); false when another holder's lease is live.

autoplayLeaseHolder()

The id of the process currently holding a live autoplay lease, or null.

public autoplayLeaseHolder() : string|null
Return values
string|null

bumpForcedObjective()

Advances the agent's forced-objective cursor one step through {@see self::OBJECTIVE_ROTATION} and returns the new objective. Called by {@see \NHA\Brain\AutoPlayer} when it detects the agent is stuck in a loop, so each successive loop break tries a *different* kind of goal.

public bumpForcedObjective(int $agent_id, int $tick) : string
Parameters
$agent_id : int
$tick : int
Tags
since
3.1.12
Return values
string

clearQueuedIntent()

Drops the stored `queued_intent` id from an agent's last decision once its outcome is settled (`applied` / `rejected`) or it has aged out (`gone`), so the autoplay loop stops issuing `GET /intent/{id}` for it every turn.

public clearQueuedIntent(int $agent_id[, int|null $only = null ]) : void
Parameters
$agent_id : int
$only : int|null = null

When given, only clears the id if it still matches — a no-op if recordDecision() has since stored a newer intent, avoiding a lost update.

Tags
since
3.1.5

getAgentPosition()

Gets an agent's last-known position, if one has been recorded.

public getAgentPosition(int $agent_id) : array{x: int, y: int, tick?: int, updated_at: int}|null
Parameters
$agent_id : int
Return values
array{x: int, y: int, tick?: int, updated_at: int}|null

getCommandSignatures()

The signature of the last successfully registered slash command by name.

public getCommandSignatures() : array<string, string>

Used to decide whether a command's definition changed since the last boot and needs pushing to Discord (a rate-limited write).

Return values
array<string, string>

command name => sha1(definition)

getDeadCombines()

Every `combine` signature the Guild has rejected for this agent — sets proven to make nothing. Oldest first.

public getDeadCombines(int $agent_id) : array<int, string>
Parameters
$agent_id : int
Tags
since
3.1.11
Return values
array<int, string>

getDefaultAgent()

The stored default agent id, or null when none has been registered.

public getDefaultAgent() : int|null
Return values
int|null

getDefaultAgentToken()

Gets the NHA action token for the default agent, if known.

public getDefaultAgentToken() : string|null
Return values
string|null

getDiscordUserAgent()

Gets the NHA identity assigned to a Discord user.

public getDiscordUserAgent(string $discord_user_id) : array{agent_id: int, name: string, token: string}|null
Parameters
$discord_user_id : string
Return values
array{agent_id: int, name: string, token: string}|null

getForcedObjective()

The objective forced by the most recent loop break, or `null` once it has aged out ({@see self::FORCED_OBJECTIVE_TTL_TICKS} ticks) — after which the agent is back on the normal ladder.

public getForcedObjective(int $agent_id, int $tick) : string|null
Parameters
$agent_id : int
$tick : int
Tags
since
3.1.12
Return values
string|null

getLastDecision()

Gets the brain's last recorded decision for an agent, if any.

public getLastDecision(int $agent_id) : array{verb: string, args: array, reason: string, queued_intent: ?int, tick: ?int, at: int}|null
Parameters
$agent_id : int
Return values
array{verb: string, args: array, reason: string, queued_intent: ?int, tick: ?int, at: int}|null

getRecentDecisions()

The agent's last few decisions, oldest first — a rolling window so the brain can detect a loop or an already-tried `combine` pair. Each entry is `{verb, args, tick}`; older/other fields are not kept here.

public getRecentDecisions(int $agent_id[, int $limit = 10 ]) : array<int, array{verb: string, args: array, tick: ?int}>
Parameters
$agent_id : int
$limit : int = 10
Return values
array<int, array{verb: string, args: array, tick: ?int}>

getStance()

The agent's persisted strategic stance and the tick it last changed (`{stance, tick}`). Defaults to `homestead` at tick 0.

public getStance(int $agent_id) : array{stance: string, tick: int}
Parameters
$agent_id : int
Tags
since
3.1.23
Return values
array{stance: string, tick: int}

getTriedCombineSignatures()

Every `combine` signature this agent has already submitted, oldest first.

public getTriedCombineSignatures(int $agent_id) : array<int, string>
Parameters
$agent_id : int
Tags
since
3.1.6
Return values
array<int, string>

isAutoplayEnabled()

Whether the autonomous LLM play loop is currently enabled. Persisted so a `!nha autoplay on` survives a restart.

public isAutoplayEnabled() : bool
Return values
bool

leaseTtlForInterval()

The lease TTL for a given autoplay interval: three intervals, floored at 45 seconds. A fixed TTL shorter than the interval expires in the gap between turns, so lease ownership ping-pongs between the two runners (harmless flapping, but noisy). Deriving it from the interval keeps the lease alive across the quiet stretch.

public static leaseTtlForInterval(int|null $interval) : int
Parameters
$interval : int|null
Return values
int

loopBreakCooldownActive()

Whether a loop break happened too recently to break again — the forced objective (and the brain turns after it) need a few ticks to actually change the situation before the loop detector is allowed to fire once more.

public loopBreakCooldownActive(int $agent_id, int $tick) : bool

Without this the loop-break moves themselves keep the "no productive action" window full and it thrashes every turn.

Parameters
$agent_id : int
$tick : int
Tags
since
3.1.14
Return values
bool

noteInventorPoints()

Records the agent's current lifetime `inventor_points` and reports whether research is paying *right now* — the score rose this turn, or rose within the last {@see self::RESEARCH_PAYING_WINDOW} seconds.

public noteInventorPoints(int $agent_id, int $points) : bool

Inventor points never drop (a rejected invention only refunds ingredients), so an absolute > 0 test stays true forever once an agent has invented anything. The autoplay fallback needs the recent trend instead: keep speculating while discoveries are still landing, fall to infrastructure once they dry up. The first sighting only sets the baseline and returns false (no trend yet).

Parameters
$agent_id : int
$points : int
Tags
since
3.1.9
Return values
bool

peekNextForcedObjective()

The objective {@see bumpForcedObjective()} WOULD return next, without advancing the cursor or arming the cooldown — so the loop guard can name the objective in the brain prompt and only commit it once the turn actually applies it.

public peekNextForcedObjective(int $agent_id) : string
Parameters
$agent_id : int
Tags
since
3.1.21
Return values
string

recordCombineSignature()

Records that a `combine` set — identified by its sorted `"a+b"` signature — has been submitted for this agent, so {@see \NHA\Brain\AutoPlayer} can refuse to resubmit it (the world mints nothing for a repeat). Kept as a capped, de-duplicated list that also survives a restart.

public recordCombineSignature(int $agent_id, string $signature) : void
Parameters
$agent_id : int
$signature : string
Tags
since
3.1.6

recordDeadCombine()

Records a `combine` set the world has PROVEN cannot make anything — the Inventors' Guild rejected the submission. Unlike {@see recordCombineSignature()} (every set the agent tried), this is the confirmed-dead subset: it is never worth another intent, ever, so {@see \NHA\Brain\AutoPlayer} treats it like a world-known recipe and never lets it through — not even a production recipe.

public recordDeadCombine(int $agent_id, string $signature) : void
Parameters
$agent_id : int
$signature : string
Tags
since
3.1.11

recordDecision()

Records the brain's most recent decision for an agent (verb, args, the one-line rationale and the `queued_intent` id it produced), so a later command can show "what did the bot last do, and did it land?".

public recordDecision(int $agent_id, array<string, mixed> $decision) : void
Parameters
$agent_id : int
$decision : array<string, mixed>

Expects keys: verb, args, reason, queued_intent, tick.

recordObservation()

Snapshots the position + tick from a fresh observation. No-op when the payload carries no position. Called for every {@see \NHA\NHA::observe()} once the store is attached via {@see \NHA\NHA::setStateStore()}.

public recordObservation(int $agent_id, AgentObservation $observation) : void
Parameters
$agent_id : int
$observation : AgentObservation

releaseAutoplayLease()

Drops the lease if `$holder` currently holds it (call on clean shutdown).

public releaseAutoplayLease(string $holder) : void
Parameters
$holder : string

setAgentPosition()

Records an agent's last-known world position (from `GET /observe/:id`), so a later turn can show it without a fresh fetch or detect that the agent has moved. Written under `agent_positions` keyed by agent id.

public setAgentPosition(int $agent_id, int $x, int $y[, int|null $tick = null ]) : void
Parameters
$agent_id : int
$x : int
$y : int
$tick : int|null = null

The observation tick, when known.

setAutoplay()

Turns the autonomous LLM play loop on or off.

public setAutoplay(bool $enabled) : void
Parameters
$enabled : bool

setCommandSignature()

Records that `$name` was registered with definition signature `$hash`.

public setCommandSignature(string $name, string $hash) : void
Parameters
$name : string
$hash : string

setDefaultAgent()

Sets the default agent id and, when known, its NHA action token.

public setDefaultAgent(int $agent_id[, string|null $token = null ]) : void

Passing null for $token leaves any previously stored token intact, so callers that only know the agent id don't clobber it.

Parameters
$agent_id : int
$token : string|null = null

setDiscordUserAgent()

Saves a Discord user's NHA identity for later turns.

public setDiscordUserAgent(string $discord_user_id, int $agent_id, string $name, string $token) : void
Parameters
$discord_user_id : string
$agent_id : int
$name : string
$token : string

setStance()

Records the agent's stance. `$tick` is only stamped when the stance actually changes, so it marks the last *switch* for the dwell timer.

public setStance(int $agent_id, string $stance, int $tick) : void
Parameters
$agent_id : int
$stance : string
$tick : int
Tags
since
3.1.23

save()

Atomically persists the current state: writes a sibling temp file then renames it over the target, so a crash (or SIGTERM) mid-write can never leave a truncated file. Silently no-ops if the temp write fails.

protected save() : void

withLeaseLock()

Runs `$fn` while holding an exclusive OS lock on a sibling `.lease.lock` file, with `$this->data` first re-read from disk so `$fn` sees the lease exactly as other processes last left it — and, on {@see save()}, does not clobber unrelated keys another process wrote in the meantime.

private withLeaseLock(callable(): T $fn) : T

The re-read is only adopted when it decodes to a non-empty array. An empty or truncated state file (full disk, interrupted first write, a hand-edit) would otherwise become [], and the next save() would persist a file holding nothing but the lease — dropping the agent token, which the NHA server issues exactly once. A stale in-memory read is the safe failure.

Degrades to running $fn unlocked (the old best-effort read-modify-write) when the lock file can't be opened: a rare doubled interval beats a loop that never drives.

Parameters
$fn : callable(): T
Tags
template
Return values
T
On this page

Search results