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` +
+ + + + + + + + ${this._challenges = html``} +
PlayerConfig
+
+ `); + + 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; + const usernames = JSON.parse(this.getAttribute('usernames')); + JSON.parse(this.getAttribute("open-challenges")).forEach(challenge => { + this._pendingOpenChallenges.set( + challenge.challengeId, + {...challenge, challengerUsername: usernames[challenge.challengerId]} + ); + }); + + 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.challengerUsername)} + + ${openChallenge.size}x${openChallenge.size}, + ${this._translateToken(openChallenge.preferredToken)}, + ${this._translations['ttt:' + openChallenge.timer] ?? openChallenge.timer} + + + `; + + 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.challengerId) { + 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; + } + + _translateToken = token => { + const key = 'ttt:token_' + (token || 'random'); + return this._translations[key] ?? key; + } + + _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, + size: event.detail.size, + preferredToken: event.detail.preferredToken, + timer: event.detail.timer, + challengerId: event.detail.challengerId, + challengerUsername: event.detail.challengerUsername + }; + + 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-challenger-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/assets/js/TicTacToe/Redirect.js b/assets/js/TicTacToe/Redirect.js new file mode 100644 index 000000000..8c1259d14 --- /dev/null +++ b/assets/js/TicTacToe/Redirect.js @@ -0,0 +1,15 @@ +import * as sse from '../Common/EventSource.js' + +customElements.define('tic-tac-toe-redirect', class extends HTMLElement { + connectedCallback() { + this._sseAbortController = new AbortController(); + + sse.subscribe(`ttt-challenge-${this.getAttribute('challenge-id')}`, { + 'TicTacToe.ChallengeAccepted': () => alert('Redirect to game.') + }, this._sseAbortController.signal); + } + + disconnectedCallback() { + this._sseAbortController.abort(); + } +}); 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 new file mode 100644 index 000000000..4e434bffe --- /dev/null +++ b/config/tic-tac-toe/config.yml @@ -0,0 +1,25 @@ +imports: [{ resource: services/ }] + +framework: + translator: + paths: + - '%kernel.project_dir%/src/TicTacToe/Port/Adapter/Translation' + +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/importmap.php b/config/tic-tac-toe/importmap.php new file mode 100644 index 000000000..ffdf57f6a --- /dev/null +++ b/config/tic-tac-toe/importmap.php @@ -0,0 +1,9 @@ + ['path' => 'js/TicTacToe/AnimatedGame.js'], + 'tic-tac-toe-challenges' => ['path' => 'js/TicTacToe/Challenges.js'], + 'tic-tac-toe-redirect' => ['path' => 'js/TicTacToe/Redirect.js'], +]; 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 new file mode 100644 index 000000000..7f0bbafc8 --- /dev/null +++ b/config/tic-tac-toe/routing.yml @@ -0,0 +1,36 @@ +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}/withdraw + 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 + +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 new file mode 100644 index 000000000..11cb00de8 --- /dev/null +++ b/config/tic-tac-toe/services/challenge.yml @@ -0,0 +1,28 @@ +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'] + + 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\GetOpenChallenges\GetOpenChallengesHandler: + 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/command_bus.yml b/config/tic-tac-toe/services/command_bus.yml new file mode 100644 index 000000000..33bcc112e --- /dev/null +++ b/config/tic-tac-toe/services/command_bus.yml @@ -0,0 +1,18 @@ +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.retry-command-bus: + class: Gaming\Common\Bus\RetryBus + decorates: 'tic-tac-toe.command-bus' + arguments: ['@.inner', 3, 'Gaming\Common\Domain\Exception\ConcurrencyException'] + + 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/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 new file mode 100644 index 000000000..af11ffeb2 --- /dev/null +++ b/config/tic-tac-toe/services/controller.yml @@ -0,0 +1,25 @@ +services: + tic-tac-toe.fragment-controller: + class: Gaming\TicTacToe\Port\Adapter\Http\FragmentController + arguments: ['@tic-tac-toe.query-bus', '@gaming.usernames'] + 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'] + + 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'] + + 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/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/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..da173c7cb 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=tic-tac-toe-follow-event-store" php-consume-messages: <<: *php-container command: bin/restartOnChange bin/console gaming:consume-messages all 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/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 %}