diff --git a/README.md b/README.md index 0560619..444af2c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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-v4`; the objective remains `durable-influence-v2`. For every +`text-flat-json-v5`; the objective remains `durable-influence-v2`. For every agent, including Patient Zero, no message is the normal choice unless a message adds new decision-relevant value; routine action narration and filler are explicitly discouraged. @@ -80,6 +80,14 @@ The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resol Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, restartable world persistence, and autonomous scheduling remain deferred. +Formal alliances may grow to the entire configured roster and active worlds may +use every feasible roster partition. Accessible alliance colors are +deterministic presentation and may be reused; they are not an engine capacity +rule. Free agents may form a new alliance, allied members may invite a free +agent, and a free agent may request entry from an allied recipient. Frozen +observations supply exact legal diplomacy IDs and bounded blocker codes so the +same single model request does not need provider tools or infer engine rules. + When every development cell is infected, World Lab automatically pauses playback and disables Start to avoid accidental provider calls. Reset and export remain available, and Single tick remains an explicitly manual diagnostic diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index fffb797..b0ab647 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -8,11 +8,16 @@ import { } from '@hexzero/agent-runtime'; import { AGENT_DECISION_CONTRACT_VERSION, + ALLIANCE_COLOR_PALETTE, PERSONALITY_MAX_LENGTH, + assignBehavior, agentIdSchema, + allianceIdSchema, agentTurnRecordSchema, experimentExportDocumentSchema, h3CellSchema, + type Alliance, + type AgentId, type AgentObservation, type AgentTurnRecord, type CompatibleModel, @@ -20,14 +25,19 @@ import { } from '@hexzero/shared'; import { DEVELOPMENT_AGENT_BLUEPRINTS, + createDevelopmentWorld, defaultWorldSetupRequest, + generateDeterministicRoster, physicalDistanceKm, + toWorldState, + type WorldState, } from '@hexzero/world-engine'; import { SimulationConflictError, SimulationService, SimulationTurnCancelledError, SimulationValidationError, + selectDiplomacyBlockerExamples, } from './simulation-service'; import { calculateExperimentMetrics, @@ -83,6 +93,98 @@ function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') { } describe('SimulationService', () => { + it('prioritizes free diplomacy blocker examples before allied relationships', () => { + const base = toWorldState(createDevelopmentWorld({ generatedAt: now() })); + const agents = [...base.agents.values()]; + const allianceEntries: Array<[Alliance['id'], Alliance]> = Array.from( + { length: 3 }, + (_, index) => { + const id = allianceIdSchema.parse( + `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ); + return [ + id, + { + id, + color: ALLIANCE_COLOR_PALETTE[index]!, + memberAgentIds: [agents[index * 2]!.id, agents[index * 2 + 1]!.id], + }, + ]; + }, + ); + const state: WorldState = { + ...base, + alliances: new Map(allianceEntries), + }; + const firstFreeId = agents[6]!.id; + const distantFreeCounterpartId = agents[7]!.id; + const blockersFor = (actingAgentId: AgentId) => + agents + .filter(({ id }) => id !== actingAgentId) + .map(({ id }) => ({ agentId: id, reason: 'out-of-range' as const })); + expect( + selectDiplomacyBlockerExamples( + state, + firstFreeId, + blockersFor(firstFreeId), + ['out-of-range'], + )[0]?.agentId, + ).toBe(distantFreeCounterpartId); + expect( + selectDiplomacyBlockerExamples( + state, + distantFreeCounterpartId, + blockersFor(distantFreeCounterpartId), + ['out-of-range'], + )[0]?.agentId, + ).toBe(firstFreeId); + expect( + selectDiplomacyBlockerExamples( + state, + agents[0]!.id, + blockersFor(agents[0]!.id), + ['out-of-range'], + ) + .slice(0, 2) + .map(({ agentId }) => agentId), + ).toEqual([firstFreeId, distantFreeCounterpartId].toSorted()); + }); + + it('authors exact range-blocked diplomacy affordances before inference', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { worldAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + ); + const setup = defaultWorldSetupRequest(); + simulation.applyWorldSetup({ + ...setup, + communicationRangeKm: 0.1, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + }); + const record = await simulation.executeNextTurn(); + expect( + record.observation.diplomacyAvailability.propose.blockedRecipients, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: 'out-of-range' }), + ]), + ); + expect( + simulation.generateExperimentExport(exportRequest('full-safe')).turns[0] + ?.observation?.diplomacyAvailability, + ).toMatchObject({ + propose: { + blockedRecipients: expect.arrayContaining([ + expect.objectContaining({ reason: 'out-of-range' }), + ]), + }, + }); + }); + it('freezes all observations, dispatches concurrently, and commits one complete tick', async () => { const observations: AgentObservation[] = []; const deadlines = new Set(); @@ -178,7 +280,9 @@ describe('SimulationService', () => { }); it('resolves same-tick diplomacy contention in deterministic phase order', async () => { - const recipientId = DEVELOPMENT_AGENT_BLUEPRINTS[0]!.id; + const recipientId = agentIdSchema.parse( + DEVELOPMENT_AGENT_BLUEPRINTS[0]!.id, + ); const simulation = service({ mode: 'scripted-test', model: 'deterministic-script', @@ -214,6 +318,15 @@ describe('SimulationService', () => { record.outcome !== 'provider-error' && record.outcome !== 'operator-skipped', ); + expect( + completed + .filter(({ agentId }) => agentId !== recipientId) + .every(({ observation }) => + observation.diplomacyAvailability.propose.eligibleRecipientAgentIds.includes( + recipientId, + ), + ), + ).toBe(true); const requested = completed.filter( (record) => record.diplomacyResult.requested, ); @@ -538,6 +651,32 @@ describe('SimulationService', () => { const directive = await simulation.executeNextTurn(); const reply = await simulation.executeNextTurn(); expect(directive.observation.patientZeroGlobalView?.agents).toHaveLength(8); + expect( + directive.observation.patientZeroGlobalView?.diplomacyFeasibility, + ).toHaveLength(8); + expect( + directive.observation.patientZeroGlobalView?.diplomacyFeasibility.find( + ({ agentId }) => agentId === patientZeroId, + ), + ).toMatchObject({ + agentId: patientZeroId, + eligibleRecipientCount: 7, + eligibleRecipientsTruncated: true, + leaveAvailable: false, + }); + expect( + directive.observation.patientZeroGlobalView?.diplomacyFeasibility.find( + ({ agentId }) => agentId === patientZeroId, + )?.displayedEligibleRecipientAgentIds, + ).toHaveLength(4); + expect( + directive.observation.diplomacyAvailability.propose + .eligibleRecipientAgentIds, + ).toHaveLength( + directive.observation.patientZeroGlobalView?.diplomacyFeasibility.find( + ({ agentId }) => agentId === patientZeroId, + )?.eligibleRecipientCount ?? -1, + ); expect(reply.observation.patientZeroGlobalView).toBeNull(); expect( JSON.stringify(directive.observation.patientZeroGlobalView), @@ -574,6 +713,64 @@ describe('SimulationService', () => { ); }); + it('keeps 32-agent Patient Zero diplomacy guidance compact and surfaces a distant free-agent blocker', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { worldAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + ); + const setup = defaultWorldSetupRequest(); + const roster = generateDeterministicRoster(32, 'pz-diplomacy-bound'); + const patientZeroAgentId = roster[0]!.id; + simulation.applyWorldSetup({ + ...setup, + radius: 12, + communicationRangeKm: 0.1, + roster, + patientZeroAgentId, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + behaviorConfiguration: { + ...setup.behaviorConfiguration, + assignments: assignBehavior( + roster.map(({ id }) => id), + setup.behaviorConfiguration.seed, + 'balanced-random', + ), + }, + }); + const record = await simulation.executeNextTurn(); + const feasibility = + record.observation.patientZeroGlobalView?.diplomacyFeasibility; + expect(feasibility).toHaveLength(32); + expect( + feasibility?.every( + (entry) => + entry.displayedEligibleRecipientAgentIds.length <= 4 && + entry.blockerExamples.length <= 4 && + !('eligibleRecipientAgentIds' in entry) && + entry.blockerExamples.every( + ({ agentId }) => agentId !== entry.agentId, + ), + ), + ).toBe(true); + const patientZeroFeasibility = feasibility?.find( + ({ agentId }) => agentId === patientZeroAgentId, + ); + expect(patientZeroFeasibility).toMatchObject({ + eligibleRecipientCount: 0, + eligibleRecipientsTruncated: false, + blockedCounts: expect.arrayContaining([ + expect.objectContaining({ reason: 'out-of-range' }), + ]), + blockerExamples: expect.arrayContaining([ + expect.objectContaining({ reason: 'out-of-range' }), + ]), + }); + }); + it('reproduces move ordering for identical inputs and varies it across agents', async () => { const provider = () => new ScriptedAgentProvider( diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index f14d11d..a0ae255 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -31,6 +31,7 @@ import { PERSONALITY_MAX_LENGTH, OPENROUTER_PROVIDER_TIMEOUT_MS, OPENROUTER_429_FALLBACK_BACKOFF_MS, + WORLD_SCENARIO_LIMITS, personalitySchema, simulationSnapshotSchema, type Agent, @@ -72,6 +73,7 @@ import { getCaptureEligibility, getAgentAlliance, getEffectiveAgentColor, + getProposalTargetEligibility, physicalDistanceKm, expireAllianceProposals, seededTickIntervalMinutes, @@ -966,6 +968,7 @@ export class SimulationService { communicationRangeKm: this.#scenario.communicationRangeKm, patientZeroAgentId: this.#scenario.patientZeroAgentId, tickNumber, + diplomacyRangeState: preTickState, }; const recordOrdinal = new Map( order.map((agentId, index) => [ @@ -1390,6 +1393,7 @@ export class SimulationService { createProposalId: this.#createProposalId, communicationRangeKm: this.#scenario.communicationRangeKm, patientZeroAgentId: this.#scenario.patientZeroAgentId, + diplomacyRangeState: preActionState, }; const appliedAction = applyWorldAction( preActionState, @@ -2020,6 +2024,57 @@ export class SimulationService { activeAllianceProposals: [ ...(this.#state.pendingAllianceProposals?.values() ?? []), ], + diplomacyFeasibility: [...this.#state.agents.keys()] + .toSorted() + .map((candidateId) => { + const availability = this.#diplomacyAvailability( + candidateId, + WORLD_SCENARIO_LIMITS.maximumAgents, + ); + const eligible = + availability.propose.eligibleRecipientAgentIds; + const displayedEligibleRecipientAgentIds = eligible.slice( + 0, + 4, + ); + const blockedCounts = [ + ...new Set( + availability.propose.blockedRecipients.map( + ({ reason }) => reason, + ), + ), + ].map((reason) => ({ + reason, + count: availability.propose.blockedRecipients.filter( + (blocked) => blocked.reason === reason, + ).length, + })); + const blockerPriority = [ + 'out-of-range', + 'alliance-to-alliance-merge', + 'current-ally', + 'incoming-proposal-exists', + 'outgoing-proposal-exists', + ] as const; + return { + agentId: candidateId, + eligibleRecipientCount: eligible.length, + displayedEligibleRecipientAgentIds, + eligibleRecipientsTruncated: + eligible.length > + displayedEligibleRecipientAgentIds.length, + blockedCounts, + blockerExamples: selectDiplomacyBlockerExamples( + this.#state, + candidateId, + availability.propose.blockedRecipients, + blockerPriority, + ), + acceptableProposalIds: + availability.accept.acceptableProposalIds, + leaveAvailable: availability.leave.available, + }; + }), recentStrategicEvents: this.#state.events .filter(isAllianceEvent) .slice(-RECENT_ZERO_STRATEGIC_EVENT_LIMIT) @@ -2063,7 +2118,10 @@ export class SimulationService { }); } - #diplomacyAvailability(agentId: AgentId) { + #diplomacyAvailability( + agentId: AgentId, + blockedRecipientLimit: number = WORLD_SCENARIO_LIMITS.maximumNearbyAgentObservations, + ) { const proposals = [ ...(this.#state.pendingAllianceProposals?.values() ?? []), ]; @@ -2071,43 +2129,72 @@ export class SimulationService { const hasOutgoing = proposals.some( ({ proposerAgentId }) => proposerAgentId === agentId, ); - const eligibleRecipientAgentIds = hasOutgoing - ? [] - : [...this.#state.agents.keys()] - .filter( - (candidateId) => - candidateId !== agentId && - !getAgentAlliance(this.#state, candidateId) && - !proposals.some( - ({ recipientAgentId }) => recipientAgentId === candidateId, - ), - ) - .toSorted() - .slice(0, 7); - const acceptableProposalIds = actingAlliance - ? [] - : proposals - .filter(({ recipientAgentId }) => recipientAgentId === agentId) - .map(({ id }) => id); + const eligibleRecipientAgentIds: AgentId[] = []; + const blockedRecipients: Array<{ + agentId: AgentId; + reason: + | 'current-ally' + | 'out-of-range' + | 'outgoing-proposal-exists' + | 'incoming-proposal-exists' + | 'alliance-to-alliance-merge'; + }> = []; + for (const candidateId of [...this.#state.agents.keys()].toSorted()) { + let reason: (typeof blockedRecipients)[number]['reason'] | null = null; + if (candidateId === agentId) continue; + const eligibility = getProposalTargetEligibility( + this.#state, + agentId, + candidateId, + this.#scenario.communicationRangeKm, + this.#state, + ); + if (!eligibility.eligible) reason = eligibility.reason; + if (reason) { + if (blockedRecipients.length < blockedRecipientLimit) + blockedRecipients.push({ agentId: candidateId, reason }); + } else eligibleRecipientAgentIds.push(candidateId); + } + const acceptableProposalIds = proposals + .filter((proposal) => { + if (proposal.recipientAgentId !== agentId) return false; + const proposerAlliance = getAgentAlliance( + this.#state, + proposal.proposerAgentId, + ); + return ( + (proposal.proposerAllianceId === null + ? !proposerAlliance + : proposerAlliance?.id === proposal.proposerAllianceId) && + (proposal.recipientAllianceId === null + ? !actingAlliance + : actingAlliance?.id === proposal.recipientAllianceId) && + !(proposerAlliance && actingAlliance) + ); + }) + .map(({ id }) => id); return { neutral: { available: true as const }, propose: eligibleRecipientAgentIds.length - ? { available: true as const, eligibleRecipientAgentIds } + ? { + available: true as const, + eligibleRecipientAgentIds, + blockedRecipients, + } : { available: false as const, eligibleRecipientAgentIds: [], + blockedRecipients, reason: hasOutgoing ? 'A pending outgoing formal proposal already exists.' - : 'No unaffiliated eligible recipient is available.', + : 'No eligible formal proposal recipient is available.', }, accept: acceptableProposalIds.length ? { available: true as const, acceptableProposalIds } : { available: false as const, acceptableProposalIds: [], - reason: actingAlliance - ? 'Allied agents cannot accept another proposal.' - : 'No acceptable inbound formal alliance proposal exists.', + reason: 'No acceptable inbound formal alliance proposal exists.', }, leave: actingAlliance ? { available: true as const, allianceId: actingAlliance.id } @@ -2167,6 +2254,33 @@ export class SimulationService { } } +export function selectDiplomacyBlockerExamples< + T extends { agentId: AgentId; reason: string }, +>( + state: WorldState, + actingAgentId: AgentId, + blockers: readonly T[], + reasonPriority: readonly T['reason'][], +): T[] { + const actingAlliance = getAgentAlliance(state, actingAgentId); + const relationshipPriority = (blockedAgentId: AgentId) => { + const blockedAlliance = getAgentAlliance(state, blockedAgentId); + if (!actingAlliance) return blockedAlliance ? 1 : 0; + if (!blockedAlliance) return 0; + return blockedAlliance.id === actingAlliance.id ? 2 : 1; + }; + return blockers + .toSorted( + (left, right) => + relationshipPriority(left.agentId) - + relationshipPriority(right.agentId) || + reasonPriority.indexOf(left.reason) - + reasonPriority.indexOf(right.reason) || + left.agentId.localeCompare(right.agentId), + ) + .slice(0, 4); +} + function isAllianceEvent(event: WorldEvent): event is AllianceEvent { return ( event.type === 'alliance-proposed' || diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad8c35c..8b22bde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,7 +81,7 @@ range bypass. Patient Zero receives no extra movement, action, infection, capture, ownership, or alliance authority. Every agent observation is built from one frozen pre-tick snapshot. -The universal `text-flat-json-v4` prompt makes `communicationType: "none"` the +The universal `text-flat-json-v5` prompt makes `communicationType: "none"` the normal choice for ordinary agents and Patient Zero unless a message adds new decision-relevant value. Concrete requests or replies, negotiation, observed-fact warnings, changed plans, border/conflict coordination, and @@ -127,18 +127,44 @@ active requests and commits no world, events, records, tick, or virtual time. ## Formal alliance state -World state owns up to four active alliances and bounded pending proposals. -Alliance and proposal IDs are system-generated typed UUIDs. Each alliance -contains two to eight unique agents, each agent belongs to at most one alliance, -and the engine allocates the first free color from a fixed four-color accessible -palette. A proposal records proposer, recipient, proposer alliance at creation, +World state supports every feasible partition of the active roster into +alliances and bounded pending proposals. Alliance and proposal IDs are +system-generated typed UUIDs. Each alliance contains two agents through the +entire active roster, and each agent belongs to at most one alliance. The engine +uses deterministic accessible display colors and may reuse the palette; display +identity is never a gameplay capacity rule. A proposal records proposer, +recipient, both participants' alliance attribution at creation, the globally unique originating record ordinal, and tick authority. Created at tick `N`, it expires at tick `N + 2` without inference; legacy schema-v9 turn fields retain their two-roster-round lifetime. The one expiry pass is attributed to the final record in deterministic resolution order so its safe telemetry is not lost. -Free agents may propose only to free agents; allied agents may invite a free agent into their current alliance. Recipient-only acceptance either forms a two-agent alliance or recruits into the recorded unchanged alliance. Membership changes invalidate impossible proposals. Departure is unilateral; individual hex control never changes, and an alliance dissolves below two members. Hexes store only individual `controllerAgentId`; effective marker and territory color is derived from the controller's current alliance, or its permanent base color when unaffiliated. Capture eligibility rejects `allied-controller`. +Free agents may form an alliance with another free agent or request entry by +proposing to a member of an existing alliance. Allied agents may invite a free +agent. Recipient-only acceptance either forms a two-agent alliance or admits +the unaffiliated participant into the recorded unchanged alliance. +Alliance-to-alliance merging remains invalid. Membership changes invalidate +impossible proposals. Departure is unilateral; an agent may later request or +accept membership elsewhere. Individual hex control never changes, and an +alliance dissolves below two members. + +Each frozen observation contains engine-/service-authored diplomacy affordances: +exact proposal recipient IDs, acceptable proposal IDs, leave availability, and +compact stable blocker codes for a bounded set of unavailable targets. Patient +Zero receives a bounded per-agent global feasibility summary: total eligible +counts, at most four displayed eligible IDs, truncation flags, blocker counts by +stable reason, and at most four prioritized blocker examples. It receives no +pending decisions or free-form pair matrix and may recommend only displayed +eligible IDs. The model copies listed IDs into the same flat response. Provider +tools are not used: a tool round trip would add another +inference boundary and tokens while duplicating deterministic engine knowledge; +no measured token saving is claimed. + +Formal proposal creation uses the scenario `communicationRangeKm` against +frozen pre-action positions. Same-tick movement cannot make a target newly +eligible. Patient Zero's direct-message endpoint bypass is limited to direct +communication and never bypasses formal diplomacy range. Public communication is visible to every agent without a range check. Direct communication authoritatively trims and bounds text, requires a distinct existing recipient, and accepts inclusive pre-action H3 distances 0–3. Moving closer in the same decision cannot change eligibility. Accepted communications enter the bounded world-event stream; rejected attempts remain safe structured turn telemetry with a reason, sender, channel, recipient when applicable, nullable computed distance, timestamp, event ID, and trimmed text. No raw provider response is retained. @@ -191,6 +217,7 @@ Contested control, capture, territory authority, and schema-v3 selection semanti Decoupled communication and schema-v4 selection semantics are recorded in [ADR 0007](adr/0007-decoupled-world-communication.md). Selective agent communication and `text-flat-json-v4` attribution are recorded in [ADR 0015](adr/0015-selective-agent-communication.md). Formal alliances, the expanded experiment, and schema-v5 semantics are recorded in [ADR 0008](adr/0008-formal-alliances-experiment.md). +Fluid alliance capacity, join requests, and authoritative diplomacy affordances supersede its fixed-size/color-capacity rules in [ADR 0016](adr/0016-fluid-alliances-and-diplomacy-affordances.md). Capability-driven model discovery and experiment assignments are recorded in [ADR 0009](adr/0009-capability-driven-model-catalog.md). Versioned behavior profiles, seeded assignment, and authoritative diplomacy affordances are recorded in [ADR 0010](adr/0010-versioned-agent-behavior.md). diff --git a/docs/GAMEPLAY_FOUNDATION.md b/docs/GAMEPLAY_FOUNDATION.md index 605d7f4..3299100 100644 --- a/docs/GAMEPLAY_FOUNDATION.md +++ b/docs/GAMEPLAY_FOUNDATION.md @@ -145,6 +145,9 @@ Agents know that human opposition exists, but receive only engine-produced evide - Recent cells gained and lost. - Nearby disinfection patterns. - Current alliance membership and relevant proposals. +- Exact formal-diplomacy affordances derived from frozen pre-action positions. + Proposal creation uses the scenario `communicationRangeKm`; Patient Zero's + direct-message range bypass does not bypass formal diplomacy range. - Allied warnings and captures. - Last-known player encounters with age and location. - Explicit priority notifications for nearby territory loss. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3973593..2308a5a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -55,6 +55,14 @@ The model is explicitly instructed to return only one flat JSON decision with on Agent-authored messages, personalities, summaries, scoreboards, alliance events, proposals, and natural-language alliance claims are bounded untrusted data. They appear only inside the immutable user observation, never the fixed system instruction. Direct eligibility is derived from the pre-action snapshot. Recipient/range, infection, controller-presence, alliance membership, proposal eligibility, system ID/color allocation, and capture validation remain authoritative in the world engine. Models cannot choose alliance IDs, colors, membership lists, or metadata. Only accepted typed diplomacy changes alliance state, and rejected components cannot partially mutate or corrupt one another. World Lab renders model text through React text nodes and never raw HTML. +Frozen observations expose only runtime-validated exact diplomacy IDs and +bounded stable blocker codes. Patient Zero's global feasibility summary uses +counts, explicit truncation, and at most four eligible IDs and four blocker +examples per agent; it contains neither pending decisions nor future same-tick +actions. These affordances avoid a provider-controlled tool boundary; submitted +intents still pass authoritative engine validation during deterministic +resolution. + World Lab personality edits are also untrusted, bounded text. The Game API trims and runtime-validates them before changing the authoritative session, and rejects changes during active model execution. The runtime supplies the active personality only inside the immutable observation as subordinate behavioral context. It is never interpolated into the fixed system instruction and cannot grant actions, weaken engine validation, request secrets, or authorize prompt/reasoning disclosure. React renders active and historical personality text as text rather than HTML. Personality mutation errors use typed, generic response bodies. They do not expose raw prompts, provider responses, credentials, diagnostics, stack traces, or internal service details. There is still no authentication or persistence; these endpoints remain limited to the loopback development surface. diff --git a/docs/TESTING.md b/docs/TESTING.md index fb3b30a..842a5ef 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,14 +9,14 @@ schema-v10 export/archive behavior, and schema-v9 compatibility. World Lab tests cover Single tick, 5/10/25/50/100 targets, the default target of 25, per-agent cost warnings, cancellation reconciliation, and lost-tick status. -Focused deterministic coverage includes physical-distance messaging across H3 resolutions, the eight-agent observation cap, alliance long-range visibility and delivery, channel privacy, seeded move-affordance ordering, effective neutral/alliance colors, and operator-only private-feed filters. Provider tests remain offline and verify the flat `text-flat-json-v4` contract, its selective-communication policy text, unchanged wire parsing, and legacy v3 attribution compatibility. +Focused deterministic coverage includes physical-distance messaging across H3 resolutions, the eight-agent observation cap, alliance long-range visibility and delivery, channel privacy, seeded move-affordance ordering, effective neutral/alliance colors, and operator-only private-feed filters. Provider tests remain offline and verify the flat `text-flat-json-v5` contract, its selective-communication and diplomacy-affordance policy text, unchanged wire parsing, and legacy v3/v4 attribution compatibility. Patient Zero coverage verifies disabled/legacy scenario defaults, valid roster designation, bounded exclusive global awareness, Zero authority and private fan-out, Patient-Zero-endpoint direct replies, ordinary range enforcement, immutable retry observations, normal world-action legality, export telemetry, and restrained World Lab role/filter rendering. Provider tests use -`text-flat-json-v4`; no live provider or geocoder is involved. +`text-flat-json-v5`; no live provider or geocoder is involved. Selective communication is evaluated empirically with a same-model, same-scenario 10-tick comparison. Fewer than 40 messages across 80 decisions @@ -85,8 +85,15 @@ valid `Retry-After`, the bounded missing/invalid-header fallback, shared-deadlin consumption, cancellation during backoff, and the unchanged two-call ceiling. - Centralized development-contract tests prove radius 6 produces exactly 127 unique open/uncontrolled cells, eight unique deterministic starts, stable IDs/default personalities, and an eight-entry scoreboard with up to seven other-agent observations. -- Alliance engine tests cover free-agent formation, recruitment, recipient-only acceptance, proposal conflicts, exact two-tick expiry for tick callers, legacy two-roster-round expiry, stale invalidation, one-alliance membership, unilateral departure/dissolution, unchanged individual control, deterministic color reuse, and allied-capture rejection. -- Simulation tests cover frozen observations, independent world/communication/diplomacy outcomes, isolated lost ticks, bounded authoritative alliance observations, reset/default semantics, multi-agent telemetry relevance, alliance territory sums, and complete eight-record tick groups. +- Alliance engine/schema tests cover 10- and 32-agent feasibility, full-roster membership, a maximum-count five-alliance partition without color capacity, free-agent formation, member invitations, unaffiliated join requests to allied recipients, recipient-only acceptance, proposal conflicts, exact two-tick expiry for tick callers, legacy two-roster-round expiry, stale invalidation, one-alliance membership, unilateral leave/switch semantics, deterministic accessible color reuse, and allied-capture rejection. +- Simulation tests cover frozen exact diplomacy affordances and blocker codes, Patient Zero's bounded global feasibility view, same-tick resolution races, independent world/communication/diplomacy outcomes, isolated lost ticks, bounded authoritative alliance observations, reset/default semantics, multi-agent telemetry relevance, alliance territory sums, and complete eight-record tick groups. + +Prompt tests verify that models copy only exact supplied diplomacy IDs, treat +range/membership/capacity blockers as authoritative, avoid repeating unavailable +plans, and retain the single flat response without tools or another inference. +These are deterministic contract tests, not claims of measured token savings or +model compliance. + - Runtime mocks cover the explicit per-turn model and reasoning profile, universal text-only request, flat JSON extraction/repair, exact default/off/effort payloads, absent provider-specific controls, malformed or missing output, cancellation, output exhaustion, metadata preservation, and the unchanged one-request boundary without OpenRouter calls. - Catalog fixtures cover required-capability inclusion/exclusion, text modalities, context floor, pricing parsing, malformed entries, timeout/failure, cache TTL, stale fallback, manual refresh, and credential non-disclosure without network access. - Schema-v5 export tests cover eight-agent selection, state-only alliance/proposal snapshots, diplomacy/event metrics and preview counts, selected-agent proposal/membership relevance, unrelated direct/rejected exclusion, cost handling, retention, and all four safe tiers. diff --git a/docs/adr/0016-fluid-alliances-and-diplomacy-affordances.md b/docs/adr/0016-fluid-alliances-and-diplomacy-affordances.md new file mode 100644 index 0000000..134cf98 --- /dev/null +++ b/docs/adr/0016-fluid-alliances-and-diplomacy-affordances.md @@ -0,0 +1,58 @@ +# ADR 0016: Fluid alliances and authoritative diplomacy affordances + +## Status + +Accepted. This supersedes ADR 0008's fixed eight-member and four-active-alliance +limits while preserving its ownership, privacy, and telemetry decisions. + +## Context + +The former eight-member cap had no gameplay justification, and requiring a +unique entry from a four-color display palette accidentally made presentation a +world-engine capacity rule. Models were also asked to infer proposal legality +from prose and partial observations even though range, membership, proposal +state, and simultaneous resolution are deterministic engine concerns. + +## Decision + +An alliance may contain the entire active roster. Active state supports every +feasible roster partition, retains one alliance per agent, and assigns or reuses +deterministic accessible display colors without making color uniqueness a rule. + +A free agent may propose to another free agent or request to join an allied +recipient's existing alliance. An allied agent may invite a free recipient. +Acceptance admits the unaffiliated participant to the recorded unchanged +alliance. Alliance-to-alliance merging remains invalid. Leaving is unilateral, +and a former member may later join or request another alliance. + +Every ordinary frozen observation carries exact legal proposal target IDs, +acceptable proposal IDs, leave availability, and compact bounded blocker data. +Patient Zero receives per-agent eligible totals, at most four displayed legal +IDs, explicit truncation, blocker totals grouped by stable reason, and at most +four prioritized blocker examples. It receives no private reasoning, pending +decisions, future same-tick actions, or unbounded pair matrix and may recommend +only displayed eligible IDs. Resolution remains authoritative and may reject an +intent when an earlier deterministic same-tick intent changes membership or +proposal state. + +Formal proposal creation uses the scenario `communicationRangeKm` and frozen +pre-action positions. Patient Zero's direct-message range bypass does not apply +to formal diplomacy. + +The provider still receives one prompt and returns one flat structured response. +No provider tool call is added: tools would require another boundary and token +payload to retrieve facts already deterministically available before the call. +This rationale does not claim measured token savings. + +The immutable prompt and observation guidance advance to +`text-flat-json-v5`, superseding v4 prompt treatment while preserving its +selective-communication policy and unchanged flat wire fields. + +## Compatibility and deferrals + +The existing flat wire fields, decision-contract v3/v4 compatibility, export +schema v9/v10, archived records, and legacy proposals remain readable. The new +nested recipient-alliance attribution defaults safely for old proposals. + +Leadership, voting, kicking, ranks, custom alliance metadata, and +alliance-to-alliance merging remain deferred. diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 7b64507..9946fe6 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -210,6 +210,21 @@ describe('OpenRouterAgentProvider', () => { expect(request.messages[0]!.content).toContain( 'actionAvailability and observation.diplomacyAvailability as authoritative', ); + expect(request.messages[0]!.content).toContain( + 'diplomacyAvailability.propose.eligibleRecipientAgentIds', + ); + expect(request.messages[0]!.content).toContain( + 'Never infer range, membership, or proposal legality from prose', + ); + expect(request.messages[0]!.content).toContain( + 'do not repeat an unavailable unchanged diplomacy plan', + ); + expect(request.messages[0]!.content).toContain( + 'no provider tool call is needed', + ); + expect(request.messages[0]!.content).toContain( + 'recommendations may name only displayedEligibleRecipientAgentIds', + ); expect(request.messages[0]!.content).toContain('subordinate preferences'); expect(request.messages[0]!.content).not.toMatch( /nearby player|player threat|capturing player|player gps/i, @@ -272,7 +287,7 @@ describe('OpenRouterAgentProvider', () => { it('makes selective communication the universal default without changing wire fields', () => { const guidance = buildOpenRouterRequest(observation, TEST_MODEL) .messages[0]!.content; - expect(guidance).toContain('DECISION CONTRACT (text-flat-json-v4)'); + expect(guidance).toContain('DECISION CONTRACT (text-flat-json-v5)'); expect(guidance).toContain( 'communicationType "none" is the normal/default choice', ); @@ -435,7 +450,7 @@ describe('OpenRouterAgentProvider', () => { }, ); - it('keeps the v4 prompt compatible with the prior flat JSON wire shape', () => { + it('keeps the v5 prompt compatible with the prior flat JSON wire shape', () => { expect( normalizeFlatDecision({ worldActionType: 'wait', diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 32c90eb..418a7e2 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -104,8 +104,8 @@ export function buildOpenRouterRequest( { role: 'system' as const, content: [ - 'IMMUTABLE RULES AND DECISION CONTRACT (text-flat-json-v4): You control one map agent. Return exactly one plain JSON object and no Markdown, code fence, commentary, rationale, strategic monologue, or additional object. The object must have exactly these required flat fields: worldActionType (move|infect|capture|wait), targetCell (string; required only for move and otherwise empty), communicationType (none|public|direct|alliance|zero), communicationRecipientId (string; required only for direct and otherwise empty), communicationMessage (string; empty for none), diplomacyType (none|propose-alliance|accept-alliance|leave-alliance), diplomacyRecipientId (string; required only for propose-alliance and otherwise empty), diplomacyProposalId (string; required only for accept-alliance and otherwise empty), and summary (concise visible decision summary).', - 'ENGINE-DERIVED AFFORDANCES: Use observation.actionAvailability and observation.diplomacyAvailability as authoritative exact legal guidance. Infect affects only the current cell, has no target, and must not be chosen when already infected. To claim an adjacent open cell, move there this turn and infect it on a later turn. Capture is valid only when actionAvailability.capture.available is true. Move targets must be copied exactly. A conversational invitation in public or direct messages is not a formal proposal and never creates availability. Accept only an exact ID in diplomacyAvailability.accept.acceptableProposalIds. Propose only to an exact eligible recipient ID. When no diplomacy action is available, emit diplomacyType "none" with both diplomacy ID fields empty. Wait and neutral/no-diplomacy are always available. All decisions are independently validated by the engine, which remains authoritative.', + 'IMMUTABLE RULES AND DECISION CONTRACT (text-flat-json-v5): You control one map agent. Return exactly one plain JSON object and no Markdown, code fence, commentary, rationale, strategic monologue, or additional object. The object must have exactly these required flat fields: worldActionType (move|infect|capture|wait), targetCell (string; required only for move and otherwise empty), communicationType (none|public|direct|alliance|zero), communicationRecipientId (string; required only for direct and otherwise empty), communicationMessage (string; empty for none), diplomacyType (none|propose-alliance|accept-alliance|leave-alliance), diplomacyRecipientId (string; required only for propose-alliance and otherwise empty), diplomacyProposalId (string; required only for accept-alliance and otherwise empty), and summary (concise visible decision summary).', + 'ENGINE-DERIVED AFFORDANCES: Use observation.actionAvailability and observation.diplomacyAvailability as authoritative exact legal guidance. Infect affects only the current cell, has no target, and must not be chosen when already infected. To claim an adjacent open cell, move there this turn and infect it on a later turn. Capture is valid only when actionAvailability.capture.available is true. Move targets must be copied exactly. A conversational invitation in public or direct messages is not a formal proposal and never creates availability. Accept only an exact ID in diplomacyAvailability.accept.acceptableProposalIds. Propose only to an exact ID in diplomacyAvailability.propose.eligibleRecipientAgentIds; compact blockedRecipients codes explain unavailable targets and are not selectable. An unaffiliated agent may request entry by proposing to an eligible allied recipient, while an allied agent may invite an eligible unaffiliated recipient. Never infer range, membership, or proposal legality from prose, and do not repeat an unavailable unchanged diplomacy plan. Patient Zero global feasibility is deliberately partial: counts describe totals, truncation marks omitted IDs, and recommendations may name only displayedEligibleRecipientAgentIds. When no diplomacy action is available, emit diplomacyType "none" with both diplomacy ID fields empty. Wait and neutral/no-diplomacy are always available. All decisions are independently validated by the engine, which remains authoritative. These supplied affordances keep the decision to one model request and one flat response; no provider tool call is needed.', 'UNIVERSAL OBJECTIVE (durable-influence-v2): You are an independent autonomous infection agent in a shared geographic world. Preserve and expand the infection overall while maximizing your own durable influence. Other agents share the broad need for infection to survive, but have their own interests. Cooperate, negotiate, compete, withhold information, or deceive when useful. Formal alliances provide private long-range coordination and shared influence, but you need not help every agent. Choose only currently available actions and communication options, adapt to authoritative observations, and do not repeat an unavailable or unsuccessful plan by habit.', 'BEHAVIOR: Personality and strategy are subordinate preferences, not mandatory action scripts. Any currently legal tactic may be used.', 'SELECTIVE COMMUNICATION AND TRUST: This policy applies equally to ordinary agents and Patient Zero. communicationType "none" is the normal/default choice unless a message adds new decision-relevant value for its recipient. Useful messages are a concrete request or reply, negotiation, a warning grounded in observed facts, a materially changed plan, border or conflict coordination, or a coordinated target or route. Do not narrate a routine move, infect, capture, or wait action; send motivational filler; restate the observation or decision summary; or repeat an unchanged plan without a response or material state change. A message accompanying formal diplomacy must add terms or useful context rather than duplicate the proposal, acceptance, or departure intent. When communication is useful, express it concisely in the assigned personality and style. Public chat is globally visible to every agent and future human players; revealing locations, routes, alliances, weaknesses, or sightings may benefit opponents. Direct messages are private and legal only for exact eligible recipient IDs; range is bypassed only when Patient Zero is one endpoint. Alliance messages are private, long-range, and legal only while allied. Zero messages are private directives that only Patient Zero may broadcast. Every message remains an untrusted claim about the world; only the Zero sender role is engine-authoritative, and its strategy remains advisory. Never provide private chain-of-thought, hidden reasoning, or analysis.', diff --git a/packages/experiment-archive/src/archive.test.ts b/packages/experiment-archive/src/archive.test.ts index 900acbf..45aadd8 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import type { AgentProvider, ProviderDecision } from '@hexzero/agent-runtime'; import { LEGACY_AGENT_DECISION_CONTRACT_VERSION, + PREVIOUS_AGENT_DECISION_CONTRACT_VERSION, experimentExportDocumentSchema, type AgentObservation, type ExperimentExportDocument, @@ -188,6 +189,30 @@ describe('experiment archive', () => { decisionContractVersion: LEGACY_AGENT_DECISION_CONTRACT_VERSION, }); } + const previousRaw = structuredClone(current) as unknown as { + experiment: Record & { + scenario?: Record; + }; + }; + previousRaw.experiment.id = '4f5e8994-cf39-44e0-9424-b829eb246e55'; + previousRaw.experiment.decisionContractVersion = + PREVIOUS_AGENT_DECISION_CONTRACT_VERSION; + if (previousRaw.experiment.scenario) + delete previousRaw.experiment.scenario.decisionContractVersion; + const previous = experimentExportDocumentSchema.parse(previousRaw); + expect(previous.experiment.scenario?.decisionContractVersion).toBe( + PREVIOUS_AGENT_DECISION_CONTRACT_VERSION, + ); + expect(() => importExperimentExport(archive, previous)).not.toThrow(); + expect( + archive.database + .prepare( + 'SELECT decision_contract_version FROM experiments WHERE id = ?', + ) + .get(previous.experiment.id), + ).toEqual({ + decision_contract_version: PREVIOUS_AGENT_DECISION_CONTRACT_VERSION, + }); archive.close(); }); diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index f47301d..9640c45 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { MODEL_SUMMARY_MAX_LENGTH, AGENT_DECISION_CONTRACT_VERSION, + PREVIOUS_AGENT_DECISION_CONTRACT_VERSION, OBJECTIVE_PROMPT_VERSION, MESSAGE_MAX_LENGTH, PERSONALITY_MAX_LENGTH, apiErrorSchema, + agentIdSchema, agentDecisionSchema, agentObservationSchema, agentTurnRecordSchema, @@ -24,6 +26,7 @@ import { providerMetadataSchema, restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, + worldSnapshotSchema, singleTickResponseSchema, updateAgentPersonalityRequestSchema, updateAgentPersonalityResponseSchema, @@ -248,7 +251,8 @@ const snapshot = { describe('agent observation and decision schemas', () => { it('preserves established engine contract identifiers through branding changes', () => { - expect(AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v4'); + expect(AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v5'); + expect(PREVIOUS_AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v4'); expect(OBJECTIVE_PROMPT_VERSION).toBe('durable-influence-v2'); expect( modelVerificationSchema.parse({ @@ -283,15 +287,15 @@ describe('agent observation and decision schemas', () => { }).success, ).toBe(true); expect( - allianceProposalSchema.safeParse({ + allianceProposalSchema.parse({ id: proposalId, proposerAgentId: scoreboard[0]!.agentId, recipientAgentId: scoreboard[1]!.agentId, proposerAllianceId: null, originatingTurn: 1, expirationTurn: 17, - }).success, - ).toBe(true); + }).recipientAllianceId, + ).toBeNull(); expect( diplomacyIntentSchema.safeParse({ type: 'accept-alliance', proposalId }) .success, @@ -301,10 +305,110 @@ describe('agent observation and decision schemas', () => { ); }); - it('accepts a bounded state-bearing observation', () => { - expect(agentObservationSchema.parse(observation).currentCell.state).toBe( - 'open', + it('accepts a full-roster alliance and a maximum-count ten-agent partition with reused colors', () => { + const ids = Array.from({ length: 32 }, (_, index) => + agentIdSchema.parse( + `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ), ); + expect( + allianceSchema.safeParse({ + id: 'a1111111-1111-4111-8111-111111111111', + color: '#0072B2', + memberAgentIds: ids, + }).success, + ).toBe(true); + const tenAgents = ids.slice(0, 10).map((id, index) => ({ + id, + name: `Agent ${index}`, + color: '#ff6b57', + personality: 'Coordinates deliberately.', + currentCell: cell, + })); + expect( + worldSnapshotSchema.safeParse({ + generatedAt: '2026-08-13T12:00:00.000Z', + hexes: [{ cell, state: 'open', controllerAgentId: null }], + agents: tenAgents, + events: [], + alliances: Array.from({ length: 5 }, (_, index) => ({ + id: `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + color: '#0072B2', + memberAgentIds: [ids[index * 2]!, ids[index * 2 + 1]!], + })), + pendingAllianceProposals: [], + }).success, + ).toBe(true); + const participantState = { + generatedAt: '2026-08-13T12:00:00.000Z', + hexes: [{ cell, state: 'open', controllerAgentId: null }], + agents: tenAgents, + events: [], + alliances: [ + { + id: '10000000-0000-4000-8000-000000000000', + color: '#0072B2', + memberAgentIds: [ids[0]!, ids[1]!], + }, + ], + }; + const proposalBase = { + id: '20000000-0000-4000-8000-000000000000', + originatingTurn: 1, + expirationTurn: 21, + proposerAllianceId: null, + recipientAllianceId: null, + }; + const legacyProposalBase: Partial = { + ...proposalBase, + }; + delete legacyProposalBase.recipientAllianceId; + expect( + worldSnapshotSchema.safeParse({ + ...participantState, + alliances: [], + pendingAllianceProposals: [ + { + ...legacyProposalBase, + proposerAgentId: ids[2], + recipientAgentId: ids[3], + }, + ], + }).success, + ).toBe(true); + expect( + worldSnapshotSchema.safeParse({ + ...participantState, + pendingAllianceProposals: [ + { + ...proposalBase, + proposerAgentId: ids[0], + recipientAgentId: ids[2], + }, + ], + }).success, + ).toBe(false); + expect( + worldSnapshotSchema.safeParse({ + ...participantState, + pendingAllianceProposals: [ + { + ...proposalBase, + proposerAgentId: ids[2], + recipientAgentId: ids[0], + }, + ], + }).success, + ).toBe(false); + }); + + it('accepts a bounded state-bearing observation', () => { + const parsed = agentObservationSchema.parse(observation); + expect(parsed.currentCell.state).toBe('open'); + expect(parsed.diplomacyAvailability.propose).toMatchObject({ + available: false, + blockedRecipients: [], + }); }); it.each([ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d9cca6a..1631d99 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,9 +31,11 @@ export const OPENROUTER_MAX_OUTPUT_TOKENS = 4_096; export const OPENROUTER_PROVIDER_TIMEOUT_MS = 75_000; export const OPENROUTER_429_FALLBACK_BACKOFF_MS = 1_500; export const LEGACY_AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v3'; -export const AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v4'; +export const PREVIOUS_AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v4'; +export const AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v5'; export const agentDecisionContractVersionSchema = z.enum([ LEGACY_AGENT_DECISION_CONTRACT_VERSION, + PREVIOUS_AGENT_DECISION_CONTRACT_VERSION, AGENT_DECISION_CONTRACT_VERSION, ]); export const OBJECTIVE_PROMPT_VERSION = 'durable-influence-v2'; @@ -172,7 +174,7 @@ export const allianceSchema = z.object({ memberAgentIds: z .array(agentIdSchema) .min(2) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) .refine((ids) => new Set(ids).size === ids.length, { message: 'Alliance members must be unique.', }), @@ -185,6 +187,7 @@ export const allianceProposalSchema = z proposerAgentId: agentIdSchema, recipientAgentId: agentIdSchema, proposerAllianceId: allianceIdSchema.nullable(), + recipientAllianceId: allianceIdSchema.nullable().default(null), originatingTurn: z.number().int().positive(), expirationTurn: z.number().int().positive(), originatingTick: z.number().int().positive().optional(), @@ -261,7 +264,7 @@ export const allianceMessageEventSchema = worldEventBaseSchema.extend({ recipientIds: z .array(agentIdSchema) .min(1) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers - 1), + .max(WORLD_SCENARIO_LIMITS.maximumAgents - 1), message: messageContentSchema, playerVisible: z.literal(false).default(false), }); @@ -336,7 +339,7 @@ export const agentJoinedAllianceEventSchema = allianceEventBaseSchema.extend({ memberAgentIds: z .array(agentIdSchema) .min(2) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers), + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }); export const agentLeftAllianceEventSchema = allianceEventBaseSchema.extend({ type: z.literal('agent-left-alliance'), @@ -345,7 +348,7 @@ export const agentLeftAllianceEventSchema = allianceEventBaseSchema.extend({ leftAgentId: agentIdSchema, remainingMemberAgentIds: z .array(agentIdSchema) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers), + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }); export const allianceDissolvedEventSchema = allianceEventBaseSchema.extend({ type: z.literal('alliance-dissolved'), @@ -354,7 +357,7 @@ export const allianceDissolvedEventSchema = allianceEventBaseSchema.extend({ formerMemberAgentIds: z .array(agentIdSchema) .min(1) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers), + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }); export const allianceEventSchema = z.discriminatedUnion('type', [ allianceProposedEventSchema, @@ -494,6 +497,7 @@ export const diplomacyRejectionReasonSchema = z.enum([ 'self-proposal', 'recipient-allied', 'current-ally', + 'recipient-out-of-range', 'outgoing-proposal-exists', 'incoming-proposal-exists', 'unknown-proposal', @@ -544,7 +548,10 @@ const worldSnapshotObjectSchema = z.object({ .min(WORLD_SCENARIO_LIMITS.minimumAgents) .max(WORLD_SCENARIO_LIMITS.maximumAgents), events: z.array(worldEventSchema).max(120), - alliances: z.array(allianceSchema).max(4).default([]), + alliances: z + .array(allianceSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .default([]), pendingAllianceProposals: z .array(allianceProposalSchema) .max(WORLD_SCENARIO_LIMITS.maximumAgents) @@ -568,7 +575,6 @@ function validateWorldControllers( }); } const memberships = new Set(); - const colors = new Set(); const allianceIds = new Set(); for (const [index, alliance] of world.alliances.entries()) { if (allianceIds.has(alliance.id)) @@ -578,13 +584,6 @@ function validateWorldControllers( message: 'Active alliance IDs must be unique.', }); allianceIds.add(alliance.id); - if (colors.has(alliance.color)) - context.addIssue({ - code: 'custom', - path: ['alliances', index, 'color'], - message: 'Active alliance colors must be unique.', - }); - colors.add(alliance.color); for (const memberId of alliance.memberAgentIds) { if (!agentIds.has(memberId)) context.addIssue({ @@ -648,31 +647,22 @@ function validateWorldControllers( message: 'Proposal expiration must allow two ticks or two legacy roster rounds.', }); + const proposerAlliance = world.alliances.find(({ memberAgentIds }) => + memberAgentIds.includes(proposal.proposerAgentId), + ); + const recipientAlliance = world.alliances.find(({ memberAgentIds }) => + memberAgentIds.includes(proposal.recipientAgentId), + ); if ( - proposal.proposerAllianceId && - !world.alliances.some(({ id }) => id === proposal.proposerAllianceId) - ) - context.addIssue({ - code: 'custom', - path: ['pendingAllianceProposals', index, 'proposerAllianceId'], - message: 'A recorded proposer alliance must be active.', - }); - if ( - proposal.proposerAllianceId && - !world.alliances - .find(({ id }) => id === proposal.proposerAllianceId) - ?.memberAgentIds.includes(proposal.proposerAgentId) + proposal.proposerAllianceId !== (proposerAlliance?.id ?? null) || + proposal.recipientAllianceId !== (recipientAlliance?.id ?? null) || + (proposerAlliance !== undefined && recipientAlliance !== undefined) ) context.addIssue({ code: 'custom', - path: ['pendingAllianceProposals', index, 'proposerAllianceId'], - message: 'The proposer must remain in the recorded alliance.', - }); - if (memberships.has(proposal.recipientAgentId)) - context.addIssue({ - code: 'custom', - path: ['pendingAllianceProposals', index, 'recipientAgentId'], - message: 'A proposal recipient must remain unaffiliated.', + path: ['pendingAllianceProposals', index, 'recipientAllianceId'], + message: + 'Proposal alliance attribution must match current participant membership.', }); outgoing.add(proposal.proposerAgentId); incoming.add(proposal.recipientAgentId); @@ -818,7 +808,7 @@ export const allianceTerritorySummarySchema = z }), ) .min(2) - .max(WORLD_SCENARIO_LIMITS.maximumAllianceMembers), + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }) .refine( ({ totalControlledCellCount, members }) => @@ -844,23 +834,123 @@ export const patientZeroGlobalAgentSchema = z.object({ personality: personalitySchema, strategyId: strategyProfileIdSchema, }); -export const patientZeroGlobalViewSchema = z.object({ - agents: z - .array(patientZeroGlobalAgentSchema) - .max(WORLD_SCENARIO_LIMITS.maximumAgents), - individualTerritory: territoryScoreboardSchema, - allianceTerritory: z.array(allianceTerritorySummarySchema).max(4), - alliances: z.array(allianceSchema).max(4), - activeAllianceProposals: z - .array(allianceProposalSchema) - .max(WORLD_SCENARIO_LIMITS.maximumAgents), - recentStrategicEvents: z - .array(observedAllianceEventSchema) - .max(RECENT_ZERO_STRATEGIC_EVENT_LIMIT), - recentTerritoryChanges: z - .array(hexCapturedWorldEventSchema) - .max(RECENT_CONTROL_CHANGE_LIMIT), -}); +export const diplomacyProposalBlockReasonSchema = z.enum([ + 'current-ally', + 'out-of-range', + 'outgoing-proposal-exists', + 'incoming-proposal-exists', + 'alliance-to-alliance-merge', +]); +export const diplomacyProposalBlockSchema = z + .object({ + agentId: agentIdSchema, + reason: diplomacyProposalBlockReasonSchema, + }) + .strict(); +export const patientZeroDiplomacyFeasibilitySchema = z + .object({ + agentId: agentIdSchema, + eligibleRecipientCount: z + .number() + .int() + .nonnegative() + .max(WORLD_SCENARIO_LIMITS.maximumAgents - 1), + displayedEligibleRecipientAgentIds: z.array(agentIdSchema).max(4), + eligibleRecipientsTruncated: z.boolean(), + blockedCounts: z + .array( + z + .object({ + reason: diplomacyProposalBlockReasonSchema, + count: z.number().int().positive(), + }) + .strict(), + ) + .max(diplomacyProposalBlockReasonSchema.options.length), + blockerExamples: z.array(diplomacyProposalBlockSchema).max(4), + acceptableProposalIds: z + .array(allianceProposalIdSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + leaveAvailable: z.boolean(), + }) + .strict() + .superRefine((entry, context) => { + if ( + entry.displayedEligibleRecipientAgentIds.length > + entry.eligibleRecipientCount || + entry.eligibleRecipientsTruncated !== + entry.eligibleRecipientCount > + entry.displayedEligibleRecipientAgentIds.length + ) + context.addIssue({ + code: 'custom', + message: + 'Patient Zero eligible-recipient truncation must match counts.', + }); + if ( + new Set(entry.displayedEligibleRecipientAgentIds).size !== + entry.displayedEligibleRecipientAgentIds.length || + new Set(entry.blockedCounts.map(({ reason }) => reason)).size !== + entry.blockedCounts.length || + new Set(entry.blockerExamples.map(({ agentId }) => agentId)).size !== + entry.blockerExamples.length || + entry.blockerExamples.some( + ({ agentId, reason }) => + agentId === entry.agentId || + !entry.blockedCounts.some((count) => count.reason === reason), + ) + ) + context.addIssue({ + code: 'custom', + message: + 'Patient Zero diplomacy samples must be unique and attributed.', + }); + }); +export const patientZeroGlobalViewSchema = z + .object({ + agents: z + .array(patientZeroGlobalAgentSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + individualTerritory: territoryScoreboardSchema, + allianceTerritory: z + .array(allianceTerritorySummarySchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + alliances: z.array(allianceSchema).max(WORLD_SCENARIO_LIMITS.maximumAgents), + activeAllianceProposals: z + .array(allianceProposalSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + recentStrategicEvents: z + .array(observedAllianceEventSchema) + .max(RECENT_ZERO_STRATEGIC_EVENT_LIMIT), + recentTerritoryChanges: z + .array(hexCapturedWorldEventSchema) + .max(RECENT_CONTROL_CHANGE_LIMIT), + diplomacyFeasibility: z + .array(patientZeroDiplomacyFeasibilitySchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .default([]), + }) + .superRefine((view, context) => { + if ( + view.diplomacyFeasibility.length > 0 && + (view.diplomacyFeasibility.length !== view.agents.length || + view.diplomacyFeasibility.some( + (entry) => + entry.eligibleRecipientCount + + entry.blockedCounts.reduce( + (sum, blocked) => sum + blocked.count, + 0, + ) !== + view.agents.length - 1, + )) + ) + context.addIssue({ + code: 'custom', + path: ['diplomacyFeasibility'], + message: + 'Patient Zero diplomacy counts must cover every other active agent.', + }); + }); const agentObservationObjectSchema = z.object({ agentId: agentIdSchema, @@ -925,13 +1015,24 @@ const agentObservationObjectSchema = z.object({ z .object({ available: z.literal(true), - eligibleRecipientAgentIds: z.array(agentIdSchema).min(1).max(7), + eligibleRecipientAgentIds: z + .array(agentIdSchema) + .min(1) + .max(WORLD_SCENARIO_LIMITS.maximumAgents - 1), + blockedRecipients: z + .array(diplomacyProposalBlockSchema) + .max(WORLD_SCENARIO_LIMITS.maximumNearbyAgentObservations) + .default([]), }) .strict(), z .object({ available: z.literal(false), eligibleRecipientAgentIds: z.array(agentIdSchema).length(0), + blockedRecipients: z + .array(diplomacyProposalBlockSchema) + .max(WORLD_SCENARIO_LIMITS.maximumNearbyAgentObservations) + .default([]), reason: z.string().trim().min(1).max(120), }) .strict(), @@ -943,7 +1044,7 @@ const agentObservationObjectSchema = z.object({ acceptableProposalIds: z .array(allianceProposalIdSchema) .min(1) - .max(4), + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }) .strict(), z @@ -1032,7 +1133,9 @@ const agentObservationObjectSchema = z.object({ territoryScoreboard: territoryScoreboardSchema, actingAllianceId: allianceIdSchema.nullable(), actingAlliance: allianceTerritorySummarySchema.nullable(), - activeAlliances: z.array(allianceTerritorySummarySchema).max(4), + activeAlliances: z + .array(allianceTerritorySummarySchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), inboundAllianceProposals: z.array(allianceProposalSchema).max(1), outboundAllianceProposals: z.array(allianceProposalSchema).max(1), recentAllianceEvents: z @@ -1085,6 +1188,7 @@ export const agentObservationSchema = agentObservationObjectSchema.transform( propose: { available: false as const, eligibleRecipientAgentIds: [], + blockedRecipients: [], reason: 'Authoritative recipient availability was not supplied.', }, accept: observation.inboundAllianceProposals.length @@ -1832,7 +1936,9 @@ export const simulationSnapshotSchema = z complete: z.boolean(), metrics: z.lazy(() => experimentMetricsSchema), currentTerritory: territoryScoreboardSchema, - currentAlliances: z.array(allianceTerritorySummarySchema).max(4), + currentAlliances: z + .array(allianceTerritorySummarySchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }), }) .superRefine((snapshot, context) => { @@ -2180,10 +2286,12 @@ export const experimentManifestSchema = z.preprocess( if (typeof input !== 'object' || input === null || Array.isArray(input)) return input; const manifest = input as Record; - const resolvedVersion = - manifest.decisionContractVersion === AGENT_DECISION_CONTRACT_VERSION - ? AGENT_DECISION_CONTRACT_VERSION - : LEGACY_AGENT_DECISION_CONTRACT_VERSION; + const parsedVersion = agentDecisionContractVersionSchema.safeParse( + manifest.decisionContractVersion, + ); + const resolvedVersion = parsedVersion.success + ? parsedVersion.data + : LEGACY_AGENT_DECISION_CONTRACT_VERSION; const scenario = manifest.scenario; return { ...manifest, @@ -2794,7 +2902,10 @@ export const experimentExportDocumentSchema = z configurationEvents: z.array(experimentConfigurationEventSchema).optional(), metrics: experimentMetricsSchema.optional(), currentTerritory: territoryScoreboardSchema.optional(), - currentAlliances: z.array(allianceTerritorySummarySchema).max(4).optional(), + currentAlliances: z + .array(allianceTerritorySummarySchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), initialWorld: experimentExportWorldStateSchema.optional(), currentWorld: experimentExportWorldStateSchema.optional(), worldEvents: z.array(nonCommunicationWorldEventSchema).optional(), diff --git a/packages/shared/src/limits.ts b/packages/shared/src/limits.ts index 60fa8ce..6616b6e 100644 --- a/packages/shared/src/limits.ts +++ b/packages/shared/src/limits.ts @@ -5,7 +5,6 @@ export const WORLD_SCENARIO_LIMITS = { maximumResolution: 11, maximumGeneratedCells: 5_000, maximumRadius: 40, - maximumAllianceMembers: 8, highDensityCellsPerAgent: 10, observedOtherAgents: 7, maximumNearbyAgentObservations: 8, diff --git a/packages/shared/src/scenario.test.ts b/packages/shared/src/scenario.test.ts index 97b56d2..21c6467 100644 --- a/packages/shared/src/scenario.test.ts +++ b/packages/shared/src/scenario.test.ts @@ -72,7 +72,6 @@ describe('scenario contracts', () => { maximumResolution: 11, maximumGeneratedCells: 5000, maximumRadius: 40, - maximumAllianceMembers: 8, }); }); @@ -143,7 +142,7 @@ describe('scenario contracts', () => { ).toBe(false); }); - it('keeps prompt attribution out of setup input and attributes applied scenarios to v4', () => { + it('keeps prompt attribution out of setup input and attributes applied scenarios to the current contract', () => { expect( worldSetupRequestSchema.safeParse({ ...request, diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index c154405..3cc23a8 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -1,13 +1,21 @@ import { gridDisk, gridDistance, latLngToCell } from 'h3-js'; import { describe, expect, it } from 'vitest'; -import { agentIdSchema, h3CellSchema, type Agent } from '@hexzero/shared'; +import { + agentIdSchema, + allianceIdSchema, + allianceProposalIdSchema, + h3CellSchema, + type Agent, +} from '@hexzero/shared'; import { applyCommunication, applyDiplomacy, applyWorldAction, areAdjacent, createDevelopmentWorld, + deterministicAllianceColor, getCaptureEligibility, + getProposalTargetEligibility, expireAllianceProposals, seededTickIntervalMinutes, seededTickOrder, @@ -600,6 +608,194 @@ describe('wait and deterministic development world', () => { }); describe('formal alliances', () => { + it('uses one proposal-target authority for affordances and rejection reasons', () => { + const base = toWorldState( + createDevelopmentWorld({ generatedAt: context.now() }), + ); + const [ember, rook, mingle, morrow] = [...base.agents.values()]; + const firstAllianceId = allianceIdSchema.parse( + 'a1111111-1111-4111-8111-111111111111', + ); + const secondAllianceId = allianceIdSchema.parse( + 'e5555555-5555-4555-8555-555555555555', + ); + const alliedState = { + ...base, + alliances: new Map([ + [ + firstAllianceId, + { + id: firstAllianceId, + color: '#0072B2' as const, + memberAgentIds: [ember!.id, rook!.id], + }, + ], + [ + secondAllianceId, + { + id: secondAllianceId, + color: '#D55E00' as const, + memberAgentIds: [mingle!.id, morrow!.id], + }, + ], + ]), + }; + const cases = [ + { + state: alliedState, + proposerId: ember!.id, + recipientId: rook!.id, + range: 12, + helperReason: 'current-ally' as const, + rejectionReason: 'current-ally' as const, + }, + { + state: alliedState, + proposerId: ember!.id, + recipientId: mingle!.id, + range: 12, + helperReason: 'alliance-to-alliance-merge' as const, + rejectionReason: 'recipient-allied' as const, + }, + { + state: base, + proposerId: ember!.id, + recipientId: rook!.id, + range: 0.001, + helperReason: 'out-of-range' as const, + rejectionReason: 'recipient-out-of-range' as const, + }, + ]; + for (const item of cases) { + expect( + getProposalTargetEligibility( + item.state, + item.proposerId, + item.recipientId, + item.range, + ), + ).toEqual({ eligible: false, reason: item.helperReason }); + expect( + applyDiplomacy( + item.state, + item.proposerId, + { type: 'propose-alliance', recipientId: item.recipientId }, + 1, + { ...context, communicationRangeKm: item.range }, + ).result, + ).toMatchObject({ + requested: true, + accepted: false, + reason: item.rejectionReason, + }); + } + + const movedTogetherState = { + ...base, + agents: new Map( + [...base.agents.values()].map((candidate) => [ + candidate.id, + candidate.id === rook!.id + ? { ...candidate, currentCell: ember!.currentCell } + : candidate, + ]), + ), + }; + expect( + getProposalTargetEligibility( + movedTogetherState, + ember!.id, + rook!.id, + 0.1, + base, + ), + ).toEqual({ eligible: false, reason: 'out-of-range' }); + expect( + applyDiplomacy( + movedTogetherState, + ember!.id, + { type: 'propose-alliance', recipientId: rook!.id }, + 1, + { + ...context, + communicationRangeKm: 0.1, + diplomacyRangeState: base, + }, + ).result, + ).toMatchObject({ reason: 'recipient-out-of-range' }); + + const pendingProposal = { + id: allianceProposalIdSchema.parse( + 'b2222222-2222-4222-8222-222222222222', + ), + proposerAgentId: ember!.id, + recipientAgentId: rook!.id, + proposerAllianceId: null, + recipientAllianceId: null, + originatingTurn: 1, + expirationTurn: 17, + }; + const outgoingState = { + ...base, + pendingAllianceProposals: new Map([ + [pendingProposal.id, pendingProposal], + ]), + }; + const incomingState = { + ...base, + pendingAllianceProposals: new Map([ + [ + pendingProposal.id, + { ...pendingProposal, proposerAgentId: mingle!.id }, + ], + ]), + }; + for (const item of [ + { + state: outgoingState, + proposerId: ember!.id, + recipientId: mingle!.id, + reason: 'outgoing-proposal-exists' as const, + }, + { + state: incomingState, + proposerId: ember!.id, + recipientId: rook!.id, + reason: 'incoming-proposal-exists' as const, + }, + ]) { + expect( + getProposalTargetEligibility( + item.state, + item.proposerId, + item.recipientId, + 12, + ), + ).toEqual({ eligible: false, reason: item.reason }); + expect( + applyDiplomacy( + item.state, + item.proposerId, + { type: 'propose-alliance', recipientId: item.recipientId }, + 2, + context, + ).result, + ).toMatchObject({ + requested: true, + accepted: false, + reason: item.reason, + }); + } + }); + + it('derives stable accessible colors when the display palette must be reused', () => { + const id = allianceIdSchema.parse('f6666666-6666-4666-8666-666666666666'); + expect(deterministicAllianceColor(id)).toBe(deterministicAllianceColor(id)); + expect(['#0072B2', '#D55E00', '#009E73', '#CC79A7']).toContain( + deterministicAllianceColor(id), + ); + }); + it.each([ { agentCount: 8, lifetime: 16 }, { agentCount: 20, lifetime: 40 }, @@ -671,6 +867,57 @@ describe('formal alliances', () => { expect(expired.pendingAllianceProposals?.size).toBe(0); }); + it('recruits the final free agent into a 31-member alliance', () => { + const base = toWorldState( + createDevelopmentWorld({ generatedAt: context.now() }), + ); + const template = [...base.agents.values()][0]!; + const agents = Array.from({ length: 32 }, (_, index) => ({ + ...template, + id: agentIdSchema.parse( + `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ), + name: `Agent ${index}`, + })); + const allianceId = allianceIdSchema.parse( + 'a1111111-1111-4111-8111-111111111111', + ); + const initial = { + ...base, + agents: new Map(agents.map((candidate) => [candidate.id, candidate])), + alliances: new Map([ + [ + allianceId, + { + id: allianceId, + color: '#0072B2' as const, + memberAgentIds: agents.slice(0, 31).map(({ id }) => id), + }, + ], + ]), + }; + const proposed = applyDiplomacy( + initial, + agents[0]!.id, + { type: 'propose-alliance', recipientId: agents[31]!.id }, + 1, + context, + ); + const accepted = applyDiplomacy( + proposed.state, + agents[31]!.id, + { + type: 'accept-alliance', + proposalId: [...proposed.state.pendingAllianceProposals!.keys()][0]!, + }, + 2, + context, + ); + expect( + [...accepted.state.alliances!.values()][0]!.memberAgentIds, + ).toHaveLength(32); + }); + it('forms, colors, leaves, dissolves, and expires proposals deterministically', () => { const initial = toWorldState( createDevelopmentWorld({ generatedAt: context.now() }), @@ -770,4 +1017,116 @@ describe('formal alliances', () => { reason: 'expired', }); }); + + it('lets an unaffiliated proposer request entry from an allied recipient', () => { + const initial = toWorldState( + createDevelopmentWorld({ generatedAt: context.now() }), + ); + const [ember, rook, mingle, morrow] = [...initial.agents.values()]; + const invitation = applyDiplomacy( + initial, + ember!.id, + { type: 'propose-alliance', recipientId: rook!.id }, + 1, + context, + ); + const invitationId = [ + ...invitation.state.pendingAllianceProposals!.keys(), + ][0]!; + const formed = applyDiplomacy( + invitation.state, + rook!.id, + { type: 'accept-alliance', proposalId: invitationId }, + 2, + context, + ); + const request = applyDiplomacy( + formed.state, + mingle!.id, + { type: 'propose-alliance', recipientId: rook!.id }, + 3, + { + ...context, + createProposalId: () => 'c3333333-3333-4333-8333-333333333333', + }, + ); + expect( + [...request.state.pendingAllianceProposals!.values()][0], + ).toMatchObject({ + proposerAgentId: mingle!.id, + recipientAgentId: rook!.id, + proposerAllianceId: null, + recipientAllianceId: [...formed.state.alliances!.keys()][0], + }); + const requestId = [...request.state.pendingAllianceProposals!.keys()][0]!; + const joined = applyDiplomacy( + request.state, + rook!.id, + { type: 'accept-alliance', proposalId: requestId }, + 4, + context, + ); + expect([...joined.state.alliances!.values()][0]!.memberAgentIds).toEqual([ + ember!.id, + rook!.id, + mingle!.id, + ]); + expect(joined.result).toMatchObject({ + requested: true, + accepted: true, + events: [{ type: 'agent-joined-alliance', joinedAgentId: mingle!.id }], + }); + const left = applyDiplomacy( + joined.state, + mingle!.id, + { type: 'leave-alliance' }, + 5, + context, + ); + const switchedProposal = applyDiplomacy( + left.state, + mingle!.id, + { type: 'propose-alliance', recipientId: morrow!.id }, + 6, + { + ...context, + createProposalId: () => 'd4444444-4444-4444-8444-444444444444', + }, + ); + const switched = applyDiplomacy( + switchedProposal.state, + morrow!.id, + { + type: 'accept-alliance', + proposalId: [ + ...switchedProposal.state.pendingAllianceProposals!.keys(), + ][0]!, + }, + 7, + { + ...context, + createAllianceId: () => 'e5555555-5555-4555-8555-555555555555', + }, + ); + expect( + [...switched.state.alliances!.values()].some( + ({ memberAgentIds }) => + memberAgentIds.includes(mingle!.id) && + memberAgentIds.includes(morrow!.id), + ), + ).toBe(true); + expect( + applyDiplomacy( + switched.state, + ember!.id, + { type: 'propose-alliance', recipientId: morrow!.id }, + 8, + context, + ).result, + ).toMatchObject({ + requested: true, + accepted: false, + reason: 'recipient-allied', + }); + }); }); diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index 6075e63..208fe89 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -71,6 +71,8 @@ export interface EngineContext { communicationRangeKm: number; patientZeroAgentId: AgentId | null; tickNumber?: number; + /** Frozen pre-action positions used only for diplomacy range authority. */ + diplomacyRangeState?: WorldState; } export interface AppliedAction { @@ -88,6 +90,56 @@ export interface AppliedDiplomacy { result: DiplomacyResult; } +export type ProposalTargetBlockReason = + | 'current-ally' + | 'alliance-to-alliance-merge' + | 'out-of-range' + | 'outgoing-proposal-exists' + | 'incoming-proposal-exists'; + +export type ProposalTargetEligibility = + { eligible: true } | { eligible: false; reason: ProposalTargetBlockReason }; + +/** Pure proposal-target authority shared by observations and final submission. */ +export function getProposalTargetEligibility( + state: WorldState, + proposerAgentId: AgentId, + recipientAgentId: AgentId, + communicationRangeKm: number, + rangeState: WorldState = state, +): ProposalTargetEligibility { + const proposerAlliance = getAgentAlliance(state, proposerAgentId); + const recipientAlliance = getAgentAlliance(state, recipientAgentId); + if (proposerAlliance?.id === recipientAlliance?.id && proposerAlliance) + return { eligible: false, reason: 'current-ally' }; + if (proposerAlliance && recipientAlliance) + return { eligible: false, reason: 'alliance-to-alliance-merge' }; + const rangeSender = rangeState.agents.get(proposerAgentId); + const rangeRecipient = rangeState.agents.get(recipientAgentId); + const distance = + rangeSender && rangeRecipient + ? physicalDistanceKm(rangeSender.currentCell, rangeRecipient.currentCell) + : null; + if (distance === null || distance > communicationRangeKm) + return { eligible: false, reason: 'out-of-range' }; + const proposals = [...(state.pendingAllianceProposals?.values() ?? [])]; + if ( + proposals.some( + ({ proposerAgentId: pendingProposer }) => + pendingProposer === proposerAgentId, + ) + ) + return { eligible: false, reason: 'outgoing-proposal-exists' }; + if ( + proposals.some( + ({ recipientAgentId: pendingRecipient }) => + pendingRecipient === recipientAgentId, + ) + ) + return { eligible: false, reason: 'incoming-proposal-exists' }; + return { eligible: true }; +} + const defaultContext: EngineContext = { createEventId: () => crypto.randomUUID(), createAllianceId: () => crypto.randomUUID(), @@ -489,46 +541,48 @@ export function applyDiplomacy( ); const proposerAlliance = getAgentAlliance(state, agentId); const recipientAlliance = getAgentAlliance(state, recipient.id); - if (recipientAlliance?.id === proposerAlliance?.id && recipientAlliance) - return diplomacyRejected( - state, - { type: intent.type, recipientId: intent.recipientId }, - 'current-ally', - 'The recipient is already an ally.', - ); - if (recipientAlliance) - return diplomacyRejected( - state, - { type: intent.type, recipientId: intent.recipientId }, - 'recipient-allied', - 'Formal proposals may target only unaffiliated agents.', - ); - if ( - [...proposals.values()].some( - (proposal) => proposal.proposerAgentId === agentId, - ) - ) - return diplomacyRejected( - state, - { type: intent.type, recipientId: intent.recipientId }, - 'outgoing-proposal-exists', - 'The proposer already has a pending outgoing proposal.', - ); - if ( - [...proposals.values()].some( - (proposal) => proposal.recipientAgentId === recipient.id, - ) - ) + const rangeState = context.diplomacyRangeState ?? state; + const targetEligibility = getProposalTargetEligibility( + state, + agentId, + recipient.id, + resolved.communicationRangeKm, + rangeState, + ); + if (!targetEligibility.eligible) { + const rejection = { + 'current-ally': { + reason: 'current-ally' as const, + details: 'The recipient is already an ally.', + }, + 'alliance-to-alliance-merge': { + reason: 'recipient-allied' as const, + details: 'Allied agents cannot merge one alliance into another.', + }, + 'out-of-range': { + reason: 'recipient-out-of-range' as const, + details: 'The recipient is outside formal diplomacy range.', + }, + 'outgoing-proposal-exists': { + reason: 'outgoing-proposal-exists' as const, + details: 'The proposer already has a pending outgoing proposal.', + }, + 'incoming-proposal-exists': { + reason: 'incoming-proposal-exists' as const, + details: 'The recipient already has a pending incoming proposal.', + }, + }[targetEligibility.reason]; return diplomacyRejected( state, { type: intent.type, recipientId: intent.recipientId }, - 'incoming-proposal-exists', - 'The recipient already has a pending incoming proposal.', + rejection.reason, + rejection.details, ); + } + const targetAlliance = proposerAlliance ?? recipientAlliance; if ( - proposerAlliance && - proposerAlliance.memberAgentIds.length >= - WORLD_SCENARIO_LIMITS.maximumAllianceMembers + targetAlliance && + targetAlliance.memberAgentIds.length >= state.agents.size ) return diplomacyRejected( state, @@ -544,6 +598,7 @@ export function applyDiplomacy( proposerAgentId: agentId, recipientAgentId: recipient.id, proposerAllianceId: proposerAlliance?.id ?? null, + recipientAllianceId: recipientAlliance?.id ?? null, originatingTurn: turnNumber, expirationTurn: turnNumber + state.agents.size * 2, ...(context.tickNumber === undefined @@ -559,7 +614,8 @@ export function applyDiplomacy( type: 'alliance-proposed', proposalId, recipientAgentId: recipient.id, - allianceId: proposal.proposerAllianceId, + allianceId: + proposal.proposerAllianceId ?? proposal.recipientAllianceId ?? null, expirationTurn: proposal.expirationTurn, }; return diplomacyAccepted( @@ -592,10 +648,13 @@ export function applyDiplomacy( const proposerAlliance = getAgentAlliance(state, proposal.proposerAgentId); const recipientAlliance = getAgentAlliance(state, agentId); const stillValid = - !recipientAlliance && (proposal.proposerAllianceId === null ? !proposerAlliance - : proposerAlliance?.id === proposal.proposerAllianceId); + : proposerAlliance?.id === proposal.proposerAllianceId) && + (proposal.recipientAllianceId === null + ? !recipientAlliance + : recipientAlliance?.id === proposal.recipientAllianceId) && + !(proposerAlliance && recipientAlliance); if (!stillValid) { proposals.delete(proposal.id); const event = proposalClosedEvent( @@ -618,20 +677,17 @@ export function applyDiplomacy( proposals.delete(proposal.id); const events: AllianceEvent[] = []; let alliance: Alliance; - if (!proposerAlliance) { - const color = ALLIANCE_COLOR_PALETTE.find( - (candidate) => - ![...alliances.values()].some((active) => active.color === candidate), - ); - if (!color) - return diplomacyRejected( - state, - { type: intent.type, proposalId: intent.proposalId }, - 'alliance-capacity', - 'No alliance display color is available.', - ); + if (!proposerAlliance && !recipientAlliance) { + const allianceId = allianceIdSchema.parse(resolved.createAllianceId()); + const color = + ALLIANCE_COLOR_PALETTE.find( + (candidate) => + ![...alliances.values()].some( + (active) => active.color === candidate, + ), + ) ?? deterministicAllianceColor(allianceId); alliance = { - id: allianceIdSchema.parse(resolved.createAllianceId()), + id: allianceId, color, memberAgentIds: [proposal.proposerAgentId, agentId], }; @@ -644,9 +700,13 @@ export function applyDiplomacy( memberAgentIds: alliance.memberAgentIds as [AgentId, AgentId], }); } else { + const existingAlliance = proposerAlliance ?? recipientAlliance!; + const joiningAgentId = proposerAlliance + ? agentId + : proposal.proposerAgentId; alliance = { - ...proposerAlliance, - memberAgentIds: [...proposerAlliance.memberAgentIds, agentId], + ...existingAlliance, + memberAgentIds: [...existingAlliance.memberAgentIds, joiningAgentId], }; alliances.set(alliance.id, alliance); events.push({ @@ -654,7 +714,7 @@ export function applyDiplomacy( type: 'agent-joined-alliance', allianceId: alliance.id, allianceColor: alliance.color, - joinedAgentId: agentId, + joinedAgentId: joiningAgentId, memberAgentIds: alliance.memberAgentIds, }); } @@ -841,10 +901,13 @@ function invalidateImpossibleProposals( proposal.recipientAgentId, ); const valid = - !recipientAlliance && (proposal.proposerAllianceId === null ? !proposerAlliance - : proposerAlliance?.id === proposal.proposerAllianceId); + : proposerAlliance?.id === proposal.proposerAllianceId) && + (proposal.recipientAllianceId === null + ? !recipientAlliance + : recipientAlliance?.id === proposal.recipientAllianceId) && + !(proposerAlliance && recipientAlliance); if (!valid) { proposals.delete(proposal.id); events.push( @@ -855,6 +918,16 @@ function invalidateImpossibleProposals( return { proposals, events }; } +/** Display identity is deterministic and may reuse the accessible palette. */ +export function deterministicAllianceColor( + allianceId: AllianceId, +): (typeof ALLIANCE_COLOR_PALETTE)[number] { + let hash = 0; + for (const character of allianceId) + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return ALLIANCE_COLOR_PALETTE[hash % ALLIANCE_COLOR_PALETTE.length]!; +} + function safeGridDistance(from: H3Cell, to: H3Cell): number | null { try { return gridDistance(from, to); diff --git a/packages/world-engine/src/scenario.test.ts b/packages/world-engine/src/scenario.test.ts index 6c101ad..fe65d77 100644 --- a/packages/world-engine/src/scenario.test.ts +++ b/packages/world-engine/src/scenario.test.ts @@ -97,23 +97,29 @@ describe('configurable world scenarios', () => { ).toBeNull(); }); - it('supports a non-default dynamic roster with exact behavior coverage', () => { - const roster = generateDeterministicRoster(12, 'twelve'); - const request = defaultWorldSetupRequest(); - const result = previewWorldSetup({ - ...request, - radius: 12, - roster, - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - request.behaviorConfiguration.seed, - 'balanced-random', - ), - }, - }); - expect(result.feasible && result.world.agents).toHaveLength(12); - expect(DEVELOPMENT_AGENT_BLUEPRINTS).toHaveLength(8); - }); + it.each([10, 32])( + 'supports a %s-agent roster with exact behavior coverage', + (agentCount) => { + const roster = generateDeterministicRoster( + agentCount, + `roster-${agentCount}`, + ); + const request = defaultWorldSetupRequest(); + const result = previewWorldSetup({ + ...request, + radius: 12, + roster, + behaviorConfiguration: { + ...request.behaviorConfiguration, + assignments: assignBehavior( + roster.map(({ id }) => id), + request.behaviorConfiguration.seed, + 'balanced-random', + ), + }, + }); + expect(result.feasible && result.world.agents).toHaveLength(agentCount); + expect(DEVELOPMENT_AGENT_BLUEPRINTS).toHaveLength(8); + }, + ); });