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:
co-authored by
Claude Fable 5
parent
4ab44cc535
commit
1924114b6d
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
Reference in New Issue
Block a user