Reflected Swap Meet: the reflector chooses the trade (rev 27)
'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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
3aef6694f3
commit
2bb2ca62d8
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user