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; 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, 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(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; } }