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}]`; } }