Relocate Sector gets ground to land on

The card asked for a destination click that did not exist: a sector
relocates onto an EMPTY 5x5 slot, and empty slots have no floor to
click — the board only rendered cells that are part of the maze. Now
lifting a sector outlines the whole 5x5 (not one square) and dashed
green landings appear beyond the maze edge, one per legal slot; the
svg canvas grows to hold them. Clicking a landing completes the cast.

The landings mirror the engine's own relocateSector law — on the
5-grid, non-negative, vacant, every sector still adjacent to at least
one other — verified slot-for-slot against engine ground truth across
2p and 4p boards. Clicking the maze again re-picks the sector, and a
sector with nowhere legal to go says so in the hint bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 20:00:43 -04:00
co-authored by Claude Fable 5
parent 18d3142f95
commit 53c84ec6cb
2 changed files with 121 additions and 15 deletions
+61 -11
View File
@@ -119,6 +119,55 @@
? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
: null,
);
/** The 5x5 sector a picked-up square belongs to (its origin), or null. */
const pendingSectorOrigin = $derived.by(() => {
if (!view || !pendingSectorFrom) return null;
const p = view.board.placements.find(
(p) =>
pendingSectorFrom!.x >= p.origin.x && pendingSectorFrom!.x < p.origin.x + 5 &&
pendingSectorFrom!.y >= p.origin.y && pendingSectorFrom!.y < p.origin.y + 5,
);
return p ? p.origin : null;
});
/** Legal empty slots the lifted sector may land on — mirroring the
* engine's relocateSector checks: on the 5-grid, non-negative, vacant,
* and leaving every sector adjacent to at least one other. */
const relocateGhosts = $derived.by(() => {
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
const origins = view.board.placements.map((p) => p.origin);
const idx = origins.findIndex(
(o) => o.x === pendingSectorOrigin!.x && o.y === pendingSectorOrigin!.y,
);
const adjacent = (a: { x: number; y: number }, b: { x: number; y: number }) =>
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
const seen = new Set<string>();
const out: { x: number; y: number }[] = [];
for (let i = 0; i < origins.length; i++) {
if (i === idx) continue;
for (const [dx, dy] of [[5, 0], [-5, 0], [0, 5], [0, -5]] as const) {
const c = { x: origins[i]!.x + dx, y: origins[i]!.y + dy };
const k = `${c.x},${c.y}`;
if (seen.has(k)) continue;
seen.add(k);
if (c.x < 0 || c.y < 0) continue;
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
const next = origins.map((o, j) => (j === idx ? c : o));
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
}
}
return out;
});
function clickGhostSlot(origin: { x: number; y: number }) {
if (!selectedCard || !pendingSectorFrom) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell: origin }, params: { cell: pendingSectorFrom },
});
clearSelection();
}
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
/** A live moment to interrupt: another wizard acts, no attack is pending. */
const canInterruptNow = $derived(
@@ -364,15 +413,9 @@
return;
}
if (selectedCard?.cardId === "relocate-sector") {
if (!pendingSectorFrom) {
pendingSectorFrom = cell; // first click: the sector to move
return;
}
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
});
clearSelection();
// Any board click (re-)picks the sector; the landing is chosen from the
// dashed ghost slots beyond the maze, since every on-board slot is taken.
pendingSectorFrom = cell;
return;
}
if (selectedCard?.cardId === "boobytrap") {
@@ -1115,7 +1158,10 @@
onPlayerClick={clickPlayer}
onCreatureClick={clickCreature}
onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingSectorFrom}
markedCell={tradeFrom}
markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)}
{litCells}
/>
@@ -1345,7 +1391,11 @@
<span> click the sector</span>
{/if}
{#if selectedCard?.cardId === "relocate-sector"}
<span>{pendingSectorFrom ? "— now the destination area" : "— click the sector to move"}</span>
<span>{pendingSectorFrom
? (relocateGhosts?.length
? "— now tap a dashed landing beside the maze"
: "— that sector has nowhere legal to go; pick another")
: "— click the sector to move"}</span>
{/if}
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>