// A Sanguosha table on the upstream engine, with no server: wmzy/sanguosha's create/bootstrap/dispatch on a virtual // clock, the views its browser gets (buildView), the moves its headless client and MCP server list // (HeadlessGameClient.getAvailableActions), its heuristic AI (rankActions) and its replay format (buildReplayFile). // The room (room.ts) and the check (play.ts) both drive games through this, so a bot sees exactly what the MCP sees. import { create, bootstrap, dispatch, buildView, checkGameOver, type GameConfig } from '../src/engine/index'; import { findActionEntry } from '../src/engine/core/skill'; import { VirtualClock } from '../src/engine/core/clock'; import { allCharacters } from '../src/engine/data/characters'; import { HeadlessGameClient } from '../src/client/headless/HeadlessGameClient'; import { registerSkillActions, clearRegistry } from '../src/client/skillActionRegistry'; import { projectView } from '../src/ai-mcp/viewProjector'; import { rankActions } from '../src/ai-mcp/heuristics'; import { getSkillDescriptionAsync } from '../src/engine/skills/lifecycle'; import { buildReplayFile } from '../src/server/gameHistory'; import type { AvailableAction, AiViewSnapshot } from '../src/client/headless/types'; import type { ClientMessage, GameState, GameView, Card } from '../src/engine/types'; export type Legal = AvailableAction; export type Snapshot = AiViewSnapshot; const CAMP: Record = { 主公: '主公', 忠臣: '主公', 反贼: '反贼', 内奸: '内奸' }; const POOL = allCharacters.map((c) => ({ name: c.name, skills: c.skills.map((s) => s.name) })); // Engine time per move on the virtual clock: replays get spacing, and a seat with nothing to answer (a "silent" ask, // so nobody learns it holds no 闪) waits its short delay on this clock, not on the wall. const TICK_MS = 500; /** What one seat may do now: its view (as the browser gets it), the MCP's snapshot of it, and the moves. */ export interface Ask { seat: number; view: GameView; obs: Snapshot & { game: string; recommended: number | null }; legal: Legal[]; key: string; } export class Table { state!: GameState; n: number; seed: number; private readonly clock = new VirtualClock(); private config!: GameConfig; // The headless client is only used offline, for its move list: getAvailableActions reads _view and _seatIndex. private hgc = new HeadlessGameClient('http://offline.invalid'); private skillsKey = new Map(); private baseline: GameView[] = []; private baselineSeq = 0; over: { winner?: number } | null = null; id: string; private static games = 0; constructor(seed: number, n = 5) { this.seed = seed; this.n = n; this.id = `sgs-${seed}-${++Table.games}`; } async open(names: string[]): Promise { clearRegistry(); const config: GameConfig = (this.config = { characters: POOL, playerCount: this.n, seed: this.seed, gameId: this.id, handSize: 4, timeoutSec: 30, mode: '身份局' as const, playerNames: names, }); this.state = create(config); this.state.clock = this.clock; this.state.startedAt = this.clock.now(); await bootstrap(this.state, config); await this.settle(); } private async quiet(ticks: number): Promise { let last = -1; for (let quiet = 0; quiet < ticks; ) { await new Promise((r) => setImmediate(r)); if (this.state.seq === last) quiet++; else (quiet = 0), (last = this.state.seq); } } /** Let the engine run its fire-and-forget execute chain to the next ask. */ private async settle(): Promise { await this.quiet(3); if (!this.baseline.length && this.state.players.every((p) => p.character) && !this.state.charSelecting) { // The replay starts once every general is chosen and shown, like the upstream server's recording. this.baseline = [...Array(this.n).keys(), -1].map((v) => JSON.parse(JSON.stringify(buildView(this.state, v)))); this.baselineSeq = this.state.seq; } if (!this.over) { const r = await checkGameOver(this.state); if (r.gameOver) this.over = { winner: r.winner }; } } /** The seat's ask, or null when it has nothing to do (HeadlessGameClient.needsAction, seat by seat). */ async ask(seat: number): Promise { if (this.over) return null; const view = buildView(this.state, seat); const p = view.pending; if (!p) return null; if (p.target >= 0 && p.target !== seat) return null; if (p.target === seat && p.responseMode === 'silent') return null; if (p.target === seat && p.isBlocking === false && view.phase !== '出牌') return null; if (p.target < 0 && this.broadcastSlot()?.skippedPlayers?.has(seat)) return null; const me = view.players[seat]; const key = me?.skills.join(',') ?? ''; if (me?.character && this.skillsKey.get(seat) !== key) { await registerSkillActions(seat, me.skills); this.skillsKey.set(seat, key); } const h = this.hgc as unknown as { _view: GameView; _seatIndex: number }; h._view = view; h._seatIndex = seat; const legal = this.hgc.getAvailableActions(); if (!legal.length) return null; const snap = projectView(view); const d = discardRange(view); if (d && snap.pending) Object.assign(snap.pending, { discard: d }); const best = rankActions(snap, legal)[0]; const obs = { ...snap, game: this.id, recommended: best ? legal.indexOf(best) : null }; // The same ask while its engine slot lives and its moves stay the same: a room keeps its request (and version) open. const slot = p.target < 0 ? this.broadcastSlot() : this.state.pendingSlots.get(seat); return { seat, view, obs, legal, key: `${slot?.createdSeq ?? this.state.seq}:${p.target}:${JSON.stringify(legal.map((a) => a.description))}` }; } private broadcastSlot() { return [...this.state.pendingSlots.values()].find((s) => ((s.atom as { target?: number }).target ?? 0) < 0); } /** Every seat that must or may act now. A broadcast ask (无懈可击) goes to every seat; one that can only pass is * passed for at once, as the upstream client's auto-skip does, so only seats holding an answer are asked. */ async asks(): Promise { for (;;) { const out: Ask[] = []; let passed = false; for (let s = 0; s < this.n && !passed; s++) { const a = await this.ask(s); if (a && a.view.pending!.target < 0 && a.legal.every((l) => l.category === 'skip')) passed = await this.act(s, 0); else if (a) out.push(a); } if (!passed) return out; } } /** A seat's move: an index into its legal list, or a ClientMessage (an MCP action, its params filled in). * The engine is the judge: a move it refuses changes nothing and returns false. */ async act(seat: number, action: unknown): Promise { const a = await this.ask(seat); if (!a) return false; const msg = toMessage(a, action); if (!msg) return false; // The same active move three times in one turn is refused: the engine can hand a card back (a 火攻 whose target // declines to show a card returns to hand), and a bot that plays it again would loop for ever. const again = `${this.state.turn?.round}:${this.state.currentPlayerIndex}:${seat}:${JSON.stringify(msg)}`; if (msg.actionType === 'use' && (this.used.get(again) ?? 0) >= 3) return false; // A transform (解围 as 无懈可击) the engine refuses makes it resolve every open ask, the play phase's among them, // and the game soon hangs with nobody to move; so its first step is validated here, exactly as dispatch would. const first = msg.preceding?.[0]; if (first && findActionEntry(this.state, first.skillId, seat, first.actionType)?.validate(this.state, first.params) !== null) return false; void this.clock.advanceTo(this.clock.now() + TICK_MS); const view = a.view; const r = await dispatch(this.state, { ...msg, ownerId: seat, baseSeq: this.state.seq, pendingSeq: view.pending?.isBlocking ? this.state.seq : undefined, } as ClientMessage).catch(() => ({ accepted: false, settle: Promise.resolve(undefined) })); // The engine signals settle when the execute suspends or ends, but some ends only after it drains a thousand // macrotasks (about a second); the seq going quiet is the same moment for us. await Promise.race([r.settle, this.quiet(20)]); await this.settle(); if (r.accepted && msg.actionType === 'use') this.used.set(again, (this.used.get(again) ?? 0) + 1); return r.accepted; } /** Nobody can act: let the engine's own clock run to its next deadline (a silent ask timing out). With no deadline * either, the engine may still be loading a skill module: wait for it a little before calling the game stuck. */ async idle(): Promise { const events = (this.clock as unknown as { events: Array<{ at: number; cancelled: boolean }> }).events; const next = events.filter((e) => !e.cancelled).map((e) => e.at).sort((x, y) => x - y)[0]; if (next === undefined) { if (++this.waits > 100) return false; await new Promise((r) => setTimeout(r, 20)); await this.settle(); return true; } this.waits = 0; // Not awaited: a timeout handler can resume into the next ask and wait there for a move, as with a real timer. void this.clock.advanceTo(next); await this.settle(); return true; } private waits = 0; private used = new Map(); identity(seat: number): string { return this.state.players[seat]?.identity ?? ''; } /** +1 for every seat of the winning camp, -1 for the others; the engine names one seat of the winners. */ scores(): Record { const camp = this.over?.winner !== undefined ? CAMP[this.identity(this.over.winner)] : undefined; const out: Record = {}; for (let s = 0; s < this.n; s++) out[String(s)] = camp && CAMP[this.identity(s)] === camp ? 1 : -1; return out; } result(): string { const w = this.over?.winner; if (w === undefined) return this.over ? 'no winner' : 'unfinished'; const camp = CAMP[this.identity(w)]; return camp === '主公' ? '主公 and 忠臣 win' : `${camp} wins`; } /** The upstream's replay file (format sanguosha-replay v2): every seat's view and the spectator's. */ replay(title: string): unknown { return buildReplayFile(this.state, this.baseline, this.baselineSeq, { createdAt: 0, playerCount: this.n, characters: this.state.players.map((p) => p.character ?? ''), roomName: title, }); } /** The public view (no one's hand), for spectators. */ publicView(): GameView { return buildView(this.state, -1); } } /** What a move names: an index into legal, one of legal's entries (its message's params filled in or not), or such a * message by itself. The engine judges it; ownerId is always the seat's. */ function toMessage(a: Ask, action: unknown): Omit | null { if (typeof action === 'number') { const l = a.legal[action]; return l ? l.message : null; } if (!action || typeof action !== 'object') return null; if ('message' in action) return toMessage(a, (action as Legal).message); const m = action as Partial; if (typeof m.skillId !== 'string' || typeof m.actionType !== 'string') return null; return { skillId: m.skillId, actionType: m.actionType, ownerId: a.seat, params: (m.params ?? {}) as ClientMessage['params'], ...(m.preceding ? { preceding: m.preceding } : {}) } as ClientMessage; } /** How many cards a discard ask wants (the browser's usePendingState reads the same prompt). */ function discardRange(view: GameView): { min: number; max: number } | null { const p = view.pending; if (!p) return null; const atom = p.atom as { requestType?: string; prompt?: { cardFilter?: { min?: number; max?: number } } }; const f = atom.requestType === '__弃牌' ? atom.prompt?.cardFilter : p.mandatory && p.prompt?.type === 'useCard' ? p.prompt.cardFilter : null; if (!f) return null; const min = f.min ?? 0; return { min, max: f.max ?? min }; } export async function skillInfo(names: string[]): Promise> { const out: Record = {}; for (const n of names) out[n] = (await getSkillDescriptionAsync(n).catch(() => null)) ?? null; return out; } // ── The upstream heuristic, made to move: rankActions ranks the MCP's moves, and a few of them are templates (pick // the targets, the cards to discard). This fills them the way the plugin's skill tells an agent to, and falls back to // the next move when one cannot be filled. ──────────────────────────────────────────────────────────────────────── const KEEP: Record = { 桃: 100, 闪: 90, 无懈可击: 85, 酒: 70, 杀: 60 }; const keep = (c: Card) => KEEP[c.name] ?? (c.type === '装备牌' ? 30 : 45); function allies(obs: Snapshot, t: number): boolean { const me = obs.players[obs.viewer]?.identity; const them = obs.players[t]?.identity; if (!me || !them || me === '内奸') return false; return CAMP[me] === CAMP[them]; } /** Fill a move's open parameters, or null when this simple filler cannot. */ export function fill(obs: Snapshot, l: Legal): ClientMessage | null { const m = structuredClone(l.message) as ClientMessage; const params = m.params as Record; const me = obs.players[obs.viewer]; const hand = me?.hand ?? []; if (l.validTargets.length && !Array.isArray(params.targets)) { const min = l.minTarget ?? 1; const max = l.maxTarget ?? 1; // Friendly moves (桃, 无中生有-like ones on self) keep allies; for the rest, enemies first, the lord first for rebels. const lord = obs.players.findIndex((p) => p.identity === '主公'); const foes = l.validTargets.filter((t) => !allies(obs, t)); const rebel = me?.identity === '反贼'; const order = [...foes.sort((x, y) => Number(rebel && y === lord) - Number(rebel && x === lord)), ...l.validTargets.filter((t) => !foes.includes(t))]; if (order.length < min) return null; if (!foes.length && min > 0 && l.category === 'play') return null; // only allies in reach: hold it params.targets = order.slice(0, Math.max(min, Math.min(max, 1))); params.target = (params.targets as number[])[0]; } if (Array.isArray(params.cardIds) && !params.cardIds.length) { const d = (obs.pending as { discard?: { min: number } } | null)?.discard; const need = Math.max(1, d?.min ?? 1); if (hand.length < need) return null; params.cardIds = [...hand].sort((a, b) => keep(a) - keep(b)).slice(0, need).map((c) => c.id); } if (Array.isArray(params.allocation) && !params.allocation.length) return null; return m; } /** The upstream AI as a seat: the heuristic's ranking, the best move this filler can complete that the engine has not * refused at this ask (obs.refused), else a pass. A move already made twice this turn is dropped, or 激将 that * nobody answers would be asked forever. */ export class Heuristic { private turn = ''; private made = new Map(); act(obs: Snapshot & { refused?: unknown[]; game?: string }, legal: Legal[]): ClientMessage | number { const turn = `${obs.game}:${obs.turn.round}:${obs.currentPlayerIndex}`; if (turn !== this.turn) (this.turn = turn), this.made.clear(); const refused = new Set((obs.refused ?? []).map((r) => JSON.stringify(r))); for (const l of rankActions(obs, legal)) { const m = fill(obs, l); const k = JSON.stringify(m); if (m && !refused.has(k) && (this.made.get(k) ?? 0) < 2) { this.made.set(k, (this.made.get(k) ?? 0) + 1); return m; } } return passIndex(legal); } } /** The move that gives up: a skip, the end of the play phase, else the last listed. */ export function passIndex(legal: Legal[]): number { const i = legal.findIndex((l) => l.category === 'skip' || l.message.actionType === 'end'); return i >= 0 ? i : legal.length - 1; }