export const BOSS_CARD_RARITIES = ["normal", "rare", "epic", "legendary"] as const; export type BossCardRarity = (typeof BOSS_CARD_RARITIES)[number]; export const BOSS_CARD_RARITY_INFO = { normal: { label: "Normal", chance: 1, odds: "100%", color: "#d8d5cb" }, rare: { label: "Rare", chance: 1 / 10, odds: "1 in 10", color: "#69b4ff" }, epic: { label: "Epic", chance: 1 / 50, odds: "1 in 50", color: "#c493ff" }, legendary: { label: "Legendary", chance: 1 / 100, odds: "1 in 100", color: "#ffb45b" }, } as const; export type BossCardCounts = Partial>; export interface BossCardCollection { readonly counts: Readonly>; /** Last reward per spawned boss prevents shared-world replay and reconnect duplicates. */ readonly lastClaims: Readonly>; } export interface BossCardDrop { readonly bossKey: string; readonly spawnId: string; readonly claimId: string; readonly rarities: readonly BossCardRarity[]; } export function emptyBossCardCollection(): BossCardCollection { return { counts: {}, lastClaims: {} }; } export function normalizeBossCardCollection(value: unknown): BossCardCollection { if (!value || typeof value !== "object" || Array.isArray(value)) return emptyBossCardCollection(); const raw = value as Partial; const counts: Record = Object.create(null); if (raw.counts && typeof raw.counts === "object" && !Array.isArray(raw.counts)) { for (const [key, candidate] of Object.entries(raw.counts)) { if (!key || !candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; const entry: BossCardCounts = {}; for (const rarity of BOSS_CARD_RARITIES) { const count = candidate[rarity]; if (typeof count === "number" && Number.isFinite(count) && count >= 1) { entry[rarity] = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(count)); } } if (Object.keys(entry).length) counts[key] = entry; } } const lastClaims = raw.lastClaims && typeof raw.lastClaims === "object" && !Array.isArray(raw.lastClaims) ? Object.fromEntries(Object.entries(raw.lastClaims).filter(([key, claim]) => key && typeof claim === "string")) : {}; return { counts, lastClaims }; } /** Sum independent legacy character logs; max reconciles copies of the same account log. */ export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection { const counts: Record = Object.create(null); const lastClaims: Record = Object.create(null); for (const value of collections) { const collection = normalizeBossCardCollection(value); for (const [bossKey, entry] of Object.entries(collection.counts)) { const merged = counts[bossKey] ??= {}; for (const rarity of BOSS_CARD_RARITIES) { const count = entry[rarity]; if (count === undefined) continue; merged[rarity] = mode === "sum" ? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count) : Math.max(merged[rarity] ?? 0, count); } } for (const [spawnId, claim] of Object.entries(collection.lastClaims)) { const previous = lastClaims[spawnId]; if (!previous) { lastClaims[spawnId] = claim; continue; } if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) { const [previousTime, previousRevision] = previous.split(":").map(Number); const [time, revision] = claim.split(":").map(Number); if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim; } else if (claim > previous) lastClaims[spawnId] = claim; } } return { counts, lastClaims }; } /** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */ export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] { return BOSS_CARD_RARITIES.filter((rarity) => { if (rarity === "normal") return true; const roll = random(); return Number.isFinite(roll) && Math.max(0, Math.min(1, roll)) < BOSS_CARD_RARITY_INFO[rarity].chance; }); } export function collectBossCardDrop(collection: BossCardCollection, drop: BossCardDrop): BossCardCollection { const lastClaim = collection.lastClaims[drop.spawnId]; if (lastClaim === drop.claimId) return collection; // Generated claims begin with the death's epoch time. Ignore older snapshots // even after the same spawn has awarded a subsequent kill. if (lastClaim && /^\d+:/.test(lastClaim) && /^\d+:/.test(drop.claimId)) { const [lastTime, lastRevision] = lastClaim.split(":").map(Number); const [time, revision] = drop.claimId.split(":").map(Number); if (time < lastTime || (time === lastTime && revision <= lastRevision)) return collection; } const counts = { ...collection.counts[drop.bossKey] }; for (const rarity of new Set(drop.rarities)) { if (BOSS_CARD_RARITIES.includes(rarity)) { counts[rarity] = Math.min(Number.MAX_SAFE_INTEGER, (counts[rarity] ?? 0) + 1); } } return { counts: { ...collection.counts, [drop.bossKey]: counts }, lastClaims: { ...collection.lastClaims, [drop.spawnId]: drop.claimId }, }; }