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
3declare(strict_types=1);
4
5namespace App\Command;
6
7use App\ApiResource\Bannergress;
8use App\Dto\AgentStatInput;
9use App\Entity\AgentStat;
10use App\Entity\DoneBanner;
11use App\Entity\TimelineEvent;
12use App\Entity\User;
13use App\Enum\ProfilePart;
14use App\Repository\UserRepository;
15use Doctrine\ORM\EntityManagerInterface;
16use Symfony\Component\Console\Attribute\AsCommand;
17use Symfony\Component\Console\Attribute\Option;
18use Symfony\Component\Console\Command\Command;
19use Symfony\Component\Console\Style\SymfonyStyle;
20use 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')]
28final 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}