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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user