Play liveChoose your opponent
Join
32fe633
207 lines · 9.9 KB Raw Permalink History
1// The viewer: wmzy/sanguosha's engine views and action building, drawn as 无名杀 draws a 三国杀 table (noname/Table.tsx),
2// fed by Shoal instead of the upstream's server. Replay mode plays a replay file from referee/ (the upstream format,
3// sanguosha-replay v2) with the upstream's replay hook. Live mode (?live) gets a room's messages through bridge.js,
4// keeps the public view and the controlled seats' own views up to date with the upstream client's own view maintainer,
5// and posts the seat's moves back with shoalAct.
6import './storage';
7import '../../src/client/animations.css';
8import './noname/noname.css';
9import { useEffect, useReducer, useState } from 'react';
10import { createRoot } from 'react-dom/client';
11import { useReplay } from '../../src/client/hooks/useReplay';
12import { applyServerMessage } from '../../src/client/headless/viewMaintainer';
13import { resourceManager } from '../../src/client/resources';
14import type { GameView, GameEventEnvelope } from '../../src/engine/types';
15import type { ReplayFile } from '../../src/client/replay/types';
16import type { ActionMsg } from '../../src/client/types';
17import type { QueuedEvent } from '../../src/client/hooks/useEventPlayback';
18import { Table } from './noname/Table';
19import { ReplayBar, ReplayView, SeatSwitch, Result, inferWinner } from './noname/Controls';
20
21type Msg = { n?: number; to?: string; version?: number; obs?: Record<string, unknown>; legal?: Array<{ description: string; message: unknown; validTargets: number[]; category: string }>; event?: Record<string, unknown>; end?: { result: string; winner?: string; identities?: Record<string, string>; scores?: Record<string, number> } };
22type Live = { views: Record<string, { view: GameView; seq: number }>; fresh: Record<string, QueuedEvent[]>; asks: Record<string, Msg & { at: number }>; you: string[]; end?: Msg['end']; n: number };
23type Shoal = Window & {
24 shoalReplay?: (start: (text: string, name?: string) => void) => void;
25 shoalLive?: (fn: (data: unknown, you?: string[]) => void) => void;
26 shoalAct?: (seat: string, version: number, action: unknown) => void;
27};
28const w = window as Shoal;
29// Until kit/bridge.js has the live half, the same contract here: {shoal: 'live', room, you, messages} in, {shoal: 'act'} out.
30w.shoalLive ??= (fn) => {
31 addEventListener('message', (e) => {
32 if (e.data?.shoal === 'live') fn(e.data);
33 });
34 if (parent !== window) parent.postMessage({ shoal: 'ready', live: true }, '*');
35};
36w.shoalAct ??= (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*');
37
38const noop = () => {};
39const PUBLIC = 'public';
40// The upstream's resource packs (art and sound it doesn't ship) stay empty: the art pack is this viewer's own (art/).
41// Loading an empty index up front keeps it from fetching one.
42resourceManager.loadIndex({ packs: [] });
43// The art pack's own stylesheet (its fonts and theme pieces), beside the art it names.
44document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: 'art/art.css' }));
45
46function apply(v: { view: GameView; seq: number } | undefined, events: GameEventEnvelope[]) {
47 if (!v) return v;
48 let { view, seq } = v;
49 for (const env of events) {
50 const r = applyServerMessage(view, seq, { type: 'event', epoch: 0, ...env } as never);
51 if (r.view) view = r.view;
52 seq = r.lastSeq;
53 }
54 return { view, seq };
55}
56
57/** The engine events of a batch, as the upstream's event playback queues them (for the table's animations). */
58const queued = (events: GameEventEnvelope[]): QueuedEvent[] => events.filter((e) => e.view).map((e) => ({ seq: e.seq, event: e.view! }));
59
60function reduce(s: Live, batch: { messages: Msg[]; you?: string[] }): Live {
61 const first = s.n < 0;
62 const next: Live = { ...s, views: { ...s.views }, fresh: {}, asks: { ...s.asks }, you: batch.you ?? s.you };
63 const fresh = (key: string, events: GameEventEnvelope[]) => {
64 if (!first) next.fresh[key] = [...(next.fresh[key] ?? []), ...queued(events)];
65 };
66 for (const m of batch.messages) {
67 if (m.n !== undefined) {
68 if (m.n <= next.n) continue;
69 next.n = m.n;
70 }
71 const e = m.event;
72 if (e?.type === 'start') next.views[PUBLIC] = { view: e.view as GameView, seq: e.seq as number };
73 else if (e?.type === 'events') (next.views[PUBLIC] = apply(next.views[PUBLIC], e.events as GameEventEnvelope[])!), fresh(PUBLIC, e.events as GameEventEnvelope[]);
74 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];
75 else if (m.end) (next.end = m.end), (next.asks = {});
76 else if (m.to !== undefined && m.version !== undefined) next.asks[m.to] = { ...m, at: Date.now() };
77 else if (m.to !== undefined && m.obs?.view) next.views[m.to] = { view: m.obs.view as GameView, seq: m.obs.seq as number };
78 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[]);
79 }
80 return next;
81}
82
83/** The room's limit as the upstream countdown: the engine's own deadlines run on the room's virtual clock. */
84function withDeadline(view: GameView, ask?: Msg & { at: number }): GameView {
85 const ms = ask ? Number(ask.obs?.limit ?? 30) * 1000 : 0;
86 const deadline = ask ? ask.at + ms : null;
87 return { ...view, deadline, deadlineTotalMs: ms, pending: view.pending ? { ...view.pending, deadline: deadline ?? undefined, totalMs: ms } : null };
88}
89
90const winnerCamp = (end: NonNullable<Live['end']>) => {
91 const w0 = Object.entries(end.scores ?? {}).find(([, v]) => v > 0)?.[0] ?? end.winner;
92 const id = w0 !== undefined ? end.identities?.[w0] : undefined;
93 return id === '忠臣' ? '主公' : (id ?? '');
94};
95
96function LiveTable() {
97 const [s, dispatch] = useReducer(reduce, { views: {}, fresh: {}, asks: {}, you: [], n: -1 });
98 const [pick, setPick] = useState<string | null>(null);
99 const [open, setOpen] = useState(false);
100 useEffect(() => {
101 w.shoalLive!((data, you) => {
102 const d = data as { messages?: Msg[]; you?: string[] } | Msg[];
103 dispatch(Array.isArray(d) ? { messages: d, you } : { messages: d.messages ?? [], you: d.you ?? you });
104 });
105 }, []);
106 const seat = pick && s.you.includes(pick) ? pick : (s.you.find((x) => s.views[x]) ?? s.you[0]);
107 const mine = seat !== undefined ? s.views[seat] : undefined;
108 const key = mine ? seat! : PUBLIC;
109 const base = mine ?? s.views[PUBLIC];
110 if (!base) return <div className="shoal-wait">等待房间开局…</div>;
111 const ask = seat !== undefined ? s.asks[seat] : undefined;
112 const act = (action: ActionMsg | number) => {
113 if (!ask || seat === undefined) return;
114 w.shoalAct!(seat, ask.version!, action);
115 dispatch({ messages: [{ event: { type: 'moved', seat, version: ask.version } }] });
116 };
117 const view = withDeadline(base.view, ask);
118 const moves = ask?.legal?.map((l, i) => ({ l, i })).filter(({ l }) => complete(l)) ?? [];
119 const top = (
120 <>
121 <SeatSwitch seats={s.you} seat={seat} view={view} onPick={setPick} />
122 {moves.length > 0 && (
123 <div className="nm-moves">
124 <button className="nm-mini" onClick={() => setOpen(!open)} title="The room's list of moves for this seat">
125 {open ? '收起' : `操作 ${moves.length}`}
126 </button>
127 {open && (
128 <div className="nm-moves-list">
129 {moves.map(({ l, i }) => (
130 <button key={i} onClick={() => act(i)} title={JSON.stringify(l.message)}>
131 {l.description}
132 </button>
133 ))}
134 </div>
135 )}
136 </div>
137 )}
138 </>
139 );
140 return (
141 <div className="nm-root">
142 <Table
143 view={view}
144 onAction={act}
145 readOnly={!mine}
146 ingestedEvents={s.fresh[key]}
147 top={top}
148 overlay={s.end ? <Result view={view} camp={winnerCamp(s.end)} identities={s.end.identities} me={view.viewer} /> : null}
149 />
150 </div>
151 );
152}
153
154/** A listed move that needs nothing more (no targets or cards left to pick): one click plays it. */
155function complete(l: { message: unknown; validTargets: number[] }): boolean {
156 const p = ((l.message as { params?: Record<string, unknown> }).params ?? {}) as Record<string, unknown>;
157 if (l.validTargets.length && !Array.isArray(p.targets)) return false;
158 return !Object.values(p).some((v) => Array.isArray(v) && !v.length);
159}
160
161function Replay({ file }: { file: ReplayFile & { shoal?: { seat?: number; names?: string[] } } }) {
162 const r = useReplay(file);
163 const focus = file.shoal?.seat;
164 useEffect(() => {
165 if (focus !== undefined && r.seats.includes(focus)) r.setSeat(focus);
166 // once, on load
167 }, []); // eslint-disable-line react-hooks/exhaustive-deps
168 if (!r.view) return <div className="shoal-wait">…</div>;
169 const names = file.shoal?.names ?? r.view.players.map((p) => p.name);
170 const ended = r.step >= r.total && r.total > 0;
171 return (
172 <div className="nm-root">
173 <Table
174 view={r.view}
175 onAction={noop}
176 readOnly
177 currentEvent={r.currentEvent}
178 ingestedEvents={r.ingestedEvents}
179 labels={file.shoal?.names}
180 top={<ReplayBar r={r} />}
181 side={<ReplayView r={r} names={names} />}
182 overlay={ended ? <Result view={r.view} camp={inferWinner(r.view)} me={r.view.viewer} labels={file.shoal?.names} /> : null}
183 />
184 </div>
185 );
186}
187
188function App() {
189 const live = new URLSearchParams(location.search).has('live');
190 const [file, setFile] = useState<ReplayFile | null>(null);
191 const [error, setError] = useState('');
192 useEffect(() => {
193 if (live) return;
194 w.shoalReplay?.((text) => {
195 try {
196 setFile(JSON.parse(text));
197 } catch {
198 setError('这个文件不是三国杀录像');
199 }
200 });
201 }, [live]);
202 if (live) return <LiveTable />;
203 if (error) return <div className="shoal-wait">{error}</div>;
204 return file ? <Replay file={file} /> : <div className="shoal-wait">等待录像…</div>;
205}
206
207createRoot(document.getElementById('root')!).render(<App />);