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.
582 lines
23 KiB
582 lines
23 KiB
|
3 weeks ago
|
import type { DragSource, DropTarget } from 'db://assets/mx/module/merge';
|
||
|
|
import { MapCellKind } from 'db://assets/mx/module/merge';
|
||
|
|
import { aiOp, aiWarn } from './AiLog';
|
||
|
|
import { Director, director } from 'cc';
|
||
|
|
import type { AiMatchProfile } from '../match/AiMatchProfile';
|
||
|
|
import type { AiBattlePersonality } from './AiBattlePersonality';
|
||
|
|
import { AiHeadlessBagHost } from './AiHeadlessBagHost';
|
||
|
|
import type { AiMapTextHost } from './AiMapTextHost';
|
||
|
|
import { AiActionPlanner } from '../decision/AiActionPlanner';
|
||
|
|
import { AiBoardProbe } from '../decision/AiBoardProbe';
|
||
|
|
import type { MapManager } from '../../map/legacy/MapManager';
|
||
|
|
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
|
||
|
|
import type { AiPlannerContext, AiPlannedAction, AiMergeActionPayload } from '../types/AiTypes';
|
||
|
|
import { AiActionKind } from '../types/AiTypes';
|
||
|
|
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
|
||
|
|
import type { BattleBagSession } from '../../bag/BattleBagSession';
|
||
|
|
import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore';
|
||
|
|
import {
|
||
|
|
hasCarriedTreasureShovel,
|
||
|
|
rollCarriedTreasureShovelPeachCount,
|
||
|
|
} from '../../../manager/battleBuff/BattleBuffPassiveSkillUtil';
|
||
|
|
|
||
|
|
/** 进入战斗后延迟多少秒才开始决策(落字等) */
|
||
|
|
const AI_START_DELAY_SEC = 6;
|
||
|
|
/** AI 决策 tick 间隔(秒):每拍最多一步操作 */
|
||
|
|
const AI_TICK_INTERVAL_SEC = 1;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 局内 AI 操作者(对面阵营)。
|
||
|
|
* 使用独立无头 bag,与底栏玩家拖拽无关。
|
||
|
|
*/
|
||
|
|
export class AiOpponentController {
|
||
|
|
private readonly _planner = new AiActionPlanner();
|
||
|
|
private readonly _probe = new AiBoardProbe();
|
||
|
|
private _accumSec = 0;
|
||
|
|
/** 开战激活倒计时(秒);>0 时不决策 */
|
||
|
|
private _startDelaySec = 0;
|
||
|
|
private _running = false;
|
||
|
|
private _profile: AiMatchProfile | null = null;
|
||
|
|
private _personality: AiBattlePersonality | null = null;
|
||
|
|
private _bagHost: AiHeadlessBagHost | null = null;
|
||
|
|
private _battle: ProjectBattleHandle | null = null;
|
||
|
|
private _map: MapManager | null = null;
|
||
|
|
/** 字池耗尽后本局不再尝试刷新 */
|
||
|
|
private _poolEmpty = false;
|
||
|
|
/** 本 tick 单字冲级目标 */
|
||
|
|
private _textFocusWeapon2Id: number | null = null;
|
||
|
|
/** 盘面变化后回调(技能绑定 / 营展示) */
|
||
|
|
private _onBoardChanged: (() => void) | null = null;
|
||
|
|
/** 多步重摆/拼装排队:每拍只执行一步,避免一瞬堆字 */
|
||
|
|
private readonly _pendingMoves: AiMergeActionPayload[] = [];
|
||
|
|
/** DeploySync 延后对账中 */
|
||
|
|
private _deployPending = false;
|
||
|
|
/** 拼装/一步合成前的神将数;完成后校验是否真正成将 */
|
||
|
|
private _mergeHeroBaseline: number | null = null;
|
||
|
|
/** 当前排队是否来自合成拼装(需成将校验) */
|
||
|
|
private _pendingIsAssemble = false;
|
||
|
|
/** 刷新拍:乾坤袋回收背包多余字(场上不回收) */
|
||
|
|
private _tryRecycleSurplus: (() => boolean) | null = null;
|
||
|
|
/** AI 地图字表现(跳飞) */
|
||
|
|
private _mapText: AiMapTextHost | null = null;
|
||
|
|
/** 跳飞进行中,暂停决策 */
|
||
|
|
private _flightBusy = false;
|
||
|
|
/** 跳飞落地后要执行的逻辑落子 */
|
||
|
|
private _flightSource: DragSource | null = null;
|
||
|
|
/** 跳飞落地目标 */
|
||
|
|
private _flightTarget: DropTarget | null = null;
|
||
|
|
/** 跳飞动作标签 */
|
||
|
|
private _flightTag = '';
|
||
|
|
/** 跳飞落地后是否校验一步合成成将 */
|
||
|
|
private _flightOneStepMerge = false;
|
||
|
|
|
||
|
|
/** 是否正在运行 */
|
||
|
|
get running(): boolean {
|
||
|
|
return this._running;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 启动控制器。
|
||
|
|
* @param profile 匹配档案
|
||
|
|
* @param personality 整局策略
|
||
|
|
* @param bagHost 无头背包宿主
|
||
|
|
* @param battle 战斗句柄
|
||
|
|
* @param map 地图
|
||
|
|
* @param onBoardChanged 部署对账后回调
|
||
|
|
* @param tryRecycleSurplus 刷新前乾坤袋回收背包多余字
|
||
|
|
* @param mapText AI 地图字表现
|
||
|
|
*/
|
||
|
|
start(profile: AiMatchProfile, personality: AiBattlePersonality, bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, map: MapManager, onBoardChanged?: () => void, tryRecycleSurplus?: () => boolean, mapText?: AiMapTextHost | null): void {
|
||
|
|
this._profile = profile;
|
||
|
|
this._personality = personality;
|
||
|
|
this._bagHost = bagHost;
|
||
|
|
this._battle = battle;
|
||
|
|
this._map = map;
|
||
|
|
this._onBoardChanged = onBoardChanged ?? null;
|
||
|
|
this._tryRecycleSurplus = tryRecycleSurplus ?? null;
|
||
|
|
this._mapText = mapText ?? null;
|
||
|
|
this._accumSec = 0;
|
||
|
|
this._startDelaySec = AI_START_DELAY_SEC;
|
||
|
|
this._poolEmpty = false;
|
||
|
|
this._textFocusWeapon2Id = null;
|
||
|
|
this._pendingMoves.length = 0;
|
||
|
|
this._deployPending = false;
|
||
|
|
this._mergeHeroBaseline = null;
|
||
|
|
this._pendingIsAssemble = false;
|
||
|
|
this._clearFlightState();
|
||
|
|
director.off(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
|
||
|
|
this._running = true;
|
||
|
|
aiOp(`开战 delay=${AI_START_DELAY_SEC}s tick=${AI_TICK_INTERVAL_SEC}s`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 停止 tick 并清理引用 */
|
||
|
|
stop(): void {
|
||
|
|
this._running = false;
|
||
|
|
this._accumSec = 0;
|
||
|
|
this._startDelaySec = 0;
|
||
|
|
this._poolEmpty = false;
|
||
|
|
this._textFocusWeapon2Id = null;
|
||
|
|
this._pendingMoves.length = 0;
|
||
|
|
this._deployPending = false;
|
||
|
|
this._mergeHeroBaseline = null;
|
||
|
|
this._pendingIsAssemble = false;
|
||
|
|
this._mapText?.abortFlight();
|
||
|
|
this._clearFlightState();
|
||
|
|
this._mapText = null;
|
||
|
|
director.off(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
|
||
|
|
this._onBoardChanged = null;
|
||
|
|
this._tryRecycleSurplus = null;
|
||
|
|
this._profile = null;
|
||
|
|
this._personality = null;
|
||
|
|
this._bagHost = null;
|
||
|
|
this._battle = null;
|
||
|
|
this._map = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 由桥接层每帧调用;开战延迟结束后按固定节拍触发决策。
|
||
|
|
* @param dtSec 帧间隔(秒)
|
||
|
|
*/
|
||
|
|
tick(dtSec: number): void {
|
||
|
|
if (!this._running || !this._profile || !this._personality || !this._bagHost) return;
|
||
|
|
if (this._flightBusy) return;
|
||
|
|
if (this._startDelaySec > 0) {
|
||
|
|
this._startDelaySec -= dtSec;
|
||
|
|
if (this._startDelaySec > 0) return;
|
||
|
|
this._startDelaySec = 0;
|
||
|
|
this._accumSec = 0;
|
||
|
|
aiOp(`已激活 tick=${AI_TICK_INTERVAL_SEC}s`);
|
||
|
|
this._runOneTick();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._accumSec += dtSec;
|
||
|
|
if (this._accumSec < AI_TICK_INTERVAL_SEC) return;
|
||
|
|
// 卡顿追帧时也不连放多拍,每帧最多一步
|
||
|
|
this._accumSec = 0;
|
||
|
|
this._runOneTick();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 单次节拍:优先消化排队重摆 → 否则一步 §6.1 动作 → 否则有桃刷一次。
|
||
|
|
*/
|
||
|
|
private _runOneTick(): void {
|
||
|
|
const bagSession = this._bagHost!.bagSession;
|
||
|
|
if (!bagSession || !this._map || !this._profile || !this._personality) return;
|
||
|
|
WeaponComposeConfigProvider.instance.load();
|
||
|
|
|
||
|
|
const ctx: AiPlannerContext = {
|
||
|
|
playerId: AiHeadlessBagHost.AI_PLAYER_ID,
|
||
|
|
profile: this._profile,
|
||
|
|
personality: this._personality,
|
||
|
|
bagSession,
|
||
|
|
map: this._map,
|
||
|
|
textFocusWeapon2Id: this._textFocusWeapon2Id,
|
||
|
|
};
|
||
|
|
this._planner.textPlanner.refreshFocus(ctx);
|
||
|
|
this._textFocusWeapon2Id = ctx.textFocusWeapon2Id;
|
||
|
|
|
||
|
|
// 排队中的拼装/重摆:每拍只走一步,保留 1s 停顿感
|
||
|
|
if (this._drainPendingMove()) {
|
||
|
|
if (this._flightBusy) return;
|
||
|
|
this._afterBoardMutation();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const action = this._planner.planNext(ctx);
|
||
|
|
if (action) {
|
||
|
|
if (this._executeAction(action)) {
|
||
|
|
if (this._flightBusy) return;
|
||
|
|
this._afterBoardMutation(action.kind);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// 规划成功但落子拒绝:本拍回退刷新,避免整拍空转
|
||
|
|
aiOp(`动作失败回退刷新 kind=${action.kind}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 升级/放字已穷尽:先乾坤袋回收背包多余字(场上不回收),再刷新
|
||
|
|
if (this._tryRecycleSurplus && this._tryRecycleSurplus()) {
|
||
|
|
this._afterBoardMutation();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this._poolEmpty) return;
|
||
|
|
const r = this._bagHost!.tryRefresh();
|
||
|
|
if (r === 'pool_empty') {
|
||
|
|
this._poolEmpty = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (r === 'ok') {
|
||
|
|
this._afterBoardMutation();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 执行排队中的下一步 map→map;无排队返回 false。
|
||
|
|
* 失败不消费该步,清空队列避免半成品长期空转。
|
||
|
|
*/
|
||
|
|
private _drainPendingMove(): boolean {
|
||
|
|
if (this._pendingMoves.length <= 0) return false;
|
||
|
|
const step = this._pendingMoves[0];
|
||
|
|
if (!this._dropOne(step.source, step.target, 'pending')) {
|
||
|
|
aiOp(
|
||
|
|
`[AiMerge] pending drop fail left=${this._pendingMoves.length}`
|
||
|
|
+ ` src=${this._fmtSrc(step.source)} dst=${this._fmtDst(step.target)}`,
|
||
|
|
);
|
||
|
|
this._clearMergeQueue('pending_drop_fail');
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
this._pendingMoves.shift();
|
||
|
|
if (this._flightBusy) return true;
|
||
|
|
if (this._pendingMoves.length <= 0 && this._pendingIsAssemble) {
|
||
|
|
this._verifyMergeHeroGain('assemble_done');
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 执行单步规划动作(多步重摆/拼装只做首步,其余入队)。
|
||
|
|
* @param action 规划结果
|
||
|
|
*/
|
||
|
|
private _executeAction(action: AiPlannedAction): boolean {
|
||
|
|
if (action.moves && action.moves.length > 0) {
|
||
|
|
const isAssemble = action.kind === AiActionKind.Merge;
|
||
|
|
if (isAssemble) {
|
||
|
|
this._mergeHeroBaseline = this._countComboHeroes();
|
||
|
|
this._pendingIsAssemble = true;
|
||
|
|
aiOp(
|
||
|
|
`[AiMerge] assemble start moves=${action.moves.length} heroesBefore=${this._mergeHeroBaseline}`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
this._pendingIsAssemble = false;
|
||
|
|
this._mergeHeroBaseline = null;
|
||
|
|
}
|
||
|
|
const first = action.moves[0];
|
||
|
|
if (!this._dropOne(first.source, first.target, String(action.kind))) {
|
||
|
|
aiOp(
|
||
|
|
`[AiMerge] first drop fail kind=${action.kind}`
|
||
|
|
+ ` src=${this._fmtSrc(first.source)} dst=${this._fmtDst(first.target)}`,
|
||
|
|
);
|
||
|
|
this._clearMergeQueue('first_drop_fail');
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
this._pendingMoves.length = 0;
|
||
|
|
for (let i = 1; i < action.moves.length; i++) {
|
||
|
|
this._pendingMoves.push(action.moves[i]);
|
||
|
|
}
|
||
|
|
if (this._flightBusy) return true;
|
||
|
|
if (this._pendingMoves.length <= 0 && isAssemble) {
|
||
|
|
this._verifyMergeHeroGain('assemble_one_step');
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (!action.merge) return false;
|
||
|
|
const isOneStepMerge = action.kind === AiActionKind.Merge;
|
||
|
|
const before = isOneStepMerge ? this._countComboHeroes() : -1;
|
||
|
|
if (isOneStepMerge) {
|
||
|
|
this._mergeHeroBaseline = before;
|
||
|
|
this._flightOneStepMerge = true;
|
||
|
|
aiOp(
|
||
|
|
`[AiMerge] one-step start heroesBefore=${before}`
|
||
|
|
+ ` src=${this._fmtSrc(action.merge.source)} dst=${this._fmtDst(action.merge.target)}`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
this._flightOneStepMerge = false;
|
||
|
|
}
|
||
|
|
if (!this._dropOne(action.merge.source, action.merge.target, String(action.kind))) {
|
||
|
|
this._flightOneStepMerge = false;
|
||
|
|
if (isOneStepMerge) {
|
||
|
|
aiOp('[AiMerge] one-step drop fail');
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (this._flightBusy) return true;
|
||
|
|
if (action.kind === AiActionKind.Place
|
||
|
|
|| action.kind === AiActionKind.Swap
|
||
|
|
|| action.kind === AiActionKind.Upgrade
|
||
|
|
|| action.kind === AiActionKind.TextPolicy
|
||
|
|
|| action.kind === AiActionKind.Shovel) {
|
||
|
|
aiOp(
|
||
|
|
`执行 ${action.kind} ${this._fmtSrc(action.merge.source)}->${this._fmtDst(action.merge.target)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (isOneStepMerge) {
|
||
|
|
this._verifyMergeHeroGain('one_step');
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 先尝试跳飞,成功则落地后再 dropLogical;否则立即落子。
|
||
|
|
* @param source 源
|
||
|
|
* @param target 目标
|
||
|
|
* @param tag 动作标签
|
||
|
|
*/
|
||
|
|
private _dropOne(source: DragSource, target: DropTarget, tag: string): boolean {
|
||
|
|
const mapText = this._mapText;
|
||
|
|
if (mapText && mapText.tryPlayDropFlight(source, target, this._onDropFlightDone)) {
|
||
|
|
this._flightBusy = true;
|
||
|
|
this._flightSource = source;
|
||
|
|
this._flightTarget = target;
|
||
|
|
this._flightTag = tag;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return this._applyLogicalDrop(source, target, tag);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 跳飞落地:逻辑落子并对账 */
|
||
|
|
private readonly _onDropFlightDone = (ok: boolean): void => {
|
||
|
|
const source = this._flightSource;
|
||
|
|
const target = this._flightTarget;
|
||
|
|
const tag = this._flightTag;
|
||
|
|
const oneStep = this._flightOneStepMerge;
|
||
|
|
if (!this._running || !source || !target) {
|
||
|
|
this._mapText?.abortShovelDig();
|
||
|
|
this._clearFlightState();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!ok) {
|
||
|
|
aiWarn(`跳飞失败,回落瞬移 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
|
||
|
|
}
|
||
|
|
if (ok && tag === AiActionKind.Shovel && target.kind === 'map') {
|
||
|
|
this._bagHost?.bagSession?.deferOpenEffectForGrid(target.gridIndex);
|
||
|
|
if (!this._applyLogicalDrop(source, target, tag)) {
|
||
|
|
this._mapText?.abortShovelDig();
|
||
|
|
this._clearFlightState();
|
||
|
|
this._onBoardChanged?.();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this._mapText?.playShovelDig(target.gridIndex, this._onShovelDigDone)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._map?.getMapGridByIndex(target.gridIndex)?.repairShovelDigVisual();
|
||
|
|
this._clearFlightState();
|
||
|
|
this._afterBoardMutation(AiActionKind.Shovel);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._clearFlightState();
|
||
|
|
if (!this._applyLogicalDrop(source, target, tag)) {
|
||
|
|
this._onBoardChanged?.();
|
||
|
|
if (oneStep) aiOp('[AiMerge] one-step drop fail');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._afterBoardMutation(tag === 'pending' ? undefined : tag);
|
||
|
|
if (this._pendingMoves.length <= 0 && this._pendingIsAssemble) {
|
||
|
|
this._verifyMergeHeroGain('assemble_done');
|
||
|
|
} else if (oneStep) {
|
||
|
|
this._verifyMergeHeroGain('one_step');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 铲开挖演出结束:对账盘面 */
|
||
|
|
private readonly _onShovelDigDone = (_ok: boolean): void => {
|
||
|
|
this._clearFlightState();
|
||
|
|
if (!this._running) return;
|
||
|
|
this._afterBoardMutation(AiActionKind.Shovel);
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 清空跳飞暂存 */
|
||
|
|
private _clearFlightState(): void {
|
||
|
|
this._flightBusy = false;
|
||
|
|
this._flightSource = null;
|
||
|
|
this._flightTarget = null;
|
||
|
|
this._flightTag = '';
|
||
|
|
this._flightOneStepMerge = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 单次 dropLogical;涉及背包时由 Headless 打槽位日志。
|
||
|
|
* @param source 源
|
||
|
|
* @param target 目标
|
||
|
|
* @param tag 动作标签
|
||
|
|
*/
|
||
|
|
private _applyLogicalDrop(source: DragSource, target: DropTarget, tag: string): boolean {
|
||
|
|
const bag = this._bagHost?.bagSession;
|
||
|
|
if (!bag) return false;
|
||
|
|
const isShovel = tag === AiActionKind.Shovel && target.kind === 'map';
|
||
|
|
const targetBefore = target.kind === 'map' ? bag.merge.map.getCellKind(target.gridIndex) : null;
|
||
|
|
const targetPeerBefore = target.kind === 'map' ? this._peerCellKind(bag, target.gridIndex) : null;
|
||
|
|
const reason = bag.dropLogical(source, target);
|
||
|
|
if (reason) {
|
||
|
|
if (isShovel) {
|
||
|
|
aiOp(`铲子落子拒绝 reason=${reason} map[${target.gridIndex}]`);
|
||
|
|
} else {
|
||
|
|
aiOp(
|
||
|
|
`落子拒绝 reason=${reason} tag=${tag}`
|
||
|
|
+ ` ${this._fmtSrc(source)}->${this._fmtDst(target)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (source.kind === 'bag' || target.kind === 'bag') {
|
||
|
|
this._bagHost!.logBagMove(tag, source, target);
|
||
|
|
}
|
||
|
|
// 地图格:按 AI 落子后 kind 对齐本机 merge(铲 Dig→Put;蟠桃 Put→Dig / 保持 Dig)
|
||
|
|
if (source.kind === 'map') {
|
||
|
|
this._syncPeerCellKind(bag, source.gridIndex, bag.merge.map.getCellKind(source.gridIndex));
|
||
|
|
}
|
||
|
|
if (target.kind === 'map') {
|
||
|
|
const afterKind = bag.merge.map.getCellKind(target.gridIndex);
|
||
|
|
this._syncPeerCellKind(bag, target.gridIndex, afterKind);
|
||
|
|
const tile = bag.merge.map.getTile(target.gridIndex);
|
||
|
|
const keepDig = !!tile
|
||
|
|
&& WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(tile.weapon2Id);
|
||
|
|
if (keepDig) {
|
||
|
|
const peerAfter = this._peerCellKind(bag, target.gridIndex);
|
||
|
|
aiOp(
|
||
|
|
`[蟠桃同步] drop tag=${tag} map[${target.gridIndex}]`
|
||
|
|
+ ` before=${targetBefore} after=${afterKind}`
|
||
|
|
+ ` peerBefore=${targetPeerBefore} peerAfter=${peerAfter}`
|
||
|
|
+ ` w2=${tile?.weapon2Id ?? 0} lv=${tile?.level ?? 0}`,
|
||
|
|
);
|
||
|
|
if (afterKind !== MapCellKind.Dig || peerAfter !== MapCellKind.Dig) {
|
||
|
|
aiWarn(
|
||
|
|
`[蟠桃同步] drop 后应为 Dig map[${target.gridIndex}]`
|
||
|
|
+ ` after=${afterKind} peer=${peerAfter}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (isShovel) {
|
||
|
|
this._tryGrantTreasureShovelPeach();
|
||
|
|
}
|
||
|
|
this._warnPeachPeerMismatch(`afterDrop:${tag}`);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 读取本机 bag 格 kind。
|
||
|
|
* @param bag AI bag
|
||
|
|
* @param gridIndex 格
|
||
|
|
*/
|
||
|
|
private _peerCellKind(bag: BattleBagSession, gridIndex: number): MapCellKind | null {
|
||
|
|
const peer = this._battle?.bagSession;
|
||
|
|
if (!peer || peer === bag) return null;
|
||
|
|
return peer.merge.map.getCellKind(gridIndex);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 对齐本机 merge 格语义到 AI 落子后 kind。
|
||
|
|
* @param bag AI bag
|
||
|
|
* @param gridIndex 格
|
||
|
|
* @param kind 落子后 kind
|
||
|
|
*/
|
||
|
|
private _syncPeerCellKind(bag: BattleBagSession, gridIndex: number, kind: MapCellKind | null): void {
|
||
|
|
const peer = this._battle?.bagSession;
|
||
|
|
if (!peer || peer === bag) return;
|
||
|
|
bag.alignPeerCellKind(peer, gridIndex, kind);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 扫描 AI 盘面蟠桃:peer kind 必须为 Dig;不一致则 warn(便于复制日志)。
|
||
|
|
* @param tag 触发点
|
||
|
|
*/
|
||
|
|
private _warnPeachPeerMismatch(tag: string): void {
|
||
|
|
const bag = this._bagHost?.bagSession;
|
||
|
|
const peer = this._battle?.bagSession;
|
||
|
|
if (!bag || !peer || peer === bag) return;
|
||
|
|
WeaponComposeConfigProvider.instance.load();
|
||
|
|
const provider = WeaponComposeConfigProvider.instance;
|
||
|
|
const indices = bag.merge.map.gridIndices;
|
||
|
|
for (let i = 0; i < indices.length; i++) {
|
||
|
|
const gridIndex = indices[i];
|
||
|
|
const tile = bag.merge.map.getTile(gridIndex);
|
||
|
|
if (!tile || !provider.passiveSpawnKeepsDigCell(tile.weapon2Id)) continue;
|
||
|
|
const aiKind = bag.merge.map.getCellKind(gridIndex);
|
||
|
|
const peerKind = peer.merge.map.getCellKind(gridIndex);
|
||
|
|
if (aiKind === MapCellKind.Dig && peerKind === MapCellKind.Dig) continue;
|
||
|
|
aiWarn(
|
||
|
|
`[蟠桃同步] 不一致 ${tag} map[${gridIndex}]`
|
||
|
|
+ ` ai=${aiKind} peer=${peerKind} w2=${tile.weapon2Id} lv=${tile.level ?? 1}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 掘宝仙铲:AI 挖开 Dig 格时按 CarryStore 掷桃入 AI 槽 */
|
||
|
|
private _tryGrantTreasureShovelPeach(): void {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle) return;
|
||
|
|
const aiPid = AiHeadlessBagHost.AI_PLAYER_ID;
|
||
|
|
const passives = BattleBuffCarryStore.getPassives(aiPid);
|
||
|
|
if (!hasCarriedTreasureShovel(passives)) return;
|
||
|
|
const peach = rollCarriedTreasureShovelPeachCount(passives);
|
||
|
|
if (peach <= 0) return;
|
||
|
|
battle.addPlayerGold(aiPid, peach);
|
||
|
|
aiOp(`掘宝仙铲产桃 +${peach} -> player${aiPid}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 对账部署并刷新地图字表现。
|
||
|
|
* DeploySync / HeroActor 改节点延后到物理帧后,避免 Box2D UpdatePairs 崩。
|
||
|
|
* @param actionKind 动作种类;铲不改合成态,跳过 DeploySync
|
||
|
|
*/
|
||
|
|
private _afterBoardMutation(actionKind?: string): void {
|
||
|
|
if (actionKind !== AiActionKind.Shovel) {
|
||
|
|
this._scheduleDeferredDeploy();
|
||
|
|
}
|
||
|
|
this._onBoardChanged?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 排队延后 DeploySync(合并同帧多次落子) */
|
||
|
|
private _scheduleDeferredDeploy(): void {
|
||
|
|
if (this._deployPending) return;
|
||
|
|
this._deployPending = true;
|
||
|
|
director.once(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
|
||
|
|
}
|
||
|
|
|
||
|
|
private readonly _onDeferredDeploy = (): void => {
|
||
|
|
this._deployPending = false;
|
||
|
|
if (!this._running) return;
|
||
|
|
this._bagHost?.deploySync?.reconcileFromMerge('aiTick');
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 当前 AI 场上合成武将数 */
|
||
|
|
private _countComboHeroes(): number {
|
||
|
|
const bag = this._bagHost?.bagSession;
|
||
|
|
if (!bag) return 0;
|
||
|
|
return this._probe.countComboHeroes(bag);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 校验合成是否真正增加神将;始终打印。
|
||
|
|
* @param tag 阶段
|
||
|
|
*/
|
||
|
|
private _verifyMergeHeroGain(tag: string): void {
|
||
|
|
if (this._mergeHeroBaseline == null) return;
|
||
|
|
const before = this._mergeHeroBaseline;
|
||
|
|
const after = this._countComboHeroes();
|
||
|
|
this._mergeHeroBaseline = null;
|
||
|
|
this._pendingIsAssemble = false;
|
||
|
|
if (after > before) {
|
||
|
|
aiOp(`[AiMerge] ok ${tag} heroes ${before}->${after}`);
|
||
|
|
} else {
|
||
|
|
aiOp(`[AiMerge] FAIL ${tag} heroes still=${after} (before=${before}) 未成将`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 清空拼装/重摆队列。
|
||
|
|
* @param reason 原因
|
||
|
|
*/
|
||
|
|
private _clearMergeQueue(reason: string): void {
|
||
|
|
if (this._pendingMoves.length > 0 || this._mergeHeroBaseline != null) {
|
||
|
|
aiOp(
|
||
|
|
`[AiMerge] clearQueue reason=${reason}`
|
||
|
|
+ ` pending=${this._pendingMoves.length} baseline=${this._mergeHeroBaseline}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
this._pendingMoves.length = 0;
|
||
|
|
this._mergeHeroBaseline = null;
|
||
|
|
this._pendingIsAssemble = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 源描述 */
|
||
|
|
private _fmtSrc(source: DragSource): string {
|
||
|
|
return source.kind === 'bag' ? `bag[${source.slotIndex}]` : `map[${source.gridIndex}]`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 目标描述 */
|
||
|
|
private _fmtDst(target: DropTarget): string {
|
||
|
|
return target.kind === 'bag' ? `bag[${target.slotIndex}]` : `map[${target.gridIndex}]`;
|
||
|
|
}
|
||
|
|
}
|