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.
456 lines
18 KiB
456 lines
18 KiB
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<ITableText>(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<ITableHero>(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<ITableText>(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;
|
|
}
|
|
|
|
/**
|
|
* 按 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<ITableText>(TableNames.Text, textIds[i]);
|
|
out.push(row ? Number(row.type) : 0);
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|