import { MapCellKind } from 'db://assets/mx/module/merge'; import type { DailyShopParsed } from '../../../manager/DailyShopTypes'; import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore'; import { passiveBuffUsesBattleGameCd, readBattleBuffPassiveCdSec, readBattleBuffPassiveSpawnWeapon2Id, } from '../../../manager/battleBuff/BattleBuffPassiveSkillUtil'; import { BattleBuffFunctionType } from '../../../manager/battleBuff/BattleBuffTypes'; import type { BattleBagSession } from '../../bag/BattleBagSession'; import { PEACH_TREE_WEAPON2_ID } from '../../bag/BagPrepConfig'; import type { ProjectBattleHandle } from '../../runtime/BattleRuntime'; import { aiOp, aiWarn } from './AiLog'; import { AiHeadlessBagHost } from './AiHeadlessBagHost'; /** 单槽 CD 被动状态 */ type AiPassiveCdSlot = { /** 携带解析结果 */ parsed: DailyShopParsed; /** CD 总秒 */ cdTotalSec: number; /** CD 剩余秒 */ cdLeftSec: number; /** 本轮 CD 结束是否已尝试触发 */ triggerConsumed: boolean; }; /** AI CD 被动 UI 快照 */ export type AiPassiveCdUiSlot = { /** buff id */ buffId: number; /** CD 剩余秒 */ cdLeftSec: number; /** CD 总秒 */ cdTotalSec: number; }; /** * AI 局内 CD 被动:蟠桃树(3)。 * 无 UI,CD 满直接写入 AI bag(不播飞行)。 */ export class AiPassiveCdHost { private _bagHost: AiHeadlessBagHost | null = null; private _battle: ProjectBattleHandle | null = null; private _onBoardChanged: (() => void) | null = null; private readonly _slots: AiPassiveCdSlot[] = []; /** * 绑定并按 CarryStore 重建 CD 槽。 * @param bagHost 无头背包 * @param battle 战斗 * @param onBoardChanged 盘面变更回调 */ bind(bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, onBoardChanged: () => void): void { this.dispose(); this._bagHost = bagHost; this._battle = battle; this._onBoardChanged = onBoardChanged; this._rebuildSlots(); } /** 解除绑定 */ dispose(): void { this._slots.length = 0; this._bagHost = null; this._battle = null; this._onBoardChanged = null; } /** * 帧更新 CD。 * @param dtSec 帧间隔 */ tick(dtSec: number): void { if (!this._bagHost || !this._battle || 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); } } /** * CD 被动 UI 快照。 * @returns 槽列表 */ snapshotUiSlots(): AiPassiveCdUiSlot[] { const out: AiPassiveCdUiSlot[] = []; for (let i = 0; i < this._slots.length; i++) { const slot = this._slots[i]; out.push({ buffId: slot.parsed.id, cdLeftSec: slot.cdLeftSec, cdTotalSec: slot.cdTotalSec, }); } return out; } /** 从 CarryStore 重建有 CD 的被动槽 */ private _rebuildSlots(): void { this._slots.length = 0; const passives = BattleBuffCarryStore.getPassives(AiHeadlessBagHost.AI_PLAYER_ID); for (let i = 0; i < passives.length; i++) { const parsed = passives[i]; const row = parsed.row as ITableBattlebuff; const ft = Math.floor(Number(row.functiontype) || 0); if (!passiveBuffUsesBattleGameCd(ft)) continue; if (ft !== BattleBuffFunctionType.PeachTree) { aiWarn(`AI CD 被动未接线 functiontype=${ft} id=${parsed.id} name=${parsed.name}`); continue; } const cdSec = Math.max(0, Math.floor(Number(parsed.cd) || 0) || readBattleBuffPassiveCdSec(row)); this._slots.push({ parsed, cdTotalSec: cdSec, cdLeftSec: cdSec, triggerConsumed: false, }); aiOp(`AI CD被动槽#${this._slots.length - 1} id=${parsed.id} ${parsed.name} ft=${ft} cd=${cdSec}s`); } } /** * 单槽倒计时与触发。 * @param slot 槽 * @param slotIndex 下标 * @param dt 帧间隔 */ private _tickSlot(slot: AiPassiveCdSlot, slotIndex: number, dt: number): void { if (slot.cdLeftSec > 0) { slot.cdLeftSec = Math.max(0, slot.cdLeftSec - dt); return; } if (slot.triggerConsumed) return; slot.triggerConsumed = true; const handled = this._onCdReady(slot, slotIndex); if (!handled) { this._restartCd(slot); } } /** * CD 结束:按 functiontype 产字。 * @param slot 槽 * @param slotIndex 下标 * @returns 是否已受理(含成功落子);false 则立刻重开 CD */ private _onCdReady(slot: AiPassiveCdSlot, slotIndex: number): boolean { const row = slot.parsed.row as ITableBattlebuff; const ft = Math.floor(Number(row.functiontype) || 0); if (ft === BattleBuffFunctionType.PeachTree) { return this._trySpawnPeachTree(row, slot, slotIndex); } return false; } /** * 蟠桃树:空 Dig 优先否则空 Put,直接落字。 * Dig 落子保持 Dig;Put 落子 Put→Dig。peer 按落子后 kind 对齐(禁止误 Dig→Put)。 * @param row 表行 * @param slot 槽 * @param slotIndex 下标 */ private _trySpawnPeachTree(row: ITableBattlebuff, slot: AiPassiveCdSlot, slotIndex: number): boolean { const bag = this._bagHost?.bagSession; if (!bag) { aiWarn('AI 蟠桃树失败:bag 未就绪'); return false; } const weapon2Id = readBattleBuffPassiveSpawnWeapon2Id(row); if (weapon2Id <= 0) return false; const gridIndex = bag.findPassiveDigSpawnGrid(); if (gridIndex == null) { // 与玩家一致:无空位不消费 CD(返回 false → 立刻重开,下帧再试) // 但玩家返回 false 会 restartCd,即无空位也重开满 CD。对齐玩家:restart。 aiOp(`AI 蟠桃树无空位 slot#${slotIndex},重开 CD`); return false; } const beforeKind = bag.merge.map.getCellKind(gridIndex); const peerBefore = this._peerCellKind(bag, gridIndex); const reason = bag.grantPassiveTileOnDigGrid(gridIndex, weapon2Id); if (reason) { aiWarn(`AI 蟠桃树落子拒绝 reason=${reason} map[${gridIndex}] w2=${weapon2Id}`); return false; } const afterKind = bag.merge.map.getCellKind(gridIndex); this._syncPeerCellKind(bag, gridIndex, afterKind); const peerAfter = this._peerCellKind(bag, gridIndex); const tile = bag.merge.map.getTile(gridIndex); aiOp( `[蟠桃同步] 被动产字 map[${gridIndex}] w2=${weapon2Id} slot#${slotIndex}` + ` before=${beforeKind} after=${afterKind}` + ` peerBefore=${peerBefore} peerAfter=${peerAfter}` + ` hasTile=${!!tile} lv=${tile?.level ?? 0}`, ); if (afterKind !== MapCellKind.Dig) { aiWarn(`[蟠桃同步] 产字后应为 Dig map[${gridIndex}] after=${afterKind}`); } if (peerAfter !== afterKind) { aiWarn(`[蟠桃同步] peer 仍不一致 map[${gridIndex}] ai=${afterKind} peer=${peerAfter}`); } this._warnPeachPeerMismatch(`afterSpawn:slot#${slotIndex}`); this._onBoardChanged?.(); this._restartCd(slot); 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,避免双 bag 分叉。 * @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 盘面蟠桃与本机 kind;不一致则 warn。 * @param tag 触发点 */ private _warnPeachPeerMismatch(tag: string): void { const bag = this._bagHost?.bagSession; const peer = this._battle?.bagSession; if (!bag || !peer || peer === bag) return; 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) continue; // 仅看 weapon2=102 蟠桃树(与 WeaponCompose keepDig 一致,避免循环依赖读表失败漏报) if (tile.weapon2Id !== PEACH_TREE_WEAPON2_ID) 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}`, ); } } /** * 重启 CD。 * @param slot 槽 */ private _restartCd(slot: AiPassiveCdSlot): void { slot.cdLeftSec = slot.cdTotalSec; slot.triggerConsumed = false; } }