Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.38% covered (warning)
64.38%
103 / 160
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
MaxFieldGenerator
64.38% covered (warning)
64.38%
103 / 160
76.92% covered (warning)
76.92%
10 / 13
121.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildCommand
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 buildExternalCommand
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
5
 appendCommandOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getContentList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 convertWayPointsToMaxFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getWaypointsMap
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getImagePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 findFrames
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 generateVariant
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
90
 generateVariantName
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3declare(strict_types=1);
4
5namespace App\Service;
6
7use App\Entity\Waypoint;
8use App\Enum\MaxfieldEngineEnum;
9use DirectoryIterator;
10use RuntimeException;
11use Symfony\Component\DependencyInjection\Attribute\Autowire;
12use Symfony\Component\Filesystem\Filesystem;
13use Symfony\Component\Process\Process;
14
15/**
16 * This is for https://github.com/tvwenger/maxfield .
17 */
18class MaxFieldGenerator
19{
20    protected string $rootDir = '';
21
22    public function __construct(
23        #[Autowire('%kernel.project_dir%')] private readonly string $projectDir,
24        #[Autowire('%env(MAXFIELDS_EXEC)%')] private readonly string $maxfieldExec,
25        #[Autowire('%env(MAXFIELD_VERSION)%')] private readonly int $maxfieldVersion,
26        #[Autowire('%env(GOOGLE_API_KEY)%')] private readonly string $googleApiKey,
27        #[Autowire('%env(GOOGLE_API_SECRET)%')] private readonly string $googleApiSecret,
28        #[Autowire('%env(INTEL_URL)%')] private readonly string $intelUrl,
29        #[Autowire('%env(PHP_BINARY)%')] private readonly string $phpBinary = PHP_BINARY,
30    )
31    {
32        $this->rootDir = $projectDir.'/public/maxfields';
33    }
34
35    /**
36     * @param array<string, bool> $options
37     * @param list<array{int, int|null, string|null, string}> $wayPointMap
38     */
39    public function generate(
40        string $projectName,
41        string $wayPointList,
42        array $wayPointMap,
43        int $playersNum,
44        array $options,
45        MaxfieldEngineEnum $engine = MaxfieldEngineEnum::php,
46        string $dockerContainer = '',
47    ): void
48    {
49        $fileSystem = new Filesystem();
50
51        $projectRoot = $this->rootDir.'/'.$projectName;
52        $fileSystem->mkdir($projectRoot);
53        $fileName = $projectRoot.'/portals.txt';
54        $fileSystem->appendToFile($fileName, $wayPointList);
55
56        $fp = fopen($projectRoot.'/portals_id_map.csv', 'w');
57
58        if (false === $fp) {
59            throw new RuntimeException('Cannot open file: '.$projectRoot.'/portals_id_map.csv');
60        }
61
62        foreach ($wayPointMap as $fields) {
63            fputcsv($fp, $fields, escape: '\\');
64        }
65
66        fclose($fp);
67
68        $command = $this->buildCommand($projectRoot, $fileName, $playersNum, $options, $engine, $dockerContainer);
69
70        $fileSystem->dumpFile($projectRoot.'/command.txt', implode(' ', $command));
71
72        $process = new Process($command);
73        $process->start();
74    }
75
76    /**
77     * @param array<string, bool> $options
78     *
79     * @return list<string>
80     */
81    private function buildCommand(
82        string $projectRoot,
83        string $fileName,
84        int $playersNum,
85        array $options,
86        MaxfieldEngineEnum $engine = MaxfieldEngineEnum::php,
87        string $dockerContainer = '',
88    ): array
89    {
90        $logFile = $projectRoot.'/log.txt';
91
92        if ($engine === MaxfieldEngineEnum::php) {
93            $command = [
94                $this->phpBinary, $this->projectDir.'/bin/console',
95                'maxfield:plan', $fileName,
96                '--outdir', $projectRoot,
97                '--num-agents', (string) $playersNum,
98                '--output-csv',
99                '-v',
100            ];
101
102            return ['sh', '-c', implode(' ', array_map(escapeshellarg(...), $command)).' > '.escapeshellarg($logFile).' 2>&1'];
103        }
104
105        $command = $this->buildExternalCommand($projectRoot, $fileName, $playersNum, $options, $engine, $dockerContainer);
106
107        // Wrap in shell to redirect output to log file and run in background
108        return ['sh', '-c', implode(' ', array_map(escapeshellarg(...), $command)).' > '.escapeshellarg($logFile).' 2>&1'];
109    }
110
111    /**
112     * @param array<string, bool> $options
113     *
114     * @return list<string>
115     */
116    private function buildExternalCommand(
117        string $projectRoot,
118        string $fileName,
119        int $playersNum,
120        array $options,
121        MaxfieldEngineEnum $engine = MaxfieldEngineEnum::python,
122        string $dockerContainer = '',
123    ): array
124    {
125        if ($engine === MaxfieldEngineEnum::docker) {
126            $command = [
127                'docker', 'run',
128                '-v', $projectRoot.':/app/share',
129                '-t', $dockerContainer,
130                '/app/share/portals.txt',
131                '--outdir', '/app/share',
132                '--num_agents', (string) $playersNum,
133                '--output_csv',
134                '--num_cpus', '0',
135                '--num_field_iterations', '10',
136                '--max_route_solutions', '10',
137            ];
138        } elseif ($this->maxfieldVersion < 4) {
139            $command = [
140                'python', $this->maxfieldExec, $fileName,
141                '-d', $projectRoot,
142                '-f', 'output.pkl',
143                '-n', (string) $playersNum,
144            ];
145        } else {
146            $command = [
147                $this->maxfieldExec, $fileName,
148                '--outdir', $projectRoot,
149                '--num_agents', (string) $playersNum,
150                '--output_csv',
151                '--num_cpus', '0',
152                '--num_field_iterations', '10',
153                '--max_route_solutions', '10',
154            ];
155        }
156
157        if ($this->googleApiKey !== '' && $this->googleApiKey !== '0') {
158            $command[] = '--google_api_key';
159            $command[] = $this->googleApiKey;
160            $command[] = '--google_api_secret';
161            $command[] = $this->googleApiSecret;
162        }
163
164        return $this->appendCommandOptions($command, $options);
165    }
166
167    /**
168     * @param list<string> $command
169     * @param array<string, bool> $options
170     *
171     * @return list<string>
172     */
173    private function appendCommandOptions(array $command, array $options): array
174    {
175        if ($options['skip_plots']) {
176            $command[] = '--skip_plots';
177        }
178
179        if ($options['skip_step_plots']) {
180            $command[] = '--skip_step_plots';
181        }
182
183        $command[] = '--verbose';
184
185        return $command;
186    }
187
188    /**
189     * @return array<string>
190     */
191    public function getContentList(string $item): array
192    {
193        $list = [];
194
195        foreach (new DirectoryIterator($this->rootDir.'/'.$item) as $fileInfo) {
196            if ($fileInfo->isFile()) {
197                $list[] = $fileInfo->getFilename();
198            }
199        }
200
201        sort($list);
202
203        return $list;
204    }
205
206    /**
207     * @param Waypoint[] $wayPoints
208     */
209    public function convertWayPointsToMaxFields(array $wayPoints): string
210    {
211        $maxFields = [];
212
213        foreach ($wayPoints as $wayPoint) {
214            $points = $wayPoint->getLat().','.$wayPoint->getLon();
215            $name = str_replace([';', '#'], '', (string)$wayPoint->getName());
216            $maxFields[] = $name.'; '.$this->intelUrl
217                .'?ll='.$points.'&z=1&pll='.$points;
218        }
219
220        return implode("\n", $maxFields);
221    }
222
223    /**
224     * @param Waypoint[] $wayPoints
225     * @return list<array{int, int|null, string|null, string}>
226     */
227    public function getWaypointsMap(array $wayPoints): array
228    {
229        $map = [];
230
231        foreach ($wayPoints as $i => $wayPoint) {
232            $name = str_replace([';', '#', ','], '', (string)$wayPoint->getName());
233            $map[] = [$i, $wayPoint->getId(), $wayPoint->getGuid(), $name];
234        }
235
236        return $map;
237    }
238
239    public function getImagePath(string $item, string $image): string
240    {
241        return $this->rootDir.sprintf('/%s/%s', $item, $image);
242    }
243
244    public function remove(string $item): void
245    {
246        $fileSystem = new Filesystem();
247
248        $fileSystem->remove($this->rootDir.('/' . $item));
249    }
250
251    public function findFrames(string $item): int
252    {
253        $path = $this->rootDir.'/'.$item.'/frames';
254        $frames = 0;
255
256        if (false === file_exists($path)) {
257            return $frames;
258        }
259
260        foreach (new DirectoryIterator($path) as $file) {
261            if (preg_match(
262                '/frame_(\d+)/',
263                $file->getFilename(),
264                $matches
265            )
266            ) {
267                $x = (int)$matches[1];
268                $frames = max($x, $frames);
269            }
270        }
271
272        return $frames;
273    }
274
275    /**
276     * Generate a variant of an existing maxfield by shuffling portal order.
277     *
278     * @param array<string, bool> $options
279     */
280    public function generateVariant(
281        string $originalProjectName,
282        int $playersNum,
283        array $options,
284        MaxfieldEngineEnum $engine = MaxfieldEngineEnum::php,
285        string $dockerContainer = '',
286    ): string {
287        $originalRoot = $this->rootDir.'/'.$originalProjectName;
288
289        // Read original portals.txt
290        $portalsFile = $originalRoot.'/portals.txt';
291        if (!file_exists($portalsFile)) {
292            throw new RuntimeException('Original portals.txt not found: '.$portalsFile);
293        }
294
295        $portalsContent = file_get_contents($portalsFile);
296        if ($portalsContent === false) {
297            throw new RuntimeException('Cannot read original portals.txt');
298        }
299
300        // Read original portals_id_map.csv
301        $mapFile = $originalRoot.'/portals_id_map.csv';
302        if (!file_exists($mapFile)) {
303            throw new RuntimeException('Original portals_id_map.csv not found: '.$mapFile);
304        }
305
306        $mapContent = file_get_contents($mapFile);
307        if ($mapContent === false) {
308            throw new RuntimeException('Cannot read original portals_id_map.csv');
309        }
310
311        // Parse portals into array
312        $portals = explode("\n", trim($portalsContent));
313
314        // Parse map into array of CSV lines (each line is an array)
315        $mapLines = explode("\n", trim($mapContent));
316        $mapData = [];
317        foreach ($mapLines as $line) {
318            $mapData[] = str_getcsv($line, ',', '"', '\\');
319        }
320
321        // Shuffle - create indices array and shuffle it
322        $indices = range(0, count($portals) - 1);
323        shuffle($indices);
324
325        // Rebuild portals.txt with shuffled order
326        $shuffledPortals = [];
327        $shuffledMap = [];
328        foreach ($indices as $i) {
329            $shuffledPortals[] = $portals[$i];
330            $shuffledMap[] = $mapData[$i];
331        }
332
333        // Generate new project name with -v{N} suffix
334        $newProjectName = $this->generateVariantName($originalProjectName);
335
336        // Create new directory and write files
337        $newRoot = $this->rootDir.'/'.$newProjectName;
338        $fileSystem = new Filesystem();
339        $fileSystem->mkdir($newRoot);
340
341        // Write shuffled portals.txt
342        $fileSystem->dumpFile($newRoot.'/portals.txt', implode("\n", $shuffledPortals));
343
344        // Write shuffled portals_id_map.csv
345        $fp = fopen($newRoot.'/portals_id_map.csv', 'w');
346        if ($fp === false) {
347            throw new RuntimeException('Cannot create portals_id_map.csv in '.$newRoot);
348        }
349        foreach ($shuffledMap as $fields) {
350            fputcsv($fp, $fields, escape: '\\');
351        }
352        fclose($fp);
353
354        // Build the wayPointList and wayPointMap from shuffled data
355        $wayPointList = implode("\n", $shuffledPortals);
356        $wayPointMap = $shuffledMap;
357
358        // Generate the variant
359        $command = $this->buildCommand($newRoot, $newRoot.'/portals.txt', $playersNum, $options, $engine, $dockerContainer);
360        $fileSystem->dumpFile($newRoot.'/command.txt', implode(' ', $command));
361
362        $process = new Process($command);
363        $process->start();
364
365        return $newProjectName;
366    }
367
368    /**
369     * Generate next variant name: "foo-v1", "foo-v2", etc.
370     */
371    private function generateVariantName(string $originalName): string
372    {
373        // Check if original already ends with -v{N}
374        if (preg_match('/^(.+)-v(\d+)$/', $originalName, $matches)) {
375            $baseName = $matches[1];
376            $nextNum = (int)$matches[2] + 1;
377        } else {
378            $baseName = $originalName;
379            $nextNum = 1;
380        }
381
382        // Find next available number
383        $dirs = glob($this->rootDir.'/'.$baseName.'-v*', GLOB_ONLYDIR) ?: [];
384        $existingNums = [];
385        foreach ($dirs as $dir) {
386            if (preg_match('/-v(\d+)$/', $dir, $m)) {
387                $existingNums[] = (int)$m[1];
388            }
389        }
390
391        // Include the nextNum from above in check
392        while (in_array($nextNum, $existingNums, true)) {
393            $nextNum++;
394        }
395
396        return $baseName.'-v'.$nextNum;
397    }
398}