import { MapCellKind, Weapon2TileType, isElementGlyphType } from 'db://assets/mx/module/merge'; import type { AiPlannerContext, AiPlannedAction, AiPlaceActionPayload } from '../types/AiTypes'; import { AiActionKind } from '../types/AiTypes'; import { AiBoardProbe } from './AiBoardProbe'; import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider'; import { BattleMapSpec } from '../../map/BattleMapSpec'; import type { MapGrid } from '../../map/legacy/MapGrid'; import { TextListCellState } from '../../view/TextListLandHost'; import { aiOp, aiWarn } from '../runtime/AiLog'; /** 落空 Put 候选评分 */ interface PlaceCand { /** 地图格索引 */ gridIndex: number; /** 行 */ row: number; /** 列 */ col: number; /** 落子后预估武将数 */ heroes: number; /** 阵型分 */ form: number; } /** * 五行数字落空 Put 评分。 */ export class AiPlacementScorer { private readonly _probe = new AiBoardProbe(); /** * 为背包某槽选最优空位并生成落子动作。 * @param ctx 决策上下文 * @param slotIndex 背包槽 */ planPlaceSlot(ctx: AiPlannerContext, slotIndex: number): AiPlannedAction | null { const slot = ctx.bagSession.getBagSlots()[slotIndex]; if (!slot) return null; const land = ctx.bagSession.landStateOf?.(slotIndex); if (land != null && land !== TextListCellState.Open) return null; WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; if (provider.passiveSpawnKeepsDigCell(slot.weapon2Id)) { return this._planKeepDigPlace(ctx, slotIndex, slot); } const w2Type = Number(provider.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType; if (!isElementGlyphType(w2Type)) return null; const level = Math.floor(slot.level ?? 1); const w1 = provider.getElementHeroId(w2Type, level) ?? 0; const heroCountBase = this._probe.predictHeroCountAfterPlace(ctx.bagSession, slot.weapon2Id); const grids = ctx.map.mapGrids; if (!Array.isArray(grids)) return null; const best = this._pickBestEmptyPut(ctx, grids, w2Type, w1, heroCountBase); if (!best) return null; const place: AiPlaceActionPayload = { weapon1Id: w1, level, row: best.row, col: best.col, }; aiOp( `摆放规划 bag[${slotIndex}] w2=${slot.weapon2Id} type=${w2Type}` + ` -> map[${best.gridIndex}] rc=(${best.row},${best.col})` + ` form=${Math.round(best.form)} heroes=${best.heroes}`, ); return { kind: AiActionKind.Place, merge: { source: { kind: 'bag', slotIndex }, target: { kind: 'map', gridIndex: best.gridIndex }, }, place, }; } /** * 为待落子单位选最优格(兼容旧接口)。 * @param ctx 决策上下文 * @param weapon1Id Weapon1 id * @param level 局内等级 */ pickBest(ctx: AiPlannerContext, weapon1Id: number, level: number): AiPlaceActionPayload | null { void level; const grids = ctx.map.mapGrids; if (!Array.isArray(grids)) return null; let best: AiPlaceActionPayload | null = null; let bestForm = -1e99; let bestIndex = 1e9; for (let i = 0; i < grids.length; i++) { const mg = grids[i]; if (!mg?.node?.isValid || !mg.node.active) continue; if (!ctx.bagSession.validator.isAllowedCampForPlace(mg.Index)) continue; if (ctx.bagSession.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) continue; if (ctx.bagSession.merge.map.hasTile(mg.Index)) continue; const form = this._probe.formationScore(ctx.map, mg, weapon1Id); if (form > bestForm || (form === bestForm && mg.Index < bestIndex)) { bestForm = form; bestIndex = mg.Index; best = { weapon1Id, level: level, row: mg.Row, col: mg.Col }; } } return best; } /** * 扫描背包五行字落空 Put。 * @param ctx 决策上下文 */ plan(ctx: AiPlannerContext): AiPlannedAction | null { WeaponComposeConfigProvider.instance.load(); const slots = ctx.bagSession.getBagSlots(); const order: number[] = []; for (let i = 0; i < slots.length; i++) { if (!slots[i]) continue; const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slots[i]!.weapon2Id)); if (!isElementGlyphType(w2Type) && !WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(slots[i]!.weapon2Id)) { continue; } order.push(i); } const emptyPut = this._probe.countEmptyTextPut(ctx.bagSession, ctx.map); this._sortPlaceOrder(ctx, order); if (order.length > 0) { const names: string[] = []; for (let i = 0; i < order.length; i++) { const s = slots[order[i]]; names.push(s ? `bag[${order[i]}]=${s.weapon2Id}` : `bag[${order[i]}]=?`); } aiOp(`摆放扫描 emptyPut=${emptyPut} order=${names.join(',')}`); } for (let i = 0; i < order.length; i++) { const action = this.planPlaceSlot(ctx, order[i]); if (action) return action; } if (emptyPut > 0 && order.length > 0) { aiWarn(`摆放失败 emptyPut=${emptyPut} bagSlots=${order.length} 无候选(空格均不可落)`); } return null; } /** * 在可落空 Put 中选最优格。 * @param ctx 决策上下文 * @param grids 地图格 * @param w2Type 字类型 * @param w1 阵型用 weapon1 * @param heroCountBase 落子后预估武将数 */ private _pickBestEmptyPut(ctx: AiPlannerContext, grids: readonly MapGrid[], w2Type: Weapon2TileType, w1: number, heroCountBase: number): PlaceCand | null { let best: PlaceCand | null = null; for (let i = 0; i < grids.length; i++) { const mg = grids[i]; if (!mg?.node?.isValid || !mg.node.active) continue; if (!ctx.bagSession.validator.isAllowedCampForPlace(mg.Index)) continue; if (!this._probe.canDropTextOnEmptyGrid(ctx.bagSession, mg, w2Type)) continue; const form = this._probe.formationScore(ctx.map, mg, w1); const cand: PlaceCand = { gridIndex: mg.Index, row: mg.Row, col: mg.Col, heroes: heroCountBase, form }; best = this._betterPlaceCand(best, cand); } return best; } /** * 比较落点候选:武将数 → 阵型 → 更小 gridIndex。 * @param best 当前最优 * @param cand 新候选 */ private _betterPlaceCand(best: PlaceCand | null, cand: PlaceCand): PlaceCand { if (!best || cand.heroes > best.heroes || (cand.heroes === best.heroes && cand.form > best.form) || (cand.heroes === best.heroes && cand.form === best.form && cand.gridIndex < best.gridIndex)) { return cand; } return best; } /** * 落子扫描序(高等级优先)。 * @param ctx 上下文 * @param order 槽下标 */ private _sortPlaceOrder(ctx: AiPlannerContext, order: number[]): void { const provider = WeaponComposeConfigProvider.instance; const slots = ctx.bagSession.getBagSlots(); for (let i = 0; i < order.length; i++) { for (let j = i + 1; j < order.length; j++) { const a = order[i]; const b = order[j]; if (this._placePriority(ctx, slots[b]!, provider) <= this._placePriority(ctx, slots[a]!, provider)) { continue; } order[i] = b; order[j] = a; } } } /** * 蟠桃树落子:优先空 Dig,否则空 Put。 * @param ctx 决策上下文 * @param slotIndex 背包槽 * @param slot 背包字 */ private _planKeepDigPlace(ctx: AiPlannerContext, slotIndex: number, slot: { weapon2Id: number; level?: number }): AiPlannedAction | null { const grids = ctx.map.mapGrids; if (!Array.isArray(grids)) return null; let bestDig: { gridIndex: number; row: number; col: number } | null = null; let bestPut: { gridIndex: number; row: number; col: number } | null = null; for (let i = 0; i < grids.length; i++) { const mg = grids[i]; if (!mg?.node?.isValid || !mg.node.active) continue; if (!this._probe.canDropKeepDigOnEmptyGrid(ctx.bagSession, mg)) continue; const kind = ctx.bagSession.merge.map.getCellKind(mg.Index); const pick = { gridIndex: mg.Index, row: mg.Row, col: mg.Col }; if (kind === MapCellKind.Dig) { if (!bestDig || mg.Index < bestDig.gridIndex) bestDig = pick; } else if (kind === MapCellKind.Put) { if (!bestPut || mg.Index < bestPut.gridIndex) bestPut = pick; } } const best = bestDig ?? bestPut; if (!best) return null; const kind = ctx.bagSession.merge.map.getCellKind(best.gridIndex); aiOp( `摆放规划(蟠桃树) bag[${slotIndex}] w2=${slot.weapon2Id}` + ` -> map[${best.gridIndex}] rc=(${best.row},${best.col}) kind=${kind}`, ); return { kind: AiActionKind.Place, merge: { source: { kind: 'bag', slotIndex }, target: { kind: 'map', gridIndex: best.gridIndex }, }, place: { weapon1Id: this._probe.resolveWeapon1Id(slot) ?? 0, level: slot.level ?? 1, row: best.row, col: best.col, }, }; } /** * 单槽落子优先级分。 * @param ctx 上下文 * @param slot 背包字 * @param provider 配表 */ private _placePriority(ctx: AiPlannerContext, slot: { weapon2Id: number; level?: number }, provider: WeaponComposeConfigProvider): number { const w2Type = Number(provider.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType; const heroAfter = this._probe.predictHeroCountAfterPlace(ctx.bagSession, slot.weapon2Id); const heroBase = this._probe.predictHeroCountAfterPlace(ctx.bagSession, -1); const heroGain = heroAfter - heroBase; let score = heroGain * 100; const level = Math.max(1, Math.floor(slot.level ?? 1)); if (isElementGlyphType(w2Type)) { score += 600 + level * 10; } score += level; const w1 = this._probe.resolveWeapon1Id(slot) ?? 0; const rangeCells = Math.max(1, Math.ceil(this._probe.rangePxForWeapon1(w1) / BattleMapSpec.CELL_SIZE)); score += Math.max(0, 12 - rangeCells) * 20; return score; } }