From 2bb2ca62d8c444ecb1056d1f9ddd3e0f537b1025 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 22:36:00 -0400 Subject: [PATCH] Reflected Swap Meet: the reflector chooses the trade (rev 27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'FULL REFLECTION lets the other player decide which objects, if any, will be swapped' — the reflector's choice now rides their counteract (params.cardId, reflector's item first), resolved with the roles swapped when the reflection settles; 'none' or no choice trades nothing. The client walks the reflector through the same give-and-take picker, with a 'swap nothing' refusal. Also from room XRT7: the trade picker now honors each room's own rules revision (older rooms trade only object-typed cards), so it can no longer offer a wizardblade a rev-25 engine will quietly refuse — and a fizzled swap finally says so in the log instead of vanishing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/game.ts | 49 +++++++++++-- packages/engine/test/expansion-combat.test.ts | 47 ++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 72 ++++++++++++++++++- packages/web/src/local.svelte.ts | 2 +- packages/web/src/net.svelte.ts | 1 + 6 files changed, 161 insertions(+), 12 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 06fbf97..91c7fca 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -183,7 +183,9 @@ export interface CastStack { powerAttackPoints: number; params: CastParams | null; kind: "spell" | "physical"; - counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell }[]; + counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell; + /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade (rev 27). */ + cardId?: string }[]; waitingOn: PlayerId; /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */ defenderShielded?: boolean; @@ -579,6 +581,7 @@ export type GameEvent = | { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean } | { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string } | { type: "itemsSwapped"; a: PlayerId; b: PlayerId } + | { type: "swapFizzled"; player: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId } | { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId } | { type: "chaosShielded"; player: PlayerId } @@ -669,7 +672,7 @@ export type Command = } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] } | { type: "cancelAmbush"; ambushId: string } - | { type: "counteract"; instanceId: string; params?: { cell?: Cell }; numberInstanceIds?: string[] } + | { type: "counteract"; instanceId: string; params?: { cell?: Cell; cardId?: string }; numberInstanceIds?: string[] } | { type: "pass" } | { type: "pickUpTreasure"; treasureId?: string } | { type: "pickUpObject"; instanceId: string } @@ -2167,16 +2170,17 @@ const CARD_EFFECTS: Record const idx = p.hand.findIndex((c) => c.cardId === id && tradable(c.cardId)); return idx === -1 ? null : { kind: "card", idx }; }; + const fizzle = () => ctx.events.push({ type: "swapFizzled", player: ctx.attacker.id }); const mine = side(ctx.attacker, mineId ?? ""); const theirs = side(ctx.defender, theirsId ?? ""); - if (!mine || !theirs) return; + if (!mine || !theirs) return fizzle(); // A one-way treasure needs an open carry slot and an able back: // "you are too weak to carry treasure" holds in a trade as at a grab. const weak = (p: PlayerState) => sustainedOn(ctx.state, p.id, "weakness").length > 0; if (mine.kind === "treasure" && theirs.kind !== "treasure" && - (ctx.defender.carriedTreasureId != null || weak(ctx.defender))) return; + (ctx.defender.carriedTreasureId != null || weak(ctx.defender))) return fizzle(); if (theirs.kind === "treasure" && mine.kind !== "treasure" && - (ctx.attacker.carriedTreasureId != null || weak(ctx.attacker))) return; + (ctx.attacker.carriedTreasureId != null || weak(ctx.attacker))) return fizzle(); const giveTreasure = (from: PlayerState, to: PlayerState) => { const t = ctx.state.treasures.find((t) => t.id === from.carriedTreasureId)!; from.carriedTreasureId = null; @@ -4950,7 +4954,7 @@ function counterDuration(state: GameState, player: PlayerState, numberInstanceId function doCounteract( prev: GameState, playerId: PlayerId, instanceId: string, - params?: { cell?: Cell }, numberInstanceIds?: string[], + params?: { cell?: Cell; cardId?: string }, numberInstanceIds?: string[], ): CommandResult { const state = clone(prev); const stack = state.stack!; @@ -5101,7 +5105,12 @@ function doCounteract( } takeFromHand(player, instanceId); state.discard.push(card); - stack.counters.push({ player: playerId, card, nullified: false }); + stack.counters.push({ + player: playerId, card, nullified: false, + // "FULL REFLECTION lets the other player decide which objects, if + // any, will be swapped" — the reflector's choice rides the counter. + ...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}), + }); stack.waitingOn = stack.attackerId; return { ok: true, @@ -5352,6 +5361,32 @@ function resolveStack(state: GameState, events: GameEvent[]): void { ? state.creatures.find((c) => c.id === stack.creatureId) : undefined; if (pipe.redirected) { + // SWAP MEET turned: "FULL REFLECTION lets the other player decide which + // objects, if any, will be swapped" (rules rev 27). The reflector's + // choice rode their counter — absent, or "none", nothing trades. + if (attackId === "swap-meet" && (state.config.deckRev ?? 1) >= 27) { + const fr = stack.counters.find((c) => !c.nullified && c.card.cardId === "full-reflection"); + const chosen = fr?.cardId; + if (chosen && chosen !== "none" && effect?.onResolved && !attackingCreature) { + effect.onResolved({ + state, events, + attacker: defender, defender: attacker, + damageDealt: 0, fullyStopped: false, duration: 0, + stack: { ...stack, params: { ...stack.params, cardId: chosen } }, + }); + } + events.push({ + type: "attackResolved", + attacker: attacker.id, + defender: defender.id, + attackCardId: attackId, + damageDealt: 0, + reflectedDamage: 0, + fullyStopped: false, + redirected: true, + }); + return; + } // The returned spell is a fresh attack on its own caster — who gets a // defender's counteraction window against it (rules rev 25). Earlier // revisions land the blow instantly and stored games replay so. diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 6e3975e..a0734ee 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -445,3 +445,50 @@ describe("swap meet trades treasures too", () => { expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker); }); }); + +describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => { + function reflectedSwapRig(reflectorChoice?: string) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + // The caster carries a treasure and offers a dagger; the reflector may + // instead demand the trade of their own choosing. + const t = state.treasures.find((t) => t.owner === attacker)!; + t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id; + const sm = giveCard(state, attacker, "swap-meet"); + giveCard(state, attacker, "dagger", "D", 1); + giveCard(state, defender, "full-reflection", "FR", 0); + giveCard(state, defender, "large-rock", "R", 1); + state = must(state, attacker, { + type: "cast", instanceId: sm.instanceId, + target: { kind: "player", playerId: defender }, + params: { cardId: "dagger;large-rock" }, + }); + state = must(state, defender, { + type: "counteract", instanceId: "full-reflection#FR", + ...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}), + }); + state = must(state, attacker, { type: "pass" }); + return { state, attacker, defender, treasureId: t.id }; + } + + it("the reflector rewrites the trade: their rock for the caster's treasure", () => { + const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure"); + const d = state.players.find((p) => p.id === defender)!; + expect(d.carriedTreasureId).toBe(treasureId); + expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true); + expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull(); + }); + + it("the reflector may decline: 'none' trades nothing", () => { + const { state, attacker, defender, treasureId } = reflectedSwapRig("none"); + expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId); + expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true); + }); + + it("a bare full reflection with no choice trades nothing", () => { + const { state, attacker } = reflectedSwapRig(undefined); + expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a35ecae..9bc8f95 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 26; +const RULES_REV = 27; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index c95574e..b9b45c2 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -383,6 +383,14 @@ attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card; return; } + // Fully reflecting a SWAP MEET: "lets the other player decide which + // objects, if any, will be swapped" — the choice rides the counter. + if (youMustRespond && card.cardId === "full-reflection" && + view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) { + reflectSwapCard = card; + reflectSwapMine = null; + return; + } dispatch({ type: "counteract", instanceId: card.instanceId, ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), @@ -838,11 +846,55 @@ // hand cards are not on the table). let swapMeetTarget = $state(null); let swapMine = $state(null); + // Reflecting a SWAP MEET: the reflector picks the trade — or none at all. + let reflectSwapCard = $state(null); + let reflectSwapMine = $state(null); + const reflectMyTradables = $derived.by(() => { + if (!view || !reflectSwapCard) return []; + const opts = new Map(); + for (const c of view.yourHand) { + if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); + } + if (me?.carriedTreasureId) { + const t = view.treasures.find((t) => t.id === me.carriedTreasureId); + opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`); + } + return [...opts].map(([key, label]) => ({ key, label })); + }); + const reflectTheirTradables = $derived.by(() => { + if (!view || !reflectSwapCard || !view.stack) return []; + const them = view.players.find((p) => p.id === view.stack!.attackerId); + if (!them) return []; + const opts = new Map(); + for (const c of them.displayed) { + if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); + } + if (them.carriedTreasureId && (reflectSwapMine === "treasure" || !me?.carriedTreasureId)) { + const t = view.treasures.find((t) => t.id === them.carriedTreasureId); + opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`); + } + return [...opts].map(([key, label]) => ({ key, label })); + }); + function castReflectSwap(pair: string) { + if (!reflectSwapCard) return; + dispatch({ + type: "counteract", instanceId: reflectSwapCard.instanceId, + params: { cardId: pair }, + }); + reflectSwapCard = null; + reflectSwapMine = null; + } + // Older rooms' rules trade only object-typed cards (rev < 26): the picker + // must offer exactly what that room's engine will honor. + function tradableHere(cardId: string): boolean { + if (!view) return false; + return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object"; + } const myTradables = $derived.by(() => { if (!view) return []; const opts = new Map(); for (const c of view.yourHand) { - if (isMovableObject(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); + if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); } if (me?.carriedTreasureId) { const t = view.treasures.find((t) => t.id === me.carriedTreasureId); @@ -856,7 +908,7 @@ if (!them) return []; const opts = new Map(); for (const c of them.displayed) { - if (isMovableObject(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); + if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name); } // Their treasure is claimable unless your arms are already full. if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) { @@ -1460,7 +1512,21 @@
🏆 {view.winner} wins!
{:else if youMustRespond}
- {#if view.stack?.defenderId === view.you} + {#if reflectSwapCard} + Your reflection turns the trade over to you — + {#if !reflectSwapMine} + give your: + {#each reflectMyTradables as c (c.key)} + + {/each} + {:else} + and take their: + {#each reflectTheirTradables as c (c.key)} + + {/each} + {/if} + + {:else if view.stack?.defenderId === view.you} Under attack — respond by your hand. {:else} Your spell is countered — respond by your hand. diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 71886c9..699b8d4 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 26, + deckRev: 27, }; const { state, events } = createGame(config); for (const e of events) { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 61b5e50..a779e08 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -137,6 +137,7 @@ export function humanize(e: GameEvent): string | null { case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`; case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`; case "itemsSwapped": return `${e.a} and ${e.b} swap items.`; + case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`; case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`; case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`; case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;