Lines
98.56%
137 / 139
Functions and Methods
92.85%
13 / 14
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| DemoAgentsCommand | 98.56% | 137 / 139 | 92.85% | 13 / 14 | 46 | 0.00% | 0 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| __invoke | 91.66% | 22 / 24 | 0.00% | 0 / 1 | 6.02 | |||||
| removeDemoAgents | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 3 | |||||
| bannergress | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 2 | |||||
| addDoneBanners | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 3 | |||||
| banners | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 1 | |||||
| addStats | 100.00% | 25 / 25 | 100.00% | 1 / 1 | 5 | |||||
| pace | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 5 | |||||
| shares | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | |||||
| addEvents | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 4 | |||||
| relativeEvent | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 4 | |||||
| newYearTravel | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 6 | |||||
| event | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 1 | |||||
| avatar | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace App\Command; | |
| 6 | ||
| 7 | use App\ApiResource\Bannergress; | |
| 8 | use App\Dto\AgentStatInput; | |
| 9 | use App\Entity\AgentStat; | |
| 10 | use App\Entity\DoneBanner; | |
| 11 | use App\Entity\TimelineEvent; | |
| 12 | use App\Entity\User; | |
| 13 | use App\Enum\ProfilePart; | |
| 14 | use App\Repository\UserRepository; | |
| 15 | use Doctrine\ORM\EntityManagerInterface; | |
| 16 | use Symfony\Component\Console\Attribute\AsCommand; | |
| 17 | use Symfony\Component\Console\Attribute\Option; | |
| 18 | use Symfony\Component\Console\Command\Command; | |
| 19 | use Symfony\Component\Console\Style\SymfonyStyle; | |
| 20 | use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
| 21 | ||
| 22 | /** | |
| 23 | * Demo agents with made-up stats (up to five years of them), timeline events, custom medals, | |
| 24 | * pictures and every kind of visibility, to try the agent pages and the timeline in dev. Running it again replaces them; --remove only removes them. Fixtures are | |
| 25 | * not used: loading them empties the database, and the tests load all of them. | |
| 26 | */ | |
| 27 | #[AsCommand(name: 'app:demo-agents', description: 'Add (or replace) demo agents with test data, dev only')] | |
| 28 | final readonly class DemoAgentsCommand | |
| 29 | { | |
| 30 | /** Demo users are recognized by it. */ | |
| 31 | private const string IDENTIFIER_SUFFIX = '@demo.example.com'; | |
| 32 | ||
| 33 | /** Values of a long-time agent; each demo agent has a share of them. */ | |
| 34 | private const array VETERAN = [ | |
| 35 | 'ap' => 250_000_000, 'explorer' => 30_000, 'recon' => 5_000, 'trekker' => 3_000, 'builder' => 300_000, | |
| 36 | 'connector' => 60_000, 'mindController' => 30_000, 'illuminator' => 5_000_000, 'recharger' => 30_000_000, | |
| 37 | 'liberator' => 30_000, 'pioneer' => 15_000, 'engineer' => 40_000, 'purifier' => 200_000, 'specops' => 400, | |
| 38 | 'hacker' => 200_000, 'translator' => 40_000, 'sojourner' => 400, 'epoch' => 50, 'missionday' => 15, | |
| 39 | 'nl1331Meetups' => 10, 'ifs' => 20, 'secondSunday' => 10, 'scout' => 3_000, 'scoutController' => 8_000, | |
| 40 | 'maverick' => 9_000, 'reclaimer' => 5_000, 'dronePortalsVisited' => 400, 'droneForcedRecalls' => 100, | |
| 41 | 'droneFlightDistance' => 10, 'kineticCapsulesCompleted' => 1_000, 'monthsSubscribed' => 70, | |
| 42 | ]; | |
| 43 | ||
| 44 | /** Has not chosen the visible parts yet: asked on sign-in. */ | |
| 45 | private const string NOT_CHOSEN = 'DemoNew'; | |
| 46 | ||
| 47 | /** A travel's "days ago" for the newest New Year past (from 30 December, 5 days): over the new year. */ | |
| 48 | private const string NEW_YEAR = 'new year'; | |
| 49 | ||
| 50 | /** A travel's length for a weekend: Saturday and Sunday, from the Saturday on or before its day. */ | |
| 51 | private const string WEEKEND = 'weekend'; | |
| 52 | ||
| 53 | /** Every visible part. */ | |
| 54 | private const array ALL_PARTS = [ProfilePart::AGENT, ProfilePart::AVATAR, ProfilePart::LATEST_STATS, ProfilePart::STATS_HISTORY, ProfilePart::CUSTOM_MEDALS, ProfilePart::BANNERGRESS]; | |
| 55 | ||
| 56 | /** | |
| 57 | * Agent name => faction, level, share of VETERAN, imports and the days between them, visible | |
| 58 | * parts, picture, custom medals and timeline events (days ago or NEW_YEAR, days long, WEEKEND or null | |
| 59 | * for one day, text). Travels cannot overlap. | |
| 60 | */ | |
| 61 | private const array AGENTS = [ | |
| 62 | // Plays for five years, an import every 45 days | |
| 63 | 'DemoFrog' => ['Enlightened', 16, 1.2, [40, 45], self::ALL_PARTS, true, | |
| 64 | ['anomaly' => ['orion' => 'platinum', 'echo' => '', 'plus_gamma' => 'gold'], 'annual' => ['voyager' => 5, 'sage' => 3], 'event' => ['csans' => 'gold']], | |
| 65 | [ | |
| 66 | [1780, null, 'Installed Ingress'], | |
| 67 | [1500, null, 'First Saturday Quito'], | |
| 68 | [1320, 16, 'Travel to Galápagos'], | |
| 69 | // Over three months | |
| 70 | [1150, 80, 'Backpacking South America'], | |
| 71 | [1100, null, 'Anomaly Darsana, Bogotá'], | |
| 72 | [860, 30, 'Travel to Germany'], | |
| 73 | [780, self::WEEKEND, 'Weekend in Mindo'], | |
| 74 | [700, null, 'Mission Day Cuenca'], | |
| 75 | [520, 10, 'Road trip to Lima'], | |
| 76 | [430, self::WEEKEND, 'Weekend in Montañita'], | |
| 77 | [365, null, 'Anomaly Orion, Medellín'], | |
| 78 | [self::NEW_YEAR, 5, 'New Year in Baños'], | |
| 79 | [250, self::WEEKEND, 'Weekend in Otavalo'], | |
| 80 | [180, 45, 'Travel to Spain'], | |
| 81 | [60, null, 'Recursion'], | |
| 82 | [20, self::WEEKEND, 'Weekend in Guayaquil'], | |
| 83 | ]], | |
| 84 | // Two years, monthly | |
| 85 | 'DemoKey' => ['Resistance', 14, 0.7, [24, 30], self::ALL_PARTS, true, | |
| 86 | ['anomaly' => ['apollo' => 'silver'], 'annual' => ['catalyst' => 2], 'event' => ['2026_summer_solstice' => 'bronze']], | |
| 87 | [ | |
| 88 | [690, null, 'Joined the Resistance'], | |
| 89 | [400, 60, 'Road trip through Mexico'], | |
| 90 | [200, self::WEEKEND, 'Weekend in Cuenca'], | |
| 91 | [150, null, 'Anomaly Apollo'], | |
| 92 | [45, self::WEEKEND, 'Mission day weekend'], | |
| 93 | ]], | |
| 94 | 'DemoOwl' => ['Enlightened', 12, 0.35, [10, 21], [ProfilePart::AGENT, ProfilePart::LATEST_STATS], false, [], [[100, null, 'A private note']]], | |
| 95 | 'DemoFox' => ['Resistance', 10, 0.2, [8, 14], [ProfilePart::AGENT, ProfilePart::AVATAR, ProfilePart::STATS_HISTORY], true, [], | |
| 96 | [[80, null, 'First Saturday'], [30, 5, 'Holidays at the beach']]], | |
| 97 | 'DemoBee' => ['Enlightened', 8, 0.08, [4, 14], [ProfilePart::AGENT, ProfilePart::CUSTOM_MEDALS], true, | |
| 98 | ['anomaly' => ['ctrl' => ''], 'annual' => ['paragon' => 1]], []], | |
| 99 | 'DemoCat' => ['Resistance', 5, 0.02, [1, 14], [ProfilePart::AGENT], false, [], []], | |
| 100 | // Chose to keep everything private, and one who has not chosen yet | |
| 101 | 'DemoAnt' => ['Enlightened', 11, 0.3, [6, 30], [], true, [], [[50, 7, 'Secret trip']]], | |
| 102 | self::NOT_CHOSEN => ['Resistance', 3, 0.005, [1, 14], [], false, [], []], | |
| 103 | ]; | |
| 104 | ||
| 105 | /** | |
| 106 | * Bannergress syncs: agent name => published, to-do, done, and the days ago the banners on the | |
| 107 | * done list were done (null: no day set). The banners are made up from BANNERS. | |
| 108 | */ | |
| 109 | private const array BANNERGRESS = [ | |
| 110 | 'DemoFrog' => [4, 9, 412, [12, 95, null, 410, 760, null, 1150]], | |
| 111 | 'DemoKey' => [0, 3, 57, [40, null, 300]], | |
| 112 | ]; | |
| 113 | ||
| 114 | /** Made-up banners: title, missions, where they start (latitude, longitude). */ | |
| 115 | private const array BANNERS = [ | |
| 116 | ['Quito Old Town', 18, -0.2202, -78.5123], ['Volcán Cotopaxi', 6, -0.6838, -78.4378], ['Galápagos Wildlife', 24, -0.7436, -90.3135], | |
| 117 | ['Cuenca Rivers', 12, -2.8974, -79.0045], ['Mitad del Mundo', 6, -0.0022, -78.4558], ['Baños Waterfalls', 18, -1.3964, -78.4247], | |
| 118 | ['Guayaquil Malecón', 12, -2.1962, -79.8862], ['Otavalo Market', 6, 0.2343, -78.2611], ['Bogotá Street Art', 36, 4.5981, -74.0758], | |
| 119 | ['Lima Coast', 12, -12.1219, -77.0300], ['Andes Railway', 30, -13.5170, -71.9785], ['Medellín Flowers', 18, 6.2442, -75.5812], | |
| 120 | ]; | |
| 121 | ||
| 122 | public function __construct( | |
| 123 | private EntityManagerInterface $entityManager, | |
| 124 | private UserRepository $userRepository, | |
| 125 | #[Autowire(param: 'kernel.environment')] private string $environment, | |
| 126 | ) { | |
| 127 | } | |
| 128 | ||
| 129 | public function __invoke(SymfonyStyle $io, #[Option(description: 'Only remove the demo agents')] bool $remove = false): int | |
| 130 | { | |
| 131 | if ('prod' === $this->environment) { | |
| 132 | $io->error('Demo agents are for dev only.'); | |
| 133 | ||
| 134 | return Command::FAILURE; | |
| 135 | } | |
| 136 | ||
| 137 | $removed = $this->removeDemoAgents(); | |
| 138 | if ($remove) { | |
| 139 | $io->success(\sprintf('Removed %d demo agents.', $removed)); | |
| 140 | ||
| 141 | return Command::SUCCESS; | |
| 142 | } | |
| 143 | ||
| 144 | foreach (self::AGENTS as $agentName => [$faction, $level, $share, [$imports, $every], $parts, $avatar, $customMedals, $events]) { | |
| 145 | $user = new User() | |
| 146 | ->setIdentifier(\strtolower($agentName).self::IDENTIFIER_SUFFIX) | |
| 147 | ->setAgentName($agentName) | |
| 148 | ->setCustomMedals($customMedals); | |
| 149 | if ($avatar) { | |
| 150 | $user->setAvatar($this->avatar($agentName, $faction), null); | |
| 151 | } | |
| 152 | ||
| 153 | $user->setBannergress($this->bannergress($agentName)); | |
| 154 | if (self::NOT_CHOSEN !== $agentName) { | |
| 155 | $user->chooseVisibleParts($parts, new \DateTimeImmutable()); | |
| 156 | } | |
| 157 | ||
| 158 | $this->entityManager->persist($user); | |
| 159 | $this->addStats($user, $agentName, $faction, $level, $share, $imports, $every); | |
| 160 | $this->addEvents($user, $events); | |
| 161 | $this->addDoneBanners($user, $agentName); | |
| 162 | } | |
| 163 | ||
| 164 | $this->entityManager->flush(); | |
| 165 | ||
| 166 | $io->success(\sprintf('Added %d demo agents (identifiers *%s); %d replaced.', \count(self::AGENTS), self::IDENTIFIER_SUFFIX, $removed)); | |
| 167 | ||
| 168 | return Command::SUCCESS; | |
| 169 | } | |
| 170 | ||
| 171 | private function removeDemoAgents(): int | |
| 172 | { | |
| 173 | $users = $this->userRepository->createQueryBuilder('u') | |
| 174 | ->where('u.identifier LIKE :suffix') | |
| 175 | ->setParameter('suffix', '%'.self::IDENTIFIER_SUFFIX) | |
| 176 | ->getQuery() | |
| 177 | ->getResult(); | |
| 178 | ||
| 179 | if ([] === $users) { | |
| 180 | return 0; | |
| 181 | } | |
| 182 | ||
| 183 | foreach ([AgentStat::class, TimelineEvent::class, DoneBanner::class] as $class) { | |
| 184 | $this->entityManager->createQuery('DELETE FROM '.$class.' s WHERE s.owner IN (:users)') | |
| 185 | ->setParameter('users', $users) | |
| 186 | ->execute(); | |
| 187 | } | |
| 188 | ||
| 189 | $this->entityManager->createQuery('DELETE FROM '.User::class.' u WHERE u IN (:users)') | |
| 190 | ->setParameter('users', $users) | |
| 191 | ->execute(); | |
| 192 | ||
| 193 | return \count($users); | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * @return array<string, mixed>|null as Bannergress::toArray(), null for an agent without a sync | |
| 198 | */ | |
| 199 | private function bannergress(string $agentName): ?array | |
| 200 | { | |
| 201 | if (!isset(self::BANNERGRESS[$agentName])) { | |
| 202 | return null; | |
| 203 | } | |
| 204 | ||
| 205 | [$published, $todo, $done] = self::BANNERGRESS[$agentName]; | |
| 206 | ||
| 207 | return new Bannergress( | |
| 208 | $published, | |
| 209 | $todo, | |
| 210 | $done, | |
| 211 | \array_slice($this->banners(), 0, \min($todo, 5)), | |
| 212 | publishedBanners: \array_slice($this->banners(), 12 - $published, $published), | |
| 213 | syncedAt: new \DateTimeImmutable('-2 days')->format(\DATE_ATOM), | |
| 214 | )->toArray(); | |
| 215 | } | |
| 216 | ||
| 217 | /** The done list: the banners after the to-do ones, some with the day they were done. */ | |
| 218 | private function addDoneBanners(User $user, string $agentName): void | |
| 219 | { | |
| 220 | foreach (self::BANNERGRESS[$agentName][3] ?? [] as $position => $daysAgo) { | |
| 221 | $synced = $this->banners()[5 + $position]; | |
| 222 | $banner = new DoneBanner(); | |
| 223 | $banner->owner = $user; | |
| 224 | $banner->bannerId = $synced['id']; | |
| 225 | $banner->title = $synced['title']; | |
| 226 | $banner->missions = $synced['missions']; | |
| 227 | $banner->startLatitude = $synced['startLatitude']; | |
| 228 | $banner->startLongitude = $synced['startLongitude']; | |
| 229 | $banner->position = $position; | |
| 230 | $banner->doneAt = null === $daysAgo ? null : new \DateTimeImmutable('today')->modify(\sprintf('-%d days', $daysAgo)); | |
| 231 | $this->entityManager->persist($banner); | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * @return list<array{id: string, title: string, missions: int, picture: null, startLatitude: float, startLongitude: float}> | |
| 237 | */ | |
| 238 | private function banners(): array | |
| 239 | { | |
| 240 | return \array_values(\array_map(static fn (array $banner): array => [ | |
| 241 | 'id' => \strtolower((string) \preg_replace('/[^A-Za-z]+/', '-', $banner[0])).'-demo', | |
| 242 | 'title' => $banner[0], | |
| 243 | 'missions' => $banner[1], | |
| 244 | 'picture' => null, | |
| 245 | 'startLatitude' => $banner[2], | |
| 246 | 'startLongitude' => $banner[3], | |
| 247 | ], self::BANNERS)); | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * [$imports] stats, [$every] days apart up to today. A long history starts small and grows to | |
| 252 | * [$share] of a veteran's values, irregularly: some periods are breaks, some bursts (an | |
| 253 | * anomaly, a holiday), and each stat varies around that. The level rises with it. | |
| 254 | */ | |
| 255 | private function addStats(User $user, string $agentName, string $faction, int $level, float $share, int $imports, int $every): void | |
| 256 | { | |
| 257 | // The same numbers on every run | |
| 258 | \mt_srand(\crc32($agentName)); | |
| 259 | $spread = \array_map(static fn (): float => \mt_rand(60, 120) / 100, self::VETERAN); | |
| 260 | // A few months of history start at 70 %, years from almost nothing | |
| 261 | $start = \max(0.02, 0.7 - $imports * $every / 1000); | |
| 262 | ||
| 263 | // How much each stat grew in each period, as a share of its total growth | |
| 264 | $pace = $this->pace($imports); | |
| 265 | $growth = \array_map( | |
| 266 | static fn (): array => self::shares(\array_map(static fn (float $period): float => $period * \mt_rand(60, 140) / 100, $pace)), | |
| 267 | self::VETERAN, | |
| 268 | ); | |
| 269 | ||
| 270 | $done = \array_fill_keys(\array_keys(self::VETERAN), 0.0); | |
| 271 | $played = 0.0; | |
| 272 | $paceShares = self::shares($pace); | |
| 273 | for ($import = 1; $import <= $imports; ++$import) { | |
| 274 | $played += $paceShares[$import - 1]; | |
| 275 | $values = []; | |
| 276 | foreach (self::VETERAN as $field => $veteran) { | |
| 277 | $done[$field] += $growth[$field][$import - 1]; | |
| 278 | $values[$field] = (int) \round($veteran * $share * ($start + (1 - $start) * $done[$field]) * $spread[$field]); | |
| 279 | } | |
| 280 | ||
| 281 | $this->entityManager->persist(new AgentStat($user, new AgentStatInput(...[ | |
| 282 | ...$values, | |
| 283 | 'datetime' => new \DateTimeImmutable('today 18:00')->modify(\sprintf('-%d days', $every * ($imports - $import)))->format(AgentStatInput::DATETIME_FORMAT), | |
| 284 | 'nickname' => $agentName, | |
| 285 | 'faction' => $faction, | |
| 286 | // Levels come faster than AP: the top level after a third of the play | |
| 287 | 'level' => \max(1, (int) \round($level * \min(1.0, $start + (1 - $start) * 3 * $played))), | |
| 288 | // A recursion in the last two months | |
| 289 | 'recursions' => $level >= 16 && $every * ($imports - $import) <= 60 ? 1 : 0, | |
| 290 | ]))); | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | /** | |
| 295 | * How much an agent plays in each of [$periods] periods: mostly 0.3 to 2, sometimes a break | |
| 296 | * (almost nothing), sometimes a burst (three times as much). The first period is the start. | |
| 297 | * | |
| 298 | * @return list<float> | |
| 299 | */ | |
| 300 | private function pace(int $periods): array | |
| 301 | { | |
| 302 | $pace = [0.0]; | |
| 303 | for ($period = 2; $period <= $periods; ++$period) { | |
| 304 | $pace[] = match (true) { | |
| 305 | \mt_rand(1, 100) <= 15 => 0.05, | |
| 306 | \mt_rand(1, 100) <= 12 => 3 * \mt_rand(80, 200) / 100, | |
| 307 | default => \mt_rand(30, 200) / 100, | |
| 308 | }; | |
| 309 | } | |
| 310 | ||
| 311 | return $pace; | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * [$weights] as shares of their sum (all 0 for a single period). | |
| 316 | * | |
| 317 | * @param list<float> $weights | |
| 318 | * | |
| 319 | * @return list<float> | |
| 320 | */ | |
| 321 | private static function shares(array $weights): array | |
| 322 | { | |
| 323 | $sum = \array_sum($weights); | |
| 324 | ||
| 325 | return \array_map(static fn (float $weight): float => $sum > 0 ? $weight / $sum : 0.0, $weights); | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * @param list<array{int|string, int|string|null, string}> $events days ago or NEW_YEAR, days long, WEEKEND or null (one day), text | |
| 330 | */ | |
| 331 | private function addEvents(User $user, array $events): void | |
| 332 | { | |
| 333 | $today = new \DateTimeImmutable('today'); | |
| 334 | $added = []; | |
| 335 | foreach (\array_filter($events, static fn (array $event): bool => self::NEW_YEAR !== $event[0]) as [$daysAgo, $days, $text]) { | |
| 336 | $added[] = $this->relativeEvent($user, $today->modify(\sprintf('-%d days', (int) $daysAgo)), $days, $text); | |
| 337 | } | |
| 338 | ||
| 339 | // After the others: travels cannot overlap, and those move with today | |
| 340 | foreach (\array_filter($events, static fn (array $event): bool => self::NEW_YEAR === $event[0]) as [, $days, $text]) { | |
| 341 | $added[] = $this->newYearTravel($user, (int) ($days ?? 1), $text, $added, $today); | |
| 342 | } | |
| 343 | ||
| 344 | foreach ($added as $event) { | |
| 345 | $this->entityManager->persist($event); | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | /** An event on [$date], [$days] long (null: one day), a WEEKEND from the Saturday on or before it. */ | |
| 350 | private function relativeEvent(User $user, \DateTimeImmutable $date, int|string|null $days, string $text): TimelineEvent | |
| 351 | { | |
| 352 | if (self::WEEKEND === $days) { | |
| 353 | $date = 'Sat' === $date->format('D') ? $date : $date->modify('last saturday'); | |
| 354 | $days = 2; | |
| 355 | } | |
| 356 | ||
| 357 | return $this->event($user, $date, null === $days ? null : $date->modify(\sprintf('+%d days', (int) $days - 1)), $text); | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * A travel from 30 December over the new year: the newest that is over and does not overlap | |
| 362 | * one of the [$events]. | |
| 363 | * | |
| 364 | * @param list<TimelineEvent> $events | |
| 365 | */ | |
| 366 | private function newYearTravel(User $user, int $days, string $text, array $events, \DateTimeImmutable $today): TimelineEvent | |
| 367 | { | |
| 368 | for ($year = (int) $today->format('Y') - 1;; --$year) { | |
| 369 | $start = new \DateTimeImmutable($year.'-12-30'); | |
| 370 | $end = $start->modify(\sprintf('+%d days', $days - 1)); | |
| 371 | $overlaps = \array_filter( | |
| 372 | $events, | |
| 373 | static fn (TimelineEvent $event): bool => $event->endDate instanceof \DateTimeImmutable && $event->date <= $end && $event->endDate >= $start, | |
| 374 | ); | |
| 375 | if ($end < $today && [] === $overlaps) { | |
| 376 | return $this->event($user, $start, $end, $text); | |
| 377 | } | |
| 378 | } | |
| 379 | } | |
| 380 | ||
| 381 | private function event(User $user, \DateTimeImmutable $date, ?\DateTimeImmutable $endDate, string $text): TimelineEvent | |
| 382 | { | |
| 383 | $event = new TimelineEvent(); | |
| 384 | $event->owner = $user; | |
| 385 | $event->date = $date; | |
| 386 | $event->endDate = $endDate; | |
| 387 | $event->text = $text; | |
| 388 | ||
| 389 | return $event; | |
| 390 | } | |
| 391 | ||
| 392 | /** A 100 px picture with the initials on the faction color, as the stored Google pictures. */ | |
| 393 | private function avatar(string $agentName, string $faction): string | |
| 394 | { | |
| 395 | $small = \imagecreatetruecolor(20, 20); | |
| 396 | [$red, $green, $blue] = 'Enlightened' === $faction ? [26, 182, 13] : [0, 100, 220]; | |
| 397 | \imagefill($small, 0, 0, (int) \imagecolorallocate($small, $red, $green, $blue)); | |
| 398 | \imagestring($small, 3, 3, 3, \substr($agentName, 4, 2), (int) \imagecolorallocate($small, 255, 255, 255)); | |
| 399 | ||
| 400 | $image = \imagescale($small, 100, 100, \IMG_NEAREST_NEIGHBOUR); | |
| 401 | \ob_start(); | |
| 402 | \imagejpeg($image ?: $small, null, 90); | |
| 403 | ||
| 404 | return 'data:image/jpeg;base64,'.\base64_encode((string) \ob_get_clean()); | |
| 405 | } | |
| 406 | } |