// A deterministic, seat-local identity-game player. No engine state or concealed role/hand is read. // Public actions provide uncertain allegiance evidence; they never turn into a claimed identity. import { scoreAction } from '../src/ai-mcp/heuristics'; import { allCharacters } from '../src/engine/data/characters'; import type { AiViewSnapshot, AvailableAction } from '../src/client/headless/types'; import type { Card, ClientMessage, GameView } from '../src/engine/types'; type Context = { frames?: GameView['settlementStack']; cancelTarget?: number; prompt?: NonNullable['prompt']; players?: Array<{ pendingTricks?: string[]; distanceVars?: { attackRange?: number } }>; }; export type Observation = AiViewSnapshot & { game?: string; refused?: unknown[]; context?: Context }; const CAMP: Record = { 主公: 'lord', 忠臣: 'lord', 反贼: 'rebel', 内奸: 'renegade' }; const BENEFIT = /桃$|青囊|仁德|遗计|节命|补益|仁心|遗礼|界再起|黄天|救援|恩怨/; const HARM = /杀|决斗|火攻|拆桥|牵羊|兵粮|乐不|强袭|挑衅|反间|离间|突袭|雷击|猛进|刚烈|天香/; const CHARACTER_SKILL: Record = { 英姿: 14, 闭月: 14, 制衡: 16, 界制衡: 22, 仁德: 12, 界仁德: 18, 急救: 12, 青囊: 12, 咆哮: 10, 界咆哮: 14, 武圣: 8, 龙胆: 10, 龙魂: 18, 看破: 14, 集智: 15, 界集智: 20, 遗计: 12, 界遗计: 16, 反馈: 10, 倾国: 8, 洛神: 12, 涅槃: 14, 界涅槃: 16, 当先: 12, 界当先: 16, 奸雄: 10, 界奸雄: 14, 界英姿: 20, 界闭月: 18, 怀橘: 12, 整论: 8, 天妒: 9, 八阵: 9, 谦逊: 8, 界谦逊: 12, }; const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); const ownHand = (o: Observation) => o.players[o.viewer]?.hand ?? []; const cardName = (o: Observation, m: ClientMessage) => ownHand(o).find((c) => c.id === m.params.cardId)?.name ?? m.skillId; const targetsOf = (m: ClientMessage): number[] => Array.isArray(m.params.targets) ? m.params.targets.filter((x): x is number => typeof x === 'number') : typeof m.params.target === 'number' ? [m.params.target] : []; const equipmentName = (id: unknown) => typeof id === 'string' ? id.split('-')[0] : ''; /** Marginal value, including duplicate cards and survival at current HP, not just card-name order. */ export function keepValue(o: Observation, c: Card): number { const me = o.players[o.viewer]; const hand = ownHand(o); const sameBefore = hand.slice(0, hand.findIndex((x) => x.id === c.id)).filter((x) => x.name === c.name).length; if (c.name === '桃') return 110 - sameBefore * 8 + (me.health <= 2 ? 15 : 0); if (c.name === '闪') return (sameBefore === 0 ? 85 : sameBefore === 1 ? 51 : 24) + (me.health <= 2 ? 12 : 0); if (c.name === '无懈可击') return 78 - sameBefore * 14; if (c.name === '酒') return (me.health <= 1 ? 92 : 50) - sameBefore * 24; if (c.name === '杀') return (me.skills.some((s) => /咆哮/.test(s)) || equipmentName(me.equipment?.武器) === '诸葛连弩' ? 63 : 52) - sameBefore * 16; if (c.name === '无中生有') return 88; if (/顺手牵羊|过河拆桥|乐不思蜀|兵粮寸断/.test(c.name)) return 66; if (c.name === '闪电') return 10; if (c.type === '装备牌') return me.equipment?.[c.subtype as keyof typeof me.equipment] ? 22 : 58; return 42; } export class Strategic { private game = ''; private turn = ''; private seen = new Set(); private loyalty = new Map(); // positive = has publicly supported the lord private made = new Map(); private observe(o: Observation): void { if (this.game !== o.game) { this.game = o.game ?? ''; this.seen.clear(); this.loyalty.clear(); this.made.clear(); } const turn = `${o.game}:${o.turn.round}:${o.currentPlayerIndex}`; if (turn !== this.turn) { this.turn = turn; this.made.clear(); } const lord = o.players.findIndex((p) => p.identity === '主公'); for (const entry of o.log ?? []) { const key = `${entry.time}:${entry.player}:${entry.text}`; if (this.seen.has(key)) continue; this.seen.add(key); const match = entry.text.match(/^使用\s+([^ (]+).* → ([\d, ]+)$/); if (!match || entry.player < 0 || entry.player === lord) continue; const sign = BENEFIT.test(match[1]) ? 1 : HARM.test(match[1]) ? -1 : 0; for (const target of match[2].split(',').map(Number)) { const known = o.players[target]?.identity; const side = target === lord ? 1 : known === '忠臣' ? 0.8 : known === '反贼' ? -0.8 : clamp((this.loyalty.get(target) ?? 0) / 3, -0.5, 0.5); this.loyalty.set(entry.player, clamp((this.loyalty.get(entry.player) ?? 0) + sign * side * 1.8, -5, 5)); } } } /** [-1,1], a strategic attitude rather than an assertion about a concealed identity. */ attitude(o: Observation, target: number): number { if (target === o.viewer) return 1; const me = o.players[o.viewer], p = o.players[target]; if (!p) return 0; const alive = o.players.filter((x) => x.alive); if (me.identity === '内奸') { // Keep the lord alive until everybody else is gone; a premature regicide loses. if (p.identity === '主公') return alive.length > 2 ? 0.95 : -1; return -0.55; } if (p.identity && me.identity) return CAMP[p.identity] === CAMP[me.identity] ? 1 : -1; const loyalty = clamp((this.loyalty.get(target) ?? 0) / 3, -1, 1); return me.identity === '反贼' ? -loyalty : loyalty; } private targetValue(o: Observation, target: number, name: string, friendly: boolean): number { const p = o.players[target], me = o.players[o.viewer]; if (!p?.alive) return -1000; const attitude = this.attitude(o, target); if (friendly) return attitude * 60 + (p.maxHealth - p.health) * 8 + (p.health <= 1 ? 18 : 0) - p.handCount * 2; let score = -attitude * 55 + (5 - p.health) * 5 + (p.handCount === 0 ? 12 : 0); if (p.identity === '主公' && me.identity === '反贼') score += 35; if (p.identity === '主公' && me.identity === '内奸' && o.players.filter((x) => x.alive).length > 2) score -= 150; if (/乐不思蜀/.test(name)) score += p.handCount * 5; if (/兵粮寸断/.test(name)) score += 10 - p.handCount; if (/决斗/.test(name)) score -= p.handCount * 5 + (p.skills.some((s) => /无双/.test(s)) ? 20 : 0); if (/杀/.test(name)) { const armor = equipmentName(p.equipment?.防具); if (armor === '仁王盾' && ownHand(o).find((c) => c.name === '杀')?.color === '黑') score -= 35; if (armor === '八卦阵' || p.skills.includes('八阵')) score -= 10; } return score; } private fill(o: Observation, l: AvailableAction): ClientMessage | null { const m = structuredClone(l.message), p = m.params; const hand = ownHand(o), name = cardName(o, m); const friendly = BENEFIT.test(m.skillId) && !/恩怨/.test(m.skillId); if (Array.isArray(p.allocation) && !p.allocation.length) { const friends = l.validTargets.filter((t) => t !== o.viewer && this.attitude(o, t) > 0.35) .sort((a, b) => this.targetValue(o, b, name, true) - this.targetValue(o, a, name, true)); if (!friends.length) return null; const give = [...hand].sort((a, b) => keepValue(o, a) - keepValue(o, b)); const healing = /仁德/.test(m.skillId) && o.players[o.viewer].health < o.players[o.viewer].maxHealth; const count = Math.min(give.length, healing ? 2 : Math.max(1, hand.length - Math.max(2, o.players[o.viewer].health))); if (!count || (!healing && keepValue(o, give[0]) >= 80)) return null; p.allocation = [{ target: friends[0], cardIds: give.slice(0, count).map((c) => c.id) }]; return m; } if (l.validTargets.length && (!Array.isArray(p.targets) || !p.targets.length) && p.target === undefined) { const order = [...l.validTargets].sort((a, b) => this.targetValue(o, b, name, friendly) - this.targetValue(o, a, name, friendly)); const min = l.minTarget ?? 1, max = l.maxTarget ?? 1; if (order.length < min) return null; if (friendly && this.attitude(o, order[0]) <= 0.15) return null; if (!friendly && this.attitude(o, order[0]) > 0.6 && l.category !== 'respond') return null; const n = name === '杀' ? Math.max(min, Math.min(max, order.filter((i) => this.attitude(o, i) < 0.2).length)) : min; p.targets = order.slice(0, n); p.target = order[0]; } if (Array.isArray(p.cardIds) && !p.cardIds.length) { const prompt = o.context?.prompt as { cardFilter?: { candidates?: string[]; min?: number; max?: number }; minTotal?: number; maxTotal?: number; cardIds?: string[] } | undefined; const discard = (o.pending as { discard?: { min: number; max: number } } | null)?.discard; const eligible = prompt?.cardFilter?.candidates; // Static selections may belong to another player's hand. Their opaque handles are // deliberately not card faces; choose visible equipment first, then an unknown back. if (prompt?.cardIds?.length) { const min = prompt.minTotal ?? 1; const choices = [...prompt.cardIds].sort((a, b) => Number(a.startsWith('hidden-')) - Number(b.startsWith('hidden-'))); if (choices.length < min) return null; p.cardIds = choices.slice(0, min); return m; } const cards = [...hand].filter((c) => !eligible || eligible.includes(c.id)).sort((a, b) => keepValue(o, a) - keepValue(o, b)); const min = discard?.min ?? prompt?.minTotal ?? prompt?.cardFilter?.min ?? 1; let count = min; if (/制衡/.test(m.skillId)) count = Math.max(1, cards.filter((c) => keepValue(o, c) < 62).length); count = Math.min(count, discard?.max ?? prompt?.maxTotal ?? prompt?.cardFilter?.max ?? cards.length); if (cards.length < min || count < min) return null; p.cardIds = cards.slice(0, count).map((c) => c.id); } return m; } private nullify(o: Observation): number { const frames = o.context?.frames ?? []; let i = frames.length - 1, parity = 0; while (i >= 0 && frames[i].skillId === '无懈可击') { parity++; i--; } const frame = frames[i]; if (!frame) return -20; // missing context is unknown, not a reason to cancel everybody's tricks const values = frame.params.targets; const target = parity === 0 && typeof o.context?.cancelTarget === 'number' ? o.context.cancelTarget : Array.isArray(values) && typeof values[0] === 'number' ? values[0] : frame.from; const p = o.players[target]; if (!p) return -20; const friendlyEffect = /无中生有|桃园结义|五谷丰登/.test(frame.skillId); let effect = this.attitude(o, target) * (friendlyEffect ? 1 : -1); if (parity % 2) effect *= -1; const dangerous = /决斗|火攻|南蛮|万箭|闪电/.test(frame.skillId); const lethal = dangerous && p.health <= 1; return -effect * (lethal ? 120 : 80) - 18; } private score(o: Observation, l: AvailableAction, m: ClientMessage, legal: AvailableAction[]): number { const me = o.players[o.viewer], name = cardName(o, m), p = m.params; if (l.category === 'skip' || m.actionType === 'end') return 0; if (l.category === 'selectChar') { const c = allCharacters.find((x) => x.name === p.character); if (!c) return 20; const skills = c.skills.map((s) => s.name); let value = c.maxHealth * 5 + skills.reduce((v, s) => v + (CHARACTER_SKILL[s] ?? 3), 0); if (me.identity === '主公') value += c.maxHealth * 4 + skills.filter((s) => /救援|仁德|奸雄|制衡|涅槃/.test(s)).length * 7; if (me.identity === '忠臣') value += skills.filter((s) => /急救|仁德|青囊|遗计/.test(s)).length * 10; return value; } const dying = o.players.find((x) => x.alive && x.health <= 0); if (m.actionType === 'respond' || l.category === 'respond' || (l.category === 'transform' && m.actionType === 'respond')) { if (name === '无懈可击') return this.nullify(o); if (dying && (name === '桃' || name === '酒' || /急救|仁心|补益/.test(m.skillId))) { if (dying.index === o.viewer) return name === '酒' ? 190 : 180; return this.attitude(o, dying.index) > 0.25 ? 150 : -100; } if (name === '闪' || name === '杀') { const costs = m.preceding?.flatMap((x) => Array.isArray(x.params.cardIds) ? x.params.cardIds : [x.params.cardId]).filter((x): x is string => typeof x === 'string') ?? []; return 95 + (me.health <= 1 ? 50 : 0) - costs.reduce((v, id) => { const c = ownHand(o).find((c) => c.id === id); return v + (c ? keepValue(o, c) * 0.25 : 0); }, 0); } // Pick which public zone to remove; never infer the face of a hidden hand card. const prompt = o.context?.prompt as { type?: string; target?: number; targetIndex?: number; cards?: Array<{ cardId: string; cardName: string }> } | undefined; if (prompt?.type === 'pickProcessingCard') { const choice = prompt.cards?.find((c) => c.cardId === p.cardId); return choice ? keepValue(o, { id: choice.cardId, name: choice.cardName } as Card) : 0; } if (prompt?.type === 'pickTargetCard') { const target = prompt.target ?? prompt.targetIndex; const ally = typeof target === 'number' && this.attitude(o, target) > 0.35; if (p.zone === 'judge') return ally ? 110 : -30; return p.zone === 'equipment' ? 72 : ally ? -40 : 60; } if (Array.isArray(p.cardIds)) return 85; const target = targetsOf(m)[0]; if (target !== undefined) return 35 + this.targetValue(o, target, name, BENEFIT.test(name)); return scoreAction(o, l).score - 25; } if (l.category === 'discard') return 120; if (l.category === 'distribute') return /制衡/.test(name) ? 58 : 52; if (/苦肉|强袭/.test(name) && (me.health <= 1 || (me.health <= 2 && !ownHand(o).some((c) => c.name === '桃')))) return -100; if (name === '桃') return me.health < me.maxHealth ? 90 + (me.health <= 1 ? 40 : 0) : -100; if (name === '酒') { const attack = legal.some((x) => x.message.skillId === '杀' && x.validTargets.some((t) => this.attitude(o, t) < 0.3)); return attack ? 78 : -35; } if (name === '无中生有') return 105; if (name === '闪电') return -30; if (name === '桃园结义') return o.players.filter((x) => x.alive && x.health < x.maxHealth).reduce((v, x) => v + this.attitude(o, x.index) * (x.health <= 1 ? 55 : 30), 0); if (/南蛮入侵|万箭齐发/.test(name)) { let value = 0; for (const x of o.players.filter((x) => x.alive && x.index !== o.viewer)) { if (me.identity === '内奸' && x.identity === '主公' && x.health <= 1 && o.players.filter((p) => p.alive).length > 2) return -150; value += -this.attitude(o, x.index) * (x.health <= 1 ? 75 : 30) + (this.attitude(o, x.index) === 0 ? 2 : 0); } return value; } if (/重铸/.test(m.actionType)) return 40; const card = ownHand(o).find((x) => x.id === p.cardId); if (card?.type === '装备牌') { const previous = equipmentName(me.equipment?.[card.subtype as keyof typeof me.equipment]); if (!previous) return card.subtype === '武器' ? 82 : 65; if (previous === '白银狮子' && me.health < me.maxHealth) return 85; if (card.name === '诸葛连弩' && ownHand(o).filter((x) => x.name === '杀').length > 1) return 90; return -10; } const targets = targetsOf(m); if (targets.length) { const friendly = BENEFIT.test(name); let v = targets.reduce((sum, t) => sum + this.targetValue(o, t, name, friendly), 0) / targets.length; if (!friendly && targets.some((t) => this.attitude(o, t) > 0.6)) return -100; if (name === '杀') v += 30; else if (name === '决斗') v += ownHand(o).filter((x) => x.name === '杀').length * 18 - 25; else if (name === '火攻') v += new Set(ownHand(o).filter((c) => c.id !== p.cardId).map((c) => c.suit)).size * 9 - 30; else v += 15; if (m.preceding) v -= m.preceding.flatMap((x) => Array.isArray(x.params.cardIds) ? x.params.cardIds : [x.params.cardId]).reduce((cost, id) => { const c = ownHand(o).find((x) => x.id === id); return cost + (c ? keepValue(o, c) * 0.3 : 0); }, 0); return v; } return scoreAction(o, l).score - 35; } act(o: Observation, legal: AvailableAction[]): ClientMessage | number { this.observe(o); const refused = new Set((o.refused ?? []).map((x) => JSON.stringify(x))); const ranked = legal.map((l, i) => { const m = this.fill(o, l); const key = JSON.stringify(m); // A rejected template index (e.g. discard with cardIds=[]) must not reject the // completed message as well; the fallback still has to fill mandatory choices. const unchangedRejectedIndex = refused.has(JSON.stringify(i)) && key === JSON.stringify(l.message); const no = !m || refused.has(key) || unchangedRejectedIndex || (this.made.get(key) ?? 0) >= 2; return { m, key, value: no ? -Infinity : this.score(o, l, m!, legal), i }; }).sort((a, b) => b.value - a.value || a.i - b.i); const best = ranked[0]; if (best?.m && Number.isFinite(best.value)) { this.made.set(best.key, (this.made.get(best.key) ?? 0) + 1); return best.m; } const pass = legal.findIndex((l) => l.category === 'skip' || l.message.actionType === 'end'); return pass >= 0 ? pass : 0; } }