diff --git a/components/coalition-nexus.tsx b/components/coalition-nexus.tsx index 6099073b..4d146a2e 100644 --- a/components/coalition-nexus.tsx +++ b/components/coalition-nexus.tsx @@ -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

Group alliance negotiations

+ {view.variant === 'three-short' &&

Alliances can have up to three members and need three strongholds to win.

} {view.variant === 'unlimited' ?

Every current member and the invited player must agree. The existing alliance remains in place while an invitation is pending.

:

All three players must agree before an invitation forms an alliance. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1505b017..d3479016 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 8b9b527c..1a4931c6 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 640cdef9..136f0fc3 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/alliance-victory-option.ts b/game/alliance-victory-option.ts index 4e5846a7..eb671241 100644 --- a/game/alliance-victory-option.ts +++ b/game/alliance-victory-option.ts @@ -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, diff --git a/game/bots.ts b/game/bots.ts index e8f8e357..e2bbb66c 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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}]; diff --git a/game/coalition-alliance.ts b/game/coalition-alliance.ts index 4f833975..cf38e75e 100644 --- a/game/coalition-alliance.ts +++ b/game/coalition-alliance.ts @@ -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 = (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 && diff --git a/game/reference.ts b/game/reference.ts index 6d4f824a..694333d8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'] }, diff --git a/game/victory-progress.ts b/game/victory-progress.ts index a91009c4..f27a6cfa 100644 --- a/game/victory-progress.ts +++ b/game/victory-progress.ts @@ -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, diff --git a/tests/coalition-three-short-runtime.test.tsx b/tests/coalition-three-short-runtime.test.tsx new file mode 100644 index 00000000..1a545fd4 --- /dev/null +++ b/tests/coalition-three-short-runtime.test.tsx @@ -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)); + } +}); diff --git a/tests/coalition-three-short-victory.test.ts b/tests/coalition-three-short-victory.test.ts new file mode 100644 index 00000000..a498c845 --- /dev/null +++ b/tests/coalition-three-short-victory.test.ts @@ -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, []); +}); diff --git a/tests/coalition-three-short.test.ts b/tests/coalition-three-short.test.ts new file mode 100644 index 00000000..2e0bfeca --- /dev/null +++ b/tests/coalition-three-short.test.ts @@ -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')); +});