Pacts are honored; Swap Meet trades items, not card names (rev 26)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
06faea0c79
commit
00517e08b1
@@ -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. */
|
/** 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 numbers = numbersInHand(view);
|
||||||
const biggest = numbers[numbers.length - 1];
|
const biggest = numbers[numbers.length - 1];
|
||||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
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.
|
/** 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)) &&
|
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
||||||
view.turn.round > 1) {
|
view.turn.round > 1) {
|
||||||
const sighted = sightedCellsFor(view);
|
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) {
|
if (visible.length > 0) {
|
||||||
const target = thief && visible.some((p) => p.id === thief.id)
|
const target = thief && visible.some((p) => p.id === thief.id)
|
||||||
? thief
|
? thief
|
||||||
@@ -1222,22 +1227,31 @@ export function automatonCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const spell = bestAttack(view, target.id, tier);
|
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;
|
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
||||||
if (misery) return misery;
|
if (misery) return misery;
|
||||||
if (cellKey(target.position) === here && style !== "worrier") {
|
if (cellKey(target.position) === here && style !== "worrier") {
|
||||||
return { type: "punch", targetId: target.id };
|
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.
|
// AROUND THE CORNER: an enemy tucked one bend out of sight is not safe.
|
||||||
if (visible.length === 0 && tier.buffs) {
|
if (visible.length === 0 && tier.buffs) {
|
||||||
const corner = inHand(view, "around-the-corner");
|
const corner = inHand(view, "around-the-corner");
|
||||||
if (corner) {
|
if (corner) {
|
||||||
for (const p of livingEnemies(view)) {
|
for (const p of livingEnemies(view)) {
|
||||||
|
if (pacted(p.id)) continue;
|
||||||
if (!bentSightFor(view, self.position, p.position)) continue;
|
if (!bentSightFor(view, self.position, p.position)) continue;
|
||||||
const spell = bestAttack(view, p.id, tier);
|
const spell = bestAttack(view, p.id, tier);
|
||||||
if (spell && spell.type === "cast") {
|
if (spell && spell.cmd.type === "cast") {
|
||||||
return { ...spell, aroundCornerInstanceId: corner.instanceId };
|
return { ...spell.cmd, aroundCornerInstanceId: corner.instanceId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1254,9 +1268,12 @@ export function automatonCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BUDDY after the blow: the wounded cannot come looking for payback —
|
// BUDDY after the blow: the wounded cannot come looking for payback —
|
||||||
// the pact holds unless the clockwork strikes them again. The berserker
|
// the pact holds unless the clockwork strikes them again. Signed only
|
||||||
// skips it; it would only break its own pact tomorrow.
|
// when the clockwork actually wants OUT of the fight (hauling gold,
|
||||||
if (tier.buffs && view.turn.attackUsed && style !== "berserker") {
|
// 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");
|
const pact = inHand(view, "buddy");
|
||||||
if (pact) {
|
if (pact) {
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
|
|||||||
@@ -2153,8 +2153,15 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped) return;
|
if (ctx.fullyStopped) return;
|
||||||
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
||||||
const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object");
|
// "Swap any two carried items": every movable object trades — daggers,
|
||||||
const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object");
|
// 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;
|
if (mine === -1 || theirs === -1) return;
|
||||||
const [a] = ctx.attacker.hand.splice(mine, 1);
|
const [a] = ctx.attacker.hand.splice(mine, 1);
|
||||||
const [b] = ctx.defender.hand.splice(theirs, 1);
|
const [b] = ctx.defender.hand.splice(theirs, 1);
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ describe("the fireball-then-buddy lockout", () => {
|
|||||||
const bot = state.players.find((p) => p.id === "bot")!;
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
const prey = state.players.find((p) => p.id === "other")!;
|
const prey = state.players.find((p) => p.id === "other")!;
|
||||||
prey.position = { ...bot.position };
|
prey.position = { ...bot.position };
|
||||||
|
bot.life = 8; // bleeding: the clockwork wants out of this fight
|
||||||
bot.hand = [
|
bot.hand = [
|
||||||
{ instanceId: "fireball#T", cardId: "fireball" },
|
{ instanceId: "fireball#T", cardId: "fireball" },
|
||||||
{ instanceId: "buddy#T", cardId: "buddy" },
|
{ instanceId: "buddy#T", cardId: "buddy" },
|
||||||
@@ -467,3 +468,29 @@ describe("the fireball-then-buddy lockout", () => {
|
|||||||
)).toBe(true);
|
)).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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -335,3 +335,33 @@ describe("ambushes (async interrupts)", () => {
|
|||||||
expect(state.ambushes.length).toBe(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface Room {
|
|||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
/** 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";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -290,7 +290,7 @@
|
|||||||
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
||||||
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
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(
|
const attackVsWall = $derived(
|
||||||
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
|
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
|
||||||
);
|
);
|
||||||
@@ -309,6 +309,8 @@
|
|||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
punchWallMode = false;
|
punchWallMode = false;
|
||||||
|
swapMeetTarget = null;
|
||||||
|
swapMine = null;
|
||||||
ambushVia = null;
|
ambushVia = null;
|
||||||
ambushTrigger = null;
|
ambushTrigger = null;
|
||||||
ambushSpell = null;
|
ambushSpell = null;
|
||||||
@@ -797,6 +799,10 @@
|
|||||||
pendingCellFor = playerId;
|
pendingCellFor = playerId;
|
||||||
return; // next: click the destination cell
|
return; // next: click the destination cell
|
||||||
}
|
}
|
||||||
|
if (selectedCard.cardId === "swap-meet") {
|
||||||
|
swapMeetTarget = playerId;
|
||||||
|
return; // next: pick your item, then theirs
|
||||||
|
}
|
||||||
const cmd: Parameters<typeof net.command>[0] = {
|
const cmd: Parameters<typeof net.command>[0] = {
|
||||||
type: "cast",
|
type: "cast",
|
||||||
instanceId: selectedCard.instanceId,
|
instanceId: selectedCard.instanceId,
|
||||||
@@ -827,6 +833,26 @@
|
|||||||
dispatch({ type: "endTurn", draw: drawCount });
|
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<string | null>(null);
|
||||||
|
let swapMine = $state<string | null>(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);
|
let pickChoice = $state(false);
|
||||||
function pickUp() {
|
function pickUp() {
|
||||||
if (treasuresHere.length > 1) { pickChoice = true; return; }
|
if (treasuresHere.length > 1) { pickChoice = true; return; }
|
||||||
@@ -1673,6 +1699,25 @@
|
|||||||
<span>— then click the target</span>
|
<span>— then click the target</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "swap-meet"}
|
||||||
|
{#if !swapMeetTarget}
|
||||||
|
<span>— click the other trader</span>
|
||||||
|
{:else if myTradables.length === 0}
|
||||||
|
<span>— you carry no items to trade</span>
|
||||||
|
{:else if !swapMine}
|
||||||
|
<span>— trade away your:</span>
|
||||||
|
{#each myTradables as c (c.instanceId)}
|
||||||
|
<button class="stamp tiny" onclick={() => (swapMine = c.cardId)}>{cardDef(c.cardId).name}</button>
|
||||||
|
{/each}
|
||||||
|
{:else if theirTradables.length === 0}
|
||||||
|
<span>— they show no item you could claim</span>
|
||||||
|
{:else}
|
||||||
|
<span>— and take their:</span>
|
||||||
|
{#each theirTradables as c (c.instanceId)}
|
||||||
|
<button class="stamp tiny" onclick={() => castSwapMeet(c.cardId)}>{cardDef(c.cardId).name}</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if selectedCard?.cardId === "waterbolt"}
|
{#if selectedCard?.cardId === "waterbolt"}
|
||||||
<label class="inline">damage <input class="num-input" type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
|
<label class="inline">damage <input class="num-input" type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
|
||||||
<span>(knockback {numberTotal - wbDamage})</span>
|
<span>(knockback {numberTotal - wbDamage})</span>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 25,
|
deckRev: 26,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|||||||
Reference in New Issue
Block a user