import { isElementGlyphType } from 'db://assets/mx/module/merge'; import { TableNames } from '../../game/ConfigTableData'; import { RankDataManager } from '../../manager/RankDataManager'; import { isAttackGlyph } from '../config/BattleHeroTable'; import { logBagRefreshDebug, logBagRefreshSection } from '../debug/BagRefreshDebugLog'; import type { BagRollEntry } from './BagWeaponPool'; /** Text.type 可入池类型(1-5:金木水火土全表;tolockland 格额外要求 type 1-5) */ const POOL_TYPES = [1, 2, 3, 4, 5]; /** number 档限量规则已废弃,剩余量统一用该值表示不限量(需兼容 |0 截断,取安全量级) */ const UNLIMITED_REMAINING = 999999; /** 局内字池建池参数 */ export type PlayerBagTextPoolBeginOptions = { /** 携带召神令被动:等概率抽 baseN∈[1,20] */ heroSummonRateActive?: boolean; /** 召神令 functionpar 去重叠加偏移 */ heroSummonRateOffset?: number; }; /** 局内字池快照 */ export interface PlayerBagTextPoolState { /** 本局 number 档(读 Text.numberN 用) */ numberN: number; /** 召神令:随机 base 档;无被动时为 0 */ baseNumberN: number; /** 召神令:functionpar 叠加偏移 */ heroSummonOffset: number; /** 候选 Text id */ textIds: number[]; /** 与 textIds 对齐的 Text.type */ types: number[]; /** 与 textIds 对齐的输出 weapon2Id(Text.id;旧快照缺失时用 textIds) */ heroIds?: number[]; /** 与 textIds 对齐的显示等级(Text.level + 段位加值;旧快照缺失时按 Text.level) */ levels?: number[]; /** 基础权重 */ weights: number[]; /** 剩余数量 */ remaining: number[]; /** 是否已建池 */ ready: boolean; } /** * 玩家局内背包字池。 * 按 Text.weight 加权随机,POOL_TYPES 内 weight>0 的行全部入池、不限量(number 档限量规则已废弃)。 */ export class PlayerBagTextPool { private _numberN = 0; private _baseNumberN = 0; private _heroSummonOffset = 0; private _textIds: number[] = []; private _types: number[] = []; private _heroIds: number[] = []; private _levels: number[] = []; private _weights: number[] = []; private _remaining: number[] = []; private _ready = false; /** 是否已开局建池 */ get ready(): boolean { return this._ready; } /** 本局选用的 number 档位 */ get numberN(): number { return this._numberN; } /** * 开战建池:默认从 Rank.charactersweight 等概率选 N; * 携带召神令被动时改为等概率抽 baseN∈[1,20],再叠加 functionpar 偏移读 Text.numberN。 * @param rankId 玩家当前段位 id * @param options 召神令被动建池参数 */ beginBattle(rankId?: number, options?: PlayerBagTextPoolBeginOptions): void { const heroSummonActive = options?.heroSummonRateActive === true; const heroSummonOffset = Math.max(0, Math.floor(Number(options?.heroSummonRateOffset) || 0)); this._baseNumberN = 0; this._heroSummonOffset = heroSummonActive ? heroSummonOffset : 0; let baseN = 0; if (heroSummonActive) { baseN = Math.floor(Math.random() * 20) + 1; this._baseNumberN = baseN; } else { const rid = rankId ?? RankDataManager.getPlayerRankId(); const rank = RankDataManager.getRankConfig(rid); if (!rank) { console.log(`[PlayerBagTextPool] Rank 缺失 rankId=${rid}`); this._ready = false; return; } const weights = rank.charactersweight; if (!weights?.length) { console.log(`[PlayerBagTextPool] charactersweight 为空 rankId=${rid}`); this._ready = false; return; } const pickIdx = Math.floor(Math.random() * weights.length); baseN = Math.floor(Number(weights[pickIdx])); if (!(baseN >= 1 && baseN <= 20)) { console.log(`[PlayerBagTextPool] 非法 number 档 N=${baseN} rankId=${rid}`); this._ready = false; return; } } const n = this._clampNumberN(baseN + this._heroSummonOffset); const rid = rankId ?? RankDataManager.getPlayerRankId(); const list = gg.data.table.getTableList(TableNames.Text) ?? []; this._textIds = []; this._types = []; this._heroIds = []; this._levels = []; this._weights = []; this._remaining = []; this._numberN = n; // 段位等级加值:Text.level + levelvalue = 显示等级;须有 Hero(herotype=type, level) 行才有编号可显示 const rid2 = rankId ?? RankDataManager.getPlayerRankId(); const levelValue = Math.max(0, Math.floor(Number(RankDataManager.getRankConfig(rid2)?.levelvalue) || 0)); const heroes = gg.data.table.getTableList(TableNames.Hero) ?? []; for (let i = 0; i < list.length; i++) { const row = list[i]; const type = Number(row.type); if (POOL_TYPES.indexOf(type) < 0) continue; const weight = Number(row.weight); if (!(weight > 0)) continue; const level = Math.floor(Number(row.level)) + levelValue; const hero = this._heroAt(heroes, type, level); if (!hero) continue; this._textIds.push(Number(row.id)); this._types.push(type); this._heroIds.push(hero.id); this._levels.push(level); this._weights.push(weight); this._remaining.push(UNLIMITED_REMAINING); } if (this._textIds.length === 0) { console.log(`[PlayerBagTextPool] 候选为空 rankId=${rid} N=${n} levelValue=${levelValue}`); this._ready = false; return; } this._ready = true; if (heroSummonActive) { logBagRefreshSection( `字池建池 rankId=${rid} 召神令 baseN=${this._baseNumberN} offset=${this._heroSummonOffset} N=${n} 候选=${this._textIds.length} levelValue=${levelValue}`, ); return; } logBagRefreshSection(`字池建池 rankId=${rid} N=${n} 候选=${this._textIds.length} levelValue=${levelValue}`); } /** 按 herotype=Text.type、level=Text.level+段位加值 查 Hero 行 */ private _heroAt(heroes: readonly ITableHero[], herotype: number, level: number): ITableHero | null { for (let i = 0; i < heroes.length; i++) { const h = heroes[i]; if (Math.floor(Number(h.herotype)) !== herotype) continue; if (Math.floor(Number(h.level)) !== level) continue; return h; } return null; } /** 钳制 Text.number 档到 [1,20] */ private _clampNumberN(n: number): number { const v = Math.floor(Number(n) || 0); if (v < 1) { console.log('[PlayerBagTextPool] numberN 低于 1,钳到 1', n); return 1; } if (v > 20) { console.log('[PlayerBagTextPool] numberN 超过 20,钳到 20', n); return 20; } return v; } /** 导出局内池状态(快照) */ exportState(): PlayerBagTextPoolState { return { numberN: this._numberN, baseNumberN: this._baseNumberN, heroSummonOffset: this._heroSummonOffset, textIds: this._textIds.slice(), types: this._types.slice(), heroIds: this._heroIds.slice(), levels: this._levels.slice(), weights: this._weights.slice(), remaining: this._remaining.slice(), ready: this._ready, }; } /** 从快照恢复局内池 */ importState(state: PlayerBagTextPoolState): void { this._numberN = state.numberN; this._baseNumberN = state.baseNumberN ?? 0; this._heroSummonOffset = state.heroSummonOffset ?? 0; this._textIds = state.textIds.slice(); this._types = state.types?.length === state.textIds.length ? state.types.slice() : this._typesFromTextIds(state.textIds); this._heroIds = state.heroIds?.length === state.textIds.length ? state.heroIds.slice() : state.textIds.slice(); this._levels = state.levels?.length === state.textIds.length ? state.levels.slice() : this._levelsFromTextIds(state.textIds); this._weights = state.weights.slice(); this._remaining = state.remaining.slice(); this._ready = state.ready; } /** 兜底:按 Text 表行补 levels */ private _levelsFromTextIds(textIds: readonly number[]): number[] { const out: number[] = []; for (let i = 0; i < textIds.length; i++) { const row = gg.data.table.getTableData(TableNames.Text, textIds[i]); out.push(row ? Math.floor(Number(row.level)) : 1); } return out; } /** * 文昌笔:按召唤字池加权抽取 1 个五行字(扣减 remaining)。 * @param excludeWeapon2Id 尽量排除该字;池内无其它候选时返回 null */ rollSingleGlyph(excludeWeapon2Id?: number): BagRollEntry | null { if (!this._ready) { console.log('[PlayerBagTextPool] 未建池不可抽取'); return null; } const idx = this._pickGlyphIndexExcluding(excludeWeapon2Id); if (idx == null) return null; this._remaining[idx] -= 1; const entry: BagRollEntry = { weapon2Id: this._textIds[idx], level: this._levels[idx] }; logBagRefreshDebug('字池单抽', String(entry.weapon2Id)); return entry; } /** 是否还能抽到与 exclude 不同的五行字(不扣减 remaining) */ canRollSingleGlyph(excludeWeapon2Id?: number): boolean { if (!this._ready) return false; return this._pickGlyphIndexExcluding(excludeWeapon2Id) != null; } /** * 属性卡:同显示等级、不同 Text.type 的字(不扣减字池)。 * @param currentWeapon2Id 当前字 * @param level 显示等级 */ pickAttrSwap(currentWeapon2Id: number, level: number): BagRollEntry | null { const curId = Math.floor(Number(currentWeapon2Id) || 0); const lv = Math.floor(Number(level) || 0); if (curId <= 0 || lv <= 0) return null; let curType = 0; for (let i = 0; i < this._textIds.length; i++) { if (this._textIds[i] !== curId && this._heroIds[i] !== curId) continue; curType = this._types[i]; break; } if (curType < 1 || curType > 5) { const list = gg.data.table.getTableList(TableNames.Text) ?? []; for (let i = 0; i < list.length; i++) { if (Number(list[i].id) !== curId) continue; curType = Math.floor(Number(list[i].type) || 0); break; } } if (curType < 1 || curType > 5) { console.log('[PlayerBagTextPool] 属性卡无法解析当前 type', curId); return null; } const candidates: number[] = []; for (let i = 0; i < this._textIds.length; i++) { if (this._types[i] === curType) continue; if (this._levels[i] !== lv) continue; const id = this._textIds[i]; if (id > 0) candidates.push(id); } if (candidates.length <= 0) { console.log('[PlayerBagTextPool] 属性卡无同级异属性候选', lv, curType); return null; } const pick = candidates[Math.floor(Math.random() * candidates.length)]; return { weapon2Id: pick, level: lv }; } /** * 按 id 扣减 1 次 remaining(GM/预设刷新等非 rollSlots 路径)。 * @param weapon2Id Text.id 或池输出的 Hero.id * @returns 是否命中字池并成功扣减 */ tryConsumeWeapon2Id(weapon2Id: number): boolean { if (!this._ready) return false; const id = Math.floor(Number(weapon2Id) || 0); if (id <= 0) return false; for (let i = 0; i < this._textIds.length; i++) { if (this._textIds[i] !== id && this._heroIds[i] !== id) continue; if (this._remaining[i] <= 0) return false; this._remaining[i] -= 1; return true; } return false; } /** * 按 id 归还 1 次 remaining(保底替换退还原抽中字)。 * @param weapon2Id Text.id 或池输出的 Hero.id * @returns 是否命中字池并成功归还 */ tryRefundWeapon2Id(weapon2Id: number): boolean { if (!this._ready) return false; const id = Math.floor(Number(weapon2Id) || 0); if (id <= 0) return false; for (let i = 0; i < this._textIds.length; i++) { if (this._textIds[i] !== id && this._heroIds[i] !== id) continue; this._remaining[i] += 1; return true; } return false; } /** * 打乱抽取结果(首次刷新保底攻击字后打乱槽位)。 * @param list 待打乱条目 */ shuffleEntries(list: BagRollEntry[]): void { this._shuffleEntries(list); } /** 是否还能抽满 count 个字(不扣减) */ canRollSlots(count: number): boolean { if (!this._ready || count <= 0) return false; let avail = 0; for (let i = 0; i < this._remaining.length; i++) { avail += Math.max(0, this._remaining[i] | 0); if (avail >= count) return true; } return false; } /** * 加权抽取 count 个字并扣减剩余量。 * @param count 槽数 * @param ensureAttacker 为 true 时至少抽 1 个可攻击单字 * @param shuffle 首次攻击字保底后是否打乱;连败保底需按抽取顺序处理时传 false * @returns 成功条目;候选不足时不扣减并返回 null */ rollSlots(count: number, ensureAttacker = false, shuffle = true): BagRollEntry[] | null { if (!this._ready) { console.log('[PlayerBagTextPool] 未建池不可抽取'); return null; } const pickedIdx: number[] = []; const snapshot = this._remaining.slice(); if (ensureAttacker) { const atk = this._pickAttackerIndex(); if (atk == null) { console.log('[PlayerBagTextPool] 字池无攻击字,首次刷新无法保底'); } else { this._remaining[atk] -= 1; pickedIdx.push(atk); } } while (pickedIdx.length < count) { const idx = this._pickIndex(); if (idx == null) { this._remaining = snapshot; console.log(`[PlayerBagTextPool] 候选耗尽,需要 ${count} 实际不可抽满`); return null; } this._remaining[idx] -= 1; pickedIdx.push(idx); } const result: BagRollEntry[] = []; for (let i = 0; i < pickedIdx.length; i++) { result.push({ weapon2Id: this._textIds[pickedIdx[i]], level: this._levels[pickedIdx[i]] }); } if (shuffle && ensureAttacker) this._shuffleEntries(result); logBagRefreshDebug('字池抽取', result.map((e) => e.weapon2Id).join(',')); return result; } /** 按当前 remaining 加权选下标;无候选返回 null(不改状态) */ private _pickIndex(): number | null { return this._pickIndexExcluding(undefined); } /** * 开格随机:normalland 用全池,tolockland 只收 Text.type 1-5。 * @param gate true 为 tolockland 候选 * @returns weapon2Id(Text.id)与显示等级;无候选返回 null */ rollOneForLand(gate: boolean): BagRollEntry | null { if (!this._ready) return null; const idx = this._pickIndexFiltered(undefined, false, gate ? 5 : 0); if (idx == null) return null; this._remaining[idx] -= 1; return { weapon2Id: this._textIds[idx], level: this._levels[idx] }; } /** 只从可攻击单字里加权选 */ private _pickAttackerIndex(): number | null { let total = 0; for (let i = 0; i < this._textIds.length; i++) { const rem = this._remaining[i]; if (rem <= 0 || !isAttackGlyph(this._textIds[i])) continue; total += this._weights[i] * rem; } if (!(total > 0)) return null; let r = Math.random() * total; for (let i = 0; i < this._textIds.length; i++) { const rem = this._remaining[i]; if (rem <= 0 || !isAttackGlyph(this._textIds[i])) continue; r -= this._weights[i] * rem; if (r < 0) return i; } for (let i = this._textIds.length - 1; i >= 0; i--) { if (this._remaining[i] <= 0 || !isAttackGlyph(this._textIds[i])) continue; return i; } return null; } /** 打乱抽取结果 */ private _shuffleEntries(list: BagRollEntry[]): void { for (let i = list.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = list[i]; list[i] = list[j]; list[j] = tmp; } } /** 加权选下标,可排除指定 weapon2Id */ private _pickIndexExcluding(excludeWeapon2Id?: number): number | null { return this._pickIndexFiltered(excludeWeapon2Id, false, 0); } /** 文昌笔:仅五行字加权选下标 */ private _pickGlyphIndexExcluding(excludeWeapon2Id?: number): number | null { return this._pickIndexFiltered(excludeWeapon2Id, true, 0); } /** * 加权选下标。 * @param excludeWeapon2Id 排除 id(匹配 Text.id 或输出 Hero.id) * @param glyphsOnly true 时跳过非改字类型 * @param maxType Text.type 上限;0 不限(tolockland 候选传 5) */ private _pickIndexFiltered(excludeWeapon2Id: number | undefined, glyphsOnly: boolean, maxType: number): number | null { let total = 0; for (let i = 0; i < this._textIds.length; i++) { if (!this._isPickable(i, excludeWeapon2Id, glyphsOnly, maxType)) continue; total += this._weights[i] * this._remaining[i]; } if (!(total > 0)) return null; let r = Math.random() * total; for (let i = 0; i < this._textIds.length; i++) { if (!this._isPickable(i, excludeWeapon2Id, glyphsOnly, maxType)) continue; r -= this._weights[i] * this._remaining[i]; if (r < 0) return i; } for (let i = this._textIds.length - 1; i >= 0; i--) { if (!this._isPickable(i, excludeWeapon2Id, glyphsOnly, maxType)) continue; return i; } return null; } /** 候选是否可参与本次加权抽取 */ private _isPickable(index: number, excludeWeapon2Id: number | undefined, glyphsOnly: boolean, maxType: number): boolean { if (this._remaining[index] <= 0) return false; if (excludeWeapon2Id != null && this._textIds[index] === excludeWeapon2Id) return false; if (excludeWeapon2Id != null && this._heroIds[index] === excludeWeapon2Id) return false; if (glyphsOnly && !isElementGlyphType(this._types[index])) return false; if (maxType > 0 && this._types[index] > maxType) return false; return true; } /** 旧快照无 types 时按 Text 表回填 */ private _typesFromTextIds(textIds: readonly number[]): number[] { const out: number[] = []; for (let i = 0; i < textIds.length; i++) { const row = gg.data.table.getTableData(TableNames.Text, textIds[i]); out.push(row ? Number(row.type) : 0); } return out; } }