|
|
|
|
|
import type { AiMatchBuildInput } from '../types/AiTypes';
|
|
|
|
|
|
import { aiLog, aiOp } from '../runtime/AiLog';
|
|
|
|
|
|
import type { AiMatchProfile } from './AiMatchProfile';
|
|
|
|
|
|
import { AiBattlebuffPicker } from './AiBattlebuffPicker';
|
|
|
|
|
|
import { RankDataManager } from '../../../manager/RankDataManager';
|
|
|
|
|
|
import { DailyShopDataManager } from '../../../manager/DailyShopDataManager';
|
|
|
|
|
|
import { MatchStreakStats } from '../../../manager/MatchStreakStats';
|
|
|
|
|
|
|
|
|
|
|
|
/** Const 11: 匹配星偏移权重 */
|
|
|
|
|
|
const CONST_STAR_WEIGHT_ID = 11;
|
|
|
|
|
|
/** Const 12: 主动 buff 数量偏移权重 (-1,0,+1) */
|
|
|
|
|
|
const CONST_ACTIVE_OFFSET_ID = 12;
|
|
|
|
|
|
/** Const 14: 被动 buff 数量偏移权重 (-2,-1,0,+1) */
|
|
|
|
|
|
const CONST_PASSIVE_OFFSET_ID = 14;
|
|
|
|
|
|
/** Const 15: 连胜 → 主动 +1 档加成 */
|
|
|
|
|
|
const CONST_WIN_ACTIVE_BONUS_ID = 15;
|
|
|
|
|
|
/** Const 16: 连胜 → 被动 +1 档加成 */
|
|
|
|
|
|
const CONST_WIN_PASSIVE_BONUS_ID = 16;
|
|
|
|
|
|
/** Const 17: 连败 → 主动 -1 档加成 */
|
|
|
|
|
|
const CONST_LOSE_ACTIVE_BONUS_ID = 17;
|
|
|
|
|
|
/** Const 18: 连败 → 被动 -2 档加成 */
|
|
|
|
|
|
const CONST_LOSE_PASSIVE_BONUS_ID = 18;
|
|
|
|
|
|
/** Const 19: 最大主动技能数量 */
|
|
|
|
|
|
const CONST_MAX_ACTIVE_ID = 19;
|
|
|
|
|
|
/** Const 20: 最大被动技能数量 */
|
|
|
|
|
|
const CONST_MAX_PASSIVE_ID = 20;
|
|
|
|
|
|
|
|
|
|
|
|
/** Const 12 主动数量偏移 */
|
|
|
|
|
|
const ACTIVE_OFFSETS = [-1, 0, 1];
|
|
|
|
|
|
/** Const 14 被动数量偏移 */
|
|
|
|
|
|
const PASSIVE_OFFSETS = [-2, -1, 0, 1];
|
|
|
|
|
|
|
|
|
|
|
|
/** 开战前匹配:Const 11/12/14/15~20 -> AiMatchProfile */
|
|
|
|
|
|
export class AiMatchService {
|
|
|
|
|
|
private readonly _buffPicker = new AiBattlebuffPicker();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 构建本局 AI 对手档案。
|
|
|
|
|
|
* @param input 玩家 buff 数量等入参
|
|
|
|
|
|
* @returns 成功返回档案;配表/权重非法时返回 null
|
|
|
|
|
|
*/
|
|
|
|
|
|
buildProfile(input: AiMatchBuildInput): AiMatchProfile | null {
|
|
|
|
|
|
const playerStars = RankDataManager.getPlayerRankStars();
|
|
|
|
|
|
const playerRankId = RankDataManager.getPlayerRankId();
|
|
|
|
|
|
let starMatch = RankDataManager.resolveOpponentStarMatch(playerStars);
|
|
|
|
|
|
if (!starMatch) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[AiMatchService] star match failed,回退玩家段位',
|
|
|
|
|
|
`playerStars=${playerStars} playerRankId=${playerRankId}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
starMatch = {
|
|
|
|
|
|
starOffset: 0,
|
|
|
|
|
|
opponentStars: Math.max(1, playerStars),
|
|
|
|
|
|
opponentRankId: playerRankId > 0 ? playerRankId : 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const caps = this._readSlotCaps();
|
|
|
|
|
|
const playerActive = Math.max(0, Math.floor(Number(input.playerActiveBuffCount) || 0));
|
|
|
|
|
|
const playerPassive = Math.max(0, Math.floor(Number(input.playerPassiveBuffCount) || 0));
|
|
|
|
|
|
|
|
|
|
|
|
let activeWeights = this._copyWeights(gg.data.table.getConstArray(CONST_ACTIVE_OFFSET_ID), ACTIVE_OFFSETS.length, 'Const12');
|
|
|
|
|
|
let passiveWeights = this._copyWeights(gg.data.table.getConstArray(CONST_PASSIVE_OFFSET_ID), PASSIVE_OFFSETS.length, 'Const14');
|
|
|
|
|
|
if (!activeWeights) {
|
|
|
|
|
|
console.warn('[AiMatchService] Const12 权重无效,用均匀权重');
|
|
|
|
|
|
activeWeights = [1, 1, 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!passiveWeights) {
|
|
|
|
|
|
console.warn('[AiMatchService] Const14 权重无效,用均匀权重');
|
|
|
|
|
|
passiveWeights = [1, 1, 1, 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this._applyStreakWeightBonus(activeWeights, passiveWeights, playerActive, playerPassive, caps);
|
|
|
|
|
|
|
|
|
|
|
|
let activeOff = this._rollOffset(activeWeights, ACTIVE_OFFSETS, 'Const12');
|
|
|
|
|
|
let passiveOff = this._rollOffset(passiveWeights, PASSIVE_OFFSETS, 'Const14');
|
|
|
|
|
|
if (activeOff == null) activeOff = 0;
|
|
|
|
|
|
if (passiveOff == null) passiveOff = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const activeRaw = playerActive + activeOff;
|
|
|
|
|
|
const passiveRaw = playerPassive + passiveOff;
|
|
|
|
|
|
const activeBuffCount = Math.max(0, Math.min(caps.active, activeRaw));
|
|
|
|
|
|
const passiveBuffCount = Math.max(0, Math.min(caps.passive, passiveRaw));
|
|
|
|
|
|
|
|
|
|
|
|
aiOp(
|
|
|
|
|
|
`AI buff 数量推导`
|
|
|
|
|
|
+ ` 玩家A=${playerActive} 偏移=${activeOff}(权重[${activeWeights.join(',')}]) raw=${activeRaw} → clamp[0,${caps.active}]=${activeBuffCount}`
|
|
|
|
|
|
+ ` | 玩家P=${playerPassive} 偏移=${passiveOff}(权重[${passiveWeights.join(',')}]) raw=${passiveRaw} → clamp[0,${caps.passive}]=${passiveBuffCount}`
|
|
|
|
|
|
+ ` | streakW=${MatchStreakStats.getWinStreak()} streakL=${MatchStreakStats.getLoseStreak()}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const seed = input.seed != null
|
|
|
|
|
|
? Math.floor(Number(input.seed) || 0)
|
|
|
|
|
|
: ((Date.now() % 100000) ^ (starMatch.opponentStars * 9973));
|
|
|
|
|
|
|
|
|
|
|
|
let activeBuffIds = this._buffPicker.pick(1, activeBuffCount, seed);
|
|
|
|
|
|
let passiveBuffIds = this._buffPicker.pick(2, passiveBuffCount, seed ^ 0x9e3779b9);
|
|
|
|
|
|
let resolvedActiveCount = activeBuffIds.length;
|
|
|
|
|
|
let resolvedPassiveCount = passiveBuffIds.length;
|
|
|
|
|
|
// 候选不足时按实际数量建档,禁止整局 AI 因 buff 失败而不启动
|
|
|
|
|
|
if (resolvedActiveCount !== activeBuffCount || resolvedPassiveCount !== passiveBuffCount) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[AiMatchService] buff 抽取不足,按实际数量建档',
|
|
|
|
|
|
`active=${activeBuffCount}/${resolvedActiveCount}`,
|
|
|
|
|
|
`passive=${passiveBuffCount}/${resolvedPassiveCount}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
aiOp(
|
|
|
|
|
|
`AI buff 抽取 seed=${seed}`
|
|
|
|
|
|
+ ` active[${resolvedActiveCount}]=[${activeBuffIds.join(',')}]`
|
|
|
|
|
|
+ ` passive[${resolvedPassiveCount}]=[${passiveBuffIds.join(',')}]`,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const gmActive = gg.game.gmAiActiveBuffIds;
|
|
|
|
|
|
const gmPassive = gg.game.gmAiPassiveBuffIds;
|
|
|
|
|
|
// 正常:上列每局重抽;GM 有值则常驻覆盖(直至 GM 清除 / 游戏 reset)
|
|
|
|
|
|
if (gmActive?.length) {
|
|
|
|
|
|
activeBuffIds = gmActive.slice();
|
|
|
|
|
|
resolvedActiveCount = activeBuffIds.length;
|
|
|
|
|
|
aiOp(`GM 常驻覆盖 AI 主动 buff=[${activeBuffIds.join(',')}]`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (gmPassive?.length) {
|
|
|
|
|
|
passiveBuffIds = gmPassive.slice();
|
|
|
|
|
|
resolvedPassiveCount = passiveBuffIds.length;
|
|
|
|
|
|
aiOp(`GM 常驻覆盖 AI 被动 buff=[${passiveBuffIds.join(',')}]`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const profile: AiMatchProfile = {
|
|
|
|
|
|
playerRankId,
|
|
|
|
|
|
playerStars,
|
|
|
|
|
|
opponentRankId: starMatch.opponentRankId,
|
|
|
|
|
|
opponentStars: starMatch.opponentStars,
|
|
|
|
|
|
starOffset: starMatch.starOffset,
|
|
|
|
|
|
activeBuffCount: resolvedActiveCount,
|
|
|
|
|
|
passiveBuffCount: resolvedPassiveCount,
|
|
|
|
|
|
activeBuffIds,
|
|
|
|
|
|
passiveBuffIds,
|
|
|
|
|
|
seed,
|
|
|
|
|
|
};
|
|
|
|
|
|
aiOp(
|
|
|
|
|
|
`匹配档案 playerRank=${playerRankId} stars=${playerStars}`
|
|
|
|
|
|
+ ` -> aiRank=${profile.opponentRankId} stars=${profile.opponentStars}`
|
|
|
|
|
|
+ ` buffA=${resolvedActiveCount}[${activeBuffIds.join(',')}]`
|
|
|
|
|
|
+ ` buffP=${resolvedPassiveCount}[${passiveBuffIds.join(',')}]`
|
|
|
|
|
|
+ ` streakW=${MatchStreakStats.getWinStreak()} streakL=${MatchStreakStats.getLoseStreak()}`
|
|
|
|
|
|
+ ` caps=${caps.active}/${caps.passive}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
return profile;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** buff 抽取器 */
|
|
|
|
|
|
get buffPicker(): AiBattlebuffPicker {
|
|
|
|
|
|
return this._buffPicker;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Const 19/20 槽位上限 */
|
|
|
|
|
|
private _readSlotCaps(): { active: number; passive: number } {
|
|
|
|
|
|
const fromShop = DailyShopDataManager.getSlotCaps();
|
|
|
|
|
|
const active = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_MAX_ACTIVE_ID)) || 0));
|
|
|
|
|
|
const passive = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_MAX_PASSIVE_ID)) || 0));
|
|
|
|
|
|
if (active <= 0 || passive <= 0) {
|
|
|
|
|
|
aiLog(`[AiMatchService] Const19/20 invalid, use DailyShop caps active=${active} passive=${passive} shopA=${fromShop.active} shopP=${fromShop.passive}`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
active: Math.max(1, fromShop.active),
|
|
|
|
|
|
passive: Math.max(1, fromShop.passive),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { active, passive };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 复制权重数组;长度不符仍截断/补 0 并 warn。
|
|
|
|
|
|
* @param raw 常量表数组
|
|
|
|
|
|
* @param expectLen 期望档位数
|
|
|
|
|
|
* @param label 日志标签
|
|
|
|
|
|
*/
|
|
|
|
|
|
private _copyWeights(raw: number[], expectLen: number, label: string): number[] | null {
|
|
|
|
|
|
const weights: number[] = [];
|
|
|
|
|
|
for (let i = 0; i < expectLen; i++) {
|
|
|
|
|
|
weights.push(Math.max(0, Math.floor(Number(raw[i]) || 0)));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw.length !== expectLen) {
|
|
|
|
|
|
aiLog(`[AiMatchService] ${label} weight length invalid len=${raw.length} expect=${expectLen}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (let i = 0; i < weights.length; i++) total += weights[i];
|
|
|
|
|
|
if (total <= 0) {
|
|
|
|
|
|
aiLog(`[AiMatchService] ${label} total weight <= 0`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return weights;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Const 15~18:连胜/连败修正 12/14 权重(不写回表)。
|
|
|
|
|
|
* @param activeWeights Const12 三档
|
|
|
|
|
|
* @param passiveWeights Const14 四档
|
|
|
|
|
|
* @param playerActive 玩家主动数
|
|
|
|
|
|
* @param playerPassive 玩家被动数
|
|
|
|
|
|
* @param caps 槽位上限
|
|
|
|
|
|
*/
|
|
|
|
|
|
private _applyStreakWeightBonus(activeWeights: number[], passiveWeights: number[], playerActive: number, playerPassive: number, caps: { active: number; passive: number }): void {
|
|
|
|
|
|
const winShares = MatchStreakStats.effectiveShares(MatchStreakStats.getWinStreak());
|
|
|
|
|
|
const loseShares = MatchStreakStats.effectiveShares(MatchStreakStats.getLoseStreak());
|
|
|
|
|
|
if (winShares > 0) {
|
|
|
|
|
|
const addA = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_WIN_ACTIVE_BONUS_ID)) || 0)) * winShares;
|
|
|
|
|
|
const addP = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_WIN_PASSIVE_BONUS_ID)) || 0)) * winShares;
|
|
|
|
|
|
// 主动:+1 档 index=2;已满上限则加到 0 档 index=1
|
|
|
|
|
|
const aIdx = playerActive >= caps.active ? 1 : 2;
|
|
|
|
|
|
activeWeights[aIdx] = (activeWeights[aIdx] || 0) + addA;
|
|
|
|
|
|
// 被动:+1 档 index=3;已满上限则加到 0 档 index=2
|
|
|
|
|
|
const pIdx = playerPassive >= caps.passive ? 2 : 3;
|
|
|
|
|
|
passiveWeights[pIdx] = (passiveWeights[pIdx] || 0) + addP;
|
|
|
|
|
|
aiOp(`连胜修正 shares=${winShares} active[+${addA}@${aIdx}] passive[+${addP}@${pIdx}]`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (loseShares > 0) {
|
|
|
|
|
|
const addA = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_LOSE_ACTIVE_BONUS_ID)) || 0)) * loseShares;
|
|
|
|
|
|
const addP = Math.max(0, Math.floor(Number(gg.data.table.getConst(CONST_LOSE_PASSIVE_BONUS_ID)) || 0)) * loseShares;
|
|
|
|
|
|
// 主动:-1 档 index=0;被动:-2 档 index=0
|
|
|
|
|
|
activeWeights[0] = (activeWeights[0] || 0) + addA;
|
|
|
|
|
|
passiveWeights[0] = (passiveWeights[0] || 0) + addP;
|
|
|
|
|
|
aiOp(`连败修正 shares=${loseShares} active[-1 +${addA}] passive[-2 +${addP}]`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 加权随机偏移;权重全 0 则返回 null */
|
|
|
|
|
|
private _rollOffset(weights: number[], offsets: number[], label: string): number | null {
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (let i = 0; i < weights.length; i++) total += Math.max(0, weights[i] || 0);
|
|
|
|
|
|
if (total <= 0) {
|
|
|
|
|
|
aiLog(`[AiMatchService] ${label} total weight <= 0`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
let roll = Math.random() * total;
|
|
|
|
|
|
for (let i = 0; i < offsets.length; i++) {
|
|
|
|
|
|
roll -= Math.max(0, weights[i] || 0);
|
|
|
|
|
|
if (roll < 0) return offsets[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
return offsets[offsets.length - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|