Play liveChoose your opponent
Join
32fe633
335 lines · 15.9 KB Raw Permalink History
1// A Sanguosha table on the upstream engine, with no server: wmzy/sanguosha's create/bootstrap/dispatch on a virtual
2// clock, the views its browser gets (buildView), the moves its headless client and MCP server list
3// (HeadlessGameClient.getAvailableActions), its heuristic AI (rankActions) and its replay format (buildReplayFile).
4// The room (room.ts) and the check (play.ts) both drive games through this, so a bot sees exactly what the MCP sees.
5import { create, bootstrap, dispatch, buildView, checkGameOver, type GameConfig } from '../src/engine/index';
6import { findActionEntry } from '../src/engine/core/skill';
7import { VirtualClock } from '../src/engine/core/clock';
8import { allCharacters } from '../src/engine/data/characters';
9import { HeadlessGameClient } from '../src/client/headless/HeadlessGameClient';
10import { registerSkillActions, clearRegistry } from '../src/client/skillActionRegistry';
11import { projectView } from '../src/ai-mcp/viewProjector';
12import { rankActions } from '../src/ai-mcp/heuristics';
13import { getSkillDescriptionAsync } from '../src/engine/skills/lifecycle';
14import { buildReplayFile } from '../src/server/gameHistory';
15import type { AvailableAction, AiViewSnapshot } from '../src/client/headless/types';
16import type { ClientMessage, GameState, GameView, Card } from '../src/engine/types';
17
18export type Legal = AvailableAction;
19export type Snapshot = AiViewSnapshot;
20const CAMP: Record<string, string> = { 主公: '主公', 忠臣: '主公', 反贼: '反贼', 内奸: '内奸' };
21const POOL = allCharacters.map((c) => ({ name: c.name, skills: c.skills.map((s) => s.name) }));
22// Engine time per move on the virtual clock: replays get spacing, and a seat with nothing to answer (a "silent" ask,
23// so nobody learns it holds no 闪) waits its short delay on this clock, not on the wall.
24const TICK_MS = 500;
25
26/** What one seat may do now: its view (as the browser gets it), the MCP's snapshot of it, and the moves. */
27export interface Ask {
28 seat: number;
29 view: GameView;
30 obs: Snapshot & { game: string; recommended: number | null };
31 legal: Legal[];
32 key: string;
33}
34
35export class Table {
36 state!: GameState;
37 n: number;
38 seed: number;
39 private readonly clock = new VirtualClock();
40 private config!: GameConfig;
41 // The headless client is only used offline, for its move list: getAvailableActions reads _view and _seatIndex.
42 private hgc = new HeadlessGameClient('http://offline.invalid');
43 private skillsKey = new Map<number, string>();
44 private baseline: GameView[] = [];
45 private baselineSeq = 0;
46 over: { winner?: number } | null = null;
47
48 id: string;
49 private static games = 0;
50
51 constructor(seed: number, n = 5) {
52 this.seed = seed;
53 this.n = n;
54 this.id = `sgs-${seed}-${++Table.games}`;
55 }
56
57 async open(names: string[]): Promise<void> {
58 clearRegistry();
59 const config: GameConfig = (this.config = {
60 characters: POOL,
61 playerCount: this.n,
62 seed: this.seed,
63 gameId: this.id,
64 handSize: 4,
65 timeoutSec: 30,
66 mode: '身份局' as const,
67 playerNames: names,
68 });
69 this.state = create(config);
70 this.state.clock = this.clock;
71 this.state.startedAt = this.clock.now();
72 await bootstrap(this.state, config);
73 await this.settle();
74 }
75
76 private async quiet(ticks: number): Promise<void> {
77 let last = -1;
78 for (let quiet = 0; quiet < ticks; ) {
79 await new Promise((r) => setImmediate(r));
80 if (this.state.seq === last) quiet++;
81 else (quiet = 0), (last = this.state.seq);
82 }
83 }
84
85 /** Let the engine run its fire-and-forget execute chain to the next ask. */
86 private async settle(): Promise<void> {
87 await this.quiet(3);
88 if (!this.baseline.length && this.state.players.every((p) => p.character) && !this.state.charSelecting) {
89 // The replay starts once every general is chosen and shown, like the upstream server's recording.
90 this.baseline = [...Array(this.n).keys(), -1].map((v) => JSON.parse(JSON.stringify(buildView(this.state, v))));
91 this.baselineSeq = this.state.seq;
92 }
93 if (!this.over) {
94 const r = await checkGameOver(this.state);
95 if (r.gameOver) this.over = { winner: r.winner };
96 }
97 }
98
99 /** The seat's ask, or null when it has nothing to do (HeadlessGameClient.needsAction, seat by seat). */
100 async ask(seat: number): Promise<Ask | null> {
101 if (this.over) return null;
102 const view = buildView(this.state, seat);
103 const p = view.pending;
104 if (!p) return null;
105 if (p.target >= 0 && p.target !== seat) return null;
106 if (p.target === seat && p.responseMode === 'silent') return null;
107 if (p.target === seat && p.isBlocking === false && view.phase !== '出牌') return null;
108 if (p.target < 0 && this.broadcastSlot()?.skippedPlayers?.has(seat)) return null;
109 const me = view.players[seat];
110 const key = me?.skills.join(',') ?? '';
111 if (me?.character && this.skillsKey.get(seat) !== key) {
112 await registerSkillActions(seat, me.skills);
113 this.skillsKey.set(seat, key);
114 }
115 const h = this.hgc as unknown as { _view: GameView; _seatIndex: number };
116 h._view = view;
117 h._seatIndex = seat;
118 const legal = this.hgc.getAvailableActions();
119 if (!legal.length) return null;
120 const snap = projectView(view);
121 const d = discardRange(view);
122 if (d && snap.pending) Object.assign(snap.pending, { discard: d });
123 const best = rankActions(snap, legal)[0];
124 const obs = { ...snap, game: this.id, recommended: best ? legal.indexOf(best) : null };
125 // The same ask while its engine slot lives and its moves stay the same: a room keeps its request (and version) open.
126 const slot = p.target < 0 ? this.broadcastSlot() : this.state.pendingSlots.get(seat);
127 return { seat, view, obs, legal, key: `${slot?.createdSeq ?? this.state.seq}:${p.target}:${JSON.stringify(legal.map((a) => a.description))}` };
128 }
129
130 private broadcastSlot() {
131 return [...this.state.pendingSlots.values()].find((s) => ((s.atom as { target?: number }).target ?? 0) < 0);
132 }
133
134 /** Every seat that must or may act now. A broadcast ask (无懈可击) goes to every seat; one that can only pass is
135 * passed for at once, as the upstream client's auto-skip does, so only seats holding an answer are asked. */
136 async asks(): Promise<Ask[]> {
137 for (;;) {
138 const out: Ask[] = [];
139 let passed = false;
140 for (let s = 0; s < this.n && !passed; s++) {
141 const a = await this.ask(s);
142 if (a && a.view.pending!.target < 0 && a.legal.every((l) => l.category === 'skip')) passed = await this.act(s, 0);
143 else if (a) out.push(a);
144 }
145 if (!passed) return out;
146 }
147 }
148
149 /** A seat's move: an index into its legal list, or a ClientMessage (an MCP action, its params filled in).
150 * The engine is the judge: a move it refuses changes nothing and returns false. */
151 async act(seat: number, action: unknown): Promise<boolean> {
152 const a = await this.ask(seat);
153 if (!a) return false;
154 const msg = toMessage(a, action);
155 if (!msg) return false;
156 // The same active move three times in one turn is refused: the engine can hand a card back (a 火攻 whose target
157 // declines to show a card returns to hand), and a bot that plays it again would loop for ever.
158 const again = `${this.state.turn?.round}:${this.state.currentPlayerIndex}:${seat}:${JSON.stringify(msg)}`;
159 if (msg.actionType === 'use' && (this.used.get(again) ?? 0) >= 3) return false;
160 // A transform (解围 as 无懈可击) the engine refuses makes it resolve every open ask, the play phase's among them,
161 // and the game soon hangs with nobody to move; so its first step is validated here, exactly as dispatch would.
162 const first = msg.preceding?.[0];
163 if (first && findActionEntry(this.state, first.skillId, seat, first.actionType)?.validate(this.state, first.params) !== null) return false;
164 void this.clock.advanceTo(this.clock.now() + TICK_MS);
165 const view = a.view;
166 const r = await dispatch(this.state, {
167 ...msg,
168 ownerId: seat,
169 baseSeq: this.state.seq,
170 pendingSeq: view.pending?.isBlocking ? this.state.seq : undefined,
171 } as ClientMessage).catch(() => ({ accepted: false, settle: Promise.resolve(undefined) }));
172 // The engine signals settle when the execute suspends or ends, but some ends only after it drains a thousand
173 // macrotasks (about a second); the seq going quiet is the same moment for us.
174 await Promise.race([r.settle, this.quiet(20)]);
175 await this.settle();
176 if (r.accepted && msg.actionType === 'use') this.used.set(again, (this.used.get(again) ?? 0) + 1);
177 return r.accepted;
178 }
179
180 /** Nobody can act: let the engine's own clock run to its next deadline (a silent ask timing out). With no deadline
181 * either, the engine may still be loading a skill module: wait for it a little before calling the game stuck. */
182 async idle(): Promise<boolean> {
183 const events = (this.clock as unknown as { events: Array<{ at: number; cancelled: boolean }> }).events;
184 const next = events.filter((e) => !e.cancelled).map((e) => e.at).sort((x, y) => x - y)[0];
185 if (next === undefined) {
186 if (++this.waits > 100) return false;
187 await new Promise((r) => setTimeout(r, 20));
188 await this.settle();
189 return true;
190 }
191 this.waits = 0;
192 // Not awaited: a timeout handler can resume into the next ask and wait there for a move, as with a real timer.
193 void this.clock.advanceTo(next);
194 await this.settle();
195 return true;
196 }
197 private waits = 0;
198 private used = new Map<string, number>();
199
200 identity(seat: number): string {
201 return this.state.players[seat]?.identity ?? '';
202 }
203
204 /** +1 for every seat of the winning camp, -1 for the others; the engine names one seat of the winners. */
205 scores(): Record<string, number> {
206 const camp = this.over?.winner !== undefined ? CAMP[this.identity(this.over.winner)] : undefined;
207 const out: Record<string, number> = {};
208 for (let s = 0; s < this.n; s++) out[String(s)] = camp && CAMP[this.identity(s)] === camp ? 1 : -1;
209 return out;
210 }
211
212 result(): string {
213 const w = this.over?.winner;
214 if (w === undefined) return this.over ? 'no winner' : 'unfinished';
215 const camp = CAMP[this.identity(w)];
216 return camp === '主公' ? '主公 and 忠臣 win' : `${camp} wins`;
217 }
218
219 /** The upstream's replay file (format sanguosha-replay v2): every seat's view and the spectator's. */
220 replay(title: string): unknown {
221 return buildReplayFile(this.state, this.baseline, this.baselineSeq, {
222 createdAt: 0,
223 playerCount: this.n,
224 characters: this.state.players.map((p) => p.character ?? ''),
225 roomName: title,
226 });
227 }
228
229 /** The public view (no one's hand), for spectators. */
230 publicView(): GameView {
231 return buildView(this.state, -1);
232 }
233}
234
235/** What a move names: an index into legal, one of legal's entries (its message's params filled in or not), or such a
236 * message by itself. The engine judges it; ownerId is always the seat's. */
237function toMessage(a: Ask, action: unknown): Omit<ClientMessage, 'baseSeq'> | null {
238 if (typeof action === 'number') {
239 const l = a.legal[action];
240 return l ? l.message : null;
241 }
242 if (!action || typeof action !== 'object') return null;
243 if ('message' in action) return toMessage(a, (action as Legal).message);
244 const m = action as Partial<ClientMessage>;
245 if (typeof m.skillId !== 'string' || typeof m.actionType !== 'string') return null;
246 return { skillId: m.skillId, actionType: m.actionType, ownerId: a.seat, params: (m.params ?? {}) as ClientMessage['params'], ...(m.preceding ? { preceding: m.preceding } : {}) } as ClientMessage;
247}
248
249/** How many cards a discard ask wants (the browser's usePendingState reads the same prompt). */
250function discardRange(view: GameView): { min: number; max: number } | null {
251 const p = view.pending;
252 if (!p) return null;
253 const atom = p.atom as { requestType?: string; prompt?: { cardFilter?: { min?: number; max?: number } } };
254 const f = atom.requestType === '__弃牌' ? atom.prompt?.cardFilter : p.mandatory && p.prompt?.type === 'useCard' ? p.prompt.cardFilter : null;
255 if (!f) return null;
256 const min = f.min ?? 0;
257 return { min, max: f.max ?? min };
258}
259
260export async function skillInfo(names: string[]): Promise<Record<string, string | null>> {
261 const out: Record<string, string | null> = {};
262 for (const n of names) out[n] = (await getSkillDescriptionAsync(n).catch(() => null)) ?? null;
263 return out;
264}
265
266// ── The upstream heuristic, made to move: rankActions ranks the MCP's moves, and a few of them are templates (pick
267// the targets, the cards to discard). This fills them the way the plugin's skill tells an agent to, and falls back to
268// the next move when one cannot be filled. ────────────────────────────────────────────────────────────────────────
269const KEEP: Record<string, number> = { 桃: 100, 闪: 90, 无懈可击: 85, 酒: 70, 杀: 60 };
270const keep = (c: Card) => KEEP[c.name] ?? (c.type === '装备牌' ? 30 : 45);
271
272function allies(obs: Snapshot, t: number): boolean {
273 const me = obs.players[obs.viewer]?.identity;
274 const them = obs.players[t]?.identity;
275 if (!me || !them || me === '内奸') return false;
276 return CAMP[me] === CAMP[them];
277}
278
279/** Fill a move's open parameters, or null when this simple filler cannot. */
280export function fill(obs: Snapshot, l: Legal): ClientMessage | null {
281 const m = structuredClone(l.message) as ClientMessage;
282 const params = m.params as Record<string, unknown>;
283 const me = obs.players[obs.viewer];
284 const hand = me?.hand ?? [];
285 if (l.validTargets.length && !Array.isArray(params.targets)) {
286 const min = l.minTarget ?? 1;
287 const max = l.maxTarget ?? 1;
288 // Friendly moves (桃, 无中生有-like ones on self) keep allies; for the rest, enemies first, the lord first for rebels.
289 const lord = obs.players.findIndex((p) => p.identity === '主公');
290 const foes = l.validTargets.filter((t) => !allies(obs, t));
291 const rebel = me?.identity === '反贼';
292 const order = [...foes.sort((x, y) => Number(rebel && y === lord) - Number(rebel && x === lord)), ...l.validTargets.filter((t) => !foes.includes(t))];
293 if (order.length < min) return null;
294 if (!foes.length && min > 0 && l.category === 'play') return null; // only allies in reach: hold it
295 params.targets = order.slice(0, Math.max(min, Math.min(max, 1)));
296 params.target = (params.targets as number[])[0];
297 }
298 if (Array.isArray(params.cardIds) && !params.cardIds.length) {
299 const d = (obs.pending as { discard?: { min: number } } | null)?.discard;
300 const need = Math.max(1, d?.min ?? 1);
301 if (hand.length < need) return null;
302 params.cardIds = [...hand].sort((a, b) => keep(a) - keep(b)).slice(0, need).map((c) => c.id);
303 }
304 if (Array.isArray(params.allocation) && !params.allocation.length) return null;
305 return m;
306}
307
308/** The upstream AI as a seat: the heuristic's ranking, the best move this filler can complete that the engine has not
309 * refused at this ask (obs.refused), else a pass. A move already made twice this turn is dropped, or 激将 that
310 * nobody answers would be asked forever. */
311export class Heuristic {
312 private turn = '';
313 private made = new Map<string, number>();
314
315 act(obs: Snapshot & { refused?: unknown[]; game?: string }, legal: Legal[]): ClientMessage | number {
316 const turn = `${obs.game}:${obs.turn.round}:${obs.currentPlayerIndex}`;
317 if (turn !== this.turn) (this.turn = turn), this.made.clear();
318 const refused = new Set((obs.refused ?? []).map((r) => JSON.stringify(r)));
319 for (const l of rankActions(obs, legal)) {
320 const m = fill(obs, l);
321 const k = JSON.stringify(m);
322 if (m && !refused.has(k) && (this.made.get(k) ?? 0) < 2) {
323 this.made.set(k, (this.made.get(k) ?? 0) + 1);
324 return m;
325 }
326 }
327 return passIndex(legal);
328 }
329}
330
331/** The move that gives up: a skip, the end of the play phase, else the last listed. */
332export function passIndex(legal: Legal[]): number {
333 const i = legal.findIndex((l) => l.category === 'skip' || l.message.actionType === 'end');
334 return i >= 0 ? i : legal.length - 1;
335}