diff --git a/src/components/EternalDominationGame.tsx b/src/components/EternalDominationGame.tsx index 481b610..4720bf1 100644 --- a/src/components/EternalDominationGame.tsx +++ b/src/components/EternalDominationGame.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -148,198 +148,206 @@ const findBorderPath = (from: Position, to: Position): Position[] => { return path1.length <= path2.length ? path1 : path2; }; -// BFS to find shortest path between two positions using hex neighbors -const findShortestPath = (from: Position, to: Position, guards: Set): Position[] => { - const queue: Position[][] = [[from]]; - const visited = new Set([posKey(from)]); - - while (queue.length > 0) { - const path = queue.shift()!; - const current = path[path.length - 1]; - - if (current.x === to.x && current.y === to.y) { - return path; - } - - for (const neighbor of getHexNeighbors(current.x, current.y)) { - const key = posKey(neighbor); - if (!visited.has(key)) { - visited.add(key); - queue.push([...path, neighbor]); - } +// === Pattern + simultaneous-move defense (no teleporting) ===================== + +// Interior configurations come in 4 residue classes. +// We model the paper's “restore configuration via border patchwork” as: +// after each attack we move (simultaneously) to the interior residue class that +// contains the attacked vertex, while keeping the entire border guarded. +const interiorResidue = (p: Position): number => ((p.x + 2 * p.y) % 4 + 4) % 4; + +const getInteriorPatternByResidue = (r: number): Set => { + const pattern = new Set(); + for (let x = 1; x < COLS - 1; x++) { + for (let y = 2; y < ROWS - 2; y++) { + if (interiorResidue({ x, y }) === r) pattern.add(posKey({ x, y })); } } - - return []; + return pattern; }; -// Defense strategy following Figure 10 exactly: -// 1. Interior guards shift in chains toward the attack (v_t) -// 2. Two interior guards exit to border (u₁→w₁, u₂→w₂) -// 3. Two border guards enter interior (w₃→u₃, w₄→u₄) -// 4. Border guards shift along complementary paths P₁,₃ and P₂,₄ -const defendAttack = (guards: Set, attack: Position): { - newGuards: Set; +const getAllBorderKeys = (): Set => { + const border = new Set(); + for (let x = 0; x < COLS; x++) { + for (let y = 0; y < ROWS; y++) { + if (isBorder(x, y)) border.add(posKey({ x, y })); + } + } + return border; +}; + +const BORDER_KEYS = getAllBorderKeys(); + +const getTargetGuardsForResidue = (r: number): Set => { + const target = new Set(BORDER_KEYS); + for (const k of getInteriorPatternByResidue(r)) target.add(k); + return target; +}; + +const inferCurrentInteriorResidue = (guards: Set): number => { + for (const k of guards) { + const p = parseKey(k); + if (isInterior(p.x, p.y)) return interiorResidue(p); + } + // Should never happen (we always have interior guards), but default safely. + return 0; +}; + +// Hopcroft–Karp for perfect matching in the “guards → target cells” bipartite graph. +// Left: current guard indices. Right: target indices. +const hopcroftKarp = (adj: number[][], leftSize: number, rightSize: number) => { + const NIL = -1; + const pairU = new Array(leftSize).fill(NIL); + const pairV = new Array(rightSize).fill(NIL); + const dist = new Array(leftSize).fill(0); + + const bfs = (): boolean => { + const q: number[] = []; + for (let u = 0; u < leftSize; u++) { + if (pairU[u] === NIL) { + dist[u] = 0; + q.push(u); + } else { + dist[u] = Number.POSITIVE_INFINITY; + } + } + + let foundFreeVertex = false; + + while (q.length) { + const u = q.shift()!; + for (const v of adj[u]) { + const u2 = pairV[v]; + if (u2 !== NIL) { + if (dist[u2] === Number.POSITIVE_INFINITY) { + dist[u2] = dist[u] + 1; + q.push(u2); + } + } else { + foundFreeVertex = true; + } + } + } + + return foundFreeVertex; + }; + + const dfs = (u: number): boolean => { + for (const v of adj[u]) { + const u2 = pairV[v]; + if (u2 === NIL || (dist[u2] === dist[u] + 1 && dfs(u2))) { + pairU[u] = v; + pairV[v] = u; + return true; + } + } + dist[u] = Number.POSITIVE_INFINITY; + return false; + }; + + let matching = 0; + while (bfs()) { + for (let u = 0; u < leftSize; u++) { + if (pairU[u] === NIL && dfs(u)) matching++; + } + } + + return { matching, pairU }; +}; + +// Defense = one simultaneous move of all guards (each guard moves at most 1 step). +// We compute the intended post-defense configuration (target) and then find a +// perfect matching that assigns each current guard to a unique reachable target. +const defendAttack = ( + guards: Set, + attack: Position +): { + newGuards: Set; movements: Movement[]; } | null => { const attackKey = posKey(attack); - + if (guards.has(attackKey)) { return { newGuards: guards, movements: [] }; } - + + // Must be defendable: some guard adjacent to v_t. const neighbors = getHexNeighbors(attack.x, attack.y); - const adjacentGuards = neighbors.filter(p => guards.has(posKey(p))); - - if (adjacentGuards.length === 0) { + if (!neighbors.some((p) => guards.has(posKey(p)))) return null; + + const currentResidue = inferCurrentInteriorResidue(guards); + const targetResidue = interiorResidue(attack); + + // Target config: same “family” of configuration, but switch residue class so that + // the attacked vertex becomes a guard position again. + const target = getTargetGuardsForResidue(targetResidue); + if (!target.has(attackKey)) { + // Should never happen by construction. return null; } - - const newGuards = new Set(guards); + + // Build bipartite graph: each guard can go to {self ∪ hex-neighbors} ∩ target. + const fromKeys = Array.from(guards); + const toKeys = Array.from(target); + + if (fromKeys.length !== toKeys.length) return null; + + const toIndex = new Map(); + for (let i = 0; i < toKeys.length; i++) toIndex.set(toKeys[i], i); + + const adj: number[][] = new Array(fromKeys.length); + for (let i = 0; i < fromKeys.length; i++) { + const from = parseKey(fromKeys[i]); + const reachable: string[] = [posKey(from), ...getHexNeighbors(from.x, from.y).map(posKey)]; + + const edges: number[] = []; + for (const k of reachable) { + const idx = toIndex.get(k); + if (idx !== undefined) edges.push(idx); + } + + // Deterministic order helps keep the visual behavior stable. + edges.sort((a, b) => a - b); + adj[i] = edges; + } + + const { matching, pairU } = hopcroftKarp(adj, fromKeys.length, toKeys.length); + if (matching !== fromKeys.length) return null; + const movements: Movement[] = []; - - // STEP 1: Find interior guard chains that will shift toward attack - // In Figure 10, the arrows show guards shifting in diagonal lines toward v_t - - // Find an interior guard adjacent to the attack to move there - let primaryDefender: Position | null = null; - for (const g of adjacentGuards) { - if (isInterior(g.x, g.y)) { - primaryDefender = g; - break; - } - } - - // If attack is on border or no interior guard adjacent, use border guard - if (!primaryDefender) { - primaryDefender = adjacentGuards[0]; - newGuards.delete(posKey(primaryDefender)); - newGuards.add(attackKey); - movements.push({ from: primaryDefender, to: attack, type: isBorder(primaryDefender.x, primaryDefender.y) ? 'toBorder' : 'interior' }); - - // If we moved a border guard, shift along border to fill gap - if (isBorder(primaryDefender.x, primaryDefender.y)) { - shiftBorderToFillGap(newGuards, movements, primaryDefender); - } - - return { newGuards, movements }; - } - - // STEP 2: Shift interior guards in a chain toward the attack - // This mimics the diagonal arrows in Figure 10 - - // Move primary defender to attack - newGuards.delete(posKey(primaryDefender)); - newGuards.add(attackKey); - movements.push({ from: primaryDefender, to: attack, type: 'interior' }); - - // Now we need to fill the gap left by the primary defender - // Find another interior guard that can shift into that position - let currentGap = primaryDefender; - let chainLength = 0; - const maxChain = 8; - - while (chainLength < maxChain && isInterior(currentGap.x, currentGap.y)) { - const gapNeighbors = getHexNeighbors(currentGap.x, currentGap.y); - let filler: Position | null = null; - - // Look for an interior guard to shift into the gap - for (const n of gapNeighbors) { - if (isInterior(n.x, n.y) && newGuards.has(posKey(n))) { - // Prefer guards that are further from the target pattern - filler = n; - break; - } - } - - if (filler) { - newGuards.delete(posKey(filler)); - newGuards.add(posKey(currentGap)); - movements.push({ from: filler, to: currentGap, type: 'interior' }); - currentGap = filler; - chainLength++; - } else { - // No interior guard to fill - need border guard (w₃→u₃ movement) - const borderFiller = gapNeighbors.find(n => - isBorder(n.x, n.y) && newGuards.has(posKey(n)) - ); - - if (borderFiller) { - newGuards.delete(posKey(borderFiller)); - newGuards.add(posKey(currentGap)); - movements.push({ from: borderFiller, to: currentGap, type: 'toInterior' }); - - // Now fill the border gap (this creates the need for P₁,₃ or P₂,₄) - shiftBorderToFillGap(newGuards, movements, borderFiller); - } - break; - } - } - - // If chain ended at border (interior guard pushed to border = u₁→w₁) - if (isBorder(currentGap.x, currentGap.y) && !newGuards.has(posKey(currentGap))) { - // The gap is on border, needs to be filled by border shift - shiftBorderToFillGap(newGuards, movements, currentGap); - } - - // NOTE: The paper's strategy restores the original configuration via the border "patchwork" - // (complementary paths) rather than ad-hoc re-domination fixes. - // - // The previous implementation included an extra "scan for undominated vertices and move a nearby guard" - // step, which can look like guards "manifest" and is not part of the Figure 10 defense. + for (let u = 0; u < fromKeys.length; u++) { + const v = pairU[u]; + if (v === -1) return null; + const from = parseKey(fromKeys[u]); + const to = parseKey(toKeys[v]); + if (from.x !== to.x || from.y !== to.y) { + const fromBorder = isBorder(from.x, from.y); + const toBorder = isBorder(to.x, to.y); - // Safety: guards are never created/destroyed—only moved. - if (newGuards.size !== guards.size) return null; + let type: Movement['type'] = 'interior'; + if (fromBorder && !toBorder) type = 'toInterior'; + else if (!fromBorder && toBorder) type = 'toBorder'; + else if (fromBorder && toBorder) type = 'pathShift'; - return { newGuards, movements }; + movements.push({ from, to, type }); + } + } + + // Safety invariants: no creation / no disappearance. + if (target.size !== guards.size) return null; + + // Theorem-backed invariant: target configuration is dominating. + // If our modeling is wrong, fail loudly rather than “teleport”. + if (!isFullyDominated(target)) return null; + + // Additional sanity: everybody moved at most one step. + // (This should be guaranteed by the edge construction.) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _unused = currentResidue; + + return { newGuards: target, movements }; }; -// Shift border guards along the cycle to fill a gap (complementary path shifting) -const shiftBorderToFillGap = ( - guards: Set, - movements: Movement[], - gap: Position -): void => { - if (guards.has(posKey(gap))) return; // No gap - - // Find adjacent border guard to shift into the gap - const gapNeighbors = getHexNeighbors(gap.x, gap.y); - - // Find a border guard that can shift in and has a backup - for (const n of gapNeighbors) { - if (isBorder(n.x, n.y) && guards.has(posKey(n))) { - // Check if this guard has another border neighbor that could back it up - const nNeighbors = getHexNeighbors(n.x, n.y); - const hasBackup = nNeighbors.some(nn => - isBorder(nn.x, nn.y) && - guards.has(posKey(nn)) && - (nn.x !== gap.x || nn.y !== gap.y) - ); - - if (hasBackup) { - guards.delete(posKey(n)); - guards.add(posKey(gap)); - movements.push({ from: n, to: gap, type: 'pathShift' }); - - // Recursively fill the new gap - if (!guards.has(posKey(n))) { - shiftBorderToFillGap(guards, movements, n); - } - return; - } - } - } - - // If no backup found, just do the shift anyway (maintaining domination) - for (const n of gapNeighbors) { - if (isBorder(n.x, n.y) && guards.has(posKey(n))) { - guards.delete(posKey(n)); - guards.add(posKey(gap)); - movements.push({ from: n, to: gap, type: 'pathShift' }); - return; - } - } -}; const EternalDominationGame: React.FC = () => { const [guards, setGuards] = useState>(generateInitialGuards); @@ -349,41 +357,100 @@ const EternalDominationGame: React.FC = () => { const [showInfo, setShowInfo] = useState(false); const [lastMovements, setLastMovements] = useState([]); - const handleCellClick = useCallback((x: number, y: number) => { - const attack: Position = { x, y }; - - if (guards.has(posKey(attack))) { - setMessage("Cannot attack a guarded position."); + const [pendingDefense, setPendingDefense] = useState<{ + endGuards: Set; + movements: Movement[]; + summary: { interiorMoves: number; borderIn: number; borderOut: number; pathShifts: number }; + } | null>(null); + const [activeMoveIdx, setActiveMoveIdx] = useState(-1); + const timerRef = useRef | null>(null); + + const activeMove = + pendingDefense && activeMoveIdx >= 0 && activeMoveIdx < pendingDefense.movements.length + ? pendingDefense.movements[activeMoveIdx] + : null; + + useEffect(() => { + if (!pendingDefense) return; + + // No moves? Apply immediately. + if (pendingDefense.movements.length === 0) { + setGuards(pendingDefense.endGuards); + setPendingDefense(null); + setActiveMoveIdx(-1); return; } - - const result = defendAttack(guards, attack); - - if (result) { - setGuards(result.newGuards); - setLastMovements(result.movements); - setAttackHistory(prev => [...prev, attack]); - setMoveCount(prev => prev + 1); - - const interiorMoves = result.movements.filter(m => m.type === 'interior').length; - const borderIn = result.movements.filter(m => m.type === 'toInterior').length; - const borderOut = result.movements.filter(m => m.type === 'toBorder').length; - const pathShifts = result.movements.filter(m => m.type === 'pathShift').length; - - if (isFullyDominated(result.newGuards)) { - let desc = `Defended! Interior shifts: ${interiorMoves}`; - if (borderIn > 0) desc += `, w→u: ${borderIn}`; - if (pathShifts > 0) desc += `, path shifts: ${pathShifts}`; - setMessage(desc); - } else { - setMessage("Defense incomplete - some vertices undominated!"); - } - } else { - setMessage("Cannot defend - no adjacent guards!"); + + if (activeMoveIdx >= pendingDefense.movements.length) { + // Finish: apply the simultaneous move. + setGuards(pendingDefense.endGuards); + setLastMovements(pendingDefense.movements); + + const { interiorMoves, borderIn, pathShifts } = pendingDefense.summary; + let desc = `Defended! Interior shifts: ${interiorMoves}`; + if (borderIn > 0) desc += `, w→u: ${borderIn}`; + if (pathShifts > 0) desc += `, path shifts: ${pathShifts}`; + setMessage(desc); + + setPendingDefense(null); + setActiveMoveIdx(-1); + return; } - }, [guards]); + + // Show one movement at a time (visualizing a simultaneous sweep). + setLastMovements([pendingDefense.movements[activeMoveIdx]]); + setMessage(`Defending… step ${activeMoveIdx + 1}/${pendingDefense.movements.length}`); + + timerRef.current = setTimeout(() => { + setActiveMoveIdx((i) => i + 1); + }, 220); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [pendingDefense, activeMoveIdx]); + + const handleCellClick = useCallback( + (x: number, y: number) => { + if (pendingDefense) return; // ignore clicks during animation + + const attack: Position = { x, y }; + + if (guards.has(posKey(attack))) { + setMessage("Cannot attack a guarded position."); + return; + } + + const result = defendAttack(guards, attack); + + if (!result) { + setMessage("Cannot defend under the rules (no legal simultaneous move found). "); + return; + } + + const interiorMoves = result.movements.filter((m) => m.type === "interior").length; + const borderIn = result.movements.filter((m) => m.type === "toInterior").length; + const borderOut = result.movements.filter((m) => m.type === "toBorder").length; + const pathShifts = result.movements.filter((m) => m.type === "pathShift").length; + + setAttackHistory((prev) => [...prev, attack]); + setMoveCount((prev) => prev + 1); + + setPendingDefense({ + endGuards: result.newGuards, + movements: result.movements, + summary: { interiorMoves, borderIn, borderOut, pathShifts }, + }); + setActiveMoveIdx(0); + }, + [guards, pendingDefense] + ); const handleReset = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setPendingDefense(null); + setActiveMoveIdx(-1); + setGuards(generateInitialGuards()); setAttackHistory([]); setLastMovements([]); @@ -392,7 +459,7 @@ const EternalDominationGame: React.FC = () => { }, []); const guardCount = guards.size; - const interiorGuards = Array.from(guards).filter(k => { + const interiorGuards = Array.from(guards).filter((k) => { const p = parseKey(k); return isInterior(p.x, p.y); }).length;