Skip to content
Open
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
2 changes: 1 addition & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ export type WaitingFor =
}
| { type: "PayAmountChoice"; data: { player: PlayerId; resource: PayableResource; min: number; max: number; accumulated?: number; source_id: ObjectId; pending_mana_ability?: unknown } }
| { type: "TargetSelection"; data: { player: PlayerId; pending_cast: PendingCast; target_slots: TargetSelectionSlot[]; mode_labels?: (string | null)[]; selection: TargetSelectionProgress } }
| { type: "DeclareAttackers"; data: { player: PlayerId; valid_attacker_ids: ObjectId[]; valid_attack_targets?: AttackTarget[] } }
| { type: "DeclareAttackers"; data: { player: PlayerId; valid_attacker_ids: ObjectId[]; valid_attack_targets?: AttackTarget[]; valid_attack_targets_by_attacker?: Record<string, AttackTarget[]> } }
| { type: "DeclareBlockers"; data: { player: PlayerId; valid_blocker_ids: ObjectId[]; valid_block_targets: Record<string, ObjectId[]>; block_requirements?: Record<string, number> } }
| { type: "GameOver"; data: { winner: PlayerId | null } }
| { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } }
Expand Down
14 changes: 11 additions & 3 deletions client/src/components/board/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { useGameStore } from "../../stores/gameStore.ts";
import { DRAFT_BOT_AI_SEAT, useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore.ts";
import { useMultiplayerStore } from "../../stores/multiplayerStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { buildAttacks, hasMultipleAttackTargets, getValidAttackTargets } from "../../utils/combat.ts";
import {
buildAttacks,
getValidAttackTargets,
selectedAttackersNeedTargetPicker,
} from "../../utils/combat.ts";
import { useBlockRequirements } from "../combat/useBlockRequirements.ts";
import { gameButtonClass } from "../ui/buttonStyles.ts";
import { GameplayTooltip } from "../ui/GameplayTooltip.tsx";
Expand Down Expand Up @@ -98,8 +102,11 @@ export function ActionButton() {

// Attack target picker visibility (multiplayer)
const [showTargetPicker, setShowTargetPicker] = useState(false);
const isMultiTarget = hasMultipleAttackTargets(gameState);
const validAttackTargets = getValidAttackTargets(gameState);
const validAttackTargetsByAttacker =
waitingFor?.type === "DeclareAttackers"
? (waitingFor.data.valid_attack_targets_by_attacker ?? {})
: {};

// Reset skip-confirm when mode changes
useEffect(() => {
Expand Down Expand Up @@ -216,7 +223,7 @@ export function ActionButton() {
}

function handleConfirmAttackers() {
if (isMultiTarget) {
if (selectedAttackersNeedTargetPicker(gameState, selectedAttackers)) {
setShowTargetPicker(true);
return;
}
Expand Down Expand Up @@ -490,6 +497,7 @@ export function ActionButton() {
{showTargetPicker && (
<AttackTargetPicker
validTargets={validAttackTargets}
validTargetsByAttacker={validAttackTargetsByAttacker}
selectedAttackers={selectedAttackers}
onConfirm={handleTargetPickerConfirm}
onCancel={() => setShowTargetPicker(false)}
Expand Down
440 changes: 296 additions & 144 deletions client/src/components/controls/AttackTargetPicker.tsx

Large diffs are not rendered by default.

150 changes: 134 additions & 16 deletions client/src/utils/__tests__/combat.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,90 @@
import { describe, expect, it } from "vitest";

import type { GameObject, ObjectId } from "../../adapter/types";
import type { AttackTarget, GameObject, GameState, ObjectId } from "../../adapter/types";
import {
buildGameObjectWithCoreTypes,
buildObjectMap,
} from "../../test/factories/gameObjectFactory";
import { buildGameState } from "../../test/factories/gameStateFactory";
import { evenSplit, groupAttackers } from "../combat";
import {
buildAttacks,
evenSplit,
getValidAttackTargetsForAttacker,
groupAttackers,
selectedAttackersNeedTargetPicker,
} from "../combat";

function playerTarget(playerId: number): AttackTarget {
return { type: "Player", data: playerId };
}

function makeObject(overrides: Partial<GameObject> & { id: ObjectId }): GameObject {
function declareAttackersState(
validAttackTargetsByAttacker: Record<string, AttackTarget[]>,
): GameState {
return {
waiting_for: {
type: "DeclareAttackers",
data: {
player: 0,
valid_attacker_ids: [11, 12],
valid_attack_targets: [playerTarget(1), playerTarget(2)],
valid_attack_targets_by_attacker: validAttackTargetsByAttacker,
},
},
players: [
{
id: 0,
life: 20,
poison_counters: 0,
mana_pool: { mana: [] },
library: [],
hand: [],
graveyard: [],
has_drawn_this_turn: false,
lands_played_this_turn: 0,
turns_taken: 0,
},
{
id: 1,
life: 20,
poison_counters: 0,
mana_pool: { mana: [] },
library: [],
hand: [],
graveyard: [],
has_drawn_this_turn: false,
lands_played_this_turn: 0,
turns_taken: 0,
},
{
id: 2,
life: 20,
poison_counters: 0,
mana_pool: { mana: [] },
library: [],
hand: [],
graveyard: [],
has_drawn_this_turn: false,
lands_played_this_turn: 0,
turns_taken: 0,
},
],
seat_order: [0, 1, 2],
} as unknown as GameState;
}

function makeObject(id: ObjectId, name: string): GameObject {
return buildGameObjectWithCoreTypes(["Creature"], {
id,
card_id: 100,
zone: "Battlefield",
name: "Goblin",
name,
power: 1,
toughness: 1,
color: ["Red"],
base_power: 1,
base_toughness: 1,
base_color: ["Red"],
...overrides,
});
}

Expand All @@ -30,6 +95,51 @@ function makeState(
return buildGameState({ objects: buildObjectMap(...objects), ring_bearer: ringBearer });
}

describe("combat utils", () => {
it("builds per-attacker defaults from engine legality instead of seat order", () => {
const state = declareAttackersState({
"11": [playerTarget(2)],
"12": [playerTarget(1)],
});

expect(buildAttacks([11, 12], state, 0)).toEqual([
[11, playerTarget(2)],
[12, playerTarget(1)],
]);
});

it("does not force the target picker when each attacker has only one legal target", () => {
const attackers: ObjectId[] = [11, 12];
const state = declareAttackersState({
"11": [playerTarget(2)],
"12": [playerTarget(1)],
});

expect(selectedAttackersNeedTargetPicker(state, attackers)).toBe(false);
});

it("forces the target picker when an attacker has multiple legal targets", () => {
const state = declareAttackersState({
"11": [playerTarget(1), playerTarget(2)],
"12": [playerTarget(1)],
});

expect(getValidAttackTargetsForAttacker(state, 11)).toEqual([
playerTarget(1),
playerTarget(2),
]);
expect(selectedAttackersNeedTargetPicker(state, [11, 12])).toBe(true);
});

it("treats an attacker missing from the per-attacker map as having no legal targets", () => {
const state = declareAttackersState({
"12": [playerTarget(1)],
});

expect(getValidAttackTargetsForAttacker(state, 11)).toEqual([]);
});
});

describe("evenSplit", () => {
it("distributes evenly with no remainder", () => {
expect(evenSplit(30, 3)).toEqual([10, 10, 10]);
Expand All @@ -51,7 +161,12 @@ describe("evenSplit", () => {
});

it("always sums back to the (clamped) count and has the right length", () => {
for (const [count, buckets] of [[31, 3], [7, 7], [1, 4], [100, 6]] as const) {
for (const [count, buckets] of [
[31, 3],
[7, 7],
[1, 4],
[100, 6],
] as const) {
const split = evenSplit(count, buckets);
expect(split).toHaveLength(buckets);
expect(split.reduce((a, b) => a + b, 0)).toBe(count);
Expand All @@ -62,35 +177,38 @@ describe("evenSplit", () => {
describe("groupAttackers", () => {
it("groups identical creatures into one stack and distinct ones separately", () => {
const state = makeState([
makeObject({ id: 200, name: "Elf", power: 2, toughness: 2 }),
makeObject({ id: 103 }),
makeObject({ id: 101 }),
makeObject({ id: 102 }),
makeObject(200, "Elf"),
makeObject(103, "Goblin"),
makeObject(101, "Goblin"),
makeObject(102, "Goblin"),
]);

const stacks = groupAttackers([200, 103, 101, 102], state);

expect(stacks).toHaveLength(2);
// Stacks are sorted by their lowest member id.
expect(stacks[0]).toMatchObject({ name: "Goblin", count: 3, ids: [101, 102, 103] });
expect(stacks[0]).toMatchObject({
name: "Goblin",
count: 3,
ids: [101, 102, 103],
});
expect(stacks[1]).toMatchObject({ name: "Elf", count: 1, ids: [200] });
expect(stacks[0].key).toBe("101");
expect(stacks[0].representative?.id).toBe(101);
});

it("sorts member ids ascending regardless of input order", () => {
const state = makeState([
makeObject({ id: 5 }),
makeObject({ id: 1 }),
makeObject({ id: 9 }),
makeObject(5, "Goblin"),
makeObject(1, "Goblin"),
makeObject(9, "Goblin"),
]);
const [stack] = groupAttackers([9, 1, 5], state);
expect(stack.ids).toEqual([1, 5, 9]);
});

it("keeps the Ring-bearer as its own stack (CR 701.54)", () => {
const state = makeState(
[makeObject({ id: 101 }), makeObject({ id: 102 }), makeObject({ id: 103 })],
[makeObject(101, "Goblin"), makeObject(102, "Goblin"), makeObject(103, "Goblin")],
{ "0": 102 },
);

Expand Down
56 changes: 47 additions & 9 deletions client/src/utils/combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export function buildAttacks(
targetOverrides?: Map<ObjectId, AttackTarget>,
): [ObjectId, AttackTarget][] {
const defaultTarget = getDefaultAttackTarget(state, myId);
return attackerIds.map((id) => [id, targetOverrides?.get(id) ?? defaultTarget]);
return attackerIds.map((id) => [
id,
targetOverrides?.get(id) ?? getDefaultAttackTargetForAttacker(state, id) ?? defaultTarget,
]);
}

/** Returns the default attack target: first non-eliminated opponent. */
Expand All @@ -23,34 +26,63 @@ export function getDefaultAttackTarget(state: GameState | null, myId: PlayerId):
const seatOrder = state.seat_order ?? state.players.map((p) => p.id);
const eliminated = state.eliminated_players ?? [];

const opponent = seatOrder.find(
(id) => id !== myId && !eliminated.includes(id),
);
const opponent = seatOrder.find((id) => id !== myId && !eliminated.includes(id));

return { type: "Player", data: opponent ?? (myId === 0 ? 1 : 0) };
}

/** Check if there are multiple valid attack targets (multiplayer or planeswalkers). */
export function hasMultipleAttackTargets(
state: GameState | null,
): boolean {
export function hasMultipleAttackTargets(state: GameState | null): boolean {
if (!state) return false;
const wf = state.waiting_for;
if (wf.type !== "DeclareAttackers") return false;
const targets = wf.data.valid_attack_targets;
return targets != null && targets.length > 1;
}

/** Check if the selected attackers need explicit target selection. */
export function selectedAttackersNeedTargetPicker(
state: GameState | null,
attackerIds: ObjectId[],
): boolean {
if (!state) return false;
const wf = state.waiting_for;
if (wf.type !== "DeclareAttackers") return false;
if (wf.data.valid_attack_targets_by_attacker != null) {
return attackerIds.some((id) => getValidAttackTargetsForAttacker(state, id).length > 1);
}
return hasMultipleAttackTargets(state);
}

/** Get valid attack targets from the current WaitingFor state. */
export function getValidAttackTargets(
export function getValidAttackTargets(state: GameState | null): AttackTarget[] {
if (!state) return [];
const wf = state.waiting_for;
if (wf.type !== "DeclareAttackers") return [];
return wf.data.valid_attack_targets ?? [];
}

/** Get legal attack targets for a specific attacker from the current WaitingFor state. */
export function getValidAttackTargetsForAttacker(
state: GameState | null,
attackerId: ObjectId,
): AttackTarget[] {
if (!state) return [];
const wf = state.waiting_for;
if (wf.type !== "DeclareAttackers") return [];
if (wf.data.valid_attack_targets_by_attacker) {
return wf.data.valid_attack_targets_by_attacker[String(attackerId)] ?? [];
}
return wf.data.valid_attack_targets ?? [];
}

function getDefaultAttackTargetForAttacker(
state: GameState | null,
attackerId: ObjectId,
): AttackTarget | null {
return getValidAttackTargetsForAttacker(state, attackerId)[0] ?? null;
}

/**
* A stack of identical attackers (e.g. 30 token "ants" → one stack of count 30),
* used by the attack-distribution UI to assign many attackers at once.
Expand Down Expand Up @@ -90,7 +122,13 @@ export function groupAttackers(
// as its own singleton stack so the UI still renders something usable.
return [...attackerIds]
.sort((a, b) => a - b)
.map((id) => ({ key: String(id), name: `#${id}`, ids: [id], count: 1, representative: null }));
.map((id) => ({
key: String(id),
name: `#${id}`,
ids: [id],
count: 1,
representative: null,
}));
}

const objects = attackerIds
Expand Down
Loading
Loading