diff --git a/components/coalition-emperor-aid.tsx b/components/coalition-emperor-aid.tsx new file mode 100644 index 00000000..6d365e4b --- /dev/null +++ b/components/coalition-emperor-aid.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useId, useState } from 'react'; +import type { Player } from '@/game/engine'; +import { eliteRevivalRemaining, paidForceRevivalCost } from '@/game/revival'; + +/** Public alliance members only; the engine remains the legality authority. */ +export type CoalitionEmperorAlly = Pick; +export type CoalitionEmperorAidAction = + | { type: 'emperorGift'; target: string; amount: number } + | { type: 'emperorRevival'; target: string; amount: number; elite: number }; + +function positiveInteger(raw: string): number | null { + if (!/^[1-9]\d*$/.test(raw)) return null; + const value = Number(raw); + return Number.isSafeInteger(value) ? value : null; +} + +/** Render for an Emperor in a staged group alliance; pair controls stay separate. */ +export function CoalitionEmperorAid({ allies, phase, advanced, availableSpice, + emperorExtra, revivalOrderHead, busy = false, act }: { + allies: readonly CoalitionEmperorAlly[]; + phase: number; + advanced: boolean; + /** Spice not committed to coalition pledges or other obligations. */ + availableSpice: number; + emperorExtra: Readonly>; + revivalOrderHead?: string | null; + busy?: boolean; + act: (action: CoalitionEmperorAidAction) => void; +}) { + const id = useId(); + const [giftTarget, setGiftTarget] = useState(''); + const [giftRaw, setGiftRaw] = useState('1'); + const [revivalTarget, setRevivalTarget] = useState(''); + const [revivalRaw, setRevivalRaw] = useState('1'); + const [eliteRaw, setEliteRaw] = useState('0'); + const giftAlly = allies.find(ally => ally.id === giftTarget); + const revivalAlly = allies.find(ally => ally.id === revivalTarget); + const gift = positiveInteger(giftRaw); + const amount = positiveInteger(revivalRaw); + const elite = /^(0|[1-9]\d*)$/.test(eliteRaw) && Number.isSafeInteger(Number(eliteRaw)) + ? Number(eliteRaw) : null; + const maximum = revivalAlly ? Math.max(0, Math.min( + 3 - (emperorExtra[revivalAlly.id] ?? 0), revivalAlly.tanks, + Math.floor(availableSpice / 2), + )) : 0; + const cost = revivalAlly && amount && elite !== null + ? paidForceRevivalCost(revivalAlly, amount, elite) : null; + const eliteTanks = revivalAlly?.elites?.tanks ?? 0; + const legalElite = revivalAlly && amount && elite !== null && + elite <= Math.min(amount, eliteRevivalRemaining(revivalAlly, advanced)) && + amount - elite <= revivalAlly.tanks - eliteTanks; + const canGift = !!giftAlly && gift !== null && gift <= availableSpice && !busy; + const canRevive = phase === 4 && !!revivalAlly && amount !== null && amount <= maximum && + legalElite && cost !== null && cost <= availableSpice && + (!revivalOrderHead || revivalOrderHead === revivalAlly.id) && !busy; + + return
+

Emperor alliance aid

+

Choose which ally receives your aid. Spice and revivals take effect after the Karama response.

+ + + + setGiftRaw(event.target.value)} /> + + {phase === 4 &&
+ + + {revivalAlly && <> +

{revivalAlly.name} has {revivalAlly.tanks} forces in the tanks and can receive up to {maximum} extra revivals now.

+ {revivalOrderHead && revivalOrderHead !== revivalAlly.id &&

Wait for this ally’s revival turn.

} + + setRevivalRaw(event.target.value)} /> + {revivalAlly.elites && <> + + setEliteRaw(event.target.value)} /> + } + {cost !== null &&

You pay {cost} spice. This uses {revivalAlly.name}’s extra-revival allowance.

} + } + +
} +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index fe60e23b..b43c7ab3 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -286,6 +286,7 @@ import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion import { AdvancedAllySeparation } from './advanced-ally-separation'; import { CoalitionNexus } from './coalition-nexus'; import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding'; +import { CoalitionEmperorAid } from './coalition-emperor-aid'; import { NexusEmperorPayment } from './nexus-emperor-payment'; import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase'; import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase'; @@ -4539,6 +4540,11 @@ export function GameTable({ )} )} + {g.coalitionEmperor && g.coalitionEmperor!.allies.includes(player.id))} + phase={g.phase} advanced={g.advanced} availableSpice={g.coalitionEmperor.availableSpice} + emperorExtra={g.emperorExtra} revivalOrderHead={g.revivalOrder?.remaining[0]} + busy={busy} act={act} />} {g.phase === 4 && ( <> diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 261198e7..40679c89 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Staged group Emperor aid — 28 October. Prototyped / Partial.** An Emperor +can choose either group ally for a spice gift or paid extra revival; the +selected recipient survives the Karama response. Separate per-ally revival +allowances are provisional. Controls and minimal targeted bot actions are +connected only for staged Basic classic groups. The five-file related batch +passes 114 cases, including real Nexus formation after a staged setup and +static control rendering. Other group benefits and normal lobby access remain +unfinished. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-emperor-aid--28-october-2026). + **Staged group Guild-rate shipments — 28 October. Prototyped / Partial.** Guild group members use the native ordinary reserve rate and unpaid Karama response. A canceled rate preserves the selected donor shares, charging the diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index ee9ed839..a9dfbe15 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -57,6 +57,20 @@ remaining faction benefits and interactions are not connected. No lobby can start a group game yet. The recipient-specific pledge and allocation procedure is a provisional online convention rather than a printed worked example. +## Group Emperor aid — 28 October 2026 + +**Staged Basic prototype, not a selectable game mode.** The Emperor chooses +which of the two group allies receives a spice gift or paid extra revival. +The ordinary Karama response retains the selected recipient, and payment and +revival occur only if that response is passed. A staged table exposes a +recipient selector; bots can offer legal targeted revivals. The native +per-recipient allowance is provisionally applied separately to both allies: +the printed power and [publisher FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +describe an ally but do not settle a three-member alliance's limit. This is +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 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 ddd1a6ff..208d3cc8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group Emperor aid — 28 October 2026 + +The staged Basic group prototype provisionally gives each ally a separate +three-force Emperor-funded allowance. The printed text and publisher FAQ do +not settle the multi-ally limit. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-emperor-aid--28-october-2026). + ## Group Guild-rate shipments — 28 October 2026 Guild's native rate provisionally applies to each group ally. Karama reprices diff --git a/game/bots.ts b/game/bots.ts index e14f8372..bfb29ec9 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3426,8 +3426,11 @@ function policyActions(g: GameView): Action[] { } if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked) actions.push({ type: 'grantRevival' }); - if (me.faction === 'emperor' && me.ally && level > 0) { - const ally = g.players.find((p) => p.id === me.ally)!; + const emperorGroup = g.coalitionEmperor; + const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? []; + if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) { + const ally = g.players.find((p) => p.id === allyId)!; + const available = emperorGroup?.availableSpice ?? me.spice ?? 0; const extra = g.revivalPrevention?.player === ally.id ? 0 @@ -3439,7 +3442,7 @@ function policyActions(g: GameView): Action[] { eliteRevivalRemaining(ally, g.advanced), ), 3 - (g.emperorExtra[ally.id] ?? 0), - Math.floor(Math.max(0, (me.spice ?? 0) - 3) / 2), + Math.floor(Math.max(0, available - 3) / 2), ); for (let amount = extra; amount > 0; amount--) { const minimumElite = Math.max( @@ -3450,8 +3453,8 @@ function policyActions(g: GameView): Action[] { Math.min(amount, eliteRevivalRemaining(ally, g.advanced)), minimumElite, ])) - if (paidForceRevivalCost(ally, amount, elite) <= (me.spice ?? 0) - 3) - actions.push({ type: 'emperorRevival', amount, elite }); + if (paidForceRevivalCost(ally, amount, elite) <= available - 3) + actions.push({ type: 'emperorRevival', amount, elite, ...(emperorGroup ? {target: ally.id} : {}) }); } } const freeRemaining = g.revival.freeRemaining; @@ -3505,7 +3508,7 @@ function policyActions(g: GameView): Action[] { return !g.revival.orderBlocked && (action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented); if (action.type === 'emperorRevival' && g.revivalOrder) - return g.revivalOrder.remaining[0] === me.ally; + return g.revivalOrder.remaining[0] === (action.target ?? me.ally); return true; }); } diff --git a/game/engine.ts b/game/engine.ts index f029b772..a554e21b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16060,6 +16060,14 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id)); return group?.members.length === 3; } +function coalitionEmperorGroup(g: Game, p: Player) { + if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced || + 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; + const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id)); + return group?.members.length === 3 ? group : null; +} function guildGroupAlly(g: Game, p: Player): boolean { return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState)); } @@ -44640,11 +44648,12 @@ function applyActionInner( return g; } if (t === 'emperorGift' || t === 'emperorRevival') { - requireRule( - p.faction === 'emperor' && p.ally, - 'Only the Emperor may use this power for an ally.', - ); - const recipient = getPlayer(g, p.ally); + const group = coalitionEmperorGroup(g, p); + const recipientId = group ? stringField(action.target) : p.ally; + requireRule(p.faction === 'emperor' && recipientId && + (group ? recipientId !== id && group.members.includes(recipientId) : recipientId === p.ally), + 'Only the Emperor may use this power for an ally.'); + const recipient = getPlayer(g, recipientId); if (t === 'emperorRevival') { requireRule( !revivalPrevented(g, recipient.id), @@ -44685,7 +44694,7 @@ function applyActionInner( ); if (t === 'emperorRevival') requireRule( - p.spice >= paidForceRevivalCost(recipient, amount, elite), + uncommittedSpice(g, p) >= paidForceRevivalCost(recipient, amount, elite), 'Not enough spice for these extra revivals.', ); if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || @@ -47019,6 +47028,11 @@ export function viewGame(state: Game, id: string, now?: number) { ? g.aid[me.ally].amount : 0, }, + coalitionEmperor: (() => { + const group = coalitionEmperorGroup(g, me); + return group ? {allies: group.members.filter(member => member !== id), + availableSpice: Math.max(0, uncommittedSpice(g, me))} : null; + })(), coalitionFunding: coalitionFundingAvailable(g, me) ? (() => { const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!; const pledges = g.coalitionEscrow!.pledges; diff --git a/game/reference.ts b/game/reference.ts index 0b1ff306..f11b2133 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 alliance target shares the progress and actual victory calculation; default and two-controller targets remain unchanged.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'The lobby offers the option and the active table displays its scope.', evidence: ['components/game-table.tsx', 'tests/short-alliance-victory-controls.test.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'Existing legal alliance and victory paths use the same shared target; strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] }, + { 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: '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 target, lobby, native Nexus and staged Mentat evidence; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts'] }, + { 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'] }, ], related: [ 'mentat', diff --git a/game/terminal-cancellation.ts b/game/terminal-cancellation.ts index 3315ac14..94e278d6 100644 --- a/game/terminal-cancellation.ts +++ b/game/terminal-cancellation.ts @@ -3,6 +3,7 @@ import { GuildAmbassadorContinuationError, } from './guild-ambassador-continuation'; import type { Game, ResponseWindow } from './engine'; +import { coalitionOf, CoalitionAllianceError } from './coalition-alliance'; import { homeworldMovementForesightBlock, homeworldSpiritualAdvisorAllowance, @@ -57,6 +58,7 @@ export type TerminalCancellationContext = Pick< | 'turn' | 'advanced' | 'players' + | 'coalitionState' | 'auction' | 'richeseAuction' | 'stormCard' @@ -138,10 +140,21 @@ export function validateTerminalCancellation( case 'emperorRevival': { faction('emperor'); amount(); + let allied = owner.ally === response.recipient; + if (!allied && g.coalitionState) { + try { + const group = coalitionOf(g.players, g.coalitionState, owner.id); + allied = !!group && group.members.length === 3 && !!response.recipient && + group.members.includes(response.recipient); + } catch (error) { + if (error instanceof CoalitionAllianceError) throw new TerminalCancellationError(error.message); + throw error; + } + } requireTerminal( recipient() && response.recipient !== owner.id && - owner.ally === response.recipient, + allied, 'The canceled Emperor power needs its originally declared ally.', ); const elite = response.elite ?? 0; diff --git a/tests/coalition-emperor-aid.test.ts b/tests/coalition-emperor-aid.test.ts new file mode 100644 index 00000000..9afe16c9 --- /dev/null +++ b/tests/coalition-emperor-aid.test.ts @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CoalitionEmperorAid } from '../components/coalition-emperor-aid'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { botActions } from '../game/bots'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +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)!; + +/** Stage a Basic Nexus because group play has no lobby entry. The actual + * alliance and unanimous admission actions create the three-member group. + * Tanks and reserves are subsequently set to conserved ordinary forces. */ +function fixture(phase = 4) { + let game = createGame('GROUPEMP', newPlayer('e', 'Emperor', 'emperor'), false); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), + newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','h','g'], + ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = send(game, 'e', {type:'alliance', target:'a'}); + game = send(game, 'a', {type:'alliance', target:'e'}); + const pair = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(pair); + game = send(game, 'e', {type:'coalitionPropose', groupId:pair.id, entrant:'h', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['a', '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}); + } + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'e')!.members, ['a','e','h']); + Object.assign(game, {phase, nexus:false, ready:[], response:null, decision: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 ['a','h']) { + player(game, id).reserves = 16; + player(game, id).tanks = 4; + } + 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); + return {game, 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, 'Each response has a remaining eligible player.'); + game = send(game, next.id, {type:'passResponse'}); + } + assert.equal(game.response ?? null, null); + return game; +} +function reject(game: Game, id: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => send(game, id, action)); + assert.deepEqual(game, before); +} +function conserved(game: Game) { + for (const seat of game.players) + assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20); +} + +void test('Emperor gifts can reach either group ally and settle from the Emperor wallet', () => { + let {game} = fixture(3); + let pending = send(game, 'e', {type:'emperorGift', target:'a', amount:3}); + assert.equal(pending.response?.recipient, 'a'); + assert.equal(player(pending, 'e').spice, 10); + game = passResponses(pending); + assert.equal(player(game, 'e').spice, 7); + assert.equal(player(game, 'a').spice, 13); + assert.equal(player(game, 'h').spice, 10); + pending = send(game, 'e', {type:'emperorGift', target:'h', amount:2}); + assert.equal(pending.response?.recipient, 'h'); + game = passResponses(pending); + assert.equal(player(game, 'e').spice, 5); + assert.equal(player(game, 'a').spice, 13); + assert.equal(player(game, 'h').spice, 12); + reject(game, 'e', {type:'emperorGift', target:'a', amount:6}); + conserved(game); +}); + +void test('Emperor revival reaches each group ally, costs two per force and tracks their separate caps', () => { + let {game} = fixture(); + player(game, 'e').spice = 12; + let pending = send(game, 'e', {type:'emperorRevival', target:'a', amount:3, elite:0}); + assert.equal(pending.response?.recipient, 'a'); + assert.equal(player(pending, 'a').tanks, 4); + game = passResponses(pending); + assert.equal(player(game, 'e').spice, 6); + assert.equal(player(game, 'a').tanks, 1); + assert.equal(player(game, 'a').reserves, 19); + assert.equal(player(game, 'a').revived, 0); + assert.equal(game.emperorExtra.a, 3); + reject(game, 'e', {type:'emperorRevival', target:'a', amount:1, elite:0}); + pending = send(game, 'e', {type:'emperorRevival', target:'h', amount:3, elite:0}); + assert.equal(pending.response?.recipient, 'h'); + game = passResponses(pending); + assert.equal(player(game, 'e').spice, 0); + assert.equal(player(game, 'h').tanks, 1); + assert.equal(player(game, 'h').reserves, 19); + assert.equal(player(game, 'h').revived, 0); + assert.equal(game.emperorExtra.h, 3); + conserved(game); +}); + +void test('Emperor aid rejects outsiders, self, missing targets and non-Emperor actors', () => { + const {game} = fixture(); + for (const type of ['emperorGift', 'emperorRevival'] as const) { + for (const target of ['g','e','missing']) + reject(game, 'e', {type, target, amount:1, elite:0}); + reject(game, 'e', {type, amount:1, elite:0}); + reject(game, 'a', {type, target:'h', amount:1, elite:0}); + } +}); + +void test('Emperor revival requires Revival, available tanks and enough Emperor spice', () => { + const {game} = fixture(3); + reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0}); + game.phase = 4; + reject(game, 'e', {type:'emperorRevival', target:'h', amount:5, elite:0}); + player(game, 'e').spice = 3; + reject(game, 'e', {type:'emperorRevival', target:'h', amount:2, elite:0}); + const done = passResponses(send(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0})); + assert.equal(player(done, 'e').spice, 1); + assert.equal(player(done, 'h').tanks, 3); + conserved(done); +}); + +for (const type of ['emperorGift', 'emperorRevival'] as const) + void test(`Karama cancels the selected group Emperor ${type} without payment or revival`, () => { + const {game, karama} = fixture(); + const declared = send(game, 'e', {type, target:'h', amount:2, elite:0}); + assert.equal(declared.response?.recipient, 'h'); + const done = passResponses(send(declared, 'g', {type:'card', mode:'cancel', card:karama.id})); + assert.equal(player(done, 'e').spice, 10); + assert.equal(player(done, 'h').spice, 10); + assert.equal(player(done, 'h').tanks, 4); + assert.equal(done.emperorExtra.h ?? 0, 0); + conserved(done); + }); + +void test('a Medium Emperor bot proposes a legal targeted group revival', () => { + const {game} = fixture(); + const view = viewGame(game, 'e'); + view.players.find(seat => seat.id === 'e')!.bot = 'Medium'; + const revival = botActions(view).find(action => action.type === 'emperorRevival'); + assert.ok(revival); + assert.ok(revival.target === 'a' || revival.target === 'h'); + const declared = send(game, 'e', revival); + assert.equal(declared.response?.recipient, revival.target); + conserved(passResponses(declared)); +}); + +void test('group Emperor controls render both recipients and require an explicit selection', () => { + const {game} = fixture(); + const view = viewGame(game, 'e'); + const projection = view.coalitionEmperor!; + const html = renderToStaticMarkup(createElement(CoalitionEmperorAid, { + allies:view.players.filter(seat => projection.allies.includes(seat.id)), phase:view.phase, + advanced:view.advanced, availableSpice:projection.availableSpice, + emperorExtra:view.emperorExtra, act:() => {}, + })); + assert.match(html, /Atreides/); + assert.match(html, /Harkonnen/); + assert.match(html, /Choose an ally/); + assert.match(html, /disabled=""[^>]*>Share spice/); + assert.match(html, /disabled=""[^>]*>Pay for extra ally revivals/); +}); + +void test('a two-player alliance retains the original implicit Emperor recipient', () => { + let game = createGame('PAIREMP', newPlayer('e', 'Emperor', 'emperor'), false); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','g'], + ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = send(game, 'e', {type:'alliance', target:'a'}); + game = send(game, 'a', {type:'alliance', target:'e'}); + Object.assign(game, {phase:4, nexus:false, ready:[]}); + for (const seat of game.players) { + seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; + seat.hand = []; seat.traitors = []; seat.traitorChoices = []; + } + player(game, 'a').reserves = 18; + player(game, 'a').tanks = 2; + assert.equal(viewGame(game, 'e').coalitionEmperor, null); + game = passResponses(send(game, 'e', {type:'emperorGift', amount:2})); + assert.equal(player(game, 'a').spice, 12); + game = passResponses(send(game, 'e', {type:'emperorRevival', amount:2, elite:0})); + assert.equal(player(game, 'e').spice, 4); + assert.equal(player(game, 'a').tanks, 0); + assert.equal(game.emperorExtra.a, 2); + conserved(game); +}); + +void test('the staged three-member Emperor benefit is unavailable in Advanced play', () => { + 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}); +}); + +void test('group Emperor aid projects and respects spice committed to its own leading bid', () => { + let {game} = fixture(2); + Object.assign(game, {active:null, ready:[]}); + for (const seat of game.players) game = send(game, seat.id, {type:'ready'}); + assert.equal(game.phase, 3); + game = passResponses(game); + assert.equal(game.auction?.active, 'e'); + game = send(game, 'e', {type:'bid', amount:6, allyPayment:0}); + assert.equal(game.auction?.bidder, 'e'); + assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 4); + reject(game, 'e', {type:'emperorGift', target:'h', amount:5}); + const pending = send(game, 'e', {type:'emperorGift', target:'h', amount:4}); + assert.equal(pending.response?.recipient, 'h'); + game = passResponses(pending); + assert.equal(player(game, 'e').spice, 6); + assert.equal(player(game, 'h').spice, 14); + assert.equal(game.auction?.bidder, 'e'); + assert.equal(game.auction?.bid, 6); + assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 0); +});