topfans/frontend/utils/laser-card/gacha.js
Lenticular Studio Agent 67cf3d4177 chore: 清理 laserCompositor 微服务残留
- 删除已弃用的 compositor_client.go
- 删除激光合成微服务代码
- 添加 gateway 合成控制器和测试文件
- 添加 Dify prompt 补丁脚本

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:44:03 +08:00

136 lines
3.8 KiB
JavaScript

/**
* 镭射卡加权抽卡 — 5 张不重复,稀有度档位加权 + 第 5 张保底
*
* 算法说明:
* 1. 单张稀有度按 RARITY_WEIGHTS (50/30/15/4/1) 独立采样
* 2. 同档位内按 style.weight 二次加权选 style
* 3. 5 张不重复(总池 45,远大于 5,无需去重惩罚)
* 4. 第 5 张保底:若前 4 张全是 N,强制抽 R+(R/SR/SSR/UR 等概率)
* 5. 纯 JS,无第三方依赖
*
* 使用:
* - drawGachaStyles(5) → 5 个 style 完整对象(含 grating_config + bg_prompt 等)
* - drawGachaStyleIds(5) → 仅 5 个 style ID
*
* 边界:
* - count <= 0 → 返回空数组
* - count > 池子大小 → 全部返回(顺序随机)
* - Math.random 无 NaN/Inf 风险
*/
import { LASER_STYLE_POOL } from './stylePool.js'
/** @typedef {('N'|'R'|'SR'|'SSR'|'UR')} Rarity */
/** 档位权重(总和 100,代表单次抽卡的稀有度分布百分比) */
export const RARITY_WEIGHTS = /** @type {Record<Rarity, number>} */ ({
N: 50,
R: 30,
SR: 15,
SSR: 4,
UR: 1,
})
/** 保底用:抽 R+ 时各档位等概率 */
const R_PLUS_BUCKETS = /** @type {Rarity[]} */ (['R', 'SR', 'SSR', 'UR'])
/**
* 加权随机选一个稀有度档位
* @returns {Rarity}
*/
export function pickRarity() {
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
let r = Math.random() * total
for (const [rarity, w] of Object.entries(RARITY_WEIGHTS)) {
r -= /** @type {number} */ (w)
if (r <= 0) return /** @type {Rarity} */ (rarity)
}
return 'N'
}
/**
* 在指定档位内按 style.weight 加权抽一个,且不与 exclude 重复
* @param {Rarity} rarity
* @param {Set<string>} exclude 已选 style id 集合
* @returns {import('./stylePool.js').LaserStyle | null}
*/
export function pickStyleInRarity(rarity, exclude) {
const candidates = LASER_STYLE_POOL.filter(
(s) => s.rarity === rarity && !exclude.has(s.id),
)
if (candidates.length === 0) return null
const sum = candidates.reduce((a, s) => a + (s.weight || 0), 0) || 1
let r = Math.random() * sum
for (const s of candidates) {
r -= s.weight || 0
if (r <= 0) return s
}
return candidates[candidates.length - 1]
}
/**
* 加权抽卡:5 张不重复,保底 ≥1 张 R+
* @param {number} [count=5] 抽卡数量
* @returns {Array<import('./stylePool.js').LaserStyle>}
*/
export function drawGachaStyles(count = 5) {
const n = Math.max(0, Math.floor(count))
if (n === 0) return []
if (n >= LASER_STYLE_POOL.length) {
// 数量超过池子大小 → 全部返回(洗牌)
const all = LASER_STYLE_POOL.slice()
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[all[i], all[j]] = [all[j], all[i]]
}
return all
}
const picked = /** @type {Array<import('./stylePool.js').LaserStyle>} */ ([])
const used = new Set()
for (let i = 0; i < n; i++) {
let rarity = pickRarity()
// 第 n 张(最后一张)保底:前 n-1 张全是 N → 强制 R+
if (i === n - 1 && picked.length > 0 && picked.every((s) => s.rarity === 'N')) {
rarity = R_PLUS_BUCKETS[Math.floor(Math.random() * R_PLUS_BUCKETS.length)]
}
let style = pickStyleInRarity(rarity, used)
// 同档位被抽空 → 降级到任意可用档位
if (!style) {
style = pickStyleInRarity('N', used)
if (!style) {
// 极端兜底:任意未选 style
style = LASER_STYLE_POOL.find((s) => !used.has(s.id)) || null
}
}
if (!style) break
picked.push(style)
used.add(style.id)
}
return picked
}
/**
* 便捷函数:只返回 ID 列表
* @param {number} [count=5]
* @returns {string[]}
*/
export function drawGachaStyleIds(count = 5) {
return drawGachaStyles(count).map((s) => s.id)
}
/**
* 工具:按 preset_id 反查 style(供调试 / 日志使用)
* @param {string} id
* @returns {import('./stylePool.js').LaserStyle | undefined}
*/
export function findStyleById(id) {
return LASER_STYLE_POOL.find((s) => s.id === id)
}