Skip to content

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.


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: idlerunningclosed.

world.start();
const result = await world.tick();
const results = await world.runRounds(20);
world.close('manual');

One global round (World.tick):

  1. Each running arena ticks once in parallel (Promise.all). Per-arena schedulers stay invariant; the World never reaches inside an arena's own decide/apply loop.
  2. The _applyLinks handler runs (end-of-round resource flow).
  3. Every world-level meta-agent observes the post-link snapshot and may issue perturbation specs (see Governance).
  4. _aggregate rolls up totalStock / totalCapacity / totalWealth / totalPlayers, plus arenasRunning / arenasClosed counts.

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.type other than 'resource'. The enum is a one-line extension but no v1.0 vignette consumes it.
  • No HTTP meta-agents at the World level (addMetaAgent accepts 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.