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
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
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
$data
protected
array<string|int, mixed>
$data
$path read-only
protected
string
$path
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|nullbumpForcedObjective()
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
Return values
stringclearQueuedIntent()
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
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}|nullgetCommandSignatures()
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
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|nullgetDefaultAgentToken()
Gets the NHA action token for the default agent, if known.
public
getDefaultAgentToken() : string|null
Return values
string|nullgetDiscordUserAgent()
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}|nullgetForcedObjective()
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
Return values
string|nullgetLastDecision()
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}|nullgetRecentDecisions()
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
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
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
boolleaseTtlForInterval()
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
intloopBreakCooldownActive()
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
Return values
boolnoteInventorPoints()
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
Return values
boolpeekNextForcedObjective()
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
Return values
stringrecordCombineSignature()
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
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
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
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