| 1 | |
| 2 | |
| 3 | import { scoreAction } from '../src/ai-mcp/heuristics'; |
| 4 | import { allCharacters } from '../src/engine/data/characters'; |
| 5 | import type { AiViewSnapshot, AvailableAction } from '../src/client/headless/types'; |
| 6 | import type { Card, ClientMessage, GameView } from '../src/engine/types'; |
| 7 | |
| 8 | type Context = { |
| 9 | frames?: GameView['settlementStack']; |
| 10 | cancelTarget?: number; |
| 11 | prompt?: NonNullable<GameView['pending']>['prompt']; |
| 12 | players?: Array<{ pendingTricks?: string[]; distanceVars?: { attackRange?: number } }>; |
| 13 | }; |
| 14 | export type Observation = AiViewSnapshot & { game?: string; refused?: unknown[]; context?: Context }; |
| 15 | const CAMP: Record<string, string> = { 主公: 'lord', 忠臣: 'lord', 反贼: 'rebel', 内奸: 'renegade' }; |
| 16 | const BENEFIT = /桃$|青囊|仁德|遗计|节命|补益|仁心|遗礼|界再起|黄天|救援|恩怨/; |
| 17 | const HARM = /杀|决斗|火攻|拆桥|牵羊|兵粮|乐不|强袭|挑衅|反间|离间|突袭|雷击|猛进|刚烈|天香/; |
| 18 | const CHARACTER_SKILL: Record<string, number> = { |
| 19 | 英姿: 14, 闭月: 14, 制衡: 16, 界制衡: 22, 仁德: 12, 界仁德: 18, 急救: 12, 青囊: 12, |
| 20 | 咆哮: 10, 界咆哮: 14, 武圣: 8, 龙胆: 10, 龙魂: 18, 看破: 14, 集智: 15, 界集智: 20, |
| 21 | 遗计: 12, 界遗计: 16, 反馈: 10, 倾国: 8, 洛神: 12, 涅槃: 14, 界涅槃: 16, |
| 22 | 当先: 12, 界当先: 16, 奸雄: 10, 界奸雄: 14, 界英姿: 20, 界闭月: 18, |
| 23 | 怀橘: 12, 整论: 8, 天妒: 9, 八阵: 9, 谦逊: 8, 界谦逊: 12, |
| 24 | }; |
| 25 | const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); |
| 26 | const ownHand = (o: Observation) => o.players[o.viewer]?.hand ?? []; |
| 27 | const cardName = (o: Observation, m: ClientMessage) => ownHand(o).find((c) => c.id === m.params.cardId)?.name ?? m.skillId; |
| 28 | 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] : []; |
| 29 | const equipmentName = (id: unknown) => typeof id === 'string' ? id.split('-')[0] : ''; |
| 30 | |
| 31 | |
| 32 | export function keepValue(o: Observation, c: Card): number { |
| 33 | const me = o.players[o.viewer]; |
| 34 | const hand = ownHand(o); |
| 35 | const sameBefore = hand.slice(0, hand.findIndex((x) => x.id === c.id)).filter((x) => x.name === c.name).length; |
| 36 | if (c.name === '桃') return 110 - sameBefore * 8 + (me.health <= 2 ? 15 : 0); |
| 37 | if (c.name === '闪') return (sameBefore === 0 ? 85 : sameBefore === 1 ? 51 : 24) + (me.health <= 2 ? 12 : 0); |
| 38 | if (c.name === '无懈可击') return 78 - sameBefore * 14; |
| 39 | if (c.name === '酒') return (me.health <= 1 ? 92 : 50) - sameBefore * 24; |
| 40 | if (c.name === '杀') return (me.skills.some((s) => /咆哮/.test(s)) || equipmentName(me.equipment?.武器) === '诸葛连弩' ? 63 : 52) - sameBefore * 16; |
| 41 | if (c.name === '无中生有') return 88; |
| 42 | if (/顺手牵羊|过河拆桥|乐不思蜀|兵粮寸断/.test(c.name)) return 66; |
| 43 | if (c.name === '闪电') return 10; |
| 44 | if (c.type === '装备牌') return me.equipment?.[c.subtype as keyof typeof me.equipment] ? 22 : 58; |
| 45 | return 42; |
| 46 | } |
| 47 | |
| 48 | export class Strategic { |
| 49 | private game = ''; |
| 50 | private turn = ''; |
| 51 | private seen = new Set<string>(); |
| 52 | private loyalty = new Map<number, number>(); |
| 53 | private made = new Map<string, number>(); |
| 54 | |
| 55 | private observe(o: Observation): void { |
| 56 | if (this.game !== o.game) { |
| 57 | this.game = o.game ?? ''; |
| 58 | this.seen.clear(); this.loyalty.clear(); this.made.clear(); |
| 59 | } |
| 60 | const turn = `${o.game}:${o.turn.round}:${o.currentPlayerIndex}`; |
| 61 | if (turn !== this.turn) { this.turn = turn; this.made.clear(); } |
| 62 | const lord = o.players.findIndex((p) => p.identity === '主公'); |
| 63 | for (const entry of o.log ?? []) { |
| 64 | const key = `${entry.time}:${entry.player}:${entry.text}`; |
| 65 | if (this.seen.has(key)) continue; |
| 66 | this.seen.add(key); |
| 67 | const match = entry.text.match(/^使用\s+([^ (]+).* → ([\d, ]+)$/); |
| 68 | if (!match || entry.player < 0 || entry.player === lord) continue; |
| 69 | const sign = BENEFIT.test(match[1]) ? 1 : HARM.test(match[1]) ? -1 : 0; |
| 70 | for (const target of match[2].split(',').map(Number)) { |
| 71 | const known = o.players[target]?.identity; |
| 72 | const side = target === lord ? 1 : known === '忠臣' ? 0.8 : known === '反贼' ? -0.8 : clamp((this.loyalty.get(target) ?? 0) / 3, -0.5, 0.5); |
| 73 | this.loyalty.set(entry.player, clamp((this.loyalty.get(entry.player) ?? 0) + sign * side * 1.8, -5, 5)); |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | |
| 79 | attitude(o: Observation, target: number): number { |
| 80 | if (target === o.viewer) return 1; |
| 81 | const me = o.players[o.viewer], p = o.players[target]; |
| 82 | if (!p) return 0; |
| 83 | const alive = o.players.filter((x) => x.alive); |
| 84 | if (me.identity === '内奸') { |
| 85 | |
| 86 | if (p.identity === '主公') return alive.length > 2 ? 0.95 : -1; |
| 87 | return -0.55; |
| 88 | } |
| 89 | if (p.identity && me.identity) return CAMP[p.identity] === CAMP[me.identity] ? 1 : -1; |
| 90 | const loyalty = clamp((this.loyalty.get(target) ?? 0) / 3, -1, 1); |
| 91 | return me.identity === '反贼' ? -loyalty : loyalty; |
| 92 | } |
| 93 | |
| 94 | private targetValue(o: Observation, target: number, name: string, friendly: boolean): number { |
| 95 | const p = o.players[target], me = o.players[o.viewer]; |
| 96 | if (!p?.alive) return -1000; |
| 97 | const attitude = this.attitude(o, target); |
| 98 | if (friendly) return attitude * 60 + (p.maxHealth - p.health) * 8 + (p.health <= 1 ? 18 : 0) - p.handCount * 2; |
| 99 | let score = -attitude * 55 + (5 - p.health) * 5 + (p.handCount === 0 ? 12 : 0); |
| 100 | if (p.identity === '主公' && me.identity === '反贼') score += 35; |
| 101 | if (p.identity === '主公' && me.identity === '内奸' && o.players.filter((x) => x.alive).length > 2) score -= 150; |
| 102 | if (/乐不思蜀/.test(name)) score += p.handCount * 5; |
| 103 | if (/兵粮寸断/.test(name)) score += 10 - p.handCount; |
| 104 | if (/决斗/.test(name)) score -= p.handCount * 5 + (p.skills.some((s) => /无双/.test(s)) ? 20 : 0); |
| 105 | if (/杀/.test(name)) { |
| 106 | const armor = equipmentName(p.equipment?.防具); |
| 107 | if (armor === '仁王盾' && ownHand(o).find((c) => c.name === '杀')?.color === '黑') score -= 35; |
| 108 | if (armor === '八卦阵' || p.skills.includes('八阵')) score -= 10; |
| 109 | } |
| 110 | return score; |
| 111 | } |
| 112 | |
| 113 | private fill(o: Observation, l: AvailableAction): ClientMessage | null { |
| 114 | const m = structuredClone(l.message), p = m.params; |
| 115 | const hand = ownHand(o), name = cardName(o, m); |
| 116 | const friendly = BENEFIT.test(m.skillId) && !/恩怨/.test(m.skillId); |
| 117 | if (Array.isArray(p.allocation) && !p.allocation.length) { |
| 118 | const friends = l.validTargets.filter((t) => t !== o.viewer && this.attitude(o, t) > 0.35) |
| 119 | .sort((a, b) => this.targetValue(o, b, name, true) - this.targetValue(o, a, name, true)); |
| 120 | if (!friends.length) return null; |
| 121 | const give = [...hand].sort((a, b) => keepValue(o, a) - keepValue(o, b)); |
| 122 | const healing = /仁德/.test(m.skillId) && o.players[o.viewer].health < o.players[o.viewer].maxHealth; |
| 123 | const count = Math.min(give.length, healing ? 2 : Math.max(1, hand.length - Math.max(2, o.players[o.viewer].health))); |
| 124 | if (!count || (!healing && keepValue(o, give[0]) >= 80)) return null; |
| 125 | p.allocation = [{ target: friends[0], cardIds: give.slice(0, count).map((c) => c.id) }]; |
| 126 | return m; |
| 127 | } |
| 128 | if (l.validTargets.length && (!Array.isArray(p.targets) || !p.targets.length) && p.target === undefined) { |
| 129 | const order = [...l.validTargets].sort((a, b) => this.targetValue(o, b, name, friendly) - this.targetValue(o, a, name, friendly)); |
| 130 | const min = l.minTarget ?? 1, max = l.maxTarget ?? 1; |
| 131 | if (order.length < min) return null; |
| 132 | if (friendly && this.attitude(o, order[0]) <= 0.15) return null; |
| 133 | if (!friendly && this.attitude(o, order[0]) > 0.6 && l.category !== 'respond') return null; |
| 134 | const n = name === '杀' ? Math.max(min, Math.min(max, order.filter((i) => this.attitude(o, i) < 0.2).length)) : min; |
| 135 | p.targets = order.slice(0, n); p.target = order[0]; |
| 136 | } |
| 137 | if (Array.isArray(p.cardIds) && !p.cardIds.length) { |
| 138 | const prompt = o.context?.prompt as { cardFilter?: { candidates?: string[]; min?: number; max?: number }; minTotal?: number; maxTotal?: number; cardIds?: string[] } | undefined; |
| 139 | const discard = (o.pending as { discard?: { min: number; max: number } } | null)?.discard; |
| 140 | const eligible = prompt?.cardFilter?.candidates; |
| 141 | |
| 142 | |
| 143 | if (prompt?.cardIds?.length) { |
| 144 | const min = prompt.minTotal ?? 1; |
| 145 | const choices = [...prompt.cardIds].sort((a, b) => Number(a.startsWith('hidden-')) - Number(b.startsWith('hidden-'))); |
| 146 | if (choices.length < min) return null; |
| 147 | p.cardIds = choices.slice(0, min); |
| 148 | return m; |
| 149 | } |
| 150 | const cards = [...hand].filter((c) => !eligible || eligible.includes(c.id)).sort((a, b) => keepValue(o, a) - keepValue(o, b)); |
| 151 | const min = discard?.min ?? prompt?.minTotal ?? prompt?.cardFilter?.min ?? 1; |
| 152 | let count = min; |
| 153 | if (/制衡/.test(m.skillId)) count = Math.max(1, cards.filter((c) => keepValue(o, c) < 62).length); |
| 154 | count = Math.min(count, discard?.max ?? prompt?.maxTotal ?? prompt?.cardFilter?.max ?? cards.length); |
| 155 | if (cards.length < min || count < min) return null; |
| 156 | p.cardIds = cards.slice(0, count).map((c) => c.id); |
| 157 | } |
| 158 | return m; |
| 159 | } |
| 160 | |
| 161 | private nullify(o: Observation): number { |
| 162 | const frames = o.context?.frames ?? []; |
| 163 | let i = frames.length - 1, parity = 0; |
| 164 | while (i >= 0 && frames[i].skillId === '无懈可击') { parity++; i--; } |
| 165 | const frame = frames[i]; |
| 166 | if (!frame) return -20; |
| 167 | const values = frame.params.targets; |
| 168 | const target = parity === 0 && typeof o.context?.cancelTarget === 'number' ? o.context.cancelTarget |
| 169 | : Array.isArray(values) && typeof values[0] === 'number' ? values[0] : frame.from; |
| 170 | const p = o.players[target]; |
| 171 | if (!p) return -20; |
| 172 | const friendlyEffect = /无中生有|桃园结义|五谷丰登/.test(frame.skillId); |
| 173 | let effect = this.attitude(o, target) * (friendlyEffect ? 1 : -1); |
| 174 | if (parity % 2) effect *= -1; |
| 175 | const dangerous = /决斗|火攻|南蛮|万箭|闪电/.test(frame.skillId); |
| 176 | const lethal = dangerous && p.health <= 1; |
| 177 | return -effect * (lethal ? 120 : 80) - 18; |
| 178 | } |
| 179 | |
| 180 | private score(o: Observation, l: AvailableAction, m: ClientMessage, legal: AvailableAction[]): number { |
| 181 | const me = o.players[o.viewer], name = cardName(o, m), p = m.params; |
| 182 | if (l.category === 'skip' || m.actionType === 'end') return 0; |
| 183 | if (l.category === 'selectChar') { |
| 184 | const c = allCharacters.find((x) => x.name === p.character); |
| 185 | if (!c) return 20; |
| 186 | const skills = c.skills.map((s) => s.name); |
| 187 | let value = c.maxHealth * 5 + skills.reduce((v, s) => v + (CHARACTER_SKILL[s] ?? 3), 0); |
| 188 | if (me.identity === '主公') value += c.maxHealth * 4 + skills.filter((s) => /救援|仁德|奸雄|制衡|涅槃/.test(s)).length * 7; |
| 189 | if (me.identity === '忠臣') value += skills.filter((s) => /急救|仁德|青囊|遗计/.test(s)).length * 10; |
| 190 | return value; |
| 191 | } |
| 192 | const dying = o.players.find((x) => x.alive && x.health <= 0); |
| 193 | if (m.actionType === 'respond' || l.category === 'respond' || (l.category === 'transform' && m.actionType === 'respond')) { |
| 194 | if (name === '无懈可击') return this.nullify(o); |
| 195 | if (dying && (name === '桃' || name === '酒' || /急救|仁心|补益/.test(m.skillId))) { |
| 196 | if (dying.index === o.viewer) return name === '酒' ? 190 : 180; |
| 197 | return this.attitude(o, dying.index) > 0.25 ? 150 : -100; |
| 198 | } |
| 199 | if (name === '闪' || name === '杀') { |
| 200 | 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') ?? []; |
| 201 | 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); |
| 202 | } |
| 203 | |
| 204 | const prompt = o.context?.prompt as { type?: string; target?: number; targetIndex?: number; cards?: Array<{ cardId: string; cardName: string }> } | undefined; |
| 205 | if (prompt?.type === 'pickProcessingCard') { |
| 206 | const choice = prompt.cards?.find((c) => c.cardId === p.cardId); |
| 207 | return choice ? keepValue(o, { id: choice.cardId, name: choice.cardName } as Card) : 0; |
| 208 | } |
| 209 | if (prompt?.type === 'pickTargetCard') { |
| 210 | const target = prompt.target ?? prompt.targetIndex; |
| 211 | const ally = typeof target === 'number' && this.attitude(o, target) > 0.35; |
| 212 | if (p.zone === 'judge') return ally ? 110 : -30; |
| 213 | return p.zone === 'equipment' ? 72 : ally ? -40 : 60; |
| 214 | } |
| 215 | if (Array.isArray(p.cardIds)) return 85; |
| 216 | const target = targetsOf(m)[0]; |
| 217 | if (target !== undefined) return 35 + this.targetValue(o, target, name, BENEFIT.test(name)); |
| 218 | return scoreAction(o, l).score - 25; |
| 219 | } |
| 220 | if (l.category === 'discard') return 120; |
| 221 | if (l.category === 'distribute') return /制衡/.test(name) ? 58 : 52; |
| 222 | if (/苦肉|强袭/.test(name) && (me.health <= 1 || (me.health <= 2 && !ownHand(o).some((c) => c.name === '桃')))) return -100; |
| 223 | if (name === '桃') return me.health < me.maxHealth ? 90 + (me.health <= 1 ? 40 : 0) : -100; |
| 224 | if (name === '酒') { |
| 225 | const attack = legal.some((x) => x.message.skillId === '杀' && x.validTargets.some((t) => this.attitude(o, t) < 0.3)); |
| 226 | return attack ? 78 : -35; |
| 227 | } |
| 228 | if (name === '无中生有') return 105; |
| 229 | if (name === '闪电') return -30; |
| 230 | 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); |
| 231 | if (/南蛮入侵|万箭齐发/.test(name)) { |
| 232 | let value = 0; |
| 233 | for (const x of o.players.filter((x) => x.alive && x.index !== o.viewer)) { |
| 234 | if (me.identity === '内奸' && x.identity === '主公' && x.health <= 1 && o.players.filter((p) => p.alive).length > 2) return -150; |
| 235 | value += -this.attitude(o, x.index) * (x.health <= 1 ? 75 : 30) + (this.attitude(o, x.index) === 0 ? 2 : 0); |
| 236 | } |
| 237 | return value; |
| 238 | } |
| 239 | if (/重铸/.test(m.actionType)) return 40; |
| 240 | const card = ownHand(o).find((x) => x.id === p.cardId); |
| 241 | if (card?.type === '装备牌') { |
| 242 | const previous = equipmentName(me.equipment?.[card.subtype as keyof typeof me.equipment]); |
| 243 | if (!previous) return card.subtype === '武器' ? 82 : 65; |
| 244 | if (previous === '白银狮子' && me.health < me.maxHealth) return 85; |
| 245 | if (card.name === '诸葛连弩' && ownHand(o).filter((x) => x.name === '杀').length > 1) return 90; |
| 246 | return -10; |
| 247 | } |
| 248 | const targets = targetsOf(m); |
| 249 | if (targets.length) { |
| 250 | const friendly = BENEFIT.test(name); |
| 251 | let v = targets.reduce((sum, t) => sum + this.targetValue(o, t, name, friendly), 0) / targets.length; |
| 252 | if (!friendly && targets.some((t) => this.attitude(o, t) > 0.6)) return -100; |
| 253 | if (name === '杀') v += 30; |
| 254 | else if (name === '决斗') v += ownHand(o).filter((x) => x.name === '杀').length * 18 - 25; |
| 255 | else if (name === '火攻') v += new Set(ownHand(o).filter((c) => c.id !== p.cardId).map((c) => c.suit)).size * 9 - 30; |
| 256 | else v += 15; |
| 257 | if (m.preceding) v -= m.preceding.flatMap((x) => Array.isArray(x.params.cardIds) ? x.params.cardIds : [x.params.cardId]).reduce<number>((cost, id) => { |
| 258 | const c = ownHand(o).find((x) => x.id === id); return cost + (c ? keepValue(o, c) * 0.3 : 0); |
| 259 | }, 0); |
| 260 | return v; |
| 261 | } |
| 262 | return scoreAction(o, l).score - 35; |
| 263 | } |
| 264 | |
| 265 | act(o: Observation, legal: AvailableAction[]): ClientMessage | number { |
| 266 | this.observe(o); |
| 267 | const refused = new Set((o.refused ?? []).map((x) => JSON.stringify(x))); |
| 268 | const ranked = legal.map((l, i) => { |
| 269 | const m = this.fill(o, l); |
| 270 | const key = JSON.stringify(m); |
| 271 | |
| 272 | |
| 273 | const unchangedRejectedIndex = refused.has(JSON.stringify(i)) && key === JSON.stringify(l.message); |
| 274 | const no = !m || refused.has(key) || unchangedRejectedIndex || (this.made.get(key) ?? 0) >= 2; |
| 275 | return { m, key, value: no ? -Infinity : this.score(o, l, m!, legal), i }; |
| 276 | }).sort((a, b) => b.value - a.value || a.i - b.i); |
| 277 | const best = ranked[0]; |
| 278 | if (best?.m && Number.isFinite(best.value)) { |
| 279 | this.made.set(best.key, (this.made.get(best.key) ?? 0) + 1); |
| 280 | return best.m; |
| 281 | } |
| 282 | const pass = legal.findIndex((l) => l.category === 'skip' || l.message.actionType === 'end'); |
| 283 | return pass >= 0 ? pass : 0; |
| 284 | } |
| 285 | } |