diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 32ff94300e..bbfb4e40e5 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -1261,105 +1261,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1535,79 +1519,66 @@ packages: resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} @@ -1702,42 +1673,36 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.33': resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.33': resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.33': resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] - libc: [glibc] '@swc/core-linux-x64-gnu@1.15.33': resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.33': resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.33': resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} @@ -1813,28 +1778,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1895,35 +1856,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.1': resolution: {integrity: sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.1': resolution: {integrity: sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.1': resolution: {integrity: sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.1': resolution: {integrity: sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.1': resolution: {integrity: sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==} @@ -3282,28 +3238,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -6104,24 +6056,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index cfd2c165c2..a34870a76d 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1174,7 +1174,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 } } | { type: "DeclareBlockers"; data: { player: PlayerId; valid_blocker_ids: ObjectId[]; valid_block_targets: Record; block_requirements?: Record } } | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidate_descriptions?: string[] } } diff --git a/client/src/components/board/ActionButton.tsx b/client/src/components/board/ActionButton.tsx index a28bdc8d0e..4cb7d73039 100644 --- a/client/src/components/board/ActionButton.tsx +++ b/client/src/components/board/ActionButton.tsx @@ -9,7 +9,11 @@ import { usePhaseInfo } from "../../hooks/usePhaseInfo.ts"; import { useGameStore } from "../../stores/gameStore.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"; @@ -97,8 +101,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(() => { @@ -215,7 +222,7 @@ export function ActionButton() { } function handleConfirmAttackers() { - if (isMultiTarget) { + if (selectedAttackersNeedTargetPicker(gameState, selectedAttackers)) { setShowTargetPicker(true); return; } @@ -462,6 +469,7 @@ export function ActionButton() { {showTargetPicker && ( setShowTargetPicker(false)} diff --git a/client/src/components/controls/AttackTargetPicker.tsx b/client/src/components/controls/AttackTargetPicker.tsx index b87403c684..f27ce02557 100644 --- a/client/src/components/controls/AttackTargetPicker.tsx +++ b/client/src/components/controls/AttackTargetPicker.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useCallback, useMemo, useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; import { Trans, useTranslation } from "react-i18next"; import type { AttackTarget, GameObject, ObjectId, PlayerId } from "../../adapter/types.ts"; @@ -10,53 +9,125 @@ import { useGameStore } from "../../stores/gameStore.ts"; import { getPlayerDisplayName } from "../../stores/multiplayerStore.ts"; import { usePlayerId } from "../../hooks/usePlayerId.ts"; import { formatCounterType } from "../../viewmodel/cardProps.ts"; +import { type AttackerStack, evenSplit, groupAttackers } from "../../utils/combat.ts"; import { gameButtonClass } from "../ui/buttonStyles.ts"; import { PeekTab } from "../modal/DialogShell.tsx"; +/** Internal assignment map: every attacker maps to its chosen target, or `null` + * while it sits in the Unassigned bucket. */ +type AssignmentMap = Map; + +interface TargetConstrainedStack extends AttackerStack { + legalTargets: AttackTarget[]; +} + interface AttackTargetPickerProps { validTargets: AttackTarget[]; + validTargetsByAttacker?: Record; selectedAttackers: ObjectId[]; onConfirm: (attacks: [ObjectId, AttackTarget][]) => void; onCancel: () => void; } /** - * Per-creature attack target selection for multiplayer games. + * Attack-target selection for multiplayer / multi-defender games. * * Two modes: - * - "all" (default): pick one target, all attackers go there - * - "split": assign each attacker to a different target + * - "all" (default): pick one target, all attackers go there. + * - "distribute": a bucket-per-target board where identical attackers are + * grouped into stacks and spread across legal targets plus an Unassigned + * bucket. Stacks are split further when otherwise-identical attackers have + * different legal target sets, so the frontend never invites an illegal + * per-attacker assignment. + * + * Frontend display layer only: it merely arranges the attacker→target choices + * the player makes and hands the flat array to the engine, which validates it. */ export function AttackTargetPicker({ validTargets, + validTargetsByAttacker, selectedAttackers, onConfirm, onCancel, }: AttackTargetPickerProps) { const { t } = useTranslation("game"); - const [mode, setMode] = useState<"all" | "split">("all"); + const [mode, setMode] = useState<"all" | "distribute">("all"); const [peeked, setPeeked] = useState(false); - const [perCreatureTargets, setPerCreatureTargets] = useState>( - () => new Map(), + const [assignments, setAssignments] = useState( + () => new Map(selectedAttackers.map((id) => [id, null] as const)), ); + const [expandedStack, setExpandedStack] = useState(null); const shouldReduceMotion = useReducedMotion(); const gameState = useGameStore((s) => s.gameState); const myId = usePlayerId(); const hoverProps = useInspectHoverProps(); const seatOrder = gameState?.seat_order; - const teamBased = gameState?.format_config?.team_based ?? false; + const targetsForCreature = useCallback( + (creatureId: ObjectId): AttackTarget[] => + validTargetsByAttacker?.[String(creatureId)] ?? validTargets, + [validTargetsByAttacker, validTargets], + ); + const sortedTargets = useMemo(() => { if (!seatOrder) return validTargets; return [...validTargets].sort((a, b) => { const aIdx = a.type === "Player" ? seatOrder.indexOf(a.data) : Infinity; const bIdx = b.type === "Player" ? seatOrder.indexOf(b.data) : Infinity; - return aIdx - bIdx; + if (aIdx !== bIdx) return aIdx - bIdx; + return Number(a.data) - Number(b.data); }); }, [validTargets, seatOrder]); + const commonTargets = useMemo( + () => sortedTargets.filter((target) => + selectedAttackers.every((id) => + targetsForCreature(id).some((candidate) => sameAttackTarget(candidate, target))), + ), + [selectedAttackers, sortedTargets, targetsForCreature], + ); + + const stacks = useMemo(() => { + const grouped = groupAttackers(selectedAttackers, gameState); + const expanded: TargetConstrainedStack[] = []; + + for (const stack of grouped) { + const bySignature = new Map(); + + for (const id of stack.ids) { + const legalTargets = targetsForCreature(id); + const signature = targetSignature(legalTargets); + const existing = bySignature.get(signature); + if (existing) { + existing.ids.push(id); + } else { + bySignature.set(signature, { ids: [id], legalTargets }); + } + } + + for (const [signature, entry] of bySignature) { + const ids = [...entry.ids].sort((a, b) => a - b); + expanded.push({ + ...stack, + key: `${stack.key}:${signature || "none"}`, + ids, + count: ids.length, + representative: gameState?.objects[ids[0]] ?? stack.representative, + legalTargets: entry.legalTargets, + }); + } + } + + return expanded.sort((a, b) => a.ids[0] - b.ids[0]); + }, [gameState, selectedAttackers, targetsForCreature]); + + const unassignedTotal = useMemo( + () => selectedAttackers.reduce((n, id) => n + (assignments.get(id) == null ? 1 : 0), 0), + [assignments, selectedAttackers], + ); + function getTargetLabel(target: AttackTarget): string { if (target.type === "Player") { return getPlayerLabel(t, target.data, myId, teamBased); @@ -77,40 +148,86 @@ export function AttackTargetPicker({ onConfirm(selectedAttackers.map((id) => [id, target])); } - function handleSplitConfirm() { - const attacks: [ObjectId, AttackTarget][] = selectedAttackers.map((id) => { - const target = perCreatureTargets.get(id) ?? validTargets[0]; - return [id, target]; + function mutate(fn: (next: AssignmentMap) => void) { + setAssignments((prev) => { + const next = new Map(prev); + fn(next); + return next; }); - onConfirm(attacks); } - function setCreatureTarget(creatureId: ObjectId, target: AttackTarget) { - setPerCreatureTargets((prev) => { - const next = new Map(prev); - next.set(creatureId, target); - return next; + function incOnTarget(stack: TargetConstrainedStack, target: AttackTarget) { + mutate((next) => { + const id = lowestUnassigned(stack, next); + if (id != null) next.set(id, target); }); } - function assignAllCreatures(target: AttackTarget) { - setPerCreatureTargets(() => { - const next = new Map(); - for (const id of selectedAttackers) { - next.set(id, target); - } - return next; + function decFromTarget(stack: TargetConstrainedStack, target: AttackTarget) { + mutate((next) => { + const id = highestOnTarget(stack, target, next); + if (id != null) next.set(id, null); }); } + function allOfStackToTarget(stack: TargetConstrainedStack, target: AttackTarget) { + mutate((next) => { + for (const id of stack.ids) next.set(id, target); + }); + } + + function spreadStack(stack: TargetConstrainedStack) { + mutate((next) => spreadStackEvenly(next, stack, stack.legalTargets)); + } + + function spreadAll() { + mutate((next) => { + for (const stack of stacks) spreadStackEvenly(next, stack, stack.legalTargets); + }); + } + + function allStacksToTarget(target: AttackTarget) { + mutate((next) => { + for (const id of selectedAttackers) next.set(id, target); + }); + } + + function resetAll() { + mutate((next) => { + for (const id of selectedAttackers) next.set(id, null); + }); + } + + function countOnTarget(stack: TargetConstrainedStack, target: AttackTarget): number { + const key = attackTargetKey(target); + return stack.ids.reduce((n, id) => { + const t = assignments.get(id); + return n + (t && attackTargetKey(t) === key ? 1 : 0); + }, 0); + } + + function countUnassigned(stack: TargetConstrainedStack): number { + return stack.ids.reduce((n, id) => n + (assignments.get(id) == null ? 1 : 0), 0); + } + + function handleDistributeConfirm() { + const attacks = selectedAttackers.flatMap((id): [ObjectId, AttackTarget][] => { + const target = assignments.get(id); + return target ? [[id, target]] : []; + }); + onConfirm(attacks); + } + const slideTransform = peeked ? { x: "calc(100vw - 32px)" } : { x: 0 }; + const sidePadding = mode === "all" ? "px-8" : "px-4 sm:px-6"; + return ( <> -
-

- {t("attackTargetPicker.heading")} -

- - {/* Mode toggle */} -
- - -
+
+
+
+

+ {t("attackTargetPicker.heading")} +

- {mode === "all" ? ( - /* Attack All mode: one button per target */ -
- {sortedTargets.map((target) => { - const color = getTargetSeatColor(target); - return ( - - ); - })} -
- ) : ( - /* Split mode: bulk-assign + per-creature dropdowns */ -
- {/* Bulk-assign buttons */} -
- {sortedTargets.map((target) => { - const color = getTargetSeatColor(target); - return ( - - ); - })} +
+ +
+
+ +
+ {mode === "all" ? ( +
+ {commonTargets.map((target) => { + const color = getTargetSeatColor(target); + return ( + + ); + })} +
+ ) : ( +
+
+

0 ? "text-amber-300" : "text-emerald-300"}`}> + {unassignedTotal > 0 + ? t("attackTargetPicker.unassignedRemaining", { count: unassignedTotal }) + : t("attackTargetPicker.allAssigned")} +

+
+ + +
+
+ +
+ + + + + + {sortedTargets.map((target) => { + const color = getTargetSeatColor(target); + const globalTarget = commonTargets.some((candidate) => + sameAttackTarget(candidate, target)); + return ( + + ); + })} + + + + {stacks.map((stack) => { + const unassigned = countUnassigned(stack); + return ( + + + + {sortedTargets.map((target) => { + const count = countOnTarget(stack, target); + const label = getTargetLabel(target); + const legalHere = stackSupportsTarget(stack, target); + return ( + + ); + })} + + ); + })} + +
+ {t("attackTargetPicker.attackersColumn")} + + {t("attackTargetPicker.unassigned")} + +
+ + + {getTargetLabel(target)} + + {globalTarget && ( + + )} +
+
+
+ + +
+
+ 0 ? "bg-amber-900/60 text-amber-100" : "text-gray-600" + }`} + > + {unassigned} + + + {legalHere ? ( + 0} + canInc={unassigned > 0} + onDec={() => decFromTarget(stack, target)} + onInc={() => incOnTarget(stack, target)} + onAll={() => allOfStackToTarget(stack, target)} + decTitle={t("attackTargetPicker.removeOne", { label })} + incTitle={t("attackTargetPicker.assignOne", { label })} + allTitle={t("attackTargetPicker.assignAllHere", { label })} + /> + ) : ( +
+ )} +
+
- {/* Per-creature assignment list */} -
- {selectedAttackers.map((creatureId) => { - const obj = gameState?.objects[creatureId]; - const currentTarget = perCreatureTargets.get(creatureId) ?? validTargets[0]; - const counters = objectCounterChips(obj); - const ptLabel = objectPtLabel(obj); - return ( -
-
-
- - {obj?.name ?? t("attackTargetPicker.creatureFallback", { id: creatureId })} - - - #{creatureId} - - {ptLabel && ( - - {ptLabel} +
+ {stacks.map((stack) => { + const unassigned = countUnassigned(stack); + const expanded = expandedStack === stack.key; + return ( +
+ + {expanded && ( +
+ +
+ {t("attackTargetPicker.unassigned")} + 0 ? "bg-amber-900/60 text-amber-100" : "text-gray-600" + }`} + > + {unassigned} + +
+ {sortedTargets.map((target) => { + const color = getTargetSeatColor(target); + const count = countOnTarget(stack, target); + const label = getTargetLabel(target); + const legalHere = stackSupportsTarget(stack, target); + return ( +
+ + + {label} + + {legalHere ? ( + 0} + canInc={unassigned > 0} + onDec={() => decFromTarget(stack, target)} + onInc={() => incOnTarget(stack, target)} + onAll={() => allOfStackToTarget(stack, target)} + decTitle={t("attackTargetPicker.removeOne", { label })} + incTitle={t("attackTargetPicker.assignOne", { label })} + allTitle={t("attackTargetPicker.assignAllHere", { label })} + /> + ) : ( +
+ )} +
+ ); + })} +
)}
- {counters.length > 0 && ( -
- {counters.map(({ type, count }) => ( - - {formatCounterType(type)} x{count} - - ))} -
- )} -
- setCreatureTarget(creatureId, target)} - /> -
- ); - })} -
+ ); + })} +
+
+ )} +
+
+ {mode === "distribute" && ( + + )}
- )} - - +
setPeeked(true)} />
@@ -280,11 +548,25 @@ function objectCounterChips(obj: GameObject | undefined): Array<{ type: string; .map(([type, count]) => ({ type, count })); } -/** Stable key for an AttackTarget. */ function attackTargetKey(target: AttackTarget): string { return `${target.type}-${target.data}`; } +function sameAttackTarget(a: AttackTarget, b: AttackTarget): boolean { + return a.type === b.type && a.data === b.data; +} + +function targetSignature(targets: AttackTarget[]): string { + return [...targets] + .map(attackTargetKey) + .sort() + .join("|"); +} + +function stackSupportsTarget(stack: TargetConstrainedStack, target: AttackTarget): boolean { + return stack.legalTargets.some((candidate) => sameAttackTarget(candidate, target)); +} + function RestoreTab({ onClick }: { onClick: () => void }) { const { t } = useTranslation("game"); return ( @@ -326,94 +608,131 @@ function RestoreTab({ onClick }: { onClick: () => void }) { ); } -interface TargetDropdownProps { - targets: AttackTarget[]; - currentTarget: AttackTarget; - getLabel: (target: AttackTarget) => string; - getColor: (target: AttackTarget) => string | undefined; - onChange: (target: AttackTarget) => void; +function lowestUnassigned(stack: AttackerStack, map: AssignmentMap): ObjectId | null { + for (const id of stack.ids) { + if (map.get(id) == null) return id; + } + return null; } -function TargetDropdown({ targets, currentTarget, getLabel, getColor, onChange }: TargetDropdownProps) { - const [open, setOpen] = useState(false); - const buttonRef = useRef(null); - const menuRef = useRef(null); - const currentColor = getColor(currentTarget); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if ( - !buttonRef.current?.contains(e.target as Node) && - !menuRef.current?.contains(e.target as Node) - ) { - setOpen(false); - } +function highestOnTarget(stack: AttackerStack, target: AttackTarget, map: AssignmentMap): ObjectId | null { + const key = attackTargetKey(target); + for (let i = stack.ids.length - 1; i >= 0; i--) { + const t = map.get(stack.ids[i]); + if (t && attackTargetKey(t) === key) return stack.ids[i]; + } + return null; +} + +function spreadStackEvenly(map: AssignmentMap, stack: AttackerStack, targets: AttackTarget[]): void { + if (targets.length === 0) return; + const counts = evenSplit(stack.count, targets.length); + let member = 0; + targets.forEach((target, ti) => { + for (let k = 0; k < counts[ti]; k++) { + map.set(stack.ids[member], target); + member += 1; } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - const [pos, setPos] = useState({ top: 0, right: 0 }); - useEffect(() => { - if (!open || !buttonRef.current) return; - const rect = buttonRef.current.getBoundingClientRect(); - setPos({ - top: rect.bottom + 4, - right: window.innerWidth - rect.right, - }); - }, [open]); + }); +} +interface StepperCellProps { + count: number; + color?: string; + canInc: boolean; + canDec: boolean; + onDec: () => void; + onInc: () => void; + onAll: () => void; + decTitle: string; + incTitle: string; + allTitle: string; +} + +function StepperCell({ + count, + color, + canInc, + canDec, + onDec, + onInc, + onAll, + decTitle, + incTitle, + allTitle, +}: StepperCellProps) { return ( - <> +
- {open && createPortal( - - - {targets.map((target) => { - const color = getColor(target); - const isSelected = attackTargetKey(target) === attackTargetKey(currentTarget); - return ( - - ); - })} - - , - document.body, + + +
+ ); +} + +interface StackLabelProps { + stack: AttackerStack; + t: ReturnType["t"]; + hoverProps: ReturnType; +} + +function StackLabel({ stack, t, hoverProps }: StackLabelProps) { + const ptLabel = objectPtLabel(stack.representative ?? undefined); + const counters = objectCounterChips(stack.representative ?? undefined); + return ( +
+
+ + {stack.name || t("attackTargetPicker.creatureFallback", { id: stack.ids[0] })} + + {stack.count > 1 && ( + + x{stack.count} + + )} + {ptLabel && ( + + {ptLabel} + + )} +
+ {counters.length > 0 && ( +
+ {counters.map(({ type, count }) => ( + + {formatCounterType(type)} x{count} + + ))} +
)} - +
); } diff --git a/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx b/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx new file mode 100644 index 0000000000..f4ff2f433b --- /dev/null +++ b/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx @@ -0,0 +1,217 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; + +import type { AttackTarget, GameObject, GameState, ObjectId } from "../../../adapter/types.ts"; +import { AttackTargetPicker } from "../AttackTargetPicker.tsx"; +import { useGameStore } from "../../../stores/gameStore.ts"; +import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts"; + +vi.mock("framer-motion", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + motion: { + div: ({ children, ...props }: import("react").HTMLAttributes) => ( +
{children}
+ ), + button: ({ children, ...props }: import("react").ButtonHTMLAttributes) => ( + + ), + }, + useReducedMotion: () => true, + }; +}); + +const P1: AttackTarget = { type: "Player", data: 1 }; +const P2: AttackTarget = { type: "Player", data: 2 }; +const TARGETS: AttackTarget[] = [P1, P2]; +const ATTACKERS: ObjectId[] = [101, 102, 103]; + +function makeObject(id: ObjectId, name: string): GameObject { + return { + id, + card_id: 1, + owner: 0, + controller: 0, + zone: "Battlefield", + tapped: false, + face_down: false, + flipped: false, + transformed: false, + damage_marked: 0, + dealt_deathtouch_damage: false, + attached_to: null, + attachments: [], + counters: {}, + name, + power: 1, + toughness: 1, + loyalty: null, + card_types: { supertypes: [], core_types: ["Creature"], subtypes: [] }, + mana_cost: { type: "NoCost" }, + keywords: [], + abilities: [], + trigger_definitions: [], + replacement_definitions: [], + static_definitions: [], + color: ["Red"], + base_power: 1, + base_toughness: 1, + base_keywords: [], + base_color: ["Red"], + timestamp: 1, + entered_battlefield_turn: 1, + }; +} + +function makeState(): GameState { + return { + seat_order: [0, 1, 2], + format_config: { team_based: false }, + objects: { + 101: makeObject(101, "Goblin"), + 102: makeObject(102, "Goblin"), + 103: makeObject(103, "Goblin"), + }, + } as unknown as GameState; +} + +function renderPicker(overrides?: Partial>) { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onConfirm, onCancel }; +} + +function enterDistribute() { + fireEvent.click(screen.getByRole("button", { name: /distribute/i })); +} + +function getAssignOneButtons() { + return screen.getAllByRole("button", { name: /assignOne/i }); +} + +function getAssignAllButtons() { + return screen.getAllByRole("button", { name: /assignAllHere/i }); +} + +function getRemoveOneButtons() { + return screen.getAllByRole("button", { name: /removeOne/i }); +} + +describe("AttackTargetPicker", () => { + beforeEach(() => { + useMultiplayerStore.setState({ activePlayerId: 0, playerNames: new Map() }); + useGameStore.setState({ gameState: makeState(), gameMode: undefined }); + }); + + afterEach(() => { + cleanup(); + useGameStore.setState({ gameState: null, gameMode: undefined }); + useMultiplayerStore.setState({ activePlayerId: null, playerNames: new Map() }); + }); + + it("keeps Attack All mode working (one click sends every attacker to a target)", () => { + const { onConfirm } = renderPicker(); + fireEvent.click(screen.getByRole("button", { name: /Attack Opp 2 with 3 creatures/ })); + expect(onConfirm).toHaveBeenCalledWith([ + [101, P1], + [102, P1], + [103, P1], + ]); + }); + + it("offers only common targets in Attack All mode when legality differs by attacker", () => { + const { onConfirm } = renderPicker({ + selectedAttackers: [101, 102], + validTargetsByAttacker: { + "101": [P2], + "102": [P1, P2], + }, + }); + + expect(screen.queryByRole("button", { name: /Opp 2/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Attack Opp 3 with 2 creatures/ })); + expect(onConfirm).toHaveBeenCalledWith([ + [101, P2], + [102, P2], + ]); + }); + + it("disables Confirm until Unassigned is empty, then even-splits across targets", () => { + const { onConfirm } = renderPicker(); + enterDistribute(); + + const gated = screen.getByRole("button", { name: /assignRemaining/i }); + expect(gated).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: /evenSplitAll/i })); + + const confirm = screen.getByRole("button", { name: /confirmDistribute/i }); + expect(confirm).not.toBeDisabled(); + fireEvent.click(confirm); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith([ + [101, P1], + [102, P1], + [103, P2], + ]); + }); + + it("steppers claim the lowest-id unassigned member deterministically", () => { + const { onConfirm } = renderPicker(); + enterDistribute(); + + fireEvent.click(getAssignOneButtons()[0]); + fireEvent.click(getAssignOneButtons()[0]); + fireEvent.click(getAssignOneButtons()[1]); + + fireEvent.click(screen.getByRole("button", { name: /confirmDistribute/i })); + expect(onConfirm).toHaveBeenCalledWith([ + [101, P1], + [102, P1], + [103, P2], + ]); + }); + + it("'-1' releases the highest-id member back to Unassigned", () => { + const { onConfirm } = renderPicker(); + enterDistribute(); + + fireEvent.click(getAssignAllButtons()[0]); + fireEvent.click(getRemoveOneButtons()[0]); + fireEvent.click(getAssignOneButtons()[1]); + + fireEvent.click(screen.getByRole("button", { name: /confirmDistribute/i })); + expect(onConfirm).toHaveBeenCalledWith([ + [101, P1], + [102, P1], + [103, P2], + ]); + }); + + it("'send all to target' assigns the whole stack at once", () => { + const { onConfirm } = renderPicker(); + enterDistribute(); + + fireEvent.click(getAssignAllButtons()[0]); + fireEvent.click(screen.getByRole("button", { name: /confirmDistribute/i })); + + expect(onConfirm).toHaveBeenCalledWith([ + [101, P1], + [102, P1], + [103, P1], + ]); + }); +}); diff --git a/client/src/utils/__tests__/combat.test.ts b/client/src/utils/__tests__/combat.test.ts new file mode 100644 index 0000000000..ab4188de38 --- /dev/null +++ b/client/src/utils/__tests__/combat.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import type { AttackTarget, GameObject, GameState, ObjectId } from "../../adapter/types"; +import { + buildAttacks, + evenSplit, + getValidAttackTargetsForAttacker, + groupAttackers, + selectedAttackersNeedTargetPicker, +} from "../combat"; + +function playerTarget(playerId: number): AttackTarget { + return { type: "Player", data: playerId }; +} + +function declareAttackersState( + validAttackTargetsByAttacker: Record, +): 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(overrides: Partial & { id: ObjectId }): GameObject { + return { + card_id: 100, + owner: 0, + controller: 0, + zone: "Battlefield", + tapped: false, + face_down: false, + flipped: false, + transformed: false, + damage_marked: 0, + dealt_deathtouch_damage: false, + attached_to: null, + attachments: [], + counters: {}, + name: "Goblin", + power: 1, + toughness: 1, + loyalty: null, + card_types: { supertypes: [], core_types: ["Creature"], subtypes: [] }, + mana_cost: { type: "NoCost" }, + keywords: [], + abilities: [], + trigger_definitions: [], + replacement_definitions: [], + static_definitions: [], + color: ["Red"], + base_power: 1, + base_toughness: 1, + base_keywords: [], + base_color: ["Red"], + timestamp: 1, + entered_battlefield_turn: 1, + ...overrides, + }; +} + +function makeState( + objects: GameObject[], + ringBearer?: Record, +): GameState { + const map: Record = {}; + for (const obj of objects) map[obj.id] = obj; + return { objects: map, ring_bearer: ringBearer } as unknown as GameState; +} + +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]); + }); + + it("front-loads the remainder onto the earliest buckets", () => { + expect(evenSplit(31, 3)).toEqual([11, 10, 10]); + expect(evenSplit(2, 5)).toEqual([1, 1, 0, 0, 0]); + }); + + it("returns all zeros for a non-positive count", () => { + expect(evenSplit(0, 3)).toEqual([0, 0, 0]); + expect(evenSplit(-4, 2)).toEqual([0, 0]); + }); + + it("returns an empty array when there are no buckets", () => { + expect(evenSplit(5, 0)).toEqual([]); + expect(evenSplit(5, -1)).toEqual([]); + }); + + 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) { + const split = evenSplit(count, buckets); + expect(split).toHaveLength(buckets); + expect(split.reduce((a, b) => a + b, 0)).toBe(count); + } + }); +}); + +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 }), + ]); + + const stacks = groupAttackers([200, 103, 101, 102], state); + + expect(stacks).toHaveLength(2); + 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 }), + ]); + 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 })], + { "0": 102 }, + ); + + const stacks = groupAttackers([101, 102, 103], state); + + expect(stacks).toHaveLength(2); + expect(stacks[0]).toMatchObject({ count: 2, ids: [101, 103] }); + expect(stacks[1]).toMatchObject({ count: 1, ids: [102] }); + }); + + it("falls back to singleton stacks (sorted) when state is missing", () => { + const stacks = groupAttackers([3, 1, 2], null); + expect(stacks.map((s) => s.ids)).toEqual([[1], [2], [3]]); + expect(stacks.every((s) => s.count === 1 && s.representative === null)).toBe(true); + }); +}); diff --git a/client/src/utils/combat.ts b/client/src/utils/combat.ts index 0953a2b21c..50e2656961 100644 --- a/client/src/utils/combat.ts +++ b/client/src/utils/combat.ts @@ -1,4 +1,5 @@ -import type { AttackTarget, GameState, ObjectId, PlayerId } from "../adapter/types"; +import type { AttackTarget, GameObject, GameState, ObjectId, PlayerId } from "../adapter/types"; +import { groupByName } from "../viewmodel/battlefieldProps"; /** * Build attacks array from selected attacker IDs, defaulting to the first @@ -12,7 +13,10 @@ export function buildAttacks( targetOverrides?: Map, ): [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. */ @@ -40,6 +44,20 @@ export function hasMultipleAttackTargets( 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( state: GameState | null, @@ -49,3 +67,106 @@ export function getValidAttackTargets( 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. + * + * `ids` is sorted ascending so per-target stepper moves are deterministic: + * "+1 to target T" claims the lowest-id unassigned member, "-1" releases the + * highest-id member currently on T (see {@link AttackTargetPicker}). + */ +export interface AttackerStack { + /** Stable key for the stack (the representative/lowest member id, stringified). */ + key: string; + /** Display name shared by every member of the stack. */ + name: string; + /** Member object ids, sorted ascending for deterministic assignment. */ + ids: ObjectId[]; + /** Convenience for `ids.length`. */ + count: number; + /** Representative object for rendering P/T and counter chips (null only if state is missing). */ + representative: GameObject | null; +} + +/** + * Group selected attackers into stacks of identical creatures, reusing the same + * `groupByName`/`groupKey` building block the battlefield uses to collapse + * identical permanents — so the picker's grouping always matches the board's. + * + * Ring-bearers (CR 701.54) are grouped solo by that building block, which is + * the correct behavior here too. Stacks and their members are returned in + * ascending-id order for a deterministic, stable layout. + */ +export function groupAttackers( + attackerIds: ObjectId[], + state: GameState | null, +): AttackerStack[] { + if (!state) { + return [...attackerIds] + .sort((a, b) => a - b) + .map((id) => ({ + key: String(id), + name: `#${id}`, + ids: [id], + count: 1, + representative: null, + })); + } + + const objects = attackerIds + .map((id) => state.objects[id]) + .filter((o): o is GameObject => o != null); + + const ringBearerIds = new Set( + Object.values(state.ring_bearer ?? {}).filter((id): id is ObjectId => id != null), + ); + + return groupByName(objects, ringBearerIds) + .map((group) => { + const ids = [...group.ids].sort((a, b) => a - b); + return { + key: String(ids[0]), + name: group.name, + ids, + count: ids.length, + representative: state.objects[ids[0]] ?? null, + }; + }) + .sort((a, b) => a.ids[0] - b.ids[0]); +} + +/** + * Distribute `count` items as evenly as possible across `buckets` slots, + * handing the remainder to the earliest buckets in order. e.g. `evenSplit(31, 3)` + * -> `[11, 10, 10]`. Returns an array of length `buckets` (all zeros when + * `count <= 0`; empty when `buckets <= 0`). + */ +export function evenSplit(count: number, buckets: number): number[] { + if (buckets <= 0) return []; + const total = Math.max(0, count); + const base = Math.floor(total / buckets); + const remainder = total % buckets; + return Array.from({ length: buckets }, (_, i) => base + (i < remainder ? 1 : 0)); +} diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index bb96c9ef46..6f8bebb8e3 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use crate::game::casting; use crate::game::combat::AttackTarget; @@ -705,7 +705,14 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec { player, valid_attacker_ids, valid_attack_targets, - } => attacker_actions(state, *player, valid_attacker_ids, valid_attack_targets), + valid_attack_targets_by_attacker, + } => attacker_actions( + state, + *player, + valid_attacker_ids, + valid_attack_targets, + valid_attack_targets_by_attacker, + ), WaitingFor::DeclareBlockers { player, valid_blocker_ids, @@ -3521,6 +3528,7 @@ fn attacker_actions( player: PlayerId, valid_attacker_ids: &[crate::types::identifiers::ObjectId], valid_attack_targets: &[AttackTarget], + valid_attack_targets_by_attacker: &HashMap>, ) -> Vec { // CR 508.1a: declaring no attackers is a structurally legal submission. The // engine's combat-requirement check rejects it at apply time only when a @@ -3536,7 +3544,7 @@ fn attacker_actions( Some(player), )]; - if valid_attack_targets.is_empty() { + if valid_attack_targets.is_empty() && valid_attack_targets_by_attacker.is_empty() { return actions; } @@ -3548,7 +3556,11 @@ fn attacker_actions( // target is the goader, collapsing the legal-action set to empty and // hanging the game. for &id in valid_attacker_ids { - for &target in valid_attack_targets { + let attacker_targets = valid_attack_targets_by_attacker + .get(&id) + .map(Vec::as_slice) + .unwrap_or(valid_attack_targets); + for &target in attacker_targets { actions.push(candidate( GameAction::DeclareAttackers { attacks: vec![(id, target)], @@ -3564,7 +3576,18 @@ fn attacker_actions( // Offer one per target so goad on a lone attacker doesn't make the only // all-in candidate illegal. if valid_attacker_ids.len() > 1 { - for &target in valid_attack_targets { + let common_targets: Vec = valid_attack_targets + .iter() + .copied() + .filter(|target| { + valid_attacker_ids.iter().all(|id| { + valid_attack_targets_by_attacker + .get(id) + .is_none_or(|targets| targets.contains(target)) + }) + }) + .collect(); + for &target in &common_targets { actions.push(candidate( GameAction::DeclareAttackers { attacks: valid_attacker_ids @@ -3630,7 +3653,10 @@ fn attacker_actions( let must_attack_players = crate::game::combat::must_attack_players_for_creature(state, obj); let goaders = crate::game::combat::goading_players_for_creature(state, id); - valid_attack_targets + valid_attack_targets_by_attacker + .get(&id) + .map(Vec::as_slice) + .unwrap_or(valid_attack_targets) .iter() .copied() // CR 508.1b: honor a directed MustAttackPlayer requirement first. @@ -3640,13 +3666,24 @@ fn attacker_actions( // CR 701.15b "if able": otherwise steer away from this creature's // goader. .or_else(|| { - valid_attack_targets.iter().copied().find(|target| match target { - AttackTarget::Player(pid) => !goaders.contains(pid), - _ => true, - }) + valid_attack_targets_by_attacker + .get(&id) + .map(Vec::as_slice) + .unwrap_or(valid_attack_targets) + .iter() + .copied() + .find(|target| match target { + AttackTarget::Player(pid) => !goaders.contains(pid), + _ => true, + }) }) // Fall back to any valid target when no constraint can be honored. - .or_else(|| valid_attack_targets.first().copied()) + .or_else(|| { + valid_attack_targets_by_attacker + .get(&id) + .and_then(|targets| targets.first().copied()) + .or_else(|| valid_attack_targets.first().copied()) + }) .map(|target| (id, target)) }) .collect(); @@ -4846,7 +4883,7 @@ mod tests { let targets = vec![AttackTarget::Player(PlayerId(1))]; crate::game::perf_counters::reset(); - let _ = attacker_actions(&state, PlayerId(0), &ids, &targets); + let _ = attacker_actions(&state, PlayerId(0), &ids, &targets, &HashMap::new()); let snap = crate::game::perf_counters::snapshot(); assert_eq!( @@ -5113,6 +5150,7 @@ mod tests { crate::types::identifiers::ObjectId(2), ], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }, ..GameState::new_two_player(42) }; @@ -5145,6 +5183,7 @@ mod tests { // The goading player is deliberately first: the pre-fix generator // would only ever offer this single (illegal-under-goad) pairing. valid_attack_targets: vec![goader, other_a, other_b], + valid_attack_targets_by_attacker: HashMap::new(), }, ..GameState::new_two_player(42) }; @@ -5213,6 +5252,7 @@ mod tests { AttackTarget::Player(PlayerId(1)), AttackTarget::Player(PlayerId(2)), ], + valid_attack_targets_by_attacker: HashMap::new(), }; let actions = candidate_actions(&state); @@ -5288,6 +5328,7 @@ mod tests { AttackTarget::Player(PlayerId(2)), AttackTarget::Player(PlayerId(1)), ], + valid_attack_targets_by_attacker: HashMap::new(), }; let actions = candidate_actions(&state); diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 4a0346f683..ca76efe01d 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1489,6 +1489,7 @@ mod tests { player: PlayerId(1), valid_attacker_ids: Vec::new(), valid_attack_targets: Vec::new(), + valid_attack_targets_by_attacker: HashMap::new(), }; // Acting player gets the full result (matches `legal_actions_full`). let acting = legal_actions_for_viewer(&state, PlayerId(1)); diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 1ea1366f5f..f03a5e48ce 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -15461,6 +15461,8 @@ mod annihilator_runtime_tests { #[cfg(test)] mod myriad_runtime_tests { + use std::collections::HashMap; + use super::*; use crate::game::combat::AttackTarget; use crate::game::effects::become_copy; @@ -15514,6 +15516,7 @@ mod myriad_runtime_tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; let card_id = CardId(state.next_object_id); diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index b88e48eaea..19ce5b7499 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -1965,16 +1965,21 @@ fn extract_combat_tax_payload<'a>( /// override (CR 702.3b) /// - `has_summoning_sickness(obj)` (CR 302.6) pub fn creature_must_attack(state: &GameState, obj_id: ObjectId) -> bool { - let attackable_players = attackable_player_targets(state); - creature_must_attack_with_attackable_players(state, obj_id, &attackable_players) + let gates = CombatStaticGates::compute(state); + let broad_targets = get_valid_attack_targets(state); + creature_must_attack_from_broad_targets_gated(state, obj_id, &broad_targets, &gates) } pub fn attackable_player_targets(state: &GameState) -> Vec { crate::game::perf_counters::record_attackable_player_sweep(); - get_valid_attack_targets(state) - .into_iter() + attackable_players_from_targets(&get_valid_attack_targets(state)) +} + +fn attackable_players_from_targets(targets: &[AttackTarget]) -> Vec { + targets + .iter() .filter_map(|target| match target { - AttackTarget::Player(pid) => Some(pid), + AttackTarget::Player(pid) => Some(*pid), _ => None, }) .collect() @@ -2076,6 +2081,20 @@ fn creature_must_attack_with_attackable_players_gated( true } +/// CR 508.1b + CR 508.1c + CR 508.1d + CR 701.15b: "If able" must be measured +/// against the defending players this attacker can legally be declared against +/// after its own scoped attack restrictions are applied. +fn creature_must_attack_from_broad_targets_gated( + state: &GameState, + obj_id: ObjectId, + broad_targets: &[AttackTarget], + gates: &CombatStaticGates, +) -> bool { + let attackable_players = + attackable_players_for_attacker_gated(state, obj_id, broad_targets, gates); + creature_must_attack_with_attackable_players_gated(state, obj_id, &attackable_players, gates) +} + /// CR 702.22: Whether a creature has the banding keyword. pub fn has_banding(state: &GameState, object_id: ObjectId) -> bool { state @@ -2329,21 +2348,36 @@ pub fn declare_attackers_with_bands( if !bands.is_empty() { validate_attack_band_declarations(state, attacks, bands)?; } - let attackable_players = attackable_player_targets(state); + let broad_targets = get_valid_attack_targets(state); // CR 604.1: hoist the combat-restriction existence gates once; every // per-permanent / per-attacker static check below reuses them so each loop // stays O(N) instead of O(N^2). let gates = CombatStaticGates::compute(state); + let attackable_players_by_attacker: HashMap> = state + .battlefield + .iter() + .copied() + .map(|id| { + ( + id, + attackable_players_for_attacker_gated(state, id, &broad_targets, &gates), + ) + }) + .collect(); // CR 508.1d / CR 701.15b: Creatures that must attack each combat if able. // `creature_must_attack` is the single authority for the requirement + // exemption logic; this loop only adds the "already declared?" check and // the rejection error text. for &obj_id in &state.battlefield { + let attackable_players = attackable_players_by_attacker + .get(&obj_id) + .map(Vec::as_slice) + .unwrap_or(&[]); if !creature_must_attack_with_attackable_players_gated( state, obj_id, - &attackable_players, + attackable_players, &gates, ) { continue; @@ -2528,16 +2562,9 @@ pub fn declare_attackers_with_bands( } // CR 701.15b: a goaded creature must attack a player other than the goading - // player *if able*. "Able" is measured against the players this creature - // could legally be declared attacking: `get_valid_attack_targets` already - // applies CR 506.2/506.3 plus the exclusions for the active player, - // eliminated players, teammates, phased-out players, and players with - // protection from everything. A non-goading player who is not a legal attack - // target (e.g. a phased-out opponent or a teammate) does not make the - // creature able to attack elsewhere, so attacking a goading player stays - // legal. The previous check counted every non-eliminated player, wrongly - // forcing the creature off a goading player toward a target it could not - // actually attack. + // player *if able*. "Able" is measured against the same per-attacker base + // target set the declare-attackers UI and helper queries use: global attack + // targets filtered by this creature's own scoped attack restrictions. for (attacker_id, target) in attacks { if let AttackTarget::Player(defending_pid) = target { let goading_players = @@ -2545,6 +2572,10 @@ pub fn declare_attackers_with_bands( if goading_players.is_empty() { continue; } + let attackable_players = attackable_players_by_attacker + .get(attacker_id) + .map(Vec::as_slice) + .unwrap_or(&[]); // Only enforce the redirect if a non-goading player is actually a // legal attack target for this creature. if goading_players.contains(defending_pid) { @@ -2570,6 +2601,10 @@ pub fn declare_attackers_with_bands( let Some(obj) = state.objects.get(attacker_id) else { continue; }; + let attackable_players = attackable_players_by_attacker + .get(attacker_id) + .map(Vec::as_slice) + .unwrap_or(&[]); for req_player in must_attack_players_for_creature(state, obj) { // Only enforce when the creature can legally attack the required player. if !attackable_players.contains(&req_player) { @@ -2902,68 +2937,12 @@ pub fn has_summoning_sickness(obj: &GameObject) -> bool { /// CR 508.1a / CR 302.6: Untapped creature controlled since turn started, without Defender. /// CR 702.26b: Phased-out creatures can't attack. pub fn get_valid_attacker_ids(state: &GameState) -> Vec { - let active = state.active_player; - // CR 604.1: hoist the combat-restriction existence gates once before the - // per-permanent scan (collapses O(N^2) to O(N)). let gates = CombatStaticGates::compute(state); + let broad_targets = get_valid_attack_targets(state); - state - .battlefield_phased_in_ids() - .iter() - .filter_map(|id| { - let obj = state.objects.get(id)?; - if obj.controller == active - && obj.card_types.core_types.contains(&CoreType::Creature) - && !obj.tapped - && (!obj.has_keyword(&Keyword::Defender) - || super::functioning_abilities::active_static_definitions(state, obj) - .any(|sd| sd.mode == StaticMode::CanAttackWithDefender) - || (gates.has_can_attack_with_defender - && crate::game::static_abilities::check_static_ability( - state, - StaticMode::CanAttackWithDefender, - &crate::game::static_abilities::StaticCheckContext { - target_id: Some(*id), - ..Default::default() - }, - ))) - && !super::functioning_abilities::active_static_definitions(state, obj).any(|sd| { - matches!( - sd.mode, - StaticMode::CantAttack | StaticMode::CantAttackOrBlock - ) && sd.attack_defended.is_none() - }) - // CR 508.1 + CR 101.2 + CR 109.5: remote CantAttack statics - // (Angelic Arbiter restricting opponents' creatures) resolved via - // the shared `check_static_ability` building block. - && !(gates.has_cant_attack - && crate::game::static_abilities::check_static_ability( - state, - StaticMode::CantAttack, - &crate::game::static_abilities::StaticCheckContext { - target_id: Some(*id), - ..Default::default() - }, - )) - && !(gates.has_cant_attack_or_block - && crate::game::static_abilities::check_static_ability( - state, - StaticMode::CantAttackOrBlock, - &crate::game::static_abilities::StaticCheckContext { - target_id: Some(*id), - ..Default::default() - }, - )) - // CR 302.6: delegate to the single authority for summoning - // sickness — folds in Haste at query time without duplicating - // the flag/keyword logic here. - && !has_summoning_sickness(obj) - { - Some(*id) - } else { - None - } - }) + valid_attack_targets_by_attacker_gated(state, &broad_targets, &gates) + .into_iter() + .map(|(id, _)| id) .collect() } @@ -2979,17 +2958,21 @@ pub fn get_valid_attacker_ids(state: &GameState) -> Vec { pub fn refresh_combat_declaration_waiting_for(state: &mut GameState) { match &state.waiting_for { crate::types::game_state::WaitingFor::DeclareAttackers { .. } => { - // CR 508.1a: Mirror turns.rs:1369-1370 — rebuild both payload fields. + // CR 508.1a: Mirror turns.rs:1369-1372 — rebuild the attacker and + // target payloads from the live legality queries. let valid_attacker_ids = get_valid_attacker_ids(state); let valid_attack_targets = get_valid_attack_targets(state); + let valid_attack_targets_by_attacker = get_valid_attack_targets_by_attacker(state); if let crate::types::game_state::WaitingFor::DeclareAttackers { valid_attacker_ids: ids, valid_attack_targets: targets, + valid_attack_targets_by_attacker: targets_by_attacker, .. } = &mut state.waiting_for { *ids = valid_attacker_ids; *targets = valid_attack_targets; + *targets_by_attacker = valid_attack_targets_by_attacker; } } crate::types::game_state::WaitingFor::DeclareBlockers { player, .. } => { @@ -3679,6 +3662,263 @@ pub fn get_valid_attack_targets(state: &GameState) -> Vec { targets } +/// CR 508.1a + CR 508.1c + CR 702.26b: An attacker must be an untapped, +/// phased-in creature the active player can legally declare as an attacker. +fn attacker_is_generically_eligible_to_attack( + state: &GameState, + attacker_id: ObjectId, + gates: &CombatStaticGates, +) -> bool { + let Some(obj) = state.objects.get(&attacker_id) else { + return false; + }; + obj.controller == state.active_player + && obj.zone == Zone::Battlefield + && !obj.is_phased_out() + && obj.card_types.core_types.contains(&CoreType::Creature) + && !obj.tapped + && (!obj.has_keyword(&Keyword::Defender) + || super::functioning_abilities::active_static_definitions(state, obj) + .any(|sd| sd.mode == StaticMode::CanAttackWithDefender) + || (gates.has_can_attack_with_defender + && crate::game::static_abilities::check_static_ability( + state, + StaticMode::CanAttackWithDefender, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(attacker_id), + ..Default::default() + }, + ))) + && !super::functioning_abilities::active_static_definitions(state, obj).any(|sd| { + matches!( + sd.mode, + StaticMode::CantAttack | StaticMode::CantAttackOrBlock + ) && sd.attack_defended.is_none() + }) + && !(gates.has_cant_attack + && crate::game::static_abilities::check_static_ability( + state, + StaticMode::CantAttack, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(attacker_id), + ..Default::default() + }, + )) + && !(gates.has_cant_attack_or_block + && crate::game::static_abilities::check_static_ability( + state, + StaticMode::CantAttackOrBlock, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(attacker_id), + ..Default::default() + }, + )) + && !has_summoning_sickness(obj) +} + +/// CR 508.1b + CR 508.1c: Each declared attacker must have a legal defending +/// player or battle after applying scoped attack restrictions. +fn attack_target_passes_scoped_restrictions( + state: &GameState, + attacker_id: ObjectId, + target: AttackTarget, + gates: &CombatStaticGates, +) -> bool { + if (gates.has_cant_attack + && crate::game::static_abilities::check_static_ability( + state, + StaticMode::CantAttack, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(attacker_id), + attack_target: Some(target), + ..Default::default() + }, + )) + || (gates.has_cant_attack_or_block + && crate::game::static_abilities::check_static_ability( + state, + StaticMode::CantAttackOrBlock, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(attacker_id), + attack_target: Some(target), + ..Default::default() + }, + )) + { + return false; + } + + let Some(attacker_controller) = state.objects.get(&attacker_id).map(|o| o.controller) else { + return false; + }; + for restriction in &state.restrictions { + let crate::types::ability::GameRestriction::ProhibitActivity { + source, + affected_players, + activity: crate::types::ability::ProhibitedActivity::Attack { defended }, + .. + } = restriction + else { + continue; + }; + let Some(protected) = state.objects.get(source).map(|o| o.controller) else { + continue; + }; + let attacker_is_affected = match affected_players { + crate::types::ability::RestrictionPlayerScope::AllPlayers => true, + crate::types::ability::RestrictionPlayerScope::SpecificPlayer(p) => { + *p == attacker_controller + } + crate::types::ability::RestrictionPlayerScope::OpponentsOfSourceController => { + attacker_controller != protected + } + crate::types::ability::RestrictionPlayerScope::TargetedPlayer + | crate::types::ability::RestrictionPlayerScope::ParentTargetedPlayer + | crate::types::ability::RestrictionPlayerScope::DefendingPlayer + | crate::types::ability::RestrictionPlayerScope::ScopedPlayer => false, + }; + if !attacker_is_affected { + continue; + } + if crate::game::restrictions::attack_target_matches_defended_scope( + state, + Some(&target), + defended, + protected, + protected, + ) { + return false; + } + } + + true +} + +/// CR 508.1b + CR 508.1c: Start from globally attackable defenders, then +/// remove attack targets this creature is individually restricted from +/// attacking. +fn base_valid_attack_targets_for_attacker_gated( + state: &GameState, + attacker_id: ObjectId, + broad_targets: &[AttackTarget], + gates: &CombatStaticGates, +) -> Vec { + if !attacker_is_generically_eligible_to_attack(state, attacker_id, gates) { + return Vec::new(); + } + + broad_targets + .iter() + .copied() + .filter(|target| { + attack_target_passes_scoped_restrictions(state, attacker_id, *target, gates) + }) + .collect() +} + +/// CR 508.1b + CR 508.1c: The defending players this creature can legally +/// attack after applying its own scoped restrictions, before goad or +/// MustAttackPlayer narrow the declaration further. +fn attackable_players_for_attacker_gated( + state: &GameState, + attacker_id: ObjectId, + broad_targets: &[AttackTarget], + gates: &CombatStaticGates, +) -> Vec { + let base_targets = + base_valid_attack_targets_for_attacker_gated(state, attacker_id, broad_targets, gates); + attackable_players_from_targets(&base_targets) +} + +/// CR 508.1d + CR 701.15b: Attack requirements and goad further narrow the +/// legal defending players for a specific attacker. +fn valid_attack_targets_for_attacker_from_base( + state: &GameState, + attacker_id: ObjectId, + base_targets: &[AttackTarget], + gates: &CombatStaticGates, +) -> Vec { + let Some(obj) = state.objects.get(&attacker_id) else { + return Vec::new(); + }; + let attackable_players: Vec = base_targets + .iter() + .filter_map(|target| match target { + AttackTarget::Player(pid) => Some(*pid), + _ => None, + }) + .collect(); + let goading_players = goading_players_for_creature_gated(state, attacker_id, gates.has_goad); + let has_attackable_non_goading_player = attackable_players + .iter() + .any(|pid| !goading_players.contains(pid)); + let required_players: Vec = must_attack_players_for_creature(state, obj) + .into_iter() + .filter(|player| attackable_players.contains(player)) + .collect(); + + base_targets + .iter() + .copied() + .filter(|target| { + if let AttackTarget::Player(defending_pid) = target { + if has_attackable_non_goading_player && goading_players.contains(defending_pid) { + return false; + } + } + if required_players.is_empty() { + return true; + } + matches!(target, AttackTarget::Player(pid) if required_players.contains(pid)) + }) + .collect() +} + +/// CR 508.1a + CR 508.1b + CR 508.1c + CR 508.1d: Enumerate the legal attack +/// targets for each attacker during declaration. +fn valid_attack_targets_by_attacker_gated( + state: &GameState, + broad_targets: &[AttackTarget], + gates: &CombatStaticGates, +) -> Vec<(ObjectId, Vec)> { + state + .battlefield_phased_in_ids() + .iter() + .filter_map(|id| { + let base_targets = + base_valid_attack_targets_for_attacker_gated(state, *id, broad_targets, gates); + let targets = + valid_attack_targets_for_attacker_from_base(state, *id, &base_targets, gates); + (!targets.is_empty()).then_some((*id, targets)) + }) + .collect() +} + +/// CR 508.1a + CR 508.1b + CR 508.1c + CR 508.1d: Legal attack targets for +/// each attacker during declaration. +pub fn get_valid_attack_targets_by_attacker( + state: &GameState, +) -> HashMap> { + let gates = CombatStaticGates::compute(state); + let broad_targets = get_valid_attack_targets(state); + valid_attack_targets_by_attacker_gated(state, &broad_targets, &gates) + .into_iter() + .collect() +} + +/// CR 508.1a + CR 508.1b + CR 508.1c + CR 508.1d: Legal attack targets for one +/// attacker during declaration. +pub fn get_valid_attack_targets_for_attacker( + state: &GameState, + attacker_id: ObjectId, +) -> Vec { + let gates = CombatStaticGates::compute(state); + let broad_targets = get_valid_attack_targets(state); + let base_targets = + base_valid_attack_targets_for_attacker_gated(state, attacker_id, &broad_targets, &gates); + valid_attack_targets_for_attacker_from_base(state, attacker_id, &base_targets, &gates) +} + /// CR 506.4: A creature stops being an attacker when it leaves the battlefield /// or phases out. Attackers that left during the declare-attackers step may /// remain listed until pruned. @@ -4365,6 +4605,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: get_valid_attacker_ids(&state), valid_attack_targets: get_valid_attack_targets(&state), + valid_attack_targets_by_attacker: get_valid_attack_targets_by_attacker(&state), }; match &state.waiting_for { WaitingFor::DeclareAttackers { @@ -4432,6 +4673,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: get_valid_attacker_ids(&state), valid_attack_targets: get_valid_attack_targets(&state), + valid_attack_targets_by_attacker: get_valid_attack_targets_by_attacker(&state), }; match &state.waiting_for { WaitingFor::DeclareAttackers { @@ -8000,6 +8242,62 @@ mod tests { assert!(attacks_non_owner.is_ok()); } + #[test] + fn valid_attack_targets_by_attacker_filters_scoped_attack_lockouts() { + let mut state = setup_multiplayer_combat(3); + let attacker = create_creature(&mut state, PlayerId(1), "Owner-Restricted Bear", 2, 2); + { + let obj = state.objects.get_mut(&attacker).unwrap(); + obj.controller = PlayerId(0); + obj.static_definitions.push( + StaticDefinition::new(StaticMode::CantAttack) + .affected(TargetFilter::SelfRef) + .attack_defended(Some(crate::types::triggers::AttackTargetFilter::Owner)), + ); + } + + let global_targets = get_valid_attack_targets(&state); + assert_eq!( + global_targets, + vec![ + AttackTarget::Player(PlayerId(1)), + AttackTarget::Player(PlayerId(2)), + ], + "global declare-attackers targets still list both opponents" + ); + + let by_attacker = get_valid_attack_targets_by_attacker(&state); + assert_eq!( + by_attacker.get(&attacker), + Some(&vec![AttackTarget::Player(PlayerId(2))]), + "per-attacker targets must exclude the owner-side target this creature can't attack" + ); + } + + #[test] + fn valid_attacker_ids_drop_creatures_with_no_legal_attack_target() { + let mut state = setup(); + let attacker = create_creature(&mut state, PlayerId(1), "Stranded Bear", 2, 2); + { + let obj = state.objects.get_mut(&attacker).unwrap(); + obj.controller = PlayerId(0); + obj.static_definitions.push( + StaticDefinition::new(StaticMode::CantAttack) + .affected(TargetFilter::SelfRef) + .attack_defended(Some(crate::types::triggers::AttackTargetFilter::Owner)), + ); + } + + assert!( + get_valid_attack_targets_for_attacker(&state, attacker).is_empty(), + "the only opponent is this creature's owner, so it has no legal attack target" + ); + assert!( + !get_valid_attacker_ids(&state).contains(&attacker), + "declare-attackers UI must not surface attackers with no legal target" + ); + } + #[test] fn cant_attack_owner_or_planeswalker_blocks_owner_side_targets() { let mut state = setup_multiplayer_combat(3); @@ -8195,6 +8493,78 @@ mod tests { ); } + #[test] + fn goad_redirect_uses_per_attacker_scoped_legality() { + let mut state = setup_multiplayer_combat(3); + let goaded = create_goaded_creature(&mut state, PlayerId(2), PlayerId(1)); + { + let obj = state.objects.get_mut(&goaded).unwrap(); + obj.controller = PlayerId(0); + obj.static_definitions.push( + StaticDefinition::new(StaticMode::CantAttack) + .affected(TargetFilter::SelfRef) + .attack_defended(Some(crate::types::triggers::AttackTargetFilter::Owner)), + ); + } + + assert_eq!( + get_valid_attack_targets_for_attacker(&state, goaded), + vec![AttackTarget::Player(PlayerId(1))], + "scoped CantAttack must remove the non-goading owner from this creature's legal targets" + ); + + let result = declare_attackers( + &mut state, + &[(goaded, AttackTarget::Player(PlayerId(1)))], + &mut vec![], + ); + assert!( + result.is_ok(), + "with no legal non-goading player left, attacking the goader is legal: {result:?}" + ); + } + + #[test] + fn must_attack_player_uses_per_attacker_scoped_legality() { + let mut state = setup_multiplayer_combat(3); + let attacker = create_creature(&mut state, PlayerId(2), "Lured Bear", 2, 2); + { + let obj = state.objects.get_mut(&attacker).unwrap(); + obj.controller = PlayerId(0); + obj.static_definitions.push( + StaticDefinition::new(StaticMode::CantAttack) + .affected(TargetFilter::SelfRef) + .attack_defended(Some(crate::types::triggers::AttackTargetFilter::Owner)), + ); + obj.static_definitions + .push(StaticDefinition::new(StaticMode::MustAttackPlayer { + player: PlayerId(2), + })); + } + + assert_eq!( + get_valid_attack_targets_for_attacker(&state, attacker), + vec![AttackTarget::Player(PlayerId(1))], + "the required player is globally attackable but not legal for this attacker" + ); + + let attacks_other_player = declare_attackers( + &mut state.clone(), + &[(attacker, AttackTarget::Player(PlayerId(1)))], + &mut vec![], + ); + assert!( + attacks_other_player.is_ok(), + "MustAttackPlayer should not force an illegal target when scoped CantAttack removes it" + ); + + let omitted = declare_attackers(&mut state, &[], &mut vec![]); + assert!( + omitted.is_ok(), + "MustAttackPlayer should not force this attacker to attack when the required player is not legal" + ); + } + #[test] fn cant_be_blocked_except_by_enforces_filter() { use crate::parser::oracle_target::parse_target; diff --git a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs index ecc6b71583..847809f97d 100644 --- a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs +++ b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use super::tests::apply_oracle_to_object; @@ -1269,6 +1270,7 @@ fn attack_trigger_resolves_before_combat_damage_and_only_once() { player: PlayerId(0), valid_attacker_ids: vec![ajani, linden], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }; let declare_result = apply_as_current( @@ -1461,6 +1463,7 @@ fn lifelink_replacement_does_not_double_fire_life_gain_triggers() { player: PlayerId(0), valid_attacker_ids: vec![bat], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }; apply_as_current( @@ -4099,6 +4102,7 @@ fn declare_blockers_grants_ap_priority_when_no_legal_blockers() { player: PlayerId(0), valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }; apply_as_current( diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index 024bc046eb..e0b95a31a7 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use super::*; @@ -1265,6 +1266,7 @@ fn broadside_bombardiers_boast_activates_after_attacking_and_requires_sacrifice( player: PlayerId(0), valid_attacker_ids: vec![bombardiers], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }; apply_as_current( &mut state, @@ -1761,6 +1763,7 @@ fn concede_owner_of_waiting_for_advances_state() { player: PlayerId(1), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; let result = apply_as_current( @@ -5026,6 +5029,7 @@ fn setup_tempest_hawk_attack(library_hawk_ids: &[u64]) -> (GameState, ObjectId, player: PlayerId(0), valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(PlayerId(1))], + valid_attack_targets_by_attacker: HashMap::new(), }; apply_as_current( diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 59b9cfd0ce..f7fdacf7a4 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -20031,6 +20031,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; // Boggart Prankster trigger: Whenever you attack, target attacking @@ -20343,6 +20344,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; // The Kratos-like commander carries the trigger but does not attack. @@ -20430,6 +20432,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; let source = make_creature(&mut state, PlayerId(0), "Subject Source", 1, 1); @@ -20716,6 +20719,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; // The Tenth Doctor — attacking creature carrying the Allons-y! trigger. @@ -20961,6 +20965,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; // Raph & Mikey on P0's battlefield, carrying its real Attacks trigger. @@ -21114,6 +21119,7 @@ pub mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }; // CR 104.3c: stock both libraries so neither player decks out while the diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index d809f79f36..2141c34be2 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -2093,10 +2093,13 @@ pub fn auto_advance(state: &mut GameState, events: &mut Vec) -> Waiti // CR 508.1: Active player declares attackers as a turn-based action. let valid_attacker_ids = super::combat::get_valid_attacker_ids(state); let valid_attack_targets = super::combat::get_valid_attack_targets(state); + let valid_attack_targets_by_attacker = + super::combat::get_valid_attack_targets_by_attacker(state); return WaitingFor::DeclareAttackers { player: state.active_player, valid_attacker_ids, valid_attack_targets, + valid_attack_targets_by_attacker, }; } Phase::DeclareBlockers => { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 7b257844c7..7f8d934048 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -2975,6 +2975,8 @@ pub enum WaitingFor { valid_attacker_ids: Vec, #[serde(default)] valid_attack_targets: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + valid_attack_targets_by_attacker: HashMap>, }, DeclareBlockers { player: PlayerId, @@ -9025,6 +9027,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), })); variants.push(Box::new(WaitingFor::DeclareBlockers { player: PlayerId(0), diff --git a/crates/engine/tests/integration/emissary_green.rs b/crates/engine/tests/integration/emissary_green.rs index 1b8a69a8be..590227fd1f 100644 --- a/crates/engine/tests/integration/emissary_green.rs +++ b/crates/engine/tests/integration/emissary_green.rs @@ -69,6 +69,7 @@ fn attack_with_emissary() -> (GameRunner, ObjectId, ObjectId) { player: P0, valid_attacker_ids: vec![emissary], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; } runner diff --git a/crates/engine/tests/integration/giggling_skitterspike_issue_890.rs b/crates/engine/tests/integration/giggling_skitterspike_issue_890.rs index 968ed122cc..b365b0b94f 100644 --- a/crates/engine/tests/integration/giggling_skitterspike_issue_890.rs +++ b/crates/engine/tests/integration/giggling_skitterspike_issue_890.rs @@ -35,6 +35,7 @@ fn issue_890_attack_trigger_deals_damage() { player: P0, valid_attacker_ids: vec![skitterspike], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; } @@ -69,6 +70,7 @@ fn issue_890_block_trigger_deals_damage() { player: P1, valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(P0)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; } diff --git a/crates/engine/tests/integration/issue_1297_venture_initiative_triggers.rs b/crates/engine/tests/integration/issue_1297_venture_initiative_triggers.rs index 0c4febb077..b2b18d0bf8 100644 --- a/crates/engine/tests/integration/issue_1297_venture_initiative_triggers.rs +++ b/crates/engine/tests/integration/issue_1297_venture_initiative_triggers.rs @@ -196,6 +196,7 @@ fn initiative_attack_trigger_skips_without_initiative() { player: P0, valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; let hand_before = runner.state().players[0].hand.len(); @@ -231,6 +232,7 @@ fn initiative_attack_trigger_draws_with_initiative() { player: P0, valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; let hand_before = runner.state().players[0].hand.len(); diff --git a/crates/engine/tests/integration/rules/combat.rs b/crates/engine/tests/integration/rules/combat.rs index f16dd18352..294d2de6d3 100644 --- a/crates/engine/tests/integration/rules/combat.rs +++ b/crates/engine/tests/integration/rules/combat.rs @@ -1177,6 +1177,7 @@ fn build_3p_propaganda_scenario(propaganda_owner: PlayerId) -> (GameRunner, Obje player: P1, valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(P0), AttackTarget::Player(PlayerId(2))], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; (runner, attacker) } diff --git a/crates/engine/tests/squirrel_perf_probe.rs b/crates/engine/tests/squirrel_perf_probe.rs index 8c4cb100c6..b956d7bb35 100644 --- a/crates/engine/tests/squirrel_perf_probe.rs +++ b/crates/engine/tests/squirrel_perf_probe.rs @@ -101,6 +101,7 @@ fn probe_squirrel_board() { player: active, valid_attacker_ids, valid_attack_targets, + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; measure("declare-attackers", &da); } diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index bb1a07c69e..0d865aa9c7 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2135,6 +2135,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: HashMap::new(), }, ), ( diff --git a/crates/phase-ai/src/bin/attack_scaling_bench.rs b/crates/phase-ai/src/bin/attack_scaling_bench.rs index 4a71958615..c7b6e8b4d8 100644 --- a/crates/phase-ai/src/bin/attack_scaling_bench.rs +++ b/crates/phase-ai/src/bin/attack_scaling_bench.rs @@ -60,6 +60,7 @@ fn bench_n(n: usize) { player: P0, valid_attacker_ids, valid_attack_targets, + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; let at_declare = matches!( diff --git a/crates/phase-ai/src/bin/declare_attackers_bench.rs b/crates/phase-ai/src/bin/declare_attackers_bench.rs index ea70b0731f..175fb7d2c2 100644 --- a/crates/phase-ai/src/bin/declare_attackers_bench.rs +++ b/crates/phase-ai/src/bin/declare_attackers_bench.rs @@ -107,6 +107,7 @@ fn main() { player: active, valid_attacker_ids: valid_attacker_ids.clone(), valid_attack_targets: valid_attack_targets.clone(), + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; // ── 2. Generic candidate path (frontend legal-actions / SimulationFilter) ─ diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 6827fa255f..3d74a72086 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -229,6 +229,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }, &dummy_action ), diff --git a/crates/phase-ai/src/policies/aggro_pressure.rs b/crates/phase-ai/src/policies/aggro_pressure.rs index b6116cc327..f5ac0764b4 100644 --- a/crates/phase-ai/src/policies/aggro_pressure.rs +++ b/crates/phase-ai/src/policies/aggro_pressure.rs @@ -262,6 +262,7 @@ mod tests { player: AI, valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }, candidates: Vec::new(), } @@ -505,6 +506,7 @@ mod tests { player: AI, valid_attacker_ids: vec![oid], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }, candidates: Vec::new(), }; diff --git a/crates/phase-ai/src/policies/tokens_wide.rs b/crates/phase-ai/src/policies/tokens_wide.rs index 6c0065d2fc..7308d0d741 100644 --- a/crates/phase-ai/src/policies/tokens_wide.rs +++ b/crates/phase-ai/src/policies/tokens_wide.rs @@ -191,6 +191,7 @@ mod tests { player: AI, valid_attacker_ids: vec![], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }, candidates: Vec::new(), } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 380c4fac8d..7947659293 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -3629,6 +3629,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: vec![creature], valid_attack_targets: vec![], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; let config = create_config(AiDifficulty::VeryHard, Platform::Native); @@ -3661,6 +3662,7 @@ mod tests { player: PlayerId(0), valid_attacker_ids: vec![creature], valid_attack_targets: vec![target], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; let action = validated_declare_attackers(&state, vec![(creature, target)]); diff --git a/crates/phase-ai/tests/ai_quality.rs b/crates/phase-ai/tests/ai_quality.rs index b3e9d2fe8b..0fc433a84c 100644 --- a/crates/phase-ai/tests/ai_quality.rs +++ b/crates/phase-ai/tests/ai_quality.rs @@ -316,6 +316,7 @@ fn attacks_when_opponent_is_at_lethal() { player: P0, valid_attacker_ids: vec![attacker], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; } @@ -395,6 +396,7 @@ fn attacks_with_evasive_creatures() { player: P0, valid_attacker_ids: vec![flyer], valid_attack_targets: vec![AttackTarget::Player(P1)], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; } diff --git a/crates/phase-ai/tests/scenarios.rs b/crates/phase-ai/tests/scenarios.rs index 59f8f51b12..f0e9394886 100644 --- a/crates/phase-ai/tests/scenarios.rs +++ b/crates/phase-ai/tests/scenarios.rs @@ -136,6 +136,7 @@ fn scenario_multiplayer_attacks_to_finish_exposed_player() { player: P0, valid_attacker_ids: vec![attacker_a, attacker_b], valid_attack_targets: vec![AttackTarget::Player(P1), AttackTarget::Player(PlayerId(2))], + valid_attack_targets_by_attacker: std::collections::HashMap::new(), }; }