From 00517e08b1396cf21e4e1e70426d4efd34235536 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 22:14:57 -0400 Subject: [PATCH] Pacts are honored; Swap Meet trades items, not card names (rev 26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pact fix: a clockwork no longer attacks a wizard it holds a BUDDY pact on — Eric watched one sign the pact and punch him the next turn. A pact is torn up for exactly one thing: a kill. And the post-attack pact is signed only by a wizard who wants OUT of the fight (hauling gold, bleeding, or with other enemies left) — a healthy duelist keeps its options, and mutual-pact stalemates stay out of bot wars. Swap Meet drops the 'name a card' prompt for a real trade picker: click the other trader, choose one of your carried items, then claim one of their displayed items. Rules rev 26 widens the engine's match from object-typed cards to every movable object (daggers, rocks, wands, stones); older games matched narrowly and replay so. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/automaton.ts | 35 ++++++++++---- packages/engine/src/game.ts | 11 ++++- packages/engine/test/automaton.test.ts | 27 +++++++++++ packages/engine/test/expansion-combat.test.ts | 30 ++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 47 ++++++++++++++++++- packages/web/src/local.svelte.ts | 2 +- 7 files changed, 140 insertions(+), 14 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index fb30051..78ff8e6 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -795,7 +795,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma } /** The best attack available against a visible target, numbers and amplify included. */ -function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Command | null { +function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null { const numbers = numbersInHand(view); const biggest = numbers[numbers.length - 1]; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; @@ -852,7 +852,7 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma }; } } - return best?.cmd ?? null; + return best; } /** An affliction worth casting when no damage lands, mid number attached. @@ -1164,7 +1164,12 @@ export function automatonCommand( if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) && view.turn.round > 1) { const sighted = sightedCellsFor(view); - const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position))); + // A wizard under the clockwork's own BUDDY pact is off the target list: + // attacking them would tear up the pact it just paid a card for. + const pacted = (id: PlayerId) => + view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === id); + const visible = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && !pacted(p.id)); if (visible.length > 0) { const target = thief && visible.some((p) => p.id === thief.id) ? thief @@ -1222,22 +1227,31 @@ export function automatonCommand( } } const spell = bestAttack(view, target.id, tier); - if (spell) return spell; + if (spell) return spell.cmd; const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; if (misery) return misery; if (cellKey(target.position) === here && style !== "worrier") { return { type: "punch", targetId: target.id }; } } + // Only pacted wizards in sight? A pact is torn up for one thing: a kill. + const pactedVisible = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && pacted(p.id)) + .sort((a, b) => a.life - b.life); + for (const p of pactedVisible) { + const spell = bestAttack(view, p.id, tier); + if (spell && spell.damage >= p.life) return spell.cmd; + } // AROUND THE CORNER: an enemy tucked one bend out of sight is not safe. if (visible.length === 0 && tier.buffs) { const corner = inHand(view, "around-the-corner"); if (corner) { for (const p of livingEnemies(view)) { + if (pacted(p.id)) continue; if (!bentSightFor(view, self.position, p.position)) continue; const spell = bestAttack(view, p.id, tier); - if (spell && spell.type === "cast") { - return { ...spell, aroundCornerInstanceId: corner.instanceId }; + if (spell && spell.cmd.type === "cast") { + return { ...spell.cmd, aroundCornerInstanceId: corner.instanceId }; } } } @@ -1254,9 +1268,12 @@ export function automatonCommand( } // BUDDY after the blow: the wounded cannot come looking for payback — - // the pact holds unless the clockwork strikes them again. The berserker - // skips it; it would only break its own pact tomorrow. - if (tier.buffs && view.turn.attackUsed && style !== "berserker") { + // the pact holds unless the clockwork strikes them again. Signed only + // when the clockwork actually wants OUT of the fight (hauling gold, + // bleeding, or with other enemies left to hunt); a healthy duelist keeps + // its options, and the berserker would only break its own pact tomorrow. + if (tier.buffs && view.turn.attackUsed && style !== "berserker" && + (self.carriedTreasureId != null || self.life <= 8 || livingEnemies(view).length > 1)) { const pact = inHand(view, "buddy"); if (pact) { const sighted = sightedCellsFor(view); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 91e7e02..4af257a 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2153,8 +2153,15 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped) return; const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";"); - const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object"); - const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object"); + // "Swap any two carried items": every movable object trades — daggers, + // rocks, wands, stones (rules rev 26; earlier games matched only + // object-typed cards and replay so). + const tradable = (id: string) => + (ctx.state.config.deckRev ?? 1) >= 26 + ? isMovableObject(id) + : cardDef(id).cardType === "object"; + const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && tradable(c.cardId)); + const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && tradable(c.cardId)); if (mine === -1 || theirs === -1) return; const [a] = ctx.attacker.hand.splice(mine, 1); const [b] = ctx.defender.hand.splice(theirs, 1); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 8ac968f..8f45c9d 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -439,6 +439,7 @@ describe("the fireball-then-buddy lockout", () => { const bot = state.players.find((p) => p.id === "bot")!; const prey = state.players.find((p) => p.id === "other")!; prey.position = { ...bot.position }; + bot.life = 8; // bleeding: the clockwork wants out of this fight bot.hand = [ { instanceId: "fireball#T", cardId: "fireball" }, { instanceId: "buddy#T", cardId: "buddy" }, @@ -467,3 +468,29 @@ describe("the fireball-then-buddy lockout", () => { )).toBe(true); }); }); + +describe("a pact once signed is honored", () => { + it("will not attack the wizard it just buddied", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 }); + for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const bot = state.players.find((p) => p.id === "bot")!; + const prey = state.players.find((p) => p.id === "other")!; + prey.position = { ...bot.position }; + // The pact already stands; a fireball waits in hand as temptation. + state.sustained.push({ + id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other", + turnsLeft: 1000, edge: undefined, + } as never); + bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + // Anything but an attack on the pacted wizard: no cast at them, no punch. + expect(cmd?.type === "punch").toBe(false); + if (cmd?.type === "cast") { + expect((cmd as { target?: { playerId?: string } }).target?.playerId).not.toBe("other"); + } + }); +}); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index fc13231..a964d1e 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -335,3 +335,33 @@ describe("ambushes (async interrupts)", () => { expect(state.ambushes.length).toBe(0); }); }); + +describe("swap meet trades carried items (rules rev 26)", () => { + function rig(deckRev: number) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sm = giveCard(state, attacker, "swap-meet"); + giveCard(state, attacker, "dagger", "D", 1); + giveCard(state, defender, "blaster-wand", "W", 0); + state = must(state, attacker, { + type: "cast", instanceId: sm.instanceId, + target: { kind: "player", playerId: defender }, + params: { cardId: "dagger;blaster-wand" }, + }); + state = must(state, defender, { type: "pass" }); + return { state, attacker, defender }; + } + + it("a dagger trades for a wand — both are carried items", () => { + const { state, attacker, defender } = rig(26); + expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); + expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true); + }); + + it("older revisions matched only object-typed cards and replay so", () => { + const { state, attacker, defender } = rig(25); + expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true); + expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 90b6c23..a35ecae 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 = 25; +const RULES_REV = 26; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index cb2ec8b..eaebc1c 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -290,7 +290,7 @@ const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); - const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); + const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); const attackVsWall = $derived( selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId), ); @@ -309,6 +309,8 @@ function clearSelection() { punchWallMode = false; + swapMeetTarget = null; + swapMine = null; ambushVia = null; ambushTrigger = null; ambushSpell = null; @@ -797,6 +799,10 @@ pendingCellFor = playerId; return; // next: click the destination cell } + if (selectedCard.cardId === "swap-meet") { + swapMeetTarget = playerId; + return; // next: pick your item, then theirs + } const cmd: Parameters[0] = { type: "cast", instanceId: selectedCard.instanceId, @@ -827,6 +833,26 @@ dispatch({ type: "endTurn", draw: drawCount }); } + // SWAP MEET: pick one of your carried items, then one of theirs you can + // see (their displayed items — hidden hand cards are not on the table). + let swapMeetTarget = $state(null); + let swapMine = $state(null); + const myTradables = $derived(view ? view.yourHand.filter((c) => isMovableObject(c.cardId)) : []); + const theirTradables = $derived.by(() => { + if (!view || !swapMeetTarget) return []; + const t = view.players.find((p) => p.id === swapMeetTarget); + return t ? t.displayed.filter((c) => isMovableObject(c.cardId)) : []; + }); + function castSwapMeet(theirs: string) { + if (!selectedCard || !swapMeetTarget || !swapMine) return; + dispatch({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "player", playerId: swapMeetTarget }, + params: { cardId: `${swapMine};${theirs}` }, + }); + clearSelection(); + } + let pickChoice = $state(false); function pickUp() { if (treasuresHere.length > 1) { pickChoice = true; return; } @@ -1673,6 +1699,25 @@ — then click the target {/if} {/if} + {#if selectedCard?.cardId === "swap-meet"} + {#if !swapMeetTarget} + — click the other trader + {:else if myTradables.length === 0} + — you carry no items to trade + {:else if !swapMine} + — trade away your: + {#each myTradables as c (c.instanceId)} + + {/each} + {:else if theirTradables.length === 0} + — they show no item you could claim + {:else} + — and take their: + {#each theirTradables as c (c.instanceId)} + + {/each} + {/if} + {/if} {#if selectedCard?.cardId === "waterbolt"} (knockback {numberTotal - wbDamage}) diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 01b8fb7..71886c9 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: 25, + deckRev: 26, }; const { state, events } = createGame(config); for (const e of events) {