// Camo mastery for checklist games. Users tick per-weapon milestones (max level, // Gold, …); class tiers (Diamond) and overall tiers (Dark Matter) are derived. // Post-launch weapons never gate a tier. They earn an achieved tier once their // own base milestone is done, as DLC weapons do in Black Ops III and WWII. export type Milestone = { key: string; label: string; description?: string; // limit to these weapon classes; every class when omitted classes?: string[]; exceptWeapons?: string[]; postLaunchOnly?: boolean; }; export type Tier = { key: string; label: string; scope: 'class' | 'all'; // a milestone key, or for `all` tiers also a class tier key requires: string; description?: string; classes?: string[]; // class tiers only: the classes whose weapons a class depends on classRequirements?: Record; }; export type MasteryRules = { milestones: Milestone[]; tiers: Tier[]; classes: string[]; steamApps: { id: number; label: string }[]; rankCaps?: { player: { level: number; prestige: number }; weaponPrestige: number; weaponLevelsByClass: Record; weaponLevelOverrides?: Record; sourceUrl?: string; }; }; export type Weapon = { id: number; name: string; category: string; postLaunch: boolean }; export type Count = { value: number; total: number; achieved: boolean }; export function appliesTo(milestone: Milestone, weapon: Weapon): boolean { if (milestone.postLaunchOnly && !weapon.postLaunch) return false; if (milestone.classes && !milestone.classes.includes(weapon.category)) return false; return !milestone.exceptWeapons?.includes(weapon.name); } function count(value: number, total: number): Count { return { value, total, achieved: total > 0 && value === total }; } export function computeMastery( rules: MasteryRules, weapons: readonly Weapon[], isDone: (weaponId: number, milestone: string) => boolean ) { const milestones = new Map(rules.milestones.map((milestone) => [milestone.key, milestone])); const tiers = new Map(rules.tiers.map((tier) => [tier.key, tier])); const counted = (key: string, classes: readonly string[]) => { const milestone = milestones.get(key); return weapons.filter( (weapon) => milestone && !weapon.postLaunch && classes.includes(weapon.category) && appliesTo(milestone, weapon) ); }; const done = (list: readonly Weapon[], key: string) => list.filter((weapon) => isDone(weapon.id, key)).length; const classTiers = new Map>(); for (const tier of rules.tiers) { if (tier.scope !== 'class') continue; const perClass = new Map(); for (const name of tier.classes ?? rules.classes) { const members = counted(tier.requires, tier.classRequirements?.[name] ?? [name]); if (members.length) perClass.set(name, count(done(members, tier.requires), members.length)); } classTiers.set(tier.key, perClass); } const allTiers = new Map(); for (const tier of rules.tiers) { if (tier.scope !== 'all') continue; const perClass = classTiers.get(tier.requires); if (perClass) { const classes = [...perClass].filter( ([name]) => !tier.classes || tier.classes.includes(name) ); allTiers.set( tier.key, count(classes.filter(([, value]) => value.achieved).length, classes.length) ); } else { const members = counted(tier.requires, tier.classes ?? rules.classes); allTiers.set(tier.key, count(done(members, tier.requires), members.length)); } } // the per-weapon milestone at the bottom of a tier chain, e.g. darkMatter → diamond → gold const baseMilestone = (key: string): string => { const tier = tiers.get(key); return tier ? baseMilestone(tier.requires) : key; }; function weaponTiers(weapon: Weapon): Tier[] { return rules.tiers.filter((tier) => { const base = baseMilestone(tier.key); const milestone = milestones.get(base); if (!milestone || !appliesTo(milestone, weapon)) return false; const achieved = tier.scope === 'class' ? classTiers.get(tier.key)?.get(weapon.category)?.achieved : allTiers.get(tier.key)?.achieved && (!tier.classes || tier.classes.includes(weapon.category)); return !!achieved && (!weapon.postLaunch || isDone(weapon.id, base)); }); } return { classTiers, allTiers, weaponTiers }; } export function summarizeGame( rules: MasteryRules, weapons: readonly Weapon[], achievementIds: readonly number[], isDone: (itemId: number, milestone: string) => boolean ) { const mastery = computeMastery(rules, weapons, isDone); return { achievements: count( achievementIds.filter((id) => isDone(id, 'done')).length, achievementIds.length ), milestones: rules.milestones.map((milestone) => { const list = weapons.filter((weapon) => appliesTo(milestone, weapon)); return { ...milestone, ...count(list.filter((weapon) => isDone(weapon.id, milestone.key)).length, list.length) }; }), tiers: rules.tiers.map((tier) => { if (tier.scope === 'all') return { ...tier, ...mastery.allTiers.get(tier.key)! }; const perClass = [...(mastery.classTiers.get(tier.key)?.values() ?? [])]; return { ...tier, ...count(perClass.filter((value) => value.achieved).length, perClass.length) }; }), mastery }; }