Files
healer-man/src/game/bossCards.ts
T
2026-09-14 13:20:20 -04:00

111 lines
5.2 KiB
TypeScript

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<Record<BossCardRarity, number>>;
export interface BossCardCollection {
readonly counts: Readonly<Record<string, BossCardCounts>>;
/** Last reward per spawned boss prevents shared-world replay and reconnect duplicates. */
readonly lastClaims: Readonly<Record<string, string>>;
}
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<BossCardCollection>;
const counts: Record<string, BossCardCounts> = 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<string, BossCardCounts> = Object.create(null);
const lastClaims: Record<string, string> = 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 },
};
}