import { BattlePhase } from 'db://assets/mx/module/battle'; import type { DailyShopParsed } from '../../../manager/DailyShopTypes'; import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore'; import { activeBuffUsesFunctionParCd, isBattleBuffRandomActive, readBattleBuffAttackSpeedConfig, readBattleBuffBindNetDuration, readBattleBuffConfuseHeroDuration, readBattleBuffFireTrapConfig, readBattleBuffRandomLevelConfig, readBattleBuffRecyclePeachCount, readBattleBuffUnitLevelDelta, resolveBattleGameSkillDragKind, BattleGameSkillDragKind, } from '../../../manager/battleBuff/BattleBuffActiveSkillUtil'; import { BattleBuffFunctionType } from '../../../manager/battleBuff/BattleBuffTypes'; import { parseBattleBuffRow } from '../../../manager/BattleBuffParse'; import { TableNames } from '../../../game/ConfigTableData'; import { getBattleGameGlobalAttackSpeedHost } from '../../combat/BattleGameGlobalAttackSpeedHost'; import { getBattleGameGlobalAttackRangeHost, } from '../../combat/BattleGameGlobalAttackRangeHost'; import { BattleGameRandomActiveSkillHost } from '../../../ui/BattleGame/BattleGameRandomActiveSkillHost'; import { BattleGameExtraBossSkill } from '../../../ui/BattleGame/BattleGameExtraBossSkill'; import { BattleGameBindNetTrapController } from '../../../ui/BattleGame/BattleGameBindNetTrapController'; import { BattleGameFireTrapController } from '../../../ui/BattleGame/BattleGameFireTrapController'; import type { ProjectBattleHandle } from '../../runtime/BattleRuntime'; import type { MapManager } from '../../map/legacy/MapManager'; import { buildEnemyMarchPathGridIndices, isEnemyMonsterWalkGrid, } from '../../map/MapPathQueries'; import { findEntityIdByGridIndex } from '../../runtime/DeployEntityQuery'; import { composeKey } from '../../runtime/ComposeUnitKey'; import { isHeroActorWeapon1 } from '../../heros/HeroActorRes'; import type { CharmTarget } from '../../mechanism/MechanismServices'; import { AbilitySetComp } from 'db://assets/mx/module/battle'; import { getTowerLoadoutService } from '../../combat/TowerLoadoutService'; import { applyBattleBuffLevelUpViaExpFill, applyBattleBuffRandomLevelWithExpFill, canApplyBattleBuffLevelUpViaExpFill, shouldBattleBuffLevelUpViaExpFill, } from '../../combat/BattleBuffUnitLevelUpApply'; import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider'; import { Weapon2TileType, MapCellKind, isElementGlyphType } from 'db://assets/mx/module/merge'; import { textDisplayName } from '../../config/BattleHeroTable'; import { AiActiveSkillTargetPicker, type AiActiveSkillTileTarget } from '../decision/AiActiveSkillTargetPicker'; import { aiLog, aiOp, aiWarn } from './AiLog'; import { AiHeadlessBagHost } from './AiHeadlessBagHost'; import { AI_VIEW_LOCAL_CAMP } from './AiCampArea'; import type { BattleBagSession } from '../../bag/BattleBagSession'; /** 橙字品质 */ const QUALITY_ORANGE = 4; /** AI 主动槽 UI 快照 */ export type AiActiveSkillUiSlot = { /** 携带 buff id */ buffId: number; /** 展示用解析(七十二变可能为 roll 结果) */ display: DailyShopParsed; /** CD 剩余秒 */ cdLeftSec: number; /** CD 总秒 */ cdTotalSec: number; }; /** AI 主动施放表现受体(与玩家拖放落点同类) */ export type AiActiveSkillCastReceptor = | { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } | { kind: 'spawn'; spawnPort: number } | { kind: 'confuse'; target: CharmTarget } | { kind: 'steal'; gridIndex: number; placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } }; /** AI 主动施放表现载荷 */ export type AiActiveSkillCastFxInfo = { /** 主动槽下标(对应 otherskill1List) */ slotIndex: number; /** 施放 buff id(七十二变后为 roll 结果) */ buffId: number; /** 拖拽类型 */ kind: BattleGameSkillDragKind; /** 落点受体 */ receptor: AiActiveSkillCastReceptor; }; /** AI 主动施放表现回调 */ export type AiActiveSkillCastFxHandler = (info: AiActiveSkillCastFxInfo) => void; /** AI 妙手神偷表现:锁定玩家字 → 抓取 → 带字飞入;成功启动返回 true */ export type AiStealGlyphFxHandler = ( playerGridIndex: number, placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number }, onExtracted: (tile: import('db://assets/mx/module/merge').MapTileRecord) => void, ) => boolean; /** AI 幻魂镜表现:对玩家神将播施法与循环层;成功启动返回 true */ export type AiConfuseHeroFxHandler = (target: CharmTarget, durationSec: number) => boolean; /** 单槽主动 CD 状态 */ type AiActiveSkillSlot = { /** 携带解析(七十二变本体) */ parsed: DailyShopParsed; /** 当前施放用解析(可能是七十二变 roll 出的) */ castParsed: DailyShopParsed; /** CD 总秒 */ cdTotalSec: number; /** CD 剩余 */ cdLeftSec: number; }; /** * AI 主动道具:CD 到就用;默认目标合成武将→普通字、等级高优先;文昌笔见 pickRewrite(§8.1)。 */ export class AiActiveSkillHost { private readonly _picker = new AiActiveSkillTargetPicker(); private readonly _bindNet = new BattleGameBindNetTrapController(); private readonly _fireTrap = new BattleGameFireTrapController(); private _bagHost: AiHeadlessBagHost | null = null; private _battle: ProjectBattleHandle | null = null; private _map: MapManager | null = null; private _onBoardChanged: (() => void) | null = null; private _onCastFx: AiActiveSkillCastFxHandler | null = null; private _onStealFx: AiStealGlyphFxHandler | null = null; private _onConfuseFx: AiConfuseHeroFxHandler | null = null; private readonly _slots: AiActiveSkillSlot[] = []; /** 进攻 AI 的行军路格缓存 */ private _aiMarchPath: ReturnType | null = null; /** 最近一次施放失败原因(供统一等待日志) */ private _castFailReason = ''; /** 各槽上次等待日志时间戳 */ private readonly _waitLogAtMs: number[] = []; /** 各槽上次等待日志原因 */ private readonly _waitLogReason: string[] = []; /** * 绑定并重建主动槽。 * @param bagHost 无头背包 * @param battle 战斗 * @param map 地图 * @param onBoardChanged 盘面回调 */ bind(bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, map: MapManager, onBoardChanged: () => void): void { this.dispose(); this._bagHost = bagHost; this._battle = battle; this._map = map; this._onBoardChanged = onBoardChanged; this._bindNet.bind(battle, map, AiHeadlessBagHost.AI_PLAYER_ID); this._fireTrap.bind(battle, map, AiHeadlessBagHost.AI_PLAYER_ID); this._aiMarchPath = buildEnemyMarchPathGridIndices(map, battle.session, AiHeadlessBagHost.AI_PLAYER_ID); this._rebuildSlots(); } /** * 设置施放表现回调(UI 复制图标飞向受体)。 * @param handler 回调;null 清除 */ setCastFxHandler(handler: AiActiveSkillCastFxHandler | null): void { this._onCastFx = handler; } /** * 注册 AI 妙手神偷神偷手表现(与玩家同套抓取/带字飞)。 * @param handler 回调;null 清除 */ setStealFxHandler(handler: AiStealGlyphFxHandler | null): void { this._onStealFx = handler; } /** * 注册 AI 幻魂镜表现(与玩家同套施法/上下层循环)。 * @param handler 回调;null 清除 */ setConfuseFxHandler(handler: AiConfuseHeroFxHandler | null): void { this._onConfuseFx = handler; } /** 解除绑定(不清除 UI 注册的施放表现回调;由 setXxxHandler(null) 显式注销) */ dispose(): void { this._bindNet.unbind(); this._fireTrap.unbind(); this._slots.length = 0; this._aiMarchPath = null; this._bagHost = null; this._battle = null; this._map = null; this._onBoardChanged = null; this._castFailReason = ''; this._waitLogAtMs.length = 0; this._waitLogReason.length = 0; } /** * 帧更新:CD 满尝试施放;并 tick AI 陷阱。 * @param dtSec 帧间隔 */ tick(dtSec: number): void { if (!this._bagHost || !this._battle) return; this._bindNet.tick(dtSec); this._fireTrap.tick(dtSec); if (this._slots.length <= 0) return; const dt = Math.max(0, Number(dtSec) || 0); for (let i = 0; i < this._slots.length; i++) { this._tickSlot(this._slots[i], i, dt); } } /** * 主动槽 UI 快照(含 CD 与当前展示 buff)。 * @returns 槽列表;未绑定为空数组 */ snapshotUiSlots(): AiActiveSkillUiSlot[] { const out: AiActiveSkillUiSlot[] = []; for (let i = 0; i < this._slots.length; i++) { const slot = this._slots[i]; out.push({ buffId: slot.parsed.id, display: slot.castParsed, cdLeftSec: slot.cdLeftSec, cdTotalSec: slot.cdTotalSec, }); } return out; } /** 从 CarryStore 重建主动槽 */ private _rebuildSlots(): void { this._slots.length = 0; const actives = BattleBuffCarryStore.getActives(AiHeadlessBagHost.AI_PLAYER_ID); for (let i = 0; i < actives.length; i++) { const parsed = actives[i]; const row = parsed.row as ITableBattlebuff; const ft = Math.floor(Number(row.functiontype) || 0); if (!activeBuffUsesFunctionParCd(ft) && !isBattleBuffRandomActive(row)) { aiWarn(`AI 主动未识别 CD id=${parsed.id} ft=${ft} ${parsed.name}`); continue; } const cdSec = Math.max(0, Math.floor(Number(parsed.cd) || 0)); this._slots.push({ parsed, castParsed: parsed, cdTotalSec: cdSec, cdLeftSec: cdSec, }); aiOp(`AI 主动槽#${this._slots.length - 1} id=${parsed.id} ${parsed.name} ft=${ft} cd=${cdSec}s`); } } /** * 刷新拍:升级/放字已穷尽后,背包仍有可回收多余字则乾坤袋收 1 格。 * 场上字永不回收;无多余字 / CD 未好返回 false。 */ tryRecycleSurplusBag(): boolean { if (!this._bagHost?.bagSession || !this._battle) return false; for (let i = 0; i < this._slots.length; i++) { const slot = this._slots[i]; if (slot.cdLeftSec > 0) continue; if (!this._isRecycleBuff(slot.parsed)) continue; slot.castParsed = slot.parsed; if (!this._castRecycle(slot.parsed, i)) continue; this._restartCd(slot); aiOp( `AI 主动成功 「${slot.parsed.name || '乾坤袋'}」 id=${slot.parsed.id}` + ` kind=乾坤袋 slot#${i} →进CD ${slot.cdTotalSec}s(刷新拍背包多余字)`, ); this._clearWaitLog(i); return true; } return false; } /** * 单槽倒计时。 * @param slot 槽 * @param slotIndex 下标 * @param dt 间隔 */ private _tickSlot(slot: AiActiveSkillSlot, slotIndex: number, dt: number): void { if (slot.cdLeftSec > 0) { slot.cdLeftSec = Math.max(0, slot.cdLeftSec - dt); return; } // 乾坤袋不在帧 tick 自动放,仅刷新拍由 tryRecycleSurplusBag 触发 if (this._isRecycleBuff(slot.parsed)) return; if (this._tryCast(slot, slotIndex)) { this._restartCd(slot); } } /** * 是否为乾坤袋主动(functiontype=5 / RecycleToPeach)。 * @param parsed 解析 */ private _isRecycleBuff(parsed: DailyShopParsed): boolean { const row = parsed.row as ITableBattlebuff; const kind = resolveBattleGameSkillDragKind(row); return kind === BattleGameSkillDragKind.RecycleToPeach; } /** * 尝试施放;无目标返回 false(CD 保持 0,下帧再试)。 * @param slot 槽 * @param slotIndex 下标 */ private _tryCast(slot: AiActiveSkillSlot, slotIndex: number): boolean { this._castFailReason = ''; const baseRow = slot.parsed.row as ITableBattlebuff; const baseName = slot.parsed.name || `id=${slot.parsed.id}`; if (isBattleBuffRandomActive(baseRow)) { if (slot.castParsed === slot.parsed || isBattleBuffRandomActive(slot.castParsed.row as ITableBattlebuff)) { const rolled = this._rollRandomActive(slotIndex); if (!rolled) { this._logCastWait(slotIndex, slot.parsed.id, baseName, '七十二变', 'roll池为空或无rng'); return false; } slot.castParsed = rolled; aiOp(`AI 主动 七十二变 roll→「${rolled.name}」id=${rolled.id} slot#${slotIndex}`); } } else { slot.castParsed = slot.parsed; } const cast = slot.castParsed; const castName = cast.name || `id=${cast.id}`; const row = cast.row as ITableBattlebuff; const kind = resolveBattleGameSkillDragKind(row); if (kind == null) { aiWarn(`AI 主动无拖拽类型 「${castName}」 id=${cast.id} ft=${row.functiontype} slot#${slotIndex}`); slot.castParsed = slot.parsed; return false; } const kindTag = this._kindTag(kind); // 无目标时每帧会进这里;尝试日志仅 verbose,避免刷屏 aiLog(`AI 主动尝试 「${castName}」 id=${cast.id} kind=${kindTag} slot#${slotIndex}` + (isBattleBuffRandomActive(baseRow) ? ` (自七十二变)` : '')); let ok = false; switch (kind) { case BattleGameSkillDragKind.UnitLevelUp: ok = this._castUnitLevelUp(cast, slotIndex); break; case BattleGameSkillDragKind.RandomLevel: ok = this._castRandomLevel(cast, slotIndex); break; case BattleGameSkillDragKind.RecycleToPeach: // 七十二变 roll 到乾坤袋:自动路径禁用,回退 roll,留给刷新拍(不打等待日志) if (isBattleBuffRandomActive(baseRow)) { slot.castParsed = slot.parsed; } aiLog(`AI 主动跳过乾坤袋(仅刷新拍回收背包多余字) slot#${slotIndex}`); return false; case BattleGameSkillDragKind.RewriteGlyph: ok = this._castRewrite(cast, slotIndex); break; case BattleGameSkillDragKind.AttackSpeedGlobal: ok = this._castAttackSpeed(cast, slotIndex); break; case BattleGameSkillDragKind.AttackRangeGlobal: ok = this._castAttackRange(cast, slotIndex); break; case BattleGameSkillDragKind.ExtraBoss: ok = this._castExtraBoss(cast, slotIndex); break; case BattleGameSkillDragKind.BindNet: ok = this._castBindNet(cast, slotIndex); break; case BattleGameSkillDragKind.FireTrap: ok = this._castFireTrap(cast, slotIndex); break; case BattleGameSkillDragKind.ConfuseHero: ok = this._castConfuseHero(cast, slotIndex); break; case BattleGameSkillDragKind.StealGlyph: ok = this._castStealGlyph(cast, slotIndex); break; default: aiWarn(`AI 主动未知 kind=${kind} 「${castName}」 id=${cast.id} slot#${slotIndex}`); slot.castParsed = slot.parsed; ok = false; break; } if (ok) { aiOp(`AI 主动成功 「${castName}」 id=${cast.id} kind=${kindTag} slot#${slotIndex} →进CD ${slot.cdTotalSec}s`); if (isBattleBuffRandomActive(baseRow)) { slot.castParsed = slot.parsed; } this._clearWaitLog(slotIndex); return true; } this._logCastWait(slotIndex, cast.id, castName, kindTag, this._castFailReason || '条件不足'); return false; } /** * 记录施放失败原因并返回 false。 * @param reason 原因 */ private _failCast(reason: string): false { this._castFailReason = reason; return false; } /** * 等待施放日志(同原因只打一次,成功或原因变化后再打)。 * @param slotIndex 槽 * @param buffId id * @param name 名 * @param kindTag 类型标签 * @param reason 原因 */ private _logCastWait(slotIndex: number, buffId: number, name: string, kindTag: string, reason: string): void { const prevReason = this._waitLogReason[slotIndex] ?? ''; if (prevReason === reason) return; this._waitLogAtMs[slotIndex] = Date.now(); this._waitLogReason[slotIndex] = reason; // 无合法目标属常态等待,不打操作链日志 if (reason === '无合法目标' || reason === '无可升级目标') { aiLog(`AI 主动等待 「${name}」 id=${buffId} kind=${kindTag} slot#${slotIndex} 原因=${reason}`); return; } aiOp(`AI 主动等待 「${name}」 id=${buffId} kind=${kindTag} slot#${slotIndex} 原因=${reason}`); } /** * 成功施放后清除等待节流。 * @param slotIndex 槽 */ private _clearWaitLog(slotIndex: number): void { this._waitLogAtMs[slotIndex] = 0; this._waitLogReason[slotIndex] = ''; } /** * 拖拽类型短标签(日志用)。 * @param kind 类型 */ private _kindTag(kind: BattleGameSkillDragKind): string { switch (kind) { case BattleGameSkillDragKind.UnitLevelUp: return '封神榜'; case BattleGameSkillDragKind.RandomLevel: return '授仙箓'; case BattleGameSkillDragKind.RecycleToPeach: return '乾坤袋'; case BattleGameSkillDragKind.RewriteGlyph: return '文昌笔'; case BattleGameSkillDragKind.AttackSpeedGlobal: return '攻速符'; case BattleGameSkillDragKind.AttackRangeGlobal: return '千里眼'; case BattleGameSkillDragKind.ExtraBoss: return '万神幡'; case BattleGameSkillDragKind.BindNet: return '缚仙网'; case BattleGameSkillDragKind.FireTrap: return '三昧真火'; case BattleGameSkillDragKind.ConfuseHero: return '幻魂镜'; case BattleGameSkillDragKind.StealGlyph: return '妙手神偷'; default: return `kind=${kind}`; } } /** * 七十二变:随机一个可施放主动(排除全部主动槽已携带 id,含自身)。 * @param slotIndex 当前槽 */ private _rollRandomActive(slotIndex: number): DailyShopParsed | null { const exclude: number[] = []; for (let i = 0; i < this._slots.length; i++) { const id = Math.floor(Number(this._slots[i].parsed.id) || 0); if (id > 0) exclude.push(id); } const pick = BattleGameRandomActiveSkillHost.pickRollBuffIdFromPool( exclude, this._battle?.session?.rng ?? null, ); if (pick <= 0) { aiWarn('AI 七十二变无可用池'); return null; } const row = gg.data.table.getTableData(TableNames.Battlebuff, pick); return parseBattleBuffRow(row); } /** 封神榜 */ private _castUnitLevelUp(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; const battle = this._battle; if (!bag) return this._failCast('无AI背包'); const delta = readBattleBuffUnitLevelDelta(parsed.row as ITableBattlebuff); if (delta <= 0) { aiWarn(`AI 封神榜 levelDelta 无效 id=${parsed.id} delta=${delta}`); return this._failCast(`配置无效 delta=${delta}`); } const target = this._picker.pick(bag, (t) => this._canLevelUp(bag, t, delta)); if (!target) return this._failCast('无可升级目标'); let ok = false; if (target.kind === 'map' && battle && shouldBattleBuffLevelUpViaExpFill(battle, bag, target)) { ok = applyBattleBuffLevelUpViaExpFill(battle, bag, target.gridIndex); if (!ok) return this._failCast(`补经验失败 ${this._fmtTarget(target)}`); aiOp(`AI 封神榜 补升级经验 ${this._fmtTarget(target)} slot#${slotIndex}`); } else { const reason = target.kind === 'map' ? bag.merge.applyBattleBuffUnitLevelUp(target.gridIndex, delta) : bag.merge.applyBattleBuffUnitLevelUpBag(target.slotIndex, delta); if (reason) return this._failCast(`升级失败 reason=${reason} ${this._fmtTarget(target)}`); aiOp(`AI 封神榜 +${delta} ${this._fmtTarget(target)} slot#${slotIndex}`); ok = true; } this._onBoardChanged?.(); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.UnitLevelUp, this._tileReceptor(target)); return ok; } /** 授仙箓 */ private _castRandomLevel(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; const battle = this._battle; if (!bag || !battle) return this._failCast('无背包或战斗'); const cfg = readBattleBuffRandomLevelConfig(parsed.row as ITableBattlebuff); if (!cfg || cfg.levelUpProbsWan.length < 4) { aiWarn(`AI 授仙箓配置无效 id=${parsed.id}`); return this._failCast('配置无效'); } const target = this._picker.pick(bag, (t) => { if (t.kind === 'map') return bag.merge.canApplyBattleBuffRandomLevel(t.gridIndex) == null; return bag.merge.canApplyBattleBuffRandomLevelBag(t.slotIndex) == null; }); if (!target) return this._failCast('无合法目标'); const rollWan = battle.session.rng.int(0, 9999); const result = applyBattleBuffRandomLevelWithExpFill( battle, bag, target, cfg.levelUpProbsWan, rollWan, ); if (result == null) return this._failCast(`施放失败 ${this._fmtTarget(target)}`); aiOp(`AI 授仙箓 Δ=${result} ${this._fmtTarget(target)} slot#${slotIndex}`); this._onBoardChanged?.(); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RandomLevel, this._tileReceptor(target)); return true; } /** * 乾坤袋:只回收背包多余字(低等级优先);场上永不回收。 * @param parsed buff * @param slotIndex 主动槽 */ private _castRecycle(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; const battle = this._battle; if (!bag || !battle) return this._failCast('无背包或战斗'); const peach = readBattleBuffRecyclePeachCount(parsed.row as ITableBattlebuff); const bagSlot = this._pickSurplusBagRecycleSlot(bag); if (bagSlot < 0) return this._failCast('背包无多余可回收字'); const target: AiActiveSkillTileTarget = { kind: 'bag', slotIndex: bagSlot }; const reason = bag.merge.applyBattleBuffRecycleBag(bagSlot); if (reason) return this._failCast(`回收失败 reason=${reason} ${this._fmtTarget(target)}`); if (peach > 0) { battle.addPlayerGold(AiHeadlessBagHost.AI_PLAYER_ID, peach); } aiOp(`AI 乾坤袋 桃+${peach} bag[${bagSlot}] slot#${slotIndex}`); this._onBoardChanged?.(); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RecycleToPeach, this._tileReceptor(target)); return true; } /** * 选背包可回收多余字:低等级 → 低品质 → 小槽下标;铲/格不可收。 * @param bag AI 背包 */ private _pickSurplusBagRecycleSlot(bag: BattleBagSession): number { WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; const slots = bag.getBagSlots(); let best = -1; let bestLevel = 1e9; let bestQuality = 1e9; for (let i = 0; i < slots.length; i++) { const s = slots[i]; if (!s) continue; if (bag.merge.canApplyBattleBuffRecycleBag(i) != null) continue; const w2Type = Number(provider.getWeapon2Type(s.weapon2Id)) as Weapon2TileType; if (!isElementGlyphType(w2Type)) continue; const level = Math.max(1, Math.floor(Number(s.level) || 1)); const row = provider.getWeapon2Config(s.weapon2Id); const quality = Math.max(0, Math.floor(Number(row?.quality) || 0)); if ( level < bestLevel || (level === bestLevel && quality < bestQuality) || (level === bestLevel && quality === bestQuality && (best < 0 || i < best)) ) { best = i; bestLevel = level; bestQuality = quality; } } return best; } /** 文昌笔:紫字赌橙优先,其余低等级优先(§8.1) */ private _castRewrite(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; if (!bag) return this._failCast('无AI背包'); const target = this._picker.pickRewrite(bag, (t) => { if (t.kind === 'map') return bag.merge.canApplyBattleBuffRewriteGlyphMap(t.gridIndex) == null; return bag.merge.canApplyBattleBuffRewriteGlyphBag(t.slotIndex) == null; }); if (!target) return this._failCast('无改写目标'); const excludeId = this._resolveExcludeWeapon2(bag, target); if (excludeId == null) return this._failCast(`目标无weapon2 ${this._fmtTarget(target)}`); const rolled = bag.rollRewriteGlyph(excludeId); if (!rolled) return this._failCast('改写字池抽空'); const reason = target.kind === 'map' ? bag.merge.applyBattleBuffRewriteGlyphMap(target.gridIndex, rolled.weapon2Id) : bag.merge.applyBattleBuffRewriteGlyphBag(target.slotIndex, rolled.weapon2Id); if (reason) return this._failCast(`改写失败 reason=${reason} ${this._fmtTarget(target)}`); aiOp(`AI 文昌笔 ->w2=${rolled.weapon2Id} ${this._fmtTarget(target)} slot#${slotIndex}`); this._onBoardChanged?.(); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RewriteGlyph, this._tileReceptor(target)); return true; } /** * 攻速符:仅对已上场攻击单位施放;无目标则 CD 保持 0 等待。 * @param parsed buff * @param slotIndex 槽 */ private _castAttackSpeed(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; const battle = this._battle; const map = this._map; if (!bag || !battle || !map) return this._failCast('缺背包/战斗/地图'); const cfg = readBattleBuffAttackSpeedConfig(parsed.row as ITableBattlebuff); if (!cfg || cfg.bonusWan <= 0) { aiWarn(`AI 攻速符配置无效 id=${parsed.id}`); return this._failCast('配置无效'); } const buffId = parsed.id; const player = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID) ?? null; const aspd = getBattleGameGlobalAttackSpeedHost(); const target = this._picker.pick(bag, (t) => { if (t.kind !== 'map') return false; if (aspd.isTargetUsed(buffId, t, battle.session, player, map)) return false; return aspd.resolveMapTargetEntityId(battle.session, player, map, t.gridIndex) != null; }); if (!target || target.kind !== 'map') return this._failCast('无已上场可挂攻速单位'); const ok = aspd.tryActivate(battle.session, player, map, buffId, slotIndex, target, cfg.bonusWan); if (!ok) return this._failCast(`tryActivate失败 ${this._fmtTarget(target)}`); aiOp(`AI 攻速符 +${cfg.bonusWan} ${this._fmtTarget(target)} slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.AttackSpeedGlobal, this._tileReceptor(target)); return true; } /** * 千里眼:仅对已上场远程单位施放;无目标则返回 false,CD 保持 0 等待。 * @param parsed buff * @param slotIndex 槽 */ private _castAttackRange(parsed: DailyShopParsed, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; const battle = this._battle; const map = this._map; if (!bag || !battle || !map) return this._failCast('缺背包/战斗/地图'); const buffId = parsed.id; const player = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID) ?? null; const rangeHost = getBattleGameGlobalAttackRangeHost(); const target = this._picker.pick(bag, (t) => { if (t.kind !== 'map') return false; if (rangeHost.isTargetUsed(buffId, t, battle.session, player, map)) return false; return rangeHost.resolveMapTargetEntityId(battle.session, player, map, t.gridIndex) != null; }); if (!target || target.kind !== 'map') return this._failCast('无已上场远程单位'); const ok = rangeHost.tryActivate( battle.session, player, map, buffId, slotIndex, { kind: 'map', gridIndex: target.gridIndex }, ); if (!ok) return this._failCast(`tryActivate失败 ${this._fmtTarget(target)}`); aiOp(`AI 千里眼 ${this._fmtTarget(target)} slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.AttackRangeGlobal, this._tileReceptor(target)); return true; } /** * 万神幡:从 AI 己方出怪口额外召唤 Boss 进攻玩家。 * @param parsed buff * @param slotIndex 槽 */ private _castExtraBoss(parsed: DailyShopParsed, slotIndex: number): boolean { const battle = this._battle; if (!battle) return this._failCast('无战斗'); if (battle.session.phase !== BattlePhase.COMBAT) return this._failCast('非战斗阶段'); const aiPlayerId = AiHeadlessBagHost.AI_PLAYER_ID; const ok = BattleGameExtraBossSkill.trySummon(battle, aiPlayerId); if (!ok) return this._failCast('召唤Boss失败(无配置/spawner)'); const spawnPort = aiPlayerId === 0 ? 2 : 1; aiOp(`AI 万神幡 AI#${aiPlayerId} 出怪口 port=${spawnPort} slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ExtraBoss, { kind: 'spawn', spawnPort }); return true; } /** 缚仙网:布置在进攻 AI 的怪物路径上;无格则等待 */ private _castBindNet(parsed: DailyShopParsed, slotIndex: number): boolean { const duration = readBattleBuffBindNetDuration(parsed.row as ITableBattlebuff); if (duration <= 0) { aiWarn(`AI 缚仙网 duration 无效 id=${parsed.id}`); return this._failCast('duration无效'); } const gridIndex = this._pickTrapGrid(); if (gridIndex == null) return this._failCast('无可行军路空格'); if (!this._bindNet.tryPlace(gridIndex, duration)) return this._failCast(`布置失败 map[${gridIndex}]`); aiOp(`AI 缚仙网 map[${gridIndex}] dur=${duration}s slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.BindNet, { kind: 'map', gridIndex }); return true; } /** 三昧真火:布置在进攻 AI 的怪物路径上;无格则等待 */ private _castFireTrap(parsed: DailyShopParsed, slotIndex: number): boolean { const cfg = readBattleBuffFireTrapConfig(parsed.row as ITableBattlebuff); if (!cfg || cfg.maxDamage <= 0 || cfg.durationSec <= 0) { aiWarn(`AI 三昧真火配置无效 id=${parsed.id}`); return this._failCast('配置无效'); } const gridIndex = this._pickTrapGrid(); if (gridIndex == null) return this._failCast('无可行军路空格'); if (!this._fireTrap.tryPlace(gridIndex, cfg.maxDamage, cfg.durationSec)) { return this._failCast(`布置失败 map[${gridIndex}]`); } aiOp(`AI 三昧真火 map[${gridIndex}] dmg=${cfg.maxDamage} dur=${cfg.durationSec}s slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.FireTrap, { kind: 'map', gridIndex }); return true; } /** * 幻魂镜:迷惑敌方(玩家)1 名多格神将;无目标则等待。 * @param parsed buff * @param slotIndex 槽 */ private _castConfuseHero(parsed: DailyShopParsed, slotIndex: number): boolean { const battle = this._battle; const map = this._map; if (!battle || !map) return this._failCast('缺战斗或地图'); if (battle.session.phase !== BattlePhase.COMBAT || battle.session.ended) { return this._failCast('非战斗阶段或已结束'); } const duration = readBattleBuffConfuseHeroDuration(parsed.row as ITableBattlebuff); if (duration <= 0) { aiWarn(`AI 幻魂镜 duration 无效 id=${parsed.id}`); return this._failCast('duration无效'); } const target = this._pickConfuseTarget(); if (!target) return this._failCast('无敌方多格神将'); if (this._onConfuseFx) { const started = this._onConfuseFx(target, duration); if (!started) return this._failCast('幻魂镜表现启动失败'); aiOp(`AI 幻魂镜 compose=${target.composeKey} dur=${duration}s slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ConfuseHero, { kind: 'confuse', target }); return true; } aiWarn('AI 幻魂镜无表现回调,瞬时结算'); battle.charmController.applyCharm(target, duration, null); aiOp(`AI 幻魂镜 compose=${target.composeKey} dur=${duration}s slot#${slotIndex}`); this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ConfuseHero, { kind: 'confuse', target }); return true; } /** * 妙手神偷:AI 有空位时偷玩家场上未合成武将散字或普通单字(橙可成将优先,其次紫)。 * 有表现回调时:先锁定玩家字播神偷手,抓取后再提取/飞入;无回调时瞬时结算。 * @param parsed buff * @param slotIndex 槽 */ private _castStealGlyph(_parsed: DailyShopParsed, slotIndex: number): boolean { const aiBag = this._bagHost?.bagSession; const battle = this._battle; const map = this._map; if (!aiBag || !battle || !map) return this._failCast('缺背包/战斗/地图'); if (aiBag.ownerPlayerId !== AiHeadlessBagHost.AI_PLAYER_ID) { aiWarn(`AI 妙手神偷 bagSession.ownerPlayerId=${aiBag.ownerPlayerId} 非 AI,中止`); return this._failCast(`ownerPlayerId=${aiBag.ownerPlayerId}非AI`); } if (battle.session.phase !== BattlePhase.COMBAT || battle.session.ended) { return this._failCast('非战斗阶段或已结束'); } const playerBag = battle.bagSession; if (!playerBag) return this._failCast('无玩家背包'); if (playerBag === aiBag) { aiWarn('AI 妙手神偷 aiBag===playerBag,中止以免写入玩家盘面'); return this._failCast('aiBag===playerBag'); } if (!aiBag.canReceiveStolenTile()) return this._failCast('AI无空Put/背包槽'); const enemyGrid = this._pickStealTarget(aiBag, playerBag); if (enemyGrid == null) return this._failCast('玩家放置区无可偷字块'); let placement = aiBag.resolveStealReceivePlacement(); if (!placement) return this._failCast('无接收落点'); placement = this._ensureAiStealPlacement(aiBag, map, placement); if (!placement) return this._failCast('落点非AI阵营且背包已满'); if (playerBag.merge.canApplyStealGlyphMap(enemyGrid) != null) { return this._failCast(`目标不可偷 map[${enemyGrid}]`); } const dest = placement.kind === 'map' ? `map[${placement.gridIndex}]` : `bag[${placement.slotIndex}]`; if (this._onStealFx) { const started = this._onStealFx(enemyGrid, placement, (tile) => { this._logStealExtracted(aiBag, placement, enemyGrid, tile, slotIndex, false); }); if (!started) return this._failCast('神偷手表现启动失败'); aiOp( `AI 妙手神偷开演 fromPlayerMap[${enemyGrid}] -> AI ${dest}` + ` owner=${aiBag.ownerPlayerId} slot#${slotIndex}`, ); return true; } aiWarn('AI 妙手神偷无神偷手回调,瞬时结算'); return this._castStealGlyphInstant(aiBag, playerBag, battle, enemyGrid, placement, slotIndex); } /** * 无表现时瞬时提取并写入(测试/无 UI)。 * @param aiBag AI bag * @param playerBag 玩家 bag * @param battle 战斗 * @param enemyGrid 玩家源格 * @param placement 落点 * @param slotIndex 技能槽 */ private _castStealGlyphInstant( aiBag: NonNullable, playerBag: NonNullable, battle: ProjectBattleHandle, enemyGrid: number, placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number }, slotIndex: number, ): boolean { const extracted = playerBag.merge.extractMapTileForSteal(enemyGrid); if ('reason' in extracted) return this._failCast(`提取失败 reason=${extracted.reason} map[${enemyGrid}]`); let granted = false; if (placement.kind === 'map') { granted = aiBag.merge.grantStolenTileOnPutGrid(placement.gridIndex, extracted.tile) == null; if (granted) aiBag.syncMapCellVisualsFromLogic(); } else { granted = aiBag.merge.grantStolenTileInBagSlot(placement.slotIndex, extracted.tile) == null; } if (!granted) { const rollback = playerBag.merge.grantStolenTileOnPutGrid(enemyGrid, extracted.tile); if (rollback) { aiWarn(`AI 妙手神偷写入失败且退回失败 map[${enemyGrid}] reason=${rollback}`); } else { aiWarn(`AI 妙手神偷写入失败已退回 map[${enemyGrid}]`); } battle.deploySync?.reconcileFromMerge('aiStealGlyphRollback'); return this._failCast(`写入失败已尝试退回 map[${enemyGrid}]`); } battle.deploySync?.reconcileFromMerge('aiStealGlyph'); this._bagHost?.deploySync?.reconcileFromMerge('aiStealGlyph'); this._onBoardChanged?.(); this._logStealExtracted(aiBag, placement, enemyGrid, extracted.tile, slotIndex, true); return true; } /** * 妙手神偷提取成功日志。 * @param aiBag AI bag * @param placement 落点 * @param enemyGrid 玩家源格 * @param tile 字块 * @param slotIndex 技能槽 * @param verifyBag 是否核对背包写入(仅瞬时结算,表现路径抓取时尚未 grant) */ private _logStealExtracted( aiBag: NonNullable, placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number }, enemyGrid: number, tile: import('db://assets/mx/module/merge').MapTileRecord, slotIndex: number, verifyBag: boolean, ): void { const w2 = tile.weapon2Id; const lv = Math.max(1, Math.floor(Number(tile.level) || 1)); const q = this._tileQuality(w2); WeaponComposeConfigProvider.instance.load(); const glyph = WeaponComposeConfigProvider.instance.getWeapon2Config(w2); const name = glyph ? (textDisplayName(glyph).trim() || `id=${w2}`) : `id=${w2}`; gg.ui.showToast(`对方使用妙手神偷,带走了你的${name}字!`); const dest = placement.kind === 'map' ? `map[${placement.gridIndex}]` : `bag[${placement.slotIndex}]`; aiOp( `AI 妙手神偷 偷走「${name}」lv${lv} q=${q} id=${w2}` + ` fromPlayerMap[${enemyGrid}] -> AI ${dest} owner=${aiBag.ownerPlayerId} slot#${slotIndex}`, ); if (verifyBag && placement.kind === 'bag') { this._logAiBagAfterSteal(aiBag, placement.slotIndex, w2); } } /** * 地图落点必须在 AI CampArea;否则改 AI 背包。 * @param aiBag AI bag * @param map 地图 * @param placement 初选落点 */ private _ensureAiStealPlacement( aiBag: NonNullable, map: MapManager, placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number }, ): { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } | null { if (placement.kind === 'bag') return placement; const aiCamp = AiHeadlessBagHost.AI_PLAYER_ID + 1; const mg = map.getMapGridByIndex(placement.gridIndex); if (mg && (mg.CampAreaID | 0) === aiCamp) return placement; aiWarn( `AI 妙手神偷地图落点非己方阵营 map[${placement.gridIndex}] camp=${mg?.CampAreaID ?? 'null'} expect=${aiCamp},改走 AI 背包`, ); const slot = aiBag.findEmptyBagSlot(); if (slot == null) return null; return { kind: 'bag', slotIndex: slot }; } /** * 偷入背包后打印 AI 槽,并检查玩家背包是否被误写。 * @param aiBag AI bag * @param slotIndex 写入槽 * @param weapon2Id 字 id */ private _logAiBagAfterSteal(aiBag: NonNullable, slotIndex: number, weapon2Id: number): void { const slots = aiBag.getBagSlots(); const parts: string[] = []; for (let i = 0; i < slots.length; i++) { const s = slots[i]; if (!s) { parts.push(`[${i}]=空`); continue; } const row = WeaponComposeConfigProvider.instance.getWeapon2Config(s.weapon2Id); const n = row ? (textDisplayName(row).trim() || `id=${s.weapon2Id}`) : `id=${s.weapon2Id}`; parts.push(`[${i}]=${n}`); } aiOp(`AI 妙手神偷后背包 ${parts.join(' ')}`); const got = slots[slotIndex]; if (!got || got.weapon2Id !== weapon2Id) { aiWarn(`AI 妙手神偷后 bag[${slotIndex}] 未写入预期字 id=${weapon2Id}`); } const playerBag = this._battle?.bagSession; if (!playerBag || playerBag === aiBag) return; const pSlots = playerBag.getBagSlots(); for (let i = 0; i < pSlots.length; i++) { const s = pSlots[i]; if (s && s.weapon2Id === weapon2Id && i === slotIndex) { // 同槽同字可能巧合;若 AI 写入前后玩家该槽从空变有则更可疑,这里仅提示对照 aiOp(`对照:玩家 bag[${i}] 亦有同 id=${weapon2Id}(若刚被误写请查会话串线)`); } } } /** * 在进攻 AI 的行军路上选一个可放陷阱格(无字、无已有陷阱)。 */ private _pickTrapGrid(): number | null { const battle = this._battle; const map = this._map; const aiBag = this._bagHost?.bagSession; if (!battle || !map || !aiBag) return null; if (!this._aiMarchPath) { this._aiMarchPath = buildEnemyMarchPathGridIndices(map, battle.session, AiHeadlessBagHost.AI_PLAYER_ID); } const grids = map.mapGrids; if (!Array.isArray(grids)) return null; const candidates: number[] = []; for (let i = 0; i < grids.length; i++) { const mg = grids[i]; if (!mg?.node?.isValid || !mg.isCanWalk) continue; if (!isEnemyMonsterWalkGrid(map, battle.session, mg, this._aiMarchPath, AiHeadlessBagHost.AI_PLAYER_ID)) { continue; } if (aiBag.merge.map.hasTile(mg.Index)) continue; if (this._bindNet.hasTrapAt(mg.Index) || this._fireTrap.hasTrapAt(mg.Index)) continue; candidates.push(mg.Index); } if (candidates.length <= 0) return null; const rng = battle.session.rng; return candidates[rng.int(0, candidates.length - 1)]; } /** 选敌方(玩家)可迷惑的多格神将 */ private _pickConfuseTarget(): CharmTarget | null { const battle = this._battle; const map = this._map; if (!battle || !map) return null; // AI 固定 playerId=1,幻魂镜迷惑本机神将 const enemyPlayer = battle.getPlayer(0); if (!enemyPlayer) return null; const comps = enemyPlayer.compositions; let best: CharmTarget | null = null; let bestLevel = -1; for (let i = 0; i < comps.length; i++) { const comp = comps[i]; if (!comp || comp.gridIndices.length < 2) continue; if (!isHeroActorWeapon1(comp.weapon1Id)) continue; const key = composeKey(comp); if (battle.charmController.isComposeKeyCharmed(key)) continue; const anchor = comp.gridIndices[0]; const entityId = findEntityIdByGridIndex(anchor, battle.session, map, enemyPlayer); if (entityId == null) continue; if (!battle.session.world.isAlive(entityId)) continue; const abilities = battle.session.world.getComponent(entityId, AbilitySetComp); if (!abilities?.abilityIds.length) continue; if (!getTowerLoadoutService().getLoadout(entityId)?.weapon1Id) continue; const level = Math.max(1, Math.floor(Number(comp.level) || 1)); if (level < bestLevel) continue; bestLevel = level; best = { entityId, composeKey: key, gridIndices: comp.gridIndices.slice(), anchorGridIndex: anchor, }; } return best; } /** * 选玩家放置区可偷五行数字:高等级优先。 * @param _aiBag AI bag(保留签名) * @param playerBag 玩家 bag */ private _pickStealTarget(_aiBag: NonNullable, playerBag: NonNullable): number | null { WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; const map = this._map; if (!map) return null; type Cand = { gridIndex: number; level: number }; const cands: Cand[] = []; playerBag.merge.map.forEachTile((gridIndex, tile) => { const mg = map.getMapGridByIndex(gridIndex); if (!mg || (mg.CampAreaID | 0) !== AI_VIEW_LOCAL_CAMP) return; if (playerBag.merge.map.getCellKind(gridIndex) !== MapCellKind.Put) return; if (playerBag.merge.canApplyStealGlyphMap(gridIndex) != null) return; const w2Type = Number(provider.getWeapon2Type(tile.weapon2Id)); if (!isElementGlyphType(w2Type)) return; cands.push({ gridIndex, level: Math.max(1, Math.floor(Number(tile.level) || 1)), }); }); if (cands.length <= 0) return null; cands.sort((a, b) => { if (a.level !== b.level) return b.level - a.level; return a.gridIndex - b.gridIndex; }); return cands[0].gridIndex; } /** * Weapon2 品质。 * @param weapon2Id Text.id */ private _tileQuality(weapon2Id: number): number { WeaponComposeConfigProvider.instance.load(); return Math.floor(Number(WeaponComposeConfigProvider.instance.getWeapon2Config(weapon2Id)?.quality) || 0); } /** * 是否可封神榜升级。 * @param bag 会话 * @param target 落点 * @param delta 增量 */ private _canLevelUp(bag: NonNullable, target: AiActiveSkillTileTarget, delta: number): boolean { const battle = this._battle; if (target.kind === 'map') { if (battle && shouldBattleBuffLevelUpViaExpFill(battle, bag, target)) { return canApplyBattleBuffLevelUpViaExpFill(battle, bag, target.gridIndex, delta); } return bag.merge.canApplyBattleBuffUnitLevelUp(target.gridIndex, delta) == null; } return bag.merge.canApplyBattleBuffUnitLevelUpBag(target.slotIndex, delta) == null; } /** * 文昌笔排除当前 weapon2Id。 * @param bag 会话 * @param target 落点 */ private _resolveExcludeWeapon2(bag: NonNullable, target: AiActiveSkillTileTarget): number | null { if (target.kind === 'map') { const tile = bag.merge.map.getTile(target.gridIndex); return tile ? tile.weapon2Id : null; } const slot = bag.getBagSlots()[target.slotIndex]; return slot ? slot.weapon2Id : null; } /** * 格式化落点日志。 * @param target 落点 */ private _fmtTarget(target: AiActiveSkillTileTarget): string { return target.kind === 'map' ? `map[${target.gridIndex}]` : `bag[${target.slotIndex}]`; } /** * 字块落点 → 表现受体。 * @param target picker 落点 */ private _tileReceptor(target: AiActiveSkillTileTarget): AiActiveSkillCastReceptor { if (target.kind === 'map') return { kind: 'map', gridIndex: target.gridIndex }; return { kind: 'bag', slotIndex: target.slotIndex }; } /** * 通知 UI 播放主动施放表现。 * @param slotIndex 槽 * @param parsed 施放 buff * @param kind 拖拽类型 * @param receptor 受体 */ private _emitCastFx( slotIndex: number, parsed: DailyShopParsed, kind: BattleGameSkillDragKind, receptor: AiActiveSkillCastReceptor, ): void { if (!this._onCastFx) return; this._onCastFx({ slotIndex, buffId: parsed.id, kind, receptor, }); } /** * 重启 CD。 * @param slot 槽 */ private _restartCd(slot: AiActiveSkillSlot): void { slot.cdLeftSec = slot.cdTotalSec; } }