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

+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',