From 3ba908740553f650a08edf8574fc70ff519d7742 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:37:23 +1100 Subject: [PATCH] Connect staged Advanced classic group alliances --- docs/CURRENT_STATUS.md | 11 ++ docs/OPTIONAL_ALLIANCE_VICTORY.md | 27 +++ docs/RULE_DECISIONS.md | 7 + game/allied-separation.ts | 6 +- game/board-resolution-quote.ts | 14 +- game/coalition-battle-preparation.ts | 6 +- game/coalition-worm-protection.ts | 4 +- game/engine.ts | 11 +- game/fremen-revival-grant.ts | 4 +- game/reference.ts | 2 +- game/victory-progress.ts | 11 +- tests/coalition-advanced-board.test.ts | 108 +++++++++++ .../coalition-advanced-faction-powers.test.ts | 168 ++++++++++++++++++ tests/coalition-advanced-funding.test.ts | 120 +++++++++++++ .../coalition-advanced-nexus-runtime.test.tsx | 71 ++++++++ tests/coalition-allied-shipment.test.ts | 4 +- tests/coalition-battle-power-question.test.ts | 2 +- tests/coalition-board-rules.test.ts | 2 +- tests/coalition-emperor-aid.test.ts | 8 +- tests/coalition-fremen-revival.test.ts | 5 +- tests/coalition-unlimited-powers.test.ts | 6 +- tests/coalition-victory.test.ts | 8 +- 22 files changed, 561 insertions(+), 44 deletions(-) create mode 100644 tests/coalition-advanced-board.test.ts create mode 100644 tests/coalition-advanced-faction-powers.test.ts create mode 100644 tests/coalition-advanced-funding.test.ts create mode 100644 tests/coalition-advanced-nexus-runtime.test.tsx diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d3479016..4a40b8c6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Advanced classic group foundation — 29 October. Prototyped / Partial.** +Staged group alliances now connect genuine Advanced Nexus consent, ordinary +multi-donor bid/shipment funding, the ending player's immediate allied +separation, classic battle/stronghold quotes and selected Fremen and Emperor +alliance benefits. Twenty related files pass 143 cases. The public-setup +journeys stage the empty group ledger and selected card ordering; funding and +board/worm tests disclose additional staged wallets, counters or cards. +Typecheck, lint and build pass. Full +group play, expansion/module profiles and the lobby entry remain unfinished. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#advanced-classic-group-foundation--29-october-2026). + **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 diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 1a4931c6..19b9f899 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,32 @@ # Optional three-stronghold alliance victory +## Advanced classic group foundation — 29 October 2026 + +**Staged prototype, not a selectable game mode.** The same group negotiation +and donor-specific ordinary bid and shipment funding now admit Advanced games +with only classic factions and no optional modules. Advanced group separation +uses each actual ally and removes the ending player's shared fighters at that +player's own movement deadline, retaining Polar Sink and advisor exceptions. +Battle selection excludes every ally; group victory uses distinct strongholds +and the configured staged target. Selected group Voice/Prescience, Fremen +worm protection and per-recipient Fremen and Emperor revival use their existing +native opportunities. Existing unanimous group admission and larger-ally +power sharing remain provisional online interpretations. + +The supplied revised rulebook, physical p. 42 (printed p. 40), lists the +larger-alliance options; physical p. 15 (printed p. 13) sets the Advanced +allied separation deadline and advisor exception. This does not certify full +Advanced group play, expansion factions, optional modules, all faction +interactions or complete games. No lobby enables a group ledger. + +Twenty related files pass 143 cases. Two genuine public Advanced setups reach +the original Nexus and form groups after only the empty group ledger is staged; +selected unopened card ordering and lotteries are recorded by their fixtures. +The funding journeys use actual bids, shipments and ordered Emperor revival, +with labelled test wallets and a conserved Tank counter. Board separation and +the Fremen worm journey use staged physical positions and cards. These tests do +not constitute an unstaged full game or Advanced expansion/module acceptance. + ## Three-member, three-stronghold variant — 29 October 2026 **Staged Basic prototype, not a selectable game mode.** A distinct saved diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 136f0fc3..3b8e948f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Advanced classic group alliance foundation — 29 October 2026 + +The staged classic group profile applies the user-authorized Advanced +separation deadline to each ally, retaining Polar and advisor exceptions. +Group consent and power-sharing details remain provisional online procedures. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#advanced-classic-group-foundation--29-october-2026). + ## Three-member short alliance option — 29 October 2026 The authorized revised rulebook, physical p. 42 (printed p. 40), lists a diff --git a/game/allied-separation.ts b/game/allied-separation.ts index e0bcb4b3..64bd1776 100644 --- a/game/allied-separation.ts +++ b/game/allied-separation.ts @@ -3,6 +3,7 @@ import { ecazOccupancyRelation } from './ecaz-occupy'; import { presenceAt, type ForcePresence } from './force-presence'; import type { OccupancySeat } from './occupancy'; import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance'; +import { FACTIONS } from './catalog'; /** Physical baseline distinguishes this turn's visitors from old co-occupation. */ export type BasicAlliedShipment = Readonly<{ @@ -40,8 +41,9 @@ function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat): })); const groups = coalitionGroups(seats, context.coalitionState); if (groups.some(group => group.members.length >= 3) && - (context.advanced || context.players.some(seat => seat.faction === 'ecaz'))) - throw new Error('Three-member shipment rules require Basic play without Ecaz.'); + (context.players.some(seat => seat.faction === 'ecaz') || context.advanced && + context.players.some(seat => !FACTIONS.some(faction => faction.id === seat.faction && faction.expansion === 'base')))) + throw new Error('Larger alliance shipment rules require a supported roster without Ecaz.'); return coalitionAllies(seats, context.coalitionState, player.id); } diff --git a/game/board-resolution-quote.ts b/game/board-resolution-quote.ts index d2feaeeb..e50cc6fd 100644 --- a/game/board-resolution-quote.ts +++ b/game/board-resolution-quote.ts @@ -1,4 +1,5 @@ import type { Player } from './engine'; +import { FACTIONS } from './catalog'; import { advisorStanceAfterSettlement } from './advisor-automatic-flip'; import type { ForcePresence } from './force-presence'; import { presenceAt, presenceByLocation } from './force-presence'; @@ -29,7 +30,7 @@ export type BoardSeat = Pick= 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz'))) - throw new Error('Three-member board rules require Basic play without Ecaz.'); + if (group.members.length >= 3 && (g.players.some(player => player.faction === 'ecaz') || + g.advanced && g.players.some(player => !FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')))) + throw new Error('Larger alliance board rules require a supported roster without Ecaz.'); } catch (error) { throw new BoardResolutionError((error as Error).message); } @@ -175,8 +177,8 @@ export function quoteBattleBoard(g: BoardContext) { return { released, battles }; } -/** Union the per-edge Basic separation obligations without inventing an age for - * an older pair when a third player joins. No forces are removed by this quote. */ +/** Union per-edge separation obligations without inventing an age for an older + * pair when a third player joins. Advanced applies at each player's turn end. */ export function quoteCoalitionAlliedSeparation( g: BoardContext & { turn: number; remaining: readonly string[] }, player: string, diff --git a/game/coalition-battle-preparation.ts b/game/coalition-battle-preparation.ts index 18af0244..353ba2fe 100644 --- a/game/coalition-battle-preparation.ts +++ b/game/coalition-battle-preparation.ts @@ -20,8 +20,8 @@ export type CoalitionBattlePreparationContext = { const classicFactions = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']); /** Select the owner or a single allied combatant for native Voice or - * Prescience. The unexposed group extension is limited to the same bare Basic - * classic profile as its other faction powers. Ambiguous group battles fail + * Prescience. The unexposed group extension supports bare Basic and Advanced + * classic profiles. Ambiguous group battles fail * closed. `requireMutualPair` preserves the stricter legacy Truthtrance quote; * normal battle preparation historically accepted the owner's scalar ally. */ export function coalitionBattleBeneficiary( @@ -36,7 +36,7 @@ export function coalitionBattleBeneficiary( if (!owner || !game.players.some(player => player.id === attacker) || !game.players.some(player => player.id === defender)) return null; const combatants = [attacker, defender]; - if (game.coalitionState && game.status === 'playing' && game.advanced === false && + if (game.coalitionState && game.status === 'playing' && typeof game.advanced === 'boolean' && Array.isArray(game.expansions) && game.expansions.length === 0 && !game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards && !game.ecazTreachery && !game.discoveryEnabled && diff --git a/game/coalition-worm-protection.ts b/game/coalition-worm-protection.ts index c8ff7375..b5c63d63 100644 --- a/game/coalition-worm-protection.ts +++ b/game/coalition-worm-protection.ts @@ -35,10 +35,10 @@ const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value && (row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value))); -/** Only the existing unexposed bare Basic classic group profile is supported. +/** Only the unexposed bare Basic/Advanced classic group profile is supported. * Return null for all legacy pair games and other profiles. */ function threatened(g: Game, owner: string, territory: string): string[] | null { - if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.advanced || + if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || !g.players.every(player => classic.has(player.faction))) return null; diff --git a/game/engine.ts b/game/engine.ts index 01b6b641..46d1553d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16200,7 +16200,7 @@ function unsettledPromises(records: readonly {fulfilled?: boolean; released?: bo * mechanics are integrated. This narrow profile keeps existing pair games * and unrelated commitments on their original paths. */ function coalitionNexusAvailable(g: Game): boolean { - return g.coalitionState !== undefined && g.status === 'playing' && !g.advanced && + return g.coalitionState !== undefined && g.status === 'playing' && g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull && @@ -16306,13 +16306,14 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) { } syncCoalitionEscrow(g, before); } -/** Staged group-funding scope: Basic physical shipments and ordinary bids; +/** Staged classic group-funding scope: physical shipments and ordinary bids; * optional modules and special transactions remain separate. */ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): boolean { - if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) || + if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || ![3, 5].includes(g.phase) || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || - g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction) return false; + g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction || + !g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return false; const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id)); if (!group || group.members.length < 3) return false; const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) => @@ -16322,7 +16323,7 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled); } function coalitionEmperorGroup(g: Game, p: Player) { - if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced || + if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || !g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null; diff --git a/game/fremen-revival-grant.ts b/game/fremen-revival-grant.ts index 38e57594..b480d2a3 100644 --- a/game/fremen-revival-grant.ts +++ b/game/fremen-revival-grant.ts @@ -25,10 +25,10 @@ const id = (value: unknown): value is string => const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; -/** Match the unexposed Basic classic group profile; other profiles retain the +/** Match the unexposed Basic/Advanced classic group profile; other profiles retain the * existing mutual-pair path until their interactions are connected. */ function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[] | null { - if (!g.coalitionState || g.status !== 'playing' || g.advanced !== false || + if (!g.coalitionState || g.status !== 'playing' || typeof g.advanced !== 'boolean' || !Array.isArray(g.expansions) || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || !g.players.every(player => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(player.faction))) diff --git a/game/reference.ts b/game/reference.ts index 694333d8..ebe43f06 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2700,7 +2700,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['spice-blow', 'alliance-funding', 'table-discussion', 'privacy'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic group Nexus actions add unanimous admission and leave; the option is not yet available in the lobby and broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic and bare-classic Advanced group Nexus actions add unanimous admission and leave; the option is not yet available in the lobby and broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The group panel is connected in staged games only; no lobby enables group play yet.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal and answer staged group invitations. Full diplomacy and difficulty tuning remain deferred.' }, { area: 'Documentation', status: 'Implemented', detail: 'Offer versus alliance, incoming invitations, readiness, refresh and concurrent choices are explained.', evidence: ['docs/NEXUS_OFFERS.md'] }, diff --git a/game/victory-progress.ts b/game/victory-progress.ts index f27a6cfa..262baa55 100644 --- a/game/victory-progress.ts +++ b/game/victory-progress.ts @@ -12,6 +12,7 @@ import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; import type { TwoPlayerControllers } from './two-player-controllers'; import { allianceVictoryTarget } from './alliance-victory-option'; import { coalitionGroups } from './coalition-alliance'; +import { FACTIONS } from './catalog'; export type StrongholdProgress = { player: string; @@ -38,9 +39,8 @@ function requireProgress( if (!condition) throw new VictoryProgressError(message); } -/** Ordered side membership for the bounded coalition victory quote. Three-member - * Advanced and Ecaz combinations remain unavailable until their board rules - * and faction interactions are integrated. No game mode is enabled here. */ +/** Ordered side membership for the bounded coalition victory quote. Advanced + * larger groups are scoped to classic rosters; no game mode is enabled here. */ export function victoryMembers(g: BoardContext, player: string): string[] { const seat = g.players.find(candidate => candidate.id === player); requireProgress(seat, 'Victory needs a seated player.'); @@ -48,8 +48,9 @@ export function victoryMembers(g: BoardContext, player: string): string[] { return g.order.filter(member => member === player || member === seat.ally); const groups = coalitionGroups(g.players, g.coalitionState); requireProgress(!groups.some(group => group.members.length >= 3 && - (g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))), - 'Three-member victory rules require Basic play without Ecaz.'); + (g.players.some(candidate => candidate.faction === 'ecaz') || g.advanced && + g.players.some(candidate => !FACTIONS.some(faction => faction.id === candidate.faction && faction.expansion === 'base')))), + 'Larger alliance victory rules require a supported roster without Ecaz.'); const group = groups.find(candidate => candidate.members.includes(player)); return g.order.filter(member => member === player || !!group?.members.includes(member)); } diff --git a/tests/coalition-advanced-board.test.ts b/tests/coalition-advanced-board.test.ts new file mode 100644 index 00000000..e32cec33 --- /dev/null +++ b/tests/coalition-advanced-board.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { quoteBattleBoard, quoteCoalitionAlliedSeparation } from '../game/board-resolution-quote'; +import { isAlliedShipmentVisit } from '../game/allied-separation'; +import { strongholdProgress } from '../game/victory-progress'; +import { quoteVictory } from '../game/victory-quote'; + +/** Controlled staged public board and coalition. The engine cannot yet start + * this preview or negotiate Advanced groups; no native game is claimed. */ +function staged(): Game { + const game = createGame('ADVANCEDGROUPBOARD', newPlayer('a', 'Atreides', 'atreides'), true); + game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Bene Gesserit', 'beneGesserit'), + newPlayer('d', 'Harkonnen', 'harkonnen')); + Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, + active: 'a', order: ['a', 'b', 'c', 'd'], movementRemaining: ['a', 'b', 'c', 'd'] }); + for (const player of game.players) { player.forces = {}; player.ally = null; player.shipped = false; player.moved = 0; } + game.players[0].ally = 'b'; game.players[1].ally = 'a'; + game.players[0].allySinceTurn = 2; game.players[1].allySinceTurn = 2; + const state = emptyCoalitionState('three-short'); + const group = coalitionOf(game.players, state, 'a'); assert.ok(group); + const offer = beginCoalitionAdmission(game.players, state, { event: 'invite', nexus: 'nexus', turn: 2, + groupId: group.id, entrant: 'c', proposer: 'a', expectedRevision: 0 }); + const approval = answerCoalitionAdmission(game.players, offer.state, { event: 'invite', nexus: 'nexus', + turn: 2, actor: 'b', accept: true, expectedRevision: offer.state.revision }); + const joined = answerCoalitionAdmission(game.players, approval.state, { event: 'invite', nexus: 'nexus', + turn: 2, actor: 'c', accept: true, expectedRevision: approval.state.revision }); + for (const player of game.players) if (Object.hasOwn(joined.allyUpdates, player.id)) player.ally = joined.allyUpdates[player.id]; + game.coalitionState = joined.state; + return game; +} +const p = (game: Game, id: string) => game.players.find(player => player.id === id)!; + +void test('Advanced group separation unions all allies immediately while exempting advisors and Polar Sink', () => { + const game = staged(); + p(game, 'a').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'sietch_tabr:14': 1, 'polar_sink:0': 1 }; + p(game, 'b').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'polar_sink:0': 1 }; + p(game, 'c').forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1 }; + p(game, 'c').advisors = { sietch_tabr: {} }; + const before = structuredClone(game); + const losses = quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'a'); + assert.deepEqual(new Set(losses), new Set(['arrakeen', 'carthag'])); + assert.deepEqual(quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'c'), ['arrakeen']); + assert.deepEqual(game, before, 'A quote does not remove any counters.'); + assert.equal(isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen'), true); +}); + +void test('Advanced endMovement actually removes only the ending group member’s shared fighters', () => { + const game = staged(); + const owner = p(game, 'a'), emperor = p(game, 'b'), bg = p(game, 'c'); + owner.forces = { 'arrakeen:10': 2, 'carthag:11': 3, 'sietch_tabr:14': 1, 'polar_sink:0': 1 }; + owner.reserves = 13; + emperor.forces = { 'arrakeen:10': 1, 'polar_sink:0': 1 }; emperor.reserves = 18; + bg.forces = { 'carthag:11': 1, 'sietch_tabr:14': 1 }; bg.reserves = 18; + bg.advisors = { sietch_tabr: {} }; + const otherForces = structuredClone(game.players.filter(seat => seat.id !== 'a').map(seat => ({ + id: seat.id, forces: seat.forces, tanks: seat.tanks, + }))); + const warning = viewGame(game, 'a').advancedAllySeparation; + assert.ok(warning); + assert.deepEqual(new Set(warning.territories), new Set(['Arrakeen', 'Carthag'])); + const after = applyAction(game, 'a', { type: 'endMovement' }); + assert.equal(p(after, 'a').tanks, 5); + assert.equal(p(after, 'a').reserves, 13); + assert.equal(p(after, 'a').forces['arrakeen:10'] ?? 0, 0); + assert.equal(p(after, 'a').forces['carthag:11'] ?? 0, 0); + assert.equal(p(after, 'a').forces['sietch_tabr:14'], 1); + assert.equal(p(after, 'a').forces['polar_sink:0'], 1); + assert.deepEqual(after.players.filter(seat => seat.id !== 'a').map(seat => ({ + id: seat.id, forces: seat.forces, tanks: seat.tanks, + })), otherForces); + assert.equal(after.active, 'b'); + assert.deepEqual(after.movementRemaining, ['b', 'c', 'd']); +}); + +void test('Advanced group board generates enemy battles without ever pairing allies', () => { + const game = staged(); + p(game, 'a').forces = { 'imperial_basin:9': 1 }; + p(game, 'b').forces = { 'imperial_basin:9': 1 }; + p(game, 'd').forces = { 'imperial_basin:9': 1 }; + const battles = quoteBattleBoard(game).battles.filter(battle => battle.territory === 'imperial_basin'); + assert.equal(battles.length, 2); + assert.deepEqual(new Set(battles.map(battle => [battle.attacker, battle.defender].sort().join('/'))), + new Set(['a/d', 'b/d'])); +}); + +void test('Advanced three-short group wins on three distinct controlled strongholds', () => { + const game = staged(); game.phase = 8; + p(game, 'a').forces = { 'arrakeen:10': 1 }; + p(game, 'b').forces = { 'carthag:11': 1 }; + p(game, 'c').forces = { 'sietch_tabr:14': 1 }; + const row = strongholdProgress(game).progress.find(item => item.player === 'a')!; + assert.deepEqual(row.members, ['a', 'b', 'c']); + assert.equal(row.target, 3); + assert.equal(row.qualifies, true); + assert.deepEqual(quoteVictory(game).winner, ['a', 'b', 'c']); +}); + +void test('Advanced groups with Ecaz or another expansion faction stay outside these board quotes', () => { + for (const faction of ['ecaz', 'ixians'] as const) { + const game = staged(); p(game, 'd').faction = faction; + assert.throws(() => quoteBattleBoard(game)); + assert.throws(() => quoteCoalitionAlliedSeparation({ ...game, remaining: game.order }, 'a')); + assert.throws(() => strongholdProgress(game)); + assert.throws(() => isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen')); + } +}); diff --git a/tests/coalition-advanced-faction-powers.test.ts b/tests/coalition-advanced-faction-powers.test.ts new file mode 100644 index 00000000..73e97c5d --- /dev/null +++ b/tests/coalition-advanced-faction-powers.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck, spiceDeck } from '../game/cards'; +import { emptyCoalitionState, type CoalitionEdge } from '../game/coalition-alliance'; +import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation'; +import { createCoalitionWormProtection, currentCoalitionWormAlly, + decideCoalitionWormProtection, resolveCoalitionWormProtection, + protectedCoalitionWormAllies } from '../game/coalition-worm-protection'; +import { projectFremenGroupRevivalGrant, quoteFremenRevivalGrant } from '../game/fremen-revival-grant'; +import { createRevivalOrder } from '../game/revival-order'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); +const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; + +/** The unexposed Advanced group and phase are deliberately staged: no lobby + * offers this mode. A real ordered-Revival frame, native response actions and + * conservation of physical forces are exercised after the controlled state. */ +function source(): Game { + const game = createGame('ADVANCEDGROUPPOWERS', newPlayer('f', 'Fremen', 'fremen'), true); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'), + newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', turn:2, phase:4, order:['f','a','h','g'], + active:'f', ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + const members = ['a','f','h']; + const edges: CoalitionEdge[] = [ + {members:['a','f'], formedTurn:1}, {members:['a','h'], formedTurn:1}, + {members:['f','h'], formedTurn:1}, + ]; + game.coalitionState = {...emptyCoalitionState('unlimited'), + groups:[{id:'staged-group', members, formedTurn:1, edges}]}; + for (const player of game.players) { + player.ally = null; player.forces = {}; player.reserves = 20; player.tanks = 0; + player.spice = 10; player.hand = []; player.traitors = []; player.traitorChoices = []; + } + for (const id of ['a','h']) { seat(game, id).reserves = 16; seat(game, id).tanks = 4; } + game.revivalOrder = createRevivalOrder({turn:game.turn, order:game.order, ready:game.ready}); + // This is an actual physical card from the staged original deck, used to + // keep the normal cancellation response open until players choose to pass. + const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0); + seat(game, 'g').hand.push(game.deck.splice(karama, 1)[0]); + return game; +} +function pass(game: Game): Game { + for (let count = 0; game.response && count < 20; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(actor); + game = act(game, actor.id, {type:'passResponse'}); + } + assert.equal(game.response, null); + return game; +} + +void test('Advanced allied Voice and Prescience select the actual combatant and reject allied opposing sides', () => { + const game = source(); + assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), 'f'); + assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h', true), 'h'); + assert.equal(coalitionBattleBeneficiary(game, 'a', 'a', 'g'), 'a'); + assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'h'), null); +}); + +void test('Advanced Fremen resolves each threatened group ally in storm order', () => { + const game = source(); + game.phase = 1; + for (const id of ['a','h']) { seat(game, id).forces = {'red_chasm:7':2}; seat(game, id).reserves -= 2; } + let frame = createCoalitionWormProtection(game, 'f', 'red_chasm', 'advanced-worm'); + assert.ok(frame); + assert.deepEqual(frame.order, ['a','h']); + assert.equal(currentCoalitionWormAlly(game, frame), 'a'); + frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'a', true); + frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'a', false); + assert.equal(currentCoalitionWormAlly(game, frame), 'h'); + frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true); + frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true); + assert.deepEqual(protectedCoalitionWormAllies(game, frame), ['a']); + assert.deepEqual(frame.outcomes.map(row => row.outcome), ['allowed','canceled']); +}); + +void test('Advanced worm draw applies allowed and canceled group protections before native casualties', () => { + // Controlled supplement to the staged group source: move the four original + // forces from reserves into the same exposed sector, and position original + // Spice Cards as the prior territory and the next worm. All decisions, + // Karama consumption, resolution and resulting casualties are engine actions. + let game = source(); + Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], active:null, + response:null, decision:null, phaseOpening:null, stormPending:null}); + delete game.revivalOrder; + for (const player of game.players) { + player.forces = {'red_chasm:7':3}; player.reserves = 17; player.tanks = 0; + } + const additional = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(additional >= 0); + seat(game, 'g').hand.push(game.deck.splice(additional, 1)[0]); + const cards = spiceDeck(); + const land = cards.find(card => 'territory' in card && card.territory === 'red_chasm'); + const worm = cards.find(card => 'worm' in card); assert.ok(land && worm); + game.spiceDiscard = [[land], []]; + game.spiceDeck = [worm, ...cards.filter(card => card !== land && card !== worm && 'territory' in card), + ...cards.filter(card => card !== worm && 'worm' in card)]; + game.spice = {'red_chasm:7':8}; + for (const player of game.players) game = act(game, player.id, {type:'ready'}); + assert.equal(game.decision?.kind, 'wormProtection'); + assert.deepEqual(game.coalitionWormProtection?.order, ['a','h']); + assert.equal(game.decision.ally, 'a'); + game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'a'}); + assert.equal(game.response?.kind, 'wormAllyProtection'); + game = pass(game); + assert.equal(game.decision?.kind, 'wormProtection'); + assert.equal(game.decision.ally, 'h'); + game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'h'}); + assert.equal(game.response?.kind, 'wormAllyProtection'); + const counter = seat(game, 'g').hand.find(card => card.effect === 'karama'); assert.ok(counter); + game = act(game, 'g', {type:'card', mode:'cancel', card:counter.id}); + assert.equal(game.response?.kind, 'wormSurvival'); + assert.deepEqual(game.coalitionWormProtection?.outcomes, + [{ally:'a', outcome:'allowed'}, {ally:'h', outcome:'canceled'}]); + game = pass(game); + for (const id of ['f','a']) assert.equal(seat(game, id).forces['red_chasm:7'], 3); + for (const id of ['h','g']) { + assert.equal(seat(game, id).forces['red_chasm:7'] ?? 0, 0); + assert.equal(seat(game, id).tanks, 3); + } + assert.equal(game.spice['red_chasm:7'], undefined); + assert.equal(game.discard.filter(card => card.id === counter.id).length, 1); + assert.equal(game.coalitionWormProtection, undefined); + for (const player of game.players) + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); +}); + +void test('Advanced Fremen grants each group ally independently while native Revival remains ordered', () => { + let game = source(); + const beforeOrder = structuredClone(game.revivalOrder); + assert.deepEqual(projectFremenGroupRevivalGrant(game, 'f')!.map(row => row.recipient), ['a','h']); + for (const target of ['a','h']) { + assert.deepEqual(quoteFremenRevivalGrant(game, 'f', target), {turn:2, owner:'f', recipient:target}); + const pending = act(game, 'f', {type:'grantRevival', target}); + assert.equal(pending.response?.kind, 'fremenRevivalGrant'); + assert.equal(pending.response?.recipient, target); + game = pass(pending); + assert.ok(game.freeRevival.includes(target)); + assert.deepEqual(game.revivalOrder, beforeOrder); + } + game = act(game, 'f', {type:'ready'}); + assert.equal(game.revivalOrder?.remaining[0], 'a'); + for (const target of ['a','h']) { + assert.equal(game.revivalOrder?.remaining[0], target); + assert.equal(viewGame(game, target).revival.freeRemaining, 3); + const original = seat(game, target).spice; + game = pass(act(game, target, {type:'revive', amount:3, elite:0})); + assert.equal(seat(game, target).spice, original); + assert.equal(seat(game, target).tanks, 1); + assert.equal(seat(game, target).reserves, 19); + game = act(game, target, {type:'ready'}); + } +}); + +void test('Advanced group faction extensions retain the existing expansion and module fences', () => { + const sourceGame = source(); + for (const patch of [{expansions:['ix']}, {homeworlds:{}}, {nexusCards:{}}, {leaderSkills:{}}, + {techTokens:{}}, {strongholdCards:{}}, {ecazTreachery:true}, {discoveryEnabled:true}]) { + const game = {...structuredClone(sourceGame), ...patch} as Game; + assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), null); + assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null); + game.phase = 1; + seat(game, 'a').forces = {'red_chasm:7':1}; + assert.equal(createCoalitionWormProtection(game, 'f', 'red_chasm', 'worm'), null); + } +}); diff --git a/tests/coalition-advanced-funding.test.ts b/tests/coalition-advanced-funding.test.ts new file mode 100644 index 00000000..ea43c824 --- /dev/null +++ b/tests/coalition-advanced-funding.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance'; + +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; +const remaining = (game: Game, donor: string, recipient: string) => + game.coalitionEscrow!.pledges.find(row => row.donor === donor && row.recipient === recipient)?.amount ?? 0; +const allSpice = (game: Game) => game.players.reduce((sum, p) => sum + p.spice, 0) + + (game.coalitionEscrow?.pledges.reduce((sum, row) => sum + row.amount, 0) ?? 0); + +/** Genuine Advanced preview and normal Nexus. Only an empty group ledger is + * added there; the base fixture selects unopened deal/lot/Spice positions and + * scalar lotteries. Pair, invitation, consent and later phases use native + * actions. Tests explicitly stage wallets or one conserved Tank force. */ +function group() { + const source = createTruthAllianceFixture({advanced:true, consent:'none'}); + let game = structuredClone(source.game); + game.coalitionState = emptyCoalitionState('three-short'); + game = act(game, source.asker, {type:'alliance', target:source.opponent}); + game = act(game, source.opponent, {type:'alliance', target:source.asker}); + const pair = coalitionOf(game.players, game.coalitionState, source.asker); assert.ok(pair); + game = act(game, source.asker, {type:'coalitionPropose', groupId:pair.id, entrant:source.target, + expectedRevision:game.coalitionState!.revision}); + for (const id of [source.opponent, source.target]) { + const offer = game.coalitionState!.proposal; assert.ok(offer); + game = act(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn, + accept:true, expectedRevision:game.coalitionState!.revision}); + } + assert.equal(coalitionOf(game.players, game.coalitionState, source.asker)!.members.length, 3); + assert.ok(game.coalitionEscrow); + return {game, source}; +} +function stageWallets(game: Game, payer: string, donor1: string, donor2: string) { + seat(game, payer).spice = 2; + seat(game, donor1).spice = 10; + seat(game, donor2).spice = 10; +} +function pledge(game: Game, payer: string, donor1: string, donor2: string): Game { + game = act(game, donor1, {type:'coalitionPledge', recipient:payer, amount:4}); + return act(game, donor2, {type:'coalitionPledge', recipient:payer, amount:3}); +} +function responses(game: Game): Game { + for (let i = 0; game.response && i < 30; i++) { + const player = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(player); + game = act(game, player.id, {type:'passResponse'}); + } + assert.equal(game.response, null); + return game; +} + +void test('Advanced group bid settles both donors once and pays the Emperor', () => { + const {game: initial, source} = group(); + const payer = source.asker, donor1 = source.opponent, donor2 = source.target; + let game = advanceTruthAlliance(initial, state => state.phase === 3 && !!state.auction && truthAllianceClean(state)); + for (let count = 0; game.auction && game.auction.active !== payer && count < game.players.length; count++) + game = act(game, game.auction.active, {type:'passBid'}); + assert.equal(game.auction?.active, payer); + stageWallets(game, payer, donor1, donor2); + const beforeSpice = allSpice(game), hand = seat(game, payer).hand.length; + game = pledge(game, payer, donor1, donor2); + assert.equal(viewGame(game, payer).coalitionFunding?.incoming.length, 2); + game = act(game, payer, {type:'bid', amount:6, allyPayment:0, + coalitionContributions:[{donor:donor1, amount:3}, {donor:donor2, amount:2}]}); + for (let count = 0; !game.response && !game.decision && game.auction?.bidder === payer && count < 10; count++) + game = act(game, game.auction.active, {type:'passBid'}); + if (game.decision?.kind === 'auctionPayment') game = act(game, payer, {type:'decision', karama:false}); + if (game.response) { + assert.equal(game.response.kind, 'emperorIncome'); + game = responses(game); + } + assert.ok(seat(game, payer).hand.length > hand); + assert.equal(seat(game, payer).spice, 1); + assert.equal(seat(game, donor1).spice, 6); + assert.equal(seat(game, donor2).spice, 13, 'Emperor receives the six-spice winning price.'); + assert.equal(remaining(game, donor1, payer), 1); + assert.equal(remaining(game, donor2, payer), 1); + assert.equal(allSpice(game), beforeSpice); +}); + +void test('Advanced group shipment spends the actual donor pledges and bank invoice', () => { + const {game: initial, source} = group(); + const payer = source.asker, donor1 = source.opponent, donor2 = source.target; + let game = advanceTruthAlliance(initial, state => state.phase === 5 && state.active === payer && truthAllianceClean(state)); + stageWallets(game, payer, donor1, donor2); + // Select a safe sector without moving any force or changing its inventory. + game.storm = 18; + const beforeSpice = allSpice(game), reserves = seat(game, payer).reserves; + game = pledge(game, payer, donor1, donor2); + game = act(game, payer, {type:'ship', territory:'imperial_basin', sector:9, amount:3, elite:0, allyPayment:0, + coalitionContributions:[{donor:donor1, amount:3}, {donor:donor2, amount:2}]}); + game = responses(game); + assert.equal(seat(game, payer).forces['imperial_basin:9'], 3); + assert.equal(seat(game, payer).reserves, reserves - 3); + assert.equal(seat(game, payer).spice, 1); + assert.equal(remaining(game, donor1, payer), 1); + assert.equal(remaining(game, donor2, payer), 1); + assert.equal(allSpice(game), beforeSpice - 6, 'There is no Guild seat; the invoice goes to the bank.'); +}); + +void test('Advanced Emperor pays a group ally for one extra revival on that ally’s actual Revival turn', () => { + const {game: initial, source} = group(); + let game = advanceTruthAlliance(initial, state => state.phase === 4 && truthAllianceClean(state) && + !!state.revivalOrder?.remaining[0] && state.revivalOrder.remaining[0] !== source.target); + const target = game.revivalOrder!.remaining[0], recipient = seat(game, target), emperor = seat(game, source.target); + // Move one original ordinary reserve to Tanks and supply a test-bank wallet. + assert.ok(recipient.reserves > 0); + recipient.reserves--; recipient.tanks++; + emperor.spice = 10; + const reserves = recipient.reserves, tanks = recipient.tanks; + game = act(game, source.target, {type:'emperorRevival', target, amount:1, elite:0}); + if (game.response) assert.equal(game.response.kind, 'emperorRevival'); + game = responses(game); + assert.equal(seat(game, source.target).spice, 8); + assert.equal(seat(game, target).reserves, reserves + 1); + assert.equal(seat(game, target).tanks, tanks - 1); + assert.equal(game.emperorExtra[target], 1); +}); diff --git a/tests/coalition-advanced-nexus-runtime.test.tsx b/tests/coalition-advanced-nexus-runtime.test.tsx new file mode 100644 index 00000000..e122afd2 --- /dev/null +++ b/tests/coalition-advanced-nexus-runtime.test.tsx @@ -0,0 +1,71 @@ +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, viewGame, type Game } from '../game/engine'; +import { createTruthAllianceFixture } from './fixture-truth-alliance'; + +/** Public Advanced Harkonnen/Atreides/Emperor setup, genuine first turn and + * ordinary worm Nexus. The fixture selects only unopened original deals, + * auction and Spice positions and scalar lotteries. The optional group's + * empty unexposed ledger is inserted at the real Nexus, before any offer. */ +function pair(variant: 'three-short' | 'unlimited') { + const source = createTruthAllianceFixture({advanced:true, consent:'none'}); + let game = structuredClone(source.game); + assert.equal(game.advanced, true); + assert.equal(game.nexus, true); + assert.ok(game.players.every(player => !player.ally)); + game.coalitionState = emptyCoalitionState(variant); + game = applyAction(game, source.asker, {type:'alliance', target:source.opponent}); + game = applyAction(game, source.opponent, {type:'alliance', target:source.asker}); + return {game, source}; +} + +function answer(game: Game, player: string): Game { + const proposal = game.coalitionState!.proposal; assert.ok(proposal); + return applyAction(game, player, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); +} + +for (const variant of ['three-short', 'unlimited'] as const) { + void test(`Advanced ${variant} genuine Nexus forms a three-player group with legal bot and human consent`, () => { + const {game, source} = pair(variant); + const group = coalitionOf(game.players, game.coalitionState, source.asker); assert.ok(group); + assert.deepEqual(new Set(group.members), new Set([source.asker, source.opponent])); + const controls = viewGame(game, source.target).coalitionNexus; assert.ok(controls); + assert.equal(controls.variant, variant); + assert.deepEqual(coalitionNexusOptions(controls).invitations.map(row => row.entrant), [source.target]); + const html = renderToStaticMarkup(createElement(CoalitionNexus, {view:controls, onIntent(){}})); + assert.match(html, /Ask to join/); + + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, source.target); + view.players.find(seat => seat.id === source.target)!.bot = difficulty; + const propose = botActions(view).find(action => action.type === 'coalitionPropose'); + assert.ok(propose, difficulty); + let pending = applyAction(game, source.target, propose); + assert.deepEqual(pending.coalitionState!.proposal!.approvals, [source.target]); + const projected = viewGame(pending, source.opponent).coalitionNexus; assert.ok(projected); + assert.equal(projected.proposal!.entrant, source.target); + assert.equal(coalitionNexusOptions(projected).mayAccept, true); + assert.match(renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent(){}})), /Accept invitation/); + pending = answer(pending, source.asker); + assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, source.asker)!.members), + new Set([source.asker, source.opponent])); + const approvalView = viewGame(pending, source.opponent); + approvalView.players.find(seat => seat.id === source.opponent)!.bot = difficulty; + const approval = botActions(approvalView).find(action => action.type === 'coalitionAnswer'); + assert.ok(approval, difficulty); + assert.equal(approval.accept, true); + pending = applyAction(pending, source.opponent, approval); + assert.equal(pending.coalitionState!.proposal, null); + assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, source.asker)!.members), + new Set([source.asker, source.opponent, source.target])); + assert.equal(viewGame(pending, source.target).coalitionNexus!.variant, variant); + } + }); +} diff --git a/tests/coalition-allied-shipment.test.ts b/tests/coalition-allied-shipment.test.ts index bc4de9aa..fe0c51af 100644 --- a/tests/coalition-allied-shipment.test.ts +++ b/tests/coalition-allied-shipment.test.ts @@ -91,7 +91,7 @@ void test('shipment eligibility keeps turn and destination guards and rejects in assert.equal(isAlliedShipmentVisit(context, p, 'red_chasm'), false); assert.throws(() => isAlliedShipmentVisit({ ...context, coalitionState: null as unknown as CoalitionState }, p, 'red_chasm'), /ledger cannot be null/); p.moved = 0; - assert.throws(() => isAlliedShipmentVisit({ ...context, advanced: true }, p, 'red_chasm'), /Basic play without Ecaz/); + assert.equal(isAlliedShipmentVisit({ ...context, advanced: true }, p, 'red_chasm'), true); g.players[3].faction = 'ecaz'; - assert.throws(() => isAlliedShipmentVisit(context, p, 'red_chasm'), /Basic play without Ecaz/); + assert.throws(() => isAlliedShipmentVisit(context, p, 'red_chasm'), /supported roster without Ecaz/); }); diff --git a/tests/coalition-battle-power-question.test.ts b/tests/coalition-battle-power-question.test.ts index 47cd54b1..f12d1ecf 100644 --- a/tests/coalition-battle-power-question.test.ts +++ b/tests/coalition-battle-power-question.test.ts @@ -71,7 +71,7 @@ void test('each group ally supplies the beneficiary for the corresponding native void test('unsupported group profiles and ambiguous battles cannot borrow an ally power', () => { const source = groupGame(); - for (const patch of [{advanced:true}, {expansions:['ix']}, {homeworlds:{}}, + for (const patch of [{expansions:['ix']}, {homeworlds:{}}, {nexusCards:{}}, {leaderSkills:{}}, {techTokens:{}}, {strongholdCards:{}}, {ecazTreachery:true}, {discoveryEnabled:true}]) { const unsupported = {...source, ...patch}; diff --git a/tests/coalition-board-rules.test.ts b/tests/coalition-board-rules.test.ts index 8e3e363c..4a1fed02 100644 --- a/tests/coalition-board-rules.test.ts +++ b/tests/coalition-board-rules.test.ts @@ -77,7 +77,7 @@ void test('legacy pair timing remains unchanged and unsupported coalition combin assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'), 'You cannot enter a territory occupied by your ally.'); g.players[0].ally = null; g.players[1].ally = null; - assert.throws(() => quoteBattleBoard({ ...g, advanced: true, coalitionState }), /Basic play without Ecaz/); + assert.doesNotThrow(() => quoteBattleBoard({ ...g, advanced: true, coalitionState })); g.players[2] = newPlayer('c', 'Ecaz', 'ecaz'); assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), /do not yet support Ecaz/); }); diff --git a/tests/coalition-emperor-aid.test.ts b/tests/coalition-emperor-aid.test.ts index 9afe16c9..aacd6fdb 100644 --- a/tests/coalition-emperor-aid.test.ts +++ b/tests/coalition-emperor-aid.test.ts @@ -197,12 +197,12 @@ void test('a two-player alliance retains the original implicit Emperor recipient conserved(game); }); -void test('the staged three-member Emperor benefit is unavailable in Advanced play', () => { +void test('the staged Advanced Emperor benefit identifies both group allies', () => { const {game} = fixture(); game.advanced = true; - assert.equal(viewGame(game, 'e').coalitionEmperor, null); - reject(game, 'e', {type:'emperorGift', target:'a', amount:1}); - reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0}); + assert.deepEqual(viewGame(game, 'e').coalitionEmperor?.allies, ['a', 'h']); + const gifted = passResponses(send(game, 'e', {type:'emperorGift', target:'a', amount:1})); + assert.equal(player(gifted, 'a').spice, 11); }); void test('group Emperor aid projects and respects spice committed to its own leading bid', () => { diff --git a/tests/coalition-fremen-revival.test.ts b/tests/coalition-fremen-revival.test.ts index 8076ca20..4336e1e0 100644 --- a/tests/coalition-fremen-revival.test.ts +++ b/tests/coalition-fremen-revival.test.ts @@ -93,12 +93,11 @@ void test('group grant rejects missing, non-allied and self targets as well as w reject(game, 'f', {type:'grantRevival', target:'a'}); }); -void test('group grant rejects unsupported Advanced play and cannot retarget a pending source', () => { +void test('group grant projects Advanced recipients and cannot retarget a pending source', () => { const {game} = fixture(); const advanced = structuredClone(game); advanced.advanced = true; - assert.equal(viewGame(advanced, 'f').fremenGroupRevivalGrant, null); - reject(advanced, 'f', {type:'grantRevival', target:'a'}); + assert.ok(viewGame(advanced, 'f').fremenGroupRevivalGrant?.some(row => row.recipient === 'a')); const pending = send(game, 'f', {type:'grantRevival', target:'a'}); pending.response!.recipient = 'h'; reject(pending, 'g', {type:'passResponse'}); diff --git a/tests/coalition-unlimited-powers.test.ts b/tests/coalition-unlimited-powers.test.ts index a32e0c3e..f742cb36 100644 --- a/tests/coalition-unlimited-powers.test.ts +++ b/tests/coalition-unlimited-powers.test.ts @@ -67,7 +67,7 @@ void test('Fremen can independently select each of five allied revival recipient } assert.throws(() => quoteFremenRevivalGrant(game, 'f', 'f')); game.advanced = true; - assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null); + assert.equal(projectFremenGroupRevivalGrant(game, 'f')?.length, 5); }); void test('group battle preparation selects any single member and rejects two allied sides', () => { @@ -77,6 +77,6 @@ void test('group battle preparation selects any single member and rejects two al assert.equal(coalitionBattleBeneficiary(game, 'a', ally, 'g'), ally); assert.equal(coalitionBattleBeneficiary(game, 'a', 'e', 'b'), null); assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h'), null); - const unsupported = {...game, advanced:true}; - assert.equal(coalitionBattleBeneficiary(unsupported, 'a', 'e', 'g'), null); + const advanced = {...game, advanced:true}; + assert.equal(coalitionBattleBeneficiary(advanced, 'a', 'e', 'g'), 'e'); }); diff --git a/tests/coalition-victory.test.ts b/tests/coalition-victory.test.ts index 0e72aed2..4303655a 100644 --- a/tests/coalition-victory.test.ts +++ b/tests/coalition-victory.test.ts @@ -95,11 +95,11 @@ void test('Fremen, Guild and no-Guild Fremen final branches award the full allia assert.deepEqual(quoteVictory(noGuild).winner, ['p0', 'p1', 'p2']); }); -void test('unsupported triples and malformed coalition records fail closed across quotes', () => { +void test('Advanced classic triples quote normally while unsupported rosters and malformed records fail closed', () => { const g = game(); g.advanced = true; - assert.throws(() => strongholdProgress(g), VictoryProgressError); - assert.throws(() => fremenSpecialVictory(g), VictoryProgressError); - assert.throws(() => quoteVictory(g), VictoryQuoteError); + assert.doesNotThrow(() => strongholdProgress(g)); + assert.doesNotThrow(() => fremenSpecialVictory(g)); + assert.doesNotThrow(() => quoteVictory(g)); g.advanced = false; g.players[3].faction = 'ecaz'; assert.throws(() => strongholdProgress(g), VictoryProgressError); assert.throws(() => quoteVictory(g), VictoryQuoteError);