diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 4af257a..06fbf97 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2155,20 +2155,58 @@ const CARD_EFFECTS: Record const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";"); // "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). + // object-typed cards and replay so) — and a carried TREASURE is an + // item too, named by the "treasure" token (never in older ledgers). 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); - ctx.attacker.hand.push(b!); - ctx.defender.hand.push(a!); - ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId); - ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId); + type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" }; + const side = (p: PlayerState, id: string): TradeSide | null => { + if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null; + const idx = p.hand.findIndex((c) => c.cardId === id && tradable(c.cardId)); + return idx === -1 ? null : { kind: "card", idx }; + }; + const mine = side(ctx.attacker, mineId ?? ""); + const theirs = side(ctx.defender, theirsId ?? ""); + if (!mine || !theirs) return; + // 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; + if (theirs.kind === "treasure" && mine.kind !== "treasure" && + (ctx.attacker.carriedTreasureId != null || weak(ctx.attacker))) return; + const giveTreasure = (from: PlayerState, to: PlayerState) => { + const t = ctx.state.treasures.find((t) => t.id === from.carriedTreasureId)!; + from.carriedTreasureId = null; + t.carriedBy = to.id; + return t; + }; + const takeCard = (p: PlayerState, s: Extract) => { + const [card] = p.hand.splice(s.idx, 1); + p.displayed = p.displayed.filter((id) => id !== card!.instanceId); + return card!; + }; + if (mine.kind === "treasure" && theirs.kind === "treasure") { + const tA = giveTreasure(ctx.attacker, ctx.defender); + const tB = giveTreasure(ctx.defender, ctx.attacker); + ctx.defender.carriedTreasureId = tA.id; + ctx.attacker.carriedTreasureId = tB.id; + } else if (mine.kind === "treasure") { + const t = giveTreasure(ctx.attacker, ctx.defender); + ctx.defender.carriedTreasureId = t.id; + ctx.attacker.hand.push(takeCard(ctx.defender, theirs as Extract)); + } else if (theirs.kind === "treasure") { + const t = giveTreasure(ctx.defender, ctx.attacker); + ctx.attacker.carriedTreasureId = t.id; + ctx.defender.hand.push(takeCard(ctx.attacker, mine)); + } else { + const a = takeCard(ctx.attacker, mine); + const b = takeCard(ctx.defender, theirs); + ctx.attacker.hand.push(b); + ctx.defender.hand.push(a); + } ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id }); }, }, diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index a964d1e..6e3975e 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -365,3 +365,83 @@ describe("swap meet trades carried items (rules rev 26)", () => { expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); }); }); + +describe("swap meet trades treasures too", () => { + function treasureRig(theirsToken: string, mineToken: string) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + // The defender hauls one of the attacker's own treasures. + const stolen = state.treasures.find((t) => t.owner === attacker)!; + stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id; + const sm = giveCard(state, attacker, "swap-meet"); + giveCard(state, attacker, "dagger", "D", 1); + state = must(state, attacker, { + type: "cast", instanceId: sm.instanceId, + target: { kind: "player", playerId: defender }, + params: { cardId: `${mineToken};${theirsToken}` }, + }); + state = must(state, defender, { type: "pass" }); + return { state, attacker, defender, a, d, stolen }; + } + + it("a dagger buys back the treasure in the thief's arms", () => { + const { state, attacker, defender } = treasureRig("treasure", "dagger"); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!; + expect(a.carriedTreasureId).toBe(t.id); + expect(t.carriedBy).toBe(attacker); + expect(d.carriedTreasureId).toBeNull(); + expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true); + }); + + it("the trade refuses to overload full arms", () => { + // The attacker also carries a treasure: claiming theirs with a dagger + // would mean two in hand — the swap quietly cannot happen. + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + const t1 = state.treasures.find((t) => t.owner === attacker)!; + t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id; + const t2 = state.treasures.find((t) => t.owner === defender)!; + t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id; + const sm = giveCard(state, attacker, "swap-meet"); + giveCard(state, attacker, "dagger", "D", 1); + state = must(state, attacker, { + type: "cast", instanceId: sm.instanceId, + target: { kind: "player", playerId: defender }, + params: { cardId: "dagger;treasure" }, + }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id); + expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id); + }); + + it("treasure for treasure trades both armfuls", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + const t1 = state.treasures.find((t) => t.owner === attacker)!; + t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id; + const t2 = state.treasures.find((t) => t.owner === defender)!; + t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id; + const sm = giveCard(state, attacker, "swap-meet"); + state = must(state, attacker, { + type: "cast", instanceId: sm.instanceId, + target: { kind: "player", playerId: defender }, + params: { cardId: "treasure;treasure" }, + }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id); + expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id); + expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender); + expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index eaebc1c..c95574e 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -834,14 +834,36 @@ } // 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). + // see (their displayed items and any treasure in their arms — 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 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 (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 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)) : []; + const them = view.players.find((p) => p.id === swapMeetTarget); + 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); + } + // Their treasure is claimable unless your arms are already full. + if (them.carriedTreasureId && (swapMine === "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 castSwapMeet(theirs: string) { if (!selectedCard || !swapMeetTarget || !swapMine) return; @@ -1706,15 +1728,15 @@ — you carry no items to trade {:else if !swapMine} — trade away your: - {#each myTradables as c (c.instanceId)} - + {#each myTradables as c (c.key)} + {/each} {:else if theirTradables.length === 0} — they show no item you could claim {:else} — and take their: - {#each theirTradables as c (c.instanceId)} - + {#each theirTradables as c (c.key)} + {/each} {/if} {/if}