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;
|
powerAttackPoints: number;
|
||||||
params: CastParams | null;
|
params: CastParams | null;
|
||||||
kind: "spell" | "physical";
|
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;
|
waitingOn: PlayerId;
|
||||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||||
defenderShielded?: boolean;
|
defenderShielded?: boolean;
|
||||||
@@ -579,6 +581,7 @@ export type GameEvent =
|
|||||||
| { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean }
|
| { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean }
|
||||||
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
|
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
|
||||||
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
|
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
|
||||||
|
| { type: "swapFizzled"; player: PlayerId }
|
||||||
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||||
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
|
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
|
||||||
| { type: "chaosShielded"; player: PlayerId }
|
| { type: "chaosShielded"; player: PlayerId }
|
||||||
@@ -669,7 +672,7 @@ export type Command =
|
|||||||
}
|
}
|
||||||
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
|
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
|
||||||
| { type: "cancelAmbush"; ambushId: 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: "pass" }
|
||||||
| { type: "pickUpTreasure"; treasureId?: string }
|
| { type: "pickUpTreasure"; treasureId?: string }
|
||||||
| { type: "pickUpObject"; instanceId: 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));
|
const idx = p.hand.findIndex((c) => c.cardId === id && tradable(c.cardId));
|
||||||
return idx === -1 ? null : { kind: "card", idx };
|
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 mine = side(ctx.attacker, mineId ?? "");
|
||||||
const theirs = side(ctx.defender, theirsId ?? "");
|
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:
|
// 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.
|
// "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;
|
const weak = (p: PlayerState) => sustainedOn(ctx.state, p.id, "weakness").length > 0;
|
||||||
if (mine.kind === "treasure" && theirs.kind !== "treasure" &&
|
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" &&
|
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 giveTreasure = (from: PlayerState, to: PlayerState) => {
|
||||||
const t = ctx.state.treasures.find((t) => t.id === from.carriedTreasureId)!;
|
const t = ctx.state.treasures.find((t) => t.id === from.carriedTreasureId)!;
|
||||||
from.carriedTreasureId = null;
|
from.carriedTreasureId = null;
|
||||||
@@ -4950,7 +4954,7 @@ function counterDuration(state: GameState, player: PlayerState, numberInstanceId
|
|||||||
|
|
||||||
function doCounteract(
|
function doCounteract(
|
||||||
prev: GameState, playerId: PlayerId, instanceId: string,
|
prev: GameState, playerId: PlayerId, instanceId: string,
|
||||||
params?: { cell?: Cell }, numberInstanceIds?: string[],
|
params?: { cell?: Cell; cardId?: string }, numberInstanceIds?: string[],
|
||||||
): CommandResult {
|
): CommandResult {
|
||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const stack = state.stack!;
|
const stack = state.stack!;
|
||||||
@@ -5101,7 +5105,12 @@ function doCounteract(
|
|||||||
}
|
}
|
||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
state.discard.push(card);
|
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;
|
stack.waitingOn = stack.attackerId;
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5352,6 +5361,32 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
? state.creatures.find((c) => c.id === stack.creatureId)
|
? state.creatures.find((c) => c.id === stack.creatureId)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (pipe.redirected) {
|
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
|
// The returned spell is a fresh attack on its own caster — who gets a
|
||||||
// defender's counteraction window against it (rules rev 25). Earlier
|
// defender's counteraction window against it (rules rev 25). Earlier
|
||||||
// revisions land the blow instantly and stored games replay so.
|
// revisions land the blow instantly and stored games replay so.
|
||||||
|
|||||||
@@ -445,3 +445,50 @@ describe("swap meet trades treasures too", () => {
|
|||||||
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
|
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
|
||||||
|
function reflectedSwapRig(reflectorChoice?: string) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
|
// The caster carries a treasure and offers a dagger; the reflector may
|
||||||
|
// instead demand the trade of their own choosing.
|
||||||
|
const t = state.treasures.find((t) => t.owner === attacker)!;
|
||||||
|
t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id;
|
||||||
|
const sm = giveCard(state, attacker, "swap-meet");
|
||||||
|
giveCard(state, attacker, "dagger", "D", 1);
|
||||||
|
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||||
|
giveCard(state, defender, "large-rock", "R", 1);
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: sm.instanceId,
|
||||||
|
target: { kind: "player", playerId: defender },
|
||||||
|
params: { cardId: "dagger;large-rock" },
|
||||||
|
});
|
||||||
|
state = must(state, defender, {
|
||||||
|
type: "counteract", instanceId: "full-reflection#FR",
|
||||||
|
...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}),
|
||||||
|
});
|
||||||
|
state = must(state, attacker, { type: "pass" });
|
||||||
|
return { state, attacker, defender, treasureId: t.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the reflector rewrites the trade: their rock for the caster's treasure", () => {
|
||||||
|
const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure");
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
expect(d.carriedTreasureId).toBe(treasureId);
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the reflector may decline: 'none' trades nothing", () => {
|
||||||
|
const { state, attacker, defender, treasureId } = reflectedSwapRig("none");
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bare full reflection with no choice trades nothing", () => {
|
||||||
|
const { state, attacker } = reflectedSwapRig(undefined);
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).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 = 26;
|
const RULES_REV = 27;
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,14 @@
|
|||||||
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Fully reflecting a SWAP MEET: "lets the other player decide which
|
||||||
|
// objects, if any, will be swapped" — the choice rides the counter.
|
||||||
|
if (youMustRespond && card.cardId === "full-reflection" &&
|
||||||
|
view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) {
|
||||||
|
reflectSwapCard = card;
|
||||||
|
reflectSwapMine = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "counteract", instanceId: card.instanceId,
|
type: "counteract", instanceId: card.instanceId,
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||||
@@ -838,11 +846,55 @@
|
|||||||
// hand cards are not on the table).
|
// 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);
|
||||||
|
// Reflecting a SWAP MEET: the reflector picks the trade — or none at all.
|
||||||
|
let reflectSwapCard = $state<CardInstance | null>(null);
|
||||||
|
let reflectSwapMine = $state<string | null>(null);
|
||||||
|
const reflectMyTradables = $derived.by(() => {
|
||||||
|
if (!view || !reflectSwapCard) return [];
|
||||||
|
const opts = new Map<string, string>();
|
||||||
|
for (const c of view.yourHand) {
|
||||||
|
if (tradableHere(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 reflectTheirTradables = $derived.by(() => {
|
||||||
|
if (!view || !reflectSwapCard || !view.stack) return [];
|
||||||
|
const them = view.players.find((p) => p.id === view.stack!.attackerId);
|
||||||
|
if (!them) return [];
|
||||||
|
const opts = new Map<string, string>();
|
||||||
|
for (const c of them.displayed) {
|
||||||
|
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||||
|
}
|
||||||
|
if (them.carriedTreasureId && (reflectSwapMine === "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 castReflectSwap(pair: string) {
|
||||||
|
if (!reflectSwapCard) return;
|
||||||
|
dispatch({
|
||||||
|
type: "counteract", instanceId: reflectSwapCard.instanceId,
|
||||||
|
params: { cardId: pair },
|
||||||
|
});
|
||||||
|
reflectSwapCard = null;
|
||||||
|
reflectSwapMine = null;
|
||||||
|
}
|
||||||
|
// Older rooms' rules trade only object-typed cards (rev < 26): the picker
|
||||||
|
// must offer exactly what that room's engine will honor.
|
||||||
|
function tradableHere(cardId: string): boolean {
|
||||||
|
if (!view) return false;
|
||||||
|
return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
|
||||||
|
}
|
||||||
const myTradables = $derived.by(() => {
|
const myTradables = $derived.by(() => {
|
||||||
if (!view) return [];
|
if (!view) return [];
|
||||||
const opts = new Map<string, string>();
|
const opts = new Map<string, string>();
|
||||||
for (const c of view.yourHand) {
|
for (const c of view.yourHand) {
|
||||||
if (isMovableObject(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||||
}
|
}
|
||||||
if (me?.carriedTreasureId) {
|
if (me?.carriedTreasureId) {
|
||||||
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
|
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
|
||||||
@@ -856,7 +908,7 @@
|
|||||||
if (!them) return [];
|
if (!them) return [];
|
||||||
const opts = new Map<string, string>();
|
const opts = new Map<string, string>();
|
||||||
for (const c of them.displayed) {
|
for (const c of them.displayed) {
|
||||||
if (isMovableObject(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
|
||||||
}
|
}
|
||||||
// Their treasure is claimable unless your arms are already full.
|
// Their treasure is claimable unless your arms are already full.
|
||||||
if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) {
|
if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) {
|
||||||
@@ -1460,7 +1512,21 @@
|
|||||||
<div class="slip winner">🏆 {view.winner} wins!</div>
|
<div class="slip winner">🏆 {view.winner} wins!</div>
|
||||||
{:else if youMustRespond}
|
{:else if youMustRespond}
|
||||||
<div class="slip urgent">
|
<div class="slip urgent">
|
||||||
{#if view.stack?.defenderId === view.you}
|
{#if reflectSwapCard}
|
||||||
|
Your reflection turns the trade over to you —
|
||||||
|
{#if !reflectSwapMine}
|
||||||
|
give your:
|
||||||
|
{#each reflectMyTradables as c (c.key)}
|
||||||
|
<button class="stamp tiny" onclick={() => (reflectSwapMine = c.key)}>{c.label}</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
and take their:
|
||||||
|
{#each reflectTheirTradables as c (c.key)}
|
||||||
|
<button class="stamp tiny" onclick={() => castReflectSwap(`${reflectSwapMine};${c.key}`)}>{c.label}</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
|
||||||
|
{:else if view.stack?.defenderId === view.you}
|
||||||
Under attack — respond by your hand.
|
Under attack — respond by your hand.
|
||||||
{:else}
|
{:else}
|
||||||
Your spell is countered — respond by your hand.
|
Your spell is countered — respond by your hand.
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 26,
|
deckRev: 27,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
||||||
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
||||||
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
||||||
|
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
|
||||||
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
||||||
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
||||||
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user