Lines 100.00% 140 / 140
Methods 100.00% 5 / 5
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 medals 100.00% 29 / 29 100.00% 1 / 1 1
 customMedals 100.00% 85 / 85 100.00% 1 / 1 1
 medalLevels 100.00% 9 / 9 100.00% 1 / 1 4
 coreBadges 100.00% 5 / 5 100.00% 1 / 1 4
 customMedalTiers 100.00% 12 / 12 100.00% 1 / 1 6
14final class MedalChecker
15{
16    /** The custom medal groups in 4e's order. */
17    public const array CUSTOM_MEDAL_GROUPS = ['anomaly', 'annual', 'event'];
18
19    /** C.O.R.E subscription badges by the months they need (0: the plain C.O.R.E badge). */
20    public const array CORE_BADGES = [
21        0 => 'badges/unique_badge_core.webp',
22        24 => 'badges/unique_badge_dual_core.webp',
23        36 => 'badges/unique_badge_core_year3.webp',
24        48 => 'badges/unique_badge_quad_core.webp',
25        60 => 'badges/unique_badge_penta_core.webp',
26    ];
27
28    /**
29     * The medals 4e-website shows for an agent, in its order.
30     *
31     * @return list<Medal>
32     */
33    public function medals(): array
34    {
35        return [
36            new Medal('explorer', 'Explorer', 'Unique Portals Visited', [100, 1000, 2000, 10000, 30000]),
37            new Medal('seer', 'Seer', 'Seer Points', [10, 50, 200, 500, 5000], discontinued: true),
38            new Medal('recon', 'Recon', 'OPR Agreements', [100, 750, 2500, 5000, 10000]),
39            new Medal('trekker', 'Trekker', 'Distance Walked', [10, 100, 300, 1000, 2500]),
40            new Medal('builder', 'Builder', 'Resonators Deployed', [2000, 10000, 30000, 100000, 200000]),
41            new Medal('connector', 'Connector', 'Links Created', [50, 1000, 5000, 25000, 100000]),
42            new Medal('mind_controller', 'Mind Controller', 'Control Fields Created', [100, 500, 2000, 10000, 40000]),
43            new Medal('engineer', 'Engineer', 'Mods Deployed', [150, 1500, 5000, 20000, 50000]),
44            new Medal('illuminator', 'Illuminator', 'Mind Units Captured', [5000, 50000, 250000, 1000000, 4000000]),
45            new Medal('recharger', 'Recharger', 'XM Recharged', [100000, 1000000, 3000000, 10000000, 25000000]),
46            new Medal('liberator', 'Liberator', 'Portals Captured', [100, 1000, 5000, 15000, 40000]),
47            new Medal('pioneer', 'Pioneer', 'Unique Portals Captured', [20, 200, 1000, 5000, 20000]),
48            new Medal('purifier', 'Purifier', 'Resonators Destroyed', [2000, 10000, 30000, 100000, 300000]),
49            new Medal('specops', 'SpecOps', 'Unique Missions Completed', [5, 25, 100, 200, 500]),
50            new Medal('missionday', 'Mission Day', 'Mission Day(s) Attended', [1, 3, 6, 10, 20]),
51            new Medal('nl1331_meetups', 'NL-1331 Meetups', 'NL-1331 Meetup(s) Attended', [1, 5, 10, 25, 50]),
52            new Medal('hacker', 'Hacker', 'Hacks', [2000, 10000, 30000, 100000, 200000]),
53            new Medal('translator', 'Translator', 'Glyph Hack Points', [200, 2000, 6000, 20000, 50000]),
54            new Medal('sojourner', 'Sojourner', 'Longest Hacking Streak', [15, 30, 60, 180, 360]),
55            new Medal('epoch', 'Epoch', 'Complete Hacking Streak', [2, 4, 8, 30, 60]),
56            new Medal('recruiter', 'Recruiter', 'Agents successfully recruited', [2, 10, 25, 50, 100]),
57            new Medal('ifs', 'First Saturday', 'First Saturday Events', [1, 6, 12, 24, 36]),
58            new Medal('second_sunday', 'Second Sunday', 'Second Sunday Events', [1, 6, 12, 24, 36]),
59            new Medal('scout', 'Scout', 'Scan portals', [50, 250, 1000, 3000, 6000]),
60            new Medal('scout_controller', 'Scout Controller', 'Scan unique portals', [100, 500, 1000, 5000, 15000]),
61            new Medal('maverick', 'Maverick', 'Drone hack portals', [250, 1000, 2000, 5000, 10000]),
62            new Medal('reclaimer', 'Reclaimer', 'Reclaim portals from Machina ingression', [100, 1000, 5000, 15000, 40000]),
63        ];
64    }
65
66    /**
67     * 4e-website's custom medals in its order, with the titles from its badgeinfos.json.
68     *
69     * @return list<CustomMedal>
70     */
71    public function customMedals(): array
72    {
73        return [
74            new CustomMedal('anomaly', 'apollo_onsite', 'Apollo (On-site)'),
75            new CustomMedal('anomaly', 'apollo', 'Apollo', ['bronze', 'silver', 'gold']),
76            new CustomMedal('anomaly', 'orion_onsite', 'Orion (On-site)'),
77            new CustomMedal('anomaly', 'orion', 'Orion', ['bronze', 'silver', 'gold', 'platinum']),
78            new CustomMedal('anomaly', 'plus_gamma_onsite', '+Gamma (On-site)'),
79            new CustomMedal('anomaly', 'plus_gamma', '+Gamma', ['bronze', 'silver', 'gold']),
80            new CustomMedal('anomaly', 'plus_beta', '+Beta', ['bronze', 'silver', 'gold']),
81            new CustomMedal('anomaly', 'plus_delta', '+Delta', ['bronze', 'silver', 'gold']),
82            new CustomMedal('anomaly', 'plus_theta', '+Theta', ['bronze', 'silver', 'gold']),
83            new CustomMedal('anomaly', 'plus_alpha', '+Alpha'),
84            new CustomMedal('anomaly', 'erased_memories', 'Erased Memories'),
85            new CustomMedal('anomaly', 'buried_memories', 'Buried Memories'),
86            new CustomMedal('anomaly', 'cryptic_memories', 'Cryptic Memories'),
87            new CustomMedal('anomaly', 'discoverie', 'Discoverie'),
88            new CustomMedal('anomaly', 'ctrl', 'Ctrl'),
89            new CustomMedal('anomaly', 'echo', 'Echo'),
90            new CustomMedal('anomaly', 'mzfpk', 'MZFPK'),
91            new CustomMedal('anomaly', 'epiphany_dawn', 'Epiphany Dawn'),
92            new CustomMedal('anomaly', 'superposition', 'Superposition'),
93            new CustomMedal('anomaly', 'kythera', 'Kythera'),
94            new CustomMedal('anomaly', 'kureze_effect', 'Kureze Effect'),
95            new CustomMedal('anomaly', 'umbra', 'Nemesis: Umbra'),
96            new CustomMedal('anomaly', 'nemesis_myriad', 'Nemesis: Myriad'),
97            new CustomMedal('anomaly', 'abaddon_prime', 'Abaddon Prime'),
98            new CustomMedal('anomaly', 'darsana_prime', 'Darsana Prime'),
99            new CustomMedal('anomaly', 'recursion_prime', 'Recursion Prime'),
100            new CustomMedal('anomaly', 'cassandra_prime', 'Cassandra Prime'),
101            new CustomMedal('anomaly', 'exo5', 'EXO5'),
102            new CustomMedal('anomaly', '13_magnusreawakens', '13MAGNUS Reawakens'),
103            new CustomMedal('anomaly', 'via_noir', 'Via Noir'),
104            new CustomMedal('anomaly', 'via_lux', 'Via Lux'),
105            new CustomMedal('anomaly', 'aegis_nova', 'Aegis Nova'),
106            new CustomMedal('anomaly', 'obsidian', 'Obsidian'),
107            new CustomMedal('anomaly', 'abaddon', 'Abaddon'),
108            new CustomMedal('anomaly', 'persepolis', 'Persepolis'),
109            new CustomMedal('anomaly', 'shonin', 'Shonin'),
110            new CustomMedal('anomaly', 'darsana', 'Darsana'),
111            new CustomMedal('anomaly', 'helios', 'Helios'),
112            new CustomMedal('anomaly', 'initio', 'Initio'),
113            new CustomMedal('anomaly', 'interitus', 'Interitus'),
114            new CustomMedal('anomaly', 'recursion', 'Recursion'),
115            new CustomMedal('annual', 'voyager', 'Voyager', ['bronze', 'silver', 'gold', 'platinum', 'black']),
116            new CustomMedal('annual', 'catalyst', 'Catalyst', ['bronze', 'silver', 'gold', 'platinum', 'black']),
117            new CustomMedal('annual', 'paragon', 'Paragon', ['bronze', 'silver', 'gold', 'platinum', 'black']),
118            new CustomMedal('annual', 'pathfinder', 'Pathfinder', ['bronze', 'silver', 'gold', 'platinum', 'black']),
119            new CustomMedal('annual', 'persistence', 'Persistence', ['bronze', 'silver', 'gold', 'platinum', 'black']),
120            new CustomMedal('annual', 'resonance', 'Resonance', ['bronze', 'silver', 'gold', 'platinum', 'black']),
121            new CustomMedal('annual', 'resurgence', 'Resurgence', ['bronze', 'silver', 'gold', 'platinum', 'black']),
122            new CustomMedal('annual', 'ouroboros', 'Ouroboros', ['bronze', 'silver', 'gold', 'platinum', 'black']),
123            new CustomMedal('annual', 'sage', 'Sage', ['bronze', 'silver', 'gold', 'platinum', 'black']),
124            new CustomMedal('annual', 'luminary', 'Luminary', ['bronze', 'silver', 'gold', 'platinum', 'black']),
125            new CustomMedal('annual', 'vanguard', 'Vanguard', ['bronze', 'silver', 'gold', 'platinum', 'black']),
126            new CustomMedal('annual', 'innovator', 'Innovator', ['bronze', 'silver', 'gold', 'platinum', 'black']),
127            new CustomMedal('event', '2026_summer_solstice', 'Summer Solstice 2026', ['bronze', 'silver']),
128            new CustomMedal('event', 'orion', 'Orion: Global Op', ['bronze', 'silver', 'gold']),
129            new CustomMedal('event', 'plus_gamma', '+Gamma: Global Op', ['bronze', 'silver', 'gold']),
130            new CustomMedal('event', '2025_winter_solstice', 'Winter Solstice 2025 Dispatch Challenge', ['bronze', 'silver']),
131            new CustomMedal('event', 'spider_web_2025', 'Spyder Web 2025'),
132            new CustomMedal('event', 'plus_alpha', '+Alpha: Global Op', ['bronze', 'silver']),
133            new CustomMedal('event', 'erased_memories', 'Erased Memories: Global Op', ['bronze', 'silver']),
134            new CustomMedal('event', 'field_test_dispatch', 'Field Test: Dispatch', ['bronze', 'silver']),
135            new CustomMedal('event', 'shared_memories', 'Shared Memories: Operation', ['bronze', 'silver']),
136            new CustomMedal('event', 'buried_memories', 'Buried Memories: Global Op', ['bronze', 'silver']),
137            new CustomMedal('event', 'cryptic_memories', 'Cryptic Memories: Global Op', ['bronze', 'silver']),
138            new CustomMedal('event', 'chronos', 'Chronos', ['bronze', 'silver']),
139            new CustomMedal('event', 'peace_day_2022', 'Peace Day 2022'),
140            new CustomMedal('event', 'solstice_recharge', 'Solstice Recharge Challenge'),
141            new CustomMedal('event', 'eosimprint', 'EOS Imprint', ['bronze', 'silver', 'gold']),
142            new CustomMedal('event', 'csans', 'C. Sans', ['bronze', 'silver', 'gold']),
143            new CustomMedal('event', 'courier_challenge', 'Courier Meet You Out There Challenge', ['bronze', 'silver', 'gold']),
144            new CustomMedal('event', 'kinetic_challenge', 'Optima Kinetic Capsule Challenge', ['bronze', 'silver', 'gold']),
145            new CustomMedal('event', 'knight_tessellation', 'Knight of Tessellation', ['silver', 'gold']),
146            new CustomMedal('event', 'paragon', 'Tessellation Paragon'),
147            new CustomMedal('event', 'didact_field', 'Didact Field Challenge', ['bronze', 'silver', 'gold']),
148            new CustomMedal('event', 'umbra_deploy', 'Umbra Deploy Challenge', ['bronze', 'silver', 'gold']),
149            new CustomMedal('event', 'avenir_shard_challenge', 'Avenir Shard Challenge'),
150            new CustomMedal('event', 'aurora_glyph', 'Aurora Glyph Challenge', ['bronze', 'silver', 'gold']),
151            new CustomMedal('event', 'myriad_hack', 'Myriad Hack Challenge', ['bronze', 'silver', 'gold']),
152            new CustomMedal('event', 'dark_xm', 'Dark XM', ['bronze', 'silver', 'gold']),
153            new CustomMedal('event', 'cassandra_neutralizer', 'Cassandra Neutralizer', ['bronze', 'silver', 'gold']),
154            new CustomMedal('event', 'exo5', 'EXO5 Controller', ['100', '500', '2000']),
155            new CustomMedal('event', 'magnus_builder', 'Magnus Builder', ['builder', 'architect']),
156            new CustomMedal('event', 'lux_adventure', 'Via Lux Adventure', ['explorer', 'odyssey']),
157        ];
158    }
159
160    /**
161     * The agent's medals, highest level first (as 4e-website's agent stats page); discontinued
162     * medals only if earned.
163     *
164     * @return list<MedalLevel>
165     */
166    public function medalLevels(AgentStat $stat): array
167    {
168        $stats = $stat->getStats();
169        $levels = [];
170        foreach ($this->medals() as $medal) {
171            $value = $stats[$medal->column] ?? null;
172            $level = $medal->level($value);
173            if (!$medal->discontinued || $level > 0) {
174                $levels[] = new MedalLevel($medal, $value, $level);
175            }
176        }
177
178        // usort is stable: the same levels keep 4e's order
179        \usort($levels, static fn (MedalLevel $a, MedalLevel $b): int => $b->level <=> $a->level);
180
181        return $levels;
182    }
183
184    /**
185     * The C.O.R.E badges for [$months] of subscription; none without a month.
186     *
187     * @return list<string>
188     */
189    public function coreBadges(int $months): array
190    {
191        $badges = [];
192        foreach (self::CORE_BADGES as $needed => $badge) {
193            if ($months > 0 && $months >= $needed) {
194                $badges[] = $badge;
195            }
196        }
197
198        return $badges;
199    }
200
201    /**
202     * Stored custom medals (User::$customMedals) by group in 4e's order, the medals in its order.
203     * Annual medals are stored as level 1 (bronze) to 5 (onyx). Unknown medals are left out.
204     *
205     * @param array<string, array<string, int|string>> $stored
206     *
207     * @return array<string, list<CustomMedalTier>>
208     */
209    public function customMedalTiers(array $stored): array
210    {
211        $groups = [];
212        foreach ($this->customMedals() as $medal) {
213            $tier = $stored[$medal->group][$medal->key] ?? null;
214            if (\is_int($tier)) {
215                $tier = $medal->tiers[$tier - 1] ?? null;
216            }
217
218            if (null !== $tier) {
219                $groups[$medal->group][] = new CustomMedalTier($medal, $tier);
220            }
221        }
222
223        $sorted = [];
224        foreach (self::CUSTOM_MEDAL_GROUPS as $group) {
225            if (isset($groups[$group])) {
226                $sorted[$group] = $groups[$group];
227            }
228        }
229
230        return $sorted;
231    }
232}