Support group battle preparation powers
Verify and publish checkpoint container / container (push) Successful in 10m57s
Verify and publish checkpoint container / container (push) Successful in 10m57s
This commit is contained in:
1 parent
d8d2e7207b
commit
83ccd71821
10 files changed
+405
-26
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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, {});
|
||||
});
|
||||
Reference in new issue
Block a user