Lines
95.34%
41 / 43
Methods
85.71%
6 / 7
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| parse | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | ||
| toColumns | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 4 | ||
| toValue | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 3 | ||
| createInput | 77.77% | 7 / 9 | 0.00% | 0 / 1 | 4.18 | ||
| parseRow | 100.00% | 13 / 13 | 100.00% | 1 / 1 | 6 | ||
| cells | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| 18 | final readonly class IngressStatsParser | |
| 19 | { | |
| 20 | /** "Time Span" values of all-time stats, per game language. */ | |
| 21 | private const array ALL_TIME_SPANS = ['ALL TIME', 'GESAMT', 'SIEMPRE']; | |
| 22 | ||
| 23 | private const array TEXT_COLUMNS = ['nickname', 'faction']; | |
| 24 | ||
| 25 | /** | |
| 26 | * Ingress stats header → agent_stat column (4e-website's MedalChecker::$primeHeaders). | |
| 27 | * Headers not listed here are not stored. | |
| 28 | */ | |
| 29 | public const array COLUMNS = [ | |
| 30 | 'Agent Name' => 'nickname', | |
| 31 | 'Agent Faction' => 'faction', | |
| 32 | 'Lifetime AP' => 'ap', | |
| 33 | 'Unique Portals Visited' => 'explorer', | |
| 34 | 'Portals Discovered' => 'portals_discovered', | |
| 35 | 'Seer Points' => 'seer', | |
| 36 | 'OPR Agreements' => 'recon', | |
| 37 | 'Distance Walked' => 'trekker', | |
| 38 | 'Resonators Deployed' => 'builder', | |
| 39 | 'Links Created' => 'connector', | |
| 40 | 'Control Fields Created' => 'mind_controller', | |
| 41 | 'Mind Units Captured' => 'illuminator', | |
| 42 | 'Longest Link Ever Created' => 'longest_link', | |
| 43 | 'Largest Control Field' => 'largest_field', | |
| 44 | 'XM Recharged' => 'recharger', | |
| 45 | 'Portals Captured' => 'liberator', | |
| 46 | 'Unique Portals Captured' => 'pioneer', | |
| 47 | 'Mods Deployed' => 'engineer', | |
| 48 | 'Resonators Destroyed' => 'purifier', | |
| 49 | 'Unique Missions Completed' => 'specops', | |
| 50 | 'Hacks' => 'hacker', | |
| 51 | 'Glyph Hack Points' => 'translator', | |
| 52 | // Old and new name | |
| 53 | 'Longest Hacking Streak' => 'sojourner', | |
| 54 | 'Longest Sojourner Streak' => 'sojourner', | |
| 55 | 'Completed Hackstreaks' => 'epoch', | |
| 56 | // Old and new name | |
| 57 | 'Agents Successfully Recruited' => 'recruiter', | |
| 58 | 'Agents Recruited' => 'recruiter', | |
| 59 | 'Mission Day(s) Attended' => 'missionday', | |
| 60 | 'NL-1331 Meetup(s) Attended' => 'nl1331_meetups', | |
| 61 | 'First Saturday Events' => 'ifs', | |
| 62 | 'Second Sunday Events' => 'second_sunday', | |
| 63 | 'Portal Scans Uploaded' => 'scout', | |
| 64 | // Old and new name | |
| 65 | 'Scout Controller on Unique Portals' => 'scout_controller', | |
| 66 | 'Uniques Scout Controlled' => 'scout_controller', | |
| 67 | 'Drone Hacks' => 'maverick', | |
| 68 | 'Unique Portals Drone Visited' => 'drone_portals_visited', | |
| 69 | 'Furthest Drone Distance' => 'drone_flight_distance', | |
| 70 | 'Forced Drone Recalls' => 'drone_forced_recalls', | |
| 71 | 'Drones Returned' => 'drones_returned', | |
| 72 | 'Level' => 'level', | |
| 73 | 'Recursions' => 'recursions', | |
| 74 | 'Kinetic Capsules Completed' => 'kinetic_capsules_completed', | |
| 75 | 'Months Subscribed' => 'months_subscribed', | |
| 76 | // Events recorded by 4e-website | |
| 77 | 'Umbra: Unique Resonator Slots Deployed' => 'current_challenge', | |
| 78 | 'Didact Fields Created' => 'current_challenge', | |
| 79 | 'Operation Chronos Points' => 'current_challenge', | |
| 80 | 'Machina Portals Reclaimed' => 'reclaimer', | |
| 81 | ]; | |
| 82 | ||
| 83 | public function __construct( | |
| 84 | private DenormalizerInterface $denormalizer, | |
| 85 | private ValidatorInterface $validator, | |
| 86 | ) { | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * @throws InvalidStatsException | |
| 91 | */ | |
| 92 | public function parse(string $text): AgentStatInput | |
| 93 | { | |
| 94 | $row = $this->parseRow($text); | |
| 95 | ||
| 96 | $timeSpan = $row['Time Span'] ?? ''; | |
| 97 | if (!\in_array($timeSpan, self::ALL_TIME_SPANS, true)) { | |
| 98 | throw new InvalidStatsException([\sprintf('Only all-time stats can be imported, not "%s".', $timeSpan)]); | |
| 99 | } | |
| 100 | ||
| 101 | return $this->createInput($this->toColumns($row)); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * @param array<string, string> $row | |
| 106 | * | |
| 107 | * @return array<string, string|int> agent_stat column => value | |
| 108 | * | |
| 109 | * @throws InvalidStatsException | |
| 110 | */ | |
| 111 | private function toColumns(array $row): array | |
| 112 | { | |
| 113 | $values = ['datetime' => \trim(($row['Date (yyyy-mm-dd)'] ?? '').' '.($row['Time (hh:mm:ss)'] ?? ''))]; | |
| 114 | ||
| 115 | foreach ($row as $name => $value) { | |
| 116 | $column = self::COLUMNS[$name] ?? null; | |
| 117 | if (null !== $column && '' !== $value) { | |
| 118 | $values[$column] = $this->toValue($column, $name, $value); | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | return $values; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * @throws InvalidStatsException | |
| 127 | */ | |
| 128 | private function toValue(string $column, string $name, string $value): string|int | |
| 129 | { | |
| 130 | if (\in_array($column, self::TEXT_COLUMNS, true)) { | |
| 131 | return $value; | |
| 132 | } | |
| 133 | ||
| 134 | if (1 !== \preg_match('/^\d+$/', $value)) { | |
| 135 | throw new InvalidStatsException([\sprintf('"%s" is not a number: "%s".', $name, $value)]); | |
| 136 | } | |
| 137 | ||
| 138 | return (int) $value; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * @param array<string, string|int> $values | |
| 143 | * | |
| 144 | * @throws InvalidStatsException | |
| 145 | */ | |
| 146 | private function createInput(array $values): AgentStatInput | |
| 147 | { | |
| 148 | try { | |
| 149 | $input = $this->denormalizer->denormalize($values, AgentStatInput::class); | |
| 150 | } catch (ExceptionInterface $exception) { | |
| 151 | throw new InvalidStatsException([$exception->getMessage()]); | |
| 152 | } | |
| 153 | ||
| 154 | $errors = []; | |
| 155 | foreach ($this->validator->validate($input) as $violation) { | |
| 156 | $errors[] = $violation->getPropertyPath().': '.$violation->getMessage(); | |
| 157 | } | |
| 158 | ||
| 159 | if ([] !== $errors) { | |
| 160 | throw new InvalidStatsException($errors); | |
| 161 | } | |
| 162 | ||
| 163 | return $input; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * @return array<string, string> header => cell of the single data line | |
| 168 | * | |
| 169 | * @throws InvalidStatsException | |
| 170 | */ | |
| 171 | private function parseRow(string $text): array | |
| 172 | { | |
| 173 | $lines = \array_values(\array_filter( | |
| 174 | \preg_split('/\R/', $text) ?: [], | |
| 175 | static fn (string $line): bool => '' !== \trim($line), | |
| 176 | )); | |
| 177 | ||
| 178 | if (\count($lines) < 2 || !\str_contains($lines[0], "\t")) { | |
| 179 | throw new InvalidStatsException(['Paste the stats as copied from Ingress: a header line and a data line, separated by tabs.']); | |
| 180 | } | |
| 181 | ||
| 182 | if (2 !== \count($lines)) { | |
| 183 | throw new InvalidStatsException(['Paste exactly one line of stats.']); | |
| 184 | } | |
| 185 | ||
| 186 | $header = $this->cells($lines[0]); | |
| 187 | $cells = $this->cells($lines[1]); | |
| 188 | ||
| 189 | if (\count($cells) > \count($header)) { | |
| 190 | throw new InvalidStatsException(['The data line has more fields than the header line.']); | |
| 191 | } | |
| 192 | ||
| 193 | return \array_combine($header, \array_pad($cells, \count($header), '')); | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Ingress ends every line with a tab: trailing empty cells are dropped. | |
| 198 | * | |
| 199 | * @return list<string> | |
| 200 | */ | |
| 201 | private function cells(string $line): array | |
| 202 | { | |
| 203 | $cells = \array_map(trim(...), \explode("\t", $line)); | |
| 204 | while ([] !== $cells && '' === \end($cells)) { | |
| 205 | \array_pop($cells); | |
| 206 | } | |
| 207 | ||
| 208 | return $cells; | |
| 209 | } | |
| 210 | } |