Support group battle preparation powers
Verify and publish checkpoint container / container (push) Successful in 10m57s

This commit is contained in:
Quintine committed 2026-10-28 23:42:41 +11:00
1 parent d8d2e7207b
commit 83ccd71821
10 files changed
+405 -26

No files matched your search

+9
View File
@@ -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;
+12
View File
@@ -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
+6
View File
@@ -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
+4 -4
View File
@@ -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<CoalitionBattlePreparationContext, 'players' | 'status'> & {
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;
};
+61
View File
@@ -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;
}
+5 -13
View File
@@ -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<CoalitionBattlePreparationContext, 'players' | 'status' | 'advanced'> & {
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' &&
+12 -7
View File
@@ -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'
+2 -2
View File
@@ -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',
@@ -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);
});
+192
View File
@@ -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, {});
});