// The table as the board draws it, built the same way from a replay (all hands known) and from a live room's messages // (only the hands of the seats this viewer controls). Cards come in RLCard's suited notation ('S3', 'HT', 'BJ', 'RJ'); // moves in RLCard's ('33344', 'BR', 'pass'), ranks always in the order of ORDER. import type { Card, Suit } from './deskpark/PlayingCard'; export const ORDER = '3456789TJQKA2BR'; const RANK: Record = { T: 10, J: 11, Q: 12, K: 13, A: 14 }; const CHAR: Record = { 10: 'T', 11: 'J', 12: 'Q', 13: 'K', 14: 'A', 16: 'B', 17: 'R' }; export function card(s: string): Card { if (s === 'BJ' || s === 'RJ') return { id: s === 'BJ' ? 116 : 117, suit: 'J', rank: s === 'BJ' ? 16 : 17 }; const rank = RANK[s.slice(1)] ?? Number(s.slice(1)); return { id: 'SHDC'.indexOf(s[0]) * 20 + rank, suit: s[0] as Suit, rank }; } export const rankChar = (c: Card) => CHAR[c.rank] ?? String(c.rank); const value = (c: Card) => ORDER.indexOf(rankChar(c)); /** big to small, as the card rooms hold a hand */ export const sortHand = (cards: Card[]) => [...cards].sort((a, b) => value(b) - value(a) || a.id - b.id); /** the move a set of cards makes, written as RLCard writes it */ export const moveOf = (cards: Card[]) => cards .map(rankChar) .sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b)) .join(''); export const isBomb = (m: string) => m === 'BR' || (m.length === 4 && new Set(m).size === 1); /** the cards of a hand that make a move, one per rank letter */ export function cardsFor(hand: Card[], move: string): Card[] { const left = [...hand]; const out: Card[] = []; for (const r of move) { const i = left.findIndex((c) => rankChar(c) === r); if (i >= 0) out.push(...left.splice(i, 1)); } return out; } export type Move = null | 'pass' | Card[]; export interface View { names: string[]; bots: boolean[]; landlord: number | null; bottom: Card[]; hands: (Card[] | null)[]; counts: number[]; /** what each seat put down in the current trick */ moves: Move[]; turn: number | null; played: Card[]; bombs: number; step: number; /** the effect of the move just made: 炸弹, 王炸, 春天 */ fx: string | null; lastBy: number; result: null | { landlordWon: boolean | null; points: number[]; spring: boolean }; } export function blank(): View { return { names: ['player 0', 'player 1', 'player 2'], bots: [false, false, false], landlord: null, bottom: [], hands: [null, null, null], counts: [0, 0, 0], moves: [null, null, null], turn: null, played: [], bombs: 0, step: 0, fx: null, lastBy: -1, result: null, }; } /** One move: the cards (or the pass) go in front of the seat; a seat whose play stood all round leads a new trick. */ function apply(v: View, seat: number, cards: Card[] | null, move: string) { if (v.lastBy === seat) v.moves = [null, null, null]; v.moves[seat] = cards ?? 'pass'; v.fx = null; if (cards) { v.lastBy = seat; v.counts[seat] -= cards.length; v.played.push(...cards); if (isBomb(move)) { v.bombs++; v.fx = move === 'BR' ? '王炸' : '炸弹'; } } v.step++; v.turn = (seat + 1) % 3; } export interface Replay { initHands: string[]; landlordCards: string; bottom?: string[]; moveHistory: { playerIdx: number; move: string }[]; landlordWon: boolean | null; points: number[]; spring: boolean; playerInfo?: { name: string }[]; } /** the table after the first t moves of a replay */ export function replayView(R: Replay, t: number): View { const v = blank(); if (R.playerInfo) v.names = R.playerInfo.map((p) => p.name); // RLCard deals each hand low to high and plays the first card of a rank it finds: taking them in that order gives // the very cards it played const hands = R.initHands.map((h) => h.split(' ').map(card)); v.landlord = 0; v.bottom = R.bottom ? R.bottom.map(card) : cardsFor(hands[0], R.landlordCards); // older replays name ranks only v.hands = hands; v.counts = hands.map((h) => h.length); v.turn = 0; for (const m of R.moveHistory.slice(0, t)) { const hand = hands[m.playerIdx]; let cards: Card[] | null = null; if (m.move !== 'pass') { cards = cardsFor(hand, m.move); for (const c of cards) hand.splice(hand.indexOf(c), 1); } apply(v, m.playerIdx, cards, m.move); } if (t >= R.moveHistory.length) { v.turn = null; v.result = { landlordWon: R.landlordWon, points: R.points, spring: R.spring }; if (R.spring) v.fx = '春天'; } else v.turn = R.moveHistory[t].playerIdx; return v; } /* eslint-disable @typescript-eslint/no-explicit-any */ export interface Msg { n: number; to?: string; version?: number; obs?: any; legal?: string[]; event?: any; end?: { result: string; scores: Record }; } export interface Req { seat: number; version: number; legal: string[]; } /** the table from a room's messages in order, and the requests still open to the seats this viewer controls */ export function liveView(msgs: Msg[], you: string[]): { v: View; reqs: Req[] } { const v = blank(); const open: (Req | null)[] = [null, null, null]; let winner: string | null = null; let spring = false; for (const m of msgs) { const e = m.event; if (e) { const s = Number(e.seat); if (e.type === 'deal') { v.landlord = Number(e.landlord); v.bottom = e.bottom.map(card); v.counts = [0, 1, 2].map((i) => e.left[i]); for (const [k, p] of Object.entries(e.seats ?? {})) { v.names[Number(k)] = p.name; v.bots[Number(k)] = p.kind === 'bot' || p.kind === 'auto'; } v.turn = v.landlord; } else if (e.type === 'play' || e.type === 'pass') { const cards = e.type === 'play' ? (e.cards as string[]).map(card) : null; const hand = v.hands[s]; if (hand && cards) v.hands[s] = hand.filter((c) => !cards.some((x) => x.id === c.id)); apply(v, s, cards, e.move ?? 'pass'); open[s] = null; } else if (e.type === 'control') { v.names[s] = e.name; v.bots[s] = e.kind === 'bot' || e.kind === 'auto'; } else if (e.type === 'over') { v.hands = [0, 1, 2].map((i) => (e.hands[i] as string[]).map(card)); winner = e.winner; spring = !!e.spring; } } else if (m.end) { v.turn = null; v.result = { landlordWon: winner === null ? null : winner === 'landlord', points: [0, 1, 2].map((i) => m.end!.scores[i] ?? 0), spring, }; v.fx = spring ? '春天' : null; } else if (m.to !== undefined && m.obs) { const s = Number(m.to); if (m.obs.hand) v.hands[s] = (m.obs.hand as string[]).map(card); if (m.version !== undefined) open[s] = { seat: s, version: m.version, legal: m.legal ?? [] }; } } return { v, reqs: open.filter((r): r is Req => !!r && you.includes(String(r.seat))) }; }