| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | import './storage'; |
| 7 | import '../../src/client/animations.css'; |
| 8 | import './noname/noname.css'; |
| 9 | import { useEffect, useReducer, useState } from 'react'; |
| 10 | import { createRoot } from 'react-dom/client'; |
| 11 | import { useReplay } from '../../src/client/hooks/useReplay'; |
| 12 | import { applyServerMessage } from '../../src/client/headless/viewMaintainer'; |
| 13 | import { resourceManager } from '../../src/client/resources'; |
| 14 | import type { GameView, GameEventEnvelope } from '../../src/engine/types'; |
| 15 | import type { ReplayFile } from '../../src/client/replay/types'; |
| 16 | import type { ActionMsg } from '../../src/client/types'; |
| 17 | import type { QueuedEvent } from '../../src/client/hooks/useEventPlayback'; |
| 18 | import { Table } from './noname/Table'; |
| 19 | import { ReplayBar, ReplayView, SeatSwitch, Result, inferWinner } from './noname/Controls'; |
| 20 | |
| 21 | type 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> } }; |
| 22 | type 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 }; |
| 23 | type 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 | }; |
| 28 | const w = window as Shoal; |
| 29 | |
| 30 | w.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 | }; |
| 36 | w.shoalAct ??= (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*'); |
| 37 | |
| 38 | const noop = () => {}; |
| 39 | const PUBLIC = 'public'; |
| 40 | |
| 41 | |
| 42 | resourceManager.loadIndex({ packs: [] }); |
| 43 | |
| 44 | document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: 'art/art.css' })); |
| 45 | |
| 46 | function 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 prompt = env.view?.prompt as { cardIds?: string[] } | undefined; |
| 51 | if (prompt?.cardIds?.some((id) => id.startsWith('hidden-'))) { |
| 52 | view = { ...view, cardMap: { ...view.cardMap } }; |
| 53 | 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]; |
| 54 | } |
| 55 | const r = applyServerMessage(view, seq, { type: 'event', epoch: 0, ...env } as never); |
| 56 | if (r.view) view = r.view; |
| 57 | seq = r.lastSeq; |
| 58 | } |
| 59 | return { view, seq }; |
| 60 | } |
| 61 | |
| 62 | |
| 63 | const queued = (events: GameEventEnvelope[]): QueuedEvent[] => events.filter((e) => e.view).map((e) => ({ seq: e.seq, event: e.view! })); |
| 64 | |
| 65 | function reduce(s: Live, batch: { messages: Msg[]; you?: string[] }): Live { |
| 66 | const first = s.n < 0; |
| 67 | const next: Live = { ...s, views: { ...s.views }, fresh: {}, asks: { ...s.asks }, you: batch.you ?? s.you }; |
| 68 | const fresh = (key: string, events: GameEventEnvelope[]) => { |
| 69 | if (!first) next.fresh[key] = [...(next.fresh[key] ?? []), ...queued(events)]; |
| 70 | }; |
| 71 | for (const m of batch.messages) { |
| 72 | if (m.n !== undefined) { |
| 73 | if (m.n <= next.n) continue; |
| 74 | next.n = m.n; |
| 75 | } |
| 76 | const e = m.event; |
| 77 | if (e?.type === 'start') next.views[PUBLIC] = { view: e.view as GameView, seq: e.seq as number }; |
| 78 | else if (e?.type === 'events') (next.views[PUBLIC] = apply(next.views[PUBLIC], e.events as GameEventEnvelope[])!), fresh(PUBLIC, e.events as GameEventEnvelope[]); |
| 79 | 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]; |
| 80 | else if (m.end) (next.end = m.end), (next.asks = {}); |
| 81 | else if (m.to !== undefined && m.version !== undefined) next.asks[m.to] = { ...m, at: Date.now() }; |
| 82 | else if (m.to !== undefined && m.obs?.view) next.views[m.to] = { view: m.obs.view as GameView, seq: m.obs.seq as number }; |
| 83 | 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[]); |
| 84 | } |
| 85 | return next; |
| 86 | } |
| 87 | |
| 88 | |
| 89 | function withDeadline(view: GameView, ask?: Msg & { at: number }): GameView { |
| 90 | const ms = ask ? Number(ask.obs?.limit ?? 30) * 1000 : 0; |
| 91 | const deadline = ask ? ask.at + ms : null; |
| 92 | return { ...view, deadline, deadlineTotalMs: ms, pending: view.pending ? { ...view.pending, deadline: deadline ?? undefined, totalMs: ms } : null }; |
| 93 | } |
| 94 | |
| 95 | const winnerCamp = (end: NonNullable<Live['end']>) => { |
| 96 | const w0 = Object.entries(end.scores ?? {}).find(([, v]) => v > 0)?.[0] ?? end.winner; |
| 97 | const id = w0 !== undefined ? end.identities?.[w0] : undefined; |
| 98 | return id === '忠臣' ? '主公' : (id ?? ''); |
| 99 | }; |
| 100 | |
| 101 | function LiveTable() { |
| 102 | const [s, dispatch] = useReducer(reduce, { views: {}, fresh: {}, asks: {}, you: [], n: -1 }); |
| 103 | const [pick, setPick] = useState<string | null>(null); |
| 104 | const [open, setOpen] = useState(false); |
| 105 | useEffect(() => { |
| 106 | w.shoalLive!((data, you) => { |
| 107 | const d = data as { messages?: Msg[]; you?: string[] } | Msg[]; |
| 108 | dispatch(Array.isArray(d) ? { messages: d, you } : { messages: d.messages ?? [], you: d.you ?? you }); |
| 109 | }); |
| 110 | }, []); |
| 111 | const seat = pick && s.you.includes(pick) ? pick : (s.you.find((x) => s.views[x]) ?? s.you[0]); |
| 112 | const mine = seat !== undefined ? s.views[seat] : undefined; |
| 113 | const key = mine ? seat! : PUBLIC; |
| 114 | const base = mine ?? s.views[PUBLIC]; |
| 115 | if (!base) return <div className="shoal-wait">等待房间开局…</div>; |
| 116 | const ask = seat !== undefined ? s.asks[seat] : undefined; |
| 117 | const act = (action: ActionMsg | number) => { |
| 118 | if (!ask || seat === undefined) return; |
| 119 | w.shoalAct!(seat, ask.version!, action); |
| 120 | dispatch({ messages: [{ event: { type: 'moved', seat, version: ask.version } }] }); |
| 121 | }; |
| 122 | const view = withDeadline(base.view, ask); |
| 123 | const moves = ask?.legal?.map((l, i) => ({ l, i })).filter(({ l }) => complete(l)) ?? []; |
| 124 | const top = ( |
| 125 | <> |
| 126 | <SeatSwitch seats={s.you} seat={seat} view={view} onPick={setPick} /> |
| 127 | {moves.length > 0 && ( |
| 128 | <div className="nm-moves"> |
| 129 | <button className="nm-mini" onClick={() => setOpen(!open)} title="The room's list of moves for this seat"> |
| 130 | {open ? '收起' : `操作 ${moves.length}`} |
| 131 | </button> |
| 132 | {open && ( |
| 133 | <div className="nm-moves-list"> |
| 134 | {moves.map(({ l, i }) => ( |
| 135 | <button key={i} onClick={() => act(i)} title={JSON.stringify(l.message)}> |
| 136 | {l.description} |
| 137 | </button> |
| 138 | ))} |
| 139 | </div> |
| 140 | )} |
| 141 | </div> |
| 142 | )} |
| 143 | </> |
| 144 | ); |
| 145 | return ( |
| 146 | <div className="nm-root"> |
| 147 | <Table |
| 148 | view={view} |
| 149 | onAction={act} |
| 150 | readOnly={!mine || !ask || !!s.end} |
| 151 | ingestedEvents={s.fresh[key]} |
| 152 | top={top} |
| 153 | overlay={s.end ? <Result view={view} camp={winnerCamp(s.end)} identities={s.end.identities} me={view.viewer} /> : null} |
| 154 | /> |
| 155 | </div> |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | |
| 160 | function complete(l: { message: unknown; validTargets: number[] }): boolean { |
| 161 | const p = ((l.message as { params?: Record<string, unknown> }).params ?? {}) as Record<string, unknown>; |
| 162 | if (l.validTargets.length && !Array.isArray(p.targets)) return false; |
| 163 | return !Object.values(p).some((v) => Array.isArray(v) && !v.length); |
| 164 | } |
| 165 | |
| 166 | function Replay({ file }: { file: ReplayFile & { shoal?: { seat?: number; names?: string[] } } }) { |
| 167 | const r = useReplay(file); |
| 168 | const focus = file.shoal?.seat; |
| 169 | useEffect(() => { |
| 170 | if (focus !== undefined && r.seats.includes(focus)) r.setSeat(focus); |
| 171 | |
| 172 | }, []); |
| 173 | if (!r.view) return <div className="shoal-wait">…</div>; |
| 174 | const names = file.shoal?.names ?? r.view.players.map((p) => p.name); |
| 175 | const ended = r.step >= r.total && r.total > 0; |
| 176 | return ( |
| 177 | <div className="nm-root"> |
| 178 | <Table |
| 179 | view={r.view} |
| 180 | onAction={noop} |
| 181 | readOnly |
| 182 | currentEvent={r.currentEvent} |
| 183 | ingestedEvents={r.ingestedEvents} |
| 184 | labels={file.shoal?.names} |
| 185 | top={<ReplayBar r={r} />} |
| 186 | side={<ReplayView r={r} names={names} />} |
| 187 | overlay={ended ? <Result view={r.view} camp={inferWinner(r.view)} me={r.view.viewer} labels={file.shoal?.names} /> : null} |
| 188 | /> |
| 189 | </div> |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | function App() { |
| 194 | const live = new URLSearchParams(location.search).has('live'); |
| 195 | const [file, setFile] = useState<ReplayFile | null>(null); |
| 196 | const [error, setError] = useState(''); |
| 197 | useEffect(() => { |
| 198 | if (live) return; |
| 199 | w.shoalReplay?.((text) => { |
| 200 | try { |
| 201 | setFile(JSON.parse(text)); |
| 202 | } catch { |
| 203 | setError('这个文件不是三国杀录像'); |
| 204 | } |
| 205 | }); |
| 206 | }, [live]); |
| 207 | if (live) return <LiveTable />; |
| 208 | if (error) return <div className="shoal-wait">{error}</div>; |
| 209 | return file ? <Replay file={file} /> : <div className="shoal-wait">等待录像…</div>; |
| 210 | } |
| 211 | |
| 212 | createRoot(document.getElementById('root')!).render(<App />); |