| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | import { create, bootstrap, dispatch, buildView, checkGameOver, type GameConfig } from '../src/engine/index'; |
| 6 | import { findActionEntry } from '../src/engine/core/skill'; |
| 7 | import { VirtualClock } from '../src/engine/core/clock'; |
| 8 | import { allCharacters } from '../src/engine/data/characters'; |
| 9 | import { HeadlessGameClient } from '../src/client/headless/HeadlessGameClient'; |
| 10 | import { registerSkillActions, clearRegistry } from '../src/client/skillActionRegistry'; |
| 11 | import { projectView } from '../src/ai-mcp/viewProjector'; |
| 12 | import { rankActions } from '../src/ai-mcp/heuristics'; |
| 13 | import { getSkillDescriptionAsync } from '../src/engine/skills/lifecycle'; |
| 14 | import { buildReplayFile } from '../src/server/gameHistory'; |
| 15 | import type { AvailableAction, AiViewSnapshot } from '../src/client/headless/types'; |
| 16 | import type { ClientMessage, GameState, GameView, Card } from '../src/engine/types'; |
| 17 | import { randomUUID } from 'node:crypto'; |
| 18 | import { BlindChoices } from './privacy'; |
| 19 | export { Strategic as Heuristic } from './strategy'; |
| 20 | |
| 21 | export type Legal = AvailableAction; |
| 22 | export type Snapshot = AiViewSnapshot; |
| 23 | const CAMP: Record<string, string> = { 主公: '主公', 忠臣: '主公', 反贼: '反贼', 内奸: '内奸' }; |
| 24 | const POOL = allCharacters.map((c) => ({ name: c.name, skills: c.skills.map((s) => s.name) })); |
| 25 | |
| 26 | |
| 27 | const TICK_MS = 500; |
| 28 | |
| 29 | |
| 30 | export interface Ask { |
| 31 | seat: number; |
| 32 | view: GameView; |
| 33 | obs: Snapshot & { game: string; recommended: number | null }; |
| 34 | legal: Legal[]; |
| 35 | key: string; |
| 36 | } |
| 37 | |
| 38 | export class Table { |
| 39 | state!: GameState; |
| 40 | n: number; |
| 41 | seed: number; |
| 42 | private readonly clock = new VirtualClock(); |
| 43 | private config!: GameConfig; |
| 44 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 113 | |
| 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 | |
| 132 | |
| 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 | |
| 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 | |
| 150 | |
| 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 | |
| 165 | |
| 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 | |
| 173 | |
| 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 | |
| 177 | |
| 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 | |
| 189 | |
| 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 | |
| 197 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 256 | |
| 257 | function 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 | |
| 270 | function 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 | |
| 280 | export 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 | |
| 287 | |
| 288 | |
| 289 | const KEEP: Record<string, number> = { 桃: 100, 闪: 90, 无懈可击: 85, 酒: 70, 杀: 60 }; |
| 290 | const keep = (c: Card) => KEEP[c.name] ?? (c.type === '装备牌' ? 30 : 45); |
| 291 | |
| 292 | function 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 | |
| 300 | export 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 | |
| 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; |
| 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 | |
| 329 | |
| 330 | |
| 331 | export 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 | |
| 352 | export 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 | } |