import type { Node } from 'cc'; import { BattleBagSession } from '../../bag/BattleBagSession'; import { aiLog, aiOp, aiWarn } from './AiLog'; import { DeploySync } from '../../runtime/DeploySync'; import type { ProjectBattleHandle } from '../../runtime/BattleRuntime'; import type { MapManager } from '../../map/legacy/MapManager'; import type { AiMatchProfile } from '../match/AiMatchProfile'; import { AI_PLACE_CAMP, AI_PLAYER_ID, AI_VIEW_LOCAL_CAMP } from './AiCampArea'; import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider'; import { textDisplayName } from '../../config/BattleHeroTable'; import { TextListCellState, TextListLandHost } from '../../view/TextListLandHost'; import { TableNames } from '../../../game/ConfigTableData'; /** playerId=1 无头 BattleBagSession:与玩家同套大地块 + 召唤补空 + 五行数字合成 */ export class AiHeadlessBagHost { /** 对手 playerId */ static readonly AI_PLAYER_ID = AI_PLAYER_ID; private _bagSession: BattleBagSession | null = null; private _deploySync: DeploySync | null = null; private _battle: ProjectBattleHandle | null = null; private readonly _land = new TextListLandHost(); /** 无头背包会话;未 bind 时为 null */ get bagSession(): BattleBagSession | null { return this._bagSession; } /** AI 部署同步 */ get deploySync(): DeploySync | null { return this._deploySync; } /** AI 背包地块(与玩家独立进度) */ get land(): TextListLandHost { return this._land; } /** * 绑定战斗、建池、开局待开启填字(对齐玩家 autoFill)。 * @param profile 本局匹配档案 * @param battle 战斗句柄 * @param map 地图 * @param bagLayout 玩家 ui_textList(槽位数与邻格几何;不改其底图) */ bind(profile: AiMatchProfile, battle: ProjectBattleHandle, map: MapManager, bagLayout?: Node | null): boolean { this.dispose(); if (!map?.gridsInitialized) { aiLog('[AiHeadlessBagHost] bind failed: map not ready'); return false; } this._logCampPutGrids(map); const levelId = battle.levelConfigId; this._land.bindHeadless(levelId, bagLayout ?? null); const slotCount = this._resolveSlotCount(levelId, bagLayout ?? null); if (slotCount <= 1) { aiWarn(`AI 背包槽数非法 slotCount=${slotCount}`); return false; } const bag = new BattleBagSession(map, levelId, battle.session, slotCount, { rankId: profile.opponentRankId, ownerPlayerId: AiHeadlessBagHost.AI_PLAYER_ID, }); bag.landStateOf = (slotIndex) => this._land.stateOf(slotIndex); bag.autoFillOpenSlots(); const deploy = new DeploySync(); deploy.bind(battle, map, bag, AiHeadlessBagHost.AI_PLAYER_ID, false); this._bagSession = bag; this._deploySync = deploy; this._battle = battle; const aiPlayer = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID); if (aiPlayer) { aiPlayer.attachBagSession(bag); } else { console.warn('[AiHeadlessBagHost] AI Player missing, compositions query unavailable'); } // 开局立刻召唤一次:待开启格不能拖,必须先有开启格数字才能合成/解锁地块 const summon = bag.tryFillEmptyOpenSlots(battle); if (summon === 'ok') { bag.applyDigitDoubleAfterRefresh(bag.lastFilledSlotIndices()); } console.warn( '[AiHeadlessBagHost] bound', `aiRank=${profile.opponentRankId}`, `gold=${battle.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold}`, `slots=${slotCount}`, `summon=${summon}`, `placeCamp=${AI_PLACE_CAMP}`, ); this._logBagSlots('firstFill+summon'); return true; } /** * 召唤:对齐玩家 tryFillEmptyOpenSlots(空开启格补字,最多 5)。 */ tryRefresh(): 'ok' | 'not_enough' | 'failed' | 'pool_empty' | 'no_slot' { if (!this._bagSession || !this._battle) return 'failed'; const gold = this._battle.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold ?? -1; const cost = this._bagSession.normalRefreshCost; if (gold < cost) { return 'not_enough'; } const result = this._bagSession.tryFillEmptyOpenSlots(this._battle); if (result === 'ok') { this._bagSession.applyDigitDoubleAfterRefresh(this._bagSession.lastFilledSlotIndices()); this._logBagSlots('summon'); } else if (result !== 'no_slot' && result !== 'not_enough') { aiOp(`召唤结果 result=${result} aiGold=${gold} need=${cost}`); } return result; } /** * 卡死时丢掉 1 个最低级开启格数字,腾出召唤空位(待开启格不丢)。 * @returns 是否丢弃成功 */ discardStuckOpenSlot(): boolean { const bag = this._bagSession; if (!bag) return false; const slots = bag.getBagSlots(); let best = -1; let bestLv = 1e9; for (let i = 0; i < slots.length; i++) { const slot = slots[i]; if (!slot || !(slot.weapon2Id > 0)) continue; if (this._land.stateOf(i) !== TextListCellState.Open) continue; const lv = Math.floor(Number(slot.level) || 0); if (!(lv > 0)) continue; if (lv < bestLv || (lv === bestLv && i < best)) { bestLv = lv; best = i; } } if (best < 0) return false; if (!bag.discardBagSlot(best)) return false; aiOp(`卡死丢弃 bag[${best}] Lv${bestLv} 腾召唤位`); return true; } /** * 背包数字合成落到待开启格后:推进地块并给新生待开启格补字。 * @param slotIndex 合成目标槽 */ onBagMergedAt(slotIndex: number): void { const bag = this._bagSession; if (!bag) return; if (this._land.stateOf(slotIndex) !== TextListCellState.OpenNoDrag) return; const promoted = this._land.promoteAround(slotIndex); bag.fillGateUnlockSlots(promoted.born, promoted.levelAdd); if (promoted.born.length > 0) { aiOp(`AI 待开启推进 slot=${slotIndex} born=${promoted.born.join(',')}`); this._logBagSlots('gateUnlock'); } } /** 下次召唤费用(按当前空开启格相对满额 5 格折算) */ get nextRefreshCost(): number { return this._bagSession?.normalRefreshCost ?? -1; } /** AI 当前桃子 */ get aiGold(): number { return this._battle?.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold ?? -1; } /** * 背包参与移动后打印源目标与当前槽位。 * @param tag 动作标签 * @param source 拖拽源 * @param target 放置目标 */ logBagMove(tag: string, source: { kind: string; slotIndex?: number; gridIndex?: number }, target: { kind: string; slotIndex?: number; gridIndex?: number }): void { const src = source.kind === 'bag' ? `bag[${source.slotIndex}]` : `map[${source.gridIndex}]`; const dst = target.kind === 'bag' ? `bag[${target.slotIndex}]` : `map[${target.gridIndex}]`; this._logBagSlots(`${tag} ${src}->${dst}`); } /** 释放背包与会话引用 */ dispose(): void { const aiPlayer = this._battle?.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID); if (aiPlayer) { aiPlayer.attachBagSession(null); aiPlayer.skillController = null; } this._deploySync?.unbind(); this._deploySync = null; this._bagSession = null; this._battle = null; this._land.clear(); } /** * 槽位数 = max(布局格子下标, 关卡地块下标) + 1。 * @param levelId 关卡 * @param bagLayout 布局 */ private _resolveSlotCount(levelId: number, bagLayout: Node | null): number { let maxIndex = 0; if (bagLayout?.isValid) { const children = bagLayout.children; for (let i = 0; i < children.length; i++) { const idx = TextListLandHost.indexOfNode(children[i]); if (idx != null && idx > maxIndex) maxIndex = idx; } } const row = gg.data.table.getTableData(TableNames.Level, levelId); if (row) { maxIndex = Math.max(maxIndex, this._maxLandIndex(row.normalland), this._maxLandIndex(row.tolockland)); } if (maxIndex <= 0) { aiWarn(`无法解析 AI 背包槽数 levelId=${levelId}`); return 0; } return maxIndex + 1; } /** * @param raw 地块下标表 */ private _maxLandIndex(raw: readonly number[] | undefined): number { if (!raw?.length) return 0; let max = 0; for (let i = 0; i < raw.length; i++) { const n = Math.floor(Number(raw[i])); if (Number.isFinite(n) && n > max) max = n; } return max; } /** 打印当前 AI 背包有字槽(中文名) */ private _logBagSlots(tag: string): void { const bag = this._bagSession; if (!bag) { aiOp(`背包[${tag}] session missing`); return; } WeaponComposeConfigProvider.instance.load(); const slots = bag.getBagSlots(); const parts: string[] = []; let filled = 0; for (let i = 0; i < slots.length; i++) { const s = slots[i]; if (!s) continue; filled++; const land = this._land.stateOf(i); parts.push(`[${i}|${land}]=${this._resolveTileName(s.weapon2Id)}Lv${s.level ?? 1}`); } aiOp( `背包[${tag}] filled=${filled}/${slots.length} gold=${this._battle?.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold}` + ` | ${parts.join(' ')}`, ); } /** Text 表中文名;缺表回退 id */ private _resolveTileName(weapon2Id: number): string { const row = WeaponComposeConfigProvider.instance.getWeapon2Config(weapon2Id); if (!row) return `id=${weapon2Id}`; const name = textDisplayName(row).trim(); return name || `id=${weapon2Id}`; } /** 开战打印各 CampArea 可放字格 / 可摆格数量 */ private _logCampPutGrids(map: MapManager): void { const grids = map.mapGrids; if (!Array.isArray(grids)) { aiLog('[AiHeadlessBagHost] mapGrids missing'); return; } let localText = 0; let enemyText = 0; let localGrid = 0; let enemyGrid = 0; let localWalk = 0; let enemyWalk = 0; let dualPutDig = 0; for (let i = 0; i < grids.length; i++) { const mg = grids[i]; if (!mg?.node?.isValid || !mg.node.active) continue; const camp = mg.CampAreaID | 0; if (mg.isCanPutText && !mg.isCanWalk) { if (camp === AI_VIEW_LOCAL_CAMP) localText++; else if (camp === AI_PLACE_CAMP) enemyText++; if (mg.isCanBeDig && camp === AI_PLACE_CAMP) dualPutDig++; } if (mg.isCanPutGrid && mg.isCanWalk) { if (camp === AI_VIEW_LOCAL_CAMP) localGrid++; else if (camp === AI_PLACE_CAMP) enemyGrid++; } if (mg.isCanWalk) { if (camp === AI_VIEW_LOCAL_CAMP) localWalk++; else if (camp === AI_PLACE_CAMP) enemyWalk++; } } aiLog( `[AiHeadlessBagHost] mapCamp textPut: local(1)=${localText} enemy(2)=${enemyText}` + ` | gridPut: local(1)=${localGrid} enemy(2)=${enemyGrid}` + ` | walk: local(1)=${localWalk} enemy(2)=${enemyWalk}`, ); if (dualPutDig > 0) { aiWarn(`地图 dig+putText 双标 enemy=${dualPutDig}(已按可放置优先当 Put)`); } if (enemyText <= 0) { aiLog('[AiHeadlessBagHost] enemy CampAreaID=2 has no isCanPutText cell; AI text cannot place'); } } }