Expansion wave 2: the magic wand system, plus Deja-Vu
Wands charge on first use from the number card(s) played (Amplify and Add both work when setting charges), spend one charge per use with a hard once-per-turn limit, stay displayed while charged, and crumble to the discard when the last charge goes. They are isolated from their wielder — usable under NO SPELL — and Absorb Spell cannot eat them. BLASTER WAND fires 3-point bolts through the normal counteraction stack; STICKY WAND webs its victim (movement -3 for a turn, +2 to any fire damage, including hotter firewall crossings); SHIFT WAND shoves a wizard one space in any direction straight through stone walls; WARP WAND opens a wall section that snaps back at the end of the turn. DEJA-VU retrieves any discard except a wand. The wands' 5e-era "stick" subtype is renamed "wand" per Eric (the 6e faces say Wand; Exp2 shelf cards keep their period names). Client: charge badges, shove targeting, retrieve-by-name. 101 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
df0675c735
commit
28949bdb7d
@@ -1337,7 +1337,7 @@
|
|||||||
"set": "expansion1",
|
"set": "expansion1",
|
||||||
"cardType": "attack",
|
"cardType": "attack",
|
||||||
"subtypes": [
|
"subtypes": [
|
||||||
"stick",
|
"wand",
|
||||||
"object"
|
"object"
|
||||||
],
|
],
|
||||||
"los": true,
|
"los": true,
|
||||||
@@ -2009,7 +2009,7 @@
|
|||||||
"set": "expansion1",
|
"set": "expansion1",
|
||||||
"cardType": "attack",
|
"cardType": "attack",
|
||||||
"subtypes": [
|
"subtypes": [
|
||||||
"stick",
|
"wand",
|
||||||
"object"
|
"object"
|
||||||
],
|
],
|
||||||
"los": true,
|
"los": true,
|
||||||
@@ -2042,7 +2042,7 @@
|
|||||||
"set": "expansion1",
|
"set": "expansion1",
|
||||||
"cardType": "attack",
|
"cardType": "attack",
|
||||||
"subtypes": [
|
"subtypes": [
|
||||||
"stick",
|
"wand",
|
||||||
"object"
|
"object"
|
||||||
],
|
],
|
||||||
"los": true,
|
"los": true,
|
||||||
@@ -2242,7 +2242,7 @@
|
|||||||
"set": "expansion1",
|
"set": "expansion1",
|
||||||
"cardType": "neutral",
|
"cardType": "neutral",
|
||||||
"subtypes": [
|
"subtypes": [
|
||||||
"stick",
|
"wand",
|
||||||
"object"
|
"object"
|
||||||
],
|
],
|
||||||
"los": true,
|
"los": true,
|
||||||
@@ -2878,7 +2878,7 @@
|
|||||||
"set": "expansion2",
|
"set": "expansion2",
|
||||||
"cardType": "attack",
|
"cardType": "attack",
|
||||||
"subtypes": [
|
"subtypes": [
|
||||||
"stick",
|
"wand",
|
||||||
"object"
|
"object"
|
||||||
],
|
],
|
||||||
"los": true,
|
"los": true,
|
||||||
|
|||||||
+185
-6
@@ -121,6 +121,8 @@ export interface TurnState {
|
|||||||
attackUsed: boolean;
|
attackUsed: boolean;
|
||||||
/** ADRENALINE's second attack, once spent. */
|
/** ADRENALINE's second attack, once spent. */
|
||||||
secondAttackUsed: boolean;
|
secondAttackUsed: boolean;
|
||||||
|
/** Wand instances already used this turn ("maximum of once per turn"). */
|
||||||
|
wandsUsed: string[];
|
||||||
/** SLOW: "his attacks [reduce] to every other turn". */
|
/** SLOW: "his attacks [reduce] to every other turn". */
|
||||||
attackForbidden: boolean;
|
attackForbidden: boolean;
|
||||||
actionsEnded: boolean;
|
actionsEnded: boolean;
|
||||||
@@ -179,6 +181,10 @@ export interface GameState {
|
|||||||
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
|
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
|
||||||
creatures: CreatureState[];
|
creatures: CreatureState[];
|
||||||
nextCreatureId: number;
|
nextCreatureId: number;
|
||||||
|
/** Remaining charges per wand card instance (set on first use). */
|
||||||
|
wandCharges: Record<string, number>;
|
||||||
|
/** WARP WAND: walls opened for this turn only, with their prior state. */
|
||||||
|
tempWarpEdges: { key: string; prior: EdgeState | null }[];
|
||||||
players: PlayerState[];
|
players: PlayerState[];
|
||||||
treasures: TreasureState[];
|
treasures: TreasureState[];
|
||||||
sustained: SustainedEffect[];
|
sustained: SustainedEffect[];
|
||||||
@@ -402,6 +408,14 @@ export type GameEvent =
|
|||||||
| { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number }
|
| { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number }
|
||||||
| { type: "impScorches"; creatureId: string; player: PlayerId }
|
| { type: "impScorches"; creatureId: string; player: PlayerId }
|
||||||
| { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" }
|
| { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" }
|
||||||
|
| { type: "wandCharged"; player: PlayerId; card: CardInstance; charges: number }
|
||||||
|
| { type: "wandUsed"; player: PlayerId; cardId: string; chargesLeft: number }
|
||||||
|
| { type: "wandExhausted"; player: PlayerId; card: CardInstance }
|
||||||
|
| { type: "wallWarpedOpen"; player: PlayerId; edge: { cell: Cell; side: Side } }
|
||||||
|
| { type: "wallsWarpedBack"; count: number }
|
||||||
|
| { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId }
|
||||||
|
| { type: "webbed"; player: PlayerId }
|
||||||
|
| { type: "cardRetrieved"; player: PlayerId; cardId: string }
|
||||||
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
||||||
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||||
| { type: "doorsRelocked"; count: number }
|
| { type: "doorsRelocked"; count: number }
|
||||||
@@ -1340,6 +1354,90 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Expansion #1: magic wands -------------------------------------------
|
||||||
|
"blaster-wand": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
keepInHand: true,
|
||||||
|
// "does 3 points of magical damage per charge"
|
||||||
|
baseDamage: () => 3,
|
||||||
|
},
|
||||||
|
"sticky-wand": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
keepInHand: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
// "reducing movement by 3 ... Lasts one turn. Any fire damage done to
|
||||||
|
// player in webs causes two extra points."
|
||||||
|
attachSustained(ctx.state, ctx.events, "sticky-web", ctx.attacker.id, ctx.defender.id, 1);
|
||||||
|
ctx.events.push({ type: "webbed", player: ctx.defender.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"shift-wand": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
keepInHand: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
validate: (state, cmd) => {
|
||||||
|
const cell = cmd.params?.cell;
|
||||||
|
if (!cell) return "choose the adjacent space to shove them into";
|
||||||
|
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
|
||||||
|
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||||
|
const to = ctx.stack.params!.cell!;
|
||||||
|
const from = ctx.defender.position;
|
||||||
|
const dist = Math.abs(to.x - from.x) + Math.abs(to.y - from.y);
|
||||||
|
if (dist !== 1) return; // must be adjacent to where they stand
|
||||||
|
// "he could be shoved through a stone wall" — walls do not stop it.
|
||||||
|
ctx.defender.position = to;
|
||||||
|
ctx.events.push({ type: "shoved", player: ctx.defender.id, from, to, by: ctx.attacker.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"warp-wand": {
|
||||||
|
kind: "neutral",
|
||||||
|
keepInHand: true,
|
||||||
|
// "makes 1 section (one space long) of wall disappear during your turn,
|
||||||
|
// reappearing at the end of your turn."
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "edge") return "aim the wand at a wall section";
|
||||||
|
const { cell, side } = cmd.target;
|
||||||
|
const key = edgeKey(cell, side);
|
||||||
|
const view = boardView(state);
|
||||||
|
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
|
||||||
|
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||||
|
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||||
|
state.edgeOverrides[key] = "open";
|
||||||
|
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"deja-vu": {
|
||||||
|
kind: "neutral",
|
||||||
|
// "Retrieve any one card from the discard pile (except a MAGIC WAND)."
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const wanted = cmd.params?.cardId;
|
||||||
|
if (!wanted) return "name the card to retrieve";
|
||||||
|
if (["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(wanted)) {
|
||||||
|
return "deja-vu cannot retrieve a magic wand";
|
||||||
|
}
|
||||||
|
for (let i = state.discard.length - 1; i >= 0; i--) {
|
||||||
|
if (state.discard[i]!.cardId === wanted) {
|
||||||
|
const [card] = state.discard.splice(i, 1);
|
||||||
|
caster.hand.push(card!);
|
||||||
|
events.push({ type: "cardRetrieved", player: caster.id, cardId: wanted });
|
||||||
|
if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "that card is not in the discard pile";
|
||||||
|
},
|
||||||
|
},
|
||||||
"reuse-spell": {
|
"reuse-spell": {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
// "You may retrieve any spell you use immediately after you use it (but
|
// "You may retrieve any spell you use immediately after you use it (but
|
||||||
@@ -1995,6 +2093,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
illusionWalls: {},
|
illusionWalls: {},
|
||||||
creatures: [],
|
creatures: [],
|
||||||
nextCreatureId: 1,
|
nextCreatureId: 1,
|
||||||
|
wandCharges: {},
|
||||||
|
tempWarpEdges: [],
|
||||||
players,
|
players,
|
||||||
treasures,
|
treasures,
|
||||||
sustained: [],
|
sustained: [],
|
||||||
@@ -2009,6 +2109,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
numberPlayedForMovement: false,
|
numberPlayedForMovement: false,
|
||||||
attackUsed: false,
|
attackUsed: false,
|
||||||
secondAttackUsed: false,
|
secondAttackUsed: false,
|
||||||
|
wandsUsed: [],
|
||||||
attackForbidden: false,
|
attackForbidden: false,
|
||||||
actionsEnded: false,
|
actionsEnded: false,
|
||||||
},
|
},
|
||||||
@@ -2190,7 +2291,8 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
|
|
||||||
if (crossedFirewall) {
|
if (crossedFirewall) {
|
||||||
events.push({ type: "firewallBurned", player: p.id });
|
events.push({ type: "firewallBurned", player: p.id });
|
||||||
applyDamage(state, events, p, 4, "wall of fire", null);
|
const webbed = sustainedOn(state, p.id, "sticky-web").length > 0;
|
||||||
|
applyDamage(state, events, p, webbed ? 6 : 4, "wall of fire", null);
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2426,8 +2528,13 @@ function consumeCast(
|
|||||||
mods: CastConsumables,
|
mods: CastConsumables,
|
||||||
keepInHand: boolean,
|
keepInHand: boolean,
|
||||||
): void {
|
): void {
|
||||||
|
// (Wand cards manage their own lifetime via charges — callers pass
|
||||||
|
// keepInHand=true for them and never discard here.)
|
||||||
if (keepInHand) {
|
if (keepInHand) {
|
||||||
if (!caster.displayed.includes(card.instanceId)) caster.displayed.push(card.instanceId);
|
if (caster.hand.some((c) => c.instanceId === card.instanceId) &&
|
||||||
|
!caster.displayed.includes(card.instanceId)) {
|
||||||
|
caster.displayed.push(card.instanceId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
takeFromHand(caster, card.instanceId);
|
takeFromHand(caster, card.instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
@@ -2458,13 +2565,23 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
return err(`${def.name} is already displayed`);
|
return err(`${def.name} is already displayed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magic wands: charged on first use by the number card(s) played; one
|
||||||
|
// charge per use, one use per turn; discarded when the last charge goes.
|
||||||
|
const WANDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
|
||||||
|
const isWand = WANDS.has(inHand.cardId);
|
||||||
|
if (isWand && state.turn.wandsUsed.includes(inHand.instanceId)) {
|
||||||
|
return err("any wand operates a maximum of once per turn");
|
||||||
|
}
|
||||||
|
|
||||||
// "Some cards involve physical actions, like picking locks, removing
|
// "Some cards involve physical actions, like picking locks, removing
|
||||||
// locks, jamming locks, and throwing daggers. These are not spells."
|
// locks, jamming locks, and throwing daggers. These are not spells."
|
||||||
// (PICK LOCK's 6e face: "This is not a spell.")
|
// (PICK LOCK's 6e face: "This is not a spell.")
|
||||||
const NOT_SPELLS = new Set([
|
const NOT_SPELLS = new Set([
|
||||||
"pick-lock", "master-key", "jam-lock", "remove-lock", "dagger", "large-rock",
|
"pick-lock", "master-key", "jam-lock", "remove-lock", "dagger", "large-rock",
|
||||||
]);
|
]);
|
||||||
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId);
|
// "This isolation of the wand from you allows you to use it even with
|
||||||
|
// NO SPELL cast on you."
|
||||||
|
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
|
||||||
if (isSpell) {
|
if (isSpell) {
|
||||||
const castBlock = castingBlocked(state, caster.id);
|
const castBlock = castingBlocked(state, caster.id);
|
||||||
if (castBlock) return err(castBlock);
|
if (castBlock) return err(castBlock);
|
||||||
@@ -2475,6 +2592,32 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
const mods = gatherModifiers(caster, cmd);
|
const mods = gatherModifiers(caster, cmd);
|
||||||
if (typeof mods === "string") return err(mods);
|
if (typeof mods === "string") return err(mods);
|
||||||
|
|
||||||
|
/** Set charges on first use, spend one, discard the wand when empty. */
|
||||||
|
const spendWandCharge = (st: GameState, wielder: PlayerState, events: GameEvent[]): string | null => {
|
||||||
|
if (!isWand) return null;
|
||||||
|
let charges = st.wandCharges[inHand.instanceId];
|
||||||
|
if (charges === undefined) {
|
||||||
|
if (mods.numbers.length === 0) return "a wand's first use needs a number card to set its charges";
|
||||||
|
charges = mods.magnitude.power; // AMPLIFY and ADD both work here
|
||||||
|
st.wandCharges[inHand.instanceId] = charges;
|
||||||
|
if (!wielder.displayed.includes(inHand.instanceId)) wielder.displayed.push(inHand.instanceId);
|
||||||
|
events.push({ type: "wandCharged", player: wielder.id, card: inHand, charges });
|
||||||
|
}
|
||||||
|
charges -= 1;
|
||||||
|
st.turn.wandsUsed.push(inHand.instanceId);
|
||||||
|
if (charges <= 0) {
|
||||||
|
delete st.wandCharges[inHand.instanceId];
|
||||||
|
takeFromHand(wielder, inHand.instanceId);
|
||||||
|
st.discard.push(inHand);
|
||||||
|
events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: 0 });
|
||||||
|
events.push({ type: "wandExhausted", player: wielder.id, card: inHand });
|
||||||
|
} else {
|
||||||
|
st.wandCharges[inHand.instanceId] = charges;
|
||||||
|
events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: charges });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
if (effect.kind === "attack") {
|
if (effect.kind === "attack") {
|
||||||
const pre = attackPreconditions(state);
|
const pre = attackPreconditions(state);
|
||||||
if (pre) return err(pre);
|
if (pre) return err(pre);
|
||||||
@@ -2489,11 +2632,16 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) {
|
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) {
|
||||||
return err("no line of sight to the creature");
|
return err("no line of sight to the creature");
|
||||||
}
|
}
|
||||||
|
const wandEvents: GameEvent[] = [];
|
||||||
|
{
|
||||||
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
|
if (werr) return err(werr);
|
||||||
|
}
|
||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
state.turn.attackUsed = true;
|
state.turn.attackUsed = true;
|
||||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
const events: GameEvent[] = [{
|
const events: GameEvent[] = [...wandEvents, {
|
||||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||||
from: caster.position, target: null, targetCell: creature.position,
|
from: caster.position, target: null, targetCell: creature.position,
|
||||||
@@ -2544,6 +2692,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wandEvents: GameEvent[] = [];
|
||||||
|
{
|
||||||
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
|
if (werr) return err(werr);
|
||||||
|
}
|
||||||
|
|
||||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
||||||
// go intended distance" — if the die disagrees with the true direction,
|
// go intended distance" — if the die disagrees with the true direction,
|
||||||
// the spell hits whoever lies that way, or dissipates.
|
// the spell hits whoever lies that way, or dissipates.
|
||||||
@@ -2569,7 +2723,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
state.turn.attackUsed = true;
|
state.turn.attackUsed = true;
|
||||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
const missEvents: GameEvent[] = [...preEvents, {
|
const missEvents: GameEvent[] = [...wandEvents, ...preEvents, {
|
||||||
type: "attackMisdirected", attacker: caster.id, intended: target.id,
|
type: "attackMisdirected", attacker: caster.id, intended: target.id,
|
||||||
rolledDirection: rolled, newTarget: along?.id ?? null,
|
rolledDirection: rolled, newTarget: along?.id ?? null,
|
||||||
}];
|
}];
|
||||||
@@ -2613,7 +2767,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
waitingOn: target.id,
|
waitingOn: target.id,
|
||||||
};
|
};
|
||||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
const events: GameEvent[] = [...preEvents];
|
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||||
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
|
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
|
||||||
events.push({
|
events.push({
|
||||||
type: "spellCast",
|
type: "spellCast",
|
||||||
@@ -2650,6 +2804,10 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
);
|
);
|
||||||
if (problem) return err(problem);
|
if (problem) return err(problem);
|
||||||
|
|
||||||
|
if (isWand) {
|
||||||
|
const werr = spendWandCharge(state, caster, events);
|
||||||
|
if (werr) return err(werr);
|
||||||
|
}
|
||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
if (effect.keepInHand) {
|
if (effect.keepInHand) {
|
||||||
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
|
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
|
||||||
@@ -2678,6 +2836,9 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
|||||||
if (playerId === stack.defenderId) {
|
if (playerId === stack.defenderId) {
|
||||||
if (card.cardId === "absorb-spell") {
|
if (card.cardId === "absorb-spell") {
|
||||||
if (stack.kind !== "spell") return err("absorb spell only works against spells");
|
if (stack.kind !== "spell") return err("absorb spell only works against spells");
|
||||||
|
if (stack.attackCard && ["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(stack.attackCard.cardId)) {
|
||||||
|
return err("Absorb Spell has no effect on magic wands");
|
||||||
|
}
|
||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
const attackCard = stack.attackCard!;
|
const attackCard = stack.attackCard!;
|
||||||
@@ -2809,6 +2970,10 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1
|
let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1
|
||||||
|
// Webs burn: "Any fire damage done to player in webs causes two extra points."
|
||||||
|
if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) {
|
||||||
|
base += 2;
|
||||||
|
}
|
||||||
// STONE DEAD: number x the stones the defender carries.
|
// STONE DEAD: number x the stones the defender carries.
|
||||||
if (attackId === "stone-dead") {
|
if (attackId === "stone-dead") {
|
||||||
base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length;
|
base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length;
|
||||||
@@ -3233,6 +3398,9 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
let allowance = BASE_MOVEMENT;
|
let allowance = BASE_MOVEMENT;
|
||||||
if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2);
|
if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2);
|
||||||
if (displays(player, "speedstone")) allowance += 1;
|
if (displays(player, "speedstone")) allowance += 1;
|
||||||
|
// STICKY WAND webs: "reducing movement by 3 (enemy can still use NUMBER
|
||||||
|
// cards for additional movement)."
|
||||||
|
allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length);
|
||||||
const slows = sustainedOn(state, player.id, "slow");
|
const slows = sustainedOn(state, player.id, "slow");
|
||||||
if (slows.length > 0) allowance = 1;
|
if (slows.length > 0) allowance = 1;
|
||||||
|
|
||||||
@@ -3253,6 +3421,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
numberPlayedForMovement: false,
|
numberPlayedForMovement: false,
|
||||||
attackUsed: false,
|
attackUsed: false,
|
||||||
secondAttackUsed: false,
|
secondAttackUsed: false,
|
||||||
|
wandsUsed: [],
|
||||||
attackForbidden,
|
attackForbidden,
|
||||||
actionsEnded: false,
|
actionsEnded: false,
|
||||||
};
|
};
|
||||||
@@ -3296,6 +3465,16 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WARP WAND: opened walls reappear at the end of the turn.
|
||||||
|
if (state.tempWarpEdges.length > 0) {
|
||||||
|
for (const t of state.tempWarpEdges) {
|
||||||
|
if (t.prior === null) delete state.edgeOverrides[t.key];
|
||||||
|
else state.edgeOverrides[t.key] = t.prior;
|
||||||
|
}
|
||||||
|
events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length });
|
||||||
|
state.tempWarpEdges = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Doors unlocked this turn relock ("the door will relock behind you").
|
// Doors unlocked this turn relock ("the door will relock behind you").
|
||||||
if (state.openDoorEdges.length > 0) {
|
if (state.openDoorEdges.length > 0) {
|
||||||
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
|
events.push({ type: "doorsRelocked", count: state.openDoorEdges.length });
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface GameView {
|
|||||||
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
||||||
knownIllusionEdges: string[];
|
knownIllusionEdges: string[];
|
||||||
creatures: CreatureState[];
|
creatures: CreatureState[];
|
||||||
|
/** Charges left on displayed wands (public), by card instance id. */
|
||||||
|
wandCharges: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||||
@@ -106,5 +108,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
openDoorEdges: [...state.openDoorEdges],
|
openDoorEdges: [...state.openDoorEdges],
|
||||||
knownIllusionEdges,
|
knownIllusionEdges,
|
||||||
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
||||||
|
wandCharges: { ...state.wandCharges },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
activePlayer,
|
||||||
|
createGame,
|
||||||
|
boardView,
|
||||||
|
type Command,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
} from "../src/game";
|
||||||
|
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
|
import type { CardInstance } from "../src/cards";
|
||||||
|
|
||||||
|
function newGame(seed = 42) {
|
||||||
|
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
||||||
|
const result = applyCommand(state, player, command);
|
||||||
|
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
||||||
|
return result.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||||
|
const p = state.players.find((p) => p.id === playerId)!;
|
||||||
|
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||||
|
p.hand[slot] = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRound2(state: GameState): GameState {
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
defender.position = { ...attacker.position };
|
||||||
|
return { attacker: attacker.id, defender: defender.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("magic wands", () => {
|
||||||
|
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const wand = giveCard(state, attacker, "blaster-wand");
|
||||||
|
giveCard(state, attacker, "number-2", "N", 1);
|
||||||
|
|
||||||
|
// First use without a number card is refused.
|
||||||
|
expect(applyCommand(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
|
||||||
|
}).ok).toBe(false);
|
||||||
|
|
||||||
|
// Charged with a 2: fires (3 damage), one charge left, wand displayed.
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||||
|
expect(state.wandCharges[wand.instanceId]).toBe(1);
|
||||||
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
|
expect(a.displayed).toContain(wand.instanceId);
|
||||||
|
|
||||||
|
// A second use the same turn is refused ("once per turn") — even with
|
||||||
|
// adrenaline-style second attacks it is the WAND that is limited.
|
||||||
|
expect(applyCommand(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
|
||||||
|
}).ok).toBe(false);
|
||||||
|
|
||||||
|
// Next turn: the last charge fires and the wand crumbles to the discard.
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(9);
|
||||||
|
const a2 = state.players.find((p) => p.id === attacker)!;
|
||||||
|
expect(a2.hand.some((c) => c.instanceId === wand.instanceId)).toBe(false);
|
||||||
|
expect(state.wandCharges[wand.instanceId]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wands fire even under NO SPELL, and Absorb Spell cannot eat them", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
// Defender silences the attacker first: give defender the first move.
|
||||||
|
state.sustained.push({
|
||||||
|
id: "fx-ns", cardId: "no-spell", casterId: defender, targetId: attacker,
|
||||||
|
remainingTurns: 3, data: {},
|
||||||
|
});
|
||||||
|
const wand = giveCard(state, attacker, "blaster-wand");
|
||||||
|
giveCard(state, attacker, "number-2", "N", 1);
|
||||||
|
giveCard(state, defender, "absorb-spell", "AS", 0);
|
||||||
|
|
||||||
|
// Fireball is silenced...
|
||||||
|
const fb = giveCard(state, attacker, "fireball", "F", 2);
|
||||||
|
expect(applyCommand(state, attacker, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||||
|
}).ok).toBe(false);
|
||||||
|
// ...but the wand is isolated from the wizard.
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
// Absorb Spell has no effect on magic wands.
|
||||||
|
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AS" }).ok).toBe(false);
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sticky wand webs the target: movement -3 and fire burns hotter", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const wand = giveCard(state, attacker, "sticky-wand");
|
||||||
|
giveCard(state, attacker, "number-3", "N", 1);
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-3#N"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
// Webbed: base 3 movement drops to 0.
|
||||||
|
expect(activePlayer(state).id).toBe(defender);
|
||||||
|
expect(state.turn.movementAllowance).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shift wand shoves a wizard through a wall", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
// Find a walled direction beside the defender with a real cell behind it.
|
||||||
|
const view = boardView(state);
|
||||||
|
let through: Cell | null = null;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const k = edgeKey(d.position, side);
|
||||||
|
const dest = { x: d.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||||
|
y: d.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||||
|
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { through = dest; break; }
|
||||||
|
}
|
||||||
|
if (!through) return; // no adjacent wall on this seed; covered elsewhere
|
||||||
|
const wand = giveCard(state, attacker, "shift-wand");
|
||||||
|
giveCard(state, attacker, "number-2", "N", 1);
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
params: { cell: through },
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(through));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warp wand opens a wall for one turn only", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const view = boardView(state);
|
||||||
|
let edge: { cell: Cell; side: Side } | null = null;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const k = edgeKey(me.position, side);
|
||||||
|
const dest = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||||
|
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||||
|
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) {
|
||||||
|
edge = { cell: me.position, side };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!edge) return;
|
||||||
|
const key = edgeKey(edge.cell, edge.side);
|
||||||
|
const wand = giveCard(state, me.id, "warp-wand");
|
||||||
|
giveCard(state, me.id, "number-2", "N", 1);
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
target: { kind: "edge", cell: edge.cell, side: edge.side },
|
||||||
|
});
|
||||||
|
// Open now: walk through.
|
||||||
|
state = must(state, me.id, { type: "move", direction: edge.side });
|
||||||
|
// At end of turn the wall reappears.
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
expect(boardView(state).edges[key]).toBe("wall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deja-vu retrieves from the discard, but never a wand", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
state.discard.push({ instanceId: "fireball#D", cardId: "fireball" });
|
||||||
|
state.discard.push({ instanceId: "blaster-wand#D", cardId: "blaster-wand" });
|
||||||
|
const dv = giveCard(state, me.id, "deja-vu");
|
||||||
|
expect(applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: dv.instanceId, params: { cardId: "blaster-wand" },
|
||||||
|
}).ok).toBe(false);
|
||||||
|
state = must(state, me.id, { type: "cast", instanceId: dv.instanceId, params: { cardId: "fireball" } });
|
||||||
|
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "fireball")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
const EDGE_CARDS = new Set([
|
const EDGE_CARDS = new Set([
|
||||||
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
|
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
|
||||||
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
||||||
|
"warp-wand",
|
||||||
]);
|
]);
|
||||||
const CELL_CARDS = new Set([
|
const CELL_CARDS = new Set([
|
||||||
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
|
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
const SELF_CARDS = new Set([
|
const SELF_CARDS = new Set([
|
||||||
"invisible", "shrink", "mist-body",
|
"invisible", "shrink", "mist-body",
|
||||||
]);
|
]);
|
||||||
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
|
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu"]);
|
||||||
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
||||||
const cellSelectMode = $derived(
|
const cellSelectMode = $derived(
|
||||||
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
||||||
@@ -284,7 +285,7 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard.cardId === "teleport-opponent") {
|
if (selectedCard.cardId === "teleport-opponent" || selectedCard.cardId === "shift-wand") {
|
||||||
pendingCellFor = playerId;
|
pendingCellFor = playerId;
|
||||||
return; // next: click the destination cell
|
return; // next: click the destination cell
|
||||||
}
|
}
|
||||||
@@ -437,8 +438,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
||||||
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
|
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
|
||||||
|
{#if selectedCard.cardId === "deja-vu"}
|
||||||
|
<button onclick={() => {
|
||||||
|
const id = nameToCardId(nameInput);
|
||||||
|
if (id && selectedCard) {
|
||||||
|
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}}>Retrieve</button>
|
||||||
|
{:else}
|
||||||
— then click the target wizard
|
— then click the target wizard
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if attachedMods.length > 0}
|
{#if attachedMods.length > 0}
|
||||||
[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]
|
[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]
|
||||||
{/if}
|
{/if}
|
||||||
@@ -486,7 +497,9 @@
|
|||||||
<span class="card-type">{def.cardType}</span>
|
<span class="card-type">{def.cardType}</span>
|
||||||
<span class="card-name">{def.name}</span>
|
<span class="card-name">{def.name}</span>
|
||||||
{#if view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId)}
|
{#if view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId)}
|
||||||
<span class="displayed-badge">displayed</span>
|
<span class="displayed-badge">
|
||||||
|
displayed{view.wandCharges[card.instanceId] != null ? ` · ${view.wandCharges[card.instanceId]} ⚡` : ""}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user