Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
64.38% |
103 / 160 |
|
76.92% |
10 / 13 |
CRAP | |
0.00% |
0 / 1 |
| MaxFieldGenerator | |
64.38% |
103 / 160 |
|
76.92% |
10 / 13 |
121.76 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| generate | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
3.00 | |||
| buildCommand | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| buildExternalCommand | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
5 | |||
| appendCommandOptions | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getContentList | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| convertWayPointsToMaxFields | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| getWaypointsMap | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getImagePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| remove | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| findFrames | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| generateVariant | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
90 | |||
| generateVariantName | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Service; |
| 6 | |
| 7 | use App\Entity\Waypoint; |
| 8 | use App\Enum\MaxfieldEngineEnum; |
| 9 | use DirectoryIterator; |
| 10 | use RuntimeException; |
| 11 | use Symfony\Component\DependencyInjection\Attribute\Autowire; |
| 12 | use Symfony\Component\Filesystem\Filesystem; |
| 13 | use Symfony\Component\Process\Process; |
| 14 | |
| 15 | /** |
| 16 | * This is for https://github.com/tvwenger/maxfield . |
| 17 | */ |
| 18 | class 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 | } |