import { randomUUID } from 'node:crypto'; import type { GameState, GameView, GameEventEnvelope, ClientMessage, Card } from '../src/engine/types'; /** Some upstream skills pass another player's hand IDs in a selection prompt. Their IDs contain * the card's name and suit! Use opaque choices for that blind selection, in snapshots AND events. * Public equipment remains face-up; a played/revealed card is still shown by its normal event. */ export class BlindChoices { private ids = new Map>(); private token(viewer: number, id: string): string { let map = this.ids.get(viewer); if (!map) this.ids.set(viewer, map = new Map()); if (!map.has(id)) map.set(id, `hidden-${randomUUID()}`); return map.get(id)!; } private prompt(state: GameState, viewer: number, prompt: unknown): unknown { if (!prompt || typeof prompt !== 'object') return prompt; const p = prompt as { type?: string; cardIds?: string[] }; if (p.type !== 'distribute' || !p.cardIds) return prompt; const hidden = new Set(state.players.flatMap((x, i) => i !== viewer ? x.hand : [])); return { ...p, cardIds: p.cardIds.map((id) => hidden.has(id) ? this.token(viewer, id) : id) }; } view(state: GameState, viewer: number, view: GameView): GameView { if (!view.pending) return view; const atom = view.pending.atom as unknown as { prompt?: unknown }; const prompt = this.prompt(state, viewer, view.pending.prompt); if (prompt === view.pending.prompt) return view; const cardMap = { ...view.cardMap }; for (const token of this.ids.get(viewer)?.values() ?? []) cardMap[token] = blindCard(token); return { ...view, cardMap, pending: { ...view.pending, prompt, atom: { ...atom, prompt } } } as GameView; } events(state: GameState, viewer: number, events: GameEventEnvelope[]): GameEventEnvelope[] { return events.map((e) => { if (e.view?.type !== '请求回应' || !e.view.prompt) return e; return { ...e, view: { ...e.view, prompt: this.prompt(state, viewer, e.view.prompt) } } as GameEventEnvelope; }); } message(state: GameState, viewer: number, message: ClientMessage): ClientMessage | null { const back = new Map([...this.ids.get(viewer)?.entries() ?? []].map(([a, b]) => [b, a])); const hidden = new Set(state.players.flatMap((p, i) => i !== viewer ? p.hand : [])); const atom = state.pendingSlots.get(viewer)?.atom as unknown as { prompt?: { type?: string; cardIds?: string[] } } | undefined; // Some skills (攻心) legitimately reveal another hand. Reject raw IDs only in the // particular blind distribute prompt, never in an authorized reveal/selection window. const protectedIds = new Set(atom?.prompt?.type === 'distribute' ? (atom.prompt.cardIds ?? []).filter((id) => hidden.has(id)) : []); let invalid = false; const visit = (value: unknown): unknown => { if (typeof value === 'string') { if (back.has(value)) return back.get(value); if (protectedIds.has(value)) invalid = true; return value; } if (Array.isArray(value)) return value.map(visit); if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, visit(v)])); return value; }; const out = visit(message) as ClientMessage; return invalid ? null : out; } } export function blindCard(id: string): Card { return { id, name: '暗牌', type: '基本牌', subtype: '暗牌', suit: '', rank: '', color: '无色', description: '对方的手牌;只能盲选,牌面不可见。' } as Card; }