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.
314 lines
12 KiB
314 lines
12 KiB
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)、化龙铲(9)。
|
|
* 无 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 && ft !== BattleBuffFunctionType.ShovelSpawn) {
|
|
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);
|
|
}
|
|
if (ft === BattleBuffFunctionType.ShovelSpawn) {
|
|
return this._trySpawnShovel(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;
|
|
}
|
|
|
|
/**
|
|
* 化龙铲:优先背包空槽,否则随机翻 Dig。
|
|
* @param slot 槽
|
|
* @param slotIndex 下标
|
|
*/
|
|
private _trySpawnShovel(slot: AiPassiveCdSlot, slotIndex: number): boolean {
|
|
const bag = this._bagHost?.bagSession;
|
|
if (!bag) {
|
|
aiWarn('AI 化龙铲失败:bag 未就绪');
|
|
return false;
|
|
}
|
|
if (!bag.merge.prepActive) {
|
|
this._restartCd(slot);
|
|
return true;
|
|
}
|
|
const bagSlot = bag.findEmptyBagSlot();
|
|
if (bagSlot != null) {
|
|
const reason = bag.grantShovelInBag(bagSlot);
|
|
if (reason) {
|
|
aiWarn(`AI 化龙铲入包拒绝 reason=${reason} bag[${bagSlot}]`);
|
|
this._restartCd(slot);
|
|
return true;
|
|
}
|
|
aiOp(`AI 化龙铲入包 bag[${bagSlot}] slot#${slotIndex}`);
|
|
this._onBoardChanged?.();
|
|
this._restartCd(slot);
|
|
return true;
|
|
}
|
|
const digIndex = bag.findRandomPassiveDigGrid();
|
|
if (digIndex == null) {
|
|
aiOp(`AI 化龙铲无目标 slot#${slotIndex},重开 CD`);
|
|
this._restartCd(slot);
|
|
return true;
|
|
}
|
|
const reason = bag.grantPassiveShovelOnDigGrid(digIndex);
|
|
if (reason) {
|
|
aiWarn(`AI 化龙铲挖格拒绝 reason=${reason} map[${digIndex}]`);
|
|
this._restartCd(slot);
|
|
return true;
|
|
}
|
|
this._syncPeerCellKind(bag, digIndex, MapCellKind.Put);
|
|
aiOp(`AI 化龙铲翻格 map[${digIndex}] 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;
|
|
}
|
|
}
|
|
|