From 3c1cbd071ea9fbf8985bc1fb0aa9497fe58eab55 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:05:16 +1100 Subject: [PATCH] Fix group preview bot admission and improve CI diagnostics --- docs/CURRENT_STATUS.md | 15 +++++ game/bots.ts | 3 +- tests/coalition-three-bot-preview.test.ts | 55 +++++++++++++++++++ ...ichese-homeworld-marker-mixed-http.test.ts | 6 +- ...ese-homeworld-marker-transfer-http.test.ts | 6 +- 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/coalition-three-bot-preview.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 59452dd8..227057f4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,21 @@ open mode gates. ## Current checkpoint and work +**Three-member preview bot admission — 29 October. Prototyped / Partial.** +The three-member/four-stronghold group preview now lets all four bot profiles +propose admission to a pair in Basic and Advanced games, despite its unstamped +ledger variant. Legacy staged games retain their earlier behavior. A genuine +setup and Nexus journey exercises the proposal and consent; 21 related cases, +typecheck, lint and the production build pass. Full group play and final AI +calibration remain open. + +**Gitea HTTP failure investigation — 29 October. Open.** Run #65 failed when a +Richese Homeworld fixture could not complete its scripted Atreides shipment. +The same case passed 22 times on an isolated local server; the cause remains +unconfirmed. Both related HTTP fixtures now report rejected destinations, +server reasons, the relevant force pool, spice and storm if it recurs. No +gameplay or fixture behavior was changed to conceal the failure. + **Advanced classic group lobby preview — 29 October. Prototyped / Partial.** The explicitly selected Advanced preview now accepts the group-alliance lobby choice for three to six classic factions, without expansion decks or other diff --git a/game/bots.ts b/game/bots.ts index e2bbb66c..0770e0c3 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3229,7 +3229,8 @@ function policyActions(g: GameView): Action[] { if (card) return [{ type: 'card', card: card.id }]; } if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && - (group?.variant === 'unlimited' || group?.variant === 'three-short') && + (group?.variant === 'unlimited' || group?.variant === 'three-short' || + !!group && g.coalitionPreview === 'three') && !group.groups.some(alliance => alliance.members.includes(me.id))) { const limit = group.variant === 'unlimited' ? 6 : 3; const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < limit && diff --git a/tests/coalition-three-bot-preview.test.ts b/tests/coalition-three-bot-preview.test.ts new file mode 100644 index 00000000..fb2749e6 --- /dev/null +++ b/tests/coalition-three-bot-preview.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { coalitionOf } from '../game/coalition-alliance'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { advanceShortAlliance, type ShortAllianceStep } from './fixture-short-alliance-victory'; + +const apply = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); + +/** Real setup and first turn; select only unopened Spice order to reach Nexus. */ +function pairAtNexus(advanced: boolean): Game { + let game = createGame('THREEGROUPBOT', newPlayer('at', 'Atreides', 'atreides')); + joinGame(game, newPlayer('em', 'Emperor', 'emperor')); + joinGame(game, newPlayer('ha', 'Harkonnen', 'harkonnen')); + if (advanced) game = apply(game, 'at', { type: 'rules', advanced: true }); + game = apply(game, 'at', { type: 'coalitionPreview', variant: 'three' }); + for (const seat of game.players) game = apply(game, seat.id, { type: 'ready' }); + game = apply(game, 'at', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const actions: ShortAllianceStep[] = []; + game = advanceShortAlliance(game, state => state.status === 'playing' && state.turn === 2 && state.phase === 0, actions); + const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); + assert.ok(index >= 0); + const before = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), before); + game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && + !state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions); + game = apply(game, 'at', { type: 'alliance', target: 'em' }); + return apply(game, 'em', { type: 'alliance', target: 'at' }); +} + +void test('each bot can seek admission to the three-member four-stronghold preview in Basic and Advanced', () => { + for (const advanced of [false, true]) { + const initial = pairAtNexus(advanced); + const pair = coalitionOf(initial.players, initial.coalitionState, 'at'); assert.ok(pair); + assert.equal(initial.coalitionState!.variant, undefined); + assert.equal(viewGame(initial, 'ha').coalitionPreview, 'three'); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(initial, 'ha'); + view.players.find(player => player.id === 'ha')!.bot = difficulty; + const proposal = botActions(view).find(action => action.type === 'coalitionPropose'); + assert.ok(proposal, `${advanced ? 'Advanced' : 'Basic'} ${difficulty}`); + let game = apply(initial, 'ha', proposal); + for (const actor of ['at', 'em']) { + const offer = game.coalitionState!.proposal; assert.ok(offer); + game = apply(game, actor, { type: 'coalitionAnswer', event: offer.event, nexus: offer.nexus, + turn: offer.turn, accept: true, expectedRevision: game.coalitionState!.revision }); + } + assert.deepEqual(new Set(coalitionOf(game.players, game.coalitionState, 'at')!.members), + new Set(['at', 'em', 'ha'])); + assert.equal(viewGame(game, 'at').victoryProgress.find(row => row.player === 'at')!.target, 4); + } + } +}); diff --git a/tests/richese-homeworld-marker-mixed-http.test.ts b/tests/richese-homeworld-marker-mixed-http.test.ts index 82cb1491..de1a94c0 100644 --- a/tests/richese-homeworld-marker-mixed-http.test.ts +++ b/tests/richese-homeworld-marker-mixed-http.test.ts @@ -61,6 +61,7 @@ void test('authenticated mixed transfer moves a concealed marker and physical vi const me = at.players.find(player => player.id === hostSeat.id)!; if (!me.shipped && me.reserves > 0) { let accepted = false; + const rejections: string[] = []; for (const territory of TERRITORIES.filter(item => item.type === 'stronghold')) { for (const sector of territory.sectors) { if (sector === at.storm) continue; @@ -70,10 +71,13 @@ void test('authenticated mixed transfer moves a concealed marker and physical vi } }); if (attempt.status === 200) { view = attempt.data; accepted = true; break; } assert.equal(attempt.status, 409, JSON.stringify(attempt.data)); + rejections.push(`${territory.id}:${sector}: ${attempt.data.error ?? 'unknown rejection'}`); } if (accepted) break; } - assert.ok(accepted, 'Atreides can ship its physical reserves to an available stronghold.'); + const source = at.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[hostSeat.id]; + assert.ok(accepted, `Atreides cannot ship ${me.reserves} reserves with ${me.spice} spice ` + + `from Caladan ${JSON.stringify(source ?? null)}; storm ${at.storm}. Rejections: ${rejections.join('; ')}`); continue; } } diff --git a/tests/richese-homeworld-marker-transfer-http.test.ts b/tests/richese-homeworld-marker-transfer-http.test.ts index d6fd257c..7dd4e533 100644 --- a/tests/richese-homeworld-marker-transfer-http.test.ts +++ b/tests/richese-homeworld-marker-transfer-http.test.ts @@ -62,6 +62,7 @@ void test('authenticated marker transfer retains its hidden identity between for const me = at.players.find(player => player.id === hostSeat.id)!; if (!me.shipped && me.reserves > 0) { let accepted = false; + const rejections: string[] = []; for (const territory of TERRITORIES.filter(item => item.type === 'stronghold')) { for (const sector of territory.sectors) { if (sector === at.storm) continue; @@ -71,10 +72,13 @@ void test('authenticated marker transfer retains its hidden identity between for } }); if (attempt.status === 200) { view = attempt.data; accepted = true; break; } assert.equal(attempt.status, 409, JSON.stringify(attempt.data)); + rejections.push(`${territory.id}:${sector}: ${attempt.data.error ?? 'unknown rejection'}`); } if (accepted) break; } - assert.ok(accepted, 'Atreides can ship its physical reserves to an available stronghold.'); + const source = at.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[hostSeat.id]; + assert.ok(accepted, `Atreides cannot ship ${me.reserves} reserves with ${me.spice} spice ` + + `from Caladan ${JSON.stringify(source ?? null)}; storm ${at.storm}. Rejections: ${rejections.join('; ')}`); continue; } }