Play liveChoose your opponent
Join
games
What shoal-games changes in this game
Branched from main at 1b3bc26 by @shoal shoal · 1 commit ahead, 0 behind main
Continue
Contribute back
62 lines · 3.5 KB Raw Permalink History
1import { randomUUID } from 'node:crypto';
2import type { GameState, GameView, GameEventEnvelope, ClientMessage, Card } from '../src/engine/types';
3
4/** Some upstream skills pass another player's hand IDs in a selection prompt. Their IDs contain
5 * the card's name and suit! Use opaque choices for that blind selection, in snapshots AND events.
6 * Public equipment remains face-up; a played/revealed card is still shown by its normal event. */
7export class BlindChoices {
8 private ids = new Map<number, Map<string, string>>();
9 private token(viewer: number, id: string): string {
10 let map = this.ids.get(viewer);
11 if (!map) this.ids.set(viewer, map = new Map());
12 if (!map.has(id)) map.set(id, `hidden-${randomUUID()}`);
13 return map.get(id)!;
14 }
15 private prompt(state: GameState, viewer: number, prompt: unknown): unknown {
16 if (!prompt || typeof prompt !== 'object') return prompt;
17 const p = prompt as { type?: string; cardIds?: string[] };
18 if (p.type !== 'distribute' || !p.cardIds) return prompt;
19 const hidden = new Set(state.players.flatMap((x, i) => i !== viewer ? x.hand : []));
20 return { ...p, cardIds: p.cardIds.map((id) => hidden.has(id) ? this.token(viewer, id) : id) };
21 }
22 view(state: GameState, viewer: number, view: GameView): GameView {
23 if (!view.pending) return view;
24 const atom = view.pending.atom as unknown as { prompt?: unknown };
25 const prompt = this.prompt(state, viewer, view.pending.prompt);
26 if (prompt === view.pending.prompt) return view;
27 const cardMap = { ...view.cardMap };
28 for (const token of this.ids.get(viewer)?.values() ?? []) cardMap[token] = blindCard(token);
29 return { ...view, cardMap, pending: { ...view.pending, prompt, atom: { ...atom, prompt } } } as GameView;
30 }
31 events(state: GameState, viewer: number, events: GameEventEnvelope[]): GameEventEnvelope[] {
32 return events.map((e) => {
33 if (e.view?.type !== '请求回应' || !e.view.prompt) return e;
34 return { ...e, view: { ...e.view, prompt: this.prompt(state, viewer, e.view.prompt) } } as GameEventEnvelope;
35 });
36 }
37 message(state: GameState, viewer: number, message: ClientMessage): ClientMessage | null {
38 const back = new Map([...this.ids.get(viewer)?.entries() ?? []].map(([a, b]) => [b, a]));
39 const hidden = new Set(state.players.flatMap((p, i) => i !== viewer ? p.hand : []));
40 const atom = state.pendingSlots.get(viewer)?.atom as unknown as { prompt?: { type?: string; cardIds?: string[] } } | undefined;
41 // Some skills (攻心) legitimately reveal another hand. Reject raw IDs only in the
42 // particular blind distribute prompt, never in an authorized reveal/selection window.
43 const protectedIds = new Set(atom?.prompt?.type === 'distribute' ? (atom.prompt.cardIds ?? []).filter((id) => hidden.has(id)) : []);
44 let invalid = false;
45 const visit = (value: unknown): unknown => {
46 if (typeof value === 'string') {
47 if (back.has(value)) return back.get(value);
48 if (protectedIds.has(value)) invalid = true;
49 return value;
50 }
51 if (Array.isArray(value)) return value.map(visit);
52 if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, visit(v)]));
53 return value;
54 };
55 const out = visit(message) as ClientMessage;
56 return invalid ? null : out;
57 }
58}
59
60export function blindCard(id: string): Card {
61 return { id, name: '暗牌', type: '基本牌', subtype: '暗牌', suit: '', rank: '', color: '无色', description: '对方的手牌;只能盲选,牌面不可见。' } as Card;
62}