import { Weapon2TileType } from 'db://assets/mx/module/merge'; import { TableNames } from '../../game/ConfigTableData'; import { ConstNumType } from '../../game/GameDataEnums'; import { MatchStreakStats } from '../../manager/MatchStreakStats'; import { heroCharacterIds, isAttackGlyph, isComboHero } from '../config/BattleHeroTable'; import { formatIdList, formatWeapon2Brief, logBagRefreshDebug } from '../debug/BagRefreshDebugLog'; import type { BagRollEntry } from './BagWeaponPool'; import type { PlayerBagTextPool } from './PlayerBagTextPool'; import { WeaponComposeConfigProvider } from './WeaponComposeConfigProvider'; /** 新号首局保底神将(配表 Hero.name) */ const FIRST_BATTLE_HERO_NAMES = ['哪吒', '杨戬', '悟空']; /** * 玩家连败组合字保底。 * 地图碎字 + 本次已抽出的组合字满 4 个、且本局尚无完整组合英雄时, * 第 5 个及之后每个组合字按「连败次数 × Const30」判定;命中则换成已有碎字所缺的另一半。 * 凑齐任意一对(含未拼上的散字)后本局停止。 */ export class ComboLosePity { /** 本局已因完整组合英雄停止 */ private _stopped = false; /** herotype=2 且 text 长度>1 的英雄缓存 */ private _comboHeroes: ITableHero[] | null = null; /** 本局是否已停止 */ get stopped(): boolean { return this._stopped; } /** * 快照恢复停止标记。 * @param stopped 是否已停止 */ setStopped(stopped: boolean): void { this._stopped = !!stopped; } /** * 按抽取顺序尝试保底替换(就地改 slots)。 * @param slots 本次抽出的槽(抽取顺序,未打乱) * @param mapComboIds 玩家地图上的组合字 Text.id * @param pool 字池(替换时扣减/归还 remaining) */ applyToSlots(slots: BagRollEntry[], mapComboIds: readonly number[], pool: PlayerBagTextPool): void { if (this._stopped) { logBagRefreshDebug('连败保底: 跳过(本局已停止)'); return; } const owned: number[] = []; for (let i = 0; i < mapComboIds.length; i++) owned.push(mapComboIds[i]); if (this._hasCompleteHero(owned)) { this._stopped = true; logBagRefreshDebug('连败保底: 本局停止(地图已有完整组合英雄)', formatIdList(owned)); return; } for (let i = 0; i < slots.length; i++) { if (this._stopped) return; const id = slots[i].weapon2Id; if (!this._isCombo(id)) continue; owned.push(id); if (this._hasCompleteHero(owned)) { this._stopped = true; logBagRefreshDebug('连败保底: 本局停止(刷新凑齐完整英雄)', formatWeapon2Brief(id)); return; } owned.pop(); if (owned.length < 4) { owned.push(id); continue; } const hit = this._rollHit(); logBagRefreshDebug( '连败保底: 判定', `碎字=${owned.length}`, `连败=${MatchStreakStats.getLoseStreak()}`, hit ? '→ 命中' : '→ 未命中', ); if (!hit) { owned.push(id); continue; } const pick = this._consumeMissing(owned, pool); if (pick == null) { owned.push(id); continue; } if (!pool.tryRefundWeapon2Id(id)) { console.log('[ComboLosePity] 归还原抽字 remaining 失败', id); } slots[i] = { weapon2Id: pick }; this._stopped = true; logBagRefreshDebug( '连败保底: 替换', `${formatWeapon2Brief(id)} → ${formatWeapon2Brief(pick)}`, `候选源=${formatIdList(owned)}`, ); return; } } /** 当前字是否为组合碎字 */ private _isCombo(weapon2Id: number): boolean { return WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id) === Weapon2TileType.Combo; } /** 已有字是否凑齐任意多格组合英雄(含未拼上) */ private _hasCompleteHero(owned: readonly number[]): boolean { const heroes = this._comboHeroList(); for (let i = 0; i < heroes.length; i++) { if (this._ownsAll(owned, heroCharacterIds(heroes[i]))) return true; } return false; } /** * 从已有碎字所缺另一半中随机一个并扣池。 * @returns 成功扣减的 Text.id;无候选或 remaining 耗尽时 null */ private _consumeMissing(owned: readonly number[], pool: PlayerBagTextPool): number | null { const missing = this._collectMissing(owned); if (missing.length === 0) { console.log('[ComboLosePity] 碎字所缺另一半为空', owned.join(',')); return null; } this._shuffleIds(missing); for (let i = 0; i < missing.length; i++) { if (pool.tryConsumeWeapon2Id(missing[i])) return missing[i]; } console.log('[ComboLosePity] 所缺字 remaining 均耗尽', missing.join(',')); return null; } /** 已有碎字对应英雄中尚未持有的另一半(去重) */ private _collectMissing(owned: readonly number[]): number[] { const missing: number[] = []; const heroes = this._comboHeroList(); for (let i = 0; i < heroes.length; i++) { const chars = heroCharacterIds(heroes[i]); if (!this._ownsAny(owned, chars)) continue; for (let c = 0; c < chars.length; c++) { if (this._owns(owned, chars[c])) continue; this._addUnique(missing, chars[c]); } } return missing; } /** 连败次数 × Const30 万分比,上限 10000 */ private _rollHit(): boolean { const arr = gg.data.table.getConstArray(ConstNumType.LoseComboPityRate); const wan = Math.floor(Number(arr[0]) || 0); if (!(wan > 0)) { console.log('[ComboLosePity] Const30 概率无效', arr); return false; } const chance = Math.min(10000, MatchStreakStats.getLoseStreak() * wan); return Math.random() * 10000 < chance; } /** 组合神将列表 */ private _comboHeroList(): ITableHero[] { if (this._comboHeroes) return this._comboHeroes; const list = gg.data.table.getTableList(TableNames.Hero) ?? []; const out: ITableHero[] = []; for (let i = 0; i < list.length; i++) { const hero = list[i]; if (!isComboHero(hero)) continue; if (heroCharacterIds(hero).length <= 1) continue; out.push(hero); } this._comboHeroes = out; return out; } /** 是否持有序列中全部 id */ private _ownsAll(owned: readonly number[], chars: readonly number[]): boolean { for (let i = 0; i < chars.length; i++) { if (!this._owns(owned, chars[i])) return false; } return chars.length > 0; } /** 是否持有序列中任一 id */ private _ownsAny(owned: readonly number[], chars: readonly number[]): boolean { for (let i = 0; i < chars.length; i++) { if (this._owns(owned, chars[i])) return true; } return false; } /** 是否持有该 Text.id */ private _owns(owned: readonly number[], id: number): boolean { for (let i = 0; i < owned.length; i++) { if (owned[i] === id) return true; } return false; } /** * 去重追加。 * @param ids 目标数组 * @param id 待加入 id */ private _addUnique(ids: number[], id: number): void { for (let i = 0; i < ids.length; i++) { if (ids[i] === id) return; } ids.push(id); } /** * 打乱 id 列表。 * @param ids 待打乱数组 */ private _shuffleIds(ids: number[]): void { for (let i = ids.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = ids[i]; ids[i] = ids[j]; ids[j] = tmp; } } } /** * 新号第一局前两次刷新:先给哪吒/杨戬/悟空六字之一,再给能合成的另一半。 * 与是否拖到地图无关。仅玩家;GM 不走。 */ export class FirstBattleHeroPity { /** 本局是否启用 */ private readonly _enabled: boolean; /** 第二次要给的另一半 Text.id;0=无需再给 */ private _pending = 0; /** 三对神将字,扁平 [a,b, a,b, a,b] */ private _pairs: number[] | null = null; /** * @param enabled 开战时是否新号第一局 */ constructor(enabled: boolean) { this._enabled = enabled; } /** 第二次待给的另一半 */ get pendingPartner(): number { return this._pending; } /** * 快照恢复待给另一半。 * @param id Text.id;0 表示无需再给 */ setPendingPartner(id: number): void { this._pending = Math.floor(Number(id) || 0); } /** * 按本局第几次刷新就地改 slots。 * @param slots 本次抽出的槽 * @param refreshUsed 本局已成功普通刷新次数(本次尚未 +1) * @param pool 字池 */ applyToSlots(slots: BagRollEntry[], refreshUsed: number, pool: PlayerBagTextPool): void { if (!this._enabled) return; if (refreshUsed === 0) { this._ensureFirst(slots, pool); return; } if (refreshUsed === 1) this._ensureSecond(slots, pool); } /** 第一次:保证六字之一并记下另一半 */ private _ensureFirst(slots: BagRollEntry[], pool: PlayerBagTextPool): void { const pairs = this._heroPairs(); if (pairs.length < 2) { console.log('[FirstBattleHeroPity] 神将字对为空,跳过首次保底'); return; } if (this._hasCompletePair(slots, pairs)) { this._pending = 0; logBagRefreshDebug('首局神将: 首次已成对,第二次不补', formatIdList(this._slotIds(slots))); return; } const present = this._presentGlyphs(slots, pairs); if (present.length > 0) { const pick = present[Math.floor(Math.random() * present.length)]; this._pending = this._partnerOf(pick, pairs); logBagRefreshDebug( '首局神将: 首次已有六字', formatWeapon2Brief(pick), `下一半=${formatWeapon2Brief(this._pending)}`, ); return; } const glyphs = this._allGlyphs(pairs); const pick = glyphs[Math.floor(Math.random() * glyphs.length)]; if (!this._replaceOne(slots, pick, pool, true)) return; this._pending = this._partnerOf(pick, pairs); logBagRefreshDebug( '首局神将: 首次替换', formatWeapon2Brief(pick), `下一半=${formatWeapon2Brief(this._pending)}`, ); } /** 第二次:保证给记下的另一半 */ private _ensureSecond(slots: BagRollEntry[], pool: PlayerBagTextPool): void { if (this._pending <= 0) return; const want = this._pending; this._pending = 0; if (this._contains(slots, want)) { logBagRefreshDebug('首局神将: 第二次已抽出', formatWeapon2Brief(want)); return; } if (!this._replaceOne(slots, want, pool, false)) return; logBagRefreshDebug('首局神将: 第二次替换', formatWeapon2Brief(want)); } /** * 替换一槽为指定字:扣新字 remaining、归还原字。remaining 不足仍强制给字。 * @param keepAttacker true 时不替换本批唯一攻击字 */ private _replaceOne(slots: BagRollEntry[], newId: number, pool: PlayerBagTextPool, keepAttacker: boolean): boolean { const idx = this._pickReplaceIndex(slots, keepAttacker); if (idx < 0) { console.log('[FirstBattleHeroPity] 无槽可替换', newId); return false; } const oldId = slots[idx].weapon2Id; if (oldId === newId) return true; if (!pool.tryConsumeWeapon2Id(newId)) { console.log('[FirstBattleHeroPity] 字池 remaining 不足仍强制给字', newId); } if (!pool.tryRefundWeapon2Id(oldId)) { console.log('[FirstBattleHeroPity] 归还原抽字 remaining 失败', oldId); } slots[idx] = { weapon2Id: newId }; return true; } /** * 随机选替换下标。 * @param keepAttacker true 时跳过本批唯一攻击字 */ private _pickReplaceIndex(slots: BagRollEntry[], keepAttacker: boolean): number { let atkCount = 0; for (let i = 0; i < slots.length; i++) { if (isAttackGlyph(slots[i].weapon2Id)) atkCount++; } const cand: number[] = []; for (let i = 0; i < slots.length; i++) { if (keepAttacker && atkCount <= 1 && isAttackGlyph(slots[i].weapon2Id)) continue; cand.push(i); } if (cand.length <= 0) { console.log('[FirstBattleHeroPity] 无可替换槽', `atk=${atkCount}`, `n=${slots.length}`); return -1; } return cand[Math.floor(Math.random() * cand.length)]; } /** 读三对神将字;缺失 Hero 或非两字则告警跳过该将 */ private _heroPairs(): number[] { if (this._pairs) return this._pairs; const out: number[] = []; const list = gg.data.table.getTableList(TableNames.Hero) ?? []; for (let n = 0; n < FIRST_BATTLE_HERO_NAMES.length; n++) { const name = FIRST_BATTLE_HERO_NAMES[n]; let found: ITableHero | null = null; for (let i = 0; i < list.length; i++) { if (String(list[i].name ?? '') !== name) continue; found = list[i]; break; } if (!found) { console.log('[FirstBattleHeroPity] Hero 缺失', name); continue; } const chars = heroCharacterIds(found); if (chars.length !== 2) { console.log('[FirstBattleHeroPity] Hero.text 不是两字', name, chars.join(',')); continue; } const a = Math.floor(Number(chars[0]) || 0); const b = Math.floor(Number(chars[1]) || 0); if (!(a > 0) || !(b > 0)) { console.log('[FirstBattleHeroPity] Hero.text 非法', name, chars.join(',')); continue; } out.push(a, b); } this._pairs = out; return out; } /** 本批是否已含任一对的两个字 */ private _hasCompletePair(slots: readonly BagRollEntry[], pairs: readonly number[]): boolean { for (let i = 0; i + 1 < pairs.length; i += 2) { if (this._contains(slots, pairs[i]) && this._contains(slots, pairs[i + 1])) return true; } return false; } /** 本批已出现的六字(去重) */ private _presentGlyphs(slots: readonly BagRollEntry[], pairs: readonly number[]): number[] { const out: number[] = []; for (let i = 0; i < slots.length; i++) { const id = slots[i].weapon2Id; if (this._isGlyph(id, pairs)) this._addUnique(out, id); } return out; } /** 六字全集 */ private _allGlyphs(pairs: readonly number[]): number[] { const out: number[] = []; for (let i = 0; i < pairs.length; i++) this._addUnique(out, pairs[i]); return out; } /** 同一对里的另一半;找不到返回 0 */ private _partnerOf(id: number, pairs: readonly number[]): number { for (let i = 0; i + 1 < pairs.length; i += 2) { if (pairs[i] === id) return pairs[i + 1]; if (pairs[i + 1] === id) return pairs[i]; } console.log('[FirstBattleHeroPity] 无配对另一半', id); return 0; } /** id 是否属于保底六字 */ private _isGlyph(id: number, pairs: readonly number[]): boolean { for (let i = 0; i < pairs.length; i++) { if (pairs[i] === id) return true; } return false; } /** 本批是否含该 Text.id */ private _contains(slots: readonly BagRollEntry[], id: number): boolean { for (let i = 0; i < slots.length; i++) { if (slots[i].weapon2Id === id) return true; } return false; } /** 槽位 Text.id 列表(调试) */ private _slotIds(slots: readonly BagRollEntry[]): number[] { const ids: number[] = []; for (let i = 0; i < slots.length; i++) ids.push(slots[i].weapon2Id); return ids; } /** * 去重追加。 * @param ids 目标数组 * @param id 待加入 id */ private _addUnique(ids: number[], id: number): void { for (let i = 0; i < ids.length; i++) { if (ids[i] === id) return; } ids.push(id); } }