Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.41% covered (warning)
58.41%
198 / 339
60.00% covered (warning)
60.00%
12 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
MaxFieldsController
58.41% covered (warning)
58.41%
198 / 339
60.00% covered (warning)
60.00%
12 / 20
422.58
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
 index
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 check
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 display
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 clearUserData
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
2.05
 submitUserData
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
6.06
 play
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 getData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getUserData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 generateMaxFields
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
2
 generateVariant
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 edit
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 delete
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 deleteFiles
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 status
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
6.00
 viewStatus
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 toggleFavourite
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 plan
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 plan2
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 exportMobile
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
272
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller;
6
7use Exception;
8use App\Entity\Maxfield;
9use App\Enum\MapBoxProfilesEnum;
10use App\Enum\MapBoxStylesEnum;
11use App\Enum\MapProvidersEnum;
12use App\Form\MaxfieldFormType;
13use App\Repository\MaxfieldRepository;
14use App\Repository\WaypointRepository;
15use App\Service\IngressHelper;
16use App\Service\MaxFieldGenerator;
17use App\Service\MaxFieldHelper;
18use App\Settings\UserSettings;
19use App\Type\MaxfieldCreateType;
20use App\Type\MaxfieldStatus;
21use App\Type\UserDataType;
22use Doctrine\ORM\EntityManagerInterface;
23use Elkuku\MaxfieldParser\JsonHelper;
24use Pagerfanta\Doctrine\ORM\QueryAdapter;
25use Pagerfanta\Pagerfanta;
26use Symfony\Component\HttpKernel\Profiler\Profiler;
27use Symfony\Component\DependencyInjection\Attribute\Autowire;
28use Symfony\Component\Filesystem\Exception\IOException;
29use Symfony\Component\HttpFoundation\JsonResponse;
30use Symfony\Component\HttpFoundation\RedirectResponse;
31use Symfony\Component\HttpFoundation\Request;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
34use Symfony\Component\Routing\Attribute\Route;
35use Symfony\Component\Routing\RouterInterface;
36use Symfony\Component\Security\Http\Attribute\IsGranted;
37use UnexpectedValueException;
38
39#[IsGranted('ROLE_AGENT')]
40class MaxFieldsController extends BaseController
41{
42    public function __construct(
43        private readonly MaxfieldRepository $maxfieldRepository,
44        private readonly MaxFieldHelper $maxFieldHelper,
45        private readonly IngressHelper $ingressHelper,
46        private readonly WaypointRepository $repository,
47        private readonly MaxFieldGenerator $maxFieldGenerator,
48        private readonly RouterInterface $router,
49        private readonly EntityManagerInterface $entityManager
50    ) {}
51
52    #[Route(path: 'maxfield/list', name: 'maxfields', methods: ['GET'])]
53    public function index(Request $request): Response
54    {
55        $page = $request->query->getInt('page', 1);
56        $pagerfanta = Pagerfanta::createForCurrentPageWithMaxPerPage(
57            new QueryAdapter($this->maxfieldRepository->createQueryBuilderSearch()),
58            $page,
59            20
60        );
61
62        $template = 'index';
63
64        $partial = $request->query->get('partial');
65
66        if ($partial) {
67            if (in_array(
68                $partial,
69                ['list_lg', 'list_sm'],
70                true
71            )
72            ) {
73                $template = '_' . $partial;
74            } else {
75                throw new UnexpectedValueException('Invalid partial');
76            }
77        }
78
79        return $this->render(
80            sprintf('maxfield/%s.html.twig', $template),
81            [
82                'favourites' => $this->getUser()?->getFavourites(),
83                'pagerfanta' => $pagerfanta,
84                'page' => $page,
85            ]
86        );
87    }
88
89    #[Route(path: 'maxfield/check', name: 'maxfields_check', methods: ['GET'])]
90    #[IsGranted('ROLE_ADMIN')]
91    public function check(): Response
92    {
93        $maxfieldFiles = $this->maxFieldHelper->getList();
94        $dbMaxfields = $this->maxfieldRepository->findAll();
95        $maxfields = [];
96        foreach ($dbMaxfields as $maxfield) {
97            $maxfieldStatus = new MaxfieldStatus($this->maxFieldHelper)
98                ->fromMaxfield($maxfield);
99            $maxfields[] = $maxfieldStatus;
100
101            $index = array_search($maxfieldStatus->getPath(), $maxfieldFiles, true);
102            if (false !== $index) {
103                unset($maxfieldFiles[$index]);
104            }
105        }
106
107        return $this->render(
108            'maxfield/check.html.twig',
109            [
110                'maxfields' => $maxfields,
111                'maxfieldFiles' => $maxfieldFiles,
112            ]
113        );
114    }
115
116    #[Route(path: 'maxfield/show/{path:maxfield}', name: 'max_fields_result', methods: ['GET'])]
117    public function display(Maxfield $maxfield): Response
118    {
119        $path = $maxfield->getPath() ?? '';
120        $info = $this->maxFieldHelper->getMaxField($path);
121        $waypointIdMap = $this->maxFieldHelper->getWaypointsIdMap($path);
122
123        return $this->render(
124            'maxfield/result.html.twig',
125            [
126                'maxfield' => $maxfield,
127                'info' => $info,
128                'waypointIdMap' => $waypointIdMap,
129            ]
130        );
131    }
132
133    #[Route(path: 'maxfield/clear-user-data/{path:maxfield}', name: 'maxfield_clear_user_data', methods: ['POST'])]
134    public function clearUserData(
135        Maxfield $maxfield,
136        Request $request,
137    ): JsonResponse
138    {
139        $response = [];
140        try {
141            /** @var array{agentNum: int|string} $data */
142            $data = json_decode($request->getContent(), true);
143
144            $agentNum = (int) $data['agentNum'];
145
146            $maxfield->setCurrentPointWithUser('-1', $agentNum);
147            $maxfield->setFarmDoneWithUser([], $agentNum);
148            $maxfield->setUserKeysWithUser([], $agentNum);
149            $this->entityManager->flush();
150            $response ['result'] = 'cleared';
151            $code = 200;
152        } catch (Exception $exception) {
153            $response['error'] = $exception->getMessage();
154            $code = 500;
155        }
156
157        return $this->json($response, $code);
158    }
159
160    #[Route(path: 'maxfield/submit-user-data/{path:maxfield}', name: 'maxfield_submit_user_data', methods: ['POST'])]
161    public function submitUserData(
162        Maxfield $maxfield,
163        Request $request,
164    ): JsonResponse
165    {
166        $response = [];
167        $status = 200;
168
169        /** @var array{agentNum: int|string, keys?: string, current_point?: string, farm_done?: array<int>} $data */
170        $data = json_decode($request->getContent(), true);
171
172        $agentNum = (int) $data['agentNum'];
173
174        $keys = $data['keys'] ?? null;
175        $currentPoint = $data['current_point'] ?? null;
176        $farmDone = $data['farm_done'] ?? null;
177
178        if ($currentPoint !== null) {
179            $maxfield->setCurrentPointWithUser((string) $currentPoint, $agentNum);
180            $this->entityManager->flush();
181        }
182
183        if ($farmDone !== null) {
184            $maxfield->setFarmDoneWithUser($farmDone, $agentNum);
185            $this->entityManager->flush();
186        }
187
188        if ($keys) {
189            $waypointIdMap = $this->maxFieldHelper->getWaypointsIdMap($maxfield->getPath() ?? '');
190            try {
191                $existingKeys = $this->ingressHelper->getExistingKeysForMaxfield($waypointIdMap, $keys);
192                if ($existingKeys !== []) {
193                    $maxfield->setUserKeysWithUser($existingKeys, $agentNum);
194                    $response['result'] = sprintf('Added keyinfo for %d portals.', count($existingKeys));
195
196                    $this->entityManager->flush();
197                } else {
198                    $response['error'] = 'No keys found :(';
199                    $status = 404;
200                }
201            } catch (Exception $exception) {
202                $response['error'] = $exception->getMessage();
203                $status = 500;
204            }
205        }
206
207        return $this->json($response, $status);
208    }
209
210    #[Route('maxfield/play/{path:maxfield}', name: 'maxfield_play', methods: ['GET'])]
211    public function play(Maxfield $maxfield): Response
212    {
213        $user = $this->getUser();
214        $userSettings = $user?->getUserParams();
215        $path = $maxfield->getPath() ?? '';
216
217        if ($userSettings && MapProvidersEnum::mapbox === $userSettings->mapProvider) {
218            return $this->render(
219                'maxfield/play2.html.twig',
220                [
221                    'maxfield' => $maxfield,
222                    'mapboxGlToken' => $userSettings->mapboxApiKey,
223                    'mapboxStylesOptions' => MapBoxStylesEnum::forSelect(),
224                    'mapboxProfilesOptions' => MapBoxProfilesEnum::forSelect(),
225                    'defaultStyle' => $userSettings->defaultStyle,
226                    'defaultProfile' => $userSettings->defaultProfile,
227                ]
228            );
229        }
230
231        return $this->render(
232            'maxfield/play.html.twig',
233            [
234                'maxfield' => $maxfield,
235                'jsonData' => new JsonHelper()
236                    ->getJson($this->maxFieldHelper->getParser($path)),
237                'waypointIdMap' => $this->maxFieldHelper->getWaypointsIdMap($path),
238            ]
239        );
240    }
241
242    #[Route('maxfield/get-data/{path:maxfield}', name: 'maxfield_get_data', methods: ['GET'])]
243    public function getData(Maxfield $maxfield): JsonResponse
244    {
245        $path = $maxfield->getPath() ?? '';
246        $json = new JsonHelper()
247            ->getJsonData($this->maxFieldHelper->getParser($path));
248
249        return $this->json([
250            'jsonData' => $json,
251            'waypointIdMap' => $this->maxFieldHelper->getWaypointsIdMap($path),
252        ]);
253
254
255    }
256
257    #[Route('maxfield/get-user-data/{path:maxfield}', name: 'maxfield_get_user_data', methods: ['POST'])]
258    public function getUserData(
259        Maxfield $maxfield,
260        #[MapRequestPayload] UserDataType $data,
261    ): JsonResponse
262    {
263        $userData = $maxfield->getUserData();
264
265        if ($userData && array_key_exists($data->userId, $userData)) {
266            return $this->json($userData[$data->userId]);
267        }
268
269        return $this->json([]);
270    }
271
272    #[Route(path: 'maxfield/export', name: 'export-maxfields', methods: ['POST'])]
273    public function generateMaxFields(
274        Request $request,
275        //   #[MapRequestPayload] MaxfieldCreateType $maxfieldType,
276
277    ): Response
278    {
279        $maxfieldType = new MaxfieldCreateType();
280        $maxfieldType->points = (string)$request->request->get('points');
281        $maxfieldType->buildName = (string)$request->request->get('buildName');
282        $maxfieldType->skipPlots = (bool)$request->request->get('skipPlots');
283        $maxfieldType->skipStepPlots = (bool)$request->request->get('skipStepPlots');
284        $maxfieldType->playersNum = (int)$request->request->get('playersNum');
285
286        $wayPoints = $this->repository->findBy(['id' => $maxfieldType->getPoints()]);
287        $maxField = $this->maxFieldGenerator->convertWayPointsToMaxFields($wayPoints);
288        $waypointMap = $this->maxFieldGenerator->getWaypointsMap($wayPoints);
289
290        $options = [
291            'skip_plots' => $maxfieldType->skipPlots,
292            'skip_step_plots' => $maxfieldType->skipStepPlots,
293        ];
294
295        $projectName = $maxfieldType->getProjectName();
296
297        $userSettings = $this->getUser()?->getUserParams() ?? new UserSettings();
298
299        $this->maxFieldGenerator->generate(
300            $projectName,
301            $maxField,
302            $waypointMap,
303            $maxfieldType->getPlayersNum(),
304            $options,
305            $userSettings->maxfieldEngine,
306            $userSettings->dockerContainer,
307        );
308
309        $maxfield = new Maxfield()
310            ->setName($maxfieldType->buildName)
311            ->setPath($projectName)
312            ->setOwner($this->getUser());
313
314        $this->entityManager->persist($maxfield);
315        $this->entityManager->flush();
316
317        return $this->render(
318            'maxfield/status.html.twig',
319            [
320                'maxfield' => $maxfield,
321            ]
322        );
323    }
324
325    #[Route(path: 'maxfield/generate-variant/{id}', name: 'maxfield_generate_variant', methods: ['POST'])]
326    public function generateVariant(
327        Maxfield $maxfield,
328        Request $request,
329    ): Response
330    {
331        $playersNum = (int)$request->request->get('playersNum', 1);
332
333        if ($playersNum < 1 || $playersNum > 10) {
334            $this->addFlash('danger', 'Número de agents inválido');
335            return $this->redirectToRoute('max_fields_result', ['path' => $maxfield->getPath()]);
336        }
337
338        $originalPath = $maxfield->getPath() ?? '';
339
340        // Read original options from command.txt if available
341        $options = [
342            'skip_plots' => false,
343            'skip_step_plots' => false,
344        ];
345
346        $commandFile = $this->maxFieldGenerator->getImagePath($originalPath, 'command.txt');
347        if (file_exists($commandFile)) {
348            $commandContent = file_get_contents($commandFile);
349            if ($commandContent !== false) {
350                $options['skip_plots'] = str_contains($commandContent, '--skip_plots');
351                $options['skip_step_plots'] = str_contains($commandContent, '--skip_step_plots');
352            }
353        }
354
355        $userSettings = $this->getUser()?->getUserParams() ?? new UserSettings();
356
357        try {
358            $newProjectName = $this->maxFieldGenerator->generateVariant(
359                $originalPath,
360                $playersNum,
361                $options,
362                $userSettings->maxfieldEngine,
363                $userSettings->dockerContainer,
364            );
365
366            // Extract the -vN suffix (e.g., "-v2", "-v3")
367            preg_match('/-v\d+$/', $newProjectName, $matches);
368            $vSuffix = $matches[0] ?? '-v1';
369            
370            $newMaxfield = new Maxfield()
371                ->setName($maxfield->getName().' '.$vSuffix)
372                ->setPath($newProjectName)
373                ->setOwner($this->getUser());
374
375            $this->entityManager->persist($newMaxfield);
376            $this->entityManager->flush();
377
378            // Render the status page directly (form has target="_blank")
379            return $this->render(
380                'maxfield/status.html.twig',
381                [
382                    'maxfield' => $newMaxfield,
383                ]
384            );
385        } catch (Exception $exception) {
386            $this->addFlash('danger', 'Error generando variante: '.$exception->getMessage());
387            return $this->redirectToRoute('max_fields_result', ['path' => $originalPath]);
388        }
389    }
390
391    #[Route(path: 'maxfield/edit/{id}', name: 'maxfield_edit', methods: [
392        'GET',
393        'POST',
394    ])]
395    public function edit(
396        Maxfield $maxfield,
397        Request $request,
398    ): RedirectResponse|Response
399    {
400        $this->denyAccessUnlessGranted(
401            'modify',
402            $maxfield,
403            'You are not allowed to edit this item :('
404        );
405
406        $form = $this->createForm(MaxfieldFormType::class, $maxfield);
407        $form->handleRequest($request);
408
409        if ($form->isSubmitted() && $form->isValid()) {
410            $maxfield = $form->getData();
411            $this->entityManager->persist($maxfield);
412            $this->entityManager->flush();
413            $this->addFlash('success', 'Maxfield updated!');
414
415            return $this->redirectToRoute('maxfields');
416        }
417
418        $template = $request->query->get('partial') ? '_form' : 'edit';
419
420        return $this->render(
421            sprintf('maxfield/%s.html.twig', $template),
422            [
423                'form' => $form,
424            ]
425        );
426    }
427
428    #[Route(path: 'maxfield/delete/{id}', name: 'max_fields_delete', methods: ['GET'])]
429    public function delete(
430        Maxfield $maxfield,
431        Request $request,
432    ): RedirectResponse
433    {
434        $this->denyAccessUnlessGranted(
435            'modify',
436            $maxfield,
437            'You are not allowed to delete this item :('
438        );
439
440        $item = $maxfield->getPath();
441        try {
442            $this->maxFieldGenerator->remove((string)$item);
443
444            $this->entityManager->remove($maxfield);
445            $this->entityManager->flush();
446
447            $this->addFlash(
448                'success',
449                sprintf('%s has been removed.', $item)
450            );
451        } catch (IOException $ioException) {
452            $this->addFlash('warning', $ioException->getMessage());
453        }
454
455        $referer = $this->getInternalReferer($request, $this->router);
456
457        return $this->redirectToRoute($referer ?: 'maxfields');
458    }
459
460    #[Route(path: 'maxfield/delete-files/{item}', name: 'maxfield_delete_files', methods: ['GET'])]
461    public function deleteFiles(string $item): RedirectResponse
462    {
463        if (!$this->isGranted('ROLE_ADMIN')) {
464            throw $this->createAccessDeniedException(
465                'You are not allowed to delete this item :('
466            );
467        }
468
469        try {
470            $this->maxFieldGenerator->remove($item);
471
472            $this->addFlash('success', sprintf('%s has been removed.', $item));
473        } catch (IOException $ioException) {
474            $this->addFlash('warning', $ioException->getMessage());
475        }
476
477        return $this->redirectToRoute('maxfields');
478    }
479
480    #[Route(path: 'maxfield/status/{id}', name: 'maxfield_status', methods: ['GET'])]
481    public function status(Maxfield $maxfield): JsonResponse
482    {
483        $maxfieldStatus = new MaxfieldStatus($this->maxFieldHelper)
484            ->fromMaxfield($maxfield);
485
486        $status = $maxfieldStatus->getStatus();
487
488        if ($status === 'finished' && $maxfield->getPlanResults() === null) {
489            $logContent = $this->maxFieldHelper->getLog($maxfield->getPath());
490            $planResults = $this->maxFieldHelper->parsePlanResults($logContent);
491            if ($planResults !== null) {
492                $maxfield->setPlanResults($planResults);
493                $this->entityManager->flush();
494            }
495        }
496
497        return $this->json($maxfieldStatus);
498    }
499
500    #[Route(path: 'maxfield/view-status/{id}', name: 'maxfield_view_status', methods: ['GET'])]
501    public function viewStatus(Maxfield $maxfield): Response
502    {
503        return $this->render(
504            'maxfield/status.html.twig',
505            [
506                'maxfield' => $maxfield,
507            ]
508        );
509    }
510
511    #[Route(path: 'maxfield/toggle-favourite/{id}', name: 'maxfield_toggle_favourite', methods: ['GET'])]
512    public function toggleFavourite(
513        Maxfield $maxfield
514    ): JsonResponse
515    {
516        $newState = $this->getUser()?->toggleFavourite($maxfield);
517
518        $this->entityManager->flush();
519
520        return $this->json([
521            'new-state' => $newState,
522        ]);
523    }
524
525    #[Route(path: 'maxfield/plan', name: 'app_maxfields_plan', methods: ['GET'])]
526    public function plan(
527        #[Autowire('%env(APP_DEFAULT_LAT)%')] float $defaultLat,
528        #[Autowire('%env(APP_DEFAULT_LON)%')] float $defaultLon,
529        #[Autowire('%env(APP_DEFAULT_ZOOM)%')] float $defaultZoom,
530    ): Response
531    {
532        $lat = $this->getUser()?->getParam('lat') ?: $defaultLat;
533        $lon = $this->getUser()?->getParam('lon') ?: $defaultLon;
534        $zoom = $this->getUser()?->getParam('zoom') ?: $defaultZoom;
535
536        return $this->render('maxfield/plan.html.twig', [
537            'defaultLat' => $lat,
538            'defaultLon' => $lon,
539            'defaultZoom' => $zoom,
540        ]);
541    }
542
543    #[Route(path: 'maxfield/plan2', name: 'app_maxfields_plan2', methods: ['GET'])]
544    public function plan2(
545        #[Autowire('%env(APP_DEFAULT_LAT)%')] float $defaultLat,
546        #[Autowire('%env(APP_DEFAULT_LON)%')] float $defaultLon,
547        #[Autowire('%env(APP_DEFAULT_ZOOM)%')] float $defaultZoom,
548    ): Response
549    {
550        $user = $this->getUser();
551        $userSettings = $user?->getUserParams();
552
553        $lat = $user?->getParam('lat') ?: $defaultLat;
554        $lon = $user?->getParam('lon') ?: $defaultLon;
555        $zoom = $user?->getParam('zoom') ?: $defaultZoom;
556
557        return $this->render('maxfield/plan2.html.twig', [
558            'lat' => $lat,
559            'lon' => $lon,
560            'zoom' => $zoom,
561            'token' => $userSettings->mapboxApiKey ?? '',
562        ]);
563    }
564
565    #[Route(path: 'maxfield/export-mobile/{path:maxfield}', name: 'maxfield_export_mobile', methods: ['GET'])]
566    public function exportMobile(
567        Maxfield $maxfield,
568        Request $request,
569        #[Autowire('%kernel.project_dir%')] string $projectDir,
570        ?Profiler $profiler,
571    ): Response
572    {
573        if ($profiler instanceof Profiler) {
574            $profiler->disable();
575        }
576        
577        $path = $maxfield->getPath() ?? '';
578        $info = $this->maxFieldHelper->getMaxField($path);
579        $waypointIdMap = $this->maxFieldHelper->getWaypointsIdMap($path);
580
581        // Get agent names from URL params
582        $agentNamesParam = $request->query->all('agent');
583        $numAgentsParam = (int)$request->query->get('count', '1');
584        
585        // Get user's base agent name
586        $user = $this->getUser();
587        $baseAgentName = $user?->getUserParams()?->agentName ?? '';
588
589        // Build agent names array - use actual number of agents
590        $numAgents = max($numAgentsParam, count($info->agentsInfo));
591        $agentNames = [];
592        for ($i = 1; $i <= $numAgents; ++$i) {
593            $name = $agentNamesParam[$i] ?? null;
594            if ($name) {
595                $agentNames[$i] = $name;
596            } elseif ($numAgents === 1 && $baseAgentName) {
597                // Single agent: use logged-in user's name
598                $agentNames[$i] = $baseAgentName;
599            } elseif ($numAgents > 1) {
600                // Multiple agents: use "Agent N" format
601                $agentNames[$i] = 'Agent ' . $i;
602            } else {
603                $agentNames[$i] = 'Agent';
604            }
605        }
606
607        // Get frames as base64
608        $framesDir = $projectDir . '/public/maxfields/' . $path . '/frames';
609        $frames = [];
610
611        if (is_dir($framesDir)) {
612            $files = scandir($framesDir);
613            foreach ($files as $file) {
614                if (preg_match('/^frame_\d+\.gif$/', $file)) {
615                    $fullPath = $framesDir . '/' . $file;
616                    $frames[$file] = 'data:image/gif;base64,' . base64_encode(file_get_contents($fullPath));
617                }
618            }
619        }
620
621        ksort($frames);
622
623        $html = $this->renderView('maxfield/export.html.twig', [
624            'maxfield' => $maxfield,
625            'info' => $info,
626            'waypointIdMap' => $waypointIdMap,
627            'frames' => $frames,
628            'agentNames' => $agentNames,
629            'numAgents' => $numAgents,
630        ]);
631
632        // Remove Symfony toolbar - the toolbar is injected by WebDebugToolbarListener after render
633        // The toolbar is inserted right before </body> so we need to cut it there
634        // First, close </body></html> if missing (our template should have them)
635        if (str_contains($html, '</body>')) {
636            $html = substr($html, 0, strpos($html, '</body>')) . '</body></html>';
637        } elseif (str_contains($html, '</html>')) {
638            $html = substr($html, 0, strpos($html, '</html>')) . '</html>';
639        }
640
641        // Force remove toolbar by finding its position in HTML and cutting
642        $toolbarStart = strpos($html, '<!-- START of Symfony Web Debug Toolbar -->');
643        if ($toolbarStart !== false) {
644            $toolbarEnd = strpos($html, '<!-- END of Symfony Web Debug Toolbar -->', $toolbarStart);
645            if ($toolbarEnd !== false) {
646                $toolbarEnd += strlen('<!-- END of Symfony Web Debug Toolbar -->');
647                $html = substr($html, 0, $toolbarStart) . substr($html, $toolbarEnd);
648            }
649        }
650
651        // Add closing tags if needed
652        if (str_contains($html, '</body>') === false && str_contains($html, '</html>') === false) {
653            $html .= '</body></html>';
654        }
655
656        return new Response($html, Response::HTTP_OK, ['Content-Type' => 'text/html; charset=UTF-8']);
657    }
658}