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

+2 -1
View File
@@ -11,7 +11,7 @@ export type CoalitionNexusView = {
proposal: CoalitionAdmission | null;
revision: number;
/** Absent preserves the original, at-most-three alliance prototype. */
variant?: 'unlimited';
variant?: 'three-short' | 'unlimited';
/** Until group admission can reconcile a live bribe commitment, only
* departure remains available in this Nexus. */
admissionBlocked?: boolean;
@@ -69,6 +69,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
const submit = (intent: CoalitionNexusIntent) => { if (!disabled) onIntent(intent); };
return <section aria-label="Group alliance negotiations" className="notice min-w-0 space-y-3 break-words">
<h3>Group alliance negotiations</h3>
{view.variant === 'three-short' && <p>Alliances can have up to three members and need three strongholds to win.</p>}
{view.variant === 'unlimited' ? <p>Every current member and the invited player must agree.
The existing alliance remains in place while an invitation is pending.</p> :
<p>All three players must agree before an invitation forms an alliance.
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Three-member short alliance option — 29 October. Prototyped / Partial.**
A distinct staged Basic variant caps alliances at three and gives pairs or
triples a three-stronghold target; the older four-stronghold and unlimited
variants retain their own limits. A real Basic setup and Nexus exercise
consent, projected controls and four minimal bot proposals with only an empty
ledger staged; pair and triple wins use a separately staged physical board.
Thirteen related files pass 59 cases; typecheck, lint and build pass. Group play remains unavailable from the
lobby until its unfinished rules and complete-game gates are met.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#three-member-three-stronghold-variant--29-october-2026).
**Sapho ordering of battle Truthtrances — 29 October. Prototyped / Partial.**
A quiet unrevealed battle can retain its event and plans while simultaneous
Truthtrance holders use Sapho to reorder their questions. In the supported
+23
View File
@@ -1,5 +1,28 @@
# Optional three-stronghold alliance victory
## Three-member, three-stronghold variant — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** A distinct saved
`three-short` variant allows at most three alliance members and gives both
pairs and triples a three-stronghold target. Existing absent-variant groups
retain their maximum of three and four-stronghold target; the separate
`unlimited` variant retains its six-player ceiling and three-stronghold target.
The current two-controller four-stronghold precedence and agreed target
increases still apply. Admission, consent and departure reuse the existing
group protocol; player controls identify this variant, and minimal bots may
request admission to a pair. No lobby creates a group ledger and full group
rules and complete-game acceptance remain open.
Thirteen related files pass 59 cases, including an original four-seat Basic
setup and Nexus with a staged empty ledger, actual group consent and all four
minimal bot proposals. Later pair/triple victories use conserved forces staged
at Collection; they are not complete played games.
The user-authorized revised rulebook, physical p. 42 (printed p. 40), lists
the three-member/three-stronghold option separately from the four-stronghold
and unlimited options. Saved variant naming and the two-controller precedence
are software choices, not additional publisher rulings.
## Group admission with joint commitments — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** The joint
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Three-member short alliance option — 29 October 2026
The authorized revised rulebook, physical p. 42 (printed p. 40), lists a
three-member limit with a three-stronghold target separately from the
four-stronghold and unlimited options. The staged Basic variant retains the
existing provisional two-controller precedence. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#three-member-three-stronghold-variant--29-october-2026).
## Sapho and simultaneous battle Truthtrances — 29 October 2026
In the provisional online ordering, a clean unrevealed battle retains its
+3 -3
View File
@@ -4,9 +4,9 @@ import {
type TwoPlayerControllers,
} from './two-player-controllers';
/** GF9's optional two-member alliance / three-stronghold variant. The
* two-person four-stronghold rule still applies, as do ordinary solo targets.
* Callers add any separately agreed increase after this base target. */
/** GF9's shorter alliance target, also used by the staged three-member and
* unlimited variants. The two-person four-stronghold rule still applies, as
* do ordinary solo targets. Callers add any agreed increase afterward. */
export function allianceVictoryTarget(
seats: readonly string[],
controllers: TwoPlayerControllers | null | undefined,
+4 -2
View File
@@ -3228,9 +3228,11 @@ function policyActions(g: GameView): Action[] {
const card = me.hand?.find((c) => c.effect === 'harvester');
if (card) return [{ type: 'card', card: card.id }];
}
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) &&
(group?.variant === 'unlimited' || group?.variant === 'three-short') &&
!group.groups.some(alliance => alliance.members.includes(me.id))) {
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6 &&
const limit = group.variant === 'unlimited' ? 6 : 3;
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < limit &&
!group.blockedInvitations?.some(blocked => blocked.groupId === candidate.id && blocked.entrant === me.id));
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
expectedRevision:group.revision}];
+6 -4
View File
@@ -28,8 +28,9 @@ export type CoalitionAdmission = {
};
export type CoalitionState = {
version: 1;
/** Absent preserves the existing three-member maximum. */
variant?: 'unlimited';
/** Absent preserves the existing three-member limit and target selection.
* The distinct short variant retains that cap and uses the short target. */
variant?: 'three-short' | 'unlimited';
revision: number;
groups: CoalitionGroup[];
proposal: CoalitionAdmission | null;
@@ -64,7 +65,7 @@ const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.st
const copy = <T>(value: T): T => structuredClone(value);
const legacyId = (members: readonly string[]) => `legacy:${JSON.stringify(sorted(members))}`;
export function emptyCoalitionState(variant?: 'unlimited'): CoalitionState {
export function emptyCoalitionState(variant?: CoalitionState['variant']): CoalitionState {
return { version: 1, ...(variant === undefined ? {} : {variant}), revision: 0, groups: [], proposal: null, usedEvents: [] };
}
function requireSeats(seats: readonly CoalitionSeat[]): void {
@@ -95,7 +96,8 @@ function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[], ma
check(same(actual, expected), 'A coalition must retain exactly one ordered edge for each pair.');
}
function requireState(state: CoalitionState): void {
check(record(state) && state.version === 1 && (state.variant === undefined || state.variant === 'unlimited') &&
check(record(state) && state.version === 1 &&
(state.variant === undefined || state.variant === 'three-short' || state.variant === 'unlimited') &&
Number.isSafeInteger(state.revision) && state.revision >= 0 &&
Array.isArray(state.groups) && Array.isArray(state.usedEvents) && state.usedEvents.every(id) &&
new Set(state.usedEvents).size === state.usedEvents.length &&
+1 -1
View File
@@ -4504,7 +4504,7 @@ export const RULE_TOPICS: RuleTopic[] = [
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. An explicit staged unlimited variant extends membership to six and uses the three-stronghold target; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. Separate staged variants support up to three members at three strongholds and up to six members at three strongholds; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
+2 -1
View File
@@ -154,7 +154,8 @@ export function strongholdProgress(
);
const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, members.length > 1,
g.coalitionState?.variant === 'unlimited' ? true : members.length >= 3 ? false : g.shortAllianceVictory,
g.coalitionState?.variant === 'unlimited' || g.coalitionState?.variant === 'three-short'
? true : members.length >= 3 ? false : g.shortAllianceVictory,
) + extra;
return {
player: id,
@@ -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'));
});