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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Agentborne is the working title for an agent-first geographic experiment. A configurable roster of model-backed agents moves, infects, captures contested territory, communicates, and conducts formal alliance diplomacy while the World Lab exposes every safe decision record.

Scenarios may optionally designate one experimental Patient Zero coordinator.
The role receives bounded global strategic information and can send private
advisory directives, but remains subject to the same movement and world-action
rules as every other agent. The universal flat provider contract is
`text-flat-json-v3`; the objective remains `durable-influence-v2`.

## Workspace

| Path | Responsibility |
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Roadmap

## Experimental Patient Zero coordinator

The current focused slice designates one normal physical agent, provides a
bounded global strategic summary, and adds private advisory Zero broadcasts
plus direct replies. It intentionally excludes extra movement/actions,
numerical bonuses, forced compliance, live player GPS, capture succession,
simulated players, and simultaneous tick scheduling.

Player development begins only after the agent milestones below demonstrate compelling behavior. Each milestone is intended to remain a focused pull request; do not implement ahead of the current milestone.

The [Gameplay Foundation](docs/GAMEPLAY_FOUNDATION.md) records accepted future
Expand Down
175 changes: 170 additions & 5 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type AgentId,
type AgentObservation,
type AgentTurnRecord,
type EventId,
type AllianceEvent,
type ExperimentExportDocument,
type ExperimentExportPreview,
Expand All @@ -17,6 +18,8 @@ import {
type ExperimentId,
type ExperimentMetrics,
type ExperimentModelConfiguration,
type ModelId,
type ReasoningProfile,
type DiplomacyRejectionReason,
type DiplomacyResult,
type ExportedCommunication,
Expand Down Expand Up @@ -86,6 +89,21 @@ export class ExperimentMetricAccumulator {
);
if (recipient) recipient.directMessagesReceived += 1;
}
if (
turn.outcome !== 'provider-error' &&
turn.outcome !== 'operator-skipped' &&
turn.communicationResult.requested &&
turn.communicationResult.accepted &&
turn.communicationResult.event.channel === 'zero'
) {
for (const recipientId of turn.communicationResult.event.recipientIds) {
const recipient = this.#records.get(recipientId);
if (recipient) {
recipient.zeroRecipientDeliveries += 1;
recipient.zeroDirectiveRecipients.add(recipientId);
}
}
}
if (
turn.outcome === 'accepted' &&
turn.worldActionResult.event.type === 'hex-captured'
Expand Down Expand Up @@ -191,6 +209,25 @@ interface MutableMetrics {
publicMessagesSent: number;
directMessagesSent: number;
directMessagesReceived: number;
zeroBroadcastsRequested: number;
zeroBroadcastsDelivered: number;
zeroBroadcastsRejected: number;
zeroRecipientDeliveries: number;
zeroDirectiveRecipients: Set<AgentId>;
patientZeroRepliers: Set<AgentId>;
directRepliesToPatientZero: number;
firstZeroDirectiveTurn: number | null;
mostRecentZeroDirective: {
eventId: EventId;
turnNumber: number;
occurredAt: string;
agentId: AgentId;
recipientCount: number;
modelId: ModelId;
reasoningProfile: ReasoningProfile;
personalityId: AgentObservation['behavior']['personalityId'];
strategyId: AgentObservation['behavior']['strategyId'];
} | null;
diplomacyProposalsRequested: number;
diplomacyAcceptancesRequested: number;
diplomacyDeparturesRequested: number;
Expand Down Expand Up @@ -275,6 +312,15 @@ function mutableMetrics(): MutableMetrics {
publicMessagesSent: 0,
directMessagesSent: 0,
directMessagesReceived: 0,
zeroBroadcastsRequested: 0,
zeroBroadcastsDelivered: 0,
zeroBroadcastsRejected: 0,
zeroRecipientDeliveries: 0,
zeroDirectiveRecipients: new Set(),
patientZeroRepliers: new Set(),
directRepliesToPatientZero: 0,
firstZeroDirectiveTurn: null,
mostRecentZeroDirective: null,
diplomacyProposalsRequested: 0,
diplomacyAcceptancesRequested: 0,
diplomacyDeparturesRequested: 0,
Expand Down Expand Up @@ -343,7 +389,8 @@ function addToMutable(
: turn.communicationResult.attempt.channel;
if (channel === 'public') metrics.publicMessagesRequested += 1;
else if (channel === 'direct') metrics.directMessagesRequested += 1;
else metrics.allianceMessagesRequested += 1;
else if (channel === 'alliance') metrics.allianceMessagesRequested += 1;
else metrics.zeroBroadcastsRequested += 1;
if (turn.communicationResult.accepted) {
if (turn.communicationResult.event.channel === 'public') {
metrics.publicMessagesAccepted += 1;
Expand All @@ -352,12 +399,47 @@ function addToMutable(
metrics.directMessagesDelivered += 1;
metrics.directMessagesSent += 1;
if (aggregate) metrics.directMessagesReceived += 1;
} else metrics.allianceMessagesDelivered += 1;
} else if (turn.communicationResult.event.channel === 'alliance')
metrics.allianceMessagesDelivered += 1;
else {
metrics.zeroBroadcastsDelivered += 1;
metrics.zeroRecipientDeliveries +=
turn.communicationResult.event.recipientIds.length;
for (const id of turn.communicationResult.event.recipientIds)
metrics.zeroDirectiveRecipients.add(id);
metrics.firstZeroDirectiveTurn ??= turn.turnNumber;
metrics.mostRecentZeroDirective = {
eventId: turn.communicationResult.event.id,
turnNumber: turn.turnNumber,
occurredAt: turn.communicationResult.event.occurredAt,
agentId: turn.agentId,
recipientCount: turn.communicationResult.event.recipientIds.length,
modelId: turn.provider.model,
reasoningProfile:
turn.modelAttempts.at(-1)?.reasoningProfile ?? 'provider-default',
personalityId: turn.observation.behavior.personalityId,
strategyId: turn.observation.behavior.strategyId,
};
}
} else if (turn.communicationResult.attempt.channel === 'public') {
metrics.publicMessagesRejected += 1;
} else if (turn.communicationResult.attempt.channel === 'direct')
metrics.directMessagesRejected += 1;
else metrics.allianceMessagesRejected += 1;
else if (turn.communicationResult.attempt.channel === 'alliance')
metrics.allianceMessagesRejected += 1;
else metrics.zeroBroadcastsRejected += 1;
}
if (
turn.communicationResult.requested &&
turn.communicationResult.accepted &&
turn.communicationResult.event.channel === 'direct' &&
turn.observation.patientZero.agentId !== null &&
turn.agentId !== turn.observation.patientZero.agentId &&
turn.communicationResult.event.recipientId ===
turn.observation.patientZero.agentId
) {
metrics.directRepliesToPatientZero += 1;
metrics.patientZeroRepliers.add(turn.agentId);
}
if (turn.diplomacyResult.requested) {
const type = turn.diplomacyResult.accepted
Expand Down Expand Up @@ -556,6 +638,15 @@ function finalizeMutable(metrics: MutableMetrics) {
publicMessagesSent: metrics.publicMessagesSent,
directMessagesSent: metrics.directMessagesSent,
directMessagesReceived: metrics.directMessagesReceived,
zeroBroadcastsRequested: metrics.zeroBroadcastsRequested,
zeroBroadcastsDelivered: metrics.zeroBroadcastsDelivered,
zeroBroadcastsRejected: metrics.zeroBroadcastsRejected,
zeroRecipientDeliveries: metrics.zeroRecipientDeliveries,
uniqueZeroDirectiveRecipients: metrics.zeroDirectiveRecipients.size,
directRepliesToPatientZero: metrics.directRepliesToPatientZero,
uniquePatientZeroRepliers: metrics.patientZeroRepliers.size,
firstZeroDirectiveTurn: metrics.firstZeroDirectiveTurn,
mostRecentZeroDirective: metrics.mostRecentZeroDirective,
diplomacyProposalsRequested: metrics.diplomacyProposalsRequested,
diplomacyAcceptancesRequested: metrics.diplomacyAcceptancesRequested,
diplomacyDeparturesRequested: metrics.diplomacyDeparturesRequested,
Expand Down Expand Up @@ -1211,15 +1302,17 @@ function summarizeEvent(
}

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

export function calculateExperimentMetrics(
Expand Down Expand Up @@ -1276,6 +1369,37 @@ export function calculateExperimentMetrics(
usageAttempts(turn).some(({ kind }) => kind !== 'initial'),
);
const movement = movementMetrics(records);
const mostRecentZeroTurn = records.findLast(
(turn) =>
turn.outcome !== 'provider-error' &&
turn.outcome !== 'operator-skipped' &&
turn.communicationResult.requested &&
turn.communicationResult.accepted &&
turn.communicationResult.event.channel === 'zero',
);
const mostRecentZeroDirective =
mostRecentZeroTurn &&
mostRecentZeroTurn.outcome !== 'provider-error' &&
mostRecentZeroTurn.outcome !== 'operator-skipped' &&
mostRecentZeroTurn.communicationResult.requested &&
mostRecentZeroTurn.communicationResult.accepted &&
mostRecentZeroTurn.communicationResult.event.channel === 'zero'
? {
eventId: mostRecentZeroTurn.communicationResult.event.id,
turnNumber: mostRecentZeroTurn.turnNumber,
occurredAt: mostRecentZeroTurn.communicationResult.event.occurredAt,
agentId: mostRecentZeroTurn.agentId,
recipientCount:
mostRecentZeroTurn.communicationResult.event.recipientIds.length,
modelId: mostRecentZeroTurn.provider.model,
reasoningProfile:
mostRecentZeroTurn.modelAttempts.at(-1)?.reasoningProfile ??
'provider-default',
personalityId:
mostRecentZeroTurn.observation.behavior.personalityId,
strategyId: mostRecentZeroTurn.observation.behavior.strategyId,
}
: null;
return {
totalTurns: records.length,
accepted: records.filter(({ outcome }) => outcome === 'accepted').length,
Expand Down Expand Up @@ -1376,6 +1500,7 @@ export function calculateExperimentMetrics(
scopedAgentIds.includes(previousControllerAgentId),
).length,
...communicationMetrics(relevantCommunications, scopedAgentIds, agentId),
mostRecentZeroDirective,
...diplomacyMetrics(
records,
agentId
Expand Down Expand Up @@ -1739,6 +1864,21 @@ function communicationMetrics(
communication.channel === 'alliance' &&
authoredBySelection(communication),
);
const zeroAuthored = communications.filter(
(communication) =>
communication.channel === 'zero' && authoredBySelection(communication),
);
const patientZeroId = communications.find(
({ channel, status }) => channel === 'zero' && status === 'accepted',
)?.agentId;
const repliesToPatientZero = patientZeroId
? directAuthored.filter(
({ status, recipientId, agentId: senderId }) =>
status === 'accepted' &&
recipientId === patientZeroId &&
senderId !== patientZeroId,
)
: [];
const deliveredDirect = directAuthored.filter(
(communication) =>
communication.status === 'accepted' &&
Expand Down Expand Up @@ -1771,6 +1911,31 @@ function communicationMetrics(
allianceMessagesRejected: allianceAuthored.filter(
({ status }) => status === 'rejected',
).length,
zeroBroadcastsRequested: zeroAuthored.length,
zeroBroadcastsDelivered: zeroAuthored.filter(
({ status }) => status === 'accepted',
).length,
zeroBroadcastsRejected: zeroAuthored.filter(
({ status }) => status === 'rejected',
).length,
zeroRecipientDeliveries: zeroAuthored.reduce(
(sum, communication) =>
sum +
(communication.status === 'accepted'
? (communication.recipientIds?.length ?? 0)
: 0),
0,
),
uniqueZeroDirectiveRecipients: new Set(
zeroAuthored.flatMap(({ recipientIds }) => recipientIds ?? []),
).size,
directRepliesToPatientZero: repliesToPatientZero.length,
uniquePatientZeroRepliers: new Set(
repliesToPatientZero.map(({ agentId: senderId }) => senderId),
).size,
firstZeroDirectiveTurn:
zeroAuthored.find(({ status }) => status === 'accepted')
?.originatingTurn ?? null,
publicMessagesSent: publicAuthored.filter(
({ status }) => status === 'accepted',
).length,
Expand Down
75 changes: 75 additions & 0 deletions apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,81 @@ describe('SimulationService', () => {
expect(applied.scenario.communicationRangeKm).toBe(7.5);
});

it('gives only Patient Zero bounded global awareness and delivers a Zero directive plus global reply', async () => {
const seen: AgentObservation[] = [];
const simulation = service({
mode: 'scripted-test',
model: 'deterministic-script',
configured: true,
async decide(observation): Promise<ProviderDecision> {
seen.push(structuredClone(observation));
return {
decision: {
worldAction: { type: 'wait' },
communication: observation.patientZero.isPatientZero
? { channel: 'zero', message: 'Take separate infection fronts.' }
: {
channel: 'direct',
recipientId: observation.patientZero.agentId!,
message: 'Taking the eastern front.',
},
summary: 'Coordinate while waiting.',
},
metadata: {
provider: 'scripted-test',
model: 'deterministic-script',
latencyMs: 0,
},
};
},
});
const setup = defaultWorldSetupRequest();
const patientZeroId = setup.roster[0]!.id;
simulation.applyWorldSetup({
...setup,
patientZeroAgentId: patientZeroId,
modelConfiguration: {
...setup.modelConfiguration,
globalModelId: 'deterministic-script',
},
});
const directive = await simulation.executeNextTurn();
const reply = await simulation.executeNextTurn();
expect(directive.observation.patientZeroGlobalView?.agents).toHaveLength(8);
expect(reply.observation.patientZeroGlobalView).toBeNull();
expect(
JSON.stringify(directive.observation.patientZeroGlobalView),
).not.toMatch(/provider|credential|pendingDecision|gps|future/i);
if (
directive.outcome === 'provider-error' ||
directive.outcome === 'operator-skipped' ||
reply.outcome === 'provider-error' ||
reply.outcome === 'operator-skipped'
)
throw new Error('Expected completed Patient Zero communication turns.');
expect(directive.communicationResult).toMatchObject({
accepted: true,
event: { channel: 'zero', recipientIds: expect.any(Array) },
});
expect(reply.communicationResult).toMatchObject({
accepted: true,
event: { channel: 'direct', recipientId: patientZeroId },
});
expect(reply.observation.recentZeroMessages).toHaveLength(1);
expect(seen).toHaveLength(2);
expect(simulation.getSnapshot().experiment.metrics.aggregate).toMatchObject(
{
zeroBroadcastsRequested: 1,
zeroBroadcastsDelivered: 1,
zeroRecipientDeliveries: 7,
uniqueZeroDirectiveRecipients: 7,
directRepliesToPatientZero: 1,
uniquePatientZeroRepliers: 1,
firstZeroDirectiveTurn: 1,
},
);
});

it('reproduces move ordering for identical inputs and varies it across agents', async () => {
const provider = () =>
new ScriptedAgentProvider(
Expand Down
Loading
Loading