From 83ccd718213eef05a4ce3b39820c27e1ca1c4a09 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:42:41 +1100 Subject: [PATCH] Support group battle preparation powers --- docs/CURRENT_STATUS.md | 9 + docs/OPTIONAL_ALLIANCE_VICTORY.md | 12 ++ docs/RULE_DECISIONS.md | 6 + game/battle-power-question.ts | 8 +- game/coalition-battle-preparation.ts | 61 ++++++ game/combat-response-quote.ts | 18 +- game/engine.ts | 19 +- game/reference.ts | 4 +- tests/coalition-battle-power-question.test.ts | 102 ++++++++++ tests/coalition-battle-preparation.test.ts | 192 ++++++++++++++++++ 10 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 game/coalition-battle-preparation.ts create mode 100644 tests/coalition-battle-power-question.test.ts create mode 100644 tests/coalition-battle-preparation.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 95c1b1ca..00971c16 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,15 @@ open mode gates. ## Current checkpoint and work +**Staged group Voice and Prescience — 28 October. Prototyped / Partial.** +Bene Gesserit and Atreides can offer their native battle powers for either +ally in a staged Basic classic group; the same beneficiary quote is used by +Truthtrance. Each multi-ally application is provisional. Other group benefits +and lobby access remain unfinished. Five related files pass 60 cases, including +real Nexus and battle-choice actions after a staged position, Karama responses, +and rendered owner controls. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-voice-and-prescience--28-october-2026). + **Staged group Fremen revival aid — 28 October. Prototyped / Partial.** Fremen can select each ally for a free-revival grant in the unexposed Basic classic group path. Each declaration retains its recipient through the Karama response; diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index a9dfbe15..20a1bf86 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -71,6 +71,18 @@ an implementation interpretation, not an official ruling. The prototype is restricted to Basic classic factions without optional modules; other group faction benefits and combinations remain unfinished and no lobby exposes it. +## Group Voice and Prescience — 28 October 2026 + +In the unexposed Basic classic group prototype, Bene Gesserit may Voice an +opponent of either allied combatant, and Atreides may request Prescience for +either ally's battle. The existing native preparation order, cancellation, +answer and control windows remain in place. The same side selection drives +Truthtrance's native-power opportunity; it never substitutes the other ally +or allows an ambiguous battle between group members. Extending the printed +single-ally wording to both group members is provisional. Advanced, expansion +factions and optional modules remain outside this prototype, and normal +group starts are not yet available. + ## Group Nexus negotiation — 28 October 2026 **Integrated groundwork, not a selectable game mode.** In a staged Basic diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index b37ce69e..bc68bcf0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group Voice and Prescience — 28 October 2026 + +The staged Basic group prototype provisionally applies these native alliance +powers to either allied combatant, preserving the original response and +answer windows. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-voice-and-prescience--28-october-2026). + ## Group Fremen revival aid — 28 October 2026 The staged Basic group prototype provisionally lets Fremen grant its ordinary diff --git a/game/battle-power-question.ts b/game/battle-power-question.ts index 6adb4748..edd2ab8c 100644 --- a/game/battle-power-question.ts +++ b/game/battle-power-question.ts @@ -1,4 +1,5 @@ import { voiceChoices } from './battle-cards'; +import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation'; export type NativeBattlePower = 'voice' | 'prescience'; export type BattlePowerClaim = @@ -15,7 +16,7 @@ export type NativeBattlePowerOpportunity = { event: string; territory: string; power: NativeBattlePower; owner: string; beneficiary: string; target: string; ready: boolean; }; -export type NativeBattlePowerGame = { +export type NativeBattlePowerGame = Omit & { status: string; phase: number; players: readonly { id: string; faction: string; ally?: string | null }[]; @@ -110,9 +111,8 @@ export function nativeBattlePowerOpportunity(game: NativeBattlePowerGame, actor: const side = (kind: NativeBattlePower) => { const owners = game.players.filter(player => player.faction === (kind === 'voice' ? 'beneGesserit' : 'atreides')); if (owners.length !== 1) return null; - const owner = owners[0], combatants = [battle.attacker, battle.defender]; - const beneficiary = combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) && - game.players.some(player => player.id === owner.ally && player.ally === owner.id) ? owner.ally : null; + const owner = owners[0]; + const beneficiary = coalitionBattleBeneficiary(game, owner.id, battle.attacker, battle.defender, true); return beneficiary ? { owner: owner.id, beneficiary, target: battle.attacker === beneficiary ? battle.defender : battle.attacker } : null; }; diff --git a/game/coalition-battle-preparation.ts b/game/coalition-battle-preparation.ts new file mode 100644 index 00000000..64e9dd20 --- /dev/null +++ b/game/coalition-battle-preparation.ts @@ -0,0 +1,61 @@ +import { coalitionOf, CoalitionAllianceError, type CoalitionState } from './coalition-alliance'; + +/** Only public roster and rule-profile data are required to select a side. + * Inspection answers and sealed plans are deliberately absent. */ +export type CoalitionBattlePreparationContext = { + status: string; + players: readonly { id: string; faction: string; ally?: string | null; allySinceTurn?: number }[]; + coalitionState?: CoalitionState; + advanced?: boolean; + expansions?: readonly unknown[]; + homeworlds?: unknown; + nexusCards?: unknown; + leaderSkills?: unknown; + techTokens?: unknown; + strongholdCards?: unknown; + ecazTreachery?: unknown; + discoveryEnabled?: unknown; +}; + +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 + * closed. `requireMutualPair` preserves the stricter legacy Truthtrance quote; + * normal battle preparation historically accepted the owner's scalar ally. */ +export function coalitionBattleBeneficiary( + game: CoalitionBattlePreparationContext, + ownerId: string, + attacker: string, + defender: string, + requireMutualPair = false, +): string | null { + if (!Array.isArray(game.players) || !ownerId || !attacker || !defender || attacker === defender) return null; + const owner = game.players.find(player => player.id === ownerId); + 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 && + Array.isArray(game.expansions) && game.expansions.length === 0 && + !game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.ecazTreachery && !game.discoveryEnabled && + game.players.every(player => classicFactions.has(player.faction))) { + try { + const seats = game.players.map(player => ({id: player.id, ally: player.ally ?? null, + ...(player.allySinceTurn !== undefined ? {allySinceTurn: player.allySinceTurn} : {})})); + const group = coalitionOf(seats, game.coalitionState, ownerId); + if (group?.members.length === 3) { + const members = combatants.filter(player => group.members.includes(player)); + return members.length === 1 ? members[0] : null; + } + } catch (error) { + if (error instanceof CoalitionAllianceError) return null; + throw error; + } + } + if (combatants.includes(owner.id)) return owner.id; + if (!owner.ally || !combatants.includes(owner.ally)) return null; + if (requireMutualPair && !game.players.some(player => player.id === owner.ally && player.ally === owner.id)) return null; + return owner.ally; +} diff --git a/game/combat-response-quote.ts b/game/combat-response-quote.ts index 52b564a1..0eb76ef1 100644 --- a/game/combat-response-quote.ts +++ b/game/combat-response-quote.ts @@ -2,6 +2,7 @@ import { FACTIONS, type FactionId } from './catalog'; import { voiceChoices } from './battle-cards'; import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle'; import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice'; +import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation'; export type CombatCheckKind = | 'kwisatz' @@ -10,7 +11,7 @@ export type CombatCheckKind = | 'choamBattleAid'; export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience'; export type CombatCheck = { kind: CombatCheckKind; owner: string }; -export type CombatResponseInput = { +export type CombatResponseInput = Omit & { ecazOccupy?: EcazOccupyBattleProfile; status: string; phase: number; @@ -21,6 +22,7 @@ export type CombatResponseInput = { id: string; faction: FactionId; ally?: string | null; + allySinceTurn?: number; specialKaramaUsed?: boolean; }[]; battle: { @@ -317,11 +319,7 @@ function calculate( requireCombat(owner, 'Combat preparation response has no owner.'); // Match battlePreparation's existing provider selection, including its // one-way ally link. CHOAM's queue above independently requires mutual links. - const beneficiary = combatants.includes(owner.id) - ? owner.id - : owner.ally && combatants.includes(owner.ally) - ? owner.ally - : null; + const beneficiary = coalitionBattleBeneficiary(input, owner.id, b.attacker, b.defender); requireCombat( beneficiary, 'Combat preparation owner has no combatant beneficiary.', @@ -339,13 +337,7 @@ function calculate( // Voice has already advanced preparation to Prescience (or its finish). if (b.preparation !== undefined) { const atreides = input.players.find((p) => p.faction === 'atreides'); - const recipient = - atreides && - (combatants.includes(atreides.id) - ? atreides.id - : atreides.ally && combatants.includes(atreides.ally) - ? atreides.ally - : null); + const recipient = atreides && coalitionBattleBeneficiary(input, atreides.id, b.attacker, b.defender); requireCombat( record(b.preparation) && b.preparation.kind === 'prescience' && diff --git a/game/engine.ts b/game/engine.ts index c9f9d809..e7bc7289 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -753,6 +753,7 @@ import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipmen type BasicAlliedShipment } from './allied-separation'; import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionGroups, coalitionOf, leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance'; +import { coalitionBattleBeneficiary } from './coalition-battle-preparation'; import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership, type CoalitionEscrowState } from './coalition-escrow'; import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding, @@ -30596,6 +30597,15 @@ function currentCombatResponseQuote( status: g.status, phase: g.phase, advanced: g.advanced, + coalitionState: g.coalitionState, + expansions: g.expansions, + homeworlds: g.homeworlds, + nexusCards: g.nexusCards, + leaderSkills: g.leaderSkills, + techTokens: g.techTokens, + strongholdCards: g.strongholdCards, + ecazTreachery: g.ecazTreachery, + discoveryEnabled: g.discoveryEnabled, turn: g.turn, ...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}), territoryIds: combatLocations(g).map((t) => t.id), @@ -30603,6 +30613,7 @@ function currentCombatResponseQuote( id: p.id, faction: p.faction, ally: p.ally, + allySinceTurn: p.allySinceTurn, specialKaramaUsed: p.specialKaramaUsed, })), battle: g.battle, @@ -30690,13 +30701,7 @@ function battlePreparation(g: Game, kind: 'voice' | 'prescience', fresh = false) } const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides'); const combatants = [b.attacker, b.defender]; - const beneficiary = - owner && - (combatants.includes(owner.id) - ? owner.id - : owner.ally && combatants.includes(owner.ally) - ? owner.ally - : null); + const beneficiary = owner && coalitionBattleBeneficiary(g, owner.id, combatants[0], combatants[1]); if (owner && beneficiary) { const target = b.attacker === beneficiary ? b.defender : b.attacker; const occupation = kind === 'voice' diff --git a/game/reference.ts b/game/reference.ts index 412caad0..e646b905 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4500,11 +4500,11 @@ 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 and the Emperor’s targeted aid; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.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, Emperor and Fremen aid, and native Voice and Prescience; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.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'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions and Emperor aid; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Emperor and Fremen aid and battle preparation; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts'] }, ], related: [ 'mentat', diff --git a/tests/coalition-battle-power-question.test.ts b/tests/coalition-battle-power-question.test.ts new file mode 100644 index 00000000..47cd54b1 --- /dev/null +++ b/tests/coalition-battle-power-question.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation'; +import { nativeBattlePowerOpportunity, type NativeBattlePowerGame } from '../game/battle-power-question'; +import { applyAction, createGame, newPlayer } from '../game/engine'; + +/** The ledger starts staged because the lobby does not expose group play; + * the pair and unanimous third admission use real Nexus actions. */ +function groupGame(): NativeBattlePowerGame { + let game = createGame('GROUPPOWER', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), false); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('e', 'Emperor', 'emperor'), + newPlayer('h', 'Harkonnen', 'harkonnen')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['bg','a','e','h'], + ready:[], storm:18, response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = applyAction(game, 'bg', {type:'alliance', target:'a'}); + game = applyAction(game, 'a', {type:'alliance', target:'bg'}); + const pair = coalitionOf(game.players, game.coalitionState, 'bg'); assert.ok(pair); + game = applyAction(game, 'bg', {type:'coalitionPropose', groupId:pair.id, entrant:'e', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['a', 'e']) { + const proposal = game.coalitionState!.proposal!; + game = applyAction(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'bg')!.members, ['a','bg','e']); + return {status:'playing', phase:6, advanced:false, expansions:[], players:game.players, + coalitionState:game.coalitionState, + battle:{event:'group-battle', territory:'carthag', attacker:'e', defender:'h', revealed:false, + prepared:true, preparation:{kind:'voice', owner:'bg', beneficiary:'e'}}}; +} + +void test('group Voice and Prescience select either actual allied combatant against an outsider', () => { + const source = groupGame(); + const before = structuredClone(source); + for (const [owner, otherAlly] of [['bg','a'], ['a','bg']] as const) { + for (const ally of [otherAlly, 'e']) { + assert.equal(coalitionBattleBeneficiary(source, owner, ally, 'h'), ally); + assert.equal(coalitionBattleBeneficiary(source, owner, 'h', ally, true), ally); + } + assert.equal(coalitionBattleBeneficiary(source, owner, owner, 'h'), owner); + } + assert.deepEqual(source, before); +}); + +void test('group Voice is ready now and allied Prescience remains the future owned opportunity', () => { + const source = groupGame(); + assert.deepEqual(nativeBattlePowerOpportunity(source, 'bg', 'voice'), { + event:'group-battle', territory:'carthag', power:'voice', owner:'bg', beneficiary:'e', target:'h', ready:true, + }); + assert.deepEqual(nativeBattlePowerOpportunity(source, 'a', 'prescience'), { + event:'group-battle', territory:'carthag', power:'prescience', owner:'a', beneficiary:'e', target:'h', ready:false, + }); + source.battle!.preparation = {kind:'prescience', owner:'a', beneficiary:'e'}; + assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience')?.ready, true); + assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null); + source.response = {}; + assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience')?.ready, false); +}); + +void test('each group ally supplies the beneficiary for the corresponding native power', () => { + const source = groupGame(); + source.battle!.attacker = 'a'; + source.battle!.preparation = {kind:'voice', owner:'bg', beneficiary:'a'}; + assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice')?.beneficiary, 'a'); + source.battle!.attacker = 'bg'; + source.battle!.preparation = {kind:'prescience', owner:'a', beneficiary:'bg'}; + assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience')?.beneficiary, 'bg'); +}); + +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:{}}, + {nexusCards:{}}, {leaderSkills:{}}, {techTokens:{}}, {strongholdCards:{}}, + {ecazTreachery:true}, {discoveryEnabled:true}]) { + const unsupported = {...source, ...patch}; + assert.equal(coalitionBattleBeneficiary(unsupported, 'bg', 'e', 'h'), null); + assert.equal(nativeBattlePowerOpportunity(unsupported, 'bg', 'voice'), null); + } + assert.equal(coalitionBattleBeneficiary(source, 'bg', 'a', 'e'), null); + assert.equal(coalitionBattleBeneficiary(source, 'bg', 'bg', 'a'), null); + assert.equal(coalitionBattleBeneficiary(source, 'bg', 'e', 'e'), null); + source.battle!.attacker = 'a'; source.battle!.defender = 'e'; + source.battle!.preparation = {kind:'voice', owner:'bg', beneficiary:'a'}; + assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null); +}); + +void test('legacy self and reciprocal-pair selection keep their existing behavior', () => { + const pair: NativeBattlePowerGame = {status:'playing', phase:6, players:[ + {id:'bg', faction:'beneGesserit', ally:'e'}, {id:'e', faction:'emperor', ally:'bg'}, + {id:'h', faction:'harkonnen', ally:null}, + ], battle:{event:'pair', territory:'carthag', attacker:'e', defender:'h', revealed:false, + preparation:{kind:'voice', owner:'bg', beneficiary:'e'}}}; + assert.equal(coalitionBattleBeneficiary(pair, 'bg', 'bg', 'h', true), 'bg'); + assert.equal(coalitionBattleBeneficiary(pair, 'bg', 'e', 'h', true), 'e'); + assert.equal(nativeBattlePowerOpportunity(pair, 'bg', 'voice')?.beneficiary, 'e'); + pair.players = pair.players.map(player => player.id === 'e' ? {...player, ally:null} : player); + assert.equal(coalitionBattleBeneficiary(pair, 'bg', 'e', 'h'), 'e'); + assert.equal(coalitionBattleBeneficiary(pair, 'bg', 'e', 'h', true), null); + assert.equal(nativeBattlePowerOpportunity(pair, 'bg', 'voice'), null); +}); diff --git a/tests/coalition-battle-preparation.test.ts b/tests/coalition-battle-preparation.test.ts new file mode 100644 index 00000000..e8cd2531 --- /dev/null +++ b/tests/coalition-battle-preparation.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BattlePreparation } from '../components/battle-preparation'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { botActions } from '../game/bots'; +import { applyAction, battles, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +type Power = 'voice' | 'prescience'; +const send = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; + +/** Group entry is unavailable in the lobby. Stage a Nexus and form the pair + * and group with real actions, then stage conserved forces at an unresolved + * battle. Card acquisition is a physical transfer from the undealt deck. */ +function source(power: Power, beneficiary: 'e' | 'h' | 'owner', group = true) { + const faction = power === 'voice' ? 'beneGesserit' : 'atreides'; + const owner = power === 'voice' ? 'b' : 'a'; + let game = createGame('GROUPBATTLE', newPlayer(owner, faction, faction), false); + game.players.push(newPlayer('e', 'Emperor', 'emperor'), + newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, + order:[owner,'e','h','g'], ready:[], storm:18, deck:baseDeck(), + response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = send(game, owner, {type:'alliance', target:'e'}); + game = send(game, 'e', {type:'alliance', target:owner}); + if (group) { + const pair = coalitionOf(game.players, game.coalitionState, owner); assert.ok(pair); + game = send(game, owner, {type:'coalitionPropose', groupId:pair.id, entrant:'h', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['e','h']) { + const proposal = game.coalitionState!.proposal!; + game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + } + const beneficiaryId = beneficiary === 'owner' ? owner : beneficiary; + Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null, + phaseOpening:null, battle:null}); + for (const seat of game.players) { + seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; + seat.hand = []; seat.traitors = []; seat.traitorChoices = []; + } + for (const id of [beneficiaryId,'g']) { + player(game,id).forces = {'arrakeen:10':4}; + player(game,id).reserves = 16; + } + const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); + const [karama] = game.deck.splice(index, 1); + player(game,'g').hand.push(karama); + const choice = battles(game).find(item => item.territory === 'arrakeen' && + [item.attacker,item.defender].includes(beneficiaryId) && [item.attacker,item.defender].includes('g')); + assert.ok(choice, 'The staged force position offers an unresolved legal battle.'); + game.active = choice.chooser; + const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker; + game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy}); + return {game, owner, beneficiary:beneficiaryId, karama}; +} +function passResponses(game: Game): Game { + for (let count = 0; game.response && count < 24; count++) { + const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); + assert.ok(next); + game = send(game, next.id, {type:'passResponse'}); + } + assert.equal(game.response ?? null, null); + return game; +} +const action = (power: Power): Action => power === 'voice' + ? {type:'voice', kind:'projectile', must:false} + : {type:'prescience', field:'weapon'}; + +for (const power of ['voice','prescience'] as const) { + for (const ally of ['e','h'] as const) { + void test(`Basic group ${power} supports ${ally} in their battle against an outsider`, () => { + const {game:before, owner, beneficiary} = source(power, ally); + assert.deepEqual(before.battle?.preparation, {kind:power, owner, beneficiary}); + let game = send(before, owner, action(power)); + assert.equal(game.response?.kind, power); + assert.equal(game.response?.owner, owner); + if (power === 'voice') assert.deepEqual(game.battle?.voice, + {target:'g', kind:'projectile', must:false}); + else assert.deepEqual(game.battle?.prescience, {player:beneficiary, field:'weapon'}); + game = passResponses(game); + if (power === 'prescience') { + assert.equal(game.battle?.preparation?.kind, 'prescienceAnswer'); + assert.equal(game.battle?.preparation?.owner, 'g'); + game = send(game, 'g', {type:'prescienceAnswer', value:null}); + assert.equal(game.battle?.prescience?.value, null); + } + assert.equal(game.battle?.revealed, false); + assert.deepEqual(game.battle?.plans, {}); + }); + void test(`Karama cancels group ${power} offered to ${ally}`, () => { + const {game:before, owner, karama} = source(power, ally); + let game = send(before, owner, action(power)); + game = send(game, 'g', {type:'card', mode:'cancel', card:karama.id}); + game = passResponses(game); + assert.equal(game.battle?.[power], undefined); + assert.equal(game.discard.filter(card => card.id === karama.id).length, 1); + assert.equal(game.battle?.revealed, false); + }); + } + for (const mode of ['pair','owner'] as const) + void test(`Basic ${power} retains the ${mode === 'pair' ? 'pair ally' : 'owner'} beneficiary`, () => { + const {game, owner, beneficiary} = source(power, mode === 'pair' ? 'e' : 'owner', mode !== 'pair'); + assert.deepEqual(game.battle?.preparation, {kind:power, owner, beneficiary}); + assert.equal(send(game, owner, action(power)).response?.kind, power); + }); + void test(`a Medium bot proposes a legal group ${power} decision`, () => { + const {game, owner} = source(power, 'h'); + const view = viewGame(game, owner); + view.players.find(seat => seat.id === owner)!.bot = 'Medium'; + const choice = botActions(view).find(candidate => candidate.type === power || candidate.type === 'declineBattlePower'); + assert.ok(choice); + const after = send(game, owner, choice); + assert.ok(after.response || after.battle?.preparation?.kind !== power); + }); + void test(`group ${power} renders the owner's controls and names its beneficiary`, () => { + const {game, owner, beneficiary} = source(power, 'h'); + const own = viewGame(game, owner); + assert.equal(own.battle?.preparation?.beneficiary, beneficiary); + assert.ok([own.battle!.attacker, own.battle!.defender].includes('g')); + const ownMarkup = renderToStaticMarkup(createElement(BattlePreparation, { + game:own, busy:false, send:async () => {}, + })); + assert.match(ownMarkup, /Harkonnen/); + assert.match(ownMarkup, power === 'voice' ? /Use the Voice/ : /Reveal weapon/); + const observer = viewGame(game, 'g'); + const observerMarkup = renderToStaticMarkup(createElement(BattlePreparation, { + game:observer, busy:false, send:async () => {}, + })); + assert.match(observerMarkup, /Waiting for/); + assert.doesNotMatch(observerMarkup, /Use the Voice|Reveal weapon/); + }); +} + +void test('group Voice and Prescience both aid the third ally in the same battle', () => { + let game = createGame('GROUPBOTH', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), false); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), + newPlayer('e', 'Emperor', 'emperor'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, + order:['b','a','e','g'], ready:[], storm:18, deck:baseDeck(), + response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = send(game, 'b', {type:'alliance', target:'a'}); + game = send(game, 'a', {type:'alliance', target:'b'}); + const pair = coalitionOf(game.players, game.coalitionState, 'b'); assert.ok(pair); + game = send(game, 'b', {type:'coalitionPropose', groupId:pair.id, entrant:'e', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['a','e']) { + const proposal = game.coalitionState!.proposal!; + game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null, + phaseOpening:null, battle:null}); + for (const seat of game.players) { + seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; + seat.hand = []; seat.traitors = []; seat.traitorChoices = []; + } + for (const id of ['e','g']) { + player(game,id).forces = {'arrakeen:10':4}; + player(game,id).reserves = 16; + } + const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(karamaIndex >= 0); + player(game, 'g').hand.push(...game.deck.splice(karamaIndex, 1)); + const choice = battles(game).find(item => item.territory === 'arrakeen' && + [item.attacker,item.defender].includes('e') && [item.attacker,item.defender].includes('g')); + assert.ok(choice); + game.active = choice.chooser; + const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker; + game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy}); + assert.deepEqual(game.battle?.preparation, {kind:'voice', owner:'b', beneficiary:'e'}); + game = send(game, 'b', {type:'voice', kind:'projectile', must:false}); + assert.equal(game.response?.kind, 'voice'); + assert.deepEqual(game.battle?.voice, {target:'g', kind:'projectile', must:false}); + game = passResponses(game); + assert.deepEqual(game.battle?.preparation, {kind:'prescience', owner:'a', beneficiary:'e'}); + game = send(game, 'a', {type:'prescience', field:'weapon'}); + assert.equal(game.response?.kind, 'prescience'); + game = passResponses(game); + assert.deepEqual(game.battle?.prescience, {player:'e', field:'weapon'}); + assert.equal(game.battle?.preparation?.owner, 'g'); + game = send(game, 'g', {type:'prescienceAnswer', value:null}); + assert.equal(game.battle?.prescience?.value, null); + assert.equal(game.battle?.voice?.target, 'g'); + assert.deepEqual(game.battle?.plans, {}); +});