From d0e61b926d4633c065e122f278fc0c69ffc0e009 Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Wed, 14 Jan 2026 23:09:15 +0100 Subject: [PATCH 01/23] Add animated home tile --- assets/css/tic-tac-toe.css | 137 ++++++++++++++++++ assets/js/TicTacToe/AnimatedGame.js | 84 +++++++++++ assets/js/app.js | 1 + config/tic-tac-toe/config.yml | 4 + config/tic-tac-toe/importmap.php | 7 + config/tic-tac-toe/routing.yml | 4 + config/tic-tac-toe/services/controller.yml | 10 ++ .../Port/Adapter/Http/FragmentController.php | 18 +++ .../Port/Adapter/Http/LobbyController.php | 16 ++ .../Port/Adapter/Http/View/base.html.twig | 13 ++ .../Adapter/Http/View/home-tile.html.twig | 38 +++++ .../Port/Adapter/Http/View/lobby.html.twig | 13 ++ .../Presentation/Http/View/home.html.twig | 3 + templates/layout/condensed.html.twig | 3 + 14 files changed, 351 insertions(+) create mode 100644 assets/css/tic-tac-toe.css create mode 100644 assets/js/TicTacToe/AnimatedGame.js create mode 100644 config/tic-tac-toe/config.yml create mode 100644 config/tic-tac-toe/importmap.php create mode 100644 config/tic-tac-toe/routing.yml create mode 100644 config/tic-tac-toe/services/controller.yml create mode 100644 src/TicTacToe/Port/Adapter/Http/FragmentController.php create mode 100644 src/TicTacToe/Port/Adapter/Http/LobbyController.php create mode 100644 src/TicTacToe/Port/Adapter/Http/View/base.html.twig create mode 100644 src/TicTacToe/Port/Adapter/Http/View/home-tile.html.twig create mode 100644 src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig diff --git a/assets/css/tic-tac-toe.css b/assets/css/tic-tac-toe.css new file mode 100644 index 000000000..1e1d63841 --- /dev/null +++ b/assets/css/tic-tac-toe.css @@ -0,0 +1,137 @@ +.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 { + 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 { + height: 5px; +} + +.gp-ttt-token-x { + position: relative; + 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; + } +} + +[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..656817636 --- /dev/null +++ b/assets/js/TicTacToe/AnimatedGame.js @@ -0,0 +1,84 @@ +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); + + if (this.matches(':hover')) { + this.#onMouseEnter(); + } + } + + 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/app.js b/assets/js/app.js index baea8bf4c..c8791a066 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2,6 +2,7 @@ import {client} from './Common/HttpClient.js' import './Common/NotificationList.js' import '@tabler/core/dist/css/tabler.min.css' import '../css/app.css' +import '../css/tic-tac-toe.css' window.fetch = (fetch => (resource, options = {}) => fetch( resource, diff --git a/config/tic-tac-toe/config.yml b/config/tic-tac-toe/config.yml new file mode 100644 index 000000000..5e4f28f87 --- /dev/null +++ b/config/tic-tac-toe/config.yml @@ -0,0 +1,4 @@ +imports: [{ resource: services/ }] + +twig: + paths: { '%kernel.project_dir%/src/TicTacToe/Port/Adapter/Http/View': tic-tac-toe } diff --git a/config/tic-tac-toe/importmap.php b/config/tic-tac-toe/importmap.php new file mode 100644 index 000000000..13de65fbf --- /dev/null +++ b/config/tic-tac-toe/importmap.php @@ -0,0 +1,7 @@ + ['path' => 'js/TicTacToe/AnimatedGame.js'], +]; diff --git a/config/tic-tac-toe/routing.yml b/config/tic-tac-toe/routing.yml new file mode 100644 index 000000000..800bda96d --- /dev/null +++ b/config/tic-tac-toe/routing.yml @@ -0,0 +1,4 @@ +tic_tac_toe_lobby: + path: /tic-tac-toe + methods: [GET] + controller: tic-tac-toe.lobby-controller::lobbyAction diff --git a/config/tic-tac-toe/services/controller.yml b/config/tic-tac-toe/services/controller.yml new file mode 100644 index 000000000..87797855a --- /dev/null +++ b/config/tic-tac-toe/services/controller.yml @@ -0,0 +1,10 @@ +services: + tic-tac-toe.fragment-controller: + class: Gaming\TicTacToe\Port\Adapter\Http\FragmentController + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] + + tic-tac-toe.lobby-controller: + class: Gaming\TicTacToe\Port\Adapter\Http\LobbyController + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] diff --git a/src/TicTacToe/Port/Adapter/Http/FragmentController.php b/src/TicTacToe/Port/Adapter/Http/FragmentController.php new file mode 100644 index 000000000..217405598 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/FragmentController.php @@ -0,0 +1,18 @@ +render('@tic-tac-toe/home-tile.html.twig'); + } +} diff --git a/src/TicTacToe/Port/Adapter/Http/LobbyController.php b/src/TicTacToe/Port/Adapter/Http/LobbyController.php new file mode 100644 index 000000000..4051488ca --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/LobbyController.php @@ -0,0 +1,16 @@ +render('@tic-tac-toe/lobby.html.twig'); + } +} diff --git a/src/TicTacToe/Port/Adapter/Http/View/base.html.twig b/src/TicTacToe/Port/Adapter/Http/View/base.html.twig new file mode 100644 index 000000000..a0f49b099 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/View/base.html.twig @@ -0,0 +1,13 @@ +{% extends 'layout/condensed.html.twig' %} + +{% block context_nav %} + +{% endblock %} diff --git a/src/TicTacToe/Port/Adapter/Http/View/home-tile.html.twig b/src/TicTacToe/Port/Adapter/Http/View/home-tile.html.twig new file mode 100644 index 000000000..4b15a0750 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/View/home-tile.html.twig @@ -0,0 +1,38 @@ + +
+

Tic Tac Toe

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Place tokens, line them up, and outsmart your opponent. +

+ Play +
+
diff --git a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig new file mode 100644 index 000000000..8ff92b1c5 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig @@ -0,0 +1,13 @@ +{% extends '@tic-tac-toe/base.html.twig' %} + +{% set page_title = 'Lobby' %} + +{% block content %} +
+
+
+ Not implemented yet. +
+
+
+{% endblock %} 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 @@
{{ render_ssi(controller('connect-four.fragment-controller::homeTileAction')) }}
+
+ {{ render_ssi(controller('tic-tac-toe.fragment-controller::homeTileAction')) }} +
{% endblock %} diff --git a/templates/layout/condensed.html.twig b/templates/layout/condensed.html.twig index 341aa3f71..6ffdd02be 100644 --- a/templates/layout/condensed.html.twig +++ b/templates/layout/condensed.html.twig @@ -20,12 +20,15 @@ From f39ced62de71f5d6d2ecac08e0cc4bc504078b50 Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Fri, 16 Jan 2026 22:24:13 +0100 Subject: [PATCH 02/23] Add challenge model --- .env | 6 + assets/css/tic-tac-toe.css | 22 ++- bin/tic-tac-toe/onEntrypoint | 15 ++ codeception.dist.yml | 1 + config/tic-tac-toe/config.yml | 16 ++ config/tic-tac-toe/migrations.yml | 5 + config/tic-tac-toe/routing.yml | 20 +++ config/tic-tac-toe/services/challenge.yml | 20 +++ config/tic-tac-toe/services/command_bus.yml | 13 ++ config/tic-tac-toe/services/controller.yml | 8 + config/tic-tac-toe/services/normalizer.yml | 13 ++ config/tic-tac-toe/services/persistence.yml | 16 ++ config/tic-tac-toe/services/query_bus.yml | 8 + project | 2 +- .../Challenge/Accept/AcceptHandler.php | 25 +++ .../Challenge/Accept/AcceptRequest.php | 19 ++ .../Challenge/GetById/GetByIdHandler.php | 34 ++++ .../Challenge/GetById/GetByIdRequest.php | 18 ++ .../Challenge/GetById/GetByIdResponse.php | 15 ++ .../Challenge/Open/OpenHandler.php | 25 +++ .../Challenge/Open/OpenRequest.php | 18 ++ .../Challenge/Open/OpenResponse.php | 13 ++ .../Challenge/Withdraw/WithdrawHandler.php | 25 +++ .../Challenge/Withdraw/WithdrawRequest.php | 19 ++ src/TicTacToe/Application/Model/Challenge.php | 53 ++++++ src/TicTacToe/Domain/Challenge/Challenge.php | 121 +++++++++++++ .../Domain/Challenge/ChallengeId.php | 48 +++++ src/TicTacToe/Domain/Challenge/Challenges.php | 27 +++ .../Challenge/Event/ChallengeAccepted.php | 22 +++ .../Challenge/Event/ChallengeOpened.php | 21 +++ .../Challenge/Event/ChallengeWithdrawn.php | 21 +++ .../CannotAcceptOwnChallengeException.php | 16 ++ .../Exception/CannotWithdrawException.php | 16 ++ .../Exception/ChallengeException.php | 11 ++ .../Exception/ChallengeNotFoundException.php | 16 ++ ...engerCannotAcceptOwnChallengeException.php | 16 ++ .../Challenge/Exception/NotOpenException.php | 16 ++ .../Port/Adapter/Http/ChallengeController.php | 67 +++++++ .../Http/View/challenge/show.html.twig | 56 ++++++ .../Port/Adapter/Http/View/lobby.html.twig | 4 +- .../Jms/Common.Domain.DomainEvent.yml | 7 + .../Migration/Version20260116011332.php | 23 +++ .../Repository/EventStoreChallenges.php | 63 +++++++ .../Domain/Challenge/ChallengeTest.php | 164 ++++++++++++++++++ 44 files changed, 1159 insertions(+), 5 deletions(-) create mode 100755 bin/tic-tac-toe/onEntrypoint create mode 100644 config/tic-tac-toe/migrations.yml create mode 100644 config/tic-tac-toe/services/challenge.yml create mode 100644 config/tic-tac-toe/services/command_bus.yml create mode 100644 config/tic-tac-toe/services/normalizer.yml create mode 100644 config/tic-tac-toe/services/persistence.yml create mode 100644 config/tic-tac-toe/services/query_bus.yml create mode 100644 src/TicTacToe/Application/Challenge/Accept/AcceptHandler.php create mode 100644 src/TicTacToe/Application/Challenge/Accept/AcceptRequest.php create mode 100644 src/TicTacToe/Application/Challenge/GetById/GetByIdHandler.php create mode 100644 src/TicTacToe/Application/Challenge/GetById/GetByIdRequest.php create mode 100644 src/TicTacToe/Application/Challenge/GetById/GetByIdResponse.php create mode 100644 src/TicTacToe/Application/Challenge/Open/OpenHandler.php create mode 100644 src/TicTacToe/Application/Challenge/Open/OpenRequest.php create mode 100644 src/TicTacToe/Application/Challenge/Open/OpenResponse.php create mode 100644 src/TicTacToe/Application/Challenge/Withdraw/WithdrawHandler.php create mode 100644 src/TicTacToe/Application/Challenge/Withdraw/WithdrawRequest.php create mode 100644 src/TicTacToe/Application/Model/Challenge.php create mode 100644 src/TicTacToe/Domain/Challenge/Challenge.php create mode 100644 src/TicTacToe/Domain/Challenge/ChallengeId.php create mode 100644 src/TicTacToe/Domain/Challenge/Challenges.php create mode 100644 src/TicTacToe/Domain/Challenge/Event/ChallengeAccepted.php create mode 100644 src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php create mode 100644 src/TicTacToe/Domain/Challenge/Event/ChallengeWithdrawn.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/CannotAcceptOwnChallengeException.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/CannotWithdrawException.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/ChallengeException.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/ChallengeNotFoundException.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/ChallengerCannotAcceptOwnChallengeException.php create mode 100644 src/TicTacToe/Domain/Challenge/Exception/NotOpenException.php create mode 100644 src/TicTacToe/Port/Adapter/Http/ChallengeController.php create mode 100644 src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig create mode 100644 src/TicTacToe/Port/Adapter/Persistence/Jms/Common.Domain.DomainEvent.yml create mode 100644 src/TicTacToe/Port/Adapter/Persistence/Migration/Version20260116011332.php create mode 100644 src/TicTacToe/Port/Adapter/Persistence/Repository/EventStoreChallenges.php create mode 100644 tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php 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/assets/css/tic-tac-toe.css b/assets/css/tic-tac-toe.css index 1e1d63841..d5799a67b 100644 --- a/assets/css/tic-tac-toe.css +++ b/assets/css/tic-tac-toe.css @@ -48,7 +48,8 @@ width: 80%; } -.gp-ttt-game-list .gp-ttt-token-o { +.gp-ttt-game-list .gp-ttt-token-o, +.gp-ttt-token-loading .gp-ttt-token-o { border-width: 5px; } @@ -70,12 +71,15 @@ } .gp-ttt-game-list .gp-ttt-token-x::before, -.gp-ttt-game-list .gp-ttt-token-x::after { +.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; } @@ -100,7 +104,9 @@ } .gp-ttt-game__field--highlight .gp-ttt-token-o, -.gp-ttt-game__field--highlight .gp-ttt-token-x { +.gp-ttt-game__field--highlight .gp-ttt-token-x, +.gp-ttt-token-loading .gp-ttt-token-o, +.gp-ttt-token-loading .gp-ttt-token-x { animation: gp-ttt-token-bounce 3s infinite; } @@ -119,6 +125,16 @@ } } +.gp-ttt-token-loading { + display: flex; + gap: var(--tblr-spacer-1); + margin: 0 auto var(--tblr-spacer-3); +} + +.gp-ttt-token-loading > * { + flex: 1 0 10%; +} + [data-bs-theme=dark] .gp-ttt-game { background-color: var(--tblr-bg-surface); } diff --git a/bin/tic-tac-toe/onEntrypoint b/bin/tic-tac-toe/onEntrypoint new file mode 100755 index 000000000..3e3f14664 --- /dev/null +++ b/bin/tic-tac-toe/onEntrypoint @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +if [ "${APP_TIC_TAC_TOE_RUN_MIGRATIONS}" = "1" ] || [ "${APP_RUN_MIGRATIONS}" = "1" ] +then + bin/console doctrine:database:create \ + --connection=tic_tac_toe \ + --if-not-exists + bin/console doctrine:migrations:migrate \ + --configuration=config/tic-tac-toe/migrations.yml \ + --conn=tic_tac_toe \ + --allow-no-migration \ + --no-interaction +fi diff --git a/codeception.dist.yml b/codeception.dist.yml index 8fcbc4968..77828b037 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -24,4 +24,5 @@ coverage: - src/ConnectFour/Domain/* - src/Identity/Domain/* - src/Memory/Domain/* + - src/TicTacToe/Domain/* - src/Chat/Application/* diff --git a/config/tic-tac-toe/config.yml b/config/tic-tac-toe/config.yml index 5e4f28f87..c9e91ccf8 100644 --- a/config/tic-tac-toe/config.yml +++ b/config/tic-tac-toe/config.yml @@ -1,4 +1,20 @@ imports: [{ resource: services/ }] +gaming_platform_bus: + buses: + tic_tac_toe_command: ~ + tic_tac_toe_query: ~ + twig: paths: { '%kernel.project_dir%/src/TicTacToe/Port/Adapter/Http/View': tic-tac-toe } + +doctrine: + dbal: + connections: + tic-tac-toe: + url: '%env(resolve:APP_TIC_TAC_TOE_DOCTRINE_DBAL_URL)%' + server_version: '8.2' + charset: utf8mb4 + default_table_options: + charset: utf8mb4 + collate: utf8mb4_unicode_ci diff --git a/config/tic-tac-toe/migrations.yml b/config/tic-tac-toe/migrations.yml new file mode 100644 index 000000000..d562d23c1 --- /dev/null +++ b/config/tic-tac-toe/migrations.yml @@ -0,0 +1,5 @@ +migrations_paths: + 'Gaming\TicTacToe\Port\Adapter\Persistence\Migration': '../../src/TicTacToe/Port/Adapter/Persistence/Migration' +table_storage: + table_name: migration_versions +connection: tic_tac_toe diff --git a/config/tic-tac-toe/routing.yml b/config/tic-tac-toe/routing.yml index 800bda96d..9d499e6e4 100644 --- a/config/tic-tac-toe/routing.yml +++ b/config/tic-tac-toe/routing.yml @@ -2,3 +2,23 @@ tic_tac_toe_lobby: path: /tic-tac-toe methods: [GET] controller: tic-tac-toe.lobby-controller::lobbyAction + +tic_tac_toe_open_challenge: + path: /tic-tac-toe/challenges/open + methods: [POST] + controller: tic-tac-toe.challenge-controller::openAction + +tic_tac_toe_withdraw_challenge: + path: /tic-tac-toe/challenge/{id}/abort + methods: [POST] + controller: tic-tac-toe.challenge-controller::withdrawAction + +tic_tac_toe_accept_challenge: + path: /tic-tac-toe/challenge/{id}/accept + methods: [POST] + controller: tic-tac-toe.challenge-controller::acceptAction + +tic_tac_toe_challenge: + path: /tic-tac-toe/challenge/{id} + methods: [GET] + controller: tic-tac-toe.challenge-controller::showAction diff --git a/config/tic-tac-toe/services/challenge.yml b/config/tic-tac-toe/services/challenge.yml new file mode 100644 index 000000000..f9224c3ab --- /dev/null +++ b/config/tic-tac-toe/services/challenge.yml @@ -0,0 +1,20 @@ +services: + tic-tac-toe.challenge-repository: + class: Gaming\TicTacToe\Port\Adapter\Persistence\Repository\EventStoreChallenges + arguments: ['@tic-tac-toe.event-store'] + + Gaming\TicTacToe\Application\Challenge\Accept\AcceptHandler: + arguments: ['@tic-tac-toe.challenge-repository'] + tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_command' }] + + Gaming\TicTacToe\Application\Challenge\Open\OpenHandler: + arguments: ['@tic-tac-toe.challenge-repository'] + tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_command' }] + + Gaming\TicTacToe\Application\Challenge\Withdraw\WithdrawHandler: + arguments: ['@tic-tac-toe.challenge-repository'] + tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_command' }] + + Gaming\TicTacToe\Application\Challenge\GetById\GetByIdHandler: + arguments: ['@tic-tac-toe.event-store'] + tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_query' }] diff --git a/config/tic-tac-toe/services/command_bus.yml b/config/tic-tac-toe/services/command_bus.yml new file mode 100644 index 000000000..21fa4e2cd --- /dev/null +++ b/config/tic-tac-toe/services/command_bus.yml @@ -0,0 +1,13 @@ +services: + tic-tac-toe.command-bus: + alias: gaming_platform_bus.tic_tac_toe_command + + tic-tac-toe.transactional-command-bus: + class: Gaming\Common\Bus\Integration\DoctrineTransactionalBus + decorates: 'tic-tac-toe.command-bus' + arguments: ['@.inner', '@tic-tac-toe.doctrine-dbal'] + + tic-tac-toe.validating-command-bus: + class: Gaming\Common\Bus\Integration\SymfonyValidatorBus + decorates: 'tic-tac-toe.command-bus' + arguments: ['@.inner', '@validator'] diff --git a/config/tic-tac-toe/services/controller.yml b/config/tic-tac-toe/services/controller.yml index 87797855a..4f3a6da69 100644 --- a/config/tic-tac-toe/services/controller.yml +++ b/config/tic-tac-toe/services/controller.yml @@ -8,3 +8,11 @@ services: class: Gaming\TicTacToe\Port\Adapter\Http\LobbyController calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] tags: ['controller.service_arguments', 'container.service_subscriber'] + + tic-tac-toe.challenge-controller: + class: Gaming\TicTacToe\Port\Adapter\Http\ChallengeController + arguments: [ + '@tic-tac-toe.command-bus', '@tic-tac-toe.query-bus', '@gaming.usernames', '@web-interface.security' + ] + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] diff --git a/config/tic-tac-toe/services/normalizer.yml b/config/tic-tac-toe/services/normalizer.yml new file mode 100644 index 000000000..48e40b43e --- /dev/null +++ b/config/tic-tac-toe/services/normalizer.yml @@ -0,0 +1,13 @@ +services: + tic-tac-toe.jms: + class: JMS\Serializer\Serializer + factory: ['Gaming\Common\JmsSerializer\JmsSerializerFactory', 'create'] + arguments: + - '%kernel.debug%' + - '%kernel.cache_dir%/tic-tac-toe/jms' + - { 'Gaming': '%kernel.project_dir%/src/TicTacToe/Port/Adapter/Persistence/Jms' } + - !tagged_iterator tic-tac-toe.jms.subscriber + + tic-tac-toe.normalizer: + class: Gaming\Common\Normalizer\Integration\JmsSerializerNormalizer + arguments: ['@tic-tac-toe.jms'] diff --git a/config/tic-tac-toe/services/persistence.yml b/config/tic-tac-toe/services/persistence.yml new file mode 100644 index 000000000..8c20adefc --- /dev/null +++ b/config/tic-tac-toe/services/persistence.yml @@ -0,0 +1,16 @@ +services: + tic-tac-toe.predis: + class: Predis\Client + arguments: ['%env(APP_TIC_TAC_TOE_PREDIS_CLIENT_URL)%', { prefix: 'tic-tac-toe:' }] + + tic-tac-toe.doctrine-dbal: + alias: 'doctrine.dbal.tic_tac_toe_connection' + + tic-tac-toe.event-store: + class: Gaming\Common\EventStore\Integration\Doctrine\DoctrineEventStore + arguments: + - '@tic-tac-toe.doctrine-dbal' + - 'event_store' + - !service + class: Gaming\Common\EventStore\Integration\JmsSerializer\JmsContentSerializer + arguments: ['@tic-tac-toe.jms', 'Gaming\Common\Domain\DomainEvent'] diff --git a/config/tic-tac-toe/services/query_bus.yml b/config/tic-tac-toe/services/query_bus.yml new file mode 100644 index 000000000..6e990916b --- /dev/null +++ b/config/tic-tac-toe/services/query_bus.yml @@ -0,0 +1,8 @@ +services: + tic-tac-toe.query-bus: + alias: gaming_platform_bus.tic_tac_toe_query + + tic-tac-toe.validating-query-bus: + class: Gaming\Common\Bus\Integration\SymfonyValidatorBus + decorates: 'tic-tac-toe.query-bus' + arguments: ['@.inner', '@validator'] diff --git a/project b/project index a907c7161..5f0121831 100755 --- a/project +++ b/project @@ -83,7 +83,7 @@ tests() { } unit() { - docker compose run --rm php vendor/bin/codecept run --skip acceptance --coverage-html + docker compose run --rm php vendor/bin/codecept run "$@" --skip acceptance --coverage-html } sniffer() { diff --git a/src/TicTacToe/Application/Challenge/Accept/AcceptHandler.php b/src/TicTacToe/Application/Challenge/Accept/AcceptHandler.php new file mode 100644 index 000000000..a2e6657e4 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/Accept/AcceptHandler.php @@ -0,0 +1,25 @@ +challenges->update( + ChallengeId::fromString($request->challengeId), + static fn(Challenge $challenge) => $challenge->accept($request->playerId) + ); + } +} diff --git a/src/TicTacToe/Application/Challenge/Accept/AcceptRequest.php b/src/TicTacToe/Application/Challenge/Accept/AcceptRequest.php new file mode 100644 index 000000000..93a3f1263 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/Accept/AcceptRequest.php @@ -0,0 +1,19 @@ + + */ +final class AcceptRequest implements Request +{ + public function __construct( + public readonly string $challengeId, + public readonly string $playerId + ) { + } +} diff --git a/src/TicTacToe/Application/Challenge/GetById/GetByIdHandler.php b/src/TicTacToe/Application/Challenge/GetById/GetByIdHandler.php new file mode 100644 index 000000000..01393bdff --- /dev/null +++ b/src/TicTacToe/Application/Challenge/GetById/GetByIdHandler.php @@ -0,0 +1,34 @@ +eventStore->byStreamId($request->challengeId); + if (count($domainEvents) === 0 || !$domainEvents[0]->content instanceof ChallengeOpened) { + throw new ChallengeNotFoundException(); + } + + return new GetByIdResponse( + array_reduce( + $domainEvents, + static fn(Challenge $challenge, $domainEvent) => $challenge->apply($domainEvent->content), + new Challenge($request->challengeId) + ) + ); + } +} diff --git a/src/TicTacToe/Application/Challenge/GetById/GetByIdRequest.php b/src/TicTacToe/Application/Challenge/GetById/GetByIdRequest.php new file mode 100644 index 000000000..402ff01d0 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/GetById/GetByIdRequest.php @@ -0,0 +1,18 @@ + + */ +final class GetByIdRequest implements Request +{ + public function __construct( + public readonly string $challengeId + ) { + } +} diff --git a/src/TicTacToe/Application/Challenge/GetById/GetByIdResponse.php b/src/TicTacToe/Application/Challenge/GetById/GetByIdResponse.php new file mode 100644 index 000000000..22c5df8d3 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/GetById/GetByIdResponse.php @@ -0,0 +1,15 @@ +challenges->nextIdentity(); + + $this->challenges->add(Challenge::open($challengeId, $request->challengerId)); + + return new OpenResponse($challengeId->toString()); + } +} diff --git a/src/TicTacToe/Application/Challenge/Open/OpenRequest.php b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php new file mode 100644 index 000000000..c6790df52 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php @@ -0,0 +1,18 @@ + + */ +final class OpenRequest implements Request +{ + public function __construct( + public readonly string $challengerId + ) { + } +} diff --git a/src/TicTacToe/Application/Challenge/Open/OpenResponse.php b/src/TicTacToe/Application/Challenge/Open/OpenResponse.php new file mode 100644 index 000000000..e8ad97830 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/Open/OpenResponse.php @@ -0,0 +1,13 @@ +challenges->update( + ChallengeId::fromString($request->challengeId), + static fn(Challenge $challenge) => $challenge->withdraw($request->playerId) + ); + } +} diff --git a/src/TicTacToe/Application/Challenge/Withdraw/WithdrawRequest.php b/src/TicTacToe/Application/Challenge/Withdraw/WithdrawRequest.php new file mode 100644 index 000000000..5380f6c55 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/Withdraw/WithdrawRequest.php @@ -0,0 +1,19 @@ + + */ +final class WithdrawRequest implements Request +{ + public function __construct( + public readonly string $challengeId, + public readonly string $playerId + ) { + } +} diff --git a/src/TicTacToe/Application/Model/Challenge.php b/src/TicTacToe/Application/Model/Challenge.php new file mode 100644 index 000000000..2e156abe8 --- /dev/null +++ b/src/TicTacToe/Application/Model/Challenge.php @@ -0,0 +1,53 @@ + $this->onChallengeOpened($domainEvent), + $domainEvent instanceof ChallengeAccepted => $this->onChallengeAccepted($domainEvent), + $domainEvent instanceof ChallengeWithdrawn => $this->onChallengeWithdrawn($domainEvent), + default => null, + }; + + return $this; + } + + private function onChallengeOpened(ChallengeOpened $event): void + { + $this->status = self::STATUS_OPEN; + $this->challengerId = $event->challengerId; + } + + private function onChallengeAccepted(ChallengeAccepted $event): void + { + $this->status = self::STATUS_ACCEPTED; + $this->acceptorId = $event->acceptorId; + } + + private function onChallengeWithdrawn(ChallengeWithdrawn $event): void + { + $this->status = self::STATUS_WITHDRAWN; + } +} diff --git a/src/TicTacToe/Domain/Challenge/Challenge.php b/src/TicTacToe/Domain/Challenge/Challenge.php new file mode 100644 index 000000000..86a841883 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/Challenge.php @@ -0,0 +1,121 @@ +toString())); + $challenge->record( + new ChallengeOpened( + $challengeId->toString(), + $challengerId + ) + ); + + return $challenge; + } + + public static function fromHistory(ChallengeId $challengeId, DomainEvents $domainEvents): self + { + $challenge = new self($challengeId, new DomainEvents((string)$challengeId, $domainEvents->streamVersion())); + foreach ($domainEvents->flush() as $domainEvent) { + $challenge->apply($domainEvent->content); + } + + return $challenge; + } + + /** + * @throws NotOpenException + * @throws CannotWithdrawException + */ + public function withdraw(string $playerId): void + { + if ($this->state !== self::STATE_OPEN) { + throw new NotOpenException(); + } + + if ($this->challengerId !== $playerId) { + throw new CannotWithdrawException(); + } + + $this->record( + new ChallengeWithdrawn( + $this->challengeId->toString(), + $this->challengerId + ) + ); + } + + /** + * @throws NotOpenException + * @throws CannotAcceptOwnChallengeException + */ + public function accept(string $acceptorId): void + { + if ($this->state !== self::STATE_OPEN) { + throw new NotOpenException(); + } + + if ($this->challengerId === $acceptorId) { + throw new CannotAcceptOwnChallengeException(); + } + + $this->record( + new ChallengeAccepted( + $this->challengeId->toString(), + $this->challengerId, + $acceptorId + ) + ); + } + + /** + * @return DomainEvent[] + */ + public function flushDomainEvents(): array + { + return $this->domainEvents->flush(); + } + + private function record(object $event): void + { + $this->domainEvents->append($event); + $this->apply($event); + } + + private function apply(object $event): void + { + match ($event::class) { + ChallengeOpened::class => $this->challengerId = $event->challengerId, + ChallengeAccepted::class, + ChallengeWithdrawn::class => $this->state = self::STATE_CLOSED, + }; + } +} diff --git a/src/TicTacToe/Domain/Challenge/ChallengeId.php b/src/TicTacToe/Domain/Challenge/ChallengeId.php new file mode 100644 index 000000000..24eabab46 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/ChallengeId.php @@ -0,0 +1,48 @@ +challengeId = $uuid->toRfc4122(); + } + + public static function generate(): ChallengeId + { + return new self(Uuid::v6()); + } + + /** + * @throws ChallengeNotFoundException + */ + public static function fromString(string $challengeId): ChallengeId + { + try { + return new self(Uuid::fromRfc4122($challengeId)); + } catch (Exception) { + // This occurs if the given string is an invalid Uuid, hence an invalid ChallengeId. + // Throw exception, that the challenge can't be found. + throw new ChallengeNotFoundException(); + } + } + + public function toString(): string + { + return $this->challengeId; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/TicTacToe/Domain/Challenge/Challenges.php b/src/TicTacToe/Domain/Challenge/Challenges.php new file mode 100644 index 000000000..930370ec7 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/Challenges.php @@ -0,0 +1,27 @@ +challengeId; + } +} diff --git a/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php new file mode 100644 index 000000000..352c30e16 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php @@ -0,0 +1,21 @@ +challengeId; + } +} diff --git a/src/TicTacToe/Domain/Challenge/Event/ChallengeWithdrawn.php b/src/TicTacToe/Domain/Challenge/Event/ChallengeWithdrawn.php new file mode 100644 index 000000000..7d0f5e9a2 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/Event/ChallengeWithdrawn.php @@ -0,0 +1,21 @@ +challengeId; + } +} diff --git a/src/TicTacToe/Domain/Challenge/Exception/CannotAcceptOwnChallengeException.php b/src/TicTacToe/Domain/Challenge/Exception/CannotAcceptOwnChallengeException.php new file mode 100644 index 000000000..151a076e9 --- /dev/null +++ b/src/TicTacToe/Domain/Challenge/Exception/CannotAcceptOwnChallengeException.php @@ -0,0 +1,16 @@ +redirectToRoute('tic_tac_toe_challenge', [ + 'id' => $this->commandBus->handle( + new OpenRequest($this->security->forceUser()->getUserIdentifier()) + )->challengeId + ]); + } + + public function showAction(string $id): Response + { + return $this->render('@tic-tac-toe/challenge/show.html.twig', [ + 'challenge' => $challenge = $this->queryBus->handle(new GetByIdRequest($id))->challenge, + 'usernames' => $this->usernames->byIds([$challenge->challengerId]) + ]); + } + + public function acceptAction(string $id): Response + { + $this->commandBus->handle( + new AcceptRequest( + $id, + $this->security->forceUser()->getUserIdentifier() + ) + ); + + return $this->redirectToRoute('tic_tac_toe_lobby'); + } + + public function withdrawAction(string $id): Response + { + $this->commandBus->handle( + new WithdrawRequest( + $id, + $this->security->forceUser()->getUserIdentifier() + ) + ); + + return $this->redirectToRoute('tic_tac_toe_lobby'); + } +} diff --git a/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig new file mode 100644 index 000000000..c2f08f98a --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig @@ -0,0 +1,56 @@ +{% extends '@tic-tac-toe/base.html.twig' %} +{% from "macro/username.html.twig" import render as renderUsername %} + +{% if app.user ? app.user.userIdentifier == challenge.challengerId %} + {% set page_title = 'Waiting for opponent...' %} +{% else %} + {% set page_title = 'Accept the challenge' %} +{% endif %} + +{% block content %} +
+
+
+
+

+ {% if app.user ? app.user.userIdentifier == challenge.challengerId %} + Waiting for opponent + {% else %} + Accept the challenge + {% endif %} +

+

+ {% 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 • 3 x 3
+ X • 15s/move +

+ {% if app.user ? app.user.userIdentifier == challenge.challengerId %} +
+ {% for i in 0..6 %} + + {% endfor %} +
+ +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig index 8ff92b1c5..b5636857f 100644 --- a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig +++ b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig @@ -6,7 +6,9 @@
- Not implemented yet. +
+ +
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 @@ +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/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php new file mode 100644 index 000000000..aa4c037db --- /dev/null +++ b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php @@ -0,0 +1,164 @@ +createOpenChallenge('player1'); + } + + #[Test] + public function itCanBeContinued(): void + { + $challengeId = ChallengeId::generate(); + + $challenge = Challenge::fromHistory($challengeId, new DomainEvents($challengeId->toString(), 1, [ + new DomainEvent( + $challengeId->toString(), + new ChallengeOpened($challengeId->toString(), 'player1'), + 1 + ) + ])); + + $this->assertCount(0, $challenge->flushDomainEvents()); + + $challenge->accept('player2'); + + $this->assertEquals($challenge->flushDomainEvents(), [ + new DomainEvent( + $challengeId->toString(), + new ChallengeAccepted($challengeId->toString(), 'player1', 'player2'), + 2 + ) + ]); + } + + #[Test] + public function challengerCanWithdrawTheirChallenge(): void + { + $challenge = $this->createOpenChallenge('player1'); + + $challenge->withdraw('player1'); + + $this->assertEquals($challenge->flushDomainEvents(), [ + new DomainEvent( + $challenge->challengeId->toString(), + new ChallengeWithdrawn($challenge->challengeId->toString(), 'player1'), + 2 + ) + ]); + } + + #[Test] + public function onlyChallengerCanWithdrawChallenge(): void + { + $this->expectException(CannotWithdrawException::class); + + $challenge = $this->createOpenChallenge('player1'); + + $challenge->withdraw('player2'); + } + + #[Test] + public function cannotWithdrawAlreadyWithdrawnChallenge(): void + { + $this->expectException(NotOpenException::class); + + $challenge = $this->createOpenChallenge('player1'); + $challenge->withdraw('player1'); + + $challenge->withdraw('player1'); + } + + #[Test] + public function cannotWithdrawAcceptedChallenge(): void + { + $this->expectException(NotOpenException::class); + + $challenge = $this->createOpenChallenge('player1'); + $challenge->accept('player2'); + + $challenge->withdraw('player1'); + } + + #[Test] + public function playersCanAcceptChallenge(): void + { + $challenge = $this->createOpenChallenge('player1'); + + $challenge->accept('player2'); + + $this->assertEquals($challenge->flushDomainEvents(), [ + new DomainEvent( + $challenge->challengeId->toString(), + new ChallengeAccepted($challenge->challengeId->toString(), 'player1', 'player2'), + 2 + ) + ]); + } + + #[Test] + public function challengerCannotAcceptOwnChallenge(): void + { + $this->expectException(CannotAcceptOwnChallengeException::class); + + $challenge = $this->createOpenChallenge('player1'); + + $challenge->accept('player1'); + } + + #[Test] + public function cannotAcceptAlreadyAcceptedChallenge(): void + { + $this->expectException(NotOpenException::class); + + $challenge = $this->createOpenChallenge('player1'); + $challenge->accept('player2'); + + $challenge->accept('another-player2'); + } + + #[Test] + public function cannotAcceptWithdrawnChallenge(): void + { + $this->expectException(NotOpenException::class); + + $challenge = $this->createOpenChallenge('player1'); + $challenge->withdraw('player1'); + + $challenge->accept('player2'); + } + + private function createOpenChallenge(string $challengerId): Challenge + { + $challenge = Challenge::open(ChallengeId::generate(), $challengerId); + + $this->assertEquals($challenge->flushDomainEvents(), [ + new DomainEvent( + $challenge->challengeId->toString(), + new ChallengeOpened($challenge->challengeId->toString(), $challengerId), + 1 + ) + ]); + + return $challenge; + } +} From 2260e479160bed17e8745ed0e170f83f632dbdcd Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Tue, 20 Jan 2026 07:47:11 +0100 Subject: [PATCH 03/23] Use same loading animations --- assets/css/app.css | 2 ++ assets/css/tic-tac-toe.css | 24 ++++++++++++++++--- assets/js/app.js | 1 - .../Http/View/challenge/show.html.twig | 3 ++- 4 files changed, 25 insertions(+), 5 deletions(-) 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 index d5799a67b..8ab8df51e 100644 --- a/assets/css/tic-tac-toe.css +++ b/assets/css/tic-tac-toe.css @@ -104,9 +104,7 @@ } .gp-ttt-game__field--highlight .gp-ttt-token-o, -.gp-ttt-game__field--highlight .gp-ttt-token-x, -.gp-ttt-token-loading .gp-ttt-token-o, -.gp-ttt-token-loading .gp-ttt-token-x { +.gp-ttt-game__field--highlight .gp-ttt-token-x { animation: gp-ttt-token-bounce 3s infinite; } @@ -131,6 +129,26 @@ 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%; } diff --git a/assets/js/app.js b/assets/js/app.js index c8791a066..baea8bf4c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2,7 +2,6 @@ import {client} from './Common/HttpClient.js' import './Common/NotificationList.js' import '@tabler/core/dist/css/tabler.min.css' import '../css/app.css' -import '../css/tic-tac-toe.css' window.fetch = (fetch => (resource, options = {}) => fetch( resource, diff --git a/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig index c2f08f98a..ebff24f2a 100644 --- a/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig +++ b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig @@ -35,7 +35,8 @@ {% if app.user ? app.user.userIdentifier == challenge.challengerId %}
{% for i in 0..6 %} - + {% endfor %}
From f9bf5756781e29b309849249e1a19fb96270767d Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Tue, 20 Jan 2026 07:55:16 +0100 Subject: [PATCH 04/23] Use left fold --- src/TicTacToe/Domain/Challenge/Challenge.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/TicTacToe/Domain/Challenge/Challenge.php b/src/TicTacToe/Domain/Challenge/Challenge.php index 86a841883..63c050006 100644 --- a/src/TicTacToe/Domain/Challenge/Challenge.php +++ b/src/TicTacToe/Domain/Challenge/Challenge.php @@ -43,12 +43,11 @@ public static function open(ChallengeId $challengeId, string $challengerId): sel public static function fromHistory(ChallengeId $challengeId, DomainEvents $domainEvents): self { - $challenge = new self($challengeId, new DomainEvents((string)$challengeId, $domainEvents->streamVersion())); - foreach ($domainEvents->flush() as $domainEvent) { - $challenge->apply($domainEvent->content); - } - - return $challenge; + return array_reduce( + $domainEvents->flush(), + static fn(Challenge $challenge, DomainEvent $event): Challenge => $challenge->apply($event->content), + new self($challengeId, new DomainEvents((string)$challengeId, $domainEvents->streamVersion())) + ); } /** @@ -110,12 +109,15 @@ private function record(object $event): void $this->apply($event); } - private function apply(object $event): void + private function apply(object $event): self { match ($event::class) { ChallengeOpened::class => $this->challengerId = $event->challengerId, ChallengeAccepted::class, ChallengeWithdrawn::class => $this->state = self::STATE_CLOSED, + default => null }; + + return $this; } } From b858425fbe266600ce77e142a330a9c7952ff543 Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Fri, 23 Jan 2026 22:35:46 +0100 Subject: [PATCH 05/23] List open challenges --- assets/js/TicTacToe/Challenges.js | 160 ++++++++++++++++++ config/tic-tac-toe/importmap.php | 1 + config/tic-tac-toe/routing.yml | 12 ++ config/tic-tac-toe/services/challenge.yml | 8 + config/tic-tac-toe/services/console.yml | 15 ++ config/tic-tac-toe/services/consumer.yml | 17 ++ config/tic-tac-toe/services/controller.yml | 7 + config/tic-tac-toe/services/subscriber.yml | 8 + docker-compose.yml | 5 + .../Application/Challenge/All/AllHandler.php | 24 +++ .../Application/Challenge/All/AllRequest.php | 18 ++ .../Application/Challenge/All/AllResponse.php | 18 ++ .../Challenge/Open/OpenHandler.php | 2 +- .../Challenge/Open/OpenRequest.php | 2 +- src/TicTacToe/Application/Model/Challenge.php | 2 +- .../Model/OpenChallenges/OpenChallenge.php | 16 ++ .../OpenChallenges/OpenChallengesStore.php | 17 ++ src/TicTacToe/Domain/Challenge/Challenge.php | 8 +- .../Challenge/Event/ChallengeOpened.php | 4 +- .../Port/Adapter/Http/ApiController.php | 46 +++++ .../Port/Adapter/Http/LobbyController.php | 20 ++- .../Port/Adapter/Http/View/lobby.html.twig | 33 +++- ...hDomainEventsToMessageBrokerSubscriber.php | 55 ++++++ ...ageBrokerEventsToBrowserMessageHandler.php | 46 +++++ .../Projection/OpenChallengesProjection.php | 43 +++++ .../Repository/PredisOpenChallengesStore.php | 71 ++++++++ .../Domain/Challenge/ChallengeTest.php | 4 +- 27 files changed, 646 insertions(+), 16 deletions(-) create mode 100644 assets/js/TicTacToe/Challenges.js create mode 100644 config/tic-tac-toe/services/console.yml create mode 100644 config/tic-tac-toe/services/consumer.yml create mode 100644 config/tic-tac-toe/services/subscriber.yml create mode 100644 src/TicTacToe/Application/Challenge/All/AllHandler.php create mode 100644 src/TicTacToe/Application/Challenge/All/AllRequest.php create mode 100644 src/TicTacToe/Application/Challenge/All/AllResponse.php create mode 100644 src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php create mode 100644 src/TicTacToe/Application/Model/OpenChallenges/OpenChallengesStore.php create mode 100644 src/TicTacToe/Port/Adapter/Http/ApiController.php create mode 100644 src/TicTacToe/Port/Adapter/Messaging/PublishDomainEventsToMessageBrokerSubscriber.php create mode 100644 src/TicTacToe/Port/Adapter/Messaging/PublishMessageBrokerEventsToBrowserMessageHandler.php create mode 100644 src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php create mode 100644 src/TicTacToe/Port/Adapter/Persistence/Repository/PredisOpenChallengesStore.php diff --git a/assets/js/TicTacToe/Challenges.js b/assets/js/TicTacToe/Challenges.js new file mode 100644 index 000000000..c90fe0b3e --- /dev/null +++ b/assets/js/TicTacToe/Challenges.js @@ -0,0 +1,160 @@ +import {html} from 'uhtml/node.js' +import * as sse from '../Common/EventSource.js' +import {createUsernameNode} from '../Identity/utils.js' + +/** + * @typedef {{challengeId: String, width: Number, height: Number, playerId: String, playerUsername: String}} OpenChallenge + */ + +customElements.define('tic-tac-toe-challenges', class extends HTMLElement { + async connectedCallback() { + this._sseAbortController = new AbortController(); + + this.append(html` +
+ + + + + + + + + ${this._challenges = html``} +
PlayerSizeRating
+
+ `); + + this._playerId = this.getAttribute('player-id'); + this._maximumNumberOfChallengesInList = parseInt(this.getAttribute('maximum-number-of-challenges')); + this._pendingOpenChallenges = new Map(); + this._scheduleRenderTimeout = null; + this._useScheduleRenderAfter = Date.now() + 750; + const usernames = JSON.parse(this.getAttribute('usernames')); + JSON.parse(this.getAttribute("open-challenges")).forEach(challenge => { + this._pendingOpenChallenges.set( + challenge.challengeId, + {...challenge, playerUsername: usernames[challenge.playerId]} + ); + }); + + await this._render(false); + this._registerEventHandler(); + } + + disconnectedCallback() { + window.removeEventListener('WebInterface.UserArrived', this._onUserArrived); + this._sseAbortController.abort(); + } + + _render = async withLoading => { + this._scheduleRenderTimeout = null; + if (withLoading) { + this._challenges.classList.add('gp-loading'); + await new Promise(r => setTimeout(r, 250)); + } + + this._challenges.querySelectorAll('[data-deleted]').forEach(row => row.remove()); + + let count = this._challenges.children.length; + for (const [challengeId, openChallenge] of this._pendingOpenChallenges) { + if (count >= this._maximumNumberOfChallengesInList) break; + this._challenges.appendChild(this._createChallengeNode(openChallenge)); + this._pendingOpenChallenges.delete(challengeId); + count++; + } + + this._challenges.classList.remove('gp-loading'); + } + + _scheduleRender = () => { + if (this._scheduleRenderTimeout) return; + this._scheduleRenderTimeout = setTimeout(() => this._render(true), 3000); + } + + /** + * @param {OpenChallenge} openChallenge + * @returns {Node} + */ + _createChallengeNode = openChallenge => { + const row = html` + + ${createUsernameNode(openChallenge.playerUsername)} + ${openChallenge.width} x ${openChallenge.height} + + + `; + + row.addEventListener('click', event => { + event.preventDefault(); + if (row.classList.contains('table-secondary') || row.closest('.gp-loading')) return; + + row.classList.add('table-secondary', 'cursor-default'); + row.classList.remove('table-success', 'table-light'); + + if (this._playerId === openChallenge.playerId) { + const url = this.getAttribute('withdraw-url').replace('CHALLENGE_ID', openChallenge.challengeId); + fetch(url, {method: 'POST'}) + .then(() => true) + .catch(() => this._removeChallenge(openChallenge.challengeId)); + } else { + const url = this.getAttribute('accept-url').replace('CHALLENGE_ID', openChallenge.challengeId); + fetch(url, {method: 'POST'}) + .then(() => alert('Redirect to game.')) + .catch(() => this._removeChallenge(openChallenge.challengeId)); + } + }); + + return row; + } + + _removeChallenge = challengeId => { + this._pendingOpenChallenges.delete(challengeId); + + const row = this.querySelector('[data-challenge-id="' + challengeId + '"]'); + if (!row) return; + + row.dataset.deleted = 'true'; + row.classList.add('table-secondary', 'cursor-default'); + row.classList.remove('table-success', 'table-light'); + + Date.now() > this._useScheduleRenderAfter ? this._scheduleRender() : this._render(false); + } + + _onChallengeOpened = event => { + const openChallenge = { + challengeId: event.detail.challengeId, + width: event.detail.width, + height: event.detail.height, + playerId: event.detail.playerId, + playerUsername: event.detail.playerUsername + }; + + if (this._challenges.querySelector(`[data-challenge-id="${openChallenge.challengeId}"]`)) return; + + if (this._challenges.children.length < this._maximumNumberOfChallengesInList) { + this._challenges.appendChild(this._createChallengeNode(openChallenge)); + } else { + this._pendingOpenChallenges.set(openChallenge.challengeId, openChallenge); + } + } + + _onChallengeAcceptedOrWithdrawn = event => this._removeChallenge(event.detail.challengeId); + + _onUserArrived = event => { + this._playerId = event.detail.userId; + + this.querySelectorAll(`[data-player-id="${this._playerId}"]`) + .forEach(challenge => challenge.classList.replace('table-light', 'table-success')); + } + + _registerEventHandler() { + window.addEventListener('WebInterface.UserArrived', this._onUserArrived); + sse.subscribe('ttt-lobby', { + 'TicTacToe.ChallengeOpened': this._onChallengeOpened, + 'TicTacToe.ChallengeAccepted': this._onChallengeAcceptedOrWithdrawn, + 'TicTacToe.ChallengeWithdrawn': this._onChallengeAcceptedOrWithdrawn + }, this._sseAbortController.signal); + } +}); diff --git a/config/tic-tac-toe/importmap.php b/config/tic-tac-toe/importmap.php index 13de65fbf..1e97b9eae 100644 --- a/config/tic-tac-toe/importmap.php +++ b/config/tic-tac-toe/importmap.php @@ -4,4 +4,5 @@ return [ 'tic-tac-toe-animated-game' => ['path' => 'js/TicTacToe/AnimatedGame.js'], + 'tic-tac-toe-challenges' => ['path' => 'js/TicTacToe/Challenges.js'], ]; diff --git a/config/tic-tac-toe/routing.yml b/config/tic-tac-toe/routing.yml index 9d499e6e4..a7a11247b 100644 --- a/config/tic-tac-toe/routing.yml +++ b/config/tic-tac-toe/routing.yml @@ -22,3 +22,15 @@ tic_tac_toe_challenge: path: /tic-tac-toe/challenge/{id} methods: [GET] controller: tic-tac-toe.challenge-controller::showAction + +tic_tac_toe_api_withdraw_challenge: + path: /tic-tac-toe/api/challenge/{id}/withdraw + methods: [POST] + controller: tic-tac-toe.api-controller::withdrawAction + defaults: { _format: json } + +tic_tac_toe_api_accept_challenge: + path: /tic-tac-toe/api/challenge/{id}/accept + methods: [POST] + controller: tic-tac-toe.api-controller::acceptAction + defaults: { _format: json } diff --git a/config/tic-tac-toe/services/challenge.yml b/config/tic-tac-toe/services/challenge.yml index f9224c3ab..58a214ba5 100644 --- a/config/tic-tac-toe/services/challenge.yml +++ b/config/tic-tac-toe/services/challenge.yml @@ -1,4 +1,8 @@ services: + tic-tac-toe.open-challenges-store: + class: Gaming\TicTacToe\Port\Adapter\Persistence\Repository\PredisOpenChallengesStore + arguments: ['@tic-tac-toe.predis', 'open-challenges', '@tic-tac-toe.normalizer'] + tic-tac-toe.challenge-repository: class: Gaming\TicTacToe\Port\Adapter\Persistence\Repository\EventStoreChallenges arguments: ['@tic-tac-toe.event-store'] @@ -15,6 +19,10 @@ services: arguments: ['@tic-tac-toe.challenge-repository'] tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_command' }] + Gaming\TicTacToe\Application\Challenge\All\AllHandler: + arguments: ['@tic-tac-toe.open-challenges-store'] + tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_query' }] + Gaming\TicTacToe\Application\Challenge\GetById\GetByIdHandler: arguments: ['@tic-tac-toe.event-store'] tags: [{ name: 'gaming_platform_bus.handler', bus: 'tic_tac_toe_query' }] diff --git a/config/tic-tac-toe/services/console.yml b/config/tic-tac-toe/services/console.yml new file mode 100644 index 000000000..e1dfafc7d --- /dev/null +++ b/config/tic-tac-toe/services/console.yml @@ -0,0 +1,15 @@ +services: + tic-tac-toe.follow-event-store-command: + class: Gaming\Common\EventStore\Integration\Symfony\FollowEventStoreCommand + arguments: + - '@tic-tac-toe.event-store' + - !service + class: Gaming\Common\EventStore\Integration\Predis\PredisEventStorePointerFactory + arguments: ['@tic-tac-toe.predis'] + - !tagged_locator { tag: 'tic-tac-toe.stored-event-subscriber', index_by: 'key' } + - '@event_dispatcher' + - '@gaming.prometheus.task' + tags: + - name: console.command + command: tic-tac-toe:follow-event-store + description: 'Publish events to subscribers.' diff --git a/config/tic-tac-toe/services/consumer.yml b/config/tic-tac-toe/services/consumer.yml new file mode 100644 index 000000000..b7ea2fd52 --- /dev/null +++ b/config/tic-tac-toe/services/consumer.yml @@ -0,0 +1,17 @@ +services: + tic-tac-toe.publish-to-browser-message-handler.topology: + class: Gaming\Common\MessageBroker\Integration\AmqpLib\Topology\QueueTopology + arguments: ['TicTacToe.BrowserNotification', 'gaming', ['TicTacToe.#']] + tags: [{ name: 'gaming.message-broker.topology' }] + + tic-tac-toe.publish-to-browser-message-handler.consumer: + class: Gaming\Common\MessageBroker\Integration\AmqpLib\AmqpConsumer + factory: ['@gaming.message-broker.amqp-consumer-factory', 'create'] + arguments: + - !service + class: Gaming\TicTacToe\Port\Adapter\Messaging\PublishMessageBrokerEventsToBrowserMessageHandler + arguments: ['@gaming.browser-notifier', '@gaming.usernames'] + - !service + class: Gaming\Common\MessageBroker\Integration\AmqpLib\QueueConsumer\ConsumeQueues + arguments: ['@tic-tac-toe.publish-to-browser-message-handler.topology'] + tags: [{ name: 'gaming.consumer', key: 'tic-tac-toe.publish-to-browser' }] diff --git a/config/tic-tac-toe/services/controller.yml b/config/tic-tac-toe/services/controller.yml index 4f3a6da69..00bf73ea6 100644 --- a/config/tic-tac-toe/services/controller.yml +++ b/config/tic-tac-toe/services/controller.yml @@ -6,6 +6,7 @@ services: tic-tac-toe.lobby-controller: class: Gaming\TicTacToe\Port\Adapter\Http\LobbyController + arguments: ['@tic-tac-toe.query-bus', '@gaming.usernames'] calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] tags: ['controller.service_arguments', 'container.service_subscriber'] @@ -16,3 +17,9 @@ services: ] calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] tags: ['controller.service_arguments', 'container.service_subscriber'] + + tic-tac-toe.api-controller: + class: Gaming\TicTacToe\Port\Adapter\Http\ApiController + arguments: ['@tic-tac-toe.command-bus', '@web-interface.security'] + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] diff --git a/config/tic-tac-toe/services/subscriber.yml b/config/tic-tac-toe/services/subscriber.yml new file mode 100644 index 000000000..9a3027259 --- /dev/null +++ b/config/tic-tac-toe/services/subscriber.yml @@ -0,0 +1,8 @@ +services: + Gaming\TicTacToe\Port\Adapter\Persistence\Projection\OpenChallengesProjection: + arguments: ['@tic-tac-toe.open-challenges-store'] + tags: [{ name: 'tic-tac-toe.stored-event-subscriber', key: 'open-challenges-projection' }] + + Gaming\TicTacToe\Port\Adapter\Messaging\PublishDomainEventsToMessageBrokerSubscriber: + arguments: ['@gaming.message-broker.gaming-exchange-publisher', '@tic-tac-toe.normalizer'] + tags: [{ name: 'tic-tac-toe.stored-event-subscriber', key: 'publish-to-message-broker' }] diff --git a/docker-compose.yml b/docker-compose.yml index 01e6a9fa9..051b71536 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -180,6 +180,11 @@ services: command: bin/restartOnChange bin/console identity:follow-event-store pointer all labels: - "prometheus-job=identity-follow-event-store" + php-tic-tac-toe-follow-event-store: + <<: *php-container + command: bin/restartOnChange bin/console tic-tac-toe:follow-event-store pointer all + labels: + - "prometheus-job=identity-follow-event-store" php-consume-messages: <<: *php-container command: bin/restartOnChange bin/console gaming:consume-messages all diff --git a/src/TicTacToe/Application/Challenge/All/AllHandler.php b/src/TicTacToe/Application/Challenge/All/AllHandler.php new file mode 100644 index 000000000..b98f42912 --- /dev/null +++ b/src/TicTacToe/Application/Challenge/All/AllHandler.php @@ -0,0 +1,24 @@ +openChallengesStore->all($request->limit)); + } +} diff --git a/src/TicTacToe/Application/Challenge/All/AllRequest.php b/src/TicTacToe/Application/Challenge/All/AllRequest.php new file mode 100644 index 000000000..4846bfa0a --- /dev/null +++ b/src/TicTacToe/Application/Challenge/All/AllRequest.php @@ -0,0 +1,18 @@ + + */ +final class AllRequest implements Request +{ + public function __construct( + public readonly int $limit + ) { + } +} diff --git a/src/TicTacToe/Application/Challenge/All/AllResponse.php b/src/TicTacToe/Application/Challenge/All/AllResponse.php new file mode 100644 index 000000000..c4dc1b10f --- /dev/null +++ b/src/TicTacToe/Application/Challenge/All/AllResponse.php @@ -0,0 +1,18 @@ +challenges->nextIdentity(); - $this->challenges->add(Challenge::open($challengeId, $request->challengerId)); + $this->challenges->add(Challenge::open($challengeId, $request->playerId)); return new OpenResponse($challengeId->toString()); } diff --git a/src/TicTacToe/Application/Challenge/Open/OpenRequest.php b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php index c6790df52..0c3303aa4 100644 --- a/src/TicTacToe/Application/Challenge/Open/OpenRequest.php +++ b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php @@ -12,7 +12,7 @@ final class OpenRequest implements Request { public function __construct( - public readonly string $challengerId + public readonly string $playerId ) { } } diff --git a/src/TicTacToe/Application/Model/Challenge.php b/src/TicTacToe/Application/Model/Challenge.php index 2e156abe8..cee30cacc 100644 --- a/src/TicTacToe/Application/Model/Challenge.php +++ b/src/TicTacToe/Application/Model/Challenge.php @@ -37,7 +37,7 @@ public function apply(object $domainEvent): self private function onChallengeOpened(ChallengeOpened $event): void { $this->status = self::STATUS_OPEN; - $this->challengerId = $event->challengerId; + $this->challengerId = $event->playerId; } private function onChallengeAccepted(ChallengeAccepted $event): void diff --git a/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php b/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php new file mode 100644 index 000000000..04c3e66bc --- /dev/null +++ b/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php @@ -0,0 +1,16 @@ +toString())); $challenge->record( new ChallengeOpened( $challengeId->toString(), - $challengerId + 3, + 3, + $playerId ) ); @@ -112,7 +114,7 @@ private function record(object $event): void private function apply(object $event): self { match ($event::class) { - ChallengeOpened::class => $this->challengerId = $event->challengerId, + ChallengeOpened::class => $this->challengerId = $event->playerId, ChallengeAccepted::class, ChallengeWithdrawn::class => $this->state = self::STATE_CLOSED, default => null diff --git a/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php index 352c30e16..20c6b2ff1 100644 --- a/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php +++ b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php @@ -10,7 +10,9 @@ final class ChallengeOpened implements DomainEvent { public function __construct( public readonly string $challengeId, - public readonly string $challengerId, + public readonly int $width, + public readonly int $height, + public readonly string $playerId ) { } diff --git a/src/TicTacToe/Port/Adapter/Http/ApiController.php b/src/TicTacToe/Port/Adapter/Http/ApiController.php new file mode 100644 index 000000000..3e876d2c5 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Http/ApiController.php @@ -0,0 +1,46 @@ +commandBus->handle( + new AcceptRequest( + $id, + $this->security->forceUser()->getUserIdentifier() + ) + ); + + return new JsonResponse(); + } + + public function withdrawAction(string $id): Response + { + $this->commandBus->handle( + new WithdrawRequest( + $id, + $this->security->forceUser()->getUserIdentifier() + ) + ); + + return new JsonResponse(); + } +} diff --git a/src/TicTacToe/Port/Adapter/Http/LobbyController.php b/src/TicTacToe/Port/Adapter/Http/LobbyController.php index 4051488ca..e39a92dfd 100644 --- a/src/TicTacToe/Port/Adapter/Http/LobbyController.php +++ b/src/TicTacToe/Port/Adapter/Http/LobbyController.php @@ -4,13 +4,31 @@ namespace Gaming\TicTacToe\Port\Adapter\Http; +use Gaming\Common\Bus\Bus; +use Gaming\Common\Usernames\Usernames; +use Gaming\TicTacToe\Application\Challenge\All\AllRequest; +use Gaming\TicTacToe\Application\Model\OpenChallenges\OpenChallenge; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; final class LobbyController extends AbstractController { + public function __construct( + private readonly Bus $queryBus, + private readonly Usernames $usernames + ) { + } + public function lobbyAction(): Response { - return $this->render('@tic-tac-toe/lobby.html.twig'); + return $this->render('@tic-tac-toe/lobby.html.twig', [ + 'openChallenges' => $openChallenges = $this->queryBus->handle(new AllRequest(100))->openChallenges, + 'usernames' => $this->usernames->byIds( + array_map( + static fn(OpenChallenge $openChallenge): string => $openChallenge->playerId, + $openChallenges + ) + ) + ]); } } diff --git a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig index b5636857f..5da5a8ed8 100644 --- a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig +++ b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig @@ -3,12 +3,33 @@ {% set page_title = 'Lobby' %} {% block content %} -
-
-
-
- -
+
+
+
+
+

+ ? + 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..6389864ad --- /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'], + $message->name(), + $message->body() + ), + default => true + }; + } + + private function handleChallengeOpened(Message $message): void + { + $body = json_decode($message->body(), true, flags: JSON_THROW_ON_ERROR); + $body['playerUsername'] = $this->usernames->byIds([$body['playerId']])[$body['playerId']]; + + $this->browserNotifier->publish( + ['ttt-lobby'], + $message->name(), + json_encode($body, JSON_THROW_ON_ERROR) + ); + } +} diff --git a/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php b/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php new file mode 100644 index 000000000..b8fef6468 --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php @@ -0,0 +1,43 @@ +content; + + match ($content::class) { + ChallengeOpened::class => $this->openChallengesStore->save( + new OpenChallenge( + $content->challengeId, + $content->width, + $content->height, + $content->playerId + ) + ), + ChallengeWithdrawn::class, + ChallengeAccepted::class => $this->openChallengesStore->remove($content->challengeId), + default => true + }; + } +} 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/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php index aa4c037db..badd839ff 100644 --- a/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php +++ b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php @@ -33,7 +33,7 @@ public function itCanBeContinued(): void $challenge = Challenge::fromHistory($challengeId, new DomainEvents($challengeId->toString(), 1, [ new DomainEvent( $challengeId->toString(), - new ChallengeOpened($challengeId->toString(), 'player1'), + new ChallengeOpened($challengeId->toString(), 3, 3, 'player1'), 1 ) ])); @@ -154,7 +154,7 @@ private function createOpenChallenge(string $challengerId): Challenge $this->assertEquals($challenge->flushDomainEvents(), [ new DomainEvent( $challenge->challengeId->toString(), - new ChallengeOpened($challenge->challengeId->toString(), $challengerId), + new ChallengeOpened($challenge->challengeId->toString(), 3, 3, $challengerId), 1 ) ]); From 482cf676d952eb18209848072c9f9491b37ca87e Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Wed, 28 Jan 2026 23:34:23 +0100 Subject: [PATCH 06/23] Remove unused code --- assets/js/TicTacToe/AnimatedGame.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/assets/js/TicTacToe/AnimatedGame.js b/assets/js/TicTacToe/AnimatedGame.js index 656817636..2e3576096 100644 --- a/assets/js/TicTacToe/AnimatedGame.js +++ b/assets/js/TicTacToe/AnimatedGame.js @@ -16,10 +16,6 @@ customElements.define('tic-tac-toe-animated-game', class extends HTMLElement { this.addEventListener('mouseenter', this.#onMouseEnter); this.addEventListener('mouseleave', this.#onMouseLeave); - - if (this.matches(':hover')) { - this.#onMouseEnter(); - } } disconnectedCallback() { From d4ecb1255d9358c3c819aff886bbd983ff1cae3d Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Thu, 29 Jan 2026 23:32:53 +0100 Subject: [PATCH 07/23] Add configuration --- assets/js/TicTacToe/Challenges.js | 23 +++++++++----- config/tic-tac-toe/config.yml | 5 ++++ .../Challenge/Open/OpenHandler.php | 16 +++++++++- .../Challenge/Open/OpenRequest.php | 5 +++- src/TicTacToe/Application/Model/Challenge.php | 8 ++++- .../Model/OpenChallenges/OpenChallenge.php | 5 ++-- src/TicTacToe/Domain/Challenge/Challenge.php | 8 +++-- .../Challenge/Event/ChallengeOpened.php | 5 ++-- src/TicTacToe/Domain/Game/Configuration.php | 30 +++++++++++++++++++ .../Domain/Game/Exception/GameException.php | 26 ++++++++++++++++ src/TicTacToe/Domain/Game/Token.php | 16 ++++++++++ .../Port/Adapter/Http/ChallengeController.php | 7 ++++- .../Http/View/challenge/show.html.twig | 4 +-- .../Port/Adapter/Http/View/lobby.html.twig | 8 ++++- .../Projection/OpenChallengesProjection.php | 5 ++-- .../Port/Adapter/Translation/messages.en.yml | 6 ++++ .../Domain/Challenge/ChallengeTest.php | 7 +++-- 17 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 src/TicTacToe/Domain/Game/Configuration.php create mode 100644 src/TicTacToe/Domain/Game/Exception/GameException.php create mode 100644 src/TicTacToe/Domain/Game/Token.php create mode 100644 src/TicTacToe/Port/Adapter/Translation/messages.en.yml diff --git a/assets/js/TicTacToe/Challenges.js b/assets/js/TicTacToe/Challenges.js index c90fe0b3e..d7c61e77a 100644 --- a/assets/js/TicTacToe/Challenges.js +++ b/assets/js/TicTacToe/Challenges.js @@ -3,7 +3,7 @@ import * as sse from '../Common/EventSource.js' import {createUsernameNode} from '../Identity/utils.js' /** - * @typedef {{challengeId: String, width: Number, height: Number, playerId: String, playerUsername: String}} OpenChallenge + * @typedef {{challengeId: String, size: Number, preferredToken: Number|null, timer: String, playerId: String, playerUsername: String}} OpenChallenge */ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { @@ -16,8 +16,7 @@ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { Player - Size - Rating + Config ${this._challenges = html``} @@ -27,6 +26,7 @@ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { this._playerId = this.getAttribute('player-id'); this._maximumNumberOfChallengesInList = parseInt(this.getAttribute('maximum-number-of-challenges')); + this._translations = JSON.parse(this.getAttribute('translations')); this._pendingOpenChallenges = new Map(); this._scheduleRenderTimeout = null; this._useScheduleRenderAfter = Date.now() + 750; @@ -81,8 +81,11 @@ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { ${createUsernameNode(openChallenge.playerUsername)} - ${openChallenge.width} x ${openChallenge.height} - + + ${openChallenge.size}x${openChallenge.size}, + ${this._translateToken(openChallenge.preferredToken)}, + ${this._translations['ttt:' + openChallenge.timer] ?? openChallenge.timer} + `; @@ -109,6 +112,11 @@ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { return row; } + _translateToken = token => { + const key = 'ttt:token_' + (token || 'random'); + return this._translations[key] ?? key; + } + _removeChallenge = challengeId => { this._pendingOpenChallenges.delete(challengeId); @@ -125,8 +133,9 @@ customElements.define('tic-tac-toe-challenges', class extends HTMLElement { _onChallengeOpened = event => { const openChallenge = { challengeId: event.detail.challengeId, - width: event.detail.width, - height: event.detail.height, + size: event.detail.size, + preferredToken: event.detail.preferredToken, + timer: event.detail.timer, playerId: event.detail.playerId, playerUsername: event.detail.playerUsername }; diff --git a/config/tic-tac-toe/config.yml b/config/tic-tac-toe/config.yml index c9e91ccf8..4e434bffe 100644 --- a/config/tic-tac-toe/config.yml +++ b/config/tic-tac-toe/config.yml @@ -1,5 +1,10 @@ imports: [{ resource: services/ }] +framework: + translator: + paths: + - '%kernel.project_dir%/src/TicTacToe/Port/Adapter/Translation' + gaming_platform_bus: buses: tic_tac_toe_command: ~ diff --git a/src/TicTacToe/Application/Challenge/Open/OpenHandler.php b/src/TicTacToe/Application/Challenge/Open/OpenHandler.php index 16f3a8dad..0756e15fe 100644 --- a/src/TicTacToe/Application/Challenge/Open/OpenHandler.php +++ b/src/TicTacToe/Application/Challenge/Open/OpenHandler.php @@ -4,8 +4,12 @@ namespace Gaming\TicTacToe\Application\Challenge\Open; +use Gaming\Common\Timer\MoveTimer; +use Gaming\Common\Timer\TimerFactory; use Gaming\TicTacToe\Domain\Challenge\Challenge; use Gaming\TicTacToe\Domain\Challenge\Challenges; +use Gaming\TicTacToe\Domain\Game\Configuration; +use Gaming\TicTacToe\Domain\Game\Token; final class OpenHandler { @@ -18,7 +22,17 @@ public function __invoke(OpenRequest $request): OpenResponse { $challengeId = $this->challenges->nextIdentity(); - $this->challenges->add(Challenge::open($challengeId, $request->playerId)); + $this->challenges->add( + Challenge::open( + $challengeId, + $request->playerId, + new Configuration( + $request->size, + Token::tryFrom($request->token), + TimerFactory::fromString($request->timer) ?? MoveTimer::set(15000) + ) + ) + ); return new OpenResponse($challengeId->toString()); } diff --git a/src/TicTacToe/Application/Challenge/Open/OpenRequest.php b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php index 0c3303aa4..a10d16c29 100644 --- a/src/TicTacToe/Application/Challenge/Open/OpenRequest.php +++ b/src/TicTacToe/Application/Challenge/Open/OpenRequest.php @@ -12,7 +12,10 @@ final class OpenRequest implements Request { public function __construct( - public readonly string $playerId + public readonly string $playerId, + public readonly int $size, + public readonly int $token, + public readonly string $timer ) { } } diff --git a/src/TicTacToe/Application/Model/Challenge.php b/src/TicTacToe/Application/Model/Challenge.php index cee30cacc..38c6c12ba 100644 --- a/src/TicTacToe/Application/Model/Challenge.php +++ b/src/TicTacToe/Application/Model/Challenge.php @@ -18,7 +18,10 @@ public function __construct( public readonly string $challengeId, public private(set) string $challengerId = '', public private(set) string $status = self::STATUS_OPEN, - public private(set) string $acceptorId = '' + public private(set) string $acceptorId = '', + public private(set) int $size = 3, + public private(set) ?int $preferredToken = null, + public private(set) string $timer = '' ) { } @@ -38,6 +41,9 @@ private function onChallengeOpened(ChallengeOpened $event): void { $this->status = self::STATUS_OPEN; $this->challengerId = $event->playerId; + $this->size = $event->size; + $this->preferredToken = $event->preferredToken; + $this->timer = $event->timer; } private function onChallengeAccepted(ChallengeAccepted $event): void diff --git a/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php b/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php index 04c3e66bc..f90fbe02c 100644 --- a/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php +++ b/src/TicTacToe/Application/Model/OpenChallenges/OpenChallenge.php @@ -8,8 +8,9 @@ final class OpenChallenge { public function __construct( public readonly string $challengeId, - public readonly int $width, - public readonly int $height, + public readonly int $size, + public readonly ?int $preferredToken, + public readonly string $timer, public readonly string $playerId ) { } diff --git a/src/TicTacToe/Domain/Challenge/Challenge.php b/src/TicTacToe/Domain/Challenge/Challenge.php index bdc991476..90dd919ad 100644 --- a/src/TicTacToe/Domain/Challenge/Challenge.php +++ b/src/TicTacToe/Domain/Challenge/Challenge.php @@ -13,6 +13,7 @@ use Gaming\TicTacToe\Domain\Challenge\Exception\CannotAcceptOwnChallengeException; use Gaming\TicTacToe\Domain\Challenge\Exception\CannotWithdrawException; use Gaming\TicTacToe\Domain\Challenge\Exception\NotOpenException; +use Gaming\TicTacToe\Domain\Game\Configuration; final class Challenge implements CollectsDomainEvents { @@ -28,14 +29,15 @@ private function __construct( ) { } - public static function open(ChallengeId $challengeId, string $playerId): self + public static function open(ChallengeId $challengeId, string $playerId, Configuration $configuration): self { $challenge = new self($challengeId, new DomainEvents($challengeId->toString())); $challenge->record( new ChallengeOpened( $challengeId->toString(), - 3, - 3, + $configuration->size, + $configuration->preferredToken?->value, + (string)$configuration->timer, $playerId ) ); diff --git a/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php index 20c6b2ff1..15ce88f61 100644 --- a/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php +++ b/src/TicTacToe/Domain/Challenge/Event/ChallengeOpened.php @@ -10,8 +10,9 @@ final class ChallengeOpened implements DomainEvent { public function __construct( public readonly string $challengeId, - public readonly int $width, - public readonly int $height, + public readonly int $size, + public readonly ?int $preferredToken, + public readonly string $timer, public readonly string $playerId ) { } diff --git a/src/TicTacToe/Domain/Game/Configuration.php b/src/TicTacToe/Domain/Game/Configuration.php new file mode 100644 index 000000000..cb0257b58 --- /dev/null +++ b/src/TicTacToe/Domain/Game/Configuration.php @@ -0,0 +1,30 @@ + 9) { + throw GameException::sizeOutOfRange($size, 3, 9); + } + } + + public static function common(): self + { + return new self(3, null, MoveTimer::set(15000)); + } +} diff --git a/src/TicTacToe/Domain/Game/Exception/GameException.php b/src/TicTacToe/Domain/Game/Exception/GameException.php new file mode 100644 index 000000000..07191160d --- /dev/null +++ b/src/TicTacToe/Domain/Game/Exception/GameException.php @@ -0,0 +1,26 @@ +redirectToRoute('tic_tac_toe_challenge', [ 'id' => $this->commandBus->handle( - new OpenRequest($this->security->forceUser()->getUserIdentifier()) + new OpenRequest( + $this->security->forceUser()->getUserIdentifier(), + 3, + 0, + 'move:15000' + ) )->challengeId ]); } diff --git a/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig index ebff24f2a..d50623f4d 100644 --- a/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig +++ b/src/TicTacToe/Port/Adapter/Http/View/challenge/show.html.twig @@ -29,8 +29,8 @@ {% endif %}

- Standard • 3 x 3
- X • 15s/move + Standard • {{ challenge.size }} x {{ challenge.size }}
+ {{ ('ttt:token_' ~ challenge.preferredToken|default('random'))|trans }} • {{ ('ttt:' ~ challenge.timer)|trans }}

{% if app.user ? app.user.userIdentifier == challenge.challengerId %}
diff --git a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig index 5da5a8ed8..6e1de7503 100644 --- a/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig +++ b/src/TicTacToe/Port/Adapter/Http/View/lobby.html.twig @@ -20,7 +20,13 @@ player-id="{{ app.user ? app.user.userIdentifier|e('html_attr') }}" accept-url="{{ path('tic_tac_toe_api_accept_challenge', {'id': 'CHALLENGE_ID'})|e('html_attr') }}" withdraw-url="{{ path('tic_tac_toe_api_withdraw_challenge', {'id': 'CHALLENGE_ID'})|e('html_attr') }}" - maximum-number-of-challenges="10"> + maximum-number-of-challenges="10" + translations="{{ { + 'ttt:token_random': 'ttt:token_random_small'|trans, + 'ttt:token_1': 'ttt:token_1'|trans, + 'ttt:token_2': 'ttt:token_2'|trans, + 'ttt:move:15000': 'ttt:move:15000_small'|trans + }|json_encode|e('html_attr') }}">
diff --git a/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php b/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php index b8fef6468..0ccd3a1fa 100644 --- a/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php +++ b/src/TicTacToe/Port/Adapter/Persistence/Projection/OpenChallengesProjection.php @@ -30,8 +30,9 @@ public function handle(DomainEvent $domainEvent): void ChallengeOpened::class => $this->openChallengesStore->save( new OpenChallenge( $content->challengeId, - $content->width, - $content->height, + $content->size, + $content->preferredToken, + $content->timer, $content->playerId ) ), 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..97bad166a --- /dev/null +++ b/src/TicTacToe/Port/Adapter/Translation/messages.en.yml @@ -0,0 +1,6 @@ +ttt:move:15000: '15s/move' +ttt:move:15000_small: '15s' +ttt:token_random: 'Random' +ttt:token_random_small: 'R' +ttt:token_1: 'X' +ttt:token_2: 'O' diff --git a/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php index badd839ff..1c54badcc 100644 --- a/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php +++ b/tests/unit/TicTacToe/Domain/Challenge/ChallengeTest.php @@ -14,6 +14,7 @@ use Gaming\TicTacToe\Domain\Challenge\Exception\CannotAcceptOwnChallengeException; use Gaming\TicTacToe\Domain\Challenge\Exception\CannotWithdrawException; use Gaming\TicTacToe\Domain\Challenge\Exception\NotOpenException; +use Gaming\TicTacToe\Domain\Game\Configuration; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -33,7 +34,7 @@ public function itCanBeContinued(): void $challenge = Challenge::fromHistory($challengeId, new DomainEvents($challengeId->toString(), 1, [ new DomainEvent( $challengeId->toString(), - new ChallengeOpened($challengeId->toString(), 3, 3, 'player1'), + new ChallengeOpened($challengeId->toString(), 3, null, 'move:15000', 'player1'), 1 ) ])); @@ -149,12 +150,12 @@ public function cannotAcceptWithdrawnChallenge(): void private function createOpenChallenge(string $challengerId): Challenge { - $challenge = Challenge::open(ChallengeId::generate(), $challengerId); + $challenge = Challenge::open(ChallengeId::generate(), $challengerId, Configuration::common()); $this->assertEquals($challenge->flushDomainEvents(), [ new DomainEvent( $challenge->challengeId->toString(), - new ChallengeOpened($challenge->challengeId->toString(), 3, 3, $challengerId), + new ChallengeOpened($challenge->challengeId->toString(), 3, null, 'move:15000', $challengerId), 1 ) ]); From 6462a4a14d1b377e865e88bcc63afde62d2bf00b Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Mon, 2 Feb 2026 21:05:13 +0100 Subject: [PATCH 08/23] Deduplicate html titles --- src/ConnectFour/Port/Adapter/Http/View/base.html.twig | 2 ++ src/TicTacToe/Port/Adapter/Http/View/base.html.twig | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/ConnectFour/Port/Adapter/Http/View/base.html.twig b/src/ConnectFour/Port/Adapter/Http/View/base.html.twig index 437a6679c..7a41c7487 100644 --- a/src/ConnectFour/Port/Adapter/Http/View/base.html.twig +++ b/src/ConnectFour/Port/Adapter/Http/View/base.html.twig @@ -1,5 +1,7 @@ {% extends 'layout/condensed.html.twig' %} +{% set page_title = page_title ~ ' - Connect Four' %} + {% block context_nav %}