Final wave: complete the 6th edition basic set (69/69 cards)

The hard six: AROUND THE CORNER attaches to any LOS attack and bends
the sight line through one intermediate cell. BLIND victims lurch in
die-rolled directions (bumping a wall costs the movement point, per
the card) and their attacks fly wherever the die says — hitting
whoever stands that way, or dissipating. UGLY drives every opponent
in sight fleeing along shortest paths (breadth-first, die-broken ties)
until they cannot see the caster. ILLUSION WALL is per-player reality:
each opponent rolls 50/50 the first time it matters and the wall is
real for believers forever — believers' views render it as a wall,
the caster and those who saw through it get a ghostly dashed line,
and Dispel Creation banishes it. ROTATE SECTOR turns a sector 90
degrees with every wall, door, wizard, treasure, object, firewall and
alteration turning in place (the home star, being the exact center,
never moves); RELOCATE SECTOR slides a sector anywhere that keeps all
sectors adjacent, reassembling the map and recomputing wraparounds.

Client: modifier attachment (Amplify/Add/Extend/Around The Corner),
two-stage relocate, rotation direction toggle, dashed known-illusions.
Every card in the 6th edition basic deck is now implemented.
81 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 20:29:09 -04:00
co-authored by Claude Fable 5
parent 4ab44cc535
commit 1924114b6d
7 changed files with 844 additions and 21 deletions
+63 -2
View File
@@ -22,6 +22,12 @@
let nameInput = $state("");
/** power-run points. */
let runPoints = $state(1);
/** Modifier cards attached to the pending cast. */
let attachedMods = $state<CardInstance[]>([]);
/** relocate-sector: the sector picked up, awaiting its destination. */
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
/** rotate-sector direction. */
let rotateCW = $state(true);
/** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set());
@@ -37,9 +43,11 @@
]);
const CELL_CARDS = new Set([
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
"rotate-sector", "relocate-sector",
]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
const SELF_CARDS = new Set([
"speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell",
"invisible", "shrink", "mist-body",
]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
@@ -51,11 +59,23 @@
function clearSelection() {
selectedCard = null;
attachedNumber = null;
attachedMods = [];
wbDamage = 0;
pendingCellFor = null;
pendingSectorFrom = null;
nameInput = "";
}
/** Fill the modifier fields of a cast command from the attachments. */
function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
for (const m of attachedMods) {
if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
}
}
/** Map a typed card name to its id (case-insensitive). */
function nameToCardId(name: string): string | null {
const wanted = name.trim().toLowerCase();
@@ -85,6 +105,12 @@
wbDamage = numberTotal;
return;
}
if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
: [...attachedMods, card];
return;
}
if (selectedCard?.instanceId === card.instanceId) {
clearSelection();
return;
@@ -100,7 +126,7 @@
// Instant untargeted spells (and stone displays) cast immediately;
// duration self-spells wait so a number card can be attached.
const INSTANT = new Set([
"speed", "pass-through-wall", "reuse-spell",
"speed", "pass-through-wall", "reuse-spell", "ugly",
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
]);
@@ -136,6 +162,26 @@
clearSelection();
return;
}
if (selectedCard?.cardId === "relocate-sector") {
if (!pendingSectorFrom) {
pendingSectorFrom = cell; // first click: the sector to move
return;
}
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
});
clearSelection();
return;
}
if (selectedCard?.cardId === "rotate-sector") {
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
});
clearSelection();
return;
}
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
net.command({
type: "cast", instanceId: selectedCard.instanceId,
@@ -197,6 +243,7 @@
target: { kind: "player", playerId },
};
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
applyMods(cmd);
if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
}
@@ -338,6 +385,20 @@
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
— then click the target wizard
{/if}
{#if attachedMods.length > 0}
[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
— click a square in the sector to rotate
{/if}
{#if selectedCard?.cardId === "relocate-sector"}
{#if pendingSectorFrom}
— now click the destination area
{:else}
— click a square in the sector to move
{/if}
{/if}
{#if selectedCard?.cardId === "power-run"}
<label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label>
<button onclick={castPowerRun}>Run!</button>
+13
View File
@@ -144,6 +144,18 @@
{/if}
{/each}
<!-- illusions YOU know are fake: ghostly dashed lines -->
{#each view.knownIllusionEdges as key (key)}
{@const kind = key.split(":")[0]}
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
{#if kind === "V"}
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
{:else}
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
{/if}
{/each}
<!-- warp openings -->
{#each view.board.warps as w, i (i)}
<text
@@ -208,6 +220,7 @@
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
.treasure { stroke: #111; stroke-width: 1.2; }
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
+12
View File
@@ -56,6 +56,18 @@ function humanize(e: GameEvent): string | null {
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
case "castAroundCorner": return `The spell bends around the corner!`;
case "moveBumped": return `${e.player} blunders into a wall!`;
case "attackMisdirected": return e.newTarget
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
: `${e.attacker}'s blind attack flies off into the darkness.`;
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
case "illusionWallCreated": return `A wall appears... or does it?`;
case "illusionTested": return e.result === "seesThrough"
? `${e.player} sees right through the illusion!`
: `${e.player} is convinced the wall is real.`;
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "trapRedrawnDuringDeal": return null;
case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;