diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 579628fb..80cb3ad8 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -114,6 +114,13 @@ that these are the only rules-permitted player-count configurations. The and Advanced using the actual optional-module setup seams. They do not open public module starts or assert complete module interaction coverage. +All-AI Nexus samples now make public reciprocal offers as well as accepting +them, so an actual alliance can form without a human proposer. At the +20260927 seed, each six-seat sample made six alliance actions and completed; +all six seats paired, leaving no unallied Nexus Card recipient. The focused +Nexus alliance regression exercises the subsequent draw when a seat remains +unallied. These samples do not establish complete card-effect coverage. + `--profile moritani-skills` adds five Basic samples with Moritani, then Emperor, Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile; Advanced requests fail before running. Its seed offsets are 16–20, leaving diff --git a/game/bots.ts b/game/bots.ts index a0eecf18..0c944f22 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2802,10 +2802,19 @@ function policyActions(g: GameView): Action[] { if (card) return [{ type: 'card', card: card.id }]; } if (g.nexus && !g.spiceWindow && !me.ally) { - const offer = Object.entries(g.allianceOffers).find( - ([id, target]) => id !== me.id && target === me.id && !g.homeworldAllianceBlocks?.[id], + const offer = g.players.find( + (player) => player.id !== me.id && + g.allianceOffers[player.id] === me.id && + !g.homeworldAllianceBlocks?.[player.id], ); - if (offer && level > 0) return [{ type: 'alliance', target: offer[0] }]; + if (offer) return [{ type: 'alliance', target: offer.id }]; + if (level > 0 && !g.ready.includes(me.id) && !g.allianceOffers[me.id]) { + const target = g.players.find( + (player) => player.id !== me.id && + !player.ally && !g.homeworldAllianceBlocks?.[player.id], + ); + if (target) return [{ type: 'alliance', target: target.id }]; + } } return g.ready.includes(me.id) ? [] : [{ type: 'ready' }]; } diff --git a/tests/nexus-alliance.test.ts b/tests/nexus-alliance.test.ts index 4d065172..8553fc73 100644 --- a/tests/nexus-alliance.test.ts +++ b/tests/nexus-alliance.test.ts @@ -1,11 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { applyAction } from '../game/engine'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; import { quoteNexusAlliance, type NexusAllianceSeat, } from '../game/nexus-alliance'; import { + nexusInventory, nexusPlayer, nexusReady, nexusTurnTwo, @@ -123,6 +125,32 @@ function negotiations() { return game; } +void test('AI initiates a reciprocal Nexus alliance without retrying its outstanding offer', () => { + let game = negotiations(); + nexusPlayer(game, 'f').bot = 'Easy'; + nexusPlayer(game, 'a').bot = 'Medium'; + nexusPlayer(game, 'h').bot = 'Hard'; + + const proposal = botActions(viewGame(game, 'a'))[0]; + assert.deepEqual(proposal, { type: 'alliance', target: 'f' }); + game = applyAction(game, 'a', proposal); + assert.deepEqual(game.allianceOffers, { a: 'f' }); + assert.deepEqual(botActions(viewGame(game, 'a'))[0], { type: 'ready' }); + const acceptance = botActions(viewGame(game, 'f'))[0]; + assert.deepEqual(acceptance, { type: 'alliance', target: 'a' }); + game = applyAction(game, 'f', acceptance); + assert.equal(nexusPlayer(game, 'a').ally, 'f'); + assert.equal(nexusPlayer(game, 'f').ally, 'a'); + assert.deepEqual(game.allianceOffers, {}); + game = nexusReady(game); + assert.equal(game.nexusCards?.phase?.stage, 'drawing'); + const draw = botActions(viewGame(game, 'h'))[0]; + assert.equal(draw.type, 'nexusCardChoice'); + game = applyAction(game, 'h', draw); + assert.equal(game.nexusCards?.phase?.stage, 'complete'); + nexusInventory(game); +}); + void test('the actual engine preserves ordinary offer, formation and withdrawal side effects', () => { let game = negotiations(); game.ready = ['h']; @@ -139,12 +167,10 @@ void test('the actual engine preserves ordinary offer, formation and withdrawal assert.equal(nexusPlayer(game, 'a').allySinceTurn, game.turn); assert.deepEqual(game.allianceOffers, {}); assert.deepEqual(game.ready, []); - assert.match(game.log.at(-1)!.text, /formed an alliance/); game.ready = ['h']; game = applyAction(game, 'f', { type: 'alliance', target: null }); assert.equal(nexusPlayer(game, 'f').ally, null); assert.equal(nexusPlayer(game, 'a').ally, null); assert.deepEqual(game.ready, []); - assert.match(game.log.at(-1)!.text, /unallied/); });