diff --git a/src/Http/Handlers/GetTeamEditor.php b/src/Http/Handlers/GetTeamEditor.php
new file mode 100644
index 0000000..0e73d23
--- /dev/null
+++ b/src/Http/Handlers/GetTeamEditor.php
@@ -0,0 +1,29 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $csrf = $request->cookies['__csrf'] ?? '';
+ $scenarioId = $params['id'] ?? 'new';
+
+ $error = null;
+ $old = [];
+ $post = [];
+
+ ob_start();
+ require_once __DIR__ . '/../../Views/layout.php';
+ require __DIR__ . '/../../Views/team-editor.php';
+ $body = (string) ob_get_clean();
+
+ return Response::html(200, $body);
+ }
+}
diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php
new file mode 100644
index 0000000..6eb286c
--- /dev/null
+++ b/src/Http/Handlers/PostTeamEditor.php
@@ -0,0 +1,86 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $submitted = (string) ($request->post['_csrf'] ?? '');
+ $expected = (string) ($request->cookies['__csrf'] ?? '');
+ $secret = (string) ($request->server['__csrf_secret'] ?? '');
+
+ if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
+ return Response::html(403, '
Forbidden
');
+ }
+
+ try {
+ $draft = ScenarioDraft::fromPost($request->post);
+ $scenario = $draft->toScenario();
+ ScenarioValidator::validate($scenario);
+ } catch (\InvalidArgumentException $exception) {
+ return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post);
+ }
+
+ $json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
+ $url = (string) ($params['id'] ?? $scenario->id);
+ $body = <<
+
+Saved
+
+Scenario saved.
+
+
+
+HTML;
+
+ return Response::html(200, $body);
+ }
+
+ /** @param array $post */
+ private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response
+ {
+ $error = Escape::html($error);
+ $csrf = Escape::html($csrf);
+ $scenarioId = Escape::html($scenarioId);
+
+ $old = array_intersect_key($post, array_flip([
+ 'id',
+ 'name',
+ 'battlefieldWidth',
+ 'battlefieldHeight',
+ 'victoryCondition',
+ 'holdRoundsRequired',
+ ]));
+ $old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old);
+
+ $teamA = $post['teamA']['units'] ?? [];
+ $teamB = $post['teamB']['units'] ?? [];
+
+ ob_start();
+ require_once __DIR__ . '/../../Views/layout.php';
+ require __DIR__ . '/../../Views/team-editor.php';
+ $body = (string) ob_get_clean();
+
+ return Response::html(200, $body);
+ }
+
+ /** @return array */
+ private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array
+ {
+ return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario);
+ }
+}
diff --git a/tests/Integration/GetTeamEditorTest.php b/tests/Integration/GetTeamEditorTest.php
new file mode 100644
index 0000000..456573f
--- /dev/null
+++ b/tests/Integration/GetTeamEditorTest.php
@@ -0,0 +1,29 @@
+handle($request, ['id' => 'demo']);
+
+ self::assertSame(200, $response->status);
+ self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
+ self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
+ self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
+ self::assertStringContainsString('name="_csrf"', $response->body);
+ self::assertStringContainsString('name="id"', $response->body);
+ self::assertStringContainsString('name="victoryCondition"', $response->body);
+ self::assertStringContainsString('value="defender"', $response->body);
+ }
+}
diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php
new file mode 100644
index 0000000..c814f9f
--- /dev/null
+++ b/tests/Integration/PostTeamEditorTest.php
@@ -0,0 +1,107 @@
+buildRequest(post: ['id' => 'demo']);
+ $response = $handler->handle($request, ['id' => 'demo']);
+
+ self::assertSame(403, $response->status);
+ self::assertStringContainsString('Forbidden', $response->body);
+ }
+
+ public function testItRejectsARequestWithAnInvalidCsrfToken(): void
+ {
+ $handler = new PostTeamEditor();
+ $request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']);
+ $response = $handler->handle($request, ['id' => 'demo']);
+
+ self::assertSame(403, $response->status);
+ }
+
+ public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void
+ {
+ [$token, $cookie] = CsrfToken::issue(self::SECRET);
+ $handler = new PostTeamEditor();
+ $request = $this->buildRequest(
+ post: $this->validPost($token),
+ cookies: ['__csrf' => $cookie],
+ server: ['__csrf_secret' => self::SECRET],
+ );
+ $response = $handler->handle($request, ['id' => 'demo']);
+
+ self::assertSame(200, $response->status);
+ self::assertStringContainsString('localStorage.setItem', $response->body);
+ self::assertStringContainsString('scenario:demo', $response->body);
+ }
+
+ public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
+ {
+ [$token, $cookie] = CsrfToken::issue(self::SECRET);
+ $handler = new PostTeamEditor();
+ $post = $this->validPost($token);
+ $post['teamA']['units'][0]['maxHealth'] = '9999';
+ $request = $this->buildRequest(
+ post: $post,
+ cookies: ['__csrf' => $cookie],
+ server: ['__csrf_secret' => self::SECRET],
+ );
+ $response = $handler->handle($request, ['id' => 'demo']);
+
+ self::assertSame(200, $response->status);
+ self::assertStringContainsString('bf-errors', $response->body);
+ self::assertStringContainsString('outside archetype bounds', $response->body);
+ }
+
+ /**
+ * @param array $post
+ * @param array $cookies
+ * @param array $server
+ */
+ private function buildRequest(array $post, array $cookies = [], array $server = []): Request
+ {
+ return new Request([], $post, [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/team', 'application/x-www-form-urlencoded', '');
+ }
+
+ /** @return array */
+ private function validPost(string $token): array
+ {
+ return [
+ '_csrf' => $token,
+ 'id' => 'demo',
+ 'name' => 'Demo',
+ 'battlefieldWidth' => '8',
+ 'battlefieldHeight' => '8',
+ 'teamA' => [
+ 'units' => [
+ ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '0', 'y' => '0'],
+ ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '1', 'y' => '0'],
+ ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '2', 'y' => '0'],
+ ],
+ ],
+ 'teamB' => [
+ 'units' => [
+ ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '7', 'y' => '7'],
+ ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '6', 'y' => '7'],
+ ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '5', 'y' => '7'],
+ ],
+ ],
+ 'victoryCondition' => 'eliminate_all',
+ 'holdRoundsRequired' => '1',
+ ];
+ }
+}