Card wave 2: durations, doors, teleports, modifiers; harden room auth
Duration system: sustained effects expire at the start of the caster's turns; SLOW (movement 1, no number cards, attack every other turn), NO SPELL, MEDUSA (paralysis + damage immunity), INVISIBLE (1-in-4 hit roll), SHRINK (50% miss, movement 2). Doors: PICK LOCK and MASTER KEY (displayed, reusable) unlock adjacent doors until end of turn, REMOVE LOCK is permanent, JAM LOCK seals a door for everyone. Movement: TELEPORT (4 spaces through walls, ends movement), PASS THROUGH WALL charges, POWER RUN (life for spaces), SWAP (consumes movement), GO AWAY (knockback + lost turn), TELEPORT OPPONENT. Card warfare: CARD ERASURE (named), THOUGHT-STEAL (2 random via seeded RNG), TELEPATH (private hand reveal), POWER DRAIN (damage feeds the caster), SUDDEN DEATH, STONE DEAD, WIZARDBLADE (same-square, number-powered, stays displayed). Cast modifiers: AMPLIFY doubles power/duration (stackable x2), ADD permits two number cards, EXTEND doubles duration; REVERSE heals instead of harms but keeps secondary effects. Counters now also halve durations (BLUNT) and split them (REFLECTION). Security (from review findings): room codes and game seeds now come from node:crypto, and every seat gets a secret token — reclaiming a name in a room requires its token, closing the impersonation hole. 29 cards implemented; 51 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
36b3ffe9a6
commit
67743dd17e
+816
-178
File diff suppressed because it is too large
Load Diff
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
|
|||||||
it("unimplemented cards refuse to cast with a clear error", () => {
|
it("unimplemented cards refuse to cast with a clear error", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
const card = giveCard(state, caster.id, "medusa");
|
const card = giveCard(state, caster.id, "ugly");
|
||||||
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
activePlayer,
|
||||||
|
createGame,
|
||||||
|
boardView,
|
||||||
|
sustainedOn,
|
||||||
|
type Command,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
} from "../src/game";
|
||||||
|
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
|
||||||
|
import type { CardInstance } from "../src/cards";
|
||||||
|
|
||||||
|
function newGame(seed = 42) {
|
||||||
|
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function castAt(
|
||||||
|
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
|
||||||
|
extra: Partial<Extract<Command, { type: "cast" }>> = {},
|
||||||
|
): GameState {
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: card.instanceId,
|
||||||
|
target: { kind: "player", playerId: defender }, ...extra,
|
||||||
|
});
|
||||||
|
return must(state, defender, { type: "pass" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("duration spells", () => {
|
||||||
|
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const slow = giveCard(state, attacker, "slow");
|
||||||
|
giveCard(state, attacker, "number-4", "N", 1);
|
||||||
|
state = castAt(state, attacker, defender, slow, { numberInstanceIds: ["number-4#N"] });
|
||||||
|
expect(sustainedOn(state, defender, "slow").length).toBe(1);
|
||||||
|
|
||||||
|
// Defender's turn: 1 movement, no number cards, no attack (1st slowed turn).
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
expect(activePlayer(state).id).toBe(defender);
|
||||||
|
expect(state.turn.movementAllowance).toBe(1);
|
||||||
|
expect(state.turn.attackForbidden).toBe(true);
|
||||||
|
const num = giveCard(state, defender, "number-3", "M", 0);
|
||||||
|
expect(applyCommand(state, defender, { type: "playNumberForMovement", instanceId: num.instanceId }).ok).toBe(false);
|
||||||
|
|
||||||
|
// Second slowed turn: attack allowed again.
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
expect(activePlayer(state).id).toBe(defender);
|
||||||
|
expect(state.turn.attackForbidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duration spells expire at the start of the caster's turn", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const ns = giveCard(state, attacker, "no-spell");
|
||||||
|
// Duration 1 (no number card).
|
||||||
|
state = castAt(state, attacker, defender, ns);
|
||||||
|
expect(sustainedOn(state, defender, "no-spell").length).toBe(1);
|
||||||
|
|
||||||
|
// Defender cannot cast while it lasts.
|
||||||
|
const fb = giveCard(state, defender, "fireball", "F", 1);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
const refused = applyCommand(state, defender, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
|
||||||
|
// Back to the caster: the spell expires at their turn start.
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
expect(sustainedOn(state, defender, "no-spell").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("medusa paralyzes and grants damage immunity", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const med = giveCard(state, attacker, "medusa");
|
||||||
|
giveCard(state, attacker, "number-2", "N", 1);
|
||||||
|
// Duration 2 so it survives past the caster's next turn start.
|
||||||
|
state = castAt(state, attacker, defender, med, { numberInstanceIds: ["number-2#N"] });
|
||||||
|
|
||||||
|
// Defender is immune to damage while paralyzed.
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
expect(activePlayer(state).id).toBe(defender);
|
||||||
|
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
||||||
|
const counter = giveCard(state, defender, "blunt", "B", 0);
|
||||||
|
void counter;
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
|
||||||
|
// Attacker punches the frozen defender: no damage.
|
||||||
|
state = must(state, attacker, { type: "punch", targetId: defender });
|
||||||
|
// Defender cannot counteract under medusa; they pass.
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invisible makes attacks miss 3 times out of 4 (deterministic per seed)", () => {
|
||||||
|
let hits = 0, misses = 0;
|
||||||
|
for (let seed = 1; seed <= 12; seed++) {
|
||||||
|
let { state } = newGame(seed);
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const inv = giveCard(state, defender, "invisible", "I", 0);
|
||||||
|
// Defender casts invisible on their own turn first — rearrange: give
|
||||||
|
// attacker the attack, let defender cast invisible when active.
|
||||||
|
// Simpler: attach directly via a cast from the defender's turn.
|
||||||
|
state.players.find((p) => p.id === defender)!.hand[0] = inv;
|
||||||
|
// Attacker ends turn; defender casts invisible; attacker attacks.
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, defender, { type: "cast", instanceId: inv.instanceId });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||||
|
state = castAt(state, attacker, defender, fb);
|
||||||
|
const life = state.players.find((p) => p.id === defender)!.life;
|
||||||
|
if (life < 15) hits++;
|
||||||
|
else misses++;
|
||||||
|
}
|
||||||
|
expect(hits + misses).toBe(12);
|
||||||
|
expect(misses).toBeGreaterThan(hits); // 75% miss rate over 12 seeds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doors", () => {
|
||||||
|
function findDoor(state: GameState): { cell: { x: number; y: number }; side: Side } {
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const [key, edgeState] of Object.entries(view.edges)) {
|
||||||
|
if (edgeState !== "door") continue;
|
||||||
|
const [kind, coords] = key.split(":") as [string, string];
|
||||||
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||||
|
return kind === "V" ? { cell: { x, y }, side: "E" } : { cell: { x, y }, side: "S" };
|
||||||
|
}
|
||||||
|
throw new Error("no door on this board");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pick lock opens an adjacent door until end of turn", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const door = findDoor(state);
|
||||||
|
me.position = { ...door.cell };
|
||||||
|
const other = neighbor(door.cell, door.side);
|
||||||
|
const dir = door.side;
|
||||||
|
|
||||||
|
// Locked: blocked.
|
||||||
|
expect(applyCommand(state, me.id, { type: "move", direction: dir }).ok).toBe(false);
|
||||||
|
|
||||||
|
const pl = giveCard(state, me.id, "pick-lock");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: pl.instanceId,
|
||||||
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
||||||
|
});
|
||||||
|
state = must(state, me.id, { type: "move", direction: dir });
|
||||||
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
||||||
|
|
||||||
|
// Relocks when the turn ends.
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
expect(state.openDoorEdges.length).toBe(0);
|
||||||
|
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove lock is permanent; jam lock seals the door for everyone", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const door = findDoor(state);
|
||||||
|
me.position = { ...door.cell };
|
||||||
|
const key = edgeKey(door.cell, door.side);
|
||||||
|
|
||||||
|
const rl = giveCard(state, me.id, "remove-lock");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: rl.instanceId,
|
||||||
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
||||||
|
});
|
||||||
|
expect(state.doorStates[key]).toBe("removed");
|
||||||
|
state = must(state, me.id, { type: "move", direction: door.side });
|
||||||
|
|
||||||
|
// Jamming a removed lock is refused.
|
||||||
|
const jl = giveCard(state, me.id, "jam-lock");
|
||||||
|
const refused = applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: jl.instanceId,
|
||||||
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("movement spells", () => {
|
||||||
|
it("teleport jumps up to four spaces through walls and ends movement", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const from = me.position;
|
||||||
|
const tp = giveCard(state, me.id, "teleport");
|
||||||
|
const far = { x: from.x, y: from.y >= 4 ? from.y - 4 : from.y + 4 };
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: far },
|
||||||
|
});
|
||||||
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(far));
|
||||||
|
expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teleport refuses jumps beyond four spaces", () => {
|
||||||
|
const { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const tp = giveCard(state, me.id, "teleport");
|
||||||
|
const tooFar = { x: me.position.x, y: me.position.y >= 5 ? me.position.y - 5 : me.position.y + 5 };
|
||||||
|
const result = applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: tooFar },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swap trades places and consumes movement", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
const aPos = { ...attacker.position };
|
||||||
|
const bPos = { ...defender.position };
|
||||||
|
const sw = giveCard(state, attacker.id, "swap");
|
||||||
|
state = castAt(state, attacker.id, defender.id, sw);
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === attacker.id)!.position)).toBe(cellKey(bPos));
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === defender.id)!.position)).toBe(cellKey(aPos));
|
||||||
|
expect(state.turn.movementUsed).toBe(state.turn.movementAllowance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("power run trades life for movement", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const pr = giveCard(state, me.id, "power-run");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: pr.instanceId, params: { points: 3 },
|
||||||
|
});
|
||||||
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||||
|
expect(state.turn.movementAllowance).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pass through wall grants a one-wall step", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
// Find a direction blocked by a wall with a real cell behind it.
|
||||||
|
const view = boardView(state);
|
||||||
|
let dir: Side | null = null;
|
||||||
|
for (const side of ["N", "S", "E", "W"] as Side[]) {
|
||||||
|
const k = edgeKey(me.position, side);
|
||||||
|
if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(me.position, side))]) {
|
||||||
|
dir = side;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dir) return; // no adjacent wall on this seed's home; fine
|
||||||
|
const ptw = giveCard(state, me.id, "pass-through-wall");
|
||||||
|
state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId });
|
||||||
|
state = must(state, me.id, { type: "move", direction: dir });
|
||||||
|
expect(state.players.find((p) => p.id === me.id)!.passWallCharges).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("card warfare", () => {
|
||||||
|
it("card erasure discards a named card; thought steal takes two at random", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
giveCard(state, defender, "fireball", "V", 0);
|
||||||
|
const ce = giveCard(state, attacker, "card-erasure");
|
||||||
|
state = castAt(state, attacker, defender, ce, { params: { cardId: "fireball" } });
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
expect(d.hand.some((c) => c.cardId === "fireball")).toBe(false);
|
||||||
|
expect(d.hand.length).toBe(6);
|
||||||
|
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const ts = giveCard(state, attacker, "thought-steal");
|
||||||
|
const handBefore = state.players.find((p) => p.id === attacker)!.hand.length;
|
||||||
|
state = castAt(state, attacker, defender, ts);
|
||||||
|
// -1 (thought steal cast) +2 stolen
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(handBefore + 1);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.hand.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("power drain transfers life; sudden death does 10", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const pd = giveCard(state, attacker, "power-drain");
|
||||||
|
giveCard(state, attacker, "number-4", "N", 1);
|
||||||
|
state = castAt(state, attacker, defender, pd, { numberInstanceIds: ["number-4#N"] });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(11);
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
|
||||||
|
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const sd = giveCard(state, attacker, "sudden-death");
|
||||||
|
state = castAt(state, attacker, defender, sd);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wizardblade needs the same square, uses a number card, and stays displayed", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
const wb = giveCard(state, attacker.id, "wizardblade");
|
||||||
|
giveCard(state, attacker.id, "number-3", "N", 1);
|
||||||
|
|
||||||
|
// Not same square: refused.
|
||||||
|
defender.position = { x: attacker.position.x, y: attacker.position.y === 0 ? 1 : attacker.position.y - 1 };
|
||||||
|
const refused = applyCommand(state, attacker.id, {
|
||||||
|
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-3#N"],
|
||||||
|
target: { kind: "player", playerId: defender.id },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
|
||||||
|
defender.position = { ...attacker.position };
|
||||||
|
state = castAt(state, attacker.id, defender.id, wb, { numberInstanceIds: ["number-3#N"] });
|
||||||
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
|
||||||
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
||||||
|
expect(a.hand.some((c) => c.cardId === "wizardblade")).toBe(true);
|
||||||
|
expect(a.displayed).toContain(wb.instanceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cast modifiers", () => {
|
||||||
|
it("amplify doubles fireball; add joins two number cards", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const fb = giveCard(state, attacker, "fireball");
|
||||||
|
giveCard(state, attacker, "amplify", "A", 1);
|
||||||
|
state = castAt(state, attacker, defender, fb, { amplifyInstanceIds: ["amplify#A"] });
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5 x2
|
||||||
|
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const lb = giveCard(state, attacker, "lightning-blast");
|
||||||
|
giveCard(state, attacker, "number-2", "N1", 1);
|
||||||
|
giveCard(state, attacker, "number-3", "N2", 2);
|
||||||
|
giveCard(state, attacker, "add", "AD", 3);
|
||||||
|
// Two numbers without ADD: refused.
|
||||||
|
const refused = applyCommand(state, attacker, {
|
||||||
|
type: "cast", instanceId: lb.instanceId,
|
||||||
|
numberInstanceIds: ["number-2#N1", "number-3#N2"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
state = castAt(state, attacker, defender, lb, {
|
||||||
|
numberInstanceIds: ["number-2#N1", "number-3#N2"],
|
||||||
|
addInstanceId: "add#AD",
|
||||||
|
});
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(0); // 5 dmg on 5 life
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse turns damage into healing but keeps secondary effects", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const lb = giveCard(state, attacker, "lightning-blast");
|
||||||
|
giveCard(state, attacker, "number-4", "N", 1);
|
||||||
|
giveCard(state, defender, "reverse", "R", 0);
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-4#N"],
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
||||||
|
state = must(state, attacker, { type: "pass" });
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
expect(d.life).toBe(19); // gained 4 instead of losing it
|
||||||
|
expect(d.lostTurns).toBe(1); // the stun still applies
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,9 +84,10 @@ wss.on("connection", (socket) => {
|
|||||||
case "create": {
|
case "create": {
|
||||||
const name = String(msg.name ?? "").trim();
|
const name = String(msg.name ?? "").trim();
|
||||||
if (!name) return send(socket, { type: "error", message: "name required" });
|
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||||
const room = createRoom(name);
|
const { room, token } = createRoom(name);
|
||||||
session.playerId = name;
|
session.playerId = name;
|
||||||
session.roomId = room.id;
|
session.roomId = room.id;
|
||||||
|
send(socket, { type: "seat", playerId: name, token });
|
||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -96,10 +97,11 @@ wss.on("connection", (socket) => {
|
|||||||
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
|
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||||
const problem = joinRoom(room, name);
|
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||||
if (problem) return send(socket, { type: "error", message: problem });
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
session.playerId = name;
|
session.playerId = name;
|
||||||
session.roomId = room.id;
|
session.roomId = room.id;
|
||||||
|
send(socket, { type: "seat", playerId: name, token: result.token });
|
||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// the append-only command log (the seed + log IS the game — the basis for
|
// the append-only command log (the seed + log IS the game — the basis for
|
||||||
// replays and async play). Clients get per-player redacted views and events.
|
// replays and async play). Clients get per-player redacted views and events.
|
||||||
|
|
||||||
|
import { randomBytes, randomInt } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
applyCommand,
|
applyCommand,
|
||||||
createGame,
|
createGame,
|
||||||
@@ -25,6 +26,8 @@ export interface Room {
|
|||||||
id: string;
|
id: string;
|
||||||
hostId: PlayerId;
|
hostId: PlayerId;
|
||||||
players: PlayerId[]; // join order
|
players: PlayerId[]; // join order
|
||||||
|
/** Per-player secrets: reclaiming a seat requires the matching token. */
|
||||||
|
tokens: Map<PlayerId, string>;
|
||||||
seed: number;
|
seed: number;
|
||||||
state: GameState | null; // null until started
|
state: GameState | null; // null until started
|
||||||
log: LoggedCommand[];
|
log: LoggedCommand[];
|
||||||
@@ -38,35 +41,47 @@ const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|||||||
function makeRoomCode(): string {
|
function makeRoomCode(): string {
|
||||||
let code = "";
|
let code = "";
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
|
code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)];
|
||||||
}
|
}
|
||||||
return rooms.has(code) ? makeRoomCode() : code;
|
return rooms.has(code) ? makeRoomCode() : code;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRoom(hostId: PlayerId): Room {
|
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||||
|
const token = randomBytes(16).toString("hex");
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
id: makeRoomCode(),
|
id: makeRoomCode(),
|
||||||
hostId,
|
hostId,
|
||||||
players: [hostId],
|
players: [hostId],
|
||||||
seed: Math.floor(Math.random() * 0xffffffff),
|
tokens: new Map([[hostId, token]]),
|
||||||
|
seed: randomInt(0, 0xffffffff),
|
||||||
state: null,
|
state: null,
|
||||||
log: [],
|
log: [],
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
rooms.set(room.id, room);
|
rooms.set(room.id, room);
|
||||||
return room;
|
return { room, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoom(id: string): Room | undefined {
|
export function getRoom(id: string): Room | undefined {
|
||||||
return rooms.get(id.toUpperCase());
|
return rooms.get(id.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinRoom(room: Room, playerId: PlayerId): string | null {
|
export function joinRoom(
|
||||||
if (room.state) return "game already started";
|
room: Room,
|
||||||
if (room.players.includes(playerId)) return null; // rejoin is fine
|
playerId: PlayerId,
|
||||||
if (room.players.length >= 4) return "room is full";
|
token: string | null,
|
||||||
|
): { token: string } | { error: string } {
|
||||||
|
if (room.players.includes(playerId)) {
|
||||||
|
// Reclaiming an existing seat requires that seat's secret.
|
||||||
|
if (token && room.tokens.get(playerId) === token) return { token };
|
||||||
|
return { error: "that wizard name is taken in this room" };
|
||||||
|
}
|
||||||
|
if (room.state) return { error: "game already started" };
|
||||||
|
if (room.players.length >= 4) return { error: "room is full" };
|
||||||
|
const fresh = randomBytes(16).toString("hex");
|
||||||
room.players.push(playerId);
|
room.players.push(playerId);
|
||||||
return null;
|
room.tokens.set(playerId, fresh);
|
||||||
|
return { token: fresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
|
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ function humanize(e: GameEvent): string | null {
|
|||||||
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
||||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||||
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
|
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||||
|
case "attackMissed": return e.because === "invisible"
|
||||||
|
? `The attack passes through empty air — ${e.defender} is invisible!`
|
||||||
|
: `${e.defender} is too small to hit — the attack misses!`;
|
||||||
|
case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
|
||||||
|
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||||
|
case "spellSustained": return `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||||
|
case "spellExpired": return `${cardDef(e.cardId).name} wears off ${e.target}.`;
|
||||||
|
case "teleported": return e.by === e.player
|
||||||
|
? `${e.player} teleports across the maze!`
|
||||||
|
: `${e.player} is teleported away by ${e.by}!`;
|
||||||
|
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
|
||||||
|
case "cardErased": return e.found
|
||||||
|
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
|
||||||
|
: `${e.player} wasn't holding that card — the erasure fizzles.`;
|
||||||
|
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
|
||||||
|
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
|
||||||
|
case "doorUnlocked": return `${e.player} unlocks a door.`;
|
||||||
|
case "doorsRelocked": return `The door swings shut and relocks.`;
|
||||||
|
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||||
|
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 "trapRedrawnDuringDeal": return null;
|
case "trapRedrawnDuringDeal": return null;
|
||||||
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
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.`;
|
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
||||||
@@ -61,6 +83,7 @@ class Net {
|
|||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
@@ -75,6 +98,9 @@ class Net {
|
|||||||
ws.onmessage = (raw) => {
|
ws.onmessage = (raw) => {
|
||||||
const msg = JSON.parse(raw.data as string);
|
const msg = JSON.parse(raw.data as string);
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case "seat":
|
||||||
|
this.token = msg.token;
|
||||||
|
break;
|
||||||
case "room":
|
case "room":
|
||||||
this.roomId = msg.roomId;
|
this.roomId = msg.roomId;
|
||||||
this.players = msg.players;
|
this.players = msg.players;
|
||||||
@@ -109,7 +135,7 @@ class Net {
|
|||||||
|
|
||||||
join(roomId: string, name: string): void {
|
join(roomId: string, name: string): void {
|
||||||
this.you = name;
|
this.you = name;
|
||||||
this.send({ type: "join", roomId, name });
|
this.send({ type: "join", roomId, name, token: this.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user