Swap Meet trades treasures — a carried treasure is a carried item
The trade grammar gains a 'treasure' token (never present in stored ledgers, so no gate needed): a dagger can buy back the treasure in a thief's arms, or two armfuls of gold can change hands outright. The one-treasure carry limit and WEAKNESS both hold in a trade as at a grab — a one-way treasure needs an open slot and an able back. The picker offers each side's carried treasure alongside displayed items, withholding theirs when your own arms are already full. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
00517e08b1
commit
3aef6694f3
+48
-10
@@ -2155,20 +2155,58 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
||||||
// "Swap any two carried items": every movable object trades — daggers,
|
// "Swap any two carried items": every movable object trades — daggers,
|
||||||
// rocks, wands, stones (rules rev 26; earlier games matched only
|
// 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) =>
|
const tradable = (id: string) =>
|
||||||
(ctx.state.config.deckRev ?? 1) >= 26
|
(ctx.state.config.deckRev ?? 1) >= 26
|
||||||
? isMovableObject(id)
|
? isMovableObject(id)
|
||||||
: cardDef(id).cardType === "object";
|
: cardDef(id).cardType === "object";
|
||||||
const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && tradable(c.cardId));
|
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
|
||||||
const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && tradable(c.cardId));
|
const side = (p: PlayerState, id: string): TradeSide | null => {
|
||||||
if (mine === -1 || theirs === -1) return;
|
if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null;
|
||||||
const [a] = ctx.attacker.hand.splice(mine, 1);
|
const idx = p.hand.findIndex((c) => c.cardId === id && tradable(c.cardId));
|
||||||
const [b] = ctx.defender.hand.splice(theirs, 1);
|
return idx === -1 ? null : { kind: "card", idx };
|
||||||
ctx.attacker.hand.push(b!);
|
};
|
||||||
ctx.defender.hand.push(a!);
|
const mine = side(ctx.attacker, mineId ?? "");
|
||||||
ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId);
|
const theirs = side(ctx.defender, theirsId ?? "");
|
||||||
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId);
|
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<TradeSide, { kind: "card" }>) => {
|
||||||
|
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<TradeSide, { kind: "card" }>));
|
||||||
|
} 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 });
|
ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -834,14 +834,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SWAP MEET: pick one of your carried items, then one of theirs you can
|
// 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<string | null>(null);
|
let swapMeetTarget = $state<string | null>(null);
|
||||||
let swapMine = $state<string | null>(null);
|
let swapMine = $state<string | null>(null);
|
||||||
const myTradables = $derived(view ? view.yourHand.filter((c) => isMovableObject(c.cardId)) : []);
|
const myTradables = $derived.by(() => {
|
||||||
|
if (!view) return [];
|
||||||
|
const opts = new Map<string, string>();
|
||||||
|
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(() => {
|
const theirTradables = $derived.by(() => {
|
||||||
if (!view || !swapMeetTarget) return [];
|
if (!view || !swapMeetTarget) return [];
|
||||||
const t = view.players.find((p) => p.id === swapMeetTarget);
|
const them = view.players.find((p) => p.id === swapMeetTarget);
|
||||||
return t ? t.displayed.filter((c) => isMovableObject(c.cardId)) : [];
|
if (!them) return [];
|
||||||
|
const opts = new Map<string, string>();
|
||||||
|
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) {
|
function castSwapMeet(theirs: string) {
|
||||||
if (!selectedCard || !swapMeetTarget || !swapMine) return;
|
if (!selectedCard || !swapMeetTarget || !swapMine) return;
|
||||||
@@ -1706,15 +1728,15 @@
|
|||||||
<span>— you carry no items to trade</span>
|
<span>— you carry no items to trade</span>
|
||||||
{:else if !swapMine}
|
{:else if !swapMine}
|
||||||
<span>— trade away your:</span>
|
<span>— trade away your:</span>
|
||||||
{#each myTradables as c (c.instanceId)}
|
{#each myTradables as c (c.key)}
|
||||||
<button class="stamp tiny" onclick={() => (swapMine = c.cardId)}>{cardDef(c.cardId).name}</button>
|
<button class="stamp tiny" onclick={() => (swapMine = c.key)}>{c.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if theirTradables.length === 0}
|
{:else if theirTradables.length === 0}
|
||||||
<span>— they show no item you could claim</span>
|
<span>— they show no item you could claim</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span>— and take their:</span>
|
<span>— and take their:</span>
|
||||||
{#each theirTradables as c (c.instanceId)}
|
{#each theirTradables as c (c.key)}
|
||||||
<button class="stamp tiny" onclick={() => castSwapMeet(c.cardId)}>{cardDef(c.cardId).name}</button>
|
<button class="stamp tiny" onclick={() => castSwapMeet(c.key)}>{c.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user