Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,12 @@ export type WaitingFor =
| { type: "CopyRetarget"; data: { player: PlayerId; copy_id: ObjectId; target_slots: CopyTargetSlot[]; current_slot?: number } }
// CR 700.3 + CR 700.3a: Subject is partitioning their own eligible objects
// into two piles for an `Effect::SeparateIntoPiles`. `player` is the
// CR 608.2d + CR 700.3: Controller chooses which opponent separates piles (multiplayer).
| { type: "SeparatePilesChooseOpponent"; data: {
player: PlayerId;
candidates: PlayerId[];
source_id: ObjectId;
} }
// partitioner (subject); pile B is derived engine-side as
// `eligible \ pile_a`. `chosen_pile_effect` is opaque to the frontend.
| { type: "SeparatePilesPartition"; data: {
Expand Down Expand Up @@ -1871,6 +1877,7 @@ export type GameAction =
// CR 702.99a: answer to CipherEncodeChoice — a creature to encode on, or null to decline.
| { type: "CipherEncode"; data: { creature: ObjectId | null } }
| { type: "ChooseClashOpponent"; data: { opponent: PlayerId } }
| { type: "ChoosePileOpponent"; data: { opponent: PlayerId } }
| { type: "ChooseAssistPlayer"; data: { player: PlayerId | null } }
| { type: "CommitAssistPayment"; data: { generic: number } }
| {
Expand Down
70 changes: 70 additions & 0 deletions client/src/components/modal/PileOpponentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import type { GameAction, PlayerId, WaitingFor } from "../../adapter/types.ts";
import { useGameDispatch } from "../../hooks/useGameDispatch.ts";
import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts";
import { ChoiceModal } from "./ChoiceModal.tsx";

type PileOpponentWaitingFor = Extract<
WaitingFor,
{ type: "SeparatePilesChooseOpponent" }
>;

interface PileOpponentModalContentProps {
waitingFor: PileOpponentWaitingFor;
seatOrder?: PlayerId[];
dispatch: (action: GameAction) => void | Promise<void>;
}

/**
* CR 700.3: When a spell or ability instructs "an opponent" to separate cards
* into piles, the controller chooses which opponent performs the separation.
*/
export function PileOpponentModalContent({
waitingFor,
seatOrder,
dispatch,
}: PileOpponentModalContentProps) {
const { t } = useTranslation("game");
const candidates = [...waitingFor.data.candidates].sort((a, b) => {
const aIdx = seatOrder?.indexOf(a) ?? a;
const bIdx = seatOrder?.indexOf(b) ?? b;
return aIdx - bIdx;
});
return (
<ChoiceModal
title={t("pileOpponent.title", "Choose Opponent")}
subtitle={t(
"pileOpponent.subtitle",
"Choose which opponent separates the cards into piles.",
)}
options={candidates.map((opponent) => ({
id: String(opponent),
label: getOpponentDisplayName(opponent),
}))}
onChoose={(id) => {
dispatch({
type: "ChoosePileOpponent",
data: { opponent: Number(id) },
});
}}
/>
);
}

export function PileOpponentModal() {
const canActForWaitingState = useCanActForWaitingState();
const dispatch = useGameDispatch();
const waitingFor = useGameStore((s) => s.waitingFor);
const seatOrder = useGameStore((s) => s.gameState?.seat_order);
if (waitingFor?.type !== "SeparatePilesChooseOpponent") return null;
if (!canActForWaitingState) return null;
return (
<PileOpponentModalContent
waitingFor={waitingFor}
seatOrder={seatOrder}
dispatch={dispatch}
/>
);
}
1 change: 1 addition & 0 deletions client/src/game/waitingForRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet<WaitingFor["type"]> =
"RevealUntilKeptChoice",
"RepeatDecision",
"VoteChoice",
"SeparatePilesChooseOpponent",
"SeparatePilesPartition",
"SeparatePilesChoice",
"ChooseRingBearer",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Kampf-Gegner wählen",
"subtitle": "Wähle, welcher Gegner mit dir kämpft."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,10 @@
"title": "Choose Clash Opponent",
"subtitle": "Choose which opponent will clash with you."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Elegir oponente para chocar",
"subtitle": "Elige qué oponente chocará contigo."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Choisir l'adversaire pour la confrontation",
"subtitle": "Choisissez quel adversaire fera la confrontation avec vous."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Scegli l'avversario per lo scontro",
"subtitle": "Scegli quale avversario si scontrerà con te."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Wybierz przeciwnika do starcia",
"subtitle": "Wybierz, który przeciwnik stoczy z tobą starcie."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,10 @@
"title": "Escolher oponente para confronto",
"subtitle": "Escolha qual oponente fará confronto com você."
},
"pileOpponent": {
"title": "Choose Opponent",
"subtitle": "Choose which opponent separates the cards into piles."
},
"assist": {
"choose": {
"title": "Choose a Player to Assist",
Expand Down
2 changes: 2 additions & 0 deletions client/src/pages/GamePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { useModalPeek } from "../components/modal/useModalPeek.ts";
import { BattleProtectorModal } from "../components/modal/BattleProtectorModal.tsx";
import { AssistChoosePlayerModal } from "../components/modal/AssistChoosePlayerModal.tsx";
import { ClashOpponentModal } from "../components/modal/ClashOpponentModal.tsx";
import { PileOpponentModal } from "../components/modal/PileOpponentModal.tsx";
import { TributeModal } from "../components/modal/TributeModal.tsx";
import { CombatTaxModal } from "../components/modal/CombatTaxModal.tsx";
import { TopOrBottomChoiceModalContent } from "../components/modal/TopOrBottomChoiceModal.tsx";
Expand Down Expand Up @@ -1694,6 +1695,7 @@ function GamePageContent({
<BattleProtectorModal />
<AssistChoosePlayerModal />
<ClashOpponentModal />
<PileOpponentModal />
<TributeModal />
<CombatTaxModal />
<AlternativeCostModal />
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,20 @@ pub fn candidate_actions_broad_with_probe(
// with the player who is authorized to submit it; otherwise the
// action gets routed to the wrong AI seat in multiplayer. The
// `actor` field is always set to the authorized submitter.
// CR 608.2d + CR 700.3: AI opponent choice for pile separation — offer each
// candidate opponent as a legal action.
WaitingFor::SeparatePilesChooseOpponent {
player, candidates, ..
} => candidates
.iter()
.map(|&opp| {
candidate(
GameAction::ChoosePileOpponent { opponent: opp },
TacticalClass::Selection,
Some(*player),
)
})
.collect(),
// CR 700.3 + CR 700.3a: AI partition candidates. Full powerset is
// exponential, so we cap at three heuristics: all-in-A (chooser
// sees an empty pile B), all-in-B (chooser sees a full pile A),
Expand Down
11 changes: 9 additions & 2 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,8 +1276,15 @@ fn collect_effects_in_effect<'a>(effect: &'a Effect, out: &mut Vec<&'a Effect>)
}
}
Effect::SeparateIntoPiles {
chosen_pile_effect, ..
} => collect_effects(chosen_pile_effect, out),
chosen_pile_effect,
unchosen_pile_effect,
..
} => {
collect_effects(chosen_pile_effect, out);
if let Some(unchosen) = unchosen_pile_effect {
collect_effects(unchosen, out);
}
}
Effect::RevealFromHand {
on_decline: Some(d),
..
Expand Down
9 changes: 8 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3186,8 +3186,15 @@ fn legacy_effect(x: &Effect) -> bool {
Effect::SeparateIntoPiles {
object_filter,
chosen_pile_effect,
unchosen_pile_effect,
..
} => legacy_target_filter(object_filter) || legacy_definition(chosen_pile_effect),
} => {
legacy_target_filter(object_filter)
|| legacy_definition(chosen_pile_effect)
|| unchosen_pile_effect
.as_ref()
.is_some_and(|d| legacy_definition(d))
}
Effect::EpicCopy { spell } => contains_legacy_event_ref(spell),
Effect::CreateDelayedTrigger { effect, .. } => legacy_definition(effect),
Effect::CreateDrawReplacement { replacement_effect } => legacy_effect(replacement_effect),
Expand Down
Loading
Loading