Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The command reads only `OPENROUTER_API_KEY` from the repository-root `.env`; its

## Development map source

The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders the same deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. World Setup previews and applies resolution 8–11 scenarios with 1–32 agents, radius at most 40, and at most 5,000 actual generated cells. Schema-v9 exports preserve the authoritative scenario and `durable-influence-v1` attribution. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution.
The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders the same deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. World Setup previews and applies resolution 8–11 scenarios with 1–32 agents, radius at most 40, at most 5,000 actual generated cells, and a 12 km default physical communication range. Schema-v9 exports preserve the authoritative scenario and `durable-influence-v2` attribution. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution.

Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, persistence, and autonomous scheduling remain deferred.

Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Temporary experiment hardening adds explicitly opted-in browser-lifetime unatten

Eleventh focused slice: redesign World Lab as a persistent long-running experiment operator workspace with a single execution controller, Live and Agents workspaces, a compact agent rail, contextual Scoreboard/Agent/Hex/Run inspection, and a bounded tabbed activity dock. This reorganizes existing authoritative configuration and telemetry without adding gameplay mechanics or persistent run storage.

Twelfth focused slice: add scenario-owned physical communication range, private alliance communications, bounded nearby awareness, deterministic legal-move ordering variety, neutral unaffiliated presentation, and operator-only private-communication observability without adding world actions or player mechanics.

## PR 5 — Goals and memory

Persistent short- and long-term objectives, compact memories, plan revision, summaries, and longer simulation runs.
Expand Down
170 changes: 163 additions & 7 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ interface MutableMetrics {
publicMessagesAccepted: number;
publicMessagesRejected: number;
directMessagesRequested: number;
allianceMessagesRequested: number;
allianceMessagesDelivered: number;
allianceMessagesRejected: number;
directMessagesDelivered: number;
directMessagesRejected: number;
publicMessagesSent: number;
Expand Down Expand Up @@ -264,6 +267,9 @@ function mutableMetrics(): MutableMetrics {
publicMessagesAccepted: 0,
publicMessagesRejected: 0,
directMessagesRequested: 0,
allianceMessagesRequested: 0,
allianceMessagesDelivered: 0,
allianceMessagesRejected: 0,
directMessagesDelivered: 0,
directMessagesRejected: 0,
publicMessagesSent: 0,
Expand Down Expand Up @@ -336,19 +342,22 @@ function addToMutable(
? turn.communicationResult.event.channel
: turn.communicationResult.attempt.channel;
if (channel === 'public') metrics.publicMessagesRequested += 1;
else metrics.directMessagesRequested += 1;
else if (channel === 'direct') metrics.directMessagesRequested += 1;
else metrics.allianceMessagesRequested += 1;
if (turn.communicationResult.accepted) {
if (turn.communicationResult.event.channel === 'public') {
metrics.publicMessagesAccepted += 1;
metrics.publicMessagesSent += 1;
} else {
} else if (turn.communicationResult.event.channel === 'direct') {
metrics.directMessagesDelivered += 1;
metrics.directMessagesSent += 1;
if (aggregate) metrics.directMessagesReceived += 1;
}
} else metrics.allianceMessagesDelivered += 1;
} else if (turn.communicationResult.attempt.channel === 'public') {
metrics.publicMessagesRejected += 1;
} else metrics.directMessagesRejected += 1;
} else if (turn.communicationResult.attempt.channel === 'direct')
metrics.directMessagesRejected += 1;
else metrics.allianceMessagesRejected += 1;
}
if (turn.diplomacyResult.requested) {
const type = turn.diplomacyResult.accepted
Expand Down Expand Up @@ -541,6 +550,9 @@ function finalizeMutable(metrics: MutableMetrics) {
directMessagesRequested: metrics.directMessagesRequested,
directMessagesDelivered: metrics.directMessagesDelivered,
directMessagesRejected: metrics.directMessagesRejected,
allianceMessagesRequested: metrics.allianceMessagesRequested,
allianceMessagesDelivered: metrics.allianceMessagesDelivered,
allianceMessagesRejected: metrics.allianceMessagesRejected,
publicMessagesSent: metrics.publicMessagesSent,
directMessagesSent: metrics.directMessagesSent,
directMessagesReceived: metrics.directMessagesReceived,
Expand Down Expand Up @@ -877,7 +889,7 @@ function filterCommunications(
const result = turn.communicationResult;
const communication = result.accepted ? result.event : result.attempt;
const selectedByParticipant =
communication.channel === 'public'
communication.channel !== 'direct'
? selected.has(communication.agentId)
: selected.has(communication.agentId) ||
(communication.recipientId !== null &&
Expand Down Expand Up @@ -1199,13 +1211,15 @@ function summarizeEvent(
}

function summarizeCommunication(
channel: 'public' | 'direct',
channel: 'public' | 'direct' | 'alliance',
recipientId?: AgentId,
distance?: number,
): string {
return channel === 'public'
? 'Published to world chat.'
: `Delivered directly to ${recipientId} from distance ${distance}.`;
: channel === 'alliance'
? 'Delivered to current alliance members.'
: `Delivered directly to ${recipientId} from distance ${distance}.`;
}

export function calculateExperimentMetrics(
Expand Down Expand Up @@ -1261,6 +1275,7 @@ export function calculateExperimentMetrics(
const retriedTurns = records.filter((turn) =>
usageAttempts(turn).some(({ kind }) => kind !== 'initial'),
);
const movement = movementMetrics(records);
return {
totalTurns: records.length,
accepted: records.filter(({ outcome }) => outcome === 'accepted').length,
Expand Down Expand Up @@ -1371,6 +1386,15 @@ export function calculateExperimentMetrics(
agentId,
),
uniqueVisitedCells: visited.size,
eligibleNearbyAgentObservations: records.reduce(
(sum, turn) =>
sum +
turn.observation.nearbyAgents.filter(
({ directMessageLegal }) => directMessageLegal,
).length,
0,
),
...movement,
...(latencies.length > 0
? {
averageLatencyMs:
Expand Down Expand Up @@ -1485,6 +1509,26 @@ function diplomacyMetrics(
requested(type).filter(
(turn) => turn.diplomacyResult.requested && turn.diplomacyResult.accepted,
).length;
const formed = relevantEvents.filter(
(event): event is Extract<AllianceEvent, { type: 'alliance-formed' }> =>
event.type === 'alliance-formed',
);
const completedDurations = formed.flatMap((created) => {
const dissolved = relevantEvents.find(
(event) =>
event.type === 'alliance-dissolved' &&
event.allianceId === created.allianceId &&
event.turnNumber >= created.turnNumber,
);
return dissolved ? [dissolved.turnNumber - created.turnNumber] : [];
});
const allianceSizes = relevantEvents.flatMap((event) =>
event.type === 'alliance-formed'
? [event.memberAgentIds.length]
: event.type === 'agent-joined-alliance'
? [event.memberAgentIds.length]
: [],
);
return {
diplomacyProposalsRequested: requested('propose-alliance').length,
diplomacyAcceptancesRequested: requested('accept-alliance').length,
Expand Down Expand Up @@ -1544,6 +1588,18 @@ function diplomacyMetrics(
alliancesDissolved: relevantEvents.filter(
(event) => event.type === 'alliance-dissolved',
).length,
firstAllianceTurn: formed.length
? Math.min(...formed.map(({ turnNumber }) => turnNumber))
: null,
maximumAllianceSize: Math.max(0, ...allianceSizes),
completedAllianceDurationTurnsTotal: completedDurations.reduce(
(sum, duration) => sum + duration,
0,
),
completedAllianceDurationTurnsAverage: completedDurations.length
? completedDurations.reduce((sum, duration) => sum + duration, 0) /
completedDurations.length
: 0,
alliedCaptureAttempts: records.filter(
(turn) =>
turn.outcome !== 'provider-error' &&
Expand All @@ -1563,6 +1619,69 @@ function diplomacyMetrics(
};
}

function movementMetrics(records: readonly AgentTurnRecord[]) {
const moves = records.flatMap((turn) => {
if (
turn.outcome !== 'accepted' ||
turn.worldActionResult.event.type !== 'agent-moved'
)
return [];
const moved = turn.worldActionResult.event;
const option = turn.observation.actionAvailability.moveOptions.find(
({ targetCell }) => targetCell === moved.toCell,
);
return option
? [{ turn, direction: option.direction, toCell: option.targetCell }]
: [];
});
const counts = new Map<(typeof moves)[number]['direction'], number>();
let longest = 0;
let streak = 0;
let previousDirection: (typeof moves)[number]['direction'] | undefined;
let previousMoveAt: string | undefined;
let directionChangesAfterCommunication = 0;
const visited = new Set<string>(
records[0] ? [records[0].observation.currentCell.cell] : [],
);
let recentCellRevisits = 0;
for (const { turn, direction, toCell } of moves) {
const priorMoveAt = previousMoveAt;
counts.set(direction, (counts.get(direction) ?? 0) + 1);
streak = direction === previousDirection ? streak + 1 : 1;
longest = Math.max(longest, streak);
if (
previousDirection &&
priorMoveAt &&
direction !== previousDirection &&
(turn.observation.recentDirectMessages.some(
({ direction: messageDirection, occurredAt }) =>
messageDirection === 'inbound' &&
occurredAt > priorMoveAt &&
occurredAt <= turn.completedAt,
) ||
turn.observation.recentAllianceMessages.some(
({ senderId, occurredAt }) =>
senderId !== turn.agentId &&
occurredAt > priorMoveAt &&
occurredAt <= turn.completedAt,
))
)
directionChangesAfterCommunication += 1;
previousDirection = direction;
previousMoveAt = turn.completedAt;
if (visited.has(toCell)) recentCellRevisits += 1;
visited.add(toCell);
}
return {
movementDirectionDistribution: [...counts.entries()]
.map(([direction, count]) => ({ direction, count }))
.toSorted((a, b) => a.direction.localeCompare(b.direction)),
longestRepeatedDirectionStreak: longest,
recentCellRevisits,
directionChangesAfterCommunication,
};
}

function groupedDiplomacyRejections(records: readonly AgentTurnRecord[]) {
const grouped = new Map<
string,
Expand Down Expand Up @@ -1615,6 +1734,21 @@ function communicationMetrics(
(communication) =>
communication.channel === 'direct' && authoredBySelection(communication),
);
const allianceAuthored = communications.filter(
(communication) =>
communication.channel === 'alliance' &&
authoredBySelection(communication),
);
const deliveredDirect = directAuthored.filter(
(communication) =>
communication.status === 'accepted' &&
communication.distance !== null &&
communication.distance !== undefined,
);
const directDistanceTotal = deliveredDirect.reduce(
(sum, communication) => sum + (communication.distance ?? 0),
0,
);
return {
publicMessagesRequested: publicAuthored.length,
publicMessagesAccepted: publicAuthored.filter(
Expand All @@ -1630,6 +1764,13 @@ function communicationMetrics(
directMessagesRejected: directAuthored.filter(
({ status }) => status === 'rejected',
).length,
allianceMessagesRequested: allianceAuthored.length,
allianceMessagesDelivered: allianceAuthored.filter(
({ status }) => status === 'accepted',
).length,
allianceMessagesRejected: allianceAuthored.filter(
({ status }) => status === 'rejected',
).length,
publicMessagesSent: publicAuthored.filter(
({ status }) => status === 'accepted',
).length,
Expand All @@ -1641,6 +1782,21 @@ function communicationMetrics(
communication.status === 'accepted' &&
receivedBySelection(communication),
).length,
uniqueDirectMessagePairs: new Set(
directAuthored.flatMap((communication) =>
communication.recipientId
? [`${communication.agentId}:${communication.recipientId}`]
: [],
),
).size,
directMessageDistanceTotalKm: directDistanceTotal,
directMessageDistanceAverageKm: deliveredDirect.length
? directDistanceTotal / deliveredDirect.length
: 0,
directMessageDistanceMaximumKm: Math.max(
0,
...deliveredDirect.map(({ distance }) => distance ?? 0),
),
};
}

Expand Down
63 changes: 58 additions & 5 deletions apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
type CompatibleModel,
type WorldEvent,
} from '@agentborne/shared';
import { DEVELOPMENT_AGENT_BLUEPRINTS } from '@agentborne/world-engine';
import {
DEVELOPMENT_AGENT_BLUEPRINTS,
defaultWorldSetupRequest,
physicalDistanceKm,
} from '@agentborne/world-engine';
import {
SimulationConflictError,
SimulationService,
Expand Down Expand Up @@ -78,19 +82,65 @@ function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') {
}

describe('SimulationService', () => {
it('preserves the default and custom physical communication range', () => {
const simulation = service(
new ScriptedAgentProvider([
{ worldAction: { type: 'wait' }, summary: 'Wait.' },
]),
);
expect(simulation.getSnapshot().scenario.communicationRangeKm).toBe(12);
const request = defaultWorldSetupRequest();
const applied = simulation.applyWorldSetup({
...request,
communicationRangeKm: 7.5,
});
expect(applied.scenario.communicationRangeKm).toBe(7.5);
});

it('reproduces move ordering for identical inputs and varies it across agents', async () => {
const provider = () =>
new ScriptedAgentProvider(
Array.from({ length: 8 }, () => ({
worldAction: { type: 'wait' as const },
summary: 'Wait.',
})),
);
const first = service(provider());
const second = service(provider());
const firstOrders: string[] = [];
const secondOrders: string[] = [];
for (let index = 0; index < 8; index += 1) {
firstOrders.push(
(
await first.executeNextTurn()
).observation.actionAvailability.moveTargetCellIds.join(','),
);
secondOrders.push(
(
await second.executeNextTurn()
).observation.actionAvailability.moveTargetCellIds.join(','),
);
}
expect(firstOrders).toEqual(secondOrders);
expect(new Set(firstOrders).size).toBeGreaterThan(1);
});

it('derives compact action availability from the same authoritative world state', async () => {
const simulation = service(
new ScriptedAgentProvider([
{ worldAction: { type: 'infect' }, summary: 'Infect.' },
]),
);
const turn = await simulation.executeNextTurn();
expect(turn.observation.actionAvailability).toEqual({
expect(turn.observation.actionAvailability).toMatchObject({
moveTargetCellIds: turn.observation.adjacentCells.map(({ cell }) => cell),
infect: { available: true },
capture: { available: false, reason: 'capture-open-cell' },
wait: { available: true },
});
expect(turn.observation.actionAvailability.moveOptions).toHaveLength(
turn.observation.adjacentCells.length,
);
expect(turn.outcome).toBe('accepted');
if (
turn.outcome === 'provider-error' ||
Expand Down Expand Up @@ -1623,13 +1673,16 @@ describe('SimulationService', () => {
},
]),
);
const initialPhysicalDistance = physicalDistanceKm(
sender.currentCell,
recipient.currentCell,
)!;
const turn = await simulation.executeNextTurn();
expect(turn).toMatchObject({
outcome: 'accepted',
communicationResult: {
accepted: false,
reason: 'out-of-range',
attempt: { distance: initialDistance },
accepted: true,
event: { distance: initialPhysicalDistance },
},
});
expect(simulation.getSnapshot().world.agents[0]?.currentCell).toBe(
Expand Down
Loading
Loading