add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s

Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:36:18 +00:00
parent 438623d857
commit a10585a4d2
49 changed files with 8236 additions and 170 deletions

View File

@@ -0,0 +1,183 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { computeMastery, summarizeGame, type MasteryRules, type Weapon } from './mastery';
const rules: MasteryRules = {
steamApps: [],
classes: ['Rifles', 'Pistols', 'Melee'],
milestones: [
{ key: 'maxLevel', label: 'max level', classes: ['Rifles', 'Pistols'] },
{ key: 'gold', label: 'Gold' }
],
tiers: [
{ key: 'diamond', label: 'Diamond', scope: 'class', requires: 'gold' },
{ key: 'darkMatter', label: 'Dark Matter', scope: 'all', requires: 'diamond' }
]
};
const weapons: Weapon[] = [
{ id: 1, name: 'A', category: 'Rifles', postLaunch: false },
{ id: 2, name: 'B', category: 'Rifles', postLaunch: false },
{ id: 3, name: 'DLC', category: 'Rifles', postLaunch: true },
{ id: 4, name: 'P', category: 'Pistols', postLaunch: false },
{ id: 5, name: 'Knife', category: 'Melee', postLaunch: false }
];
const doneWith = (pairs: [number, string][]) => {
const set = new Set(pairs.map(([id, key]) => `${id}:${key}`));
return (id: number, key: string) => set.has(`${id}:${key}`);
};
const labels = (tiers: { key: string }[]) => tiers.map((tier) => tier.key);
describe('camo mastery', () => {
it('awards a class tier once every base weapon in the class has the milestone', () => {
const mastery = computeMastery(
rules,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold']
])
);
expect(mastery.classTiers.get('diamond')?.get('Rifles')).toEqual({
value: 2,
total: 2,
achieved: true
});
expect(mastery.classTiers.get('diamond')?.get('Pistols')?.achieved).toBe(false);
expect(labels(mastery.weaponTiers(weapons[0]))).toEqual(['diamond']);
// post-launch weapons don't gate the tier and only earn it with their own Gold
expect(labels(mastery.weaponTiers(weapons[2]))).toEqual([]);
expect(mastery.allTiers.get('darkMatter')).toEqual({ value: 1, total: 3, achieved: false });
});
it('derives the overall tier from every class tier', () => {
const mastery = computeMastery(
rules,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold'],
[3, 'gold'],
[4, 'gold'],
[5, 'gold']
])
);
expect(mastery.allTiers.get('darkMatter')?.achieved).toBe(true);
expect(labels(mastery.weaponTiers(weapons[2]))).toEqual(['diamond', 'darkMatter']);
expect(labels(mastery.weaponTiers(weapons[4]))).toEqual(['diamond', 'darkMatter']);
});
it('supports classes that depend on other classes and overall tiers on a subset', () => {
const custom: MasteryRules = {
...rules,
tiers: [
{
key: 'diamond',
label: 'Diamond',
scope: 'class',
requires: 'gold',
classRequirements: { Melee: ['Pistols', 'Melee'] }
},
{
key: 'solar',
label: 'Solar',
scope: 'all',
requires: 'diamond',
classes: ['Rifles', 'Melee']
}
]
};
const mastery = computeMastery(
custom,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold'],
[5, 'gold']
])
);
expect(mastery.classTiers.get('diamond')?.get('Melee')).toEqual({
value: 1,
total: 2,
achieved: false
});
expect(mastery.allTiers.get('solar')).toEqual({ value: 1, total: 2, achieved: false });
});
it('summarizes milestones only for the weapons they apply to', () => {
const summary = summarizeGame(
rules,
weapons,
[10, 11],
doneWith([
[10, 'done'],
[1, 'maxLevel']
])
);
expect(summary.achievements).toEqual({ value: 1, total: 2, achieved: false });
expect(summary.milestones.map(({ key, value, total }) => [key, value, total])).toEqual([
['maxLevel', 1, 4],
['gold', 0, 5]
]);
expect(summary.tiers.map(({ key, total }) => [key, total])).toEqual([
['diamond', 3],
['darkMatter', 3]
]);
});
});
describe('Call of Duty catalog', () => {
const catalog = JSON.parse(readFileSync('data/call-of-duty.json', 'utf8')) as {
games: {
slug: string;
milestones: MasteryRules['milestones'];
tiers: MasteryRules['tiers'];
classes: { name: string; weapons: string[]; postLaunch?: string[] }[];
achievements: { key: string; name: string }[];
}[];
};
it('covers the tracked games with unique weapons and achievements', () => {
expect(catalog.games.map((game) => game.slug)).toEqual([
'black-ops',
'black-ops-2',
'ghosts',
'advanced-warfare',
'black-ops-3',
'infinite-warfare',
'wwii'
]);
for (const game of catalog.games) {
const names = game.classes.flatMap((c) => [...c.weapons, ...(c.postLaunch ?? [])]);
const perClass = game.classes.map((c) => c.name);
expect(new Set(perClass).size).toBe(perClass.length);
for (const c of game.classes)
expect(new Set([...c.weapons, ...(c.postLaunch ?? [])]).size).toBe(
c.weapons.length + (c.postLaunch?.length ?? 0)
);
expect(names.length).toBeGreaterThan(20);
// weapon progress is keyed by the slug of its name (scripts/seed-cod-catalog.ts)
const keys = names.map((name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
expect(new Set(keys).size, game.slug).toBe(names.length);
expect(game.achievements.length).toBeGreaterThan(30);
expect(new Set(game.achievements.map((a) => a.key)).size).toBe(game.achievements.length);
}
});
it('only references known classes and milestones in mastery rules', () => {
for (const game of catalog.games) {
const classes = new Set(game.classes.map((c) => c.name));
const milestones = new Set(game.milestones.map((m) => m.key));
const tiers = new Set(game.tiers.map((t) => t.key));
for (const milestone of game.milestones)
for (const name of milestone.classes ?? []) expect(classes, name).toContain(name);
for (const tier of game.tiers) {
expect(milestones.has(tier.requires) || tiers.has(tier.requires), tier.key).toBe(true);
for (const name of [
...(tier.classes ?? []),
...Object.values(tier.classRequirements ?? {}).flat()
])
expect(classes, name).toContain(name);
}
}
});
});

View File

@@ -0,0 +1,149 @@
// 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<string, string[]>;
};
export type MasteryRules = {
milestones: Milestone[];
tiers: Tier[];
classes: string[];
steamApps: { id: number; label: 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<string, Map<string, Count>>();
for (const tier of rules.tiers) {
if (tier.scope !== 'class') continue;
const perClass = new Map<string, Count>();
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<string, Count>();
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
};
}