Skip to content

Urges

What an actor keeps asking for, said out loud. An urge is a recurring want held by any agent — a lifeform, a station, a side: a condition, a cadence, a pool of authored lines, and optionally an Action:. One shared ticker walks every actor that has urges, picks at most one, and says it.

## [DS1 calls for resupply](ds1_calling)
---
Urge
Actor: DS1
Whenever: quest ds1_resupply active
Every: 5m
Escalates: with deadline
---
% DS1 requests a resupply run when someone has the tonnage.
%% DS1 is below reserve. We need that shipment.
%%% DS1 going to minimal power. Nobody is coming, are they.

An urge carries no stakes of its own

The consequence belongs to the quest it watches. A quest already has a deadline (Fails when: after 30m) and a consequence (Reward: / Penalty: / Then:) — what it never had was a voice, so it counted down in silence.

That split is the whole design: one clock, one place to tune, and deleting an urge costs the drama but not the mechanics. Give the quest to the thing it is about — a station's resupply job is Held by: ds1 — and the penalty lands on the world rather than on a passing crew.

Fields

Field Meaning
Actor: who speaks — a declared landmark key, or a role
Whenever: the recurring condition; true = eligible
Every: minimum gap between firings. 5m, or 3-5m to jitter
Until: retire permanently when true (optional)
Weight: which of this actor's urges wins; 90+ is urgent
Escalates: with deadline (stage from the bound quest's clock) or yes
Title: the comms card header; defaults to the speaker's name
Action: optional stage directions — the same grammar beats use

The body is the line pool: one line per entry, % markers giving the stage.

Escalation is the countdown

Escalates: with deadline takes the stage from how much of the bound quest's clock has gone, so the drama curve is the countdown that already exists. The bound quest is the one named in Whenever: — there is no second field to keep in agreement.

The marker count is the curve and Fails when: is the tempo:

% Doctor Voss is on the docking ring, if anyone is bound for the Verdant worlds.
%% Voss again. My window at the Verdant site closes, captain.
%%% Last call. I need to be on a hull today or not at all.

The speech budget

The thing that decides whether autonomous speech is pleasant or unbearable is not cleverness, it is restraint. Three floors, cheapest first:

Gate Default Why
per-urge Every: authored this specific thing should not repeat
per-actor ~45s one actor should not monologue across its own urges
global ~20s five actors should not pile up after a jump

A Weight: 90+ urge bypasses the global floor only — an actor leaving forever outranks politeness toward other speakers, but not its own self-restraint. The global clock is shared with announce, because an announcement and an urge are the same thing from the bridge's side: an unprompted voice. A player hailing a station is deliberately not counted — that is traffic the player asked for, and counting it would starve autonomous speech exactly when the crew is busy.

Where the voice goes

Reach follows hosting, so nothing has to declare it:

Actor Surface
hosted on a player ship an internal crew message, with the actor's own face
hosted elsewhere a comms message from the host to the player ships
unhosted a galaxy-wide comms message from the actor

What an urge is not

  • Not a brain. A behavior tree re-decides continuously because the world moved; an urge's world changes on clocks and events. Grid lifeforms (damcons) keep their brains — they have position and paths and are correctly a tree.
  • Not a goal selector. Weight: arbitrates one actor's own urges — the same fixed-priority fallback every brain root already does. It never arbitrates between two actors.
  • It never invents words. An urge selects among authored lines.

Installing

urges_install(section) resolves each record's Actor: by name — right for a cast declared up front. urges_install_on(agent, section, key) is the identity path, for when you already hold the character (you just spawned or boarded them).

API

Urges - what an actor keeps asking for, said out loud.

An urge is a recurring want held by any agent (a lifeform, a station, a side): a condition, a cooldown, a pool of authored lines, and optionally an Action: block. One shared ticker walks every agent that has urges, picks at most one, and says it.

## [DS1 calls for resupply](ds1_calling)
---
Urge
Actor: DS1
Whenever: quest ds1_resupply active
Every: 5m
---
% DS1 requests a resupply run when someone has the tonnage.
% DS1 is below reserve. We need that shipment.

An urge carries no stakes of its own. The consequence belongs to the quest it watches (Fails when: after 30m, Penalty:), so there is one deadline and one place to tune, and deleting an urge costs the drama but not the mechanics. See DESIGN_RECORD.md s4.

Not a brain. A behavior tree re-decides continuously because the world moved; an urge's world changes on clocks and events. A brain node spawns a MAST task per node per pass; an urge evaluates a condition. Grid lifeforms (damcons) keep their brains - they have position and paths, and are correctly a tree.

Not a goal selector. Weight: arbitrates one actor's OWN urges - the same fixed-priority fallback every brain root already does. It never arbitrates between two actors; that is the contest DESIGN_RECORD.md s5 surveyed and rejected.

urge_actors()

Every agent holding urges, as a LIST.

A list, not the live set: running an urge's Action: can give another agent its first urge, and mutating the registry mid-iteration raises "Set changed size during iteration" - the lesson brain.py s396-400 already paid for.

urge_add(agents, record)

Give one urge to one or more agents. Idempotent per (agent, urge key): re-running a section does not give an actor the same want twice.

urge_bound_quest(rec)

The quest id this urge watches, read out of its Whenever:, or None.

No separate field: the bound quest IS the one the urge is conditional on, and a second field would be one more thing to keep in agreement with the first.

urge_budget_allows(actor_id, state, now=None)

Whether this actor may speak right now.

A refusal here must NOT stamp the urge's cooldown - the caller retries next pass, so a floor never costs an urge its turn. (A speech FAILURE is the opposite case and does stamp; see urge_run_one.)

urge_budget_reset()

Drop the per-actor speech clocks (called by reset_mission_state).

urge_clear(agents)

Drop every urge from one or more agents (they stop being visited).

urge_clear_conditions()

Test-only: drop every registered condition (then re-install the built-ins).

urge_condition_eval(actor_id, text)

Evaluate a Whenever: / Until: line. Unknown phrasing is FALSE and logged - an urge nobody can trigger is a bug, and a silently-true one would talk forever.

A leading not negates, so the vocabulary does not need a second phrase for every inverse.

urge_conditions()

Every registered phrase, longest first - the order the parser matches in.

urge_deadline_fraction(rec)

How much of the bound quest's clock is GONE, 0.0 -> 1.0, or None if there is no deadline to read.

Uses the same timer quest_tick_fail_after anchors (qfail:<id>), so the urge's escalation and the quest's failure are reading one clock rather than two that can disagree. Before the watcher anchors it, nothing has elapsed - which is correct, and is why an un-anchored timer reads 0.0 rather than None.

urge_every(rec)

The cooldown to apply after a firing, in seconds.

every is either a number or a (low, high) range, in which case each firing picks afresh. Jitter is not decoration: a character who speaks on an exact metronome reads as a machine, and the one shipped nagger in the corpus (LM's Florbin) was written as random.randint(180, 300) for exactly that reason.

urge_line(state)

A line for this urge - random within its current stage, or "" if it has none.

A stage with no lines of its own falls back to the nearest LOWER stage that has some, so an author can write three stage-1 lines and one stage-3 line without the middle silently going quiet.

urge_note_spoke(actor_id, now=None)

Record that this actor just spoke - feeding both floors.

The global clock lives in announce because an urge and a mission announcement are the same thing from the bridge's side: an unprompted voice.

urge_pick(actor_id, now=None)

The urge this actor should act on now, or None.

Retires anything whose Until: is true, skips anything still cooling, evaluates the rest, and takes the highest Weight: (ties at random). Pure apart from the retire stamp, so it is testable without a tick.

urge_record(key=None, whenever='always', every=60, until=None, weight=0, pool=None, action=None, actor=None, stages=None, escalates=None, title=None)

One urge, as plain data. every is seconds; pool is the flat line list.

stages is the optional {1: [...], 2: [...]} map built from % markers, and escalates is "deadline" | "firing" | None. With neither, an urge behaves exactly as it did before escalation existed.

urge_register_condition(phrase, fn, operand='required', domain=None)

Declare a Whenever: phrase.

fn(actor_id, operand) returns truthy when the urge is eligible. A fixed vocabulary plus this registry, rather than a bare python expression: an expression is powerful and untypeable by the linter, and the whole point of the AMD layer is that the tooling can check what an author wrote. Same contract as amd_action_register - re-registering a phrase with a different function raises, re-registering the identical one is a no-op so reloading is safe.

urge_reset()

Forget the scheduled tick task so the next mission re-registers it.

The same latch that made objective_reset necessary: a restart calls TickDispatcher.clear(), which throws the task away, but a still-set global would make urge_schedule() decide there was nothing to do - and no actor would ever speak again, silently, from run 2 onward.

urge_run_one(actor_id, now=None)

Pick and act on at most one urge for this actor. Returns the urge acted on.

urge_schedule()

Ensure the background tick task driving urges is running.

urge_speak(actor_id, line, title=None)

Say one line as this actor, routed by where the actor IS (DESIGN_RECORD.md s4).

  • hosted on a player ship -> an internal crew message from the actor
  • hosted elsewhere -> a comms message from the host to the player ships
  • unhosted -> a comms message from the actor itself (galaxy-wide, the way Open Universe's news voice already works)

Defensive by design: comms runs the message through compile_and_format_string, so a stray { in authored prose raises there rather than here. One bad line must not stop the ticker for every other actor, so it is caught and logged against the actor.

urge_stage(state)

Which stage this urge should speak at (1-based), clamped to what was authored.

urge_ticks_stale()

True if we think the tick task is scheduled but the dispatcher has lost it.

urges_run_all(tick_task=None, pass_seconds=None)

Run one pass (or a rolling slice of one) over every actor with urges.

Declarative urges from AMD - author what an actor keeps asking for as data.

An Urge heading's BODY is its line pool, exactly the way amd_chatter already treats a pool: no fence needed for the words, so {placeholder} and colon-heavy prose never trip the YAML-flow path. A leading % (the dialogue random-variant marker) is stripped, and // comment lines are ignored::

## [DS1 calls for resupply](ds1_calling)
---
Urge
Actor: DS1
Whenever: quest ds1_resupply active
Every: 5m
Weight: 20
---
% DS1 requests a resupply run when someone has the tonnage.
% DS1 is below reserve. We need that shipment.

Actor: is resolved by amd_action_actors - a declared landmark key, then a role - so "DS1" means the same thing here as in the stage direction DS1 departs. The runtime (selection, cooldowns, speaking) lives in urge.py; this module is only the reader.

amd_urge_data(text)

Parse one urge fence into a data dict (default coercion - all fields are strings). Most urges need no fence values beyond these few; the words are the BODY.

urges_from_section(section)

Urge records from a section node's children (empty list if None).

A heading with no body is skipped and logged - an urge with nothing to say would burn its turn every pass and never be noticed.

urges_install(section)

Give every authored urge to the agents its Actor: names. Returns how many (record, agent) pairs were installed.

An urge whose actor nobody answers to is LOGGED and skipped, never silently dropped - most often it means the AMD was installed before the actor spawned, which the message says. Idempotent per (agent, urge key), so re-running a section is safe.

urges_install_on(agents, section, key=None)

Install the urges authored under a record onto agents you ALREADY have.

The identity path, next to urges_install's name-resolution path. A mission that is holding the character - it just spawned them, or boarded them - should not have to invent a role so a name lookup can find its way back to an agent it already has.

key picks one record out of section (a cast entry, say); without it the section's own children are the urges. Returns how many were installed; idempotent per (agent, urge key), so re-entering a route does not stack a second copy.