Fix group preview bot admission and improve CI diagnostics
Verify and publish checkpoint container / container (push) Successful in 11m1s

This commit is contained in:
Quintine committed 2026-10-29 04:05:16 +11:00
1 parent d8d3cfd26c
commit 3c1cbd071e
5 files changed
+82 -3

No files matched your search

+15
View File
@@ -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
+2 -1
View File
@@ -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 &&
+55
View File
@@ -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);
}
}
});
@@ -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;
}
}
@@ -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;
}
}