// The viewer: wmzy/sanguosha's engine views and action building, drawn as 无名杀 draws a 三国杀 table (noname/Table.tsx), // fed by Shoal instead of the upstream's server. Replay mode plays a replay file from referee/ (the upstream format, // sanguosha-replay v2) with the upstream's replay hook. Live mode (?live) gets a room's messages through bridge.js, // keeps the public view and the controlled seats' own views up to date with the upstream client's own view maintainer, // and posts the seat's moves back with shoalAct. import './storage'; import '../../src/client/animations.css'; import './noname/noname.css'; import { useEffect, useReducer, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { useReplay } from '../../src/client/hooks/useReplay'; import { applyServerMessage } from '../../src/client/headless/viewMaintainer'; import { resourceManager } from '../../src/client/resources'; import type { GameView, GameEventEnvelope } from '../../src/engine/types'; import type { ReplayFile } from '../../src/client/replay/types'; import type { ActionMsg } from '../../src/client/types'; import type { QueuedEvent } from '../../src/client/hooks/useEventPlayback'; import { Table } from './noname/Table'; import { ReplayBar, ReplayView, SeatSwitch, Result, inferWinner } from './noname/Controls'; type Msg = { n?: number; to?: string; version?: number; obs?: Record; legal?: Array<{ description: string; message: unknown; validTargets: number[]; category: string }>; event?: Record; end?: { result: string; winner?: string; identities?: Record; scores?: Record } }; type Live = { views: Record; fresh: Record; asks: Record; you: string[]; end?: Msg['end']; n: number }; type Shoal = Window & { shoalReplay?: (start: (text: string, name?: string) => void) => void; shoalLive?: (fn: (data: unknown, you?: string[]) => void) => void; shoalAct?: (seat: string, version: number, action: unknown) => void; }; const w = window as Shoal; // Until kit/bridge.js has the live half, the same contract here: {shoal: 'live', room, you, messages} in, {shoal: 'act'} out. w.shoalLive ??= (fn) => { addEventListener('message', (e) => { if (e.data?.shoal === 'live') fn(e.data); }); if (parent !== window) parent.postMessage({ shoal: 'ready', live: true }, '*'); }; w.shoalAct ??= (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*'); const noop = () => {}; const PUBLIC = 'public'; // The upstream's resource packs (art and sound it doesn't ship) stay empty: the art pack is this viewer's own (art/). // Loading an empty index up front keeps it from fetching one. resourceManager.loadIndex({ packs: [] }); // The art pack's own stylesheet (its fonts and theme pieces), beside the art it names. document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: 'art/art.css' })); function apply(v: { view: GameView; seq: number } | undefined, events: GameEventEnvelope[]) { if (!v) return v; let { view, seq } = v; for (const env of events) { const prompt = env.view?.prompt as { cardIds?: string[] } | undefined; if (prompt?.cardIds?.some((id) => id.startsWith('hidden-'))) { view = { ...view, cardMap: { ...view.cardMap } }; for (const id of prompt.cardIds) if (id.startsWith('hidden-')) view.cardMap[id] = { id, name: '暗牌', type: '基本牌', subtype: '暗牌', suit: '', rank: '', color: '无色', description: '对方的手牌;只能盲选。' } as GameView['cardMap'][string]; } const r = applyServerMessage(view, seq, { type: 'event', epoch: 0, ...env } as never); if (r.view) view = r.view; seq = r.lastSeq; } return { view, seq }; } /** The engine events of a batch, as the upstream's event playback queues them (for the table's animations). */ const queued = (events: GameEventEnvelope[]): QueuedEvent[] => events.filter((e) => e.view).map((e) => ({ seq: e.seq, event: e.view! })); function reduce(s: Live, batch: { messages: Msg[]; you?: string[] }): Live { const first = s.n < 0; const next: Live = { ...s, views: { ...s.views }, fresh: {}, asks: { ...s.asks }, you: batch.you ?? s.you }; const fresh = (key: string, events: GameEventEnvelope[]) => { if (!first) next.fresh[key] = [...(next.fresh[key] ?? []), ...queued(events)]; }; for (const m of batch.messages) { if (m.n !== undefined) { if (m.n <= next.n) continue; next.n = m.n; } const e = m.event; if (e?.type === 'start') next.views[PUBLIC] = { view: e.view as GameView, seq: e.seq as number }; else if (e?.type === 'events') (next.views[PUBLIC] = apply(next.views[PUBLIC], e.events as GameEventEnvelope[])!), fresh(PUBLIC, e.events as GameEventEnvelope[]); else if ((e?.type === 'moved' || e?.type === 'control') && next.asks[e.seat as string]?.version === (e.version ?? next.asks[e.seat as string]?.version)) delete next.asks[e.seat as string]; else if (m.end) (next.end = m.end), (next.asks = {}); else if (m.to !== undefined && m.version !== undefined) next.asks[m.to] = { ...m, at: Date.now() }; else if (m.to !== undefined && m.obs?.view) next.views[m.to] = { view: m.obs.view as GameView, seq: m.obs.seq as number }; else if (m.to !== undefined && m.obs?.events) (next.views[m.to] = apply(next.views[m.to], m.obs.events as GameEventEnvelope[])!), fresh(m.to, m.obs.events as GameEventEnvelope[]); } return next; } /** The room's limit as the upstream countdown: the engine's own deadlines run on the room's virtual clock. */ function withDeadline(view: GameView, ask?: Msg & { at: number }): GameView { const ms = ask ? Number(ask.obs?.limit ?? 30) * 1000 : 0; const deadline = ask ? ask.at + ms : null; return { ...view, deadline, deadlineTotalMs: ms, pending: view.pending ? { ...view.pending, deadline: deadline ?? undefined, totalMs: ms } : null }; } const winnerCamp = (end: NonNullable) => { const w0 = Object.entries(end.scores ?? {}).find(([, v]) => v > 0)?.[0] ?? end.winner; const id = w0 !== undefined ? end.identities?.[w0] : undefined; return id === '忠臣' ? '主公' : (id ?? ''); }; function LiveTable() { const [s, dispatch] = useReducer(reduce, { views: {}, fresh: {}, asks: {}, you: [], n: -1 }); const [pick, setPick] = useState(null); const [open, setOpen] = useState(false); useEffect(() => { w.shoalLive!((data, you) => { const d = data as { messages?: Msg[]; you?: string[] } | Msg[]; dispatch(Array.isArray(d) ? { messages: d, you } : { messages: d.messages ?? [], you: d.you ?? you }); }); }, []); const seat = pick && s.you.includes(pick) ? pick : (s.you.find((x) => s.views[x]) ?? s.you[0]); const mine = seat !== undefined ? s.views[seat] : undefined; const key = mine ? seat! : PUBLIC; const base = mine ?? s.views[PUBLIC]; if (!base) return
等待房间开局…
; const ask = seat !== undefined ? s.asks[seat] : undefined; const act = (action: ActionMsg | number) => { if (!ask || seat === undefined) return; w.shoalAct!(seat, ask.version!, action); dispatch({ messages: [{ event: { type: 'moved', seat, version: ask.version } }] }); }; const view = withDeadline(base.view, ask); const moves = ask?.legal?.map((l, i) => ({ l, i })).filter(({ l }) => complete(l)) ?? []; const top = ( <> {moves.length > 0 && (
{open && (
{moves.map(({ l, i }) => ( ))}
)}
)} ); return (
: null} /> ); } /** A listed move that needs nothing more (no targets or cards left to pick): one click plays it. */ function complete(l: { message: unknown; validTargets: number[] }): boolean { const p = ((l.message as { params?: Record }).params ?? {}) as Record; if (l.validTargets.length && !Array.isArray(p.targets)) return false; return !Object.values(p).some((v) => Array.isArray(v) && !v.length); } function Replay({ file }: { file: ReplayFile & { shoal?: { seat?: number; names?: string[] } } }) { const r = useReplay(file); const focus = file.shoal?.seat; useEffect(() => { if (focus !== undefined && r.seats.includes(focus)) r.setSeat(focus); // once, on load }, []); // eslint-disable-line react-hooks/exhaustive-deps if (!r.view) return
…
; const names = file.shoal?.names ?? r.view.players.map((p) => p.name); const ended = r.step >= r.total && r.total > 0; return (
} side={} overlay={ended ? : null} /> ); } function App() { const live = new URLSearchParams(location.search).has('live'); const [file, setFile] = useState(null); const [error, setError] = useState(''); useEffect(() => { if (live) return; w.shoalReplay?.((text) => { try { setFile(JSON.parse(text)); } catch { setError('这个文件不是三国杀录像'); } }); }, [live]); if (live) return ; if (error) return
{error}
; return file ? :
等待录像…
; } createRoot(document.getElementById('root')!).render();