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