You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
294 lines
13 KiB
294 lines
13 KiB
import { MapCellKind, Weapon2TileType } 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 { isNonCombatWeapon1 } from '../../heros/HeroActorRes';
|
|
import { aiOp, aiWarn } from '../runtime/AiLog';
|
|
|
|
/** 落空 Put 候选评分 */
|
|
interface PlaceCand {
|
|
/** 地图格索引 */
|
|
gridIndex: number;
|
|
/** 行 */
|
|
row: number;
|
|
/** 列 */
|
|
col: number;
|
|
/** 落子后预估武将数 */
|
|
heroes: number;
|
|
/** 字序分 */
|
|
order: number;
|
|
/** 阵型分 */
|
|
form: number;
|
|
}
|
|
|
|
/** 需求 7 落点评分:武将数 -> 字序 -> 阵型(路径出口火力/覆盖/短前长后) */
|
|
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;
|
|
WeaponComposeConfigProvider.instance.load();
|
|
if (WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(slot.weapon2Id)) {
|
|
return this._planKeepDigPlace(ctx, slotIndex, slot);
|
|
}
|
|
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
|
|
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) return null;
|
|
|
|
const w1Raw = this._probe.resolveWeapon1Id(slot) ?? 0;
|
|
const w1 = w1Raw > 0 ? w1Raw : (this._probe.comboHeroIdForText(slot.weapon2Id) ?? 0);
|
|
const heroCountBase = this._probe.predictHeroCountAfterPlace(ctx.bagSession, slot.weapon2Id);
|
|
const grids = ctx.map.mapGrids;
|
|
if (!Array.isArray(grids)) return null;
|
|
|
|
// 组合字:先避开逆序邻接;若因此无候选,再允许落到任意可落空 Put,避免有空位长期不摆
|
|
const best = this._pickBestEmptyPut(ctx, grids, slot.weapon2Id, w2Type, w1, heroCountBase, w2Type === Weapon2TileType.Combo);
|
|
if (!best) return null;
|
|
const place: AiPlaceActionPayload = {
|
|
weapon1Id: w1,
|
|
level: slot.level ?? 1,
|
|
row: best.row,
|
|
col: best.col,
|
|
};
|
|
aiOp(
|
|
`摆放规划 bag[${slotIndex}] w2=${slot.weapon2Id} type=${w2Type}`
|
|
+ ` -> map[${best.gridIndex}] rc=(${best.row},${best.col})`
|
|
+ ` order=${best.order} 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 (w2Type === Weapon2TileType.Shovel) continue;
|
|
order.push(i);
|
|
}
|
|
const emptyPut = this._probe.countEmptyTextPut(ctx.bagSession, ctx.map);
|
|
this._sortPlaceOrder(ctx, order, emptyPut > 0);
|
|
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} preferSingle=${emptyPut > 0} 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 weapon2Id 待落字
|
|
* @param w2Type 字类型
|
|
* @param w1 阵型用 weapon1
|
|
* @param heroCountBase 落子后预估武将数
|
|
* @param preferAvoidReverse 组合字时是否先避开逆序邻接
|
|
*/
|
|
private _pickBestEmptyPut(ctx: AiPlannerContext, grids: readonly MapGrid[], weapon2Id: number, w2Type: Weapon2TileType, w1: number, heroCountBase: number, preferAvoidReverse: boolean): PlaceCand | null {
|
|
let bestStrict: PlaceCand | null = null;
|
|
let bestAny: 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 order = this._probe.composeOrderScore(ctx.bagSession, ctx.map, weapon2Id, mg);
|
|
const cand: PlaceCand = { gridIndex: mg.Index, row: mg.Row, col: mg.Col, heroes: heroCountBase, order, form };
|
|
bestAny = this._betterPlaceCand(bestAny, cand);
|
|
if (!preferAvoidReverse || order >= 0) {
|
|
bestStrict = this._betterPlaceCand(bestStrict, cand);
|
|
}
|
|
}
|
|
if (bestStrict) return bestStrict;
|
|
if (bestAny && preferAvoidReverse) {
|
|
aiWarn(
|
|
`组合字无正序空位,回退逆序邻接格 map[${bestAny.gridIndex}]`
|
|
+ ` w2=${weapon2Id} order=${bestAny.order}`,
|
|
);
|
|
}
|
|
return bestAny;
|
|
}
|
|
|
|
/**
|
|
* 比较落点候选:武将数 → 字序 → 阵型 → 更小 gridIndex。
|
|
* @param best 当前最优
|
|
* @param cand 新候选
|
|
*/
|
|
private _betterPlaceCand(best: PlaceCand | null, cand: PlaceCand): PlaceCand {
|
|
if (!best
|
|
|| cand.heroes > best.heroes
|
|
|| (cand.heroes === best.heroes && cand.order > best.order)
|
|
|| (cand.heroes === best.heroes && cand.order === best.order && cand.form > best.form)
|
|
|| (cand.heroes === best.heroes && cand.order === best.order && cand.form === best.form && cand.gridIndex < best.gridIndex)) {
|
|
return cand;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* 落子扫描序。
|
|
* @param ctx 上下文
|
|
* @param order 槽下标
|
|
* @param preferSingle 有空位时普通字优先于组合散字
|
|
*/
|
|
private _sortPlaceOrder(ctx: AiPlannerContext, order: number[], preferSingle: boolean): 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, preferSingle) <= this._placePriority(ctx, slots[a]!, provider, preferSingle)) {
|
|
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 配表
|
|
* @param preferSingle 有空 Put 时抬高普通字
|
|
*/
|
|
private _placePriority(ctx: AiPlannerContext, slot: { weapon2Id: number; level?: number }, provider: WeaponComposeConfigProvider, preferSingle: boolean): number {
|
|
const w2Type = Number(provider.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
|
|
const quality = Number(provider.getWeapon2Config(slot.weapon2Id)?.quality) || 0;
|
|
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;
|
|
if (preferSingle) {
|
|
if (w2Type === Weapon2TileType.Single) score += 800 + quality * 5;
|
|
else if (w2Type === Weapon2TileType.Combo) score += 200 + quality * 20;
|
|
} else if (w2Type === Weapon2TileType.Combo) {
|
|
score += 500 + quality * 20;
|
|
} else if (w2Type === Weapon2TileType.Single) {
|
|
score += 100 + quality * 5;
|
|
}
|
|
score += (slot.level ?? 1);
|
|
const w1Raw = this._probe.resolveWeapon1Id(slot) ?? 0;
|
|
const w1 = w1Raw > 0 ? w1Raw : (this._probe.comboHeroIdForText(slot.weapon2Id) ?? 0);
|
|
if (isNonCombatWeapon1(w1)) {
|
|
// 无攻击功能字最后落,避免占前排
|
|
score -= 5000;
|
|
return score;
|
|
}
|
|
const rangeCells = Math.max(1, Math.ceil(this._probe.rangePxForWeapon1(w1) / BattleMapSpec.CELL_SIZE));
|
|
score += Math.max(0, 12 - rangeCells) * 20;
|
|
return score;
|
|
}
|
|
}
|
|
|