diff --git a/.env b/.env index 3491453fd..ed2c7e4bc 100644 --- a/.env +++ b/.env @@ -27,6 +27,12 @@ APP_CONNECT_FOUR_DOCTRINE_DBAL_SHARDS='connect-four' APP_CONNECT_FOUR_PUBLISH_TO_BROWSER_SHARDS='1' APP_CONNECT_FOUR_PREDIS_CLIENT_URL='redis://redis:6379?persistent=1' +############################ +# Tic Tac Toe Context # +############################ +APP_TIC_TAC_TOE_DOCTRINE_DBAL_URL='mysqli://root:password@localhost/tic-tac-toe?persistent=1&unix_socket=/var/run/proxysql/proxysql.sock' +APP_TIC_TAC_TOE_PREDIS_CLIENT_URL='redis://redis:6379?persistent=1' + ############################ # Identity Context # ############################ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 23f56ab8c..e4ff097bc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,6 +18,10 @@ The UI is composed using SSI (Server Side Includes) and Custom Elements. SSI ren different contexts into a page. Custom Elements encapsulate client-side behavior. Both serve as transclusion boundaries between contexts. +### CSRF Protection +CSRF protection is handled globally by `marein/symfony-standard-headers-csrf-bundle`. It validates Origin/Referer +headers on all unsafe requests at the kernel level. No per-route or per-form CSRF tokens are needed. + ## Conventions ### Wiring diff --git a/assets/css/app.css b/assets/css/app.css index 7e24e1930..5eb359c56 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,3 +1,5 @@ +@import "tic-tac-toe.css"; + html { scroll-padding-top: 10px; } diff --git a/assets/css/tic-tac-toe.css b/assets/css/tic-tac-toe.css new file mode 100644 index 000000000..8ab8df51e --- /dev/null +++ b/assets/css/tic-tac-toe.css @@ -0,0 +1,171 @@ +.gp-ttt-game { + --grid-cols: 3; + display: grid; + grid-template-columns: repeat(var(--grid-cols), 1fr); + grid-gap: var(--tblr-spacer-1); + background-color: var(--tblr-yellow); + border: var(--tblr-border-width) solid var(--tblr-yellow-darken); + border-radius: var(--tblr-border-radius); + padding: var(--tblr-spacer-2); +} + +.gp-ttt-game--disabled { + pointer-events: none; +} + +.gp-ttt-game__field { + position: relative; + text-align: initial; + width: 100%; + aspect-ratio: 1; + background-color: var(--tblr-yellow-lt); + border: var(--tblr-border-width) solid var(--tblr-yellow-darken); + border-radius: calc(var(--tblr-border-radius) / 2); + cursor: pointer; +} + +.gp-ttt-game__field:after { + content: ""; + position: absolute; + top: calc(var(--tblr-border-width) * -1); + left: calc(var(--tblr-border-width) * -1 + var(--tblr-spacer-1) / 2 * -1); + width: calc(100% + var(--tblr-border-width) * 2 + var(--tblr-spacer-1)); + height: calc(100% + var(--tblr-border-width) * 2 + var(--tblr-spacer-1)); +} + +.gp-ttt-game__field--highlight.gp-ttt-game__field--current { + box-shadow: 0 0 .5em var(--tblr-light); +} + +.gp-ttt-game__field--highlight { + border-color: var(--tblr-dark); +} + +.gp-ttt-game__field .gp-ttt-token-o { + position: absolute; + top: 10%; + left: 10%; + width: 80%; +} + +.gp-ttt-game-list .gp-ttt-token-o, +.gp-ttt-token-loading .gp-ttt-token-o { + border-width: 5px; +} + +.gp-ttt-token-o { + position: relative; + border-radius: 50%; + aspect-ratio: 1; + box-sizing: border-box; + border: 15px solid var(--tblr-orange); +} + +.gp-ttt-game__field .gp-ttt-token-x { + position: absolute; +} + +.gp-ttt-game__field .gp-ttt-token-x::before, +.gp-ttt-game__field .gp-ttt-token-x::after { + width: 80%; +} + +.gp-ttt-game-list .gp-ttt-token-x::before, +.gp-ttt-game-list .gp-ttt-token-x::after, +.gp-ttt-token-loading .gp-ttt-token-x::before, +.gp-ttt-token-loading .gp-ttt-token-x::after { + height: 5px; +} + +.gp-ttt-token-x { + position: relative; + display: inline-block; + width: 100%; + aspect-ratio: 1; +} + +.gp-ttt-token-x::before, +.gp-ttt-token-x::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 120%; + height: 15px; + background-color: var(--tblr-gray); +} + +.gp-ttt-token-x::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.gp-ttt-token-x::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +.gp-ttt-game__field--highlight .gp-ttt-token-o, +.gp-ttt-game__field--highlight .gp-ttt-token-x { + animation: gp-ttt-token-bounce 3s infinite; +} + +@keyframes gp-ttt-token-bounce { + 20%, 40%, 60% { + scale: 1; + } + 10% { + scale: .9; + } + 30% { + scale: .95; + } + 50% { + scale: .98; + } +} + +.gp-ttt-token-loading { + display: flex; + gap: var(--tblr-spacer-1); + margin: 0 auto var(--tblr-spacer-3); +} + +.gp-ttt-token-loading .gp-ttt-token-o, +.gp-ttt-token-loading .gp-ttt-token-x { + --token-index: 0; + scale: .9; + animation: gp-ttt-token-loading 1.4s linear calc(var(--token-index) * .1s) infinite; +} + +@keyframes gp-ttt-token-loading { + 0%, 100% { + scale: .9; + } + 15% { + scale: 1; + } + 30% { + scale: .9; + } +} + + +.gp-ttt-token-loading > * { + flex: 1 0 10%; +} + +[data-bs-theme=dark] .gp-ttt-game { + background-color: var(--tblr-bg-surface); +} + +[data-bs-theme=dark] .gp-ttt-game__field { + background-color: var(--tblr-bg-surface); +} + +[data-bs-theme=dark] .gp-ttt-game__field--highlight { + border-color: var(--tblr-light); +} + +[data-bs-theme=dark] .gp-ttt-token-x::before, +[data-bs-theme=dark] .gp-ttt-token-x::after { + background-color: var(--tblr-gray-300); +} diff --git a/assets/js/TicTacToe/AnimatedGame.js b/assets/js/TicTacToe/AnimatedGame.js new file mode 100644 index 000000000..54964ba81 --- /dev/null +++ b/assets/js/TicTacToe/AnimatedGame.js @@ -0,0 +1,79 @@ +import {html} from 'uhtml/node.js' + +customElements.define('tic-tac-toe-animated-game', class extends HTMLElement { + #plainElement; #moveElements; #abortController; + + connectedCallback() { + this.#plainElement = html`
`; + this.#moveElements = Array.from(this.querySelectorAll('[data-move]')).sort((a, b) => { + return parseInt(a.dataset.move) - parseInt(b.dataset.move); + }); + this.#abortController = new AbortController(); + + if (typeof AbortSignal?.timeout !== 'function' || typeof AbortSignal?.any !== 'function') { + return; + } + + this.addEventListener('mouseenter', this.#onMouseEnter); + this.addEventListener('mouseleave', this.#onMouseLeave); + } + + disconnectedCallback() { + this.#abortController.abort(); + } + + #onMouseEnter = () => { + this.#abortController = new AbortController(); + this.#runAnimation(); + } + + #onMouseLeave = () => { + this.#abortController.abort(); + this.#showFinalState(); + } + + #runAnimation = async () => { + this.#clear(); + + for (const [i, moveElement] of this.#moveElements.entries()) { + this.#moveElements.forEach(e => { + e.classList.remove('gp-ttt-game__field--highlight', 'gp-ttt-game__field--current') + }); + moveElement.classList.add('gp-ttt-game__field--highlight', 'gp-ttt-game__field--current'); + this.querySelector('[data-move="' + moveElement.dataset.move + '"]')?.replaceWith(moveElement); + + const isLastMove = i === this.#moveElements.length - 1; + isLastMove && this.#showFinalState(); + + try { + await new Promise((resolve, reject) => { + AbortSignal.any([this.#abortController.signal, AbortSignal.timeout(isLastMove ? 1500 : 350)]) + .addEventListener('abort', reject); + }); + } catch { + if (this.#abortController.signal.aborted) return; + } + } + + this.#runAnimation(); + } + + #clear() { + this.#moveElements.forEach(e => { + e.classList.remove('gp-ttt-game__field--highlight', 'gp-ttt-game__field--current'); + const clone = this.#plainElement.cloneNode(); + clone.dataset.move = e.dataset.move; + if (e.hasAttribute('data-win')) clone.dataset.win = e.dataset.win; + this.querySelector('[data-move="' + e.dataset.move + '"]')?.replaceWith(clone); + }); + } + + #showFinalState() { + this.#moveElements.forEach((e, k) => { + e.classList.remove('gp-ttt-game__field--highlight', 'gp-ttt-game__field--current'); + k === this.#moveElements.length - 1 && e.classList.add('gp-ttt-game__field--current'); + e.hasAttribute('data-win') && e.classList.add('gp-ttt-game__field--highlight'); + this.querySelector('[data-move="' + e.dataset.move + '"]')?.replaceWith(e); + }); + } +}); diff --git a/assets/js/TicTacToe/Challenges.js b/assets/js/TicTacToe/Challenges.js new file mode 100644 index 000000000..07f2e7e34 --- /dev/null +++ b/assets/js/TicTacToe/Challenges.js @@ -0,0 +1,169 @@ +import {html} from 'uhtml/node.js' +import * as sse from '../Common/EventSource.js' +import {createUsernameNode} from '../Identity/utils.js' + +/** + * @typedef {{challengeId: String, size: Number, preferredToken: Number|null, timer: String, challengerId: String, challengerUsername: String}} OpenChallenge + */ + +customElements.define('tic-tac-toe-challenges', class extends HTMLElement { + async connectedCallback() { + this._sseAbortController = new AbortController(); + + this.append(html` +| Player | +Config | +
|---|
+ {% if app.user ? app.user.userIdentifier == challenge.challengerId %} + Once another player joins, you'll be automatically redirected. + {% else %} + Player + {{ renderUsername(usernames[challenge.challengerId]) }} + is waiting for an opponent. + {% endif %} +
+
+ Standard • {{ challenge.size }} x {{ challenge.size }}
+ {{ ('ttt:token_' ~ challenge.preferredToken|default('random'))|trans }} • {{ ('ttt:' ~ challenge.timer)|trans }}
+
+ Place tokens, line them up, and outsmart your opponent. +
+ Play ++ ? + running games +
diff --git a/src/TicTacToe/Port/Adapter/Messaging/PublishDomainEventsToMessageBrokerSubscriber.php b/src/TicTacToe/Port/Adapter/Messaging/PublishDomainEventsToMessageBrokerSubscriber.php new file mode 100644 index 000000000..27997dddd --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Messaging/PublishDomainEventsToMessageBrokerSubscriber.php @@ -0,0 +1,55 @@ +content; + + $this->publisher->send( + new Message( + 'TicTacToe.' . $this->nameFromDomainEvent($content), + json_encode( + $this->normalizer->normalize($content, $content::class), + JSON_THROW_ON_ERROR + ), + $domainEvent->streamId + ) + ); + } + + public function commit(): void + { + $this->publisher->flush(); + } + + private function nameFromDomainEvent(object $domainEvent): string + { + return match ($domainEvent::class) { + ChallengeOpened::class => 'ChallengeOpened', + ChallengeAccepted::class => 'ChallengeAccepted', + ChallengeWithdrawn::class => 'ChallengeWithdrawn', + default => throw new RuntimeException($domainEvent::class . ' must be handled.') + }; + } +} diff --git a/src/TicTacToe/Port/Adapter/Messaging/PublishMessageBrokerEventsToBrowserMessageHandler.php b/src/TicTacToe/Port/Adapter/Messaging/PublishMessageBrokerEventsToBrowserMessageHandler.php new file mode 100644 index 000000000..a05ccf296 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Messaging/PublishMessageBrokerEventsToBrowserMessageHandler.php @@ -0,0 +1,46 @@ +name()) { + 'TicTacToe.ChallengeOpened' => $this->handleChallengeOpened($message), + 'TicTacToe.ChallengeAccepted', + 'TicTacToe.ChallengeWithdrawn' => $this->browserNotifier->publish( + ['ttt-lobby', 'ttt-challenge-' . $message->streamId()], + $message->name(), + $message->body() + ), + default => true + }; + } + + private function handleChallengeOpened(Message $message): void + { + $body = json_decode($message->body(), true, flags: JSON_THROW_ON_ERROR); + $body['challengerUsername'] = $this->usernames->byIds([$body['challengerId']])[$body['challengerId']]; + + $this->browserNotifier->publish( + ['ttt-lobby'], + $message->name(), + json_encode($body, JSON_THROW_ON_ERROR) + ); + } +} diff --git a/src/TicTacToe/Port/Adapter/Persistence/Jms/Common.Domain.DomainEvent.yml b/src/TicTacToe/Port/Adapter/Persistence/Jms/Common.Domain.DomainEvent.yml new file mode 100644 index 000000000..9a2572571 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Persistence/Jms/Common.Domain.DomainEvent.yml @@ -0,0 +1,7 @@ +Gaming\Common\Domain\DomainEvent: + discriminator: + field_name: '#' + map: + ChallengeAccepted: Gaming\TicTacToe\Domain\Challenge\Event\ChallengeAccepted + ChallengeOpened: Gaming\TicTacToe\Domain\Challenge\Event\ChallengeOpened + ChallengeWithdrawn: Gaming\TicTacToe\Domain\Challenge\Event\ChallengeWithdrawn diff --git a/src/TicTacToe/Port/Adapter/Persistence/Migration/Version20260116011332.php b/src/TicTacToe/Port/Adapter/Persistence/Migration/Version20260116011332.php new file mode 100644 index 000000000..0734ba2c1 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Persistence/Migration/Version20260116011332.php @@ -0,0 +1,23 @@ +content; + + match ($content::class) { + ChallengeOpened::class => $this->openChallengesStore->save( + new OpenChallenge( + $content->challengeId, + $content->size, + $content->preferredToken, + $content->timer, + $content->challengerId + ) + ), + ChallengeWithdrawn::class, + ChallengeAccepted::class => $this->openChallengesStore->remove($content->challengeId), + default => true + }; + } +} diff --git a/src/TicTacToe/Port/Adapter/Persistence/Repository/EventStoreChallenges.php b/src/TicTacToe/Port/Adapter/Persistence/Repository/EventStoreChallenges.php new file mode 100644 index 000000000..ee4e89c4a --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Persistence/Repository/EventStoreChallenges.php @@ -0,0 +1,64 @@ +appendDomainEvents($challenge); + } + + public function update(ChallengeId $challengeId, Closure $operation): void + { + $domainEvents = $this->eventStore->byStreamId($challengeId->toString()); + if (count($domainEvents) === 0 || !$domainEvents[0]->content instanceof ChallengeOpened) { + throw new ChallengeNotFoundException(); + } + + $challenge = Challenge::fromHistory( + $challengeId, + new DomainEvents($challengeId->toString(), count($domainEvents), $domainEvents) + ); + + $operation($challenge); + + $this->appendDomainEvents($challenge); + } + + /** + * @throws ConcurrencyException + */ + private function appendDomainEvents(Challenge $challenge): void + { + try { + $this->eventStore->append(...$challenge->flushDomainEvents()); + } catch (DuplicateVersionInStreamException) { + throw new ConcurrencyException(); + } + } +} diff --git a/src/TicTacToe/Port/Adapter/Persistence/Repository/PredisOpenChallengesStore.php b/src/TicTacToe/Port/Adapter/Persistence/Repository/PredisOpenChallengesStore.php new file mode 100644 index 000000000..378c7ff6c --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Persistence/Repository/PredisOpenChallengesStore.php @@ -0,0 +1,71 @@ +predis->pipeline(function (ClientContextInterface $pipeline) use ($openChallenge): void { + $pipeline->set( + $this->storageKeyForChallengeInfo($openChallenge->challengeId), + json_encode( + $this->normalizer->normalize($openChallenge, OpenChallenge::class), + JSON_THROW_ON_ERROR + ) + ); + + $pipeline->zadd( + $this->storageKey, + [$openChallenge->challengeId => microtime(true)] + ); + }); + } + + public function remove(string $challengeId): void + { + $this->predis->pipeline(function ($pipeline) use ($challengeId): void { + $pipeline->zrem($this->storageKey, $challengeId); + + $pipeline->expire($this->storageKeyForChallengeInfo($challengeId), self::CHALLENGE_INFO_EXPIRE_TIME); + }); + } + + public function all(int $limit): array + { + $challengeIds = $this->predis->zrange($this->storageKey, 0, $limit - 1); + if (count($challengeIds) === 0) { + return []; + } + + return array_map( + fn(string $openChallenge): OpenChallenge => $this->normalizer->denormalize( + json_decode($openChallenge, true, flags: JSON_THROW_ON_ERROR), + OpenChallenge::class + ), + $this->predis->mget(array_map($this->storageKeyForChallengeInfo(...), $challengeIds)) + ); + } + + private function storageKeyForChallengeInfo(string $challengeId): string + { + return $this->storageKey . ':' . $challengeId; + } +} diff --git a/src/TicTacToe/Port/Adapter/Translation/messages.en.yml b/src/TicTacToe/Port/Adapter/Translation/messages.en.yml new file mode 100644 index 000000000..f40e62a36 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Translation/messages.en.yml @@ -0,0 +1,8 @@ +ttt:move:15000: '15s/move' +ttt:move:15000_small: '15s' +ttt:move:30000: '30s/move' +ttt:move:30000_small: '30s' +ttt:token_random: 'Random' +ttt:token_random_small: 'R' +ttt:token_1: 'X' +ttt:token_2: 'O' diff --git a/src/WebInterface/Presentation/Http/View/home.html.twig b/src/WebInterface/Presentation/Http/View/home.html.twig index 5acb1a236..82fb36b3d 100644 --- a/src/WebInterface/Presentation/Http/View/home.html.twig +++ b/src/WebInterface/Presentation/Http/View/home.html.twig @@ -9,6 +9,9 @@