From 2e10503f87c80bee40e468365010a97c65a4f692 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 20 Aug 2026 01:52:14 -0400 Subject: [PATCH] fix: scale alliance proposal expiration --- packages/shared/src/index.test.ts | 2 +- packages/shared/src/index.ts | 5 +- packages/world-engine/src/index.test.ts | 72 ++++++++++++++++++++++++- packages/world-engine/src/index.ts | 3 +- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 5e171c3..0ef2406 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -265,7 +265,7 @@ describe('agent observation and decision schemas', () => { recipientAgentId: scoreboard[1]!.agentId, proposerAllianceId: null, originatingTurn: 1, - expirationTurn: 9, + expirationTurn: 17, }).success, ).toBe(true); expect( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1c3dbac..6938389 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -37,7 +37,6 @@ export const DEVELOPMENT_WORLD_CONFIG = { cellCount: 127, agentCount: 8, } as const; -export const ALLIANCE_PROPOSAL_DURATION_TURNS = 8; export const ALLIANCE_COLOR_PALETTE = [ '#0072B2', '#D55E00', @@ -587,12 +586,12 @@ function validateWorldControllers( }); if ( proposal.expirationTurn !== - proposal.originatingTurn + ALLIANCE_PROPOSAL_DURATION_TURNS + proposal.originatingTurn + world.agents.length * 2 ) context.addIssue({ code: 'custom', path: ['pendingAllianceProposals', index, 'expirationTurn'], - message: 'Proposal expiration must be exactly one eight-agent round.', + message: 'Proposal expiration must allow two complete roster rounds.', }); if ( proposal.proposerAllianceId && diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index 6ba959f..6ebb3f2 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -524,6 +524,76 @@ describe('wait and deterministic development world', () => { }); describe('formal alliances', () => { + it.each([ + { agentCount: 8, lifetime: 16 }, + { agentCount: 20, lifetime: 40 }, + ])( + 'gives proposals a $lifetime-turn lifetime for an $agentCount-agent roster', + ({ agentCount, lifetime }) => { + const base = toWorldState( + createDevelopmentWorld({ generatedAt: context.now() }), + ); + const agents = [...base.agents.values()]; + for (let index = agents.length; index < agentCount; index += 1) { + const id = agentIdSchema.parse( + `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ); + agents.push({ ...agents[0]!, id, name: `Agent ${index + 1}` }); + } + const initial = { + ...base, + agents: new Map(agents.map((item) => [item.id, item])), + }; + const [proposer, recipient] = agents; + + const proposed = applyDiplomacy( + initial, + proposer!.id, + { type: 'propose-alliance', recipientId: recipient!.id }, + 1, + context, + ); + const proposal = [ + ...proposed.state.pendingAllianceProposals!.values(), + ][0]!; + + expect(proposal.expirationTurn).toBe(1 + lifetime); + }, + ); + + it('keeps a proposal open for two scheduled recipient opportunities', () => { + const initial = toWorldState( + createDevelopmentWorld({ generatedAt: context.now() }), + ); + const [proposer, recipient] = [...initial.agents.values()]; + const proposed = applyDiplomacy( + initial, + proposer!.id, + { type: 'propose-alliance', recipientId: recipient!.id }, + 1, + context, + ); + + const afterFirstOpportunity = expireAllianceProposals( + proposed.state, + 2, + context, + ); + const afterSecondOpportunity = expireAllianceProposals( + afterFirstOpportunity, + 10, + context, + ); + expect(afterSecondOpportunity.pendingAllianceProposals?.size).toBe(1); + + const expired = expireAllianceProposals( + afterSecondOpportunity, + 17, + context, + ); + expect(expired.pendingAllianceProposals?.size).toBe(0); + }); + it('forms, colors, leaves, dissolves, and expires proposals deterministically', () => { const initial = toWorldState( createDevelopmentWorld({ generatedAt: context.now() }), @@ -616,7 +686,7 @@ describe('formal alliances', () => { 10, context, ); - const expired = expireAllianceProposals(laterProposal.state, 18, context); + const expired = expireAllianceProposals(laterProposal.state, 26, context); expect(expired.pendingAllianceProposals?.size).toBe(0); expect(expired.events.at(-1)).toMatchObject({ type: 'alliance-proposal-closed', diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index 3ef7cea..fec7ba6 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -9,7 +9,6 @@ import { } from 'h3-js'; import { ALLIANCE_COLOR_PALETTE, - ALLIANCE_PROPOSAL_DURATION_TURNS, DEVELOPMENT_WORLD_CONFIG, DEFAULT_COMMUNICATION_RANGE_KM, NEUTRAL_AGENT_COLOR, @@ -510,7 +509,7 @@ export function applyDiplomacy( recipientAgentId: recipient.id, proposerAllianceId: proposerAlliance?.id ?? null, originatingTurn: turnNumber, - expirationTurn: turnNumber + ALLIANCE_PROPOSAL_DURATION_TURNS, + expirationTurn: turnNumber + state.agents.size * 2, }; proposals.set(proposal.id, proposal); const event: AllianceEvent = {