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.
 
 
 
 
AdLike2048/assets/script/battle/bag/BagWeaponPool.ts

463 lines
18 KiB

import { Weapon2TileType, isElementGlyphType } from 'db://assets/mx/module/merge';
import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, membershipToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
import {
formatBagRollEntries,
formatWeapon2Brief,
formatIdList,
logBagRefreshDebug,
} from '../debug/BagRefreshDebugLog';
import {
applyBagPitySlotResult,
isEarlyChapterGridScope,
loadBagPitySlotContext,
parseEarlyChapterGridConfig,
resolveGridBaseWeightForChapter,
rollBagPitySlot,
saveBagPityPersistedState,
} from './BagPityAlgorithm';
import { WeaponComposeConfigProvider } from './WeaponComposeConfigProvider';
import { textDisplayName, textIconName } from '../config/BattleHeroTable';
import { applyBagRefreshWeightBonus } from '../skill/GlobalSkillEffects';
import { isChapter1NormalOnly } from './BagPrepConfig';
/** 高阶刷新:1/2/3 级文字权重(常量表无此行,局内固定) */
const AD_BAG_REFRESH_LEVEL_WEIGHTS = '5,3,1';
export type BagRollEntry = { weapon2Id: number; weapon1Id?: number; level?: number };
type WeightedEntry = { weight: number; entry: BagRollEntry };
/** 地图已摆放未激活字的刷新降权(Const_weight 1404) */
export type BagRefreshPoolOptions = {
mapPlacedUncomposedIds?: ReadonlyMembershipSet<number>;
mapPlacedPenaltyWan?: number;
};
/**
* 解析 GM 输入为 Weapon2.id 列表。
* 支持:id 逗号分隔(11,12)、名称(悟,空)、资源名;空串或 0 表示关闭。
*/
export function parseGmBagWeapon2Filter(raw: string): number[] | null {
const trimmed = raw.trim();
if (!trimmed || trimmed === '0') return null;
const config = WeaponComposeConfigProvider.instance;
config.load();
const all = config.getAllWeapon2Configs();
const ids: number[] = [];
for (const part of trimmed.split(/[,|,\s]+/)) {
const token = part.trim();
if (!token) continue;
const num = Number(token);
if (Number.isFinite(num) && num > 0) {
if (all.some((w) => Number(w.id) === num)) ids.push(num);
continue;
}
const row = all.find((w) => {
const icon = textIconName(w);
const group = w.group?.trim();
return icon === token || group === token || textDisplayName(w) === token;
});
if (row) ids.push(Number(row.id));
}
if (ids.length === 0) return null;
return membershipDedupeArray(ids);
}
/** 背包刷新:基础权重来自 Weapon2.weight(unlock 过滤);GM 可指定仅刷某些字 */
export class BagWeaponPool {
private readonly _config = WeaponComposeConfigProvider.instance;
rollSlots(
count: number,
chapterId: number,
gmWeapon2Ids?: number[] | null,
forcedWeapon2Ids?: number[] | null,
/** 仅第一章引导首次刷新:保持 ConstConf 配表顺序 */
preserveForcedOrder = false,
/** PopGM 预设 Weapon2:独占槽位前缀,顺序固定 */
gmPresetWeapon2Ids?: number[] | null,
poolOptions?: BagRefreshPoolOptions,
): BagRollEntry[] {
this._config.load();
const fixed = gmWeapon2Ids?.length ? gmWeapon2Ids : null;
if (fixed) {
const result = this._rollFixedSlots(count, fixed);
logBagRefreshDebug('基础roll: GM固定池', formatBagRollEntries(result, this._config));
return result;
}
const pool = this._buildBasePool(chapterId, poolOptions);
const result: BagRollEntry[] = [];
let presetFilled = 0;
if (gmPresetWeapon2Ids?.length) {
for (const id of gmPresetWeapon2Ids) {
if (result.length >= count) break;
const row = this._config.getWeapon2Config(id);
if (!row) continue;
result.push(this._entryFromWeapon2Row(row));
}
presetFilled = result.length;
logBagRefreshDebug('基础roll: GM预设前缀', formatBagRollEntries(result, this._config));
}
if (pool.length === 0 && result.length === 0) {
logBagRefreshDebug('基础roll: 候选池为空');
return [];
}
let forcedFilled = 0;
if (forcedWeapon2Ids?.length) {
for (const id of forcedWeapon2Ids) {
if (result.length >= count) break;
const row = this._config.getWeapon2Config(id);
if (!row) continue;
result.push(this._entryFromWeapon2Row(row));
}
forcedFilled = result.length - presetFilled;
if (forcedFilled > 0) {
logBagRefreshDebug('基础roll: forced前置', formatBagRollEntries(result.slice(presetFilled), this._config));
}
}
let usedRandomFill = false;
while (result.length < count && pool.length > 0) {
usedRandomFill = true;
const pick = this._weightedPick(pool);
if (!pick) break;
result.push(pick);
}
if (gmPresetWeapon2Ids?.length) {
const head = result.slice(0, presetFilled);
const tail = usedRandomFill ? this._shuffle(result.slice(presetFilled)) : result.slice(presetFilled);
const out = [...head, ...tail].slice(0, count);
logBagRefreshDebug('基础roll: 保持GM预设顺序', formatBagRollEntries(out, this._config));
return out;
}
if (preserveForcedOrder && forcedWeapon2Ids?.length) {
const head = result.slice(0, presetFilled + forcedFilled);
const tail = usedRandomFill ? this._shuffle(result.slice(presetFilled + forcedFilled)) : result.slice(presetFilled + forcedFilled);
const out = [...head, ...tail].slice(0, count);
logBagRefreshDebug('基础roll: 保持forced顺序', formatBagRollEntries(out, this._config));
return out;
}
const out = forcedWeapon2Ids?.length ? this._shuffle(result) : result;
logBagRefreshDebug(
'基础roll: 完成',
formatBagRollEntries(out, this._config),
forcedWeapon2Ids?.length ? '(已打乱)' : '',
);
return out;
}
/**
* P4 最终保底:按槽位顺序逐格 roll,脸黑计数持久化,脸白限制同次刷新内生效。
* forced 项按顺序优先填入前若干槽,再对剩余槽逐格随机。
*/
rollSlotsWithPity(
count: number,
chapterId: number,
forcedWeapon2Ids?: number[] | null,
preserveForcedOrder = false,
poolOptions?: BagRefreshPoolOptions,
): BagRollEntry[] {
this._config.load();
const basePool = this._buildBasePool(chapterId, poolOptions);
if (basePool.length === 0) {
logBagRefreshDebug('P4最终保底: 候选池为空');
return [];
}
const ctx = loadBagPitySlotContext(chapterId);
logBagRefreshDebug(
'P4最终保底: 入参',
`格子计数=${ctx.persisted.notGridCount}`,
`五行计数=${ctx.persisted.notElementGlyphCount}`,
`| 格子配置 ${ctx.gridPity.startAddWeightAt},${ctx.gridPity.weightAddStep},${ctx.gridPity.guaranteedAt}`,
`| 五行配置 ${ctx.elementPity.startAddWeightAt},${ctx.elementPity.weightAddStep},${ctx.elementPity.guaranteedAt}`,
`| 脸白间隔 格子=${ctx.gridInterval} 五行=${ctx.elementInterval}`,
);
if (forcedWeapon2Ids?.length) {
logBagRefreshDebug('P4最终保底: forced队列', formatBagRollEntries(
forcedWeapon2Ids.map((id) => ({ weapon2Id: id })),
this._config,
));
}
const forcedQueue = forcedWeapon2Ids?.length ? [...forcedWeapon2Ids] : [];
const result: BagRollEntry[] = [];
for (let i = 0; i < count; i++) {
let entry: BagRollEntry | null = null;
if (forcedQueue.length > 0) {
const forcedId = forcedQueue.shift()!;
const row = this._config.getWeapon2Config(forcedId);
if (row) {
entry = this._entryFromWeapon2Row(row);
logBagRefreshDebug(`P4槽${i} forced → ${formatWeapon2Brief(entry.weapon2Id, this._config)}`);
}
}
if (!entry) {
entry = rollBagPitySlot(ctx, basePool, this._config, (pool) => this._weightedPick(pool), i);
}
if (!entry) break;
result.push(entry);
applyBagPitySlotResult(ctx, entry.weapon2Id, this._config, i);
}
saveBagPityPersistedState(ctx.persisted);
logBagRefreshDebug(
'P4最终保底: 持久计数出',
`格子=${ctx.persisted.notGridCount}`,
`五行=${ctx.persisted.notElementGlyphCount}`,
);
if (preserveForcedOrder && forcedWeapon2Ids?.length && result.length === forcedWeapon2Ids.length) {
const out = result.slice(0, count);
logBagRefreshDebug('P4最终保底: 保持顺序', formatBagRollEntries(out, this._config));
return out;
}
const rollOrder = [...result];
const out = forcedWeapon2Ids?.length ? this._shuffle(result) : result;
logBagRefreshDebug('P4最终保底: roll顺序', formatBagRollEntries(rollOrder, this._config));
if (forcedWeapon2Ids?.length) {
logBagRefreshDebug('P4最终保底: 打乱后', formatBagRollEntries(out, this._config));
}
return out;
}
/**
* 高阶刷新:
* 1)必出 1 个场上没有的五行字(Text.type 1–5)
* 2)其余位按正常权重随机
* 3)抽到的常规字(Single)按常量表 45 权重滚等级
* 4)打乱顺序后返回
*/
rollAdSlots(
count: number,
chapterId: number,
mapWeapon2Ids: ReadonlyMembershipSet<number>,
gmWeapon2Ids?: number[] | null,
forcedPresetWeapon2Ids?: number[] | null,
poolOptions?: BagRefreshPoolOptions,
): BagRollEntry[] {
this._config.load();
if (gmWeapon2Ids?.length) {
const result = this._shuffle(this._rollFixedSlots(count, gmWeapon2Ids));
logBagRefreshDebug('高阶刷新: GM固定池', formatBagRollEntries(result, this._config));
return result;
}
const pool = this._buildBasePool(chapterId, poolOptions);
const result: BagRollEntry[] = [];
if (forcedPresetWeapon2Ids?.length) {
for (const id of forcedPresetWeapon2Ids) {
const row = this._config.getWeapon2Config(id);
if (row) result.push(this._entryFromWeapon2Row(row));
}
logBagRefreshDebug('高阶刷新: GM预设前置', formatBagRollEntries(result, this._config));
}
if (result.length >= count) {
return result.slice(0, count);
}
if (pool.length === 0) {
logBagRefreshDebug('高阶刷新: 候选池为空');
return result;
}
const missingGlyphs = pool.filter(
(p) =>
isElementGlyphType(this._config.getWeapon2Type(p.entry.weapon2Id))
&& !mapWeapon2Ids.has(p.entry.weapon2Id),
);
const allGlyphs = pool.filter(
(p) => isElementGlyphType(this._config.getWeapon2Type(p.entry.weapon2Id)),
);
let guaranteed = this._weightedPick(missingGlyphs);
let guaranteedSource = '场上缺失五行字池';
if (!guaranteed) {
guaranteed = this._weightedPick(allGlyphs) ?? this._weightedPick(pool);
guaranteedSource = guaranteed
? (allGlyphs.length > 0 ? '全五行字池/总池回退' : '总池回退')
: '无';
}
if (!guaranteed) {
logBagRefreshDebug('高阶刷新: 无法抽出保底五行字');
return result;
}
logBagRefreshDebug(
'高阶刷新: 保底五行字',
formatWeapon2Brief(guaranteed.weapon2Id, this._config),
`来源=${guaranteedSource}`,
`场上已有=${membershipToArray(mapWeapon2Ids).join(',')}`,
);
result.push(this._applyAdRefreshLevel(guaranteed));
for (let i = result.length; i < count; i++) {
const pick = this._weightedPick(pool);
if (!pick) break;
result.push(this._applyAdRefreshLevel(pick));
}
const out = forcedPresetWeapon2Ids?.length ? result : this._shuffle(result);
logBagRefreshDebug('高阶刷新: 完成', formatBagRollEntries(out, this._config));
return out;
}
private _rollFixedSlots(count: number, weapon2Ids: number[]): BagRollEntry[] {
const pool: BagRollEntry[] = [];
for (const id of weapon2Ids) {
const row = this._config.getAllWeapon2Configs().find((w) => w.id === id);
if (!row) continue;
pool.push(this._entryFromWeapon2Row(row));
}
if (pool.length === 0) return [];
const result: BagRollEntry[] = [];
for (let i = 0; i < count; i++) {
const pick = pool[Math.floor(Math.random() * pool.length)];
result.push({ ...pick });
}
return result;
}
private _entryFromWeapon2Row(row: ITableText): BagRollEntry {
const weapon2Id = Number(row.id);
const entry: BagRollEntry = { weapon2Id, level: 1 };
if (Number(row.type) === Weapon2TileType.Single) {
const weapon1Id = this._config.getWeapon1ByGroupSequence([weapon2Id]);
if (weapon1Id != null) {
entry.weapon1Id = weapon1Id;
}
}
return entry;
}
/** 五行字按 const 45 权重赋等级;格子保持 1 级 */
private _applyAdRefreshLevel(entry: BagRollEntry): BagRollEntry {
const out = { ...entry };
if (!isElementGlyphType(this._config.getWeapon2Type(out.weapon2Id))) {
out.level = 1;
return out;
}
out.level = this._rollAdRefreshLevel();
return out;
}
private _rollAdRefreshLevel(): number {
const raw = AD_BAG_REFRESH_LEVEL_WEIGHTS;
const parts = raw.split(',').map((s) => Number(s.trim()));
const weights = [
Number.isFinite(parts[0]) && parts[0] >= 0 ? parts[0] : 5,
Number.isFinite(parts[1]) && parts[1] >= 0 ? parts[1] : 3,
Number.isFinite(parts[2]) && parts[2] >= 0 ? parts[2] : 1,
];
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return 1;
let r = Math.random() * total;
for (let i = 0; i < weights.length; i++) {
r -= weights[i];
if (r <= 0) return i + 1;
}
return 3;
}
/** 基础池:Weapon2 表 weight + unlock;全局词条加成;地图未激活字降权 */
private _buildBasePool(chapterId: number, poolOptions?: BagRefreshPoolOptions): WeightedEntry[] {
const pool: WeightedEntry[] = [];
const skills = gg.game.localPlayer?.selectedSkills ?? [];
const penaltyWan = poolOptions?.mapPlacedPenaltyWan ?? 0;
const penalizedIds = poolOptions?.mapPlacedUncomposedIds;
let gridBase = 0;
let gridFinal = 0;
let totalFinal = 0;
let mapPenaltyHits = 0;
const earlyGridCfg = isEarlyChapterGridScope(chapterId) ? parseEarlyChapterGridConfig() : null;
if (earlyGridCfg) {
logBagRefreshDebug(
'前X章格子配置',
`章节≤${earlyGridCfg.chapterLimit}`,
`格子权重=${earlyGridCfg.gridWeight}`,
'保底1405/脸白1406',
);
}
for (const row of this._config.getWeapon2RefreshCandidates(chapterId)) {
const tileType = Number(row.type);
const weapon2Id = Number(row.id);
let base = Number(row.weight);
if (tileType === Weapon2TileType.Grid) {
base = resolveGridBaseWeightForChapter(chapterId, base);
}
let weight = applyBagRefreshWeightBonus(base, tileType, weapon2Id, skills);
if (penaltyWan > 0 && penalizedIds?.has(weapon2Id)) {
weight *= Math.max(0, 1 - penaltyWan / 10000);
mapPenaltyHits++;
}
if (tileType === Weapon2TileType.Grid) {
gridBase += base;
gridFinal += weight;
}
totalFinal += weight;
pool.push({ weight, entry: this._entryFromWeapon2Row(row) });
}
if (mapPenaltyHits > 0) {
logBagRefreshDebug(
'地图未激活字降权',
`命中${mapPenaltyHits}种`,
`降低${penaltyWan}/10000`,
formatIdList(membershipToArray(penalizedIds ?? createMembershipSet())),
);
}
if (skills.length > 0 && totalFinal > 0) {
const share = ((gridFinal / totalFinal) * 100).toFixed(2);
logBagRefreshDebug(
'全局词条权重池',
`格子 ${gridBase}→${gridFinal}`,
`池总权重=${totalFinal.toFixed(0)}`,
`格子占比≈${share}%`,
);
}
return pool;
}
private _weightedPick(pool: WeightedEntry[]): BagRollEntry | null {
let total = 0;
for (const p of pool) total += p.weight;
if (total <= 0) return null;
let r = Math.random() * total;
for (const p of pool) {
r -= p.weight;
if (r <= 0) return { ...p.entry };
}
return { ...pool[pool.length - 1].entry };
}
private _shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
}