Add three-member short alliance prototype
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 02:31:50 +11:00
1 parent cdc47ed2e1
commit ef92ddf2b9
12 files changed
+343 -12

No files matched your search

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { quoteVictory } from '../game/victory-quote';
import { advanceShortAlliance, shortAllianceCollection, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
/** Real four-seat Basic deal, first turn, normal worm and Nexus; the empty
* preview ledger and unopened worm order are the only staged inputs here. */
function nexus(): Game {
let game = shortAllianceLobby();
joinGame(game, newPlayer('ha', 'Harkonnen', 'harkonnen'));
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
game = applyAction(game, 'at', {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);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
!state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
assert.ok(game.players.every(player => !player.ally));
game.coalitionState = emptyCoalitionState('three-short');
game = applyAction(game, 'at', {type:'alliance', target:'em'});
return applyAction(game, 'em', {type:'alliance', target:'at'});
}
function accept(game: Game, actor: string): Game {
const offer = game.coalitionState!.proposal; assert.ok(offer);
return applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
void test('three-short pair invites a third with unanimous native consent and each bot can propose', () => {
const game = nexus();
const pair = coalitionOf(game.players, game.coalitionState, 'at'); assert.ok(pair);
assert.deepEqual(new Set(pair.members), new Set(['at','em']));
const controls = viewGame(game, 'gu').coalitionNexus; assert.ok(controls);
assert.equal(controls.variant, 'three-short');
assert.deepEqual(coalitionNexusOptions(controls).invitations.map(row => row.entrant), ['gu']);
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:controls, onIntent(){}}));
assert.match(markup, /up to three members and need three strongholds/);
assert.match(markup, /Ask to join Atreides, Emperor/);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'gu'); view.players.find(player => player.id === 'gu')!.bot = difficulty;
const action = botActions(view).find(row => row.type === 'coalitionPropose');
assert.ok(action, difficulty);
let pending = applyAction(game, 'gu', action);
assert.deepEqual(pending.coalitionState!.proposal!.approvals, ['gu']);
assert.equal(viewGame(pending, 'em').coalitionNexus!.proposal!.entrant, 'gu');
pending = accept(pending, 'at');
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, 'at')!.members), new Set(['at','em']));
pending = accept(pending, 'em');
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, 'at')!.members), new Set(['at','em','gu']));
const full = viewGame(pending, 'ha').coalitionNexus; assert.ok(full);
assert.deepEqual(coalitionNexusOptions(full).invitations, []);
const before = structuredClone(pending);
assert.throws(() => applyAction(pending, 'ha', {type:'coalitionPropose', groupId:pair.id,
entrant:'ha', expectedRevision:pending.coalitionState!.revision}));
assert.deepEqual(pending, before);
}
});
void test('three-short pair and triple share the three-stronghold target and native win', () => {
const pair = nexus();
const group = coalitionOf(pair.players, pair.coalitionState, 'at'); assert.ok(group);
let triple = applyAction(pair, 'at', {type:'coalitionPropose', groupId:group.id, entrant:'gu',
expectedRevision:pair.coalitionState!.revision});
triple = accept(triple, 'em');
triple = accept(triple, 'gu');
for (const [source, members] of [[pair, ['at','em']], [triple, ['at','em','gu']]] as const) {
let closed = source;
for (const player of source.players) {
if (closed.nexus && !closed.ready.includes(player.id))
closed = applyAction(closed, player.id, {type:'ready'});
}
closed = advanceShortAlliance(closed, state => !state.nexus && state.phase >= 2, []);
// Preserve the actual alliance; only the Collection boundary and original
// physical forces are staged by the common stronghold fixture.
const game = shortAllianceCollection(closed);
const row = viewGame(game, 'at').victoryProgress.find(progress => progress.player === 'at'); assert.ok(row);
assert.equal(row.target, 3);
assert.equal(row.qualifies, true);
assert.equal(row.strongholds.length, 3);
assert.deepEqual(new Set(quoteVictory(game).winner), new Set(members));
let current = game;
for (const player of game.players) {
if (current.status === 'finished') break;
current = applyAction(current, player.id, {type:'ready'});
}
assert.equal(current.status, 'finished');
assert.deepEqual(new Set(current.winner), new Set(members));
}
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
import { strongholdProgress } from '../game/victory-progress';
import { quoteVictory } from '../game/victory-quote';
/** The ledger and Nexus are staged because the larger-alliance mode is not
* selectable. Pair formation and, when requested, unanimous group admission
* use real engine actions; the later public stronghold position is staged. */
function board(variant: 'three-short' | 'unlimited' | undefined, group = true): Game {
let game = createGame('THREESHORT', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, storm:18,
order:['a','b','c','d'], ready:[], deck:baseDeck()});
game.coalitionState = emptyCoalitionState(variant);
game = applyAction(game, 'a', {type:'alliance', target:'b'});
game = applyAction(game, 'b', {type:'alliance', target:'a'});
if (group) {
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b', 'c']) {
const offer = game.coalitionState!.proposal!;
game = applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
game.phase = 8;
game.nexus = false;
for (const player of game.players) { player.forces = {}; player.reserves = 20; player.tanks = 0; }
const placements: [string, string][] = group
? [['a','arrakeen:10'], ['b','carthag:11'], ['c','sietch_tabr:14']]
: [['a','arrakeen:10'], ['b','carthag:11'], ['b','sietch_tabr:14']];
for (const [id, location] of placements) {
const player = game.players.find(p => p.id === id)!;
player.reserves--;
player.forces[location] = 1;
}
return game;
}
const progress = (game: Game, actor = 'a') => strongholdProgress(game).progress.find(row => row.player === actor)!;
for (const group of [false, true])
void test(`three-short gives ${group ? 'three allied members' : 'an allied pair'} the official three-stronghold target`, () => {
const game = board('three-short', group);
const row = progress(game);
assert.equal(row.target, 3);
assert.equal(row.qualifies, true);
assert.deepEqual(row.members, group ? ['a','b','c'] : ['a','b']);
assert.deepEqual(quoteVictory(game).winner, row.members);
assert.equal(progress(game, 'd').target, 3, 'Solo victory retains its own target.');
const before = structuredClone(game);
const enemy = game.players.find(p => p.id === 'd')!;
enemy.reserves--;
enemy.forces['arrakeen:10'] = 1;
assert.equal(progress(game).qualifies, false, 'A contested stronghold is not controlled by the alliance.');
assert.deepEqual(quoteVictory(game).winner, []);
assert.deepEqual(before.players.find(p => p.id === 'a')!.forces, {'arrakeen:10':1});
});
void test('three-short remains distinct from the prior longer three-member and unlimited variants', () => {
const legacy = board(undefined), longer = progress(legacy);
assert.equal(longer.target, 4);
assert.equal(longer.qualifies, false);
assert.deepEqual(quoteVictory(legacy).winner, []);
const unlimited = board('unlimited');
assert.equal(progress(unlimited).target, 3);
assert.deepEqual(quoteVictory(unlimited).winner, ['a','b','c']);
const short = board('three-short');
short.victoryExtraStrongholds = 1;
assert.equal(progress(short).target, 4, 'The separately agreed increment remains additive.');
assert.equal(progress(short).qualifies, false);
});
void test('two physical controllers retain the existing four-stronghold precedence', () => {
const game = board('three-short', false);
game.twoPlayerControllers = {version:1, controllers:[
{id:'a', seats:['a','c']}, {id:'b', seats:['b','d']},
]};
assert.equal(progress(game).target, 4);
assert.equal(progress(game).qualifies, false);
assert.deepEqual(quoteVictory(game).winner, []);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
answerCoalitionAdmission, beginCoalitionAdmission, coalitionOf, emptyCoalitionState,
leaveCoalition, type CoalitionSeat, type CoalitionState, type CoalitionTransition,
} from '../game/coalition-alliance';
function seats(): CoalitionSeat[] {
return [{ id: 'a', ally: 'b', allySinceTurn: 2 }, { id: 'b', ally: 'a', allySinceTurn: 2 },
{ id: 'c', ally: null }, { id: 'd', ally: null }];
}
function apply(players: CoalitionSeat[], transition: CoalitionTransition): CoalitionSeat[] {
return players.map(player => {
const result = { ...player };
if (Object.hasOwn(transition.allyUpdates, player.id)) result.ally = transition.allyUpdates[player.id];
if (Object.hasOwn(transition.allySinceUpdates, player.id)) {
const turn = transition.allySinceUpdates[player.id];
if (turn === null) delete result.allySinceTurn;
else result.allySinceTurn = turn;
}
return result;
});
}
function begin(players: CoalitionSeat[], state: CoalitionState, entrant: string, event: string): CoalitionTransition {
const group = coalitionOf(players, state, 'a'); assert.ok(group);
return beginCoalitionAdmission(players, state, { event, nexus: 'nexus', turn: 5,
groupId: group.id, entrant, proposer: 'a', expectedRevision: state.revision });
}
function answer(players: CoalitionSeat[], state: CoalitionState, actor: string, event: string): CoalitionTransition {
return answerCoalitionAdmission(players, state, { event, nexus: 'nexus', turn: 5,
actor, accept: true, expectedRevision: state.revision });
}
function triple(variant: CoalitionState['variant']) {
const players = seats(), original = emptyCoalitionState(variant);
const offered = begin(players, original, 'c', 'first');
const approved = answer(players, offered.state, 'b', 'first');
const completed = answer(players, approved.state, 'c', 'first');
return { players: apply(players, completed), state: completed.state, original, offered, approved };
}
void test('three-short preserves its saved variant and the actual invitation revision across unanimous admission', () => {
const { players, state, original, offered, approved } = triple('three-short');
assert.deepEqual(original, { version: 1, variant: 'three-short', revision: 0, groups: [], proposal: null, usedEvents: [] });
assert.equal(offered.state.variant, 'three-short');
assert.deepEqual(offered.state.proposal?.approvals, ['a']);
assert.equal(approved.state.variant, 'three-short');
assert.deepEqual(approved.state.proposal?.approvals, ['a', 'b']);
assert.equal(state.variant, 'three-short');
assert.equal(state.revision, 3);
assert.deepEqual(state.usedEvents, ['first']);
assert.deepEqual(coalitionOf(players, JSON.parse(JSON.stringify(state)) as CoalitionState, 'a')?.members, ['a', 'b', 'c']);
assert.equal(state.proposal, null);
});
void test('three-short refuses a fourth member while unlimited preserves its separate six-member capacity', () => {
for (const variant of [undefined, 'three-short'] as const) {
const { players, state } = triple(variant), before = structuredClone({ players, state });
assert.throws(() => begin(players, state, 'd', 'second'));
assert.deepEqual({ players, state }, before);
}
const { players, state } = triple('unlimited');
const offer = begin(players, state, 'd', 'second');
let next = answer(players, offer.state, 'b', 'second');
next = answer(players, next.state, 'c', 'second');
next = answer(players, next.state, 'd', 'second');
assert.equal(next.state.variant, 'unlimited');
assert.deepEqual(coalitionOf(apply(players, next), next.state, 'a')?.members, ['a', 'b', 'c', 'd']);
});
void test('three-short departure retains variant and rejects a stale invitation revision or replayed event', () => {
const { players, state } = triple('three-short');
const before = structuredClone({ players, state });
const left = leaveCoalition(players, state, { actor: 'c', expectedRevision: state.revision });
const reduced = apply(players, left);
assert.equal(left.state.variant, 'three-short');
assert.equal(left.state.revision, state.revision + 1);
const group = coalitionOf(reduced, left.state, 'a'); assert.ok(group);
const request = { event: 'first', nexus: 'nexus', turn: 5, groupId: group.id,
entrant: 'c', proposer: 'a', expectedRevision: left.state.revision };
assert.throws(() => beginCoalitionAdmission(reduced, left.state, request));
assert.throws(() => beginCoalitionAdmission(reduced, left.state, {
...request, event: 'fresh', expectedRevision: state.revision,
}));
const again = beginCoalitionAdmission(reduced, left.state, { ...request, event: 'fresh' });
assert.equal(again.state.variant, 'three-short');
assert.deepEqual(again.state.usedEvents, ['first', 'fresh']);
assert.deepEqual({ players, state }, before);
});
void test('older missing variants keep their exact shape and invalid variant strings are rejected', () => {
const legacy = emptyCoalitionState();
assert.equal(Object.hasOwn(legacy, 'variant'), false);
assert.deepEqual(JSON.parse(JSON.stringify(legacy)), legacy);
const invalid = { ...legacy, variant: 'short' } as unknown as CoalitionState;
assert.throws(() => coalitionOf(seats(), invalid, 'a'));
});