World¶
The World is the v1.0 composition layer that federates N arenas
into a single nested structure. Arenas keep owning their own engine,
scheduler, players, pacts, and observability — the World adds a layer
above them for cross-arena resource flow, treaties, agent migration,
and world-level governance.
A World does not clone its member arenas: it holds them by
reference. Adding an arena to a World is a re-pointer, not a copy. The
same is true on the way out — removeArena detaches without
destroying the arena. The World layer is composition, not ownership.
What a World contains¶
A World instance owns four named collections:
| Collection | Type | Source of truth |
|---|---|---|
arenas |
Map<arenaId, Arena> |
Held by reference; lifecycle stays with the arena |
links |
Map<linkId, Link> |
Cross-arena resource flow (see below) |
treaties |
Map<treatyId, Treaty> |
Cross-arena constraint metadata — see Treaties |
metaAgents |
Map<metaId, GovernanceAgent> |
C3.b world-level meta-agents — see Governance |
…plus a global clock (globalClock), a status field
('idle' | 'running' | 'closed'), and an owner key for the public API.
Composing arenas¶
import { worldRegistry } from './platform/world.js';
import { arenaRegistry } from './platform/registry.js';
const { worldId, ownerKey } = worldRegistry.createWorld({ name: 'mule_5p' });
const world = worldRegistry.getWorld(worldId);
const { arenaId: a0 } = arenaRegistry.createArena({ /* …model… */ });
const { arenaId: a1 } = arenaRegistry.createArena({ /* …model… */ });
world.addArena(arenaRegistry.getArena(a0));
world.addArena(arenaRegistry.getArena(a1));
addArena is rejected if the arena is already attached to a different
World and has already persisted turns under that other World's path
(turn > 0). The check is conservative — migrating across worlds is
allowed before any turn has been written, but never after, to avoid
orphaning the JSONL log under the previous data/worlds/<wid>/ tree.
removeArena also drops every Link or Treaty that referenced the
departing arena, so the world invariants stay tight without forcing
the caller to clean up by hand.
Resource Links¶
A Link is a one-way per-round resource flow between two arenas in
the same world. In v1.0 only type: 'resource' exists; future kinds
(information, pact, sanction) are out of scope.
world.addLink({
type: 'resource',
sourceArenaId: a0,
targetArenaId: a1,
rate: 0.05, // fraction of source.stock per round
});
The flow handler runs at end-of-round, after every arena has ticked. The semantics are intentionally simple:
outflow = max(0, min(source.stock, rate × source.stock))
inflow = max(0, min(outflow, target.capacity − target.stock))
spillage = outflow − inflow
A two-pass algorithm keeps results deterministic when multiple links
share the same source: pass 1 reads pre-application stocks and
accumulates per-arena deltas; pass 2 applies them via engine.setStock
(engine-agnostic — every BaseEngine implements setStock as part of
the v0.4 perturbation contract). Closed arenas on either side are
skipped.
The context.inflow / context.outflow channel reserved on the engine
step contract since v0.6 is not used by v1.0; end-of-round
application keeps the engine.step call surface a no-op for cross-arena
flow. If a future engine (e.g. Mule-polycentric) reveals an ordering
artefact, the channel is ready.
Agent migration¶
World.migrateAgent moves a player from one arena to another within
this world. State is preserved by reference: the same PlayerRecord
instance leaves the source arena and enters the target, carrying:
wealth- the builtin internal memory (
_builtinAgent) - the governance instance (
_governanceAgent) - history
- the original UUID
…with no serialise/deserialise round-trip.
const r = world.migrateAgent({
agentId: 'player-uuid',
fromArenaId: a0,
toArenaId: a1,
});
// r = { ok: true, agentId, fromArenaId, toArenaId, fromTurn, toTurn, wealthCarried }
What is not migrated, by design:
| State | Behaviour after migration |
|---|---|
| Pact membership | Stays in the source arena's PactRegistry; the player is an "absentee party" until their next intentional signature in the target |
| Reputation | Per-arena; resets in target |
| Message inbox | Per-arena; drops on migration |
On attach failure the source attach is restored automatically (rollback). Same-arena migrations and cross-world migrations are rejected.
The most common consumer of migrateAgent is the exclude
perturbation, which routes pact-violators
into a designated junk arena.
Cross-arena messaging¶
World.routeCrossArenaMessage lets a player in one arena send a
private message to a player in another arena of the same world:
world.routeCrossArenaMessage({
fromId: senderUUID,
fromArenaId: a0,
toId: recipientUUID, // looked up across world.arenas
text: '…',
turn,
});
The message is written to the target arena's MessageBus so the
recipient's inboxFor query surfaces it with the same one-turn lag
as intra-arena privates.
Broadcasts and announcements stay arena-scoped by design — cheap talk is a feature of the local commons community, not a world-wide PA system.
World lifecycle and the super-scheduler¶
A World moves through three states: idle → running → closed.
world.start();
const result = await world.tick();
const results = await world.runRounds(20);
world.close('manual');
One global round (World.tick):
- Each
runningarena ticks once in parallel (Promise.all). Per-arena schedulers stay invariant; the World never reaches inside an arena's own decide/apply loop. - The
_applyLinkshandler runs (end-of-round resource flow). - Every world-level meta-agent observes the post-link snapshot and may issue perturbation specs (see Governance).
_aggregaterolls up totalStock / totalCapacity / totalWealth / totalPlayers, plusarenasRunning/arenasClosedcounts.
Auto-close: if every composed arena has reached a non-running status,
the world closes itself with reason: 'all_arenas_ended'. Callers
can still inspect world.summary() after this.
Summary and aggregation¶
world.summary() returns a JSON-safe snapshot suitable for the API:
{
id, name, status, globalClock,
arenas: [ { id, status, turn, players, model: { name, scheduler } } ],
links: [ /* Link.toJSON() */ ],
treaties: [ /* Treaty.toJSON() */ ],
aggregated: {
arenas, arenasRunning, arenasClosed,
totalPlayers, totalStock, totalCapacity, totalWealth,
},
createdAt, closedAt,
}
The aggregated block is also returned on every tick() result, so
the dashboard can plot world-level wealth and stock curves without
re-querying.
WorldRegistry¶
WorldRegistry is the singleton index of live worlds, parallel to
ArenaRegistry:
| Method | Use |
|---|---|
createWorld({ name, ownerKey }) |
Create a new world; returns { ok, worldId, ownerKey, name } |
getWorld(worldId) |
Lookup; returns World or null |
listWorlds({ ownerKey }) |
List all worlds, optionally filtered by owner |
checkOwner(worldId, ownerKey) |
Auth helper for the World REST surface |
Persistence of the world shell itself (a worlds.json index) is
intentionally out of scope for v1.0 — the per-arena JSONL logs under
data/worlds/<worldId>/arenas/<arenaId>/ already provide replay, and
a World "exists" as long as at least one of its arenas does.
What the World layer does not do¶
For honest reading of v1.0:
- No cross-arena cheap talk between player-level pacts. Cross-arena messaging is private (1:1) only.
- No
Link.typeother than'resource'. The enum is a one-line extension but no v1.0 vignette consumes it. - No HTTP meta-agents at the World level (
addMetaAgentaccepts builtin strategies only; HTTP world-meta is deferred to v1.1, parallel to the C3.a HTTP path). - No world-level persistence shell. Only the per-arena logs are durable.
These are intentional v1.0 close-doors documented in
docs/dev/V1.0_PLAN.md.