import { Weapon2TileType } 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:基础字 / 组合字 / 铲子可入池 */ const POOL_TYPES = [1, 2, 4]; /** 文昌笔改字:仅单字 / 组合字(禁止抽到铲子落地图变永久铲) */ const REWRITE_GLYPH_TYPES = [Weapon2TileType.Single, Weapon2TileType.Combo]; /** 局内字池建池参数 */ 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[]; /** 基础权重 */ weights: number[]; /** 剩余数量 */ remaining: number[]; /** 是否已建池 */ ready: boolean; } /** * 玩家局内背包字池。 * 开局按段位选 numberN 档,整局累计扣减;最终权重 = weight × remaining。 */ export class PlayerBagTextPool { private _numberN = 0; private _baseNumberN = 0; private _heroSummonOffset = 0; private _textIds: number[] = []; private _types: 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._weights = []; this._remaining = []; this._numberN = n; 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 cap = this._readNumberN(row, n); if (!(cap > 0)) continue; this._textIds.push(Number(row.id)); this._types.push(type); this._weights.push(weight); this._remaining.push(cap); } if (this._textIds.length === 0) { console.log(`[PlayerBagTextPool] 候选为空 rankId=${rid} N=${n}`); this._ready = false; return; } this._ready = true; if (heroSummonActive) { logBagRefreshSection( `字池建池 rankId=${rid} 召神令 baseN=${this._baseNumberN} offset=${this._heroSummonOffset} N=${n} 候选=${this._textIds.length}`, ); return; } logBagRefreshSection(`字池建池 rankId=${rid} N=${n} 候选=${this._textIds.length}`); } /** 钳制 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(), 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._weights = state.weights.slice(); this._remaining = state.remaining.slice(); this._ready = state.ready; } /** * 文昌笔:按召唤字池加权抽取 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] }; logBagRefreshDebug('字池单抽', String(entry.weapon2Id)); return entry; } /** 是否还能抽到与 exclude 不同的单字/组合字(不扣减 remaining) */ canRollSingleGlyph(excludeWeapon2Id?: number): boolean { if (!this._ready) return false; return this._pickGlyphIndexExcluding(excludeWeapon2Id) != null; } /** * 按 Text.id 扣减 1 次 remaining(GM/预设刷新等非 rollSlots 路径)。 * @param weapon2Id Text.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) continue; if (this._remaining[i] <= 0) return false; this._remaining[i] -= 1; return true; } return false; } /** * 按 Text.id 归还 1 次 remaining(保底替换退还原抽中字)。 * @param weapon2Id Text.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) 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]] }); } 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); } /** 只从可攻击单字里加权选 */ 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); } /** 文昌笔:仅单字/组合字加权选下标 */ private _pickGlyphIndexExcluding(excludeWeapon2Id?: number): number | null { return this._pickIndexFiltered(excludeWeapon2Id, true); } /** * 加权选下标。 * @param excludeWeapon2Id 排除 id * @param glyphsOnly true 时跳过铲子等非改字类型 */ private _pickIndexFiltered(excludeWeapon2Id: number | undefined, glyphsOnly: boolean): number | null { let total = 0; for (let i = 0; i < this._textIds.length; i++) { if (!this._isPickable(i, excludeWeapon2Id, glyphsOnly)) 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)) 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)) continue; return i; } return null; } /** 候选是否可参与本次加权抽取 */ private _isPickable(index: number, excludeWeapon2Id: number | undefined, glyphsOnly: boolean): boolean { if (this._remaining[index] <= 0) return false; if (excludeWeapon2Id != null && this._textIds[index] === excludeWeapon2Id) return false; if (glyphsOnly && REWRITE_GLYPH_TYPES.indexOf(this._types[index]) < 0) 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; } /** 读取 Text.numberN */ private _readNumberN(row: ITableText, n: number): number { const key = `number${n}` as keyof ITableText; const raw = row[key]; const v = Number(raw); return Number.isFinite(v) ? v : 0; } }