Refine Eternal Defense moves
Improve Eternal Domination defense to follow exact Figure 10 protocol with step-by-step, non-random guard movements on a fixed 10x12 hex grid; ensure no guard manifests unexpectedly by enforcing simultaneous-move calculation and explicit movement sequencing, including border patchwork shifts and colored movement overlays. Clarify that only existing guards move, provide per-move visualization, and update UI messaging accordingly. X-Lovable-Edit-ID: edt-8827e02e-a2a2-4c4d-a46c-de0153a748b8
This commit is contained in:
commit
e327f076b3
1 changed files with 273 additions and 206 deletions
|
|
@ -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<string>): Position[] => {
|
||||
const queue: Position[][] = [[from]];
|
||||
const visited = new Set<string>([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<string> => {
|
||||
const pattern = new Set<string>();
|
||||
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<string>, attack: Position): {
|
||||
newGuards: Set<string>;
|
||||
const getAllBorderKeys = (): Set<string> => {
|
||||
const border = new Set<string>();
|
||||
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<string> => {
|
||||
const target = new Set<string>(BORDER_KEYS);
|
||||
for (const k of getInteriorPatternByResidue(r)) target.add(k);
|
||||
return target;
|
||||
};
|
||||
|
||||
const inferCurrentInteriorResidue = (guards: Set<string>): 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<number>(leftSize).fill(NIL);
|
||||
const pairV = new Array<number>(rightSize).fill(NIL);
|
||||
const dist = new Array<number>(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<string>,
|
||||
attack: Position
|
||||
): {
|
||||
newGuards: Set<string>;
|
||||
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<string, number>();
|
||||
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<string>,
|
||||
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<Set<string>>(generateInitialGuards);
|
||||
|
|
@ -349,41 +357,100 @@ const EternalDominationGame: React.FC = () => {
|
|||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [lastMovements, setLastMovements] = useState<Movement[]>([]);
|
||||
|
||||
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<string>;
|
||||
movements: Movement[];
|
||||
summary: { interiorMoves: number; borderIn: number; borderOut: number; pathShifts: number };
|
||||
} | null>(null);
|
||||
const [activeMoveIdx, setActiveMoveIdx] = useState<number>(-1);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue