From 6f5354d24efad6132a661f3971d0870402837723 Mon Sep 17 00:00:00 2001 From: Markus Reinhold Date: Wed, 4 Dec 2024 21:05:53 +0100 Subject: [PATCH] Allow players to choose game configuration --- assets/css/app.css | 8 ++ assets/js/ConnectFour/OpenButton.js | 63 -------------- config/connect-four/importmap.php | 1 - config/web-interface/config.yml | 2 +- config/web-interface/routing.yml | 12 ++- config/web-interface/services/controller.yml | 8 +- .../load-test/scenario/play-connect-four.js | 7 +- .../Application/Game/Command/OpenCommand.php | 10 +-- .../Application/Game/Command/OpenHandler.php | 11 ++- .../Game/Query/Model/Game/Game.php | 74 ++++------------ .../Game/Query/Model/Game/Move.php | 29 ++----- src/ConnectFour/Domain/Game/Board/Size.php | 23 +++-- src/ConnectFour/Domain/Game/Board/Stone.php | 5 ++ src/ConnectFour/Domain/Game/Configuration.php | 42 ++++++--- .../Domain/Game/Event/GameAborted.php | 14 ++- .../Domain/Game/Event/GameOpened.php | 31 +++++-- .../Domain/Game/Event/PlayerJoined.php | 14 ++- src/ConnectFour/Domain/Game/Game.php | 11 +-- src/ConnectFour/Domain/Game/State/Open.php | 28 ++---- src/ConnectFour/Domain/Game/State/Running.php | 4 +- .../Repository/InMemoryCacheGameStore.php | 6 +- .../Repository/PredisGameStore.php | 4 +- .../Http/ConnectFourController.php | 45 ++++++++-- .../Presentation/Http/Form/OpenType.php | 87 +++++++++++++++++++ .../Presentation/Http/PageController.php | 50 ++++++----- .../Http/View/challenge.html.twig | 38 ++++++++ .../Presentation/Http/View/game.html.twig | 6 +- .../Http/View/layout/forms.html.twig | 24 +++++ .../Presentation/Http/View/lobby.html.twig | 18 +++- tests/acceptance/GameCest.php | 14 +-- .../Game/Query/Model/Game/GameTest.php | 16 ++-- .../Domain/Game/Board/SizeTest.php | 2 - .../unit/ConnectFour/Domain/Game/GameTest.php | 19 ++-- 33 files changed, 421 insertions(+), 305 deletions(-) delete mode 100644 assets/js/ConnectFour/OpenButton.js create mode 100644 src/WebInterface/Presentation/Http/Form/OpenType.php create mode 100644 src/WebInterface/Presentation/Http/View/challenge.html.twig create mode 100644 src/WebInterface/Presentation/Http/View/layout/forms.html.twig diff --git a/assets/css/app.css b/assets/css/app.css index 63f4304a..7e0672bc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -179,3 +179,11 @@ notification-list { [data-bs-theme=dark] .gp-game__field { border-color: var(--tblr-blue); } + +/** Fix specificity for checked buttons on hover */ +.btn-check:checked + .btn:hover { + color: var(--tblr-btn-active-color); + background-color: var(--tblr-btn-active-bg); + border-color: var(--tblr-btn-active-border-color); + box-shadow: var(--tblr-btn-active-shadow) +} diff --git a/assets/js/ConnectFour/OpenButton.js b/assets/js/ConnectFour/OpenButton.js deleted file mode 100644 index a570b8cf..00000000 --- a/assets/js/ConnectFour/OpenButton.js +++ /dev/null @@ -1,63 +0,0 @@ -import {service} from './GameService.js' -import {html} from 'uhtml/node.js' - -customElements.define('connect-four-open-button', class extends HTMLElement { - connectedCallback() { - this._onDisconnect = []; - this._button = html` - - `; - - this.replaceChildren(this._button); - - this._currentOpenGameId = ''; - - this._button.addEventListener('click', this._onButtonClick.bind(this)); - - ((n, f) => { - window.addEventListener(n, f); - this._onDisconnect.push(() => window.removeEventListener(n, f)); - })('ConnectFour.PlayerJoined', this._onPlayerJoined.bind(this)); - - ((n, f) => { - window.addEventListener(n, f); - this._onDisconnect.push(() => window.removeEventListener(n, f)); - })('ConnectFour.GameAborted', this._onGameAborted.bind(this)); - } - - disconnectedCallback() { - this._onDisconnect.forEach(f => f()); - } - - async _onButtonClick(event) { - event.preventDefault(); - - this._button.disabled = true; - this._button.classList.add('btn-loading'); - - if (this._currentOpenGameId) { - service.abort(this._currentOpenGameId); - } - - service.open().then((game) => { - this._currentOpenGameId = game.gameId; - this._button.disabled = false; - this._button.classList.remove('btn-loading'); - }).catch(() => { - this._button.disabled = false; - this._button.classList.remove('btn-loading'); - }); - } - - _onPlayerJoined(event) { - if (this._currentOpenGameId === event.detail.gameId) { - service.redirectTo(this._currentOpenGameId); - } - } - - _onGameAborted(event) { - if (this._currentOpenGameId === event.detail.gameId) { - this._currentOpenGameId = ''; - } - } -}); diff --git a/config/connect-four/importmap.php b/config/connect-four/importmap.php index 12f5dbe8..00ee87cb 100644 --- a/config/connect-four/importmap.php +++ b/config/connect-four/importmap.php @@ -6,7 +6,6 @@ 'connect-four-running-games' => ['path' => 'js/ConnectFour/RunningGames.js'], 'connect-four-game-list' => ['path' => 'js/ConnectFour/GameList.js'], 'connect-four-game' => ['path' => 'js/ConnectFour/Game.js'], - 'connect-four-open-button' => ['path' => 'js/ConnectFour/OpenButton.js'], 'connect-four-abort-button' => ['path' => 'js/ConnectFour/AbortButton.js'], 'connect-four-resign-button' => ['path' => 'js/ConnectFour/ResignButton.js'] ]; diff --git a/config/web-interface/config.yml b/config/web-interface/config.yml index cf8a1da9..19b6fc9f 100644 --- a/config/web-interface/config.yml +++ b/config/web-interface/config.yml @@ -8,7 +8,7 @@ framework: csrf_protection: false # CSRF protection is handled by marein/symfony-standard-headers-csrf-bundle. twig: - form_themes: ['bootstrap_5_layout.html.twig'] + form_themes: ['@web-interface/layout/forms.html.twig'] paths: { '%kernel.project_dir%/src/WebInterface/Presentation/Http/View': web-interface } security: diff --git a/config/web-interface/routing.yml b/config/web-interface/routing.yml index 78cdee26..1fdf3d87 100644 --- a/config/web-interface/routing.yml +++ b/config/web-interface/routing.yml @@ -21,6 +21,11 @@ game: methods: [GET] controller: web-interface.page-controller::gameAction +challenge: + path: /challenge/{id} + methods: [GET] + controller: web-interface.page-controller::challengeAction + signup: path: /signup methods: [GET, POST] @@ -73,7 +78,6 @@ open: path: /api/connect-four/games/open methods: [POST] controller: web-interface.connect-four-controller::openAction - defaults: { _format: json } abort: path: /api/connect-four/games/{gameId}/abort @@ -98,3 +102,9 @@ move: methods: [POST] controller: web-interface.connect-four-controller::moveAction defaults: { _format: json } + +challenge_abort: + path: /api/connect-four/challenges/{gameId}/abort + methods: [POST] + controller: web-interface.connect-four-controller::abortChallengeAction + defaults: { _format: json } diff --git a/config/web-interface/services/controller.yml b/config/web-interface/services/controller.yml index a837cda6..0eacf847 100644 --- a/config/web-interface/services/controller.yml +++ b/config/web-interface/services/controller.yml @@ -1,8 +1,9 @@ services: web-interface.page-controller: class: Gaming\WebInterface\Presentation\Http\PageController - arguments: ['@twig', '@connect-four.query-bus', '@web-interface.security'] - tags: ['controller.service_arguments'] + arguments: ['@connect-four.query-bus', '@web-interface.security'] + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] web-interface.signup-controller: class: Gaming\WebInterface\Presentation\Http\SignupController @@ -34,4 +35,5 @@ services: web-interface.connect-four-controller: class: Gaming\WebInterface\Presentation\Http\ConnectFourController arguments: ['@connect-four.command-bus', '@web-interface.security'] - tags: ['controller.service_arguments'] + calls: [[setContainer, ['@Psr\Container\ContainerInterface']]] + tags: ['controller.service_arguments', 'container.service_subscriber'] diff --git a/deploy/load-test/scenario/play-connect-four.js b/deploy/load-test/scenario/play-connect-four.js index 7ac52e78..e683a848 100644 --- a/deploy/load-test/scenario/play-connect-four.js +++ b/deploy/load-test/scenario/play-connect-four.js @@ -34,7 +34,12 @@ export default function () { function open(jar) { let url = `${baseUrl}/api/connect-four/games/open`; - return http.post(url, {}, {jar, headers}).json().gameId; + let response = http.post( + url, + {'open[size]': '7x6', 'open[variant]': 'standard', 'open[color]': '1'}, + {jar, headers, redirects: 0} + ); + return response.headers['Location'].match(/\/challenge\/(.*)$/)[1]; } function join(jar, gameId) { diff --git a/src/ConnectFour/Application/Game/Command/OpenCommand.php b/src/ConnectFour/Application/Game/Command/OpenCommand.php index 3d018ffd..1ed3914b 100644 --- a/src/ConnectFour/Application/Game/Command/OpenCommand.php +++ b/src/ConnectFour/Application/Game/Command/OpenCommand.php @@ -12,12 +12,10 @@ final class OpenCommand implements Request { public function __construct( - private readonly string $playerId + public readonly string $playerId, + public readonly int $width, + public readonly int $height, + public readonly int $stone ) { } - - public function playerId(): string - { - return $this->playerId; - } } diff --git a/src/ConnectFour/Application/Game/Command/OpenHandler.php b/src/ConnectFour/Application/Game/Command/OpenHandler.php index 2394e9a8..43c8088d 100644 --- a/src/ConnectFour/Application/Game/Command/OpenHandler.php +++ b/src/ConnectFour/Application/Game/Command/OpenHandler.php @@ -4,9 +4,12 @@ namespace Gaming\ConnectFour\Application\Game\Command; +use Gaming\ConnectFour\Domain\Game\Board\Size; +use Gaming\ConnectFour\Domain\Game\Board\Stone; use Gaming\ConnectFour\Domain\Game\Configuration; use Gaming\ConnectFour\Domain\Game\Game; use Gaming\ConnectFour\Domain\Game\Games; +use Gaming\ConnectFour\Domain\Game\WinningRule\WinningRules; final class OpenHandler { @@ -21,8 +24,12 @@ public function __invoke(OpenCommand $command): string { $game = Game::open( $this->games->nextIdentity(), - Configuration::common(), - $command->playerId() + new Configuration( + new Size($command->width, $command->height), + WinningRules::standard(), + Stone::tryFrom($command->stone) + ), + $command->playerId ); $this->games->add($game); diff --git a/src/ConnectFour/Application/Game/Query/Model/Game/Game.php b/src/ConnectFour/Application/Game/Query/Model/Game/Game.php index 139027f3..b2cf4caa 100644 --- a/src/ConnectFour/Application/Game/Query/Model/Game/Game.php +++ b/src/ConnectFour/Application/Game/Query/Model/Game/Game.php @@ -13,7 +13,6 @@ use Gaming\ConnectFour\Domain\Game\Event\PlayerJoined; use Gaming\ConnectFour\Domain\Game\Event\PlayerMoved; use Gaming\ConnectFour\Domain\Game\WinningRule\WinningSequence; -use JsonSerializable; use RuntimeException; /** @@ -24,60 +23,24 @@ * we send the user the latest projection from the event store. This way, the domain model * itself stays clean and gets not inflated by a bunch of getters. */ -final class Game implements JsonSerializable +final class Game { - private string $gameId = ''; - - private string $chatId = ''; - - /** - * @var string[] - */ - private array $players = []; - - private int $width = 0; - - private int $height = 0; - - private bool $finished = false; - - /** - * @var WinningSequence[] - */ - private array $winningSequences = []; - /** - * @var Move[] + * @param string[] $players + * @param WinningSequence[] $winningSequences + * @param Move[] $moves */ - private array $moves = []; - - public function id(): string - { - return $this->gameId; - } - - public function chatId(): string - { - return $this->chatId; - } - - public function finished(): bool - { - return $this->finished; - } - - public function jsonSerialize(): mixed - { - return [ - 'gameId' => $this->gameId, - 'chatId' => $this->chatId, - 'players' => $this->players, - 'finished' => $this->finished, - 'height' => $this->height, - 'width' => $this->width, - 'moves' => $this->moves, - 'winningSequences' => $this->winningSequences - ]; + public function __construct( + public private(set) string $gameId = '', + public private(set) string $chatId = '', + public private(set) array $players = [], + public private(set) bool $finished = false, + public private(set) int $height = 0, + public private(set) int $width = 0, + public private(set) ?int $preferredStone = null, + public private(set) array $moves = [], + public private(set) array $winningSequences = [] + ) { } /** @@ -102,9 +65,10 @@ public function apply(object $domainEvent): void private function handleGameOpened(GameOpened $gameOpened): void { $this->gameId = $gameOpened->aggregateId(); - $this->width = $gameOpened->width(); - $this->height = $gameOpened->height(); - $this->addPlayer($gameOpened->playerId()); + $this->width = $gameOpened->width; + $this->height = $gameOpened->height; + $this->preferredStone = $gameOpened->preferredStone; + $this->addPlayer($gameOpened->playerId); } private function handlePlayerJoined(PlayerJoined $playerJoined): void diff --git a/src/ConnectFour/Application/Game/Query/Model/Game/Move.php b/src/ConnectFour/Application/Game/Query/Model/Game/Move.php index be8a1386..606ab66f 100644 --- a/src/ConnectFour/Application/Game/Query/Model/Game/Move.php +++ b/src/ConnectFour/Application/Game/Query/Model/Game/Move.php @@ -4,29 +4,12 @@ namespace Gaming\ConnectFour\Application\Game\Query\Model\Game; -use JsonSerializable; - -final class Move implements JsonSerializable +final class Move { - private int $x; - - private int $y; - - private int $color; - - public function __construct(int $x, int $y, int $color) - { - $this->x = $x; - $this->y = $y; - $this->color = $color; - } - - public function jsonSerialize(): mixed - { - return [ - 'x' => $this->x, - 'y' => $this->y, - 'color' => $this->color - ]; + public function __construct( + public readonly int $x, + public readonly int $y, + public readonly int $color + ) { } } diff --git a/src/ConnectFour/Domain/Game/Board/Size.php b/src/ConnectFour/Domain/Game/Board/Size.php index 5dc186d8..98ea9814 100644 --- a/src/ConnectFour/Domain/Game/Board/Size.php +++ b/src/ConnectFour/Domain/Game/Board/Size.php @@ -8,32 +8,29 @@ final class Size { - private int $width; - - private int $height; - /** * @throws InvalidSizeException */ - public function __construct(int $width, int $height) - { + public function __construct( + public readonly int $width, + public readonly int $height + ) { if ($width < 2 || $height < 2) { throw new InvalidSizeException('Width and height must be greater then 1.'); } - - if (($width * $height) % 2 !== 0) { - throw new InvalidSizeException('Product of width and height must be an even number.'); - } - - $this->height = $height; - $this->width = $width; } + /** + * @deprecated Use property instead. + */ public function width(): int { return $this->width; } + /** + * @deprecated Use property instead. + */ public function height(): int { return $this->height; diff --git a/src/ConnectFour/Domain/Game/Board/Stone.php b/src/ConnectFour/Domain/Game/Board/Stone.php index b49f47c4..ccd1d6eb 100644 --- a/src/ConnectFour/Domain/Game/Board/Stone.php +++ b/src/ConnectFour/Domain/Game/Board/Stone.php @@ -9,4 +9,9 @@ enum Stone: int case None = 0; case Red = 1; case Yellow = 2; + + public static function random(): self + { + return self::from(rand(1, 2)); + } } diff --git a/src/ConnectFour/Domain/Game/Configuration.php b/src/ConnectFour/Domain/Game/Configuration.php index 3d6b1348..992dbdb1 100644 --- a/src/ConnectFour/Domain/Game/Configuration.php +++ b/src/ConnectFour/Domain/Game/Configuration.php @@ -5,33 +5,28 @@ namespace Gaming\ConnectFour\Domain\Game; use Gaming\ConnectFour\Domain\Game\Board\Size; +use Gaming\ConnectFour\Domain\Game\Board\Stone; +use Gaming\ConnectFour\Domain\Game\Exception\PlayersNotUniqueException; use Gaming\ConnectFour\Domain\Game\WinningRule\WinningRules; final class Configuration { - private Size $size; - - private WinningRules $winningRules; - - private function __construct(Size $size, WinningRules $winningRules) - { - $this->size = $size; - $this->winningRules = $winningRules; + public function __construct( + private readonly Size $size, + private readonly WinningRules $winningRules, + public readonly ?Stone $preferredStone = null + ) { } public static function common(): Configuration { return new self( new Size(7, 6), - WinningRules::standard() + WinningRules::standard(), + Stone::Red // Should be null, but is Red for keeping unit tests green. ); } - public static function custom(Size $size, WinningRules $winningRules): Configuration - { - return new self($size, $winningRules); - } - public function size(): Size { return $this->size; @@ -41,4 +36,23 @@ public function winningRules(): WinningRules { return $this->winningRules; } + + /** + * @throws PlayersNotUniqueException + */ + public function createPlayers(string $playerId, string $joinedPlayerId): Players + { + $players = match ($this->preferredStone ?? Stone::random()) { + Stone::Red => [ + new Player($playerId, Stone::Red), + new Player($joinedPlayerId, Stone::Yellow) + ], + default => [ + new Player($joinedPlayerId, Stone::Red), + new Player($playerId, Stone::Yellow) + ] + }; + + return new Players(...$players); + } } diff --git a/src/ConnectFour/Domain/Game/Event/GameAborted.php b/src/ConnectFour/Domain/Game/Event/GameAborted.php index 9d437102..ce70cf45 100644 --- a/src/ConnectFour/Domain/Game/Event/GameAborted.php +++ b/src/ConnectFour/Domain/Game/Event/GameAborted.php @@ -6,21 +6,17 @@ use Gaming\Common\Domain\DomainEvent; use Gaming\ConnectFour\Domain\Game\GameId; -use Gaming\ConnectFour\Domain\Game\Player; final class GameAborted implements DomainEvent { private string $gameId; - private string $abortedPlayerId; - - private string $opponentPlayerId; - - public function __construct(GameId $gameId, Player $abortedPlayer, ?Player $opponentPlayer = null) - { + public function __construct( + GameId $gameId, + private readonly string $abortedPlayerId, + private readonly string $opponentPlayerId = '' + ) { $this->gameId = $gameId->toString(); - $this->abortedPlayerId = $abortedPlayer->id(); - $this->opponentPlayerId = $opponentPlayer ? $opponentPlayer->id() : ''; } public function aggregateId(): string diff --git a/src/ConnectFour/Domain/Game/Event/GameOpened.php b/src/ConnectFour/Domain/Game/Event/GameOpened.php index f7e57110..9a5de02e 100644 --- a/src/ConnectFour/Domain/Game/Event/GameOpened.php +++ b/src/ConnectFour/Domain/Game/Event/GameOpened.php @@ -6,25 +6,29 @@ use Gaming\Common\Domain\DomainEvent; use Gaming\ConnectFour\Domain\Game\Board\Size; +use Gaming\ConnectFour\Domain\Game\Board\Stone; use Gaming\ConnectFour\Domain\Game\GameId; -use Gaming\ConnectFour\Domain\Game\Player; final class GameOpened implements DomainEvent { private string $gameId; - private int $width; + public readonly int $width; - private int $height; + public readonly int $height; - private string $playerId; + public readonly ?int $preferredStone; - public function __construct(GameId $gameId, Size $size, Player $player) - { + public function __construct( + GameId $gameId, + Size $size, + ?Stone $preferredStone, + public readonly string $playerId + ) { $this->gameId = $gameId->toString(); - $this->width = $size->width(); - $this->height = $size->height(); - $this->playerId = $player->id(); + $this->width = $size->width; + $this->height = $size->height; + $this->preferredStone = $preferredStone?->value; } public function aggregateId(): string @@ -32,16 +36,25 @@ public function aggregateId(): string return $this->gameId; } + /** + * @deprecated Use property instead. + */ public function width(): int { return $this->width; } + /** + * @deprecated Use property instead. + */ public function height(): int { return $this->height; } + /** + * @deprecated Use property instead. + */ public function playerId(): string { return $this->playerId; diff --git a/src/ConnectFour/Domain/Game/Event/PlayerJoined.php b/src/ConnectFour/Domain/Game/Event/PlayerJoined.php index c3ac99fa..53bb551e 100644 --- a/src/ConnectFour/Domain/Game/Event/PlayerJoined.php +++ b/src/ConnectFour/Domain/Game/Event/PlayerJoined.php @@ -6,21 +6,17 @@ use Gaming\Common\Domain\DomainEvent; use Gaming\ConnectFour\Domain\Game\GameId; -use Gaming\ConnectFour\Domain\Game\Player; final class PlayerJoined implements DomainEvent { private string $gameId; - private string $joinedPlayerId; - - private string $opponentPlayerId; - - public function __construct(GameId $gameId, Player $joinedPlayer, Player $opponentPlayer) - { + public function __construct( + GameId $gameId, + private readonly string $joinedPlayerId, + private readonly string $opponentPlayerId + ) { $this->gameId = $gameId->toString(); - $this->joinedPlayerId = $joinedPlayer->id(); - $this->opponentPlayerId = $opponentPlayer->id(); } public function aggregateId(): string diff --git a/src/ConnectFour/Domain/Game/Game.php b/src/ConnectFour/Domain/Game/Game.php index 90190bb2..8569f9e6 100644 --- a/src/ConnectFour/Domain/Game/Game.php +++ b/src/ConnectFour/Domain/Game/Game.php @@ -7,7 +7,6 @@ use Gaming\Common\Domain\AggregateRoot; use Gaming\Common\Domain\DomainEvent; use Gaming\Common\Domain\IsAggregateRoot; -use Gaming\ConnectFour\Domain\Game\Board\Stone; use Gaming\ConnectFour\Domain\Game\Event\ChatAssigned; use Gaming\ConnectFour\Domain\Game\Event\GameOpened; use Gaming\ConnectFour\Domain\Game\Exception\GameException; @@ -48,20 +47,18 @@ public function id(): GameId public static function open(GameId $gameId, Configuration $configuration, string $playerId): Game { - $size = $configuration->size(); - $player = new Player($playerId, Stone::Red); - return new self( $gameId, new Open( $configuration, - $player + $playerId ), [ new GameOpened( $gameId, - $size, - $player + $configuration->size(), + $configuration->preferredStone, + $playerId ) ] ); diff --git a/src/ConnectFour/Domain/Game/State/Open.php b/src/ConnectFour/Domain/Game/State/Open.php index 8f657bfe..e1cffe43 100644 --- a/src/ConnectFour/Domain/Game/State/Open.php +++ b/src/ConnectFour/Domain/Game/State/Open.php @@ -5,51 +5,41 @@ namespace Gaming\ConnectFour\Domain\Game\State; use Gaming\ConnectFour\Domain\Game\Board\Board; -use Gaming\ConnectFour\Domain\Game\Board\Stone; use Gaming\ConnectFour\Domain\Game\Configuration; use Gaming\ConnectFour\Domain\Game\Event\GameAborted; use Gaming\ConnectFour\Domain\Game\Event\PlayerJoined; use Gaming\ConnectFour\Domain\Game\Exception\GameNotRunningException; use Gaming\ConnectFour\Domain\Game\Exception\PlayerNotOwnerException; use Gaming\ConnectFour\Domain\Game\GameId; -use Gaming\ConnectFour\Domain\Game\Player; -use Gaming\ConnectFour\Domain\Game\Players; final class Open implements State { - private Configuration $configuration; - - private Player $player; - - public function __construct(Configuration $configuration, Player $player) - { - $this->configuration = $configuration; - $this->player = $player; + public function __construct( + private readonly Configuration $configuration, + private readonly string $playerId + ) { } public function join(GameId $gameId, string $playerId): Transition { - $joinedPlayer = new Player($playerId, Stone::Yellow); $size = $this->configuration->size(); - $width = $size->width(); - $height = $size->height(); return new Transition( new Running( $this->configuration->winningRules(), - $width * $height, + $size->width * $size->height, Board::empty($size), - new Players($this->player, $joinedPlayer) + $this->configuration->createPlayers($this->playerId, $playerId) ), [ - new PlayerJoined($gameId, $joinedPlayer, $this->player) + new PlayerJoined($gameId, $playerId, $this->playerId) ] ); } public function abort(GameId $gameId, string $playerId): Transition { - if ($this->player->id() !== $playerId) { + if ($this->playerId !== $playerId) { throw new PlayerNotOwnerException(); } @@ -58,7 +48,7 @@ public function abort(GameId $gameId, string $playerId): Transition [ new GameAborted( $gameId, - $this->player + $this->playerId ) ] ); diff --git a/src/ConnectFour/Domain/Game/State/Running.php b/src/ConnectFour/Domain/Game/State/Running.php index 4db13fd9..fe03229e 100644 --- a/src/ConnectFour/Domain/Game/State/Running.php +++ b/src/ConnectFour/Domain/Game/State/Running.php @@ -102,8 +102,8 @@ public function abort(GameId $gameId, string $playerId): Transition [ new GameAborted( $gameId, - $this->players->get($playerId), - $this->players->opponentOf($playerId) + $playerId, + $this->players->opponentOf($playerId)->id() ) ] ); diff --git a/src/ConnectFour/Port/Adapter/Persistence/Repository/InMemoryCacheGameStore.php b/src/ConnectFour/Port/Adapter/Persistence/Repository/InMemoryCacheGameStore.php index 6e367cac..4d4b229f 100644 --- a/src/ConnectFour/Port/Adapter/Persistence/Repository/InMemoryCacheGameStore.php +++ b/src/ConnectFour/Port/Adapter/Persistence/Repository/InMemoryCacheGameStore.php @@ -28,15 +28,15 @@ public function find(GameId $gameId): Game public function persist(Game $game): void { - if ($game->finished()) { - unset($this->cachedGames[$game->id()]); + if ($game->finished) { + unset($this->cachedGames[$game->gameId]); } $this->removeFirstElementIfLimitHasBeenExceeded(); $this->gameStore->persist($game); - $this->cachedGames[$game->id()] = $game; + $this->cachedGames[$game->gameId] = $game; } public function flush(): void diff --git a/src/ConnectFour/Port/Adapter/Persistence/Repository/PredisGameStore.php b/src/ConnectFour/Port/Adapter/Persistence/Repository/PredisGameStore.php index 00b36bba..83670710 100644 --- a/src/ConnectFour/Port/Adapter/Persistence/Repository/PredisGameStore.php +++ b/src/ConnectFour/Port/Adapter/Persistence/Repository/PredisGameStore.php @@ -37,7 +37,7 @@ public function find(GameId $gameId): Game public function persist(Game $game): void { - $this->pendingGames[$game->id()] = $game; + $this->pendingGames[$game->gameId] = $game; if (count($this->pendingGames) === $this->maxNumberOfPendingGamesBeforeFlush) { $this->flush(); @@ -49,7 +49,7 @@ public function flush(): void $this->predis->pipeline(function (ClientContextInterface $pipeline): void { foreach (array_splice($this->pendingGames, 0) as $game) { $pipeline->set( - $this->storageKeyPrefix . $game->id(), + $this->storageKeyPrefix . $game->gameId, $this->serializeGame($game) ); } diff --git a/src/WebInterface/Presentation/Http/ConnectFourController.php b/src/WebInterface/Presentation/Http/ConnectFourController.php index 9d98adbe..5278c81e 100644 --- a/src/WebInterface/Presentation/Http/ConnectFourController.php +++ b/src/WebInterface/Presentation/Http/ConnectFourController.php @@ -11,10 +11,13 @@ use Gaming\ConnectFour\Application\Game\Command\OpenCommand; use Gaming\ConnectFour\Application\Game\Command\ResignCommand; use Gaming\WebInterface\Infrastructure\Security\Security; +use Gaming\WebInterface\Presentation\Http\Form\OpenType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; -final class ConnectFourController +final class ConnectFourController extends AbstractController { public function __construct( private readonly Bus $connectFourCommandBus, @@ -22,15 +25,27 @@ public function __construct( ) { } - public function openAction(Request $request): JsonResponse + public function openAction(Request $request): Response { - return new JsonResponse( - [ - 'gameId' => $this->connectFourCommandBus->handle( - new OpenCommand($this->security->getUser()->getUserIdentifier()) + $form = $this->createForm(OpenType::class) + ->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + [$width, $height] = explode('x', $form->get('size')->getData()); + + return $this->redirectToRoute('challenge', [ + 'id' => $this->connectFourCommandBus->handle( + new OpenCommand( + $this->security->getUser()->getUserIdentifier(), + (int)$width, + (int)$height, + (int)$form->get('color')->getData() + ) ) - ] - ); + ]); + } + + return $this->redirectToRoute('lobby'); } public function joinAction(Request $request, string $gameId): JsonResponse @@ -57,6 +72,20 @@ public function abortAction(Request $request, string $gameId): JsonResponse return new JsonResponse(); } + public function abortChallengeAction(string $gameId): Response + { + try { + $this->connectFourCommandBus->handle( + new AbortCommand( + $gameId, + $this->security->getUser()->getUserIdentifier() + ) + ); + } finally { + return $this->redirectToRoute('lobby'); + } + } + public function resignAction(Request $request, string $gameId): JsonResponse { $this->connectFourCommandBus->handle( diff --git a/src/WebInterface/Presentation/Http/Form/OpenType.php b/src/WebInterface/Presentation/Http/Form/OpenType.php new file mode 100644 index 00000000..6b43690b --- /dev/null +++ b/src/WebInterface/Presentation/Http/Form/OpenType.php @@ -0,0 +1,87 @@ +add('size', ChoiceType::class, [ + 'data' => '7x6', + 'label' => 'Size', + 'label_attr' => ['class' => 'btn'], + 'attr' => ['class' => 'btn-group w-100'], + 'choices' => self::sizes(), + 'choice_attr' => static fn() => ['class' => 'btn-check'], + 'expanded' => true, + 'constraints' => [ + new NotBlank(), + new Choice(choices: self::sizes()) + ] + ]) + ->add('variant', ChoiceType::class, [ + 'data' => 'standard', + 'label' => 'Variant', + 'choices' => self::variants(), + 'choice_attr' => static fn(string $value): array => $value === 'popout' + ? ['disabled' => 'disabled'] + : [], + 'constraints' => [ + new NotBlank(), + new Choice(choices: self::variants()) + ] + ]) + ->add('color', ChoiceType::class, [ + 'data' => -1, + 'label' => 'Color', + 'label_attr' => ['class' => 'btn'], + 'attr' => ['class' => 'btn-group w-100'], + 'choices' => self::colors(), + 'choice_attr' => static fn() => ['class' => 'btn-check'], + 'expanded' => true, + 'constraints' => [ + new NotBlank(), + new Choice(choices: self::colors()) + ] + ]) + ->add('open', SubmitType::class, [ + 'label' => 'Let\'s play!', + 'attr' => ['class' => 'btn-primary w-100', 'data-open-game-button' => ''], + 'row_attr' => ['class' => 'mb-0'] + ]); + } + + /** + * @return array + */ + private static function sizes(): array + { + return ['7 x 6' => '7x6', '8 x 7' => '8x7', '9 x 7' => '9x7', '10 x 7' => '10x7']; + } + + /** + * @return array + */ + private static function variants(): array + { + return ['Standard' => 'standard', 'PopOut' => 'popout']; + } + + /** + * @return array + */ + private static function colors(): array + { + return ['Red' => 1, 'Yellow' => 2, 'Random' => -1]; + } +} diff --git a/src/WebInterface/Presentation/Http/PageController.php b/src/WebInterface/Presentation/Http/PageController.php index d50c78f0..174b303a 100644 --- a/src/WebInterface/Presentation/Http/PageController.php +++ b/src/WebInterface/Presentation/Http/PageController.php @@ -10,14 +10,14 @@ use Gaming\ConnectFour\Application\Game\Query\OpenGamesQuery; use Gaming\ConnectFour\Application\Game\Query\RunningGamesQuery; use Gaming\WebInterface\Infrastructure\Security\Security; +use Gaming\WebInterface\Presentation\Http\Form\OpenType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Twig\Environment; -final class PageController +final class PageController extends AbstractController { public function __construct( - private readonly Environment $twig, private readonly Bus $connectFourQueryBus, private readonly Security $security ) { @@ -25,34 +25,36 @@ public function __construct( public function lobbyAction(): Response { - return new Response( - $this->twig->render('@web-interface/lobby.html.twig', [ - 'maximumNumberOfGamesInList' => 10, - 'openGames' => $this->connectFourQueryBus->handle(new OpenGamesQuery()), - 'runningGames' => $this->connectFourQueryBus->handle(new RunningGamesQuery()), - 'user' => $this->security->getUser() - ]) - ); + return $this->render('@web-interface/lobby.html.twig', [ + 'maximumNumberOfGamesInList' => 10, + 'openGames' => $this->connectFourQueryBus->handle(new OpenGamesQuery()), + 'runningGames' => $this->connectFourQueryBus->handle(new RunningGamesQuery()), + 'user' => $this->security->getUser(), + 'openForm' => $this->createForm(OpenType::class) + ]); } public function gameAction(string $id): Response { - return new Response( - $this->twig->render('@web-interface/game.html.twig', [ - 'game' => $this->connectFourQueryBus->handle(new GameQuery($id)), - 'user' => $this->security->getUser() - ]) - ); + return $this->render('@web-interface/game.html.twig', [ + 'game' => $this->connectFourQueryBus->handle(new GameQuery($id)), + 'user' => $this->security->getUser() + ]); + } + + public function challengeAction(string $id): Response + { + return $this->render('@web-interface/challenge.html.twig', [ + 'game' => $this->connectFourQueryBus->handle(new GameQuery($id)) + ]); } public function profileAction(Request $request): Response { - return new Response( - $this->twig->render('@web-interface/profile.html.twig', [ - 'games' => $this->connectFourQueryBus->handle( - new GamesByPlayerQuery($this->security->getUser()->getUserIdentifier()) - )->games() - ]) - ); + return $this->render('@web-interface/profile.html.twig', [ + 'games' => $this->connectFourQueryBus->handle( + new GamesByPlayerQuery($this->security->getUser()->getUserIdentifier()) + )->games() + ]); } } diff --git a/src/WebInterface/Presentation/Http/View/challenge.html.twig b/src/WebInterface/Presentation/Http/View/challenge.html.twig new file mode 100644 index 00000000..47a68ee2 --- /dev/null +++ b/src/WebInterface/Presentation/Http/View/challenge.html.twig @@ -0,0 +1,38 @@ +{% extends '@web-interface/layout/condensed.html.twig' %} + +{% set page_title = 'Finding your next match...' %} + +{% block content %} +

+ Finding your next match +

+
+

+ While we find the best match for you, feel free to explore other games or browse around. + Once your match is ready, you'll be automatically redirected. +

+

+ Size: {{ game.width }} x {{ game.height }}, + Variant: Standard, + Color: {{ game.preferredStone|replace({1: 'Red', 2: 'Yellow'})|default('Random') }} +

+
+ +
+
+
Running games
+
+

+ Feature not implemented yet. +

+
+ + + +{% endblock %} diff --git a/src/WebInterface/Presentation/Http/View/game.html.twig b/src/WebInterface/Presentation/Http/View/game.html.twig index 7d8611b4..7fd9ddcb 100644 --- a/src/WebInterface/Presentation/Http/View/game.html.twig +++ b/src/WebInterface/Presentation/Http/View/game.html.twig @@ -78,14 +78,14 @@
- +
- + @@ -101,7 +101,7 @@
- {% endblock %} diff --git a/src/WebInterface/Presentation/Http/View/layout/forms.html.twig b/src/WebInterface/Presentation/Http/View/layout/forms.html.twig new file mode 100644 index 00000000..fec7d133 --- /dev/null +++ b/src/WebInterface/Presentation/Http/View/layout/forms.html.twig @@ -0,0 +1,24 @@ +{% use "bootstrap_5_layout.html.twig" %} + +{# Override to ignore label_attr for expanded choices, allowing them to be rendered as buttons. #} +{%- block choice_label -%} + {% if expanded is defined and expanded %} + {{ label }} + {% else %} + {{- parent() -}} + {% endif %} +{%- endblock choice_label %} + +{# Override to ensure choices don't inherit the required attribute, removing the asterisk from each choice. #} +{%- block choice_widget_expanded -%} +
+ {%- for child in form %} + {{- form_widget(child, { + required: false, + parent_label_class: label_attr.class|default(''), + translation_domain: choice_translation_domain, + valid: valid, + }) -}} + {% endfor -%} +
+{%- endblock choice_widget_expanded %} diff --git a/src/WebInterface/Presentation/Http/View/lobby.html.twig b/src/WebInterface/Presentation/Http/View/lobby.html.twig index 3a0ae290..7519d3d4 100644 --- a/src/WebInterface/Presentation/Http/View/lobby.html.twig +++ b/src/WebInterface/Presentation/Http/View/lobby.html.twig @@ -27,9 +27,21 @@
- - Open a new game - +
diff --git a/tests/acceptance/GameCest.php b/tests/acceptance/GameCest.php index 3c72099b..099942b7 100644 --- a/tests/acceptance/GameCest.php +++ b/tests/acceptance/GameCest.php @@ -9,10 +9,10 @@ class GameCest { public function iCanOpenAGameAndAbortIt(AcceptanceTester $I): void { - $gameId = $this->prepareOpenGameScenario($I); + $this->prepareOpenGameScenario($I); - $I->click('.table-success'); - $I->waitForElementNotVisible('[data-game-id="' . $gameId . '"]'); + $I->submitForm('[data-abort-form]', []); + $I->retrySeeInCurrentUrl('/'); } public function iCanAbortAGameWithAJoinedFriend(AcceptanceTester $I): void @@ -48,10 +48,14 @@ static function (AcceptanceTester $I): void { private function prepareOpenGameScenario(AcceptanceTester $I): string { $I->amOnPage('/'); + $I->click('label[for="open-game-dropdown"]'); + $I->waitForElementVisible('[data-open-game-button]'); $I->click('[data-open-game-button]'); - $I->waitForElement('.table-success'); - return $I->grabAttributeFrom('.table-success', 'data-game-id'); + $I->retrySeeCurrentUrlMatches('#^/challenge/(.*)$#'); + preg_match('#^/challenge/(.*)$#', $I->grabFromCurrentUrl(), $matches); + + return $matches[1]; } private function prepareRunningGameScenario(AcceptanceTester $I, Friend $friend): string diff --git a/tests/unit/ConnectFour/Application/Game/Query/Model/Game/GameTest.php b/tests/unit/ConnectFour/Application/Game/Query/Model/Game/GameTest.php index 78744d29..223b65c2 100644 --- a/tests/unit/ConnectFour/Application/Game/Query/Model/Game/GameTest.php +++ b/tests/unit/ConnectFour/Application/Game/Query/Model/Game/GameTest.php @@ -33,6 +33,7 @@ public function itShouldProjectEvents(): void 'finished' => $expectedFinished, 'height' => 6, 'width' => 7, + 'preferredStone' => 1, 'moves' => [ [ 'x' => 1, @@ -58,8 +59,8 @@ public function itShouldProjectEvents(): void $game = new Game(); $this->applyFromDomainGame($game, $domainGame); - $this->assertEquals($expectedGameId, $game->id()); - $this->assertEquals($expectedFinished, $game->finished()); + $this->assertEquals($expectedGameId, $game->gameId); + $this->assertEquals($expectedFinished, $game->finished); // Implicitly test if it's serializable. $this->assertEquals($expectedSerializedGame, json_encode($game, JSON_THROW_ON_ERROR)); } @@ -75,8 +76,7 @@ public function itShouldBeMarkedAsFinishedWhenGameAborted(): void $game = new Game(); $this->applyFromDomainGame($game, $domainGame); - $this->assertEquals(true, $game->finished()); - $this->assertEquals(true, $game->jsonSerialize()['finished']); + $this->assertEquals(true, $game->finished); } /** @@ -93,8 +93,7 @@ public function itShouldBeMarkedAsFinishedWhenGameResigned(): void $game = new Game(); $this->applyFromDomainGame($game, $domainGame); - $this->assertEquals(true, $game->finished()); - $this->assertEquals(true, $game->jsonSerialize()['finished']); + $this->assertEquals(true, $game->finished); } /** @@ -115,7 +114,7 @@ public function itShouldBeMarkedAsFinishedWhenGameWon(): void $game = new Game(); $this->applyFromDomainGame($game, $domainGame); - $this->assertEquals(true, $game->finished()); + $this->assertEquals(true, $game->finished); $this->assertEquals( [[ 'rule' => 'vertical', @@ -136,8 +135,7 @@ public function itShouldBeMarkedAsFinishedWhenGameDrawn(): void new GameDrawn(GameId::generate()) ); - $this->assertEquals(true, $game->finished()); - $this->assertEquals(true, $game->jsonSerialize()['finished']); + $this->assertEquals(true, $game->finished); } private function applyFromDomainGame(Game $game, DomainGame $domainGame): void diff --git a/tests/unit/ConnectFour/Domain/Game/Board/SizeTest.php b/tests/unit/ConnectFour/Domain/Game/Board/SizeTest.php index 47478d2f..1496eaaf 100644 --- a/tests/unit/ConnectFour/Domain/Game/Board/SizeTest.php +++ b/tests/unit/ConnectFour/Domain/Game/Board/SizeTest.php @@ -46,8 +46,6 @@ public function itShouldThrowAnExceptionOnInvalidSizes(int $width, int $height): public function wrongSizeProvider(): array { return [ - [3, 3], - [5, 5], [-1, 3], [2, -3], [-1, -3], diff --git a/tests/unit/ConnectFour/Domain/Game/GameTest.php b/tests/unit/ConnectFour/Domain/Game/GameTest.php index a026e06c..edda5fe9 100644 --- a/tests/unit/ConnectFour/Domain/Game/GameTest.php +++ b/tests/unit/ConnectFour/Domain/Game/GameTest.php @@ -443,9 +443,10 @@ private function createOpenGame(): Game assert($domainEvents[0] instanceof GameOpened); self::assertEquals($game->id()->toString(), $domainEvents[0]->aggregateId()); - self::assertEquals('playerId1', $domainEvents[0]->playerId()); - self::assertEquals(7, $domainEvents[0]->width()); - self::assertEquals(6, $domainEvents[0]->height()); + self::assertEquals('playerId1', $domainEvents[0]->playerId); + self::assertEquals(7, $domainEvents[0]->width); + self::assertEquals(6, $domainEvents[0]->height); + self::assertEquals(1, $domainEvents[0]->preferredStone); return $game; } @@ -542,9 +543,10 @@ private function createDrawnGame(): Game { $game = Game::open( GameId::generate(), - Configuration::custom( + new Configuration( new Size(2, 2), - WinningRules::standard() + WinningRules::standard(), + Stone::Red ), 'playerId1' ); @@ -560,9 +562,10 @@ private function createDrawnGame(): Game assert($domainEvents[0] instanceof GameOpened); self::assertEquals($game->id()->toString(), $domainEvents[0]->aggregateId()); - self::assertEquals('playerId1', $domainEvents[0]->playerId()); - self::assertEquals(2, $domainEvents[0]->width()); - self::assertEquals(2, $domainEvents[0]->height()); + self::assertEquals('playerId1', $domainEvents[0]->playerId); + self::assertEquals(2, $domainEvents[0]->width); + self::assertEquals(2, $domainEvents[0]->height); + self::assertEquals(1, $domainEvents[0]->preferredStone); assert($domainEvents[1] instanceof PlayerJoined); self::assertEquals($game->id()->toString(), $domainEvents[1]->aggregateId());