diff --git a/client/src/adapter/__tests__/draftPodAdapter.test.ts b/client/src/adapter/__tests__/draftPodAdapter.test.ts index 04ba6e049d..bea8f7e0ff 100644 --- a/client/src/adapter/__tests__/draftPodAdapter.test.ts +++ b/client/src/adapter/__tests__/draftPodAdapter.test.ts @@ -48,6 +48,7 @@ const mockHostOnEvent = vi.fn((_handler: (event: Record) => voi const mockHostInitialize = vi.fn(async () => {}); const mockHostStartDraft = vi.fn(async () => {}); const mockHostSubmitHostPick = vi.fn(async () => mockView("Drafting")); +const mockHostSubmitHostPickWithDraftEffect = vi.fn(async () => mockView("Drafting")); const mockHostSubmitHostDeck = vi.fn(async () => mockView("Deckbuilding")); const mockHostGetHostView = vi.fn(async () => mockView("Lobby")); const mockHostKickPlayer = vi.fn(); @@ -64,6 +65,7 @@ vi.mock("../p2p-draft-host", () => ({ initialize: mockHostInitialize, startDraft: mockHostStartDraft, submitHostPick: mockHostSubmitHostPick, + submitHostPickWithDraftEffect: mockHostSubmitHostPickWithDraftEffect, submitHostDeck: mockHostSubmitHostDeck, getHostView: mockHostGetHostView, kickPlayer: mockHostKickPlayer, @@ -83,6 +85,7 @@ vi.mock("../p2p-draft-host", () => ({ const mockGuestOnEvent = vi.fn((_handler: (event: Record) => void) => vi.fn()); const mockGuestInitialize = vi.fn(async () => {}); const mockGuestSubmitPick = vi.fn(async () => {}); +const mockGuestSubmitPickWithDraftEffect = vi.fn(async () => {}); const mockGuestSubmitDeck = vi.fn(async () => {}); const mockGuestLeave = vi.fn(async () => {}); @@ -92,6 +95,7 @@ vi.mock("../p2p-draft-guest", () => ({ onEvent: mockGuestOnEvent, initialize: mockGuestInitialize, submitPick: mockGuestSubmitPick, + submitPickWithDraftEffect: mockGuestSubmitPickWithDraftEffect, submitDeck: mockGuestSubmitDeck, leave: mockGuestLeave, view: null, @@ -112,6 +116,7 @@ function mockView(status: string): DraftPlayerView { pass_direction: "Left", current_pack: null, pool: [], + draft_effects: [], pool_groups: { color_groups: [], type_groups: [], @@ -294,6 +299,21 @@ describe("DraftPodHostAdapter", () => { expect(view.status).toBe("Drafting"); }); + it("delegates draft-effect picks and returns view", async () => { + await adapter.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + }); + + const view = await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]); + expect(mockHostSubmitHostPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]); + expect(view.status).toBe("Drafting"); + }); + it("delegates submitDeck and returns view", async () => { await adapter.initialize({ poolInput: { type: "Set", data: { set_pool_json: "{}" } }, @@ -500,6 +520,13 @@ describe("DraftPodGuestAdapter", () => { expect(mockGuestSubmitPick).toHaveBeenCalledWith("card-456"); }); + it("delegates draft-effect picks to P2PDraftGuest", async () => { + await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + + await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]); + expect(mockGuestSubmitPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]); + }); + it("delegates submitDeck to P2PDraftGuest", async () => { await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); diff --git a/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts b/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts new file mode 100644 index 0000000000..ecff81901d --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +import { P2PDraftGuest } from "../p2p-draft-guest"; +import { P2PDraftHost } from "../p2p-draft-host"; + +describe("P2P draft-effect picks", () => { + it("serializes guest draft-effect picks without a client-supplied seat", async () => { + const guest = new P2PDraftGuest( + {} as never, + "host-peer", + {} as never, + "Alice", + ); + const send = vi.fn(async () => {}); + (guest as unknown as { session: { send: typeof send } }).session = { send }; + + await guest.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]); + + expect(send).toHaveBeenCalledWith({ + type: "draft_pick_with_draft_effect", + effectCardInstanceId: "cogwork-1", + cardInstanceIds: ["card-1", "card-2"], + }); + }); + + it("binds a guest draft-effect pick to the host-assigned seat", async () => { + const host = new P2PDraftHost( + { id: "host" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + "Host", + "Swiss", + "Competitive", + ); + const privateHost = host as unknown as { + draftStarted: boolean; + paused: boolean; + handleGuestMessage: (seat: number, message: unknown) => Promise; + handlePickWithDraftEffect: ReturnType; + }; + privateHost.draftStarted = true; + privateHost.paused = false; + privateHost.handlePickWithDraftEffect = vi.fn(async () => {}); + + await privateHost.handleGuestMessage(3, { + type: "draft_pick_with_draft_effect", + effectCardInstanceId: "cogwork-1", + cardInstanceIds: ["card-1", "card-2"], + }); + + expect(privateHost.handlePickWithDraftEffect).toHaveBeenCalledWith( + 3, + "cogwork-1", + ["card-1", "card-2"], + ); + }); + + it("rejects host normal and draft-effect picks while paused", async () => { + const host = new P2PDraftHost( + { id: "host" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + "Host", + "Swiss", + "Competitive", + ); + const privateHost = host as unknown as { + draftStarted: boolean; + paused: boolean; + adapter: { + submitPickForSeat: ReturnType; + submitPickWithDraftEffectForSeat: ReturnType; + }; + }; + privateHost.draftStarted = true; + privateHost.paused = true; + privateHost.adapter = { + submitPickForSeat: vi.fn(), + submitPickWithDraftEffectForSeat: vi.fn(), + }; + + await expect(host.submitHostPick("card-1")).rejects.toThrow("Draft is paused"); + await expect( + host.submitHostPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]), + ).rejects.toThrow("Draft is paused"); + + expect(privateHost.adapter.submitPickForSeat).not.toHaveBeenCalled(); + expect(privateHost.adapter.submitPickWithDraftEffectForSeat).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index a42de47ff9..ccad40aa25 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -67,6 +67,7 @@ function createMockDraftView(overrides: Partial = {}): DraftPla pass_direction: "Left", current_pack: null, pool: [], + draft_effects: [], pool_groups: EMPTY_DRAFT_POOL_GROUPS, seats: [], cards_per_pack: 14, diff --git a/client/src/adapter/draft-adapter.ts b/client/src/adapter/draft-adapter.ts index c949679498..c9baeb730a 100644 --- a/client/src/adapter/draft-adapter.ts +++ b/client/src/adapter/draft-adapter.ts @@ -12,6 +12,7 @@ export interface DraftCardInstance { colors: string[]; cmc: number; type_line: string; + draft_effect?: "additional_pick"; } export type DraftPoolGroupKind = @@ -80,6 +81,7 @@ export interface SeatPublicView { connected: boolean; has_submitted_deck: boolean; pick_status: "Pending" | "Picked" | "TimedOut" | "NotDrafting"; + face_up_draft_cards: DraftCardInstance[]; } export type DraftStatus = @@ -169,6 +171,7 @@ export interface DraftPlayerView { pass_direction: "Left" | "Right"; current_pack: DraftCardInstance[] | null; pool: DraftCardInstance[]; + draft_effects: DraftCardInstance[]; /** Engine-owned grouping, ordering, and duplicate counts for the pool. */ pool_groups: DraftPoolGroups; /** Engine-provided sealed packs in opening order. Absent for draft events. */ @@ -291,6 +294,17 @@ export class DraftAdapter { return wasm.submit_pick(cardInstanceId) as DraftPlayerView; } + async submitPickWithDraftEffect( + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + const wasm = await ensureDraftWasm(); + return wasm.submit_pick_with_draft_effect( + effectCardInstanceId, + JSON.stringify(cardInstanceIds), + ) as DraftPlayerView; + } + /** Let the bot AI pick the best card from the current pack for the player. */ async autoPick(): Promise { const wasm = await ensureDraftWasm(); @@ -360,6 +374,19 @@ export class DraftAdapter { return wasm.submit_pick_for_seat(seat, cardInstanceId) as DraftPlayerView; } + async submitPickWithDraftEffectForSeat( + seat: number, + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + const wasm = await ensureDraftWasm(); + return wasm.submit_pick_with_draft_effect_for_seat( + seat, + effectCardInstanceId, + JSON.stringify(cardInstanceIds), + ) as DraftPlayerView; + } + async submitDeckForSeat(seat: number, mainDeck: string[]): Promise { const wasm = await ensureDraftWasm(); return wasm.submit_deck_for_seat(seat, JSON.stringify(mainDeck)) as DraftPlayerView; diff --git a/client/src/adapter/draftPodGuestAdapter.ts b/client/src/adapter/draftPodGuestAdapter.ts index 24f2c923ca..dea5868e99 100644 --- a/client/src/adapter/draftPodGuestAdapter.ts +++ b/client/src/adapter/draftPodGuestAdapter.ts @@ -339,6 +339,14 @@ export class DraftPodGuestAdapter { await this.guest.submitPick(cardInstanceId); } + async submitPickWithDraftEffect( + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + if (!this.guest) throw new Error("Guest not initialized"); + await this.guest.submitPickWithDraftEffect(effectCardInstanceId, cardInstanceIds); + } + async submitDeck(mainDeck: string[]): Promise { if (!this.guest) throw new Error("Guest not initialized"); await this.guest.submitDeck(mainDeck); diff --git a/client/src/adapter/draftPodHostAdapter.ts b/client/src/adapter/draftPodHostAdapter.ts index 0dad1fd4f4..9459176c94 100644 --- a/client/src/adapter/draftPodHostAdapter.ts +++ b/client/src/adapter/draftPodHostAdapter.ts @@ -400,6 +400,14 @@ export class DraftPodHostAdapter { return this.host.submitHostPick(cardInstanceId); } + async submitPickWithDraftEffect( + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + if (!this.host) throw new Error("Host not initialized"); + return this.host.submitHostPickWithDraftEffect(effectCardInstanceId, cardInstanceIds); + } + async submitDeck(mainDeck: string[]): Promise { if (!this.host) throw new Error("Host not initialized"); return this.host.submitHostDeck(mainDeck); diff --git a/client/src/adapter/p2p-draft-guest.ts b/client/src/adapter/p2p-draft-guest.ts index 897db6972d..a8bb4476d7 100644 --- a/client/src/adapter/p2p-draft-guest.ts +++ b/client/src/adapter/p2p-draft-guest.ts @@ -130,6 +130,18 @@ export class P2PDraftGuest { await this.session.send({ type: "draft_pick", cardInstanceId }); } + async submitPickWithDraftEffect( + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + if (!this.session) throw new Error("Not connected to draft host"); + await this.session.send({ + type: "draft_pick_with_draft_effect", + effectCardInstanceId, + cardInstanceIds, + }); + } + async submitDeck(mainDeck: string[]): Promise { if (!this.session) throw new Error("Not connected to draft host"); await this.session.send({ type: "draft_submit_deck", mainDeck }); diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index bce95698d5..bfddac3398 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -461,16 +461,19 @@ export class P2PDraftHost { private async handleGuestMessage(seat: number, msg: DraftP2PMessage): Promise { switch (msg.type) { case "draft_pick": { - if (!this.draftStarted || this.paused) { - this.guestSessions.get(seat)?.send({ - type: "draft_error", - reason: this.paused ? "Draft is paused" : "Draft not started", - }); - return; - } + if (!this.canGuestPick(seat)) return; await this.handlePick(seat, msg.cardInstanceId); break; } + case "draft_pick_with_draft_effect": { + if (!this.canGuestPick(seat)) return; + await this.handlePickWithDraftEffect( + seat, + msg.effectCardInstanceId, + msg.cardInstanceIds, + ); + break; + } case "draft_submit_deck": { if (!this.draftStarted) { this.guestSessions.get(seat)?.send({ @@ -594,6 +597,14 @@ export class P2PDraftHost { return this.handlePick(0, cardInstanceId); } + /** Host submits an effect pick for seat 0. */ + async submitHostPickWithDraftEffect( + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + return this.handlePickWithDraftEffect(0, effectCardInstanceId, cardInstanceIds); + } + /** * Host submits their own deck (seat 0). */ @@ -601,11 +612,28 @@ export class P2PDraftHost { return this.handleDeckSubmission(0, mainDeck); } + private assertPickAllowed(): void { + if (!this.draftStarted) throw new Error("Draft not started"); + if (this.paused) throw new Error("Draft is paused"); + } + + private canGuestPick(seat: number): boolean { + try { + this.assertPickAllowed(); + return true; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + this.guestSessions.get(seat)?.send({ type: "draft_error", reason }); + return false; + } + } + private async handlePick( seat: number, cardInstanceId: string, resolveBots = true, ): Promise { + this.assertPickAllowed(); return this.applyPick(seat, cardInstanceId, { acknowledge: true, emit: true, @@ -614,13 +642,37 @@ export class P2PDraftHost { }); } + private async handlePickWithDraftEffect( + seat: number, + effectCardInstanceId: string, + cardInstanceIds: string[], + ): Promise { + this.assertPickAllowed(); + return this.applyPick( + seat, + effectCardInstanceId, + { + acknowledge: true, + emit: true, + persist: true, + resolveBots: true, + }, + () => this.adapter.submitPickWithDraftEffectForSeat( + seat, + effectCardInstanceId, + cardInstanceIds, + ), + ); + } + private async applyPick( seat: number, cardInstanceId: string, options: PickOptions, + submitPick = () => this.adapter.submitPickForSeat(seat, cardInstanceId), ): Promise { try { - const view = await this.adapter.submitPickForSeat(seat, cardInstanceId); + const view = await submitPick(); this.picksThisRound.add(seat); // Send pick acknowledgement to the picking player @@ -2001,6 +2053,7 @@ export class P2PDraftHost { connected: i === 0 || this.guestSessions.has(i), has_submitted_deck: false, pick_status: "NotDrafting", + face_up_draft_cards: [], }); } return seats; @@ -2015,6 +2068,7 @@ export class P2PDraftHost { pass_direction: "Left", current_pack: null, pool: [], + draft_effects: [], pool_groups: EMPTY_DRAFT_POOL_GROUPS, seats: this.buildSeatPublicViews(), cards_per_pack: 14, diff --git a/client/src/components/draft/HostControls.tsx b/client/src/components/draft/HostControls.tsx index 2d02357f27..333adae463 100644 --- a/client/src/components/draft/HostControls.tsx +++ b/client/src/components/draft/HostControls.tsx @@ -92,7 +92,7 @@ export function HostControls() { return null; return ( -
+
{t("hostControls.title")}
diff --git a/client/src/components/draft/PackDisplay.tsx b/client/src/components/draft/PackDisplay.tsx index fef8f80ff6..877d97f022 100644 --- a/client/src/components/draft/PackDisplay.tsx +++ b/client/src/components/draft/PackDisplay.tsx @@ -6,6 +6,8 @@ import { useDraftStore } from "../../stores/draftStore"; import type { DraftCardInstance, DraftPlayerView } from "../../adapter/draft-adapter"; import type { CardHoverInfo } from "../card/CardPreview"; +const EMPTY_DRAFT_EFFECTS: DraftCardInstance[] = []; + // ── Card tile ─────────────────────────────────────────────────────────── interface PackCardProps { @@ -13,6 +15,7 @@ interface PackCardProps { isSelected: boolean; onSelect: (instanceId: string) => void; onConfirm: () => void; + confirmDisabled?: boolean; onHover: (info: CardHoverInfo | null) => void; } @@ -21,6 +24,7 @@ function PackCard({ isSelected, onSelect, onConfirm, + confirmDisabled = false, onHover, }: PackCardProps) { const { t } = useTranslation("draft"); @@ -60,7 +64,8 @@ function PackCard({ {isSelected ? ( @@ -115,20 +120,25 @@ interface PackDisplayProps { onCardHover: (info: CardHoverInfo | null) => void; /** Show the "Auto-pick" button when the active draft mode supports it. */ showAutoPick?: boolean; + /** Show draft-effect controls for the local Draft page. */ + enableDraftEffects?: boolean; view?: DraftPlayerView | null; selectedCard?: string | null; onSelectCard?: (instanceId: string | null) => void; onConfirmPick?: () => Promise | void; + onPickWithDraftEffect?: (effectCardInstanceId: string, cardInstanceIds: string[]) => Promise | void; onAutoPick?: () => Promise | void; } export function PackDisplay({ onCardHover, showAutoPick = false, + enableDraftEffects = false, view: viewOverride, selectedCard: selectedCardOverride, onSelectCard, onConfirmPick, + onPickWithDraftEffect, onAutoPick, }: PackDisplayProps) { const { t } = useTranslation("draft"); @@ -136,6 +146,7 @@ export function PackDisplay({ const quickSelectedCard = useDraftStore((s) => s.selectedCard); const quickSelectCard = useDraftStore((s) => s.selectCard); const quickConfirmPick = useDraftStore((s) => s.confirmPick); + const quickPickCardWithDraftEffect = useDraftStore((s) => s.pickCardWithDraftEffect); const quickAutoPickCard = useDraftStore((s) => s.autoPickCard); const [autoPicking, setAutoPicking] = useState(false); @@ -145,7 +156,10 @@ export function PackDisplay({ : quickSelectedCard; const selectCard = onSelectCard ?? quickSelectCard; const confirmPick = onConfirmPick ?? quickConfirmPick; + const pickCardWithDraftEffect = onPickWithDraftEffect ?? quickPickCardWithDraftEffect; const autoPickCard = onAutoPick ?? quickAutoPickCard; + const [activeDraftEffect, setActiveDraftEffect] = useState(null); + const [additionalCard, setAdditionalCard] = useState(null); useEffect(() => { if (view?.current_pack?.length === 1 && !selectedCard) { @@ -153,6 +167,21 @@ export function PackDisplay({ } }, [view?.current_pack, selectedCard, selectCard]); + const draftEffects = view?.draft_effects ?? EMPTY_DRAFT_EFFECTS; + + useEffect(() => { + if ( + activeDraftEffect && + !draftEffects.some((card) => card.instance_id === activeDraftEffect) + ) { + setActiveDraftEffect(null); + } + }, [activeDraftEffect, draftEffects]); + + useEffect(() => { + if (!activeDraftEffect) setAdditionalCard(null); + }, [activeDraftEffect]); + if (!view) return null; const pack = view.current_pack; @@ -174,17 +203,73 @@ export function PackDisplay({ } }; + const handleConfirmPick = async () => { + if (activeDraftEffect && selectedCard && additionalCard) { + await pickCardWithDraftEffect(activeDraftEffect, [selectedCard, additionalCard]); + setActiveDraftEffect(null); + setAdditionalCard(null); + return; + } + if (activeDraftEffect) return; + await confirmPick(); + }; + + const handleSelectCard = (instanceId: string) => { + if (!activeDraftEffect) { + selectCard(instanceId); + return; + } + if (selectedCard === instanceId) { + selectCard(null); + setAdditionalCard(null); + } else if (additionalCard === instanceId) { + setAdditionalCard(null); + } else if (!selectedCard) { + selectCard(instanceId); + } else { + setAdditionalCard(instanceId); + } + }; + + const handleToggleDraftEffect = (instanceId: string) => { + setAdditionalCard(null); + setActiveDraftEffect((current) => (current === instanceId ? null : instanceId)); + }; + const sections = groupByRarity(pack); return (
+ {enableDraftEffects && draftEffects.length > 0 && ( +
+ + {t("pack.draftEffects")} + +
+ {draftEffects.map((card) => ( + + ))} +
+
+ )}
{t("pack.cardsInPack", { count: pack.length })} {showAutoPick && (