实时对局选你的对手
加入
0e1d7fa
285 行 · 17.1 KB 原始文件 永久链接 历史
1// A deterministic, seat-local identity-game player. No engine state or concealed role/hand is read.
2// Public actions provide uncertain allegiance evidence; they never turn into a claimed identity.
3import { scoreAction } from '../src/ai-mcp/heuristics';
4import { allCharacters } from '../src/engine/data/characters';
5import type { AiViewSnapshot, AvailableAction } from '../src/client/headless/types';
6import type { Card, ClientMessage, GameView } from '../src/engine/types';
7
8type Context = {
9 frames?: GameView['settlementStack'];
10 cancelTarget?: number;
11 prompt?: NonNullable<GameView['pending']>['prompt'];
12 players?: Array<{ pendingTricks?: string[]; distanceVars?: { attackRange?: number } }>;
13};
14export type Observation = AiViewSnapshot & { game?: string; refused?: unknown[]; context?: Context };
15const CAMP: Record<string, string> = { 主公: 'lord', 忠臣: 'lord', 反贼: 'rebel', 内奸: 'renegade' };
16const BENEFIT = /桃$|青囊|仁德|遗计|节命|补益|仁心|遗礼|界再起|黄天|救援|恩怨/;
17const HARM = /杀|决斗|火攻|拆桥|牵羊|兵粮|乐不|强袭|挑衅|反间|离间|突袭|雷击|猛进|刚烈|天香/;
18const 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};
25const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
26const ownHand = (o: Observation) => o.players[o.viewer]?.hand ?? [];
27const cardName = (o: Observation, m: ClientMessage) => ownHand(o).find((c) => c.id === m.params.cardId)?.name ?? m.skillId;
28const 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] : [];
29const equipmentName = (id: unknown) => typeof id === 'string' ? id.split('-')[0] : '';
30
31/** Marginal value, including duplicate cards and survival at current HP, not just card-name order. */
32export 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
48export class Strategic {
49 private game = '';
50 private turn = '';
51 private seen = new Set<string>();
52 private loyalty = new Map<number, number>(); // positive = has publicly supported the lord
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 /** [-1,1], a strategic attitude rather than an assertion about a concealed identity. */
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 // Keep the lord alive until everybody else is gone; a premature regicide loses.
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 // Static selections may belong to another player's hand. Their opaque handles are
142 // deliberately not card faces; choose visible equipment first, then an unknown back.
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; // missing context is unknown, not a reason to cancel everybody's tricks
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 // Pick which public zone to remove; never infer the face of a hidden hand card.
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 // A rejected template index (e.g. discard with cardIds=[]) must not reject the
272 // completed message as well; the fallback still has to fill mandatory choices.
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}