From 98b6b0ea4f43dd6d0334b0c637cae31e7b0990dd Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 11:20:41 +0200 Subject: [PATCH 01/95] Remove custom path parameter type binding from controllers --- .../codosseum/controller/GameController.java | 28 ++++++++++++------- .../codosseum/controller/RoundController.java | 5 ++-- .../controller/SolutionController.java | 5 ++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 8db6a29..297713e 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -37,25 +37,32 @@ import java.security.Principal; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; +import org.developerden.codosseum.dto.GameCreateRequest; import org.developerden.codosseum.dto.GameCreateResponse; import org.developerden.codosseum.dto.GameInfo; import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.event.GameEvent; -import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.service.GameService; import org.reactivestreams.Publisher; @Validated @Controller("/games") public class GameController { + private final GameService gameService; + + public GameController(GameService gameService) { + this.gameService = gameService; + } + @Post - public HttpResponse createGame(@Valid @Body GameSettings settings) { - throw new UnsupportedOperationException(); + public HttpResponse createGame(@Valid @Body GameCreateRequest request) { + return HttpResponse.ok(gameService.createGame(request)); } // typed argument binding for looking up games @Get("/{id}") - public HttpResponse getGame(@PathVariable("id") Game game) { + public HttpResponse getGame(@PathVariable("id") String gameId) { throw new UnsupportedOperationException(); } @@ -63,7 +70,7 @@ public HttpResponse getGame(@PathVariable("id") Game game) { @GameAuthorized(GameRole.ADMIN) public HttpResponse updateGame( Principal principal, - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @Valid @Body GameSettings settings ) { throw new UnsupportedOperationException(); @@ -71,13 +78,13 @@ public HttpResponse updateGame( @Delete("/{id}") @GameAuthorized(GameRole.ADMIN) - public HttpResponse deleteGame(Principal principal, @PathVariable("id") Game game) { + public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { throw new UnsupportedOperationException(); } @Post("/{id}/start") @GameAuthorized(GameRole.ADMIN) - public HttpResponse startGame(Principal principal, @PathVariable("id") Game game) { + public HttpResponse startGame(Principal principal, @PathVariable("id") String gameId) { throw new UnsupportedOperationException(); } @@ -86,7 +93,7 @@ public HttpResponse startGame(Principal principal, @PathVariable("id") Gam @Produces(MediaType.TEXT_PLAIN) public HttpResponse getCodeTemplate( Principal principal, - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @QueryValue("lang") String language ) { throw new UnsupportedOperationException(); @@ -95,7 +102,7 @@ public HttpResponse getCodeTemplate( @Post("/{id}/restart") @GameAuthorized(GameRole.PLAYER) public HttpResponse restartGame( - Principal principal, @PathVariable("id") Game game + Principal principal, @PathVariable("id") String gameId ) { throw new UnsupportedOperationException(); } @@ -105,8 +112,9 @@ public HttpResponse restartGame( @Produces(MediaType.TEXT_EVENT_STREAM) public Publisher> subscribeToGameEvents( @Nullable Principal principal, - @PathVariable("id") Game game + @PathVariable("id") String gameId ) { throw new UnsupportedOperationException(); } + } diff --git a/src/main/java/org/developerden/codosseum/controller/RoundController.java b/src/main/java/org/developerden/codosseum/controller/RoundController.java index d36d31d..f71cce8 100644 --- a/src/main/java/org/developerden/codosseum/controller/RoundController.java +++ b/src/main/java/org/developerden/codosseum/controller/RoundController.java @@ -25,7 +25,6 @@ import java.security.Principal; import org.developerden.codosseum.dto.PlayerRoundResult; import org.developerden.codosseum.dto.Round; -import org.developerden.codosseum.model.Game; @Controller("/games/{id}/rounds") public class RoundController { @@ -33,7 +32,7 @@ public class RoundController { @Get("/{round}") public HttpResponse getRound( Principal principal, - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @PathVariable int round, @QueryValue(defaultValue = "false") boolean withCode ) { @@ -42,7 +41,7 @@ public HttpResponse getRound( @Get("/{round}/results/{player}") public HttpResponse getRoundResults( - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @PathVariable int round, @PathVariable String player ) { diff --git a/src/main/java/org/developerden/codosseum/controller/SolutionController.java b/src/main/java/org/developerden/codosseum/controller/SolutionController.java index 7324d33..0ffcc30 100644 --- a/src/main/java/org/developerden/codosseum/controller/SolutionController.java +++ b/src/main/java/org/developerden/codosseum/controller/SolutionController.java @@ -31,7 +31,6 @@ import org.developerden.codosseum.auth.GameRole; import org.developerden.codosseum.dto.Submission; import org.developerden.codosseum.dto.TestResponse; -import org.developerden.codosseum.model.Game; @Validated @Controller("/games/{id}/solutions") @@ -40,7 +39,7 @@ public class SolutionController { @Post("/test") @GameAuthorized(GameRole.PLAYER) public HttpResponse testSolution( - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @Nullable @QueryValue List testNumbers, @Valid @Body Submission submission ) { @@ -50,7 +49,7 @@ public HttpResponse testSolution( @Post("/submit") @GameAuthorized(GameRole.PLAYER) public HttpResponse submitSolution( - @PathVariable("id") Game game, + @PathVariable("id") String gameId, @Valid @Body Submission submission ) { throw new UnsupportedOperationException(); From ab1bafac3fef8b771c531660820e4a0c83756651 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 11:21:22 +0200 Subject: [PATCH 02/95] Use correct main class in build.gradle --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index db28916..2fdb57e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { application { - mainClass.set("org.developerden.codosseum.server.Application") + mainClass.set("org.developerden.codosseum.Application") } java { From 4b9ef87aec316e11dece5b29554b8e4cd5c9a9c3 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 11:32:43 +0200 Subject: [PATCH 03/95] Add secured annotations where previously missing --- src/main/java/module-info.java | 9 +++++---- .../codosseum/controller/GameController.java | 6 ++++++ .../codosseum/controller/InfoController.java | 3 +++ .../codosseum/controller/PlayerController.java | 4 ++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index aec4d8c..c3b9d74 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -22,19 +22,20 @@ requires io.micronaut.serde.micronaut_serde_api; requires io.micronaut.http_client; requires io.micronaut.http_server; + requires io.micronaut.security.micronaut_security; + requires io.micronaut.security.micronaut_security_annotations; + requires io.micronaut.validation.micronaut_validation; + requires io.micronaut.context; + requires io.micronaut.router; requires io.swagger.v3.oas.annotations; requires io.soabase.recordbuilder.core; requires jakarta.annotation; requires jakarta.validation; requires com.fasterxml.jackson.annotation; requires jakarta.inject; - requires io.micronaut.security.micronaut_security; requires java.compiler; requires io.micronaut.http; - requires io.micronaut.validation.micronaut_validation; requires org.reactivestreams; - requires io.micronaut.context; - requires io.micronaut.router; requires reactor.core; } diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 297713e..9124858 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -31,6 +31,8 @@ import io.micronaut.http.sse.Event; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import jakarta.annotation.Nullable; import jakarta.validation.Valid; @@ -47,6 +49,7 @@ @Validated @Controller("/games") +@Secured(SecurityRule.IS_AUTHENTICATED) public class GameController { private final GameService gameService; @@ -56,12 +59,14 @@ public GameController(GameService gameService) { } @Post + @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse createGame(@Valid @Body GameCreateRequest request) { return HttpResponse.ok(gameService.createGame(request)); } // typed argument binding for looking up games @Get("/{id}") + @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse getGame(@PathVariable("id") String gameId) { throw new UnsupportedOperationException(); } @@ -110,6 +115,7 @@ public HttpResponse restartGame( @ExecuteOn(TaskExecutors.IO) @Get("/{id}/events") @Produces(MediaType.TEXT_EVENT_STREAM) + @Secured(SecurityRule.IS_ANONYMOUS) public Publisher> subscribeToGameEvents( @Nullable Principal principal, @PathVariable("id") String gameId diff --git a/src/main/java/org/developerden/codosseum/controller/InfoController.java b/src/main/java/org/developerden/codosseum/controller/InfoController.java index 66c5ff6..c0e413a 100644 --- a/src/main/java/org/developerden/codosseum/controller/InfoController.java +++ b/src/main/java/org/developerden/codosseum/controller/InfoController.java @@ -19,9 +19,12 @@ import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; import java.util.List; @Controller +@Secured(SecurityRule.IS_ANONYMOUS) public class InfoController { @Get("/languages") diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index 9656fde..1b3b0e3 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -24,6 +24,8 @@ import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.PathVariable; import io.micronaut.http.annotation.Post; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import jakarta.validation.Valid; import java.security.Principal; @@ -36,9 +38,11 @@ @Validated @Controller("/games/{id}/players") +@Secured(SecurityRule.IS_AUTHENTICATED) public class PlayerController { @Get + @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse getPlayers(@PathVariable("id") Game game) { throw new UnsupportedOperationException(); } From 013eabfdfcd191a33df6cf06106bedfaeebad047 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 12:35:55 +0200 Subject: [PATCH 04/95] Add exception classes --- .../exception/GameNotFoundException.java | 25 +++++++++++++++ .../exception/IllegalGameStateException.java | 31 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java create mode 100644 src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java diff --git a/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java b/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java new file mode 100644 index 0000000..2dadf02 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.exception; + +public class GameNotFoundException extends RuntimeException { + + public GameNotFoundException(String gameId) { + super("No game with ID '" + gameId + "' found"); + } +} diff --git a/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java new file mode 100644 index 0000000..a0b6150 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.exception; + +import java.util.Set; +import java.util.stream.Collectors; +import org.developerden.codosseum.model.GameState; + +public class IllegalGameStateException extends RuntimeException { + + public IllegalGameStateException(String gameId, GameState state, Set expected) { + super("Game '" + gameId + "' is in state '" + state + "' but this action can only be " + + "taken while in one of the following states: " + + expected.stream().map(GameState::name).collect(Collectors.joining(", "))); + } +} From 2d0f787fbe55f6e803b1b7ddca0493a812646cf2 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 12:36:39 +0200 Subject: [PATCH 05/95] Add caching module (for template caching) --- build.gradle.kts | 1 + src/main/java/module-info.java | 1 + src/main/resources/application.yml | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 2fdb57e..e2127c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { annotationProcessor("io.soabase.record-builder:record-builder-processor:37") implementation("io.micronaut.reactor:micronaut-reactor") + implementation("io.micronaut.cache:micronaut-cache-caffeine") implementation("io.micronaut.security:micronaut-security") implementation("io.micronaut.validation:micronaut-validation") implementation("io.soabase.record-builder:record-builder-core:37") diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index c3b9d74..ed14477 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -22,6 +22,7 @@ requires io.micronaut.serde.micronaut_serde_api; requires io.micronaut.http_client; requires io.micronaut.http_server; + requires io.micronaut.cache.micronaut_cache_core; requires io.micronaut.security.micronaut_security; requires io.micronaut.security.micronaut_security_annotations; requires io.micronaut.validation.micronaut_validation; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eab238a..9de37fd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,3 +14,8 @@ micronaut: application: name: codosseum + caches: + templates: + expire-after-write: 5m + security: + enabled: true From 47d460e8a176eae6fb78ea7509ed035fa0b2e361 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 12:36:59 +0200 Subject: [PATCH 06/95] Refine GameController --- .../codosseum/controller/GameController.java | 26 ++++++++++++------- .../codosseum/service/GameService.java | 22 ++++++++++++++-- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 9124858..693cca1 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -17,6 +17,7 @@ package org.developerden.codosseum.controller; +import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpResponse; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Body; @@ -36,6 +37,7 @@ import io.micronaut.validation.Validated; import jakarta.annotation.Nullable; import jakarta.validation.Valid; +import java.net.URI; import java.security.Principal; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; @@ -61,14 +63,14 @@ public GameController(GameService gameService) { @Post @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse createGame(@Valid @Body GameCreateRequest request) { - return HttpResponse.ok(gameService.createGame(request)); + GameCreateResponse response = gameService.createGame(request); + return HttpResponse.created(response, URI.create(response.id())); } - // typed argument binding for looking up games @Get("/{id}") @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse getGame(@PathVariable("id") String gameId) { - throw new UnsupportedOperationException(); + return HttpResponse.ok(gameService.getGame(gameId)); } @Patch("/{id}") @@ -78,19 +80,21 @@ public HttpResponse updateGame( @PathVariable("id") String gameId, @Valid @Body GameSettings settings ) { - throw new UnsupportedOperationException(); + return HttpResponse.ok(gameService.updateGame(gameId, settings)); } @Delete("/{id}") @GameAuthorized(GameRole.ADMIN) public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { - throw new UnsupportedOperationException(); + gameService.deleteGame(gameId); + return HttpResponse.noContent(); } @Post("/{id}/start") @GameAuthorized(GameRole.ADMIN) public HttpResponse startGame(Principal principal, @PathVariable("id") String gameId) { - throw new UnsupportedOperationException(); + gameService.startGame(gameId); + return HttpResponse.noContent(); } @Get("/{id}/template") @@ -99,17 +103,21 @@ public HttpResponse startGame(Principal principal, @PathVariable("id") Str public HttpResponse getCodeTemplate( Principal principal, @PathVariable("id") String gameId, + // add custom validation annotation here @QueryValue("lang") String language ) { - throw new UnsupportedOperationException(); + return HttpResponse.ok(gameService.getTemplate(gameId, language)); } @Post("/{id}/restart") @GameAuthorized(GameRole.PLAYER) public HttpResponse restartGame( - Principal principal, @PathVariable("id") String gameId + Principal principal, + @PathVariable("id") String gameId, + @Valid @Body GameSettings settings ) { - throw new UnsupportedOperationException(); + GameCreateResponse response = gameService.restartGame(gameId); + return HttpResponse.created(response, URI.create(response.id())); } @ExecuteOn(TaskExecutors.IO) diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index e2f3471..bff9961 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -18,6 +18,7 @@ package org.developerden.codosseum.service; import jakarta.inject.Singleton; +import org.developerden.codosseum.dto.GameCreateRequest; import org.developerden.codosseum.dto.GameCreateResponse; import org.developerden.codosseum.dto.GameInfo; import org.developerden.codosseum.dto.GameSettings; @@ -25,11 +26,15 @@ @Singleton public class GameService { - public GameCreateResponse createGame(String creator) { + public GameCreateResponse createGame(GameCreateRequest request) { throw new UnsupportedOperationException(); } - public GameInfo updateGame(GameSettings settings) { + public GameInfo updateGame(String gameId, GameSettings settings) { + throw new UnsupportedOperationException(); + } + + public void deleteGame(String gameId) { throw new UnsupportedOperationException(); } @@ -37,8 +42,21 @@ public GameInfo getGame(String id) { throw new UnsupportedOperationException(); } + public void startGame(String gameId) { + // check before if game is in warmup state + initiateNextRound(gameId); + } + + public String getTemplate(String gameId, String lang) { + throw new UnsupportedOperationException(); + } + public void initiateNextRound(String gameId) { throw new UnsupportedOperationException(); } + public GameCreateResponse restartGame(String gameId) { + throw new UnsupportedOperationException(); + } + } From 2405b3a4c0fc65580c6b92e40763d0974683857d Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 13:14:51 +0200 Subject: [PATCH 07/95] Add spdx validation to challenge object --- build.gradle.kts | 1 + .../codosseum/challenge/Challenge.java | 6 ++- .../challenge/validation/SpdxId.java | 32 +++++++++++++ .../validation/SpdxIdConstraintValidator.java | 45 +++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java create mode 100644 src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java diff --git a/build.gradle.kts b/build.gradle.kts index e2127c0..1de8b53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation("io.micronaut:micronaut-http-client") implementation("com.github.kkuegler:human-readable-ids-java:0.1.1") implementation("com.networknt:json-schema-validator:1.0.86") + implementation("org.spdx:java-spdx-library:1.1.7") runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("org.yaml:snakeyaml") } diff --git a/src/main/java/org/developerden/codosseum/challenge/Challenge.java b/src/main/java/org/developerden/codosseum/challenge/Challenge.java index 5b0676a..c433875 100644 --- a/src/main/java/org/developerden/codosseum/challenge/Challenge.java +++ b/src/main/java/org/developerden/codosseum/challenge/Challenge.java @@ -17,6 +17,7 @@ package org.developerden.codosseum.challenge; +import io.micronaut.core.annotation.Introspected; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; @@ -24,16 +25,17 @@ import jakarta.validation.constraints.Size; import java.util.List; import java.util.Set; +import org.developerden.codosseum.challenge.validation.SpdxId; +@Introspected public record Challenge( @Nullable Author author, - // Custom validator + @SpdxId @Nullable String license, - // Custom validator @Nullable String language, diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java new file mode 100644 index 0000000..515e620 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.challenge.validation; + +import jakarta.validation.Constraint; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = SpdxIdConstraintValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SpdxId { +} diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java new file mode 100644 index 0000000..3ddb679 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.challenge.validation; + +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.validation.validator.constraints.ConstraintValidator; +import io.micronaut.validation.validator.constraints.ConstraintValidatorContext; +import org.spdx.library.model.license.InvalidLicenseStringException; +import org.spdx.library.model.license.LicenseInfoFactory; + +public class SpdxIdConstraintValidator implements ConstraintValidator { + + @Override + public boolean isValid(@Nullable String value, + @NonNull AnnotationValue annotationMetadata, + @NonNull ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + try { + LicenseInfoFactory.parseSPDXLicenseString(value); + } catch (InvalidLicenseStringException e) { + return false; + } + return true; + } +} From 44408a08c4d49bb581d5fb2c30045e623b3d053b Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Sun, 3 Sep 2023 14:43:47 +0200 Subject: [PATCH 08/95] Begin writing config code --- src/main/java/module-info.java | 4 +- .../codosseum/config/ChallengeDirectory.java | 28 ++++++++++++ .../codosseum/config/ChallengeRepository.java | 39 +++++++++++++++++ .../codosseum/config/ChallengeSource.java | 29 +++++++++++++ .../codosseum/config/CodosseumConfig.java | 43 +++++++++++++++++++ src/main/resources/application-local.yml | 23 ++++++++++ src/main/resources/application.yml | 4 +- 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java create mode 100644 src/main/java/org/developerden/codosseum/config/ChallengeRepository.java create mode 100644 src/main/java/org/developerden/codosseum/config/ChallengeSource.java create mode 100644 src/main/java/org/developerden/codosseum/config/CodosseumConfig.java create mode 100644 src/main/resources/application-local.yml diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index ed14477..219b0f2 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -27,6 +27,7 @@ requires io.micronaut.security.micronaut_security_annotations; requires io.micronaut.validation.micronaut_validation; requires io.micronaut.context; + requires io.micronaut.inject; requires io.micronaut.router; requires io.swagger.v3.oas.annotations; requires io.soabase.recordbuilder.core; @@ -38,5 +39,6 @@ requires io.micronaut.http; requires org.reactivestreams; requires reactor.core; - + requires human.readable.ids.java; + requires java.spdx.library; } diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java b/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java new file mode 100644 index 0000000..ce5698d --- /dev/null +++ b/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.config; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import java.nio.file.Path; + +@JsonTypeName("directory") +public record ChallengeDirectory( + Path path +) implements ChallengeSource { + +} diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java b/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java new file mode 100644 index 0000000..f6acdca --- /dev/null +++ b/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.config; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.time.Duration; + +@JsonTypeName("repo") +public record ChallengeRepository( + @Nonnull + String url, + + @Nonnull + Duration pullInterval, + + @Nullable + String username, + + @Nullable + String password +) implements ChallengeSource { +} diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeSource.java b/src/main/java/org/developerden/codosseum/config/ChallengeSource.java new file mode 100644 index 0000000..8adcfcb --- /dev/null +++ b/src/main/java/org/developerden/codosseum/config/ChallengeSource.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.config; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(ChallengeRepository.class), + @JsonSubTypes.Type(ChallengeDirectory.class) +}) +public sealed interface ChallengeSource permits ChallengeRepository, ChallengeDirectory { +} diff --git a/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java b/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java new file mode 100644 index 0000000..9fdd067 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.config; + +import io.micronaut.context.annotation.ConfigurationProperties; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.time.Duration; +import java.util.List; + + +@ConfigurationProperties("codosseum") +public record CodosseumConfig( + @Nonnull + ChallengeSource challengeSource, + + @Nonnull + Duration gameRetention, + + @Nullable + List disabledLanguages, + + @Nullable + List enabledLanguages + + +) { +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..b919797 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2023 JohnnyJayJay +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify it under the terms of +# the GNU Affero General Public License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. +# If not, see . +codosseum: + challenge-source: + type: repo + url: https://github.com/codosseum-org/challenges.git + pull-interval: PT1M + game-retention: PT30S + enabled-languages: + - clojure + - java + - haskell diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9de37fd..8e727f1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,8 +14,8 @@ micronaut: application: name: codosseum + security: + enabled: true caches: templates: expire-after-write: 5m - security: - enabled: true From 735ed191c95636ae889aca8e0cfd7d407c755172 Mon Sep 17 00:00:00 2001 From: JohnnyJayJay Date: Mon, 4 Sep 2023 11:10:46 +0200 Subject: [PATCH 09/95] remove module info (rip) --- src/main/java/module-info.java | 44 ---------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 src/main/java/module-info.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java deleted file mode 100644 index 219b0f2..0000000 --- a/src/main/java/module-info.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License along with this program. - * If not, see . - * - */ - -module codosseum { - - requires io.micronaut.core; - requires io.micronaut.reactor.micronaut_reactor; - requires io.micronaut.serde.micronaut_serde_api; - requires io.micronaut.http_client; - requires io.micronaut.http_server; - requires io.micronaut.cache.micronaut_cache_core; - requires io.micronaut.security.micronaut_security; - requires io.micronaut.security.micronaut_security_annotations; - requires io.micronaut.validation.micronaut_validation; - requires io.micronaut.context; - requires io.micronaut.inject; - requires io.micronaut.router; - requires io.swagger.v3.oas.annotations; - requires io.soabase.recordbuilder.core; - requires jakarta.annotation; - requires jakarta.validation; - requires com.fasterxml.jackson.annotation; - requires jakarta.inject; - requires java.compiler; - requires io.micronaut.http; - requires org.reactivestreams; - requires reactor.core; - requires human.readable.ids.java; - requires java.spdx.library; -} From c4ca6283ccb6ef8776fb86716b627b0de3ee0823 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 2 Sep 2024 11:12:06 -0400 Subject: [PATCH 10/95] fix weird logging error with conflicting slf4j impls --- build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1de8b53..74e281e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,7 +35,9 @@ dependencies { implementation("io.micronaut:micronaut-http-client") implementation("com.github.kkuegler:human-readable-ids-java:0.1.1") implementation("com.networknt:json-schema-validator:1.0.86") - implementation("org.spdx:java-spdx-library:1.1.7") + implementation("org.spdx:java-spdx-library:1.1.7") { + exclude("org.apache.logging.log4j") + } runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("org.yaml:snakeyaml") } From 06e989ae4ad706aacb3b12a7e8fab52821d05a76 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 19:57:07 +0100 Subject: [PATCH 11/95] add keycloak config --- build.gradle.kts | 2 ++ src/main/resources/application.yml | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 74e281e..88bf670 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation("io.micronaut.cache:micronaut-cache-caffeine") implementation("io.micronaut.security:micronaut-security") implementation("io.micronaut.validation:micronaut-validation") + implementation("io.micronaut.security:micronaut-security-oauth2") + implementation("io.micronaut.security:micronaut-security-jwt") implementation("io.soabase.record-builder:record-builder-core:37") implementation("io.micronaut.serde:micronaut-serde-jackson") implementation("io.swagger.core.v3:swagger-annotations") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8e727f1..81c203c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,7 +15,17 @@ micronaut: application: name: codosseum security: - enabled: true + authentication: idtoken + oauth2: + clients: + keycloak: + client-secret: '${OAUTH_CLIENT_SECRET:secret}' + client-id: '${OAUTH_CLIENT_ID:myclient}' + openid: + issuer: '${OIDC_ISSUER_DOMAIN:`http://localhost:8080`}/realms/${KEYCLOAK_REALM:myrealm}' + endpoints: + logout: + get-allowed: true caches: templates: expire-after-write: 5m From f15495494e5585be59c551518ade57522a338800 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 20:05:35 +0100 Subject: [PATCH 12/95] fix buggy tokenvalidator --- .../codosseum/auth/GameKeyTokenValidator.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java index 87a5438..168df96 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java @@ -23,11 +23,13 @@ import io.micronaut.security.token.validator.TokenValidator; import jakarta.inject.Singleton; import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; @Singleton public class GameKeyTokenValidator implements TokenValidator> { - @Override - public Publisher validateToken(String token, @Nullable HttpRequest request) { - return null; - } + @Override + public Publisher validateToken(String token, @Nullable HttpRequest request) { + return Mono.empty(); + + } } From e64a0f15f8bc08754862c35b85d217c1b555a1be Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 20:31:52 +0100 Subject: [PATCH 13/95] expose swagger-ui and openapi --- build.gradle.kts | 1 + openapi.properties | 1 + src/main/resources/application.yml | 21 +++++++++++++++++++ .../codosseum/OpenApiExposedTest.java | 19 +++++++++++++++++ .../codosseum/OpenApiGeneratedTest.java | 16 ++++++++++++++ .../codosseum/SwaggerUiGeneratedTest.java | 16 ++++++++++++++ .../developerden/codosseum/SwaggerUiTest.java | 19 +++++++++++++++++ 7 files changed, 93 insertions(+) create mode 100644 openapi.properties create mode 100644 src/test/java/org/developerden/codosseum/OpenApiExposedTest.java create mode 100644 src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java create mode 100644 src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java create mode 100644 src/test/java/org/developerden/codosseum/SwaggerUiTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 88bf670..f00d8c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ repositories { dependencies { annotationProcessor("io.micronaut.openapi:micronaut-openapi") + implementation("io.micronaut.openapi:micronaut-openapi-annotations") annotationProcessor("io.micronaut.serde:micronaut-serde-processor") annotationProcessor("io.micronaut.validation:micronaut-validation-processor") annotationProcessor("io.micronaut.security:micronaut-security-annotations") diff --git a/openapi.properties b/openapi.properties new file mode 100644 index 0000000..0f25672 --- /dev/null +++ b/openapi.properties @@ -0,0 +1 @@ +swagger-ui.enabled=true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 81c203c..9b7aa9f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,7 @@ # You should have received a copy of the GNU Affero General Public License along with this program. # If not, see . micronaut: + application: name: codosseum security: @@ -26,6 +27,26 @@ micronaut: endpoints: logout: get-allowed: true + intercept-url-map: + - pattern: /swagger/** + http-method: GET + access: + - isAnonymous() + - pattern: /swagger-ui/** + http-method: GET + access: + - isAnonymous() + caches: templates: expire-after-write: 5m + + router: + static-resources: + swagger: + paths: classpath:META-INF/swagger + mapping: /swagger/** + + swagger-ui: + paths: classpath:META-INF/swagger/views/swagger-ui + mapping: /swagger-ui/** \ No newline at end of file diff --git a/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java b/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java new file mode 100644 index 0000000..a59fd93 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java @@ -0,0 +1,19 @@ +package org.developerden.codosseum; + +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@MicronautTest +class OpenApiExposedTest { + + @Test + void openApi(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + assertDoesNotThrow(() -> client.exchange("/swagger/codosseum-0.0.yml")); + } +} \ No newline at end of file diff --git a/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java b/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java new file mode 100644 index 0000000..6271e40 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java @@ -0,0 +1,16 @@ +package org.developerden.codosseum; + +import io.micronaut.core.io.ResourceLoader; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(startApplication = false) +class OpenApiGeneratedTest { + + @Test + void buildGeneratesOpenApi(ResourceLoader resourceLoader) { + assertTrue(resourceLoader.getResource("META-INF/swagger/codosseum-0.0.yml").isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java b/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java new file mode 100644 index 0000000..00a0265 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java @@ -0,0 +1,16 @@ +package org.developerden.codosseum; + +import io.micronaut.core.io.ResourceLoader; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(startApplication = false) +class SwaggerUiGeneratedTest { + + @Test + void buildGeneratesOpenApi(ResourceLoader resourceLoader) { + assertTrue(resourceLoader.getResource("META-INF/swagger/views/swagger-ui/index.html").isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/org/developerden/codosseum/SwaggerUiTest.java b/src/test/java/org/developerden/codosseum/SwaggerUiTest.java new file mode 100644 index 0000000..7931513 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/SwaggerUiTest.java @@ -0,0 +1,19 @@ +package org.developerden.codosseum; + +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@MicronautTest +class SwaggerUiTest { + + @Test + void openApi(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + assertDoesNotThrow(() -> client.exchange("/swagger-ui/index.html")); + } +} \ No newline at end of file From 4629f9b7d144e989f52677ba2914cd3c399f9152 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 20:39:51 +0100 Subject: [PATCH 14/95] add Serdeable annotations where required --- .../codosseum/dto/GameCreateRequest.java | 15 +++--- .../codosseum/dto/GameCreateResponse.java | 2 + .../codosseum/dto/GameSettings.java | 46 ++++++++++--------- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java index 2352a19..c161feb 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java @@ -18,17 +18,16 @@ package org.developerden.codosseum.dto; import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; import jakarta.annotation.Nonnull; import jakarta.validation.constraints.NotNull; @Introspected +@Serdeable public record GameCreateRequest( - @Nonnull - @NotNull - GameSettings settings, - - @Nonnull - @NotNull - Player player -){ + @NotNull + GameSettings settings, + @NotNull + Player player +) { } diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java index e738fcf..7303918 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java @@ -17,10 +17,12 @@ package org.developerden.codosseum.dto; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; @RecordBuilder +@Serdeable public record GameCreateResponse( @Nonnull String adminKey, diff --git a/src/main/java/org/developerden/codosseum/dto/GameSettings.java b/src/main/java/org/developerden/codosseum/dto/GameSettings.java index 6ed1ce3..2d52a5b 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameSettings.java +++ b/src/main/java/org/developerden/codosseum/dto/GameSettings.java @@ -18,37 +18,41 @@ package org.developerden.codosseum.dto; import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nullable; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; + import java.util.List; + import org.developerden.codosseum.mode.GameMode; @Introspected @RecordBuilder +@Serdeable public record GameSettings( - // custom validator for elements - @Nullable - List allowedLanguages, - - @Nullable - GameMode gameMode, - - @Nullable - @Min(2) - @Max(50) - Integer maxPlayers, - - @Nullable - @Min(60) - @Max(1800) - Integer timeLimit, - - @Nullable - @Min(0) - @Max(300) - Integer maxWarmupTime + // custom validator for elements + @Nullable + List allowedLanguages, + + @Nullable + GameMode gameMode, + + @Nullable + @Min(2) + @Max(50) + Integer maxPlayers, + + @Nullable + @Min(60) + @Max(1800) + Integer timeLimit, + + @Nullable + @Min(0) + @Max(300) + Integer maxWarmupTime ) { } From 68921c1aeea4f5a55b79de87776338536ea4a477 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 20:42:21 +0100 Subject: [PATCH 15/95] remove redundant import --- .../java/org/developerden/codosseum/dto/GameCreateRequest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java index c161feb..3bdb923 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java @@ -19,7 +19,6 @@ import io.micronaut.core.annotation.Introspected; import io.micronaut.serde.annotation.Serdeable; -import jakarta.annotation.Nonnull; import jakarta.validation.constraints.NotNull; @Introspected From 15d13bfbc13255d76c1751282a092879f83793f4 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 20:51:46 +0100 Subject: [PATCH 16/95] start to more explicitly openapi document everything --- .../codosseum/dto/GameCreateRequest.java | 13 +++++++++++++ .../developerden/codosseum/dto/GameSettings.java | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java index 3bdb923..0dbc941 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java @@ -19,14 +19,27 @@ import io.micronaut.core.annotation.Introspected; import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @Introspected @Serdeable +@Schema( + description = "Settings for the new game" +) + public record GameCreateRequest( @NotNull GameSettings settings, @NotNull + @Schema(description = """ + Player info of the person creating the game. + Without special authorisation, this must not be omitted and the provided + player will join the newly created game automatically. + + In the future, there might be a mechanism for technical users to create games without joining them, + where this property is optional. + """) Player player ) { } diff --git a/src/main/java/org/developerden/codosseum/dto/GameSettings.java b/src/main/java/org/developerden/codosseum/dto/GameSettings.java index 2d52a5b..3c2aea2 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameSettings.java +++ b/src/main/java/org/developerden/codosseum/dto/GameSettings.java @@ -20,39 +20,47 @@ import io.micronaut.core.annotation.Introspected; import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import org.developerden.codosseum.mode.GameMode; import java.util.List; -import org.developerden.codosseum.mode.GameMode; - @Introspected @RecordBuilder @Serdeable +@Schema( + description = "Settings for a Codosseum game" +) public record GameSettings( // custom validator for elements @Nullable + @Schema(description = "Which programming languages are allowed to be used for submissions. If omitted, all languages are allowed.") List allowedLanguages, @Nullable + @Schema(description = "Game modes from which the server will make a random selection. If omitted, all game modes are allowed") GameMode gameMode, @Nullable @Min(2) @Max(50) + @Schema(description = "Maximum player count for the game") Integer maxPlayers, @Nullable @Min(60) @Max(1800) + @Schema(description = "Time limit for the game, in seconds.") Integer timeLimit, @Nullable @Min(0) @Max(300) + @Schema(description = "Maximum warmup time for a game in seconds - that is, a timer that starts once 2 or more players have joined the game that delays the starting of the game to allow more players to join.") Integer maxWarmupTime ) { } From c0f3d4b3007488bd7debb486cd028ca6aa98b148 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 6 Oct 2025 21:02:51 +0100 Subject: [PATCH 17/95] get a GameCreateRequest to parse --- .../org/developerden/codosseum/dto/GameCreateRequest.java | 1 - .../java/org/developerden/codosseum/dto/GameSettings.java | 4 ++-- src/main/java/org/developerden/codosseum/dto/Player.java | 4 ++++ .../java/org/developerden/codosseum/mode/GameMode.java | 7 ++++--- .../org/developerden/codosseum/mode/GameModeFactory.java | 1 + .../java/org/developerden/codosseum/mode/GameModeType.java | 7 +++++++ 6 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/mode/GameModeType.java diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java index 0dbc941..f9c27fa 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java @@ -27,7 +27,6 @@ @Schema( description = "Settings for the new game" ) - public record GameCreateRequest( @NotNull GameSettings settings, diff --git a/src/main/java/org/developerden/codosseum/dto/GameSettings.java b/src/main/java/org/developerden/codosseum/dto/GameSettings.java index 3c2aea2..4fd3bea 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameSettings.java +++ b/src/main/java/org/developerden/codosseum/dto/GameSettings.java @@ -24,7 +24,7 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import org.developerden.codosseum.mode.GameMode; +import org.developerden.codosseum.mode.GameModeType; import java.util.List; @@ -43,7 +43,7 @@ public record GameSettings( @Nullable @Schema(description = "Game modes from which the server will make a random selection. If omitted, all game modes are allowed") - GameMode gameMode, + List allowedGameModes, @Nullable @Min(2) diff --git a/src/main/java/org/developerden/codosseum/dto/Player.java b/src/main/java/org/developerden/codosseum/dto/Player.java index ba1a378..4e242a4 100644 --- a/src/main/java/org/developerden/codosseum/dto/Player.java +++ b/src/main/java/org/developerden/codosseum/dto/Player.java @@ -17,10 +17,14 @@ package org.developerden.codosseum.dto; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; @RecordBuilder +@Introspected +@Serdeable public record Player( @Nonnull String name diff --git a/src/main/java/org/developerden/codosseum/mode/GameMode.java b/src/main/java/org/developerden/codosseum/mode/GameMode.java index 09ce624..5da586c 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameMode.java +++ b/src/main/java/org/developerden/codosseum/mode/GameMode.java @@ -17,15 +17,16 @@ package org.developerden.codosseum.mode; -import java.util.List; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.PlayerRoundResult; -import org.developerden.codosseum.model.Game; + +import java.util.List; // should have a serialiser/deserialiser using the game mode name and the available // implementations on the classpath public interface GameMode { + GameModeType getType(); - double computeScore(Player player, List results); + double computeScore(Player player, List results); } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java index 044649d..b11f012 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java @@ -21,4 +21,5 @@ public interface GameModeFactory { GameMode initForGame(String gameId); + } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeType.java b/src/main/java/org/developerden/codosseum/mode/GameModeType.java new file mode 100644 index 0000000..252425d --- /dev/null +++ b/src/main/java/org/developerden/codosseum/mode/GameModeType.java @@ -0,0 +1,7 @@ +package org.developerden.codosseum.mode; + +public enum GameModeType { + FASTEST, + REVERSE, + GOLF +} From 48fcaae9c51b7b0d72ca340c682238fddd9b0c40 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 10:39:29 +0100 Subject: [PATCH 18/95] add basic game storage and creation --- .../codosseum/controller/GameController.java | 169 ++++++++++-------- .../codosseum/dto/GameCreateResponse.java | 9 +- .../developerden/codosseum/dto/GameInfo.java | 6 +- .../developerden/codosseum/model/Game.java | 12 +- .../codosseum/model/GamePlayers.java | 8 + .../codosseum/model/GameState.java | 17 +- .../codosseum/repository/GameRepository.java | 6 +- .../repository/InMemoryGameRepository.java | 23 +++ .../codosseum/service/GameService.java | 85 ++++++--- 9 files changed, 215 insertions(+), 120 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/model/GamePlayers.java create mode 100644 src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 693cca1..de703fd 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -17,28 +17,21 @@ package org.developerden.codosseum.controller; -import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpResponse; import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Delete; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Patch; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.http.annotation.Post; -import io.micronaut.http.annotation.Produces; -import io.micronaut.http.annotation.QueryValue; +import io.micronaut.http.annotation.*; import io.micronaut.http.sse.Event; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; import jakarta.validation.Valid; -import java.net.URI; -import java.security.Principal; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; import org.developerden.codosseum.dto.GameCreateRequest; @@ -49,86 +42,104 @@ import org.developerden.codosseum.service.GameService; import org.reactivestreams.Publisher; +import java.net.URI; +import java.security.Principal; +import java.util.UUID; + @Validated @Controller("/games") @Secured(SecurityRule.IS_AUTHENTICATED) public class GameController { - private final GameService gameService; + private final GameService gameService; - public GameController(GameService gameService) { - this.gameService = gameService; - } + public GameController(GameService gameService) { + this.gameService = gameService; + } - @Post - @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse createGame(@Valid @Body GameCreateRequest request) { - GameCreateResponse response = gameService.createGame(request); - return HttpResponse.created(response, URI.create(response.id())); - } + @Post + @Secured(SecurityRule.IS_ANONYMOUS) + @ApiResponse( + responseCode = "201", + description = "new game created", + content = @Content(), + headers = {@Header( + name = "Location", + description = "URL of the newly created game", + required = true, + schema = @Schema( + type = "string", + format = "uri-reference" + ) + )} + ) + public HttpResponse createGame(@Valid @Body GameCreateRequest request) { + GameCreateResponse response = gameService.createGame(request); + return HttpResponse.created(response, URI.create(response.id().toString())); + } - @Get("/{id}") - @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getGame(@PathVariable("id") String gameId) { - return HttpResponse.ok(gameService.getGame(gameId)); - } + @Get("/{id}") + @Secured(SecurityRule.IS_ANONYMOUS) + public HttpResponse getGame(@PathVariable("id") @Valid UUID gameId) { + return HttpResponse.ok(gameService.getGame(gameId)); + } - @Patch("/{id}") - @GameAuthorized(GameRole.ADMIN) - public HttpResponse updateGame( - Principal principal, - @PathVariable("id") String gameId, - @Valid @Body GameSettings settings - ) { - return HttpResponse.ok(gameService.updateGame(gameId, settings)); - } + @Patch("/{id}") + @GameAuthorized(GameRole.ADMIN) + public HttpResponse updateGame( + Principal principal, + @PathVariable("id") String gameId, + @Valid @Body GameSettings settings + ) { + return HttpResponse.ok(gameService.updateGame(gameId, settings)); + } - @Delete("/{id}") - @GameAuthorized(GameRole.ADMIN) - public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { - gameService.deleteGame(gameId); - return HttpResponse.noContent(); - } + @Delete("/{id}") + @GameAuthorized(GameRole.ADMIN) + public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { + gameService.deleteGame(gameId); + return HttpResponse.noContent(); + } - @Post("/{id}/start") - @GameAuthorized(GameRole.ADMIN) - public HttpResponse startGame(Principal principal, @PathVariable("id") String gameId) { - gameService.startGame(gameId); - return HttpResponse.noContent(); - } + @Post("/{id}/start") + @GameAuthorized(GameRole.ADMIN) + public HttpResponse startGame(Principal principal, @PathVariable("id") String gameId) { + gameService.startGame(gameId); + return HttpResponse.noContent(); + } - @Get("/{id}/template") - @GameAuthorized(GameRole.PLAYER) - @Produces(MediaType.TEXT_PLAIN) - public HttpResponse getCodeTemplate( - Principal principal, - @PathVariable("id") String gameId, - // add custom validation annotation here - @QueryValue("lang") String language - ) { - return HttpResponse.ok(gameService.getTemplate(gameId, language)); - } + @Get("/{id}/template") + @GameAuthorized(GameRole.PLAYER) + @Produces(MediaType.TEXT_PLAIN) + public HttpResponse getCodeTemplate( + Principal principal, + @PathVariable("id") String gameId, + // add custom validation annotation here + @QueryValue("lang") String language + ) { + return HttpResponse.ok(gameService.getTemplate(gameId, language)); + } - @Post("/{id}/restart") - @GameAuthorized(GameRole.PLAYER) - public HttpResponse restartGame( - Principal principal, - @PathVariable("id") String gameId, - @Valid @Body GameSettings settings - ) { - GameCreateResponse response = gameService.restartGame(gameId); - return HttpResponse.created(response, URI.create(response.id())); - } + @Post("/{id}/restart") + @GameAuthorized(GameRole.PLAYER) + public HttpResponse restartGame( + Principal principal, + @PathVariable("id") String gameId, + @Valid @Body GameSettings settings + ) { + GameCreateResponse response = gameService.restartGame(gameId); + return HttpResponse.created(response, URI.create(response.id().toString())); + } - @ExecuteOn(TaskExecutors.IO) - @Get("/{id}/events") - @Produces(MediaType.TEXT_EVENT_STREAM) - @Secured(SecurityRule.IS_ANONYMOUS) - public Publisher> subscribeToGameEvents( - @Nullable Principal principal, - @PathVariable("id") String gameId - ) { - throw new UnsupportedOperationException(); - } + @ExecuteOn(TaskExecutors.IO) + @Get("/{id}/events") + @Produces(MediaType.TEXT_EVENT_STREAM) + @Secured(SecurityRule.IS_ANONYMOUS) + public Publisher> subscribeToGameEvents( + @Nullable Principal principal, + @PathVariable("id") String gameId + ) { + throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java index 7303918..2412142 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java @@ -19,15 +19,20 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; -import jakarta.annotation.Nonnull; +import jakarta.validation.constraints.NotNull; + +import javax.annotation.Nonnull; +import java.util.UUID; @RecordBuilder @Serdeable public record GameCreateResponse( + @NotNull @Nonnull String adminKey, + @NotNull @Nonnull - String id + UUID id ) { } diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index 3f44638..ed27487 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -20,17 +20,19 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import java.util.List; import org.developerden.codosseum.mode.GameMode; import org.developerden.codosseum.model.GameState; +import java.util.List; +import java.util.UUID; + @RecordBuilder public record GameInfo( @Nonnull GameSettings settings, @Nonnull - String id, + UUID id, @Nonnull GameMode gameMode, diff --git a/src/main/java/org/developerden/codosseum/model/Game.java b/src/main/java/org/developerden/codosseum/model/Game.java index 6be1be5..b464a5b 100644 --- a/src/main/java/org/developerden/codosseum/model/Game.java +++ b/src/main/java/org/developerden/codosseum/model/Game.java @@ -18,14 +18,16 @@ package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; -import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; -import java.util.List; -import org.developerden.codosseum.mode.GameMode; +import org.developerden.codosseum.dto.GameSettings; + +import java.util.UUID; // TODO: 19/08/23 internal model @RecordBuilder public record Game( - + UUID id, + String adminKey, + GameSettings settings, + GamePlayers players ) { } diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java new file mode 100644 index 0000000..d3bc0f3 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -0,0 +1,8 @@ +package org.developerden.codosseum.model; + +import org.developerden.codosseum.dto.Player; + +import java.util.Set; + +public record GamePlayers(Player admin, Set others) { +} diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index b1ea89b..f39d6f0 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -17,11 +17,18 @@ package org.developerden.codosseum.model; -public enum GameState { +import io.swagger.v3.oas.annotations.media.Schema; - WARMUP, - IN_PROGRESS, - ROUND_OVER, - GAME_OVER +public enum GameState { + @Schema(description = "The game has not yet started and is waiting for more players before it can begin") + WAITING_FOR_PLAYERS, + @Schema(description = "The game is in a warmup phase and is ready to begin") + WARMUP, + @Schema(description = "The game is currently in progress") + IN_PROGRESS, + @Schema(description = "The current round of the game is over") + ROUND_OVER, + @Schema(description = "The game has ended") + GAME_OVER } diff --git a/src/main/java/org/developerden/codosseum/repository/GameRepository.java b/src/main/java/org/developerden/codosseum/repository/GameRepository.java index ee99552..7fc7992 100644 --- a/src/main/java/org/developerden/codosseum/repository/GameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/GameRepository.java @@ -19,10 +19,12 @@ import org.developerden.codosseum.model.Game; +import java.util.UUID; + public interface GameRepository { - Game findGameById(String id); + Game findGameById(UUID id); - void insertGame(Game game); + void insertGame(Game game); } diff --git a/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java new file mode 100644 index 0000000..ed6faef --- /dev/null +++ b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java @@ -0,0 +1,23 @@ +package org.developerden.codosseum.repository; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.Game; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Singleton +public class InMemoryGameRepository implements GameRepository { + private final Map games = new ConcurrentHashMap<>(); + + @Override + public Game findGameById(UUID id) { + return games.get(id); + } + + @Override + public void insertGame(Game game) { + games.put(game.id(), game); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index bff9961..c1d02be 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -17,46 +17,81 @@ package org.developerden.codosseum.service; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.developerden.codosseum.dto.GameCreateRequest; import org.developerden.codosseum.dto.GameCreateResponse; import org.developerden.codosseum.dto.GameInfo; import org.developerden.codosseum.dto.GameSettings; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.GamePlayers; +import org.developerden.codosseum.repository.GameRepository; + +import java.util.HashSet; +import java.util.Optional; +import java.util.UUID; @Singleton public class GameService { + private final GameRepository gameRepository; + + public @Inject GameService(GameRepository gameRepository) { + this.gameRepository = gameRepository; + } + + private String generateAdminKey() { + return UUID.randomUUID().toString(); + } + + public GameCreateResponse createGame(GameCreateRequest request) { + + var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new GamePlayers( + request.player(), + new HashSet<>() + )); + + gameRepository.insertGame(game); + + return new GameCreateResponse(game.adminKey(), game.id()); + } + + public GameInfo updateGame(String gameId, GameSettings settings) { + throw new UnsupportedOperationException(); + } - public GameCreateResponse createGame(GameCreateRequest request) { - throw new UnsupportedOperationException(); - } + public void deleteGame(String gameId) { + throw new UnsupportedOperationException(); + } - public GameInfo updateGame(String gameId, GameSettings settings) { - throw new UnsupportedOperationException(); - } + public Optional getGame(UUID id) { + var game = gameRepository.findGameById(id); - public void deleteGame(String gameId) { - throw new UnsupportedOperationException(); - } + if(game == null) { + return Optional.empty(); + } - public GameInfo getGame(String id) { - throw new UnsupportedOperationException(); - } + return Optional.of(game) + .map(game -> new GameInfo( + game.settings(), + game.id(), + )) + } - public void startGame(String gameId) { - // check before if game is in warmup state - initiateNextRound(gameId); - } + public void startGame(String gameId) { + // check before if game is in warmup state + initiateNextRound(gameId); + } - public String getTemplate(String gameId, String lang) { - throw new UnsupportedOperationException(); - } + public String getTemplate(String gameId, String lang) { + throw new UnsupportedOperationException(); + } - public void initiateNextRound(String gameId) { - throw new UnsupportedOperationException(); - } + public void initiateNextRound(String gameId) { + throw new UnsupportedOperationException(); + } - public GameCreateResponse restartGame(String gameId) { - throw new UnsupportedOperationException(); - } + public GameCreateResponse restartGame(String gameId) { + throw new UnsupportedOperationException(); + } } From 0c68325a0d81063fc56f0d834bb4a9b2e548eaa8 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 13:21:15 +0100 Subject: [PATCH 19/95] game modes wip --- .../codosseum/controller/GameController.java | 9 +++- .../developerden/codosseum/dto/GameInfo.java | 2 + .../codosseum/dto/PlayerRoundResult.java | 35 ++++++------- .../developerden/codosseum/dto/Players.java | 9 ++-- .../codosseum/mode/FastestGameMode.java | 31 ++++++++++++ .../developerden/codosseum/mode/GameMode.java | 7 +++ .../codosseum/mode/GameModeFactory.java | 1 + .../codosseum/mode/GameModeFactoryImpl.java | 19 +++++++ .../codosseum/mode/GameModeSerializer.java | 31 ++++++++++++ .../developerden/codosseum/model/Game.java | 5 +- .../codosseum/service/GameService.java | 49 ++++++++++++------- .../codosseum/utils/CollectionUtils.java | 16 ++++++ 12 files changed, 174 insertions(+), 40 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/mode/FastestGameMode.java create mode 100644 src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java create mode 100644 src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java create mode 100644 src/main/java/org/developerden/codosseum/utils/CollectionUtils.java diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index de703fd..d2dc441 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -44,6 +44,7 @@ import java.net.URI; import java.security.Principal; +import java.util.Optional; import java.util.UUID; @Validated @@ -80,8 +81,12 @@ public HttpResponse createGame(@Valid @Body GameCreateReques @Get("/{id}") @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getGame(@PathVariable("id") @Valid UUID gameId) { - return HttpResponse.ok(gameService.getGame(gameId)); + public HttpResponse getGame(@PathVariable("id") UUID gameId) { + Optional game = gameService.getGame(gameId); + if (game.isEmpty()) { + return HttpResponse.notFound(); + } + return HttpResponse.ok(game.get()); } @Patch("/{id}") diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index ed27487..4c16286 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -17,6 +17,7 @@ package org.developerden.codosseum.dto; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; @@ -27,6 +28,7 @@ import java.util.UUID; @RecordBuilder +@Serdeable public record GameInfo( @Nonnull GameSettings settings, diff --git a/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java b/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java index 0e058db..f5a7636 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java @@ -23,30 +23,31 @@ @RecordBuilder public record PlayerRoundResult( - @Nonnull - String name, + @Nonnull Player player, + @Nonnull + String name, - @Nonnull - String language, + @Nonnull + String language, - @Nullable - String code, + @Nullable + String code, - int byteCount, + int byteCount, - int timeLeft, + int timeLeft, - @Nullable - Score score + @Nullable + Score score ) { - @RecordBuilder - public record Score( - int testsRun, - int testsPassed, - double averageRuntime - ) { + @RecordBuilder + public record Score( + int testsRun, + int testsPassed, + double averageRuntime + ) { - } + } } diff --git a/src/main/java/org/developerden/codosseum/dto/Players.java b/src/main/java/org/developerden/codosseum/dto/Players.java index 55f21bf..54d97ef 100644 --- a/src/main/java/org/developerden/codosseum/dto/Players.java +++ b/src/main/java/org/developerden/codosseum/dto/Players.java @@ -17,16 +17,19 @@ package org.developerden.codosseum.dto; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; -import java.util.List; + +import java.util.Set; @RecordBuilder +@Serdeable public record Players( @Nonnull - List players, + Set players, @Nonnull - String admin + Player admin ) { } diff --git a/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java b/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java new file mode 100644 index 0000000..bc5b6bd --- /dev/null +++ b/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java @@ -0,0 +1,31 @@ +package org.developerden.codosseum.mode; + +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.dto.PlayerRoundResult; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +public class FastestGameMode implements GameMode { + @Override + public GameModeType getType() { + return GameModeType.FASTEST; + } + + @Override + public double computeScore(Player player, List results) { + var orderedScores = results.stream() + .sorted(Comparator.comparing(PlayerRoundResult::timeLeft).reversed()) + .toList(); + + // find the result of the player + Optional any = results.stream() + .filter(res -> res.player().equals(player)) + .findAny(); + + // warn? + return any.map(playerRoundResult -> orderedScores.indexOf(playerRoundResult) + 1.0) + .orElse(0.0); + } +} diff --git a/src/main/java/org/developerden/codosseum/mode/GameMode.java b/src/main/java/org/developerden/codosseum/mode/GameMode.java index 5da586c..bc10fbd 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameMode.java +++ b/src/main/java/org/developerden/codosseum/mode/GameMode.java @@ -27,6 +27,13 @@ public interface GameMode { GameModeType getType(); + /** + * Compute the score for the given player and results. + * The results are all the player's results for the current round. + * @param player + * @param results + * @return a score between 0 and 100 (inclusive) + */ double computeScore(Player player, List results); } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java index b11f012..74b9f7d 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java @@ -21,5 +21,6 @@ public interface GameModeFactory { GameMode initForGame(String gameId); + GameMode fromType(GameModeType type); } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java b/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java new file mode 100644 index 0000000..b0ec935 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java @@ -0,0 +1,19 @@ +package org.developerden.codosseum.mode; + +import jakarta.inject.Singleton; + +@Singleton +public class GameModeFactoryImpl implements GameModeFactory { + @Override + public GameMode initForGame(String gameId) { + return null; + } + + @Override + public GameMode fromType(GameModeType type) { + return switch (type) { + case FASTEST -> new FastestGameMode(); + default -> throw new UnsupportedOperationException("Unsupported game mode type: " + type); + }; + } +} diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java b/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java new file mode 100644 index 0000000..ca37cff --- /dev/null +++ b/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java @@ -0,0 +1,31 @@ +package org.developerden.codosseum.mode; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.type.Argument; +import io.micronaut.serde.Decoder; +import io.micronaut.serde.Encoder; +import io.micronaut.serde.Serde; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import java.io.IOException; + +@Singleton +public class GameModeSerializer implements Serde { + private final @Inject GameModeFactory gameModeFactory; + + public GameModeSerializer(GameModeFactory gameModeFactory) { + this.gameModeFactory = gameModeFactory; + } + + @Override + public @Nullable GameMode deserialize(@NonNull Decoder decoder, @NonNull DecoderContext context, @NonNull Argument type) throws IOException { + return gameModeFactory.fromType(GameModeType.valueOf(decoder.decodeString().toUpperCase())); + } + + @Override + public void serialize(@NonNull Encoder encoder, @NonNull EncoderContext context, @NonNull Argument type, @NonNull GameMode value) throws IOException { + encoder.encodeString(value.getType().name().toLowerCase()); + } +} diff --git a/src/main/java/org/developerden/codosseum/model/Game.java b/src/main/java/org/developerden/codosseum/model/Game.java index b464a5b..80ec3e2 100644 --- a/src/main/java/org/developerden/codosseum/model/Game.java +++ b/src/main/java/org/developerden/codosseum/model/Game.java @@ -19,6 +19,8 @@ import io.soabase.recordbuilder.core.RecordBuilder; import org.developerden.codosseum.dto.GameSettings; +import org.developerden.codosseum.dto.Players; +import org.developerden.codosseum.mode.GameMode; import java.util.UUID; @@ -28,6 +30,7 @@ public record Game( UUID id, String adminKey, GameSettings settings, - GamePlayers players + Players players, + GameMode mode ) { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index c1d02be..f8d9df1 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -19,24 +19,24 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.developerden.codosseum.dto.GameCreateRequest; -import org.developerden.codosseum.dto.GameCreateResponse; -import org.developerden.codosseum.dto.GameInfo; -import org.developerden.codosseum.dto.GameSettings; +import org.developerden.codosseum.dto.*; +import org.developerden.codosseum.mode.GameModeFactory; +import org.developerden.codosseum.mode.GameModeType; import org.developerden.codosseum.model.Game; -import org.developerden.codosseum.model.GamePlayers; +import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.repository.GameRepository; +import org.developerden.codosseum.utils.CollectionUtils; -import java.util.HashSet; -import java.util.Optional; -import java.util.UUID; +import java.util.*; @Singleton public class GameService { private final GameRepository gameRepository; + private final GameModeFactory gameModeFactory; - public @Inject GameService(GameRepository gameRepository) { + public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory) { this.gameRepository = gameRepository; + this.gameModeFactory = gameModeFactory; } private String generateAdminKey() { @@ -45,10 +45,18 @@ private String generateAdminKey() { public GameCreateResponse createGame(GameCreateRequest request) { - var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new GamePlayers( - request.player(), - new HashSet<>() - )); + var gameModeTypes = EnumSet.allOf(GameModeType.class); + if (Optional.ofNullable(request.settings().allowedGameModes()).map(modes -> !modes.isEmpty()) + .orElse(false)) { + gameModeTypes.retainAll(request.settings().allowedGameModes()); + } + var gameModeType = CollectionUtils.pickRandom(gameModeTypes); + var gameMode = gameModeFactory.fromType(gameModeType); + + var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new Players( + new HashSet<>(), + request.player() + ), gameMode); gameRepository.insertGame(game); @@ -64,17 +72,24 @@ public void deleteGame(String gameId) { } public Optional getGame(UUID id) { - var game = gameRepository.findGameById(id); + var gameOpt = gameRepository.findGameById(id); - if(game == null) { + if (gameOpt == null) { return Optional.empty(); } - return Optional.of(game) + return Optional.of(gameOpt) .map(game -> new GameInfo( game.settings(), game.id(), - )) + game.mode(), + game.players(), + GameState.WAITING_FOR_PLAYERS, + 0, + 0, + 1, + new ArrayList<>() + )); } public void startGame(String gameId) { diff --git a/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java b/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java new file mode 100644 index 0000000..0151c50 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java @@ -0,0 +1,16 @@ +package org.developerden.codosseum.utils; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.Collection; + +public class CollectionUtils { + public static E pickRandom(@NotEmpty Collection collection) { + if (collection.isEmpty()) { + throw new IllegalArgumentException("Collection is empty"); + } + + return collection.stream().skip((int) (Math.random() * collection.size())).findFirst() + .orElseThrow(); + } +} From 330c17429aca68fdcb7f951268f1760579f00289 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 13:26:56 +0100 Subject: [PATCH 20/95] update dependencies --- build.gradle.kts | 8 +++--- .../validation/SpdxIdConstraintValidator.java | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f00d8c8..6ec1f7e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later plugins { id("com.github.johnrengelman.shadow") version "8.1.1" - id("io.micronaut.application") version "4.0.1" - id("io.micronaut.aot") version "4.0.1" - id("io.micronaut.openapi") version "4.0.1" + id("io.micronaut.application") version "4.5.5" + id("io.micronaut.aot") version "4.5.5" + id("io.micronaut.openapi") version "4.5.5" checkstyle } @@ -38,7 +38,7 @@ dependencies { implementation("io.micronaut:micronaut-http-client") implementation("com.github.kkuegler:human-readable-ids-java:0.1.1") implementation("com.networknt:json-schema-validator:1.0.86") - implementation("org.spdx:java-spdx-library:1.1.7") { + implementation("org.spdx:java-spdx-library:(,2.0]") { exclude("org.apache.logging.log4j") } runtimeOnly("ch.qos.logback:logback-classic") diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java index 3ddb679..04d1b9f 100644 --- a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java +++ b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java @@ -25,21 +25,22 @@ import org.spdx.library.model.license.InvalidLicenseStringException; import org.spdx.library.model.license.LicenseInfoFactory; + public class SpdxIdConstraintValidator implements ConstraintValidator { - @Override - public boolean isValid(@Nullable String value, - @NonNull AnnotationValue annotationMetadata, - @NonNull ConstraintValidatorContext context) { - if (value == null) { - return true; - } + @Override + public boolean isValid(@Nullable String value, + @NonNull AnnotationValue annotationMetadata, + @NonNull ConstraintValidatorContext context) { + if (value == null) { + return true; + } - try { - LicenseInfoFactory.parseSPDXLicenseString(value); - } catch (InvalidLicenseStringException e) { - return false; + try { + LicenseInfoFactory.parseSPDXLicenseString(value); + } catch (InvalidLicenseStringException e) { + return false; + } + return true; } - return true; - } } From bf3daa5649fd825c41cc328b5db199cf02d75948 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 15:43:55 +0100 Subject: [PATCH 21/95] update to java 21 --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6ec1f7e..4602317 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,8 +51,8 @@ application { } java { - sourceCompatibility = JavaVersion.toVersion("17") - targetCompatibility = JavaVersion.toVersion("17") + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } checkstyle { From 102a9f065b3adaf34f141d343706a98d9a8daecd Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 16:30:58 +0100 Subject: [PATCH 22/95] start implementation of game logic runner using actor model --- .../developerden/codosseum/dto/GameInfo.java | 4 +- .../codosseum/event/SyncEvent.java | 4 +- .../exception/IllegalGameStateException.java | 7 +- .../codosseum/model/GamePhase.java | 36 ++++++++++ .../codosseum/model/GameState.java | 35 ++-------- .../codosseum/service/GameService.java | 26 +++++-- .../codosseum/service/game/EventSink.java | 5 ++ .../codosseum/service/game/GameAggregate.java | 70 +++++++++++++++++++ .../service/game/GameAggregateFactory.java | 9 +++ .../game/GameAggregateFactoryImpl.java | 15 ++++ .../codosseum/service/game/GameCommand.java | 19 +++++ .../codosseum/service/game/GameEvent.java | 14 ++++ .../codosseum/service/game/GameRunner.java | 56 +++++++++++++++ .../service/game/GameRunnerFactory.java | 26 +++++++ .../service/game/GameRunnerRegistry.java | 32 +++++++++ .../game/state/InMemorySnapshotStore.java | 37 ++++++++++ .../service/game/state/SnapshotStore.java | 16 +++++ 17 files changed, 370 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/model/GamePhase.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/EventSink.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameAggregate.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameCommand.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameEvent.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameRunner.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index 4c16286..1732a62 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -22,7 +22,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.developerden.codosseum.mode.GameMode; -import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.model.GamePhase; import java.util.List; import java.util.UUID; @@ -43,7 +43,7 @@ public record GameInfo( Players players, @Nonnull - GameState state, + GamePhase state, int timeLeft, diff --git a/src/main/java/org/developerden/codosseum/event/SyncEvent.java b/src/main/java/org/developerden/codosseum/event/SyncEvent.java index caa071f..e055655 100644 --- a/src/main/java/org/developerden/codosseum/event/SyncEvent.java +++ b/src/main/java/org/developerden/codosseum/event/SyncEvent.java @@ -19,12 +19,12 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; -import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.model.GamePhase; @RecordBuilder public record SyncEvent( @Nonnull - GameState state, + GamePhase state, int timeLeft ) implements GameEvent { diff --git a/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java index a0b6150..a8b16b6 100644 --- a/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java +++ b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java @@ -19,13 +19,14 @@ import java.util.Set; import java.util.stream.Collectors; -import org.developerden.codosseum.model.GameState; + +import org.developerden.codosseum.model.GamePhase; public class IllegalGameStateException extends RuntimeException { - public IllegalGameStateException(String gameId, GameState state, Set expected) { + public IllegalGameStateException(String gameId, GamePhase state, Set expected) { super("Game '" + gameId + "' is in state '" + state + "' but this action can only be " + "taken while in one of the following states: " - + expected.stream().map(GameState::name).collect(Collectors.joining(", "))); + + expected.stream().map(GamePhase::name).collect(Collectors.joining(", "))); } } diff --git a/src/main/java/org/developerden/codosseum/model/GamePhase.java b/src/main/java/org/developerden/codosseum/model/GamePhase.java new file mode 100644 index 0000000..5596360 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/GamePhase.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2023 JohnnyJayJay + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + * + */ + +package org.developerden.codosseum.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum GamePhase { + @Schema(description = "The game phase is not defined, usually indicating an error state. Clients should generally not have to handle this state.") + UNDEFINED, + @Schema(description = "The game has not yet started and is waiting for more players before it can begin") + WAITING_FOR_PLAYERS, + @Schema(description = "The game is in a warmup phase and is ready to begin") + WARMUP, + @Schema(description = "The game is currently in progress") + IN_PROGRESS, + @Schema(description = "The current round of the game is over") + ROUND_OVER, + @Schema(description = "The game has ended") + GAME_OVER + +} diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index f39d6f0..f44cb61 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -1,34 +1,11 @@ -/* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License along with this program. - * If not, see . - * - */ - package org.developerden.codosseum.model; -import io.swagger.v3.oas.annotations.media.Schema; +import io.soabase.recordbuilder.core.RecordBuilder; -public enum GameState { - @Schema(description = "The game has not yet started and is waiting for more players before it can begin") - WAITING_FOR_PLAYERS, - @Schema(description = "The game is in a warmup phase and is ready to begin") - WARMUP, - @Schema(description = "The game is currently in progress") - IN_PROGRESS, - @Schema(description = "The current round of the game is over") - ROUND_OVER, - @Schema(description = "The game has ended") - GAME_OVER +import java.util.UUID; +@RecordBuilder +public record GameState(UUID gameId, + GamePhase phase + ) { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index f8d9df1..4005a57 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -23,8 +23,13 @@ import org.developerden.codosseum.mode.GameModeFactory; import org.developerden.codosseum.mode.GameModeType; import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.repository.GameRepository; +import org.developerden.codosseum.service.game.GameCommand; +import org.developerden.codosseum.service.game.GameRunner; +import org.developerden.codosseum.service.game.GameRunnerRegistry; +import org.developerden.codosseum.service.game.state.SnapshotStore; import org.developerden.codosseum.utils.CollectionUtils; import java.util.*; @@ -33,10 +38,14 @@ public class GameService { private final GameRepository gameRepository; private final GameModeFactory gameModeFactory; + private final GameRunnerRegistry gameRunnerRegistry; + private final SnapshotStore snapshotStore; - public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory) { + public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; + this.gameRunnerRegistry = gameRunnerRegistry; + this.snapshotStore = snapshotStore; } private String generateAdminKey() { @@ -78,13 +87,21 @@ public Optional getGame(UUID id) { return Optional.empty(); } + var stateOpt = gameRunnerRegistry + .find(id) + .map(GameRunner::getCurrentState) + .or(() -> snapshotStore.load(id)); + + var phase = stateOpt.map(GameState::phase) + .orElse(GamePhase.UNDEFINED); + return Optional.of(gameOpt) .map(game -> new GameInfo( game.settings(), game.id(), game.mode(), game.players(), - GameState.WAITING_FOR_PLAYERS, + phase, 0, 0, 1, @@ -92,9 +109,8 @@ public Optional getGame(UUID id) { )); } - public void startGame(String gameId) { - // check before if game is in warmup state - initiateNextRound(gameId); + public void startGame(UUID gameId) { + gameRunnerRegistry.getOrCreate(gameId).tell(new GameCommand.StartGame(gameId)); } public String getTemplate(String gameId, String lang) { diff --git a/src/main/java/org/developerden/codosseum/service/game/EventSink.java b/src/main/java/org/developerden/codosseum/service/game/EventSink.java new file mode 100644 index 0000000..6307b2c --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/EventSink.java @@ -0,0 +1,5 @@ +package org.developerden.codosseum.service.game; + +public interface EventSink { + void publish(GameEvent event); +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java new file mode 100644 index 0000000..600a85b --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -0,0 +1,70 @@ +package org.developerden.codosseum.service.game; + +import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.model.GameStateBuilder; + +import java.util.List; +import java.util.UUID; + +public class GameAggregate { + private final UUID gameId; + + private final GameState gameState; + + public GameAggregate(UUID gameId, GameState gameState) { + this.gameId = gameId; + this.gameState = gameState; + } + + public GameState getGameState() { + return gameState; + } + + public Result handle(GameCommand cmd) { + var events = decide(cmd); + var newState = applyAll(gameState, events); + + var next = new GameAggregate(gameId, newState); + + return new Result(events, next); + } + + private List decide(GameCommand cmd) { + if (!cmd.gameId().equals(gameId)) { + throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); + } + + return switch (cmd) { + case GameCommand.StartGame(var id) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Cannot start game that is not in WAITING_FOR_PLAYERS phase"); + } else { + // TODO check if enough players + yield List.of(new GameEvent.GameStarted(id)); + } + } + + }; + } + + private GameState applyAll(GameState state, List events) { + var newState = state; + for (var event : events) { + newState = apply(newState, event); + } + return newState; + } + + private GameState apply(GameState state, GameEvent event) { + return switch (event) { + case GameEvent.GameStarted(var id) -> GameStateBuilder.builder(state).phase(GamePhase.WARMUP).build(); + }; + + } + + + public record Result(List events, GameAggregate next) { + } + +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java new file mode 100644 index 0000000..939eb0f --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java @@ -0,0 +1,9 @@ +package org.developerden.codosseum.service.game; + +import org.developerden.codosseum.model.GameState; + +import java.util.UUID; + +public interface GameAggregateFactory { + GameAggregate create(UUID gameId, GameState snapshot); +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java new file mode 100644 index 0000000..97a9ed9 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java @@ -0,0 +1,15 @@ +package org.developerden.codosseum.service.game; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.GameState; + +import java.util.UUID; + +@Singleton +public class GameAggregateFactoryImpl implements GameAggregateFactory { + @Override + public GameAggregate create(UUID gameId, GameState snapshot) { + return new GameAggregate(gameId, snapshot); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java new file mode 100644 index 0000000..a6daa3c --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -0,0 +1,19 @@ +package org.developerden.codosseum.service.game; + +import java.util.UUID; + +/** + * A command that can be executed on a {@link org.developerden.codosseum.model.Game} + */ +public sealed interface GameCommand { + UUID gameId(); + + /** + * Command to start a game + * @param gameId the id of the game to start + */ + record StartGame(UUID gameId) implements GameCommand { + } + + +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameEvent.java b/src/main/java/org/developerden/codosseum/service/game/GameEvent.java new file mode 100644 index 0000000..3876f34 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameEvent.java @@ -0,0 +1,14 @@ +package org.developerden.codosseum.service.game; + +import org.developerden.codosseum.model.Game; + +import java.util.UUID; + +/// An event that happened to a [Game] +/// See also [GameCommand] +public sealed interface GameEvent { + UUID gameId(); + + record GameStarted(UUID gameId) implements GameEvent { + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java new file mode 100644 index 0000000..5ea848d --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java @@ -0,0 +1,56 @@ +package org.developerden.codosseum.service.game; + +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.service.game.state.SnapshotStore; + +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public class GameRunner { + private final UUID gameId; + + private final ExecutorService loop; + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final EventSink eventSink; + private final SnapshotStore snapshotStore; + private volatile GameAggregate gameAggregate; + + public GameRunner(UUID gameId, EventSink eventSink, SnapshotStore snapshotStore, GameAggregateFactory aggregateFactory) { + this.gameId = gameId; + this.eventSink = eventSink; + this.snapshotStore = snapshotStore; + + this.loop = Executors.newSingleThreadExecutor(r -> new Thread(r, "game-" + gameId)); + + GameState snapshot = this.snapshotStore.load(gameId) + .orElseGet(() -> this.snapshotStore.createInitial(gameId)); + this.gameAggregate = aggregateFactory.create(gameId, snapshot); + } + + public void tell(GameCommand cmd) { + loop.execute(() -> handle(cmd)); + } + + public GameState getCurrentState() { + return gameAggregate.getGameState(); + } + + private void handle(GameCommand cmd) { + var result = gameAggregate.handle(cmd); + this.gameAggregate = result.next(); + for (var event : result.events()) { + eventSink.publish(event); + } + snapshotStore.save(gameId, gameAggregate.getGameState()); + } + + public void shutdown() { + scheduler.shutdownNow(); + loop.shutdownNow(); + } + + +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java new file mode 100644 index 0000000..d4e9a76 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java @@ -0,0 +1,26 @@ +package org.developerden.codosseum.service.game; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.developerden.codosseum.service.game.state.SnapshotStore; + +import java.util.UUID; + +@Singleton +public class GameRunnerFactory { + + private final EventSink eventSink; + private final SnapshotStore snapshotStore; + private final GameAggregateFactory aggregateFactory; + + @Inject + public GameRunnerFactory(EventSink eventSink, SnapshotStore snapshotStore, GameAggregateFactory aggregateFactory) { + this.eventSink = eventSink; + this.snapshotStore = snapshotStore; + this.aggregateFactory = aggregateFactory; + } + + public GameRunner create(UUID gameId) { + return new GameRunner(gameId, eventSink, snapshotStore, aggregateFactory); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java new file mode 100644 index 0000000..6b08e06 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java @@ -0,0 +1,32 @@ +package org.developerden.codosseum.service.game; + +import jakarta.inject.Singleton; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Singleton +public class GameRunnerRegistry { + private final GameRunnerFactory factory; + private final Map runners = new ConcurrentHashMap<>(); + + public GameRunnerRegistry(GameRunnerFactory factory) { + this.factory = factory; + } + + public GameRunner getOrCreate(UUID gameId) { + return runners.computeIfAbsent(gameId, factory::create); + } + + + public Optional find(UUID gameId) { + return Optional.ofNullable(runners.get(gameId)); + } + + public void stop(UUID gameId) { + var r = runners.remove(gameId); + if (r != null) r.shutdown(); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java new file mode 100644 index 0000000..a865937 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java @@ -0,0 +1,37 @@ +package org.developerden.codosseum.service.game.state; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.GameState; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; + +@Singleton +public final class InMemorySnapshotStore implements SnapshotStore { + private final ConcurrentMap store = new ConcurrentHashMap<>(); + private final Function initialProvider; + + public InMemorySnapshotStore(Function initialProvider) { + this.initialProvider = initialProvider; + } + + @Override + public Optional load(UUID gameId) { + return Optional.ofNullable(store.get(gameId)); + } + + @Override + public void save(UUID gameId, GameState state) { + store.put(gameId, state); + } + + @Override + public GameState createInitial(UUID gameId) { + var state = initialProvider.apply(gameId); + store.put(gameId, state); + return state; + } +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java new file mode 100644 index 0000000..d23c964 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java @@ -0,0 +1,16 @@ +package org.developerden.codosseum.service.game.state; + +import org.developerden.codosseum.model.GameState; + +import java.util.Optional; +import java.util.UUID; + +/** + * Persistence boundary for GameState snapshots. + * Pure IO: load/save by gameId. No domain logic or timers here. + */ +public interface SnapshotStore { + Optional load(UUID gameId); + void save(UUID gameId, GameState state); + GameState createInitial(UUID gameId); +} \ No newline at end of file From 5d5ee29e89cb99c2940573f32458f33788926360 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 16:35:03 +0100 Subject: [PATCH 23/95] improve startGame api documentation and functionality --- .../codosseum/controller/GameController.java | 22 +++++++++++++++++-- .../codosseum/service/GameService.java | 7 +++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index d2dc441..e80c986 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -17,7 +17,9 @@ package org.developerden.codosseum.controller; +import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.*; import io.micronaut.http.sse.Event; @@ -26,6 +28,7 @@ import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -108,8 +111,23 @@ public HttpResponse deleteGame(Principal principal, @PathVariable("id") St @Post("/{id}/start") @GameAuthorized(GameRole.ADMIN) - public HttpResponse startGame(Principal principal, @PathVariable("id") String gameId) { - gameService.startGame(gameId); + @Operation(operationId = "startGame", summary = "Start a game", description = "Forcefully start a game, regardless of player-count and warmup time") + @ApiResponse( + responseCode = "204", + description = "Successfully started the game. Further info will be received via server-sent events." + ) + @ApiResponse( + responseCode = "409", + description = "Game is already running or finished" + ) + + public HttpResponse startGame(Principal principal, @PathVariable("id") UUID gameId) { + try { + gameService.startGame(gameId); + } catch (IllegalStateException e) { + return HttpResponse.status(HttpStatus.CONFLICT); + } + return HttpResponse.noContent(); } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 4005a57..693fbce 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -110,7 +110,12 @@ public Optional getGame(UUID id) { } public void startGame(UUID gameId) { - gameRunnerRegistry.getOrCreate(gameId).tell(new GameCommand.StartGame(gameId)); + GameRunner runner = gameRunnerRegistry.getOrCreate(gameId); + if (runner.getCurrentState().phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Game is already running or finished"); + } + + runner.tell(new GameCommand.StartGame(gameId)); } public String getTemplate(String gameId, String lang) { From ec1e31c7b54c95cee9df314cfda0f98d03b087c6 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 17:21:45 +0100 Subject: [PATCH 24/95] flesh out event system, able to send basic game created event --- openapi.yaml | 2 +- .../codosseum/controller/GameController.java | 11 ++-- .../developerden/codosseum/dto/Players.java | 15 +++-- .../codosseum/event/PlayerJoinEvent.java | 8 +++ .../codosseum/model/GameState.java | 4 +- .../codosseum/service/GameService.java | 12 +++- .../codosseum/service/game/EventSink.java | 5 -- .../codosseum/service/game/GameAggregate.java | 39 ++++++++++--- .../codosseum/service/game/GameCommand.java | 9 +++ .../codosseum/service/game/GameEvent.java | 14 ----- .../codosseum/service/game/GameRunner.java | 2 +- .../service/game/GameRunnerFactory.java | 1 + .../service/game/event/EventMapper.java | 21 +++++++ .../service/game/event/EventSink.java | 5 ++ .../service/game/event/InternalGameEvent.java | 25 ++++++++ .../service/game/event/SseEventSink.java | 57 +++++++++++++++++++ .../DefaultInitialGameStateProvider.java | 22 +++++++ .../game/state/InMemorySnapshotStore.java | 6 +- .../game/state/InitialGameStateProvider.java | 13 +++++ 19 files changed, 227 insertions(+), 44 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java delete mode 100644 src/main/java/org/developerden/codosseum/service/game/EventSink.java delete mode 100644 src/main/java/org/developerden/codosseum/service/game/GameEvent.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/event/EventSink.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java diff --git a/openapi.yaml b/openapi.yaml index 8a56078..82f7ec5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -699,7 +699,7 @@ components: description: Current game state timeLeft: type: integer - description: Time left in seconds until "next thing" (warmup countdown, round timer, next rount countdown) + description: Time left in seconds until "next thing" (warmup countdown, round timer, next round countdown) EliminatedEvent: type: object diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index e80c986..277b801 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -17,7 +17,6 @@ package org.developerden.codosseum.controller; -import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; import io.micronaut.http.MediaType; @@ -43,6 +42,7 @@ import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.service.GameService; +import org.developerden.codosseum.service.game.event.SseEventSink; import org.reactivestreams.Publisher; import java.net.URI; @@ -57,8 +57,11 @@ public class GameController { private final GameService gameService; - public GameController(GameService gameService) { + private final SseEventSink eventSink; + + public GameController(GameService gameService, SseEventSink eventSink) { this.gameService = gameService; + this.eventSink = eventSink; } @Post @@ -160,9 +163,9 @@ public HttpResponse restartGame( @Secured(SecurityRule.IS_ANONYMOUS) public Publisher> subscribeToGameEvents( @Nullable Principal principal, - @PathVariable("id") String gameId + @PathVariable("id") UUID gameId ) { - throw new UnsupportedOperationException(); + return eventSink.subscribeToPublicSSE(gameId); } } diff --git a/src/main/java/org/developerden/codosseum/dto/Players.java b/src/main/java/org/developerden/codosseum/dto/Players.java index 54d97ef..25d5761 100644 --- a/src/main/java/org/developerden/codosseum/dto/Players.java +++ b/src/main/java/org/developerden/codosseum/dto/Players.java @@ -23,13 +23,18 @@ import java.util.Set; -@RecordBuilder +@RecordBuilder() +@RecordBuilder.Options( + useImmutableCollections = true, + addSingleItemCollectionBuilders = true +) @Serdeable public record Players( - @Nonnull - Set players, + @Nonnull + Set players, - @Nonnull - Player admin + @Nonnull + Player admin ) { + } diff --git a/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java b/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java new file mode 100644 index 0000000..7811fda --- /dev/null +++ b/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java @@ -0,0 +1,8 @@ +package org.developerden.codosseum.event; + +import org.developerden.codosseum.dto.Player; + +import java.util.UUID; + +public record PlayerJoinEvent(UUID gameId, Player player) implements GameEvent { +} diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index f44cb61..3061727 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -1,11 +1,13 @@ package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; +import org.developerden.codosseum.dto.Players; import java.util.UUID; @RecordBuilder public record GameState(UUID gameId, - GamePhase phase + GamePhase phase, + Players players ) { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 693fbce..f028d36 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -29,6 +29,7 @@ import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunner; import org.developerden.codosseum.service.game.GameRunnerRegistry; +import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; import org.developerden.codosseum.utils.CollectionUtils; @@ -41,11 +42,14 @@ public class GameService { private final GameRunnerRegistry gameRunnerRegistry; private final SnapshotStore snapshotStore; - public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore) { + private final EventSink eventSink; + + public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, EventSink eventSink) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; this.snapshotStore = snapshotStore; + this.eventSink = eventSink; } private String generateAdminKey() { @@ -53,7 +57,6 @@ private String generateAdminKey() { } public GameCreateResponse createGame(GameCreateRequest request) { - var gameModeTypes = EnumSet.allOf(GameModeType.class); if (Optional.ofNullable(request.settings().allowedGameModes()).map(modes -> !modes.isEmpty()) .orElse(false)) { @@ -69,6 +72,9 @@ public GameCreateResponse createGame(GameCreateRequest request) { gameRepository.insertGame(game); + gameRunnerRegistry.getOrCreate(game.id()) + .tell(new GameCommand.CreateGame(game.id())); + return new GameCreateResponse(game.adminKey(), game.id()); } @@ -93,7 +99,7 @@ public Optional getGame(UUID id) { .or(() -> snapshotStore.load(id)); var phase = stateOpt.map(GameState::phase) - .orElse(GamePhase.UNDEFINED); + .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); return Optional.of(gameOpt) .map(game -> new GameInfo( diff --git a/src/main/java/org/developerden/codosseum/service/game/EventSink.java b/src/main/java/org/developerden/codosseum/service/game/EventSink.java deleted file mode 100644 index 6307b2c..0000000 --- a/src/main/java/org/developerden/codosseum/service/game/EventSink.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.developerden.codosseum.service.game; - -public interface EventSink { - void publish(GameEvent event); -} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 600a85b..c228a93 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -1,8 +1,10 @@ package org.developerden.codosseum.service.game; +import org.developerden.codosseum.dto.PlayersBuilder; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; +import org.developerden.codosseum.service.game.event.InternalGameEvent; import java.util.List; import java.util.UUID; @@ -30,25 +32,39 @@ public Result handle(GameCommand cmd) { return new Result(events, next); } - private List decide(GameCommand cmd) { + private List decide(GameCommand cmd) { if (!cmd.gameId().equals(gameId)) { throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); } return switch (cmd) { + case GameCommand.CreateGame(var id) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Cannot create game that is not in WAITING_FOR_PLAYERS phase"); + } else { + yield List.of(new InternalGameEvent.GameCreated(gameId)); + } + } case GameCommand.StartGame(var id) -> { if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { throw new IllegalStateException("Cannot start game that is not in WAITING_FOR_PLAYERS phase"); } else { - // TODO check if enough players - yield List.of(new GameEvent.GameStarted(id)); + // TODO check if enough players and actually do something + yield List.of(); + } + } + case GameCommand.AddPlayer(var id, var player) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Cannot join game that is not in WAITING_FOR_PLAYERS phase"); + } else { + yield List.of(new InternalGameEvent.PlayerJoined(gameId, player)); } } }; } - private GameState applyAll(GameState state, List events) { + private GameState applyAll(GameState state, List events) { var newState = state; for (var event : events) { newState = apply(newState, event); @@ -56,15 +72,24 @@ private GameState applyAll(GameState state, List events) { return newState; } - private GameState apply(GameState state, GameEvent event) { + private GameState apply(GameState state, InternalGameEvent event) { + if (!event.gameId().equals(gameId)) { + throw new IllegalArgumentException("Event gameId does not match aggregate gameId"); + } return switch (event) { - case GameEvent.GameStarted(var id) -> GameStateBuilder.builder(state).phase(GamePhase.WARMUP).build(); + case InternalGameEvent.PlayerJoined(var gameId, var player) -> GameStateBuilder.from(state) + .withPlayers( + PlayersBuilder.builder(state.players()) + .addPlayers(player) + .build() + ); + case InternalGameEvent.GameCreated(var gameId) -> state; // no-op for now }; } - public record Result(List events, GameAggregate next) { + public record Result(List events, GameAggregate next) { } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index a6daa3c..4825cf5 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -1,5 +1,7 @@ package org.developerden.codosseum.service.game; +import org.developerden.codosseum.dto.Player; + import java.util.UUID; /** @@ -8,6 +10,10 @@ public sealed interface GameCommand { UUID gameId(); + + record CreateGame(UUID gameId) implements GameCommand { + } + /** * Command to start a game * @param gameId the id of the game to start @@ -15,5 +21,8 @@ public sealed interface GameCommand { record StartGame(UUID gameId) implements GameCommand { } + record AddPlayer(UUID gameId, Player player) implements GameCommand { + } + } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameEvent.java b/src/main/java/org/developerden/codosseum/service/game/GameEvent.java deleted file mode 100644 index 3876f34..0000000 --- a/src/main/java/org/developerden/codosseum/service/game/GameEvent.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.developerden.codosseum.service.game; - -import org.developerden.codosseum.model.Game; - -import java.util.UUID; - -/// An event that happened to a [Game] -/// See also [GameCommand] -public sealed interface GameEvent { - UUID gameId(); - - record GameStarted(UUID gameId) implements GameEvent { - } -} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java index 5ea848d..c575eaa 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java @@ -1,7 +1,7 @@ package org.developerden.codosseum.service.game; -import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; import java.util.UUID; diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java index d4e9a76..afad92a 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java @@ -2,6 +2,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; import java.util.UUID; diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java new file mode 100644 index 0000000..ef84365 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -0,0 +1,21 @@ +package org.developerden.codosseum.service.game.event; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.event.GameEvent; +import org.developerden.codosseum.event.PlayerJoinEvent; + +import java.util.Optional; + +@Singleton +public class EventMapper { + + public Optional fromInternal(InternalGameEvent internalEvent) { + return switch (internalEvent) { + case InternalGameEvent.GameCreated ignored -> Optional.empty(); + + case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( + new PlayerJoinEvent(playerJoined.gameId(), playerJoined.player()) + ); + }; + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java new file mode 100644 index 0000000..5ecf116 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java @@ -0,0 +1,5 @@ +package org.developerden.codosseum.service.game.event; + +public interface EventSink { + void publish(InternalGameEvent event); +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java new file mode 100644 index 0000000..c58124c --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -0,0 +1,25 @@ +package org.developerden.codosseum.service.game.event; + +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.event.GameEvent; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.service.game.GameCommand; + +import java.util.UUID; + +/// An event that happened to a [Game] +/// This is the internal version of [GameEvent], and is used for internal messaging. +/// +/// Some events may not have a public counterpart, see [EventMapper] +/// +/// See also [GameCommand] +public sealed interface InternalGameEvent { + UUID gameId(); + + /// Emitted when a new game is created and the lobby is opened + record GameCreated(UUID gameId) implements InternalGameEvent { + } + + record PlayerJoined(UUID gameId, Player player) implements InternalGameEvent { + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java new file mode 100644 index 0000000..e7d2e13 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -0,0 +1,57 @@ +package org.developerden.codosseum.service.game.event; + +import io.micronaut.http.sse.Event; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.developerden.codosseum.event.GameEvent; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Singleton +public class SseEventSink implements EventSink { + private final Map> sinks = new ConcurrentHashMap<>(); + private final EventMapper eventMapper; + private final Logger logger = LoggerFactory.getLogger(SseEventSink.class); + + @Inject + public SseEventSink(EventMapper eventMapper) { + this.eventMapper = eventMapper; + } + + @Override + public void publish(InternalGameEvent event) { + UUID gameId = event.gameId(); + logger.atInfo().log("Publishing event {} for game {}", event, gameId); + var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); + sink.tryEmitNext(event); + } + + public Publisher> subscribeToSse(UUID gameId) { + var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); + return sink.asFlux().map(event -> Event.of(event).name(event.getClass().getSimpleName())); + } + + public Publisher> subscribeToPublicSSE(UUID gameId) { + var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); + return sink.asFlux() + .flatMap(e -> eventMapper.fromInternal(e) + .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) + .map(Mono::just) + .orElse(Mono.empty()) + ); + } + + public void close(UUID gameId) { + var sink = sinks.remove(gameId); + if (sink != null) { + sink.tryEmitComplete(); + } + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java new file mode 100644 index 0000000..fe455bb --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java @@ -0,0 +1,22 @@ +package org.developerden.codosseum.service.game.state; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.model.GameStateBuilder; + +import java.util.UUID; + +/** + * Default initial state: lobby waiting for players. + */ +@Singleton +public class DefaultInitialGameStateProvider implements InitialGameStateProvider { + @Override + public GameState create(UUID gameId) { + return GameStateBuilder.builder() + .gameId(gameId) + .phase(GamePhase.WAITING_FOR_PLAYERS) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java index a865937..28579cf 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java @@ -12,9 +12,9 @@ @Singleton public final class InMemorySnapshotStore implements SnapshotStore { private final ConcurrentMap store = new ConcurrentHashMap<>(); - private final Function initialProvider; + private final InitialGameStateProvider initialProvider; - public InMemorySnapshotStore(Function initialProvider) { + public InMemorySnapshotStore(InitialGameStateProvider initialProvider) { this.initialProvider = initialProvider; } @@ -30,7 +30,7 @@ public void save(UUID gameId, GameState state) { @Override public GameState createInitial(UUID gameId) { - var state = initialProvider.apply(gameId); + var state = initialProvider.create(gameId); store.put(gameId, state); return state; } diff --git a/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java new file mode 100644 index 0000000..6351357 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java @@ -0,0 +1,13 @@ +package org.developerden.codosseum.service.game.state; + +import org.developerden.codosseum.model.GameState; + +import java.util.UUID; + +/** + * Produces the initial GameState for a given game id. + */ +@FunctionalInterface +public interface InitialGameStateProvider { + GameState create(UUID gameId); +} \ No newline at end of file From ef3728d4b3c29fe2e9e59045a5abc8c23a9b804e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 18:33:46 +0100 Subject: [PATCH 25/95] bind games by id in pathvariables --- .../controller/PlayerController.java | 57 +++++++++------- .../controller/binder/GameParam.java | 10 +++ .../controller/binder/GameParamBinder.java | 68 +++++++++++++++++++ .../codosseum/repository/GameRepository.java | 3 +- .../repository/InMemoryGameRepository.java | 5 +- .../codosseum/service/GameService.java | 4 +- 6 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/controller/binder/GameParam.java create mode 100644 src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index 1b3b0e3..da73a20 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -18,48 +18,57 @@ package org.developerden.codosseum.controller; import io.micronaut.http.HttpResponse; -import io.micronaut.http.annotation.Body; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Delete; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.*; import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import jakarta.validation.Valid; -import java.security.Principal; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; +import org.developerden.codosseum.controller.binder.GameParam; import org.developerden.codosseum.dto.GameJoinResponse; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.service.GameService; + +import java.security.Principal; +import java.util.UUID; @Validated @Controller("/games/{id}/players") + @Secured(SecurityRule.IS_AUTHENTICATED) public class PlayerController { - @Get - @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getPlayers(@PathVariable("id") Game game) { - throw new UnsupportedOperationException(); - } + private final GameService gameService; + + public PlayerController(GameService gameService) { + this.gameService = gameService; + } + + @Get + @Secured(SecurityRule.IS_ANONYMOUS) + public HttpResponse getPlayers(@PathVariable("id") UUID id, @GameParam Game game) { + // TODO this route seems very redundant and unhelpful + return gameService.getGame(game.id()) + .map(g -> HttpResponse.ok(g.players())) + .orElse(HttpResponse.notFound()); + } - @Post - public HttpResponse joinGame( - @PathVariable("id") Game game, - @Valid @Body Player player - ) { - throw new UnsupportedOperationException(); - } + @Post + public HttpResponse joinGame( + @PathVariable("id") UUID id, @GameParam Game game, + @Valid @Body Player player + ) { + throw new UnsupportedOperationException(); + } - @Delete("/@self") - @GameAuthorized(GameRole.PLAYER) - public HttpResponse leaveGame(Principal principal, @PathVariable("id") Game game) { - throw new UnsupportedOperationException(); - } + @Delete("/@self") + @GameAuthorized(GameRole.PLAYER) + public HttpResponse leaveGame(Principal principal, @PathVariable("id") Game game) { + throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java b/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java new file mode 100644 index 0000000..4a1f5a1 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java @@ -0,0 +1,10 @@ +package org.developerden.codosseum.controller.binder; + +import java.lang.annotation.*; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface GameParam { + String value() default "id"; // The path variable name containing the UUID +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java b/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java new file mode 100644 index 0000000..c26090d --- /dev/null +++ b/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java @@ -0,0 +1,68 @@ +package org.developerden.codosseum.controller.binder; + +import io.micronaut.core.bind.ArgumentBinder; +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.web.router.UriRouteMatch; +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.repository.GameRepository; + +import java.util.Optional; +import java.util.UUID; + +@Singleton +public class GameParamBinder implements TypedRequestArgumentBinder { + private final GameRepository gameRepository; + + public GameParamBinder(GameRepository gameRepository) { + this.gameRepository = gameRepository; + } + + @Override + public Argument argumentType() { + return Argument.of(Game.class); + } + + @Override + public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + // Check if the parameter has @GameParam annotation + if (!context.getArgument().isAnnotationPresent(GameParam.class)) { + return ArgumentBinder.BindingResult.UNSATISFIED; + } + + // Get the path variable name from the annotation + String pathVariableName = context.getArgument() + .getAnnotation(GameParam.class) + .stringValue() + .orElse("id"); + + // Extract the UUID from path variables using getParameters() + String uuidStr = (String) source.getAttribute("micronaut.http.route.match", UriRouteMatch.class) + .map(m -> m.getVariableValues().get(pathVariableName)) + .filter(x -> x instanceof String).orElse(null); + + + + if (uuidStr == null) { + return ArgumentBinder.BindingResult.UNSATISFIED; + } + + try { + UUID gameId = UUID.fromString(uuidStr); + Optional game = gameRepository.findGameById(gameId); + + if (game.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Game not found"); + } + + return () -> game; + } catch (IllegalArgumentException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid game ID format"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/developerden/codosseum/repository/GameRepository.java b/src/main/java/org/developerden/codosseum/repository/GameRepository.java index 7fc7992..f1aad1b 100644 --- a/src/main/java/org/developerden/codosseum/repository/GameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/GameRepository.java @@ -19,11 +19,12 @@ import org.developerden.codosseum.model.Game; +import java.util.Optional; import java.util.UUID; public interface GameRepository { - Game findGameById(UUID id); + Optional findGameById(UUID id); void insertGame(Game game); diff --git a/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java index ed6faef..97d6b92 100644 --- a/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java @@ -4,6 +4,7 @@ import org.developerden.codosseum.model.Game; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -12,8 +13,8 @@ public class InMemoryGameRepository implements GameRepository { private final Map games = new ConcurrentHashMap<>(); @Override - public Game findGameById(UUID id) { - return games.get(id); + public Optional findGameById(UUID id) { + return Optional.ofNullable(games.get(id)); } @Override diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index f028d36..66ca4f4 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -89,7 +89,7 @@ public void deleteGame(String gameId) { public Optional getGame(UUID id) { var gameOpt = gameRepository.findGameById(id); - if (gameOpt == null) { + if (gameOpt.isEmpty()) { return Optional.empty(); } @@ -101,7 +101,7 @@ public Optional getGame(UUID id) { var phase = stateOpt.map(GameState::phase) .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); - return Optional.of(gameOpt) + return gameOpt .map(game -> new GameInfo( game.settings(), game.id(), From 2ed164e700594e37b7af3a538dac37c863768a58 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 7 Oct 2025 18:36:58 +0100 Subject: [PATCH 26/95] fix the openapi spec --- .../developerden/codosseum/controller/PlayerController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index da73a20..10d5634 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -22,6 +22,7 @@ import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; @@ -37,7 +38,6 @@ @Validated @Controller("/games/{id}/players") - @Secured(SecurityRule.IS_AUTHENTICATED) public class PlayerController { @@ -49,7 +49,7 @@ public PlayerController(GameService gameService) { @Get @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getPlayers(@PathVariable("id") UUID id, @GameParam Game game) { + public HttpResponse getPlayers(@PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game) { // TODO this route seems very redundant and unhelpful return gameService.getGame(game.id()) .map(g -> HttpResponse.ok(g.players())) @@ -58,7 +58,7 @@ public HttpResponse getPlayers(@PathVariable("id") UUID id, @GameParam @Post public HttpResponse joinGame( - @PathVariable("id") UUID id, @GameParam Game game, + @PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game, @Valid @Body Player player ) { throw new UnsupportedOperationException(); From cd44ca7a84dda8a28c39fcb5a938e77298c09ef1 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 10:12:17 +0100 Subject: [PATCH 27/95] make internal and public player types more clearly distinct --- .../controller/PlayerController.java | 6 ++++- .../codosseum/model/GamePlayers.java | 16 ++++++++++-- .../codosseum/model/GameState.java | 9 ++++++- .../model/{ => player}/EphemeralPlayer.java | 4 +-- .../codosseum/model/player/GamePlayer.java | 5 ++++ .../codosseum/repository/AuthRepository.java | 2 +- .../codosseum/service/GameService.java | 6 +++++ .../codosseum/service/game/GameAggregate.java | 6 ++--- .../codosseum/service/game/GameCommand.java | 3 ++- .../service/game/event/EventMapper.java | 25 ++++++++++++++++++- .../service/game/event/InternalGameEvent.java | 4 ++- 11 files changed, 73 insertions(+), 13 deletions(-) rename src/main/java/org/developerden/codosseum/model/{ => player}/EphemeralPlayer.java (91%) create mode 100644 src/main/java/org/developerden/codosseum/model/player/GamePlayer.java diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index 10d5634..35eebfc 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -17,6 +17,7 @@ package org.developerden.codosseum.controller; +import io.micronaut.context.ApplicationContext; import io.micronaut.http.HttpResponse; import io.micronaut.http.annotation.*; import io.micronaut.security.annotation.Secured; @@ -61,7 +62,10 @@ public HttpResponse joinGame( @PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game, @Valid @Body Player player ) { - throw new UnsupportedOperationException(); + + return gameService.addPlayer(game.id(), player) + .map(response -> HttpResponse.ok(new GameJoinResponse(response.game(), response.player()))) + .orElse(HttpResponse.notFound()); } diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java index d3bc0f3..f3bcea9 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePlayers.java +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -1,8 +1,20 @@ package org.developerden.codosseum.model; -import org.developerden.codosseum.dto.Player; +import io.soabase.recordbuilder.core.RecordBuilder; +import org.developerden.codosseum.model.player.GamePlayer; import java.util.Set; -public record GamePlayers(Player admin, Set others) { +/** + * Internal model representing the players in a game. + * + * @param admin the admin player + * @param others the other players + */ +@RecordBuilder() +@RecordBuilder.Options( + useImmutableCollections = true, + addSingleItemCollectionBuilders = true +) +public record GamePlayers(GamePlayer admin, Set others) { } diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 3061727..3bec596 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -2,12 +2,19 @@ import io.soabase.recordbuilder.core.RecordBuilder; import org.developerden.codosseum.dto.Players; +import org.developerden.codosseum.model.player.GamePlayer; import java.util.UUID; +/** + * Internal model representing the current state of a game. + * @param gameId the unique identifier of the game + * @param phase the current phase of the game + * @param players the players involved in the game + */ @RecordBuilder public record GameState(UUID gameId, GamePhase phase, - Players players + GamePlayers players ) { } diff --git a/src/main/java/org/developerden/codosseum/model/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java similarity index 91% rename from src/main/java/org/developerden/codosseum/model/EphemeralPlayer.java rename to src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index c2e3e87..bece652 100644 --- a/src/main/java/org/developerden/codosseum/model/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -15,7 +15,7 @@ * */ -package org.developerden.codosseum.model; +package org.developerden.codosseum.model.player; -public record EphemeralPlayer(String name, String key, boolean admin) { +public record EphemeralPlayer(String name, String key, boolean admin) implements GamePlayer { } diff --git a/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java new file mode 100644 index 0000000..7870c68 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java @@ -0,0 +1,5 @@ +package org.developerden.codosseum.model.player; + +public sealed interface GamePlayer permits EphemeralPlayer { + String name(); +} diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java index cb0853f..3b4be20 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java @@ -17,7 +17,7 @@ package org.developerden.codosseum.repository; -import org.developerden.codosseum.model.EphemeralPlayer; +import org.developerden.codosseum.model.player.EphemeralPlayer; public interface AuthRepository { diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 66ca4f4..9ae5cf0 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -19,6 +19,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.validation.Valid; import org.developerden.codosseum.dto.*; import org.developerden.codosseum.mode.GameModeFactory; import org.developerden.codosseum.mode.GameModeType; @@ -33,6 +34,7 @@ import org.developerden.codosseum.service.game.state.SnapshotStore; import org.developerden.codosseum.utils.CollectionUtils; +import java.nio.channels.FileChannel; import java.util.*; @Singleton @@ -124,6 +126,7 @@ public void startGame(UUID gameId) { runner.tell(new GameCommand.StartGame(gameId)); } + public String getTemplate(String gameId, String lang) { throw new UnsupportedOperationException(); } @@ -136,4 +139,7 @@ public GameCreateResponse restartGame(String gameId) { throw new UnsupportedOperationException(); } + public Optional addPlayer(UUID id, @Valid Player player) { + return null; + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index c228a93..0e05e44 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -1,7 +1,7 @@ package org.developerden.codosseum.service.game; -import org.developerden.codosseum.dto.PlayersBuilder; import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.model.GamePlayersBuilder; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; import org.developerden.codosseum.service.game.event.InternalGameEvent; @@ -79,8 +79,8 @@ private GameState apply(GameState state, InternalGameEvent event) { return switch (event) { case InternalGameEvent.PlayerJoined(var gameId, var player) -> GameStateBuilder.from(state) .withPlayers( - PlayersBuilder.builder(state.players()) - .addPlayers(player) + GamePlayersBuilder.builder(state.players()) + .addOthers(player) .build() ); case InternalGameEvent.GameCreated(var gameId) -> state; // no-op for now diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 4825cf5..cd1b20e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -1,6 +1,7 @@ package org.developerden.codosseum.service.game; import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.model.player.GamePlayer; import java.util.UUID; @@ -21,7 +22,7 @@ record CreateGame(UUID gameId) implements GameCommand { record StartGame(UUID gameId) implements GameCommand { } - record AddPlayer(UUID gameId, Player player) implements GameCommand { + record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index ef84365..38d7ad6 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -1,10 +1,17 @@ package org.developerden.codosseum.service.game.event; +import jakarta.annotation.Nonnull; import jakarta.inject.Singleton; +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.event.PlayerJoinEvent; +import org.developerden.codosseum.model.GamePlayers; +import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.model.player.GamePlayer; import java.util.Optional; +import java.util.stream.Collectors; @Singleton public class EventMapper { @@ -14,8 +21,24 @@ public Optional fromInternal(InternalGameEvent internalEvent) { case InternalGameEvent.GameCreated ignored -> Optional.empty(); case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( - new PlayerJoinEvent(playerJoined.gameId(), playerJoined.player()) + new PlayerJoinEvent(playerJoined.gameId(), fromGamePlayer(playerJoined.player())) ); }; } + + private Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { + var players = new Players( + gamePlayers.others().stream() + .map(this::fromGamePlayer) + .collect(Collectors.toSet()), + fromGamePlayer(gamePlayers.admin()) + ); + return Optional.of(players); + } + + private Player fromGamePlayer(@Nonnull GamePlayer player) { + return switch (player) { + case EphemeralPlayer(var name, var ignored, var ignored2) -> (new Player(name)); + }; + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index c58124c..eb9c973 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -3,6 +3,8 @@ import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.GamePlayers; +import org.developerden.codosseum.model.player.GamePlayer; import org.developerden.codosseum.service.game.GameCommand; import java.util.UUID; @@ -20,6 +22,6 @@ public sealed interface InternalGameEvent { record GameCreated(UUID gameId) implements InternalGameEvent { } - record PlayerJoined(UUID gameId, Player player) implements InternalGameEvent { + record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { } } From b32a1fed11e18a5b0cf60b43eaa2c9048ce4f150 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 10:18:44 +0100 Subject: [PATCH 28/95] upgrade gradle and use java 25 because we are cool --- .github/workflows/docker.yml | 4 +- .github/workflows/gradle.yml | 4 +- build.gradle.kts | 13 +- gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 28 +-- gradlew.bat | 185 +++++++++--------- .../controller/PlayerController.java | 4 +- 8 files changed, 123 insertions(+), 117 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 545531d..1e3e3ac 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,11 +18,11 @@ jobs: key: ${{ runner.os }}-gradle-test-${{ hashFiles('**/*.gradle') }} restore-keys: | ${{ runner.os }}-gradle-test- - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@v3 with: distribution: temurin - java-version: 17 + java-version: 25 - name: Docker login uses: docker/login-action@v1 with: diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 11a0006..18f8414 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: temurin - java-version: 17 + java-version: 25 - name: Setup Gradle uses: gradle/gradle-build-action@v2 with: @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: temurin - java-version: 17 + java-version: 25 - name: Setup Gradle uses: gradle/gradle-build-action@v2 with: diff --git a/build.gradle.kts b/build.gradle.kts index 4602317..9903840 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2023 Alex Wood // SPDX-License-Identifier: AGPL-3.0-or-later plugins { - id("com.github.johnrengelman.shadow") version "8.1.1" id("io.micronaut.application") version "4.5.5" id("io.micronaut.aot") version "4.5.5" id("io.micronaut.openapi") version "4.5.5" @@ -43,6 +42,9 @@ dependencies { } runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("org.yaml:snakeyaml") + + testImplementation("org.junit.jupiter:junit-jupiter:5.7.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -51,8 +53,8 @@ application { } java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } checkstyle { @@ -96,3 +98,8 @@ micronaut { } } + + +tasks.named("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..8bdaf60c75ab801e22807dde59e12a8735a34077 100644 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 63375 zcmb5VV{~QRw)Y#`wrv{~+qP{x72B%VwzFc}c2cp;N~)5ZbDrJayPv(!dGEd-##*zr z)#n-$y^sH|_dchh3@8{H5D*j;5D<{i*8l5IFJ|DjL!e)upfGNX(kojugZ3I`oH1PvW`wFW_ske0j@lB9bX zO;2)`y+|!@X(fZ1<2n!Qx*)_^Ai@Cv-dF&(vnudG?0CsddG_&Wtae(n|K59ew)6St z#dj7_(Cfwzh$H$5M!$UDd8=4>IQsD3xV=lXUq($;(h*$0^yd+b{qq63f0r_de#!o_ zXDngc>zy`uor)4A^2M#U*DC~i+dc<)Tb1Tv&~Ev@oM)5iJ4Sn#8iRw16XXuV50BS7 zdBL5Mefch(&^{luE{*5qtCZk$oFr3RH=H!c3wGR=HJ(yKc_re_X9pD` zJ;uxPzUfVpgU>DSq?J;I@a+10l0ONXPcDkiYcihREt5~T5Gb}sT0+6Q;AWHl`S5dV>lv%-p9l#xNNy7ZCr%cyqHY%TZ8Q4 zbp&#ov1*$#grNG#1vgfFOLJCaNG@K|2!W&HSh@3@Y%T?3YI75bJp!VP*$*!< z;(ffNS_;@RJ`=c7yX04!u3JP*<8jeqLHVJu#WV&v6wA!OYJS4h<_}^QI&97-;=ojW zQ-1t)7wnxG*5I%U4)9$wlv5Fr;cIizft@&N+32O%B{R1POm$oap@&f| zh+5J{>U6ftv|vAeKGc|zC=kO(+l7_cLpV}-D#oUltScw})N>~JOZLU_0{Ka2e1evz z{^a*ZrLr+JUj;)K&u2CoCAXLC2=fVScI(m_p~0FmF>>&3DHziouln?;sxW`NB}cSX z8?IsJB)Z=aYRz!X=yJn$kyOWK%rCYf-YarNqKzmWu$ZvkP12b4qH zhS9Q>j<}(*frr?z<%9hl*i^#@*O2q(Z^CN)c2c z>1B~D;@YpG?G!Yk+*yn4vM4sO-_!&m6+`k|3zd;8DJnxsBYtI;W3We+FN@|tQ5EW= z!VU>jtim0Mw#iaT8t_<+qKIEB-WwE04lBd%Letbml9N!?SLrEG$nmn7&W(W`VB@5S zaY=sEw2}i@F_1P4OtEw?xj4@D6>_e=m=797#hg}f*l^`AB|Y0# z9=)o|%TZFCY$SzgSjS|8AI-%J4x}J)!IMxY3_KYze`_I=c1nmrk@E8c9?MVRu)7+Ue79|)rBX7tVB7U|w4*h(;Gi3D9le49B38`wuv zp7{4X^p+K4*$@gU(Tq3K1a#3SmYhvI42)GzG4f|u zwQFT1n_=n|jpi=70-yE9LA+d*T8u z`=VmmXJ_f6WmZveZPct$Cgu^~gFiyL>Lnpj*6ee>*0pz=t$IJ}+rE zsf@>jlcG%Wx;Cp5x)YSVvB1$yyY1l&o zvwX=D7k)Dn;ciX?Z)Pn8$flC8#m`nB&(8?RSdBvr?>T9?E$U3uIX7T?$v4dWCa46 z+&`ot8ZTEgp7G+c52oHJ8nw5}a^dwb_l%MOh(ebVj9>_koQP^$2B~eUfSbw9RY$_< z&DDWf2LW;b0ZDOaZ&2^i^g+5uTd;GwO(-bbo|P^;CNL-%?9mRmxEw~5&z=X^Rvbo^WJW=n_%*7974RY}JhFv46> zd}`2|qkd;89l}R;i~9T)V-Q%K)O=yfVKNM4Gbacc7AOd>#^&W&)Xx!Uy5!BHnp9kh z`a(7MO6+Ren#>R^D0K)1sE{Bv>}s6Rb9MT14u!(NpZOe-?4V=>qZ>}uS)!y~;jEUK z&!U7Fj&{WdgU#L0%bM}SYXRtM5z!6M+kgaMKt%3FkjWYh=#QUpt$XX1!*XkpSq-pl zhMe{muh#knk{9_V3%qdDcWDv}v)m4t9 zQhv{;} zc{}#V^N3H>9mFM8`i`0p+fN@GqX+kl|M94$BK3J-X`Hyj8r!#x6Vt(PXjn?N)qedP z=o1T^#?1^a{;bZ&x`U{f?}TMo8ToN zkHj5v|}r}wDEi7I@)Gj+S1aE-GdnLN+$hw!=DzglMaj#{qjXi_dwpr|HL(gcCXwGLEmi|{4&4#OZ4ChceA zKVd4K!D>_N=_X;{poT~4Q+!Le+ZV>=H7v1*l%w`|`Dx8{)McN@NDlQyln&N3@bFpV z_1w~O4EH3fF@IzJ9kDk@7@QctFq8FbkbaH7K$iX=bV~o#gfh?2JD6lZf(XP>~DACF)fGFt)X%-h1yY~MJU{nA5 ze2zxWMs{YdX3q5XU*9hOH0!_S24DOBA5usB+Ws$6{|AMe*joJ?RxfV}*7AKN9V*~J zK+OMcE@bTD>TG1*yc?*qGqjBN8mgg@h1cJLDv)0!WRPIkC` zZrWXrceVw;fB%3`6kq=a!pq|hFIsQ%ZSlo~)D z|64!aCnw-?>}AG|*iOl44KVf8@|joXi&|)1rB;EQWgm+iHfVbgllP$f!$Wf42%NO5b(j9Bw6L z;0dpUUK$5GX4QbMlTmLM_jJt!ur`_0~$b#BB7FL*%XFf<b__1o)Ao3rlobbN8-(T!1d-bR8D3S0@d zLI!*GMb5s~Q<&sjd}lBb8Nr0>PqE6_!3!2d(KAWFxa{hm`@u|a(%#i(#f8{BP2wbs zt+N_slWF4IF_O|{w`c~)Xvh&R{Au~CFmW#0+}MBd2~X}t9lz6*E7uAD`@EBDe$>7W zzPUkJx<`f$0VA$=>R57^(K^h86>09?>_@M(R4q($!Ck6GG@pnu-x*exAx1jOv|>KH zjNfG5pwm`E-=ydcb+3BJwuU;V&OS=6yM^4Jq{%AVqnTTLwV`AorIDD}T&jWr8pB&j28fVtk_y*JRP^t@l*($UZ z6(B^-PBNZ+z!p?+e8@$&jCv^EWLb$WO=}Scr$6SM*&~B95El~;W_0(Bvoha|uQ1T< zO$%_oLAwf1bW*rKWmlD+@CP&$ObiDy=nh1b2ejz%LO9937N{LDe7gle4i!{}I$;&Y zkexJ9Ybr+lrCmKWg&}p=`2&Gf10orS?4$VrzWidT=*6{KzOGMo?KI0>GL0{iFWc;C z+LPq%VH5g}6V@-tg2m{C!-$fapJ9y}c$U}aUmS{9#0CM*8pC|sfer!)nG7Ji>mfRh z+~6CxNb>6eWKMHBz-w2{mLLwdA7dA-qfTu^A2yG1+9s5k zcF=le_UPYG&q!t5Zd_*E_P3Cf5T6821bO`daa`;DODm8Ih8k89=RN;-asHIigj`n=ux>*f!OC5#;X5i;Q z+V!GUy0|&Y_*8k_QRUA8$lHP;GJ3UUD08P|ALknng|YY13)}!!HW@0z$q+kCH%xet zlWf@BXQ=b=4}QO5eNnN~CzWBbHGUivG=`&eWK}beuV*;?zt=P#pM*eTuy3 zP}c#}AXJ0OIaqXji78l;YrP4sQe#^pOqwZUiiN6^0RCd#D271XCbEKpk`HI0IsN^s zES7YtU#7=8gTn#lkrc~6)R9u&SX6*Jk4GFX7){E)WE?pT8a-%6P+zS6o&A#ml{$WX zABFz#i7`DDlo{34)oo?bOa4Z_lNH>n;f0nbt$JfAl~;4QY@}NH!X|A$KgMmEsd^&Y zt;pi=>AID7ROQfr;MsMtClr5b0)xo|fwhc=qk33wQ|}$@?{}qXcmECh>#kUQ-If0$ zseb{Wf4VFGLNc*Rax#P8ko*=`MwaR-DQ8L8V8r=2N{Gaips2_^cS|oC$+yScRo*uF zUO|5=?Q?{p$inDpx*t#Xyo6=s?bbN}y>NNVxj9NZCdtwRI70jxvm3!5R7yiWjREEd zDUjrsZhS|P&|Ng5r+f^kA6BNN#|Se}_GF>P6sy^e8kBrgMv3#vk%m}9PCwUWJg-AD zFnZ=}lbi*mN-AOm zCs)r=*YQAA!`e#1N>aHF=bb*z*hXH#Wl$z^o}x##ZrUc=kh%OHWhp=7;?8%Xj||@V?1c ziWoaC$^&04;A|T)!Zd9sUzE&$ODyJaBpvqsw19Uiuq{i#VK1!htkdRWBnb z`{rat=nHArT%^R>u#CjjCkw-7%g53|&7z-;X+ewb?OLWiV|#nuc8mp*LuGSi3IP<<*Wyo9GKV7l0Noa4Jr0g3p_$ z*R9{qn=?IXC#WU>48-k5V2Oc_>P;4_)J@bo1|pf=%Rcbgk=5m)CJZ`caHBTm3%!Z9 z_?7LHr_BXbKKr=JD!%?KhwdYSdu8XxPoA{n8^%_lh5cjRHuCY9Zlpz8g+$f@bw@0V z+6DRMT9c|>1^3D|$Vzc(C?M~iZurGH2pXPT%F!JSaAMdO%!5o0uc&iqHx?ImcX6fI zCApkzc~OOnfzAd_+-DcMp&AOQxE_EsMqKM{%dRMI5`5CT&%mQO?-@F6tE*xL?aEGZ z8^wH@wRl`Izx4sDmU>}Ym{ybUm@F83qqZPD6nFm?t?(7>h*?`fw)L3t*l%*iw0Qu#?$5eq!Qc zpQvqgSxrd83NsdO@lL6#{%lsYXWen~d3p4fGBb7&5xqNYJ)yn84!e1PmPo7ChVd%4 zHUsV0Mh?VpzZD=A6%)Qrd~i7 z96*RPbid;BN{Wh?adeD_p8YU``kOrGkNox3D9~!K?w>#kFz!4lzOWR}puS(DmfjJD z`x0z|qB33*^0mZdM&6$|+T>fq>M%yoy(BEjuh9L0>{P&XJ3enGpoQRx`v6$txXt#c z0#N?b5%srj(4xmPvJxrlF3H%OMB!jvfy z;wx8RzU~lb?h_}@V=bh6p8PSb-dG|-T#A?`c&H2`_!u+uenIZe`6f~A7r)`9m8atC zt(b|6Eg#!Q*DfRU=Ix`#B_dK)nnJ_+>Q<1d7W)eynaVn`FNuN~%B;uO2}vXr5^zi2 z!ifIF5@Zlo0^h~8+ixFBGqtweFc`C~JkSq}&*a3C}L?b5Mh-bW=e)({F_g4O3 zb@SFTK3VD9QuFgFnK4Ve_pXc3{S$=+Z;;4+;*{H}Rc;845rP?DLK6G5Y-xdUKkA6E3Dz&5f{F^FjJQ(NSpZ8q-_!L3LL@H* zxbDF{gd^U3uD;)a)sJwAVi}7@%pRM&?5IaUH%+m{E)DlA_$IA1=&jr{KrhD5q&lTC zAa3c)A(K!{#nOvenH6XrR-y>*4M#DpTTOGQEO5Jr6kni9pDW`rvY*fs|ItV;CVITh z=`rxcH2nEJpkQ^(;1c^hfb8vGN;{{oR=qNyKtR1;J>CByul*+=`NydWnSWJR#I2lN zTvgnR|MBx*XFsfdA&;tr^dYaqRZp*2NwkAZE6kV@1f{76e56eUmGrZ>MDId)oqSWw z7d&r3qfazg+W2?bT}F)4jD6sWaw`_fXZGY&wnGm$FRPFL$HzVTH^MYBHWGCOk-89y zA+n+Q6EVSSCpgC~%uHfvyg@ufE^#u?JH?<73A}jj5iILz4Qqk5$+^U(SX(-qv5agK znUkfpke(KDn~dU0>gdKqjTkVk`0`9^0n_wzXO7R!0Thd@S;U`y)VVP&mOd-2 z(hT(|$=>4FY;CBY9#_lB$;|Wd$aOMT5O_3}DYXEHn&Jrc3`2JiB`b6X@EUOD zVl0S{ijm65@n^19T3l%>*;F(?3r3s?zY{thc4%AD30CeL_4{8x6&cN}zN3fE+x<9; zt2j1RRVy5j22-8U8a6$pyT+<`f+x2l$fd_{qEp_bfxfzu>ORJsXaJn4>U6oNJ#|~p z`*ZC&NPXl&=vq2{Ne79AkQncuxvbOG+28*2wU$R=GOmns3W@HE%^r)Fu%Utj=r9t` zd;SVOnA(=MXgnOzI2@3SGKHz8HN~Vpx&!Ea+Df~`*n@8O=0!b4m?7cE^K*~@fqv9q zF*uk#1@6Re_<^9eElgJD!nTA@K9C732tV~;B`hzZ321Ph=^BH?zXddiu{Du5*IPg} zqDM=QxjT!Rp|#Bkp$(mL)aar)f(dOAXUiw81pX0DC|Y4;>Vz>>DMshoips^8Frdv} zlTD=cKa48M>dR<>(YlLPOW%rokJZNF2gp8fwc8b2sN+i6&-pHr?$rj|uFgktK@jg~ zIFS(%=r|QJ=$kvm_~@n=ai1lA{7Z}i+zj&yzY+!t$iGUy|9jH#&oTNJ;JW-3n>DF+ z3aCOzqn|$X-Olu_p7brzn`uk1F*N4@=b=m;S_C?#hy{&NE#3HkATrg?enaVGT^$qIjvgc61y!T$9<1B@?_ibtDZ{G zeXInVr5?OD_nS_O|CK3|RzzMmu+8!#Zb8Ik;rkIAR%6?$pN@d<0dKD2c@k2quB%s( zQL^<_EM6ow8F6^wJN1QcPOm|ehA+dP(!>IX=Euz5qqIq}Y3;ibQtJnkDmZ8c8=Cf3 zu`mJ!Q6wI7EblC5RvP*@)j?}W=WxwCvF3*5Up_`3*a~z$`wHwCy)2risye=1mSp%p zu+tD6NAK3o@)4VBsM!@);qgsjgB$kkCZhaimHg&+k69~drbvRTacWKH;YCK(!rC?8 zP#cK5JPHSw;V;{Yji=55X~S+)%(8fuz}O>*F3)hR;STU`z6T1aM#Wd+FP(M5*@T1P z^06O;I20Sk!bxW<-O;E081KRdHZrtsGJflFRRFS zdi5w9OVDGSL3 zNrC7GVsGN=b;YH9jp8Z2$^!K@h=r-xV(aEH@#JicPy;A0k1>g1g^XeR`YV2HfmqXY zYbRwaxHvf}OlCAwHoVI&QBLr5R|THf?nAevV-=~V8;gCsX>jndvNOcFA+DI+zbh~# zZ7`qNk&w+_+Yp!}j;OYxIfx_{f0-ONc?mHCiCUak=>j>~>YR4#w# zuKz~UhT!L~GfW^CPqG8Lg)&Rc6y^{%3H7iLa%^l}cw_8UuG;8nn9)kbPGXS}p3!L_ zd#9~5CrH8xtUd?{d2y^PJg+z(xIfRU;`}^=OlehGN2=?}9yH$4Rag}*+AWotyxfCJ zHx=r7ZH>j2kV?%7WTtp+-HMa0)_*DBBmC{sd$)np&GEJ__kEd`xB5a2A z*J+yx>4o#ZxwA{;NjhU*1KT~=ZK~GAA;KZHDyBNTaWQ1+;tOFFthnD)DrCn`DjBZ% zk$N5B4^$`n^jNSOr=t(zi8TN4fpaccsb`zOPD~iY=UEK$0Y70bG{idLx@IL)7^(pL z{??Bnu=lDeguDrd%qW1)H)H`9otsOL-f4bSu};o9OXybo6J!Lek`a4ff>*O)BDT_g z<6@SrI|C9klY(>_PfA^qai7A_)VNE4c^ZjFcE$Isp>`e5fLc)rg@8Q_d^Uk24$2bn z9#}6kZ2ZxS9sI(RqT7?El2@B+($>eBQrNi_k#CDJ8D9}8$mmm z4oSKO^F$i+NG)-HE$O6s1--6EzJa?C{x=QgK&c=)b(Q9OVoAXYEEH20G|q$}Hue%~ zO3B^bF=t7t48sN zWh_zA`w~|){-!^g?6Mqf6ieV zFx~aPUOJGR=4{KsW7I?<=J2|lY`NTU=lt=%JE9H1vBpkcn=uq(q~=?iBt_-r(PLBM zP-0dxljJO>4Wq-;stY)CLB4q`-r*T$!K2o}?E-w_i>3_aEbA^MB7P5piwt1dI-6o!qWCy0 ztYy!x9arGTS?kabkkyv*yxvsPQ7Vx)twkS6z2T@kZ|kb8yjm+^$|sEBmvACeqbz)RmxkkDQX-A*K!YFziuhwb|ym>C$}U|J)4y z$(z#)GH%uV6{ec%Zy~AhK|+GtG8u@c884Nq%w`O^wv2#A(&xH@c5M`Vjk*SR_tJnq z0trB#aY)!EKW_}{#L3lph5ow=@|D5LzJYUFD6 z7XnUeo_V0DVSIKMFD_T0AqAO|#VFDc7c?c-Q%#u00F%!_TW1@JVnsfvm@_9HKWflBOUD~)RL``-!P;(bCON_4eVdduMO>?IrQ__*zE@7(OX zUtfH@AX*53&xJW*Pu9zcqxGiM>xol0I~QL5B%Toog3Jlenc^WbVgeBvV8C8AX^Vj& z^I}H})B=VboO%q1;aU5ACMh{yK4J;xlMc`jCnZR^!~LDs_MP&8;dd@4LDWw~*>#OT zeZHwdQWS!tt5MJQI~cw|Ka^b4c|qyd_ly(+Ql2m&AAw^ zQeSXDOOH!!mAgzAp0z)DD>6Xo``b6QwzUV@w%h}Yo>)a|xRi$jGuHQhJVA%>)PUvK zBQ!l0hq<3VZ*RnrDODP)>&iS^wf64C;MGqDvx>|p;35%6(u+IHoNbK z;Gb;TneFo*`zUKS6kwF*&b!U8e5m4YAo03a_e^!5BP42+r)LFhEy?_7U1IR<; z^0v|DhCYMSj<-;MtY%R@Fg;9Kky^pz_t2nJfKWfh5Eu@_l{^ph%1z{jkg5jQrkvD< z#vdK!nku*RrH~TdN~`wDs;d>XY1PH?O<4^U4lmA|wUW{Crrv#r%N>7k#{Gc44Fr|t z@UZP}Y-TrAmnEZ39A*@6;ccsR>)$A)S>$-Cj!=x$rz7IvjHIPM(TB+JFf{ehuIvY$ zsDAwREg*%|=>Hw$`us~RP&3{QJg%}RjJKS^mC_!U;E5u>`X`jW$}P`Mf}?7G7FX#{ zE(9u1SO;3q@ZhDL9O({-RD+SqqPX)`0l5IQu4q)49TUTkxR(czeT}4`WV~pV*KY&i zAl3~X%D2cPVD^B43*~&f%+Op)wl<&|D{;=SZwImydWL6@_RJjxP2g)s=dH)u9Npki zs~z9A+3fj0l?yu4N0^4aC5x)Osnm0qrhz@?nwG_`h(71P znbIewljU%T*cC=~NJy|)#hT+lx#^5MuDDnkaMb*Efw9eThXo|*WOQzJ*#3dmRWm@! zfuSc@#kY{Um^gBc^_Xdxnl!n&y&}R4yAbK&RMc+P^Ti;YIUh|C+K1|=Z^{nZ}}rxH*v{xR!i%qO~o zTr`WDE@k$M9o0r4YUFFeQO7xCu_Zgy)==;fCJ94M_rLAv&~NhfvcLWCoaGg2ao~3e zBG?Ms9B+efMkp}7BhmISGWmJsKI@a8b}4lLI48oWKY|8?zuuNc$lt5Npr+p7a#sWu zh!@2nnLBVJK!$S~>r2-pN||^w|fY`CT{TFnJy`B|e5;=+_v4l8O-fkN&UQbA4NKTyntd zqK{xEKh}U{NHoQUf!M=2(&w+eef77VtYr;xs%^cPfKLObyOV_9q<(%76-J%vR>w9!us-0c-~Y?_EVS%v!* z15s2s3eTs$Osz$JayyH|5nPAIPEX=U;r&p;K14G<1)bvn@?bM5kC{am|C5%hyxv}a z(DeSKI5ZfZ1*%dl8frIX2?);R^^~LuDOpNpk-2R8U1w92HmG1m&|j&J{EK=|p$;f9 z7Rs5|jr4r8k5El&qcuM+YRlKny%t+1CgqEWO>3;BSRZi(LA3U%Jm{@{y+A+w(gzA< z7dBq6a1sEWa4cD0W7=Ld9z0H7RI^Z7vl(bfA;72j?SWCo`#5mVC$l1Q2--%V)-uN* z9ha*s-AdfbDZ8R8*fpwjzx=WvOtmSzGFjC#X)hD%Caeo^OWjS(3h|d9_*U)l%{Ab8 zfv$yoP{OuUl@$(-sEVNt{*=qi5P=lpxWVuz2?I7Dc%BRc+NGNw+323^ z5BXGfS71oP^%apUo(Y#xkxE)y?>BFzEBZ}UBbr~R4$%b7h3iZu3S(|A;&HqBR{nK& z$;GApNnz=kNO^FL&nYcfpB7Qg;hGJPsCW44CbkG1@l9pn0`~oKy5S777uH)l{irK!ru|X+;4&0D;VE*Ii|<3P zUx#xUqvZT5kVQxsF#~MwKnv7;1pR^0;PW@$@T7I?s`_rD1EGUdSA5Q(C<>5SzE!vw z;{L&kKFM-MO>hy#-8z`sdVx})^(Dc-dw;k-h*9O2_YZw}|9^y-|8RQ`BWJUJL(Cer zP5Z@fNc>pTXABbTRY-B5*MphpZv6#i802giwV&SkFCR zGMETyUm(KJbh+&$8X*RB#+{surjr;8^REEt`2&Dubw3$mx>|~B5IKZJ`s_6fw zKAZx9&PwBqW1Oz0r0A4GtnZd7XTKViX2%kPfv+^X3|_}RrQ2e3l=KG_VyY`H?I5&CS+lAX5HbA%TD9u6&s#v!G> zzW9n4J%d5ye7x0y`*{KZvqyXUfMEE^ZIffzI=Hh|3J}^yx7eL=s+TPH(Q2GT-sJ~3 zI463C{(ag7-hS1ETtU;_&+49ABt5!A7CwLwe z=SoA8mYZIQeU;9txI=zcQVbuO%q@E)JI+6Q!3lMc=Gbj(ASg-{V27u>z2e8n;Nc*pf}AqKz1D>p9G#QA+7mqqrEjGfw+85Uyh!=tTFTv3|O z+)-kFe_8FF_EkTw!YzwK^Hi^_dV5x-Ob*UWmD-})qKj9@aE8g240nUh=g|j28^?v7 zHRTBo{0KGaWBbyX2+lx$wgXW{3aUab6Bhm1G1{jTC7ota*JM6t+qy)c5<@ zpc&(jVdTJf(q3xB=JotgF$X>cxh7k*(T`-V~AR+`%e?YOeALQ2Qud( zz35YizXt(aW3qndR}fTw1p()Ol4t!D1pitGNL95{SX4ywzh0SF;=!wf=?Q?_h6!f* zh7<+GFi)q|XBsvXZ^qVCY$LUa{5?!CgwY?EG;*)0ceFe&=A;!~o`ae}Z+6me#^sv- z1F6=WNd6>M(~ z+092z>?Clrcp)lYNQl9jN-JF6n&Y0mp7|I0dpPx+4*RRK+VQI~>en0Dc;Zfl+x z_e_b7s`t1_A`RP3$H}y7F9_na%D7EM+**G_Z0l_nwE+&d_kc35n$Fxkd4r=ltRZhh zr9zER8>j(EdV&Jgh(+i}ltESBK62m0nGH6tCBr90!4)-`HeBmz54p~QP#dsu%nb~W z7sS|(Iydi>C@6ZM(Us!jyIiszMkd)^u<1D+R@~O>HqZIW&kearPWmT>63%_t2B{_G zX{&a(gOYJx!Hq=!T$RZ&<8LDnxsmx9+TBL0gTk$|vz9O5GkK_Yx+55^R=2g!K}NJ3 zW?C;XQCHZl7H`K5^BF!Q5X2^Mj93&0l_O3Ea3!Ave|ixx+~bS@Iv18v2ctpSt4zO{ zp#7pj!AtDmti$T`e9{s^jf(ku&E|83JIJO5Qo9weT6g?@vX!{7)cNwymo1+u(YQ94 zopuz-L@|5=h8A!(g-MXgLJC0MA|CgQF8qlonnu#j z;uCeq9ny9QSD|p)9sp3ebgY3rk#y0DA(SHdh$DUm^?GI<>%e1?&}w(b zdip1;P2Z=1wM+$q=TgLP$}svd!vk+BZ@h<^4R=GS2+sri7Z*2f`9 z5_?i)xj?m#pSVchk-SR!2&uNhzEi+#5t1Z$o0PoLGz*pT64%+|Wa+rd5Z}60(j?X= z{NLjtgRb|W?CUADqOS@(*MA-l|E342NxRaxLTDqsOyfWWe%N(jjBh}G zm7WPel6jXijaTiNita+z(5GCO0NM=Melxud57PP^d_U## zbA;9iVi<@wr0DGB8=T9Ab#2K_#zi=$igyK48@;V|W`fg~7;+!q8)aCOo{HA@vpSy-4`^!ze6-~8|QE||hC{ICKllG9fbg_Y7v z$jn{00!ob3!@~-Z%!rSZ0JO#@>|3k10mLK0JRKP-Cc8UYFu>z93=Ab-r^oL2 zl`-&VBh#=-?{l1TatC;VweM^=M7-DUE>m+xO7Xi6vTEsReyLs8KJ+2GZ&rxw$d4IT zPXy6pu^4#e;;ZTsgmG+ZPx>piodegkx2n0}SM77+Y*j^~ICvp#2wj^BuqRY*&cjmL zcKp78aZt>e{3YBb4!J_2|K~A`lN=u&5j!byw`1itV(+Q_?RvV7&Z5XS1HF)L2v6ji z&kOEPmv+k_lSXb{$)of~(BkO^py&7oOzpjdG>vI1kcm_oPFHy38%D4&A4h_CSo#lX z2#oqMCTEP7UvUR3mwkPxbl8AMW(e{ARi@HCYLPSHE^L<1I}OgZD{I#YH#GKnpRmW3 z2jkz~Sa(D)f?V?$gNi?6)Y;Sm{&?~2p=0&BUl_(@hYeX8YjaRO=IqO7neK0RsSNdYjD zaw$g2sG(>JR=8Iz1SK4`*kqd_3-?;_BIcaaMd^}<@MYbYisWZm2C2|Np_l|8r9yM|JkUngSo@?wci(7&O9a z%|V(4C1c9pps0xxzPbXH=}QTxc2rr7fXk$9`a6TbWKPCz&p=VsB8^W96W=BsB|7bc zf(QR8&Ktj*iz)wK&mW`#V%4XTM&jWNnDF56O+2bo<3|NyUhQ%#OZE8$Uv2a@J>D%t zMVMiHh?es!Ex19q&6eC&L=XDU_BA&uR^^w>fpz2_`U87q_?N2y;!Z!bjoeKrzfC)} z?m^PM=(z{%n9K`p|7Bz$LuC7!>tFOuN74MFELm}OD9?%jpT>38J;=1Y-VWtZAscaI z_8jUZ#GwWz{JqvGEUmL?G#l5E=*m>`cY?m*XOc*yOCNtpuIGD+Z|kn4Xww=BLrNYS zGO=wQh}Gtr|7DGXLF%|`G>J~l{k^*{;S-Zhq|&HO7rC_r;o`gTB7)uMZ|WWIn@e0( zX$MccUMv3ABg^$%_lNrgU{EVi8O^UyGHPNRt%R!1#MQJn41aD|_93NsBQhP80yP<9 zG4(&0u7AtJJXLPcqzjv`S~5;Q|5TVGccN=Uzm}K{v)?f7W!230C<``9(64}D2raRU zAW5bp%}VEo{4Rko`bD%Ehf=0voW?-4Mk#d3_pXTF!-TyIt6U+({6OXWVAa;s-`Ta5 zTqx&8msH3+DLrVmQOTBOAj=uoxKYT3DS1^zBXM?1W+7gI!aQNPYfUl{3;PzS9*F7g zWJN8x?KjBDx^V&6iCY8o_gslO16=kh(|Gp)kz8qlQ`dzxQv;)V&t+B}wwdi~uBs4? zu~G|}y!`3;8#vIMUdyC7YEx6bb^1o}G!Jky4cN?BV9ejBfN<&!4M)L&lRKiuMS#3} z_B}Nkv+zzxhy{dYCW$oGC&J(Ty&7%=5B$sD0bkuPmj7g>|962`(Q{ZZMDv%YMuT^KweiRDvYTEop3IgFv#)(w>1 zSzH>J`q!LK)c(AK>&Ib)A{g`Fdykxqd`Yq@yB}E{gnQV$K!}RsgMGWqC3DKE(=!{}ekB3+(1?g}xF>^icEJbc z5bdxAPkW90atZT+&*7qoLqL#p=>t-(-lsnl2XMpZcYeW|o|a322&)yO_8p(&Sw{|b zn(tY$xn5yS$DD)UYS%sP?c|z>1dp!QUD)l;aW#`%qMtQJjE!s2z`+bTSZmLK7SvCR z=@I4|U^sCwZLQSfd*ACw9B@`1c1|&i^W_OD(570SDLK`MD0wTiR8|$7+%{cF&){$G zU~|$^Ed?TIxyw{1$e|D$050n8AjJvvOWhLtLHbSB|HIfhMpqVf>AF&}ZQHhOJ14Bz zww+XL+qP}nww+W`F>b!by|=&a(cM4JIDhsTXY8@|ntQG}-}jm0&Bcj|LV(#sc=BNS zRjh;k9l>EdAFdd)=H!U`~$WP*}~^3HZ_?H>gKw>NBa;tA8M1{>St|)yDF_=~{KEPAGkg3VB`QCHol!AQ0|?e^W?81f{@()Wy!vQ$bY; z0ctx)l7VK83d6;dp!s{Nu=SwXZ8lHQHC*J2g@P0a={B8qHdv(+O3wV=4-t4HK1+smO#=S; z3cSI#Nh+N@AqM#6wPqjDmQM|x95JG|l1#sAU|>I6NdF*G@bD?1t|ytHlkKD+z9}#j zbU+x_cR-j9yX4s{_y>@zk*ElG1yS({BInGJcIT>l4N-DUs6fufF#GlF2lVUNOAhJT zGZThq54GhwCG(h4?yWR&Ax8hU<*U)?g+HY5-@{#ls5CVV(Wc>Bavs|l<}U|hZn z_%m+5i_gaakS*Pk7!v&w3&?R5Xb|AkCdytTY;r+Z7f#Id=q+W8cn)*9tEet=OG+Y} z58U&!%t9gYMx2N=8F?gZhIjtkH!`E*XrVJ?$2rRxLhV1z82QX~PZi8^N5z6~f-MUE zLKxnNoPc-SGl7{|Oh?ZM$jq67sSa)Wr&3)0YxlJt(vKf!-^L)a|HaPv*IYXb;QmWx zsqM>qY;tpK3RH-omtta+Xf2Qeu^$VKRq7`e$N-UCe1_2|1F{L3&}M0XbJ@^xRe&>P zRdKTgD6601x#fkDWkoYzRkxbn#*>${dX+UQ;FbGnTE-+kBJ9KPn)501#_L4O_k`P3 zm+$jI{|EC?8BXJY{P~^f-{**E53k%kVO$%p+=H5DiIdwMmUo>2euq0UzU90FWL!>; z{5@sd0ecqo5j!6AH@g6Mf3keTP$PFztq}@)^ZjK;H6Go$#SV2|2bAFI0%?aXgVH$t zb4Kl`$Xh8qLrMbZUS<2*7^F0^?lrOE=$DHW+O zvLdczsu0^TlA6RhDy3=@s!k^1D~Awulk!Iyo#}W$xq8{yTAK!CLl={H0@YGhg-g~+ z(u>pss4k#%8{J%~%8=H5!T`rqK6w^es-cNVE}=*lP^`i&K4R=peg1tdmT~UAbDKc& zg%Y*1E{hBf<)xO>HDWV7BaMWX6FW4ou1T2m^6{Jb!Su1UaCCYY8RR8hAV$7ho|FyEyP~ zEgK`@%a$-C2`p zV*~G>GOAs*3KN;~IY_UR$ISJxB(N~K>=2C2V6>xTmuX4klRXdrJd&UPAw7&|KEwF8Zcy2j-*({gSNR1^p02Oj88GN9a_Hq;Skdp}kO0;FLbje%2ZvPiltDZgv^ z#pb4&m^!79;O8F+Wr9X71laPY!CdNXG?J6C9KvdAE2xWW1>U~3;0v≫L+crb^Bz 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 /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -201,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,92 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index 35eebfc..78e65a6 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -63,9 +63,7 @@ public HttpResponse joinGame( @Valid @Body Player player ) { - return gameService.addPlayer(game.id(), player) - .map(response -> HttpResponse.ok(new GameJoinResponse(response.game(), response.player()))) - .orElse(HttpResponse.notFound()); + return HttpResponse.notFound(); } From e7ff30149b6e404a67b5bca7c6a6e219e00fb4b8 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:28:49 +0100 Subject: [PATCH 29/95] Add devenv for easy nix environment provisioning --- .envrc | 3 + .gitignore | 11 ++++ devenv.lock | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 .envrc create mode 100644 devenv.lock diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..6de8a8a --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" + +use devenv \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52b2545..612b711 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,14 @@ out/ .settings .classpath .factorypath + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..a08eda0 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,156 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1693179221, + "narHash": "sha256-vfndyVSFhfWwO5b7d0j92YJ06obEaHsJZAa3MI0sYvc=", + "owner": "cachix", + "repo": "devenv", + "rev": "68ea687ed567d578543d89b47281119a3511ac08", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1685518550, + "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1693145325, + "narHash": "sha256-Gat9xskErH1zOcLjYMhSDBo0JTBZKfGS0xJlIRnj6Rc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "cddebdb60de376c1bdb7a4e6ee3d98355453fe56", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1692274144, + "narHash": "sha256-BxTQuRUANQ81u8DJznQyPmRsg63t4Yc+0kcyq6OLz8s=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "7e3517c03d46159fdbf8c0e5c97f82d5d4b0c8fa", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} From 772f15a1fe36230923b7f5dbc3cf5ef2ebd6413f Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:31:12 +0100 Subject: [PATCH 30/95] add actual devenv config --- devenv.nix | 20 ++++++++++++++++++++ devenv.yaml | 3 +++ 2 files changed, 23 insertions(+) create mode 100644 devenv.nix create mode 100644 devenv.yaml diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..d57c633 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: + +{ + + # https://devenv.sh/packages/ + packages = [ pkgs.git pkgs.pre-commit pkgs.checkstyle ]; + + # https://devenv.sh/languages/ + languages.nix.enable = true; + languages.java.enable = true; + languages.java.gradle.enable = true; + + # https://devenv.sh/pre-commit-hooks/ + # pre-commit.hooks.shellcheck.enable = true; + + # https://devenv.sh/processes/ + # processes.ping.exec = "ping example.com"; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..c7cb5ce --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,3 @@ +inputs: + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable From 9d6b2e49ff68ee0124163c435eba91bb36a49c80 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:34:30 +0100 Subject: [PATCH 31/95] add nixfmt --- devenv.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/devenv.nix b/devenv.nix index d57c633..49cef47 100644 --- a/devenv.nix +++ b/devenv.nix @@ -2,8 +2,7 @@ { - # https://devenv.sh/packages/ - packages = [ pkgs.git pkgs.pre-commit pkgs.checkstyle ]; + packages = with pkgs; [ git pre-commit checkstyle nixfmt ]; # https://devenv.sh/languages/ languages.nix.enable = true; From 3ed164f7cd156ac44825d95364a9888e90ae3cdf Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:34:37 +0100 Subject: [PATCH 32/95] tidy up devenv.nix --- devenv.nix | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/devenv.nix b/devenv.nix index 49cef47..4c6e5d9 100644 --- a/devenv.nix +++ b/devenv.nix @@ -4,16 +4,8 @@ packages = with pkgs; [ git pre-commit checkstyle nixfmt ]; - # https://devenv.sh/languages/ - languages.nix.enable = true; - languages.java.enable = true; - languages.java.gradle.enable = true; + languages.nix.enable = true; + languages.java.enable = true; + languages.java.gradle.enable = true; - # https://devenv.sh/pre-commit-hooks/ - # pre-commit.hooks.shellcheck.enable = true; - - # https://devenv.sh/processes/ - # processes.ping.exec = "ping example.com"; - - # See full reference at https://devenv.sh/reference/options/ } From 26ade390f2fcb7a08ad135d1e037c8d9a59e3a1e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:38:14 +0100 Subject: [PATCH 33/95] add copyright --- .envrc | 2 ++ devenv.nix | 2 ++ devenv.yaml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.envrc b/.envrc index 6de8a8a..9be9893 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: 2023 Alexander Wood +# SPDX-License-Identifier: AGPL-3.0-or-later source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" use devenv \ No newline at end of file diff --git a/devenv.nix b/devenv.nix index 4c6e5d9..c7c8e73 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: 2023 Alexander Wood +# SPDX-License-Identifier: AGPL-3.0-or-later { pkgs, ... }: { diff --git a/devenv.yaml b/devenv.yaml index c7cb5ce..e02a749 100644 --- a/devenv.yaml +++ b/devenv.yaml @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: 2023 Alexander Wood +# SPDX-License-Identifier: AGPL-3.0-or-later inputs: nixpkgs: url: github:NixOS/nixpkgs/nixpkgs-unstable From a4e4f20f90c1ac8d30f36ff531c505112e1ed881 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 28 Aug 2023 13:44:04 +0100 Subject: [PATCH 34/95] Remove pre-commit from .gitignore --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 612b711..2a628ef 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,3 @@ devenv.local.nix # direnv .direnv - -# pre-commit -.pre-commit-config.yaml - From d02cc2e5075394dd35969c3ff074054c48bf4b57 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 10:21:09 +0100 Subject: [PATCH 35/95] goodbye devenv! you suck --- .envrc | 4 +- devenv.lock | 156 ---------------------------------------------------- devenv.nix | 13 ----- devenv.yaml | 5 -- 4 files changed, 1 insertion(+), 177 deletions(-) delete mode 100644 devenv.lock delete mode 100644 devenv.nix delete mode 100644 devenv.yaml diff --git a/.envrc b/.envrc index 9be9893..9bee7a3 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,3 @@ # SPDX-FileCopyrightText: 2023 Alexander Wood # SPDX-License-Identifier: AGPL-3.0-or-later -source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" - -use devenv \ No newline at end of file +use flake . \ No newline at end of file diff --git a/devenv.lock b/devenv.lock deleted file mode 100644 index a08eda0..0000000 --- a/devenv.lock +++ /dev/null @@ -1,156 +0,0 @@ -{ - "nodes": { - "devenv": { - "locked": { - "dir": "src/modules", - "lastModified": 1693179221, - "narHash": "sha256-vfndyVSFhfWwO5b7d0j92YJ06obEaHsJZAa3MI0sYvc=", - "owner": "cachix", - "repo": "devenv", - "rev": "68ea687ed567d578543d89b47281119a3511ac08", - "type": "github" - }, - "original": { - "dir": "src/modules", - "owner": "cachix", - "repo": "devenv", - "type": "github" - } - }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1673956053, - "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1685518550, - "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "pre-commit-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1660459072, - "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1693145325, - "narHash": "sha256-Gat9xskErH1zOcLjYMhSDBo0JTBZKfGS0xJlIRnj6Rc=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "cddebdb60de376c1bdb7a4e6ee3d98355453fe56", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1685801374, - "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-23.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "pre-commit-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "flake-utils": "flake-utils", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1692274144, - "narHash": "sha256-BxTQuRUANQ81u8DJznQyPmRsg63t4Yc+0kcyq6OLz8s=", - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "rev": "7e3517c03d46159fdbf8c0e5c97f82d5d4b0c8fa", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "type": "github" - } - }, - "root": { - "inputs": { - "devenv": "devenv", - "nixpkgs": "nixpkgs", - "pre-commit-hooks": "pre-commit-hooks" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/devenv.nix b/devenv.nix deleted file mode 100644 index c7c8e73..0000000 --- a/devenv.nix +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Alexander Wood -# SPDX-License-Identifier: AGPL-3.0-or-later -{ pkgs, ... }: - -{ - - packages = with pkgs; [ git pre-commit checkstyle nixfmt ]; - - languages.nix.enable = true; - languages.java.enable = true; - languages.java.gradle.enable = true; - -} diff --git a/devenv.yaml b/devenv.yaml deleted file mode 100644 index e02a749..0000000 --- a/devenv.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Alexander Wood -# SPDX-License-Identifier: AGPL-3.0-or-later -inputs: - nixpkgs: - url: github:NixOS/nixpkgs/nixpkgs-unstable From 977c53a0114bfecc0d8a9a67a96dddc262e15f1b Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 11:02:58 +0100 Subject: [PATCH 36/95] use a more simple nix flake --- .reuse/dep5 | 21 ------------ REUSE.toml | 16 +++++++++ flake.lock | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 62 ++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 21 deletions(-) delete mode 100644 .reuse/dep5 create mode 100644 REUSE.toml create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.reuse/dep5 b/.reuse/dep5 deleted file mode 100644 index ac76fcf..0000000 --- a/.reuse/dep5 +++ /dev/null @@ -1,21 +0,0 @@ -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: codosseum-backend -Upstream-Contact: JohnnyJayJay -Source: https://github.com/codosseum-org/backend - -# Sample paragraph, commented out: -# -# Files: src/* -# Copyright: $YEAR $NAME <$CONTACT> -# License: ... - -Files: .github/workflows/* -Copyright: JohnnyJayJay -License: CC0-1.0 - -Files: - gradle/* - gradlew - gradlew.bat -Copyright: Gradle, Inc. -License: Apache-2.0 diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..53c02f1 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,16 @@ +version = 1 +SPDX-PackageName = "codosseum-backend" +SPDX-PackageSupplier = "JohnnyJayJay " +SPDX-PackageDownloadLocation = "https://github.com/codosseum-org/backend" + +[[annotations]] +path = ".github/workflows/**" +precedence = "aggregate" +SPDX-FileCopyrightText = "JohnnyJayJay" +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["gradle/**", "gradlew", "gradlew.bat"] +precedence = "aggregate" +SPDX-FileCopyrightText = "Gradle, Inc." +SPDX-License-Identifier = "Apache-2.0" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..eed65d6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,96 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1759362264, + "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1759735786, + "narHash": "sha256-a0+h02lyP2KwSNrZz4wLJTu9ikujNsTWIC874Bv7IJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20c4598c84a671783f741e02bf05cbfaf4907cff", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1754340878, + "narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "cab778239e705082fe97bb4990e0d24c50924c04", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1758728421, + "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..92e21bf --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "Codosseum Backend Development Flake"; + + inputs = { + flake-parts.url = "github:hercules-ci/flake-parts"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + }; + + nixConfig = { + extra-trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + "numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=" + ]; + extra-substituters = [ + "https://cache.nixos.org" + "https://nix-community.cachix.org/" + "https://numtide.cachix.org" + ]; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.treefmt-nix.flakeModule + ]; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + "x86_64-darwin" + ]; + perSystem = + { + config, + self', + inputs', + pkgs, + system, + ... + }: + { + treefmt = { + flakeCheck = true; + flakeFormatter = true; + + programs.nixfmt.enable = true; + }; + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + pre-commit + reuse + checkstyle + ]; + }; + }; + flake = { + }; + }; +} From 6e6f605a38067b24145a5273d594b8c567d02b11 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 11:05:46 +0100 Subject: [PATCH 37/95] update pre-commit-config to use latest hook versions --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 631f183..45e3313 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,17 +5,17 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/python-openapi/openapi-spec-validator - rev: 0.5.5 # The version to use or 'master' for latest + rev: 0.7.2 hooks: - id: openapi-spec-validator - repo: https://github.com/fsfe/reuse-tool - rev: v1.0.0 + rev: v6.1.2 hooks: - id: reuse From ecc643069f37cb240c2cbbf45513ef3f2c10147c Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 11:17:57 +0100 Subject: [PATCH 38/95] make everything REUSE compliant --- .envrc | 6 ++++-- flake.lock.license | 2 ++ flake.nix | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 flake.lock.license diff --git a/.envrc b/.envrc index 9bee7a3..e40abe2 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,5 @@ -# SPDX-FileCopyrightText: 2023 Alexander Wood +# SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) +# # SPDX-License-Identifier: AGPL-3.0-or-later -use flake . \ No newline at end of file + +use flake . diff --git a/flake.lock.license b/flake.lock.license new file mode 100644 index 0000000..4fdbaad --- /dev/null +++ b/flake.lock.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/flake.nix b/flake.nix index 92e21bf..e8de626 100644 --- a/flake.nix +++ b/flake.nix @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + { description = "Codosseum Backend Development Flake"; From 28c908d7d70a59b761023be670e2e66ffc4e35b3 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 11:21:46 +0100 Subject: [PATCH 39/95] update reuse-action version --- .github/workflows/compliance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 1114582..0356dfa 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -15,7 +15,7 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: REUSE Compliance check - uses: fsfe/reuse-action@v2 + uses: fsfe/reuse-action@v6.0.0 openapi: runs-on: ubuntu-latest steps: From 3746e57ea686dde13c79ff691f5f27474131ec9e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 12:05:58 +0100 Subject: [PATCH 40/95] update checkstyle version and config to latest google style guide --- build.gradle.kts | 4 +- config/checkstyle/google_checks.xml | 767 ++++++++++++++++------------ 2 files changed, 436 insertions(+), 335 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9903840..d8afa8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,7 +58,7 @@ java { } checkstyle { - toolVersion = "10.12.2" + toolVersion = "11.1.0" configFile = configDirectory.file("google_checks.xml").get().asFile sourceSets = emptySet() } @@ -102,4 +102,4 @@ micronaut { tasks.named("test") { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/config/checkstyle/google_checks.xml b/config/checkstyle/google_checks.xml index 1728dc8..fee396f 100644 --- a/config/checkstyle/google_checks.xml +++ b/config/checkstyle/google_checks.xml @@ -4,8 +4,8 @@ SPDX-FileCopyrightText: Max Vetrenko, Ruslan Diachenko, Roman Ivanov. SPDX-License-Identifier: LGPL-2.1-or-later --> + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> - - + - + - - - - - - - - - - - - - - - - - + + + + + + - - - - - + - - - - - - - - - - - - - - - - + + + + - - - + + + + + + - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + - - - - - + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From a7d4997671c638914b25fe72523b5a3742c20fe3 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 12:39:05 +0100 Subject: [PATCH 41/95] checkstyle: allow longer line lengths in javadocs --- config/checkstyle/google_checks.xml | 2 +- .../developerden/codosseum/Application.java | 17 +- .../codosseum/auth/GameKeyTokenValidator.java | 23 +- .../validation/SpdxIdConstraintValidator.java | 41 ++- .../codosseum/controller/GameController.java | 255 +++++++++--------- .../controller/PlayerController.java | 77 +++--- .../controller/binder/GameParam.java | 24 +- .../controller/binder/GameParamBinder.java | 94 ++++--- .../codosseum/dto/GameCreateRequest.java | 41 ++- .../codosseum/dto/GameCreateResponse.java | 18 +- .../developerden/codosseum/dto/GameInfo.java | 20 +- .../codosseum/dto/GameSettings.java | 70 ++--- .../codosseum/dto/PlayerRoundResult.java | 51 ++-- .../developerden/codosseum/dto/Players.java | 28 +- .../codosseum/event/PlayerJoinEvent.java | 17 +- .../exception/IllegalGameStateException.java | 17 +- .../codosseum/mode/FastestGameMode.java | 53 ++-- .../developerden/codosseum/mode/GameMode.java | 37 ++- .../codosseum/mode/GameModeFactoryImpl.java | 36 ++- .../codosseum/mode/GameModeSerializer.java | 43 ++- .../developerden/codosseum/model/Game.java | 28 +- .../codosseum/model/GamePhase.java | 41 ++- .../codosseum/model/GamePlayers.java | 21 +- .../codosseum/model/GameState.java | 24 +- .../codosseum/model/player/GamePlayer.java | 16 +- .../codosseum/repository/GameRepository.java | 22 +- .../repository/InMemoryGameRepository.java | 35 ++- .../codosseum/service/GameService.java | 202 +++++++------- .../codosseum/service/game/GameAggregate.java | 152 ++++++----- .../service/game/GameAggregateFactory.java | 21 +- .../game/GameAggregateFactoryImpl.java | 26 +- .../codosseum/service/game/GameCommand.java | 43 +-- .../codosseum/service/game/GameRunner.java | 86 +++--- .../service/game/GameRunnerFactory.java | 42 ++- .../service/game/GameRunnerRegistry.java | 45 ++-- .../service/game/event/EventMapper.java | 63 +++-- .../service/game/event/EventSink.java | 18 +- .../service/game/event/InternalGameEvent.java | 31 ++- .../service/game/event/SseEventSink.java | 88 +++--- .../DefaultInitialGameStateProvider.java | 33 ++- .../game/state/InMemorySnapshotStore.java | 67 +++-- .../game/state/InitialGameStateProvider.java | 21 +- .../service/game/state/SnapshotStore.java | 27 +- .../codosseum/utils/CollectionUtils.java | 29 +- 44 files changed, 1238 insertions(+), 907 deletions(-) diff --git a/config/checkstyle/google_checks.xml b/config/checkstyle/google_checks.xml index fee396f..45af060 100644 --- a/config/checkstyle/google_checks.xml +++ b/config/checkstyle/google_checks.xml @@ -60,7 +60,7 @@ SPDX-License-Identifier: LGPL-2.1-or-later + value="^package.*|^import.*|href\s*=\s*"[^"]*"|http://|https://|ftp://| * "/> diff --git a/src/main/java/org/developerden/codosseum/Application.java b/src/main/java/org/developerden/codosseum/Application.java index 49ba3ab..8286634 100644 --- a/src/main/java/org/developerden/codosseum/Application.java +++ b/src/main/java/org/developerden/codosseum/Application.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum; @@ -29,7 +26,7 @@ ) public class Application { - public static void main(String[] args) { + static void main(String[] args) { Micronaut.run(Application.class, args); } } diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java index 168df96..75131b4 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; @@ -27,9 +24,9 @@ @Singleton public class GameKeyTokenValidator implements TokenValidator> { - @Override - public Publisher validateToken(String token, @Nullable HttpRequest request) { - return Mono.empty(); + @Override + public Publisher validateToken(String token, @Nullable HttpRequest request) { + return Mono.empty(); - } + } } diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java index 04d1b9f..8328caa 100644 --- a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java +++ b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxIdConstraintValidator.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.challenge.validation; @@ -28,19 +25,19 @@ public class SpdxIdConstraintValidator implements ConstraintValidator { - @Override - public boolean isValid(@Nullable String value, - @NonNull AnnotationValue annotationMetadata, - @NonNull ConstraintValidatorContext context) { - if (value == null) { - return true; - } + @Override + public boolean isValid(@Nullable String value, + @NonNull AnnotationValue annotationMetadata, + @NonNull ConstraintValidatorContext context) { + if (value == null) { + return true; + } - try { - LicenseInfoFactory.parseSPDXLicenseString(value); - } catch (InvalidLicenseStringException e) { - return false; - } - return true; + try { + LicenseInfoFactory.parseSPDXLicenseString(value); + } catch (InvalidLicenseStringException e) { + return false; } + return true; + } } diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 277b801..724afa7 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.controller; @@ -20,7 +17,15 @@ import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Patch; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.annotation.QueryValue; import io.micronaut.http.sse.Event; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; @@ -34,6 +39,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; import jakarta.validation.Valid; +import java.net.URI; +import java.security.Principal; +import java.util.Optional; +import java.util.UUID; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; import org.developerden.codosseum.dto.GameCreateRequest; @@ -45,127 +54,125 @@ import org.developerden.codosseum.service.game.event.SseEventSink; import org.reactivestreams.Publisher; -import java.net.URI; -import java.security.Principal; -import java.util.Optional; -import java.util.UUID; - @Validated @Controller("/games") @Secured(SecurityRule.IS_AUTHENTICATED) public class GameController { - private final GameService gameService; - - private final SseEventSink eventSink; - - public GameController(GameService gameService, SseEventSink eventSink) { - this.gameService = gameService; - this.eventSink = eventSink; - } - - @Post - @Secured(SecurityRule.IS_ANONYMOUS) - @ApiResponse( - responseCode = "201", - description = "new game created", - content = @Content(), - headers = {@Header( - name = "Location", - description = "URL of the newly created game", - required = true, - schema = @Schema( - type = "string", - format = "uri-reference" - ) - )} - ) - public HttpResponse createGame(@Valid @Body GameCreateRequest request) { - GameCreateResponse response = gameService.createGame(request); - return HttpResponse.created(response, URI.create(response.id().toString())); - } - - @Get("/{id}") - @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getGame(@PathVariable("id") UUID gameId) { - Optional game = gameService.getGame(gameId); - if (game.isEmpty()) { - return HttpResponse.notFound(); - } - return HttpResponse.ok(game.get()); + private final GameService gameService; + + private final SseEventSink eventSink; + + public GameController(GameService gameService, SseEventSink eventSink) { + this.gameService = gameService; + this.eventSink = eventSink; + } + + @Post + @Secured(SecurityRule.IS_ANONYMOUS) + @ApiResponse( + responseCode = "201", + description = "new game created", + content = @Content(), + headers = @Header( + name = "Location", + description = "URL of the newly created game", + required = true, + schema = @Schema( + type = "string", + format = "uri-reference" + ) + ) + ) + public HttpResponse createGame(@Valid @Body GameCreateRequest request) { + GameCreateResponse response = gameService.createGame(request); + return HttpResponse.created(response, URI.create(response.id().toString())); + } + + @Get("/{id}") + @Secured(SecurityRule.IS_ANONYMOUS) + public HttpResponse getGame(@PathVariable("id") UUID gameId) { + Optional game = gameService.getGame(gameId); + if (game.isEmpty()) { + return HttpResponse.notFound(); } - - @Patch("/{id}") - @GameAuthorized(GameRole.ADMIN) - public HttpResponse updateGame( - Principal principal, - @PathVariable("id") String gameId, - @Valid @Body GameSettings settings - ) { - return HttpResponse.ok(gameService.updateGame(gameId, settings)); + return HttpResponse.ok(game.get()); + } + + @Patch("/{id}") + @GameAuthorized(GameRole.ADMIN) + public HttpResponse updateGame( + Principal principal, + @PathVariable("id") String gameId, + @Valid @Body GameSettings settings + ) { + return HttpResponse.ok(gameService.updateGame(gameId, settings)); + } + + @Delete("/{id}") + @GameAuthorized(GameRole.ADMIN) + public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { + gameService.deleteGame(gameId); + return HttpResponse.noContent(); + } + + @Post("/{id}/start") + @GameAuthorized(GameRole.ADMIN) + @Operation(operationId = "startGame", + summary = "Start a game", + description = "Forcefully start a game, regardless of player-count and warmup time") + @ApiResponse( + responseCode = "204", + description = "Successfully started the game. " + + "Further info will be received via server-sent events." + ) + @ApiResponse( + responseCode = "409", + description = "Game is already running or finished" + ) + + public HttpResponse startGame(Principal principal, @PathVariable("id") UUID gameId) { + try { + gameService.startGame(gameId); + } catch (IllegalStateException e) { + return HttpResponse.status(HttpStatus.CONFLICT); } - @Delete("/{id}") - @GameAuthorized(GameRole.ADMIN) - public HttpResponse deleteGame(Principal principal, @PathVariable("id") String gameId) { - gameService.deleteGame(gameId); - return HttpResponse.noContent(); - } - - @Post("/{id}/start") - @GameAuthorized(GameRole.ADMIN) - @Operation(operationId = "startGame", summary = "Start a game", description = "Forcefully start a game, regardless of player-count and warmup time") - @ApiResponse( - responseCode = "204", - description = "Successfully started the game. Further info will be received via server-sent events." - ) - @ApiResponse( - responseCode = "409", - description = "Game is already running or finished" - ) - - public HttpResponse startGame(Principal principal, @PathVariable("id") UUID gameId) { - try { - gameService.startGame(gameId); - } catch (IllegalStateException e) { - return HttpResponse.status(HttpStatus.CONFLICT); - } - - return HttpResponse.noContent(); - } - - @Get("/{id}/template") - @GameAuthorized(GameRole.PLAYER) - @Produces(MediaType.TEXT_PLAIN) - public HttpResponse getCodeTemplate( - Principal principal, - @PathVariable("id") String gameId, - // add custom validation annotation here - @QueryValue("lang") String language - ) { - return HttpResponse.ok(gameService.getTemplate(gameId, language)); - } - - @Post("/{id}/restart") - @GameAuthorized(GameRole.PLAYER) - public HttpResponse restartGame( - Principal principal, - @PathVariable("id") String gameId, - @Valid @Body GameSettings settings - ) { - GameCreateResponse response = gameService.restartGame(gameId); - return HttpResponse.created(response, URI.create(response.id().toString())); - } - - @ExecuteOn(TaskExecutors.IO) - @Get("/{id}/events") - @Produces(MediaType.TEXT_EVENT_STREAM) - @Secured(SecurityRule.IS_ANONYMOUS) - public Publisher> subscribeToGameEvents( - @Nullable Principal principal, - @PathVariable("id") UUID gameId - ) { - return eventSink.subscribeToPublicSSE(gameId); - } + return HttpResponse.noContent(); + } + + @Get("/{id}/template") + @GameAuthorized(GameRole.PLAYER) + @Produces(MediaType.TEXT_PLAIN) + public HttpResponse getCodeTemplate( + Principal principal, + @PathVariable("id") String gameId, + // add custom validation annotation here + @QueryValue("lang") String language + ) { + return HttpResponse.ok(gameService.getTemplate(gameId, language)); + } + + @Post("/{id}/restart") + @GameAuthorized(GameRole.PLAYER) + public HttpResponse restartGame( + Principal principal, + @PathVariable("id") String gameId, + @Valid @Body GameSettings settings + ) { + GameCreateResponse response = gameService.restartGame(gameId); + return HttpResponse.created(response, URI.create(response.id().toString())); + } + + @ExecuteOn(TaskExecutors.IO) + @Get("/{id}/events") + @Produces(MediaType.TEXT_EVENT_STREAM) + @Secured(SecurityRule.IS_ANONYMOUS) + public Publisher> subscribeToGameEvents( + @Nullable Principal principal, + @PathVariable("id") UUID gameId + ) { + return eventSink.subscribeToPublicSse(gameId); + } } diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index 78e65a6..f28358b 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -1,30 +1,33 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.controller; -import io.micronaut.context.ApplicationContext; import io.micronaut.http.HttpResponse; -import io.micronaut.http.annotation.*; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; +import java.security.Principal; +import java.util.UUID; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; import org.developerden.codosseum.controller.binder.GameParam; @@ -34,43 +37,41 @@ import org.developerden.codosseum.model.Game; import org.developerden.codosseum.service.GameService; -import java.security.Principal; -import java.util.UUID; - @Validated @Controller("/games/{id}/players") @Secured(SecurityRule.IS_AUTHENTICATED) public class PlayerController { - private final GameService gameService; + private final GameService gameService; - public PlayerController(GameService gameService) { - this.gameService = gameService; - } + public PlayerController(GameService gameService) { + this.gameService = gameService; + } - @Get - @Secured(SecurityRule.IS_ANONYMOUS) - public HttpResponse getPlayers(@PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game) { - // TODO this route seems very redundant and unhelpful - return gameService.getGame(game.id()) - .map(g -> HttpResponse.ok(g.players())) - .orElse(HttpResponse.notFound()); - } + @Get + @Secured(SecurityRule.IS_ANONYMOUS) + public HttpResponse getPlayers(@PathVariable("id") UUID id, + @Parameter(hidden = true) @GameParam Game game) { + // TODO: this route seems very redundant and unhelpful + return gameService.getGame(game.id()) + .map(g -> HttpResponse.ok(g.players())) + .orElse(HttpResponse.notFound()); + } - @Post - public HttpResponse joinGame( - @PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game, - @Valid @Body Player player - ) { + @Post + public HttpResponse joinGame( + @PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game, + @Valid @Body Player player + ) { - return HttpResponse.notFound(); - } + return HttpResponse.notFound(); + } - @Delete("/@self") - @GameAuthorized(GameRole.PLAYER) - public HttpResponse leaveGame(Principal principal, @PathVariable("id") Game game) { - throw new UnsupportedOperationException(); - } + @Delete("/@self") + @GameAuthorized(GameRole.PLAYER) + public HttpResponse leaveGame(Principal principal, @PathVariable("id") Game game) { + throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java b/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java index 4a1f5a1..b9eea7e 100644 --- a/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java +++ b/src/main/java/org/developerden/codosseum/controller/binder/GameParam.java @@ -1,10 +1,28 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.controller.binder; -import java.lang.annotation.*; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) public @interface GameParam { - String value() default "id"; // The path variable name containing the UUID -} \ No newline at end of file + String value() default "id"; // The path variable name containing the UUID +} diff --git a/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java b/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java index c26090d..882dd61 100644 --- a/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java +++ b/src/main/java/org/developerden/codosseum/controller/binder/GameParamBinder.java @@ -1,3 +1,17 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.controller.binder; import io.micronaut.core.bind.ArgumentBinder; @@ -9,60 +23,58 @@ import io.micronaut.http.exceptions.HttpStatusException; import io.micronaut.web.router.UriRouteMatch; import jakarta.inject.Singleton; -import org.developerden.codosseum.model.Game; -import org.developerden.codosseum.repository.GameRepository; - import java.util.Optional; import java.util.UUID; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.repository.GameRepository; @Singleton public class GameParamBinder implements TypedRequestArgumentBinder { - private final GameRepository gameRepository; + private final GameRepository gameRepository; - public GameParamBinder(GameRepository gameRepository) { - this.gameRepository = gameRepository; - } - - @Override - public Argument argumentType() { - return Argument.of(Game.class); - } + public GameParamBinder(GameRepository gameRepository) { + this.gameRepository = gameRepository; + } - @Override - public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { - // Check if the parameter has @GameParam annotation - if (!context.getArgument().isAnnotationPresent(GameParam.class)) { - return ArgumentBinder.BindingResult.UNSATISFIED; - } + @Override + public Argument argumentType() { + return Argument.of(Game.class); + } - // Get the path variable name from the annotation - String pathVariableName = context.getArgument() - .getAnnotation(GameParam.class) - .stringValue() - .orElse("id"); + @Override + public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + // Check if the parameter has @GameParam annotation + if (!context.getArgument().isAnnotationPresent(GameParam.class)) { + return ArgumentBinder.BindingResult.UNSATISFIED; + } - // Extract the UUID from path variables using getParameters() - String uuidStr = (String) source.getAttribute("micronaut.http.route.match", UriRouteMatch.class) - .map(m -> m.getVariableValues().get(pathVariableName)) - .filter(x -> x instanceof String).orElse(null); + // Get the path variable name from the annotation + String pathVariableName = context.getArgument() + .getAnnotation(GameParam.class) + .stringValue() + .orElse("id"); + // Extract the UUID from path variables using getParameters() + String uuidStr = (String) source.getAttribute("micronaut.http.route.match", UriRouteMatch.class) + .map(m -> m.getVariableValues().get(pathVariableName)) + .filter(x -> x instanceof String).orElse(null); - if (uuidStr == null) { - return ArgumentBinder.BindingResult.UNSATISFIED; - } + if (uuidStr == null) { + return ArgumentBinder.BindingResult.UNSATISFIED; + } - try { - UUID gameId = UUID.fromString(uuidStr); - Optional game = gameRepository.findGameById(gameId); + try { + UUID gameId = UUID.fromString(uuidStr); + Optional game = gameRepository.findGameById(gameId); - if (game.isEmpty()) { - throw new HttpStatusException(HttpStatus.NOT_FOUND, "Game not found"); - } + if (game.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Game not found"); + } - return () -> game; - } catch (IllegalArgumentException e) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid game ID format"); - } + return () -> game; + } catch (IllegalArgumentException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid game ID format"); } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java index f9c27fa..e7eb50d 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateRequest.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -25,20 +22,20 @@ @Introspected @Serdeable @Schema( - description = "Settings for the new game" + description = "Settings for the new game" ) public record GameCreateRequest( - @NotNull - GameSettings settings, - @NotNull - @Schema(description = """ - Player info of the person creating the game. - Without special authorisation, this must not be omitted and the provided - player will join the newly created game automatically. - - In the future, there might be a mechanism for technical users to create games without joining them, - where this property is optional. - """) - Player player + @NotNull + GameSettings settings, + @NotNull + @Schema(description = """ + Player info of the person creating the game. + Without special authorisation, this must not be omitted and the provided + player will join the newly created game automatically. + + In the future, there might be a mechanism for technical users to create games without joining them, + where this property is optional. + """) + Player player ) { } diff --git a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java index 2412142..78fce21 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/GameCreateResponse.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -20,9 +17,8 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.validation.constraints.NotNull; - -import javax.annotation.Nonnull; import java.util.UUID; +import javax.annotation.Nonnull; @RecordBuilder @Serdeable diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index 1732a62..2d23d2b 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -21,11 +18,10 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import org.developerden.codosseum.mode.GameMode; -import org.developerden.codosseum.model.GamePhase; - import java.util.List; import java.util.UUID; +import org.developerden.codosseum.mode.GameMode; +import org.developerden.codosseum.model.GamePhase; @RecordBuilder @Serdeable diff --git a/src/main/java/org/developerden/codosseum/dto/GameSettings.java b/src/main/java/org/developerden/codosseum/dto/GameSettings.java index 4fd3bea..aac26e4 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameSettings.java +++ b/src/main/java/org/developerden/codosseum/dto/GameSettings.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -24,43 +21,48 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import org.developerden.codosseum.mode.GameModeType; - import java.util.List; +import org.developerden.codosseum.mode.GameModeType; @Introspected @RecordBuilder @Serdeable @Schema( - description = "Settings for a Codosseum game" + description = "Settings for a Codosseum game" ) public record GameSettings( - // custom validator for elements - @Nullable - @Schema(description = "Which programming languages are allowed to be used for submissions. If omitted, all languages are allowed.") - List allowedLanguages, + // custom validator for elements + @Nullable + @Schema(description = """ + Which programming languages are allowed to be used for submissions. + If omitted, all languages are allowed.""") + List allowedLanguages, - @Nullable - @Schema(description = "Game modes from which the server will make a random selection. If omitted, all game modes are allowed") - List allowedGameModes, + @Nullable + @Schema(description = "Game modes from which the server will make a random selection. " + + "If omitted, all game modes are allowed") + List allowedGameModes, - @Nullable - @Min(2) - @Max(50) - @Schema(description = "Maximum player count for the game") - Integer maxPlayers, + @Nullable + @Min(2) + @Max(50) + @Schema(description = "Maximum player count for the game") + Integer maxPlayers, - @Nullable - @Min(60) - @Max(1800) - @Schema(description = "Time limit for the game, in seconds.") - Integer timeLimit, + @Nullable + @Min(60) + @Max(1800) + @Schema(description = "Time limit for the game, in seconds.") + Integer timeLimit, - @Nullable - @Min(0) - @Max(300) - @Schema(description = "Maximum warmup time for a game in seconds - that is, a timer that starts once 2 or more players have joined the game that delays the starting of the game to allow more players to join.") - Integer maxWarmupTime + @Nullable + @Min(0) + @Max(300) + @Schema(description = + """ + Maximum warmup time for a game in seconds - that is, a timer that starts once 2 or more players have joined the game + that delays the starting of the game to allow more players to join.""") + Integer maxWarmupTime ) { } diff --git a/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java b/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java index f5a7636..061ce46 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayerRoundResult.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -23,31 +20,31 @@ @RecordBuilder public record PlayerRoundResult( - @Nonnull Player player, - @Nonnull - String name, + @Nonnull Player player, + @Nonnull + String name, - @Nonnull - String language, + @Nonnull + String language, - @Nullable - String code, + @Nullable + String code, - int byteCount, + int byteCount, - int timeLeft, + int timeLeft, - @Nullable - Score score + @Nullable + Score score ) { - @RecordBuilder - public record Score( - int testsRun, - int testsPassed, - double averageRuntime - ) { + @RecordBuilder + public record Score( + int testsRun, + int testsPassed, + double averageRuntime + ) { - } + } } diff --git a/src/main/java/org/developerden/codosseum/dto/Players.java b/src/main/java/org/developerden/codosseum/dto/Players.java index 25d5761..0bee54b 100644 --- a/src/main/java/org/developerden/codosseum/dto/Players.java +++ b/src/main/java/org/developerden/codosseum/dto/Players.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; @@ -20,21 +17,20 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; - import java.util.Set; @RecordBuilder() @RecordBuilder.Options( - useImmutableCollections = true, - addSingleItemCollectionBuilders = true + useImmutableCollections = true, + addSingleItemCollectionBuilders = true ) @Serdeable public record Players( - @Nonnull - Set players, + @Nonnull + Set players, - @Nonnull - Player admin + @Nonnull + Player admin ) { } diff --git a/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java b/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java index 7811fda..57b8cb7 100644 --- a/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java +++ b/src/main/java/org/developerden/codosseum/event/PlayerJoinEvent.java @@ -1,8 +1,21 @@ -package org.developerden.codosseum.event; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.dto.Player; +package org.developerden.codosseum.event; import java.util.UUID; +import org.developerden.codosseum.dto.Player; public record PlayerJoinEvent(UUID gameId, Player player) implements GameEvent { } diff --git a/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java index a8b16b6..fe6f715 100644 --- a/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java +++ b/src/main/java/org/developerden/codosseum/exception/IllegalGameStateException.java @@ -1,25 +1,22 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.exception; + import java.util.Set; import java.util.stream.Collectors; - import org.developerden.codosseum.model.GamePhase; public class IllegalGameStateException extends RuntimeException { diff --git a/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java b/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java index bc5b6bd..786cba2 100644 --- a/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java +++ b/src/main/java/org/developerden/codosseum/mode/FastestGameMode.java @@ -1,31 +1,44 @@ -package org.developerden.codosseum.mode; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.dto.Player; -import org.developerden.codosseum.dto.PlayerRoundResult; +package org.developerden.codosseum.mode; import java.util.Comparator; import java.util.List; import java.util.Optional; +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.dto.PlayerRoundResult; public class FastestGameMode implements GameMode { - @Override - public GameModeType getType() { - return GameModeType.FASTEST; - } + @Override + public GameModeType getType() { + return GameModeType.FASTEST; + } - @Override - public double computeScore(Player player, List results) { - var orderedScores = results.stream() - .sorted(Comparator.comparing(PlayerRoundResult::timeLeft).reversed()) - .toList(); + @Override + public double computeScore(Player player, List results) { + var orderedScores = results.stream() + .sorted(Comparator.comparing(PlayerRoundResult::timeLeft).reversed()) + .toList(); - // find the result of the player - Optional any = results.stream() - .filter(res -> res.player().equals(player)) - .findAny(); + // find the result of the player + Optional any = results.stream() + .filter(res -> res.player().equals(player)) + .findAny(); - // warn? - return any.map(playerRoundResult -> orderedScores.indexOf(playerRoundResult) + 1.0) - .orElse(0.0); - } + // warn? + return any.map(playerRoundResult -> orderedScores.indexOf(playerRoundResult) + 1.0) + .orElse(0.0); + } } diff --git a/src/main/java/org/developerden/codosseum/mode/GameMode.java b/src/main/java/org/developerden/codosseum/mode/GameMode.java index bc10fbd..0f5f68b 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameMode.java +++ b/src/main/java/org/developerden/codosseum/mode/GameMode.java @@ -1,39 +1,36 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.mode; +import java.util.List; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.PlayerRoundResult; -import java.util.List; - // should have a serialiser/deserialiser using the game mode name and the available // implementations on the classpath public interface GameMode { - GameModeType getType(); + GameModeType getType(); - /** - * Compute the score for the given player and results. - * The results are all the player's results for the current round. - * @param player - * @param results - * @return a score between 0 and 100 (inclusive) - */ - double computeScore(Player player, List results); + /** + * Compute the score for the given player and results. + * The results are all the player's results for the current round. + * + * @param player the player to compute the score for + * @param results all player's results for the current round + * @return a score between 0 and 100 (inclusive) + */ + double computeScore(Player player, List results); } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java b/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java index b0ec935..90a0d89 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeFactoryImpl.java @@ -1,19 +1,33 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.mode; import jakarta.inject.Singleton; @Singleton public class GameModeFactoryImpl implements GameModeFactory { - @Override - public GameMode initForGame(String gameId) { - return null; - } + @Override + public GameMode initForGame(String gameId) { + return null; + } - @Override - public GameMode fromType(GameModeType type) { - return switch (type) { - case FASTEST -> new FastestGameMode(); - default -> throw new UnsupportedOperationException("Unsupported game mode type: " + type); - }; - } + @Override + public GameMode fromType(GameModeType type) { + return switch (type) { + case FASTEST -> new FastestGameMode(); + default -> throw new UnsupportedOperationException("Unsupported game mode type: " + type); + }; + } } diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java b/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java index ca37cff..08b2e3d 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeSerializer.java @@ -1,3 +1,17 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.mode; import io.micronaut.core.annotation.NonNull; @@ -8,24 +22,27 @@ import io.micronaut.serde.Serde; import jakarta.inject.Inject; import jakarta.inject.Singleton; - import java.io.IOException; @Singleton public class GameModeSerializer implements Serde { - private final @Inject GameModeFactory gameModeFactory; + private final @Inject GameModeFactory gameModeFactory; - public GameModeSerializer(GameModeFactory gameModeFactory) { - this.gameModeFactory = gameModeFactory; - } + public GameModeSerializer(GameModeFactory gameModeFactory) { + this.gameModeFactory = gameModeFactory; + } - @Override - public @Nullable GameMode deserialize(@NonNull Decoder decoder, @NonNull DecoderContext context, @NonNull Argument type) throws IOException { - return gameModeFactory.fromType(GameModeType.valueOf(decoder.decodeString().toUpperCase())); - } + @Override + public @Nullable GameMode deserialize(@NonNull Decoder decoder, @NonNull DecoderContext context, + @NonNull Argument type) + throws IOException { + return gameModeFactory.fromType(GameModeType.valueOf(decoder.decodeString().toUpperCase())); + } - @Override - public void serialize(@NonNull Encoder encoder, @NonNull EncoderContext context, @NonNull Argument type, @NonNull GameMode value) throws IOException { - encoder.encodeString(value.getType().name().toLowerCase()); - } + @Override + public void serialize(@NonNull Encoder encoder, @NonNull EncoderContext context, + @NonNull Argument type, @NonNull GameMode value) + throws IOException { + encoder.encodeString(value.getType().name().toLowerCase()); + } } diff --git a/src/main/java/org/developerden/codosseum/model/Game.java b/src/main/java/org/developerden/codosseum/model/Game.java index 80ec3e2..1635187 100644 --- a/src/main/java/org/developerden/codosseum/model/Game.java +++ b/src/main/java/org/developerden/codosseum/model/Game.java @@ -1,36 +1,32 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; +import java.util.UUID; import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.mode.GameMode; -import java.util.UUID; - // TODO: 19/08/23 internal model @RecordBuilder public record Game( - UUID id, - String adminKey, - GameSettings settings, - Players players, - GameMode mode + UUID id, + String adminKey, + GameSettings settings, + Players players, + GameMode mode ) { } diff --git a/src/main/java/org/developerden/codosseum/model/GamePhase.java b/src/main/java/org/developerden/codosseum/model/GamePhase.java index 5596360..c72016c 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePhase.java +++ b/src/main/java/org/developerden/codosseum/model/GamePhase.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.model; @@ -20,17 +17,19 @@ import io.swagger.v3.oas.annotations.media.Schema; public enum GamePhase { - @Schema(description = "The game phase is not defined, usually indicating an error state. Clients should generally not have to handle this state.") - UNDEFINED, - @Schema(description = "The game has not yet started and is waiting for more players before it can begin") - WAITING_FOR_PLAYERS, - @Schema(description = "The game is in a warmup phase and is ready to begin") - WARMUP, - @Schema(description = "The game is currently in progress") - IN_PROGRESS, - @Schema(description = "The current round of the game is over") - ROUND_OVER, - @Schema(description = "The game has ended") - GAME_OVER + @Schema(description = """ + The game phase is not defined, usually indicating an error state. + Clients should generally not have to handle this state.""") + UNDEFINED, + @Schema(description = "The game has not yet started and is waiting for more players before it can begin") + WAITING_FOR_PLAYERS, + @Schema(description = "The game is in a warmup phase and is ready to begin") + WARMUP, + @Schema(description = "The game is currently in progress") + IN_PROGRESS, + @Schema(description = "The current round of the game is over") + ROUND_OVER, + @Schema(description = "The game has ended") + GAME_OVER } diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java index f3bcea9..c0b6a49 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePlayers.java +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -1,9 +1,22 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; -import org.developerden.codosseum.model.player.GamePlayer; - import java.util.Set; +import org.developerden.codosseum.model.player.GamePlayer; /** * Internal model representing the players in a game. @@ -13,8 +26,8 @@ */ @RecordBuilder() @RecordBuilder.Options( - useImmutableCollections = true, - addSingleItemCollectionBuilders = true + useImmutableCollections = true, + addSingleItemCollectionBuilders = true ) public record GamePlayers(GamePlayer admin, Set others) { } diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 3bec596..1add7bc 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -1,20 +1,32 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; -import org.developerden.codosseum.dto.Players; -import org.developerden.codosseum.model.player.GamePlayer; - import java.util.UUID; /** * Internal model representing the current state of a game. - * @param gameId the unique identifier of the game - * @param phase the current phase of the game + * + * @param gameId the unique identifier of the game + * @param phase the current phase of the game * @param players the players involved in the game */ @RecordBuilder public record GameState(UUID gameId, GamePhase phase, GamePlayers players - ) { +) { } diff --git a/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java index 7870c68..3fec16c 100644 --- a/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java @@ -1,5 +1,19 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.model.player; public sealed interface GamePlayer permits EphemeralPlayer { - String name(); + String name(); } diff --git a/src/main/java/org/developerden/codosseum/repository/GameRepository.java b/src/main/java/org/developerden/codosseum/repository/GameRepository.java index f1aad1b..a1188dd 100644 --- a/src/main/java/org/developerden/codosseum/repository/GameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/GameRepository.java @@ -1,31 +1,27 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.repository; -import org.developerden.codosseum.model.Game; - import java.util.Optional; import java.util.UUID; +import org.developerden.codosseum.model.Game; public interface GameRepository { - Optional findGameById(UUID id); + Optional findGameById(UUID id); - void insertGame(Game game); + void insertGame(Game game); } diff --git a/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java index 97d6b92..c99af3a 100644 --- a/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/InMemoryGameRepository.java @@ -1,24 +1,37 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.repository; import jakarta.inject.Singleton; -import org.developerden.codosseum.model.Game; - import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import org.developerden.codosseum.model.Game; @Singleton public class InMemoryGameRepository implements GameRepository { - private final Map games = new ConcurrentHashMap<>(); + private final Map games = new ConcurrentHashMap<>(); - @Override - public Optional findGameById(UUID id) { - return Optional.ofNullable(games.get(id)); - } + @Override + public Optional findGameById(UUID id) { + return Optional.ofNullable(games.get(id)); + } - @Override - public void insertGame(Game game) { - games.put(game.id(), game); - } + @Override + public void insertGame(Game game) { + games.put(game.id(), game); + } } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 9ae5cf0..57362d9 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.service; @@ -20,7 +17,17 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.validation.Valid; -import org.developerden.codosseum.dto.*; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Optional; +import java.util.UUID; +import org.developerden.codosseum.dto.GameCreateRequest; +import org.developerden.codosseum.dto.GameCreateResponse; +import org.developerden.codosseum.dto.GameInfo; +import org.developerden.codosseum.dto.GameSettings; +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.mode.GameModeFactory; import org.developerden.codosseum.mode.GameModeType; import org.developerden.codosseum.model.Game; @@ -34,112 +41,111 @@ import org.developerden.codosseum.service.game.state.SnapshotStore; import org.developerden.codosseum.utils.CollectionUtils; -import java.nio.channels.FileChannel; -import java.util.*; - @Singleton public class GameService { - private final GameRepository gameRepository; - private final GameModeFactory gameModeFactory; - private final GameRunnerRegistry gameRunnerRegistry; - private final SnapshotStore snapshotStore; - - private final EventSink eventSink; - - public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, EventSink eventSink) { - this.gameRepository = gameRepository; - this.gameModeFactory = gameModeFactory; - this.gameRunnerRegistry = gameRunnerRegistry; - this.snapshotStore = snapshotStore; - this.eventSink = eventSink; + private final GameRepository gameRepository; + private final GameModeFactory gameModeFactory; + private final GameRunnerRegistry gameRunnerRegistry; + private final SnapshotStore snapshotStore; + + private final EventSink eventSink; + + public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, + GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, + EventSink eventSink) { + this.gameRepository = gameRepository; + this.gameModeFactory = gameModeFactory; + this.gameRunnerRegistry = gameRunnerRegistry; + this.snapshotStore = snapshotStore; + this.eventSink = eventSink; + } + + private String generateAdminKey() { + return UUID.randomUUID().toString(); + } + + public GameCreateResponse createGame(GameCreateRequest request) { + var gameModeTypes = EnumSet.allOf(GameModeType.class); + if (Optional.ofNullable(request.settings().allowedGameModes()).map(modes -> !modes.isEmpty()) + .orElse(false)) { + gameModeTypes.retainAll(request.settings().allowedGameModes()); } + var gameModeType = CollectionUtils.pickRandom(gameModeTypes); + var gameMode = gameModeFactory.fromType(gameModeType); - private String generateAdminKey() { - return UUID.randomUUID().toString(); - } + var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new Players( + new HashSet<>(), + request.player() + ), gameMode); - public GameCreateResponse createGame(GameCreateRequest request) { - var gameModeTypes = EnumSet.allOf(GameModeType.class); - if (Optional.ofNullable(request.settings().allowedGameModes()).map(modes -> !modes.isEmpty()) - .orElse(false)) { - gameModeTypes.retainAll(request.settings().allowedGameModes()); - } - var gameModeType = CollectionUtils.pickRandom(gameModeTypes); - var gameMode = gameModeFactory.fromType(gameModeType); + gameRepository.insertGame(game); - var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new Players( - new HashSet<>(), - request.player() - ), gameMode); + gameRunnerRegistry.getOrCreate(game.id()) + .tell(new GameCommand.CreateGame(game.id())); - gameRepository.insertGame(game); + return new GameCreateResponse(game.adminKey(), game.id()); + } - gameRunnerRegistry.getOrCreate(game.id()) - .tell(new GameCommand.CreateGame(game.id())); + public GameInfo updateGame(String gameId, GameSettings settings) { + throw new UnsupportedOperationException(); + } - return new GameCreateResponse(game.adminKey(), game.id()); - } + public void deleteGame(String gameId) { + throw new UnsupportedOperationException(); + } - public GameInfo updateGame(String gameId, GameSettings settings) { - throw new UnsupportedOperationException(); - } + public Optional getGame(UUID id) { + var gameOpt = gameRepository.findGameById(id); - public void deleteGame(String gameId) { - throw new UnsupportedOperationException(); + if (gameOpt.isEmpty()) { + return Optional.empty(); } - public Optional getGame(UUID id) { - var gameOpt = gameRepository.findGameById(id); - - if (gameOpt.isEmpty()) { - return Optional.empty(); - } - - var stateOpt = gameRunnerRegistry - .find(id) - .map(GameRunner::getCurrentState) - .or(() -> snapshotStore.load(id)); - - var phase = stateOpt.map(GameState::phase) - .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); - - return gameOpt - .map(game -> new GameInfo( - game.settings(), - game.id(), - game.mode(), - game.players(), - phase, - 0, - 0, - 1, - new ArrayList<>() - )); + var stateOpt = gameRunnerRegistry + .find(id) + .map(GameRunner::getCurrentState) + .or(() -> snapshotStore.load(id)); + + var phase = stateOpt.map(GameState::phase) + .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); + + return gameOpt + .map(game -> new GameInfo( + game.settings(), + game.id(), + game.mode(), + game.players(), + phase, + 0, + 0, + 1, + new ArrayList<>() + )); + } + + public void startGame(UUID gameId) { + GameRunner runner = gameRunnerRegistry.getOrCreate(gameId); + if (runner.getCurrentState().phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Game is already running or finished"); } - public void startGame(UUID gameId) { - GameRunner runner = gameRunnerRegistry.getOrCreate(gameId); - if (runner.getCurrentState().phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException("Game is already running or finished"); - } + runner.tell(new GameCommand.StartGame(gameId)); + } - runner.tell(new GameCommand.StartGame(gameId)); - } + public String getTemplate(String gameId, String lang) { + throw new UnsupportedOperationException(); + } - public String getTemplate(String gameId, String lang) { - throw new UnsupportedOperationException(); - } + public void initiateNextRound(String gameId) { + throw new UnsupportedOperationException(); + } - public void initiateNextRound(String gameId) { - throw new UnsupportedOperationException(); - } + public GameCreateResponse restartGame(String gameId) { + throw new UnsupportedOperationException(); + } - public GameCreateResponse restartGame(String gameId) { - throw new UnsupportedOperationException(); - } - - public Optional addPlayer(UUID id, @Valid Player player) { - return null; - } + public Optional addPlayer(UUID id, @Valid Player player) { + return null; + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 0e05e44..ee14a26 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -1,95 +1,111 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game; +import java.util.List; +import java.util.UUID; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GamePlayersBuilder; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; import org.developerden.codosseum.service.game.event.InternalGameEvent; -import java.util.List; -import java.util.UUID; - public class GameAggregate { - private final UUID gameId; + private final UUID gameId; - private final GameState gameState; + private final GameState gameState; - public GameAggregate(UUID gameId, GameState gameState) { - this.gameId = gameId; - this.gameState = gameState; - } + public GameAggregate(UUID gameId, GameState gameState) { + this.gameId = gameId; + this.gameState = gameState; + } - public GameState getGameState() { - return gameState; - } + public GameState getGameState() { + return gameState; + } + + public Result handle(GameCommand cmd) { + var events = decide(cmd); + var newState = applyAll(gameState, events); - public Result handle(GameCommand cmd) { - var events = decide(cmd); - var newState = applyAll(gameState, events); + var next = new GameAggregate(gameId, newState); - var next = new GameAggregate(gameId, newState); + return new Result(events, next); + } - return new Result(events, next); + private List decide(GameCommand cmd) { + if (!cmd.gameId().equals(gameId)) { + throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); } - private List decide(GameCommand cmd) { - if (!cmd.gameId().equals(gameId)) { - throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); + return switch (cmd) { + case GameCommand.CreateGame(var id) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException( + "Cannot create game that is not in WAITING_FOR_PLAYERS phase"); + } else { + yield List.of(new InternalGameEvent.GameCreated(gameId)); } + } + case GameCommand.StartGame(var id) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException( + "Cannot start game that is not in WAITING_FOR_PLAYERS phase"); + } else { + // TODO: check if enough players and actually do something + yield List.of(); + } + } + case GameCommand.AddPlayer(var id, var player) -> { + if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException( + "Cannot join game that is not in WAITING_FOR_PLAYERS phase"); + } else { + yield List.of(new InternalGameEvent.PlayerJoined(gameId, player)); + } + } - return switch (cmd) { - case GameCommand.CreateGame(var id) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException("Cannot create game that is not in WAITING_FOR_PLAYERS phase"); - } else { - yield List.of(new InternalGameEvent.GameCreated(gameId)); - } - } - case GameCommand.StartGame(var id) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException("Cannot start game that is not in WAITING_FOR_PLAYERS phase"); - } else { - // TODO check if enough players and actually do something - yield List.of(); - } - } - case GameCommand.AddPlayer(var id, var player) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException("Cannot join game that is not in WAITING_FOR_PLAYERS phase"); - } else { - yield List.of(new InternalGameEvent.PlayerJoined(gameId, player)); - } - } - - }; - } + }; + } - private GameState applyAll(GameState state, List events) { - var newState = state; - for (var event : events) { - newState = apply(newState, event); - } - return newState; + private GameState applyAll(GameState state, List events) { + var newState = state; + for (var event : events) { + newState = apply(newState, event); } + return newState; + } - private GameState apply(GameState state, InternalGameEvent event) { - if (!event.gameId().equals(gameId)) { - throw new IllegalArgumentException("Event gameId does not match aggregate gameId"); - } - return switch (event) { - case InternalGameEvent.PlayerJoined(var gameId, var player) -> GameStateBuilder.from(state) - .withPlayers( - GamePlayersBuilder.builder(state.players()) - .addOthers(player) - .build() - ); - case InternalGameEvent.GameCreated(var gameId) -> state; // no-op for now - }; - + private GameState apply(GameState state, InternalGameEvent event) { + if (!event.gameId().equals(gameId)) { + throw new IllegalArgumentException("Event gameId does not match aggregate gameId"); } + return switch (event) { + case InternalGameEvent.PlayerJoined(var gameId, var player) -> GameStateBuilder.from(state) + .withPlayers( + GamePlayersBuilder.builder(state.players()) + .addOthers(player) + .build() + ); + case InternalGameEvent.GameCreated(var gameId) -> state; // no-op for now + }; + } - public record Result(List events, GameAggregate next) { - } + + public record Result(List events, GameAggregate next) { + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java index 939eb0f..949b5cf 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactory.java @@ -1,9 +1,22 @@ -package org.developerden.codosseum.service.game; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.model.GameState; +package org.developerden.codosseum.service.game; import java.util.UUID; +import org.developerden.codosseum.model.GameState; public interface GameAggregateFactory { - GameAggregate create(UUID gameId, GameState snapshot); -} \ No newline at end of file + GameAggregate create(UUID gameId, GameState snapshot); +} diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java index 97a9ed9..5fd55d7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java @@ -1,15 +1,27 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game; import jakarta.inject.Singleton; -import org.developerden.codosseum.model.Game; -import org.developerden.codosseum.model.GameState; - import java.util.UUID; +import org.developerden.codosseum.model.GameState; @Singleton public class GameAggregateFactoryImpl implements GameAggregateFactory { - @Override - public GameAggregate create(UUID gameId, GameState snapshot) { - return new GameAggregate(gameId, snapshot); - } + @Override + public GameAggregate create(UUID gameId, GameState snapshot) { + return new GameAggregate(gameId, snapshot); + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index cd1b20e..290a8be 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -1,29 +1,42 @@ -package org.developerden.codosseum.service.game; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.dto.Player; -import org.developerden.codosseum.model.player.GamePlayer; +package org.developerden.codosseum.service.game; import java.util.UUID; +import org.developerden.codosseum.model.player.GamePlayer; /** - * A command that can be executed on a {@link org.developerden.codosseum.model.Game} + * A command that can be executed on a {@link org.developerden.codosseum.model.Game}. */ public sealed interface GameCommand { - UUID gameId(); + UUID gameId(); - record CreateGame(UUID gameId) implements GameCommand { - } + record CreateGame(UUID gameId) implements GameCommand { + } - /** - * Command to start a game - * @param gameId the id of the game to start - */ - record StartGame(UUID gameId) implements GameCommand { - } + /** + * Command to start a game. + * + * @param gameId the id of the game to start + */ + record StartGame(UUID gameId) implements GameCommand { + } - record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { - } + record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java index c575eaa..c20c471 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java @@ -1,56 +1,70 @@ -package org.developerden.codosseum.service.game; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.model.GameState; -import org.developerden.codosseum.service.game.event.EventSink; -import org.developerden.codosseum.service.game.state.SnapshotStore; +package org.developerden.codosseum.service.game; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.service.game.event.EventSink; +import org.developerden.codosseum.service.game.state.SnapshotStore; public class GameRunner { - private final UUID gameId; + private final UUID gameId; - private final ExecutorService loop; - private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - private final EventSink eventSink; - private final SnapshotStore snapshotStore; - private volatile GameAggregate gameAggregate; + private final ExecutorService loop; + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final EventSink eventSink; + private final SnapshotStore snapshotStore; + private volatile GameAggregate gameAggregate; - public GameRunner(UUID gameId, EventSink eventSink, SnapshotStore snapshotStore, GameAggregateFactory aggregateFactory) { - this.gameId = gameId; - this.eventSink = eventSink; - this.snapshotStore = snapshotStore; + public GameRunner(UUID gameId, EventSink eventSink, SnapshotStore snapshotStore, + GameAggregateFactory aggregateFactory) { + this.gameId = gameId; + this.eventSink = eventSink; + this.snapshotStore = snapshotStore; - this.loop = Executors.newSingleThreadExecutor(r -> new Thread(r, "game-" + gameId)); + this.loop = Executors.newSingleThreadExecutor(r -> new Thread(r, "game-" + gameId)); - GameState snapshot = this.snapshotStore.load(gameId) - .orElseGet(() -> this.snapshotStore.createInitial(gameId)); - this.gameAggregate = aggregateFactory.create(gameId, snapshot); - } + GameState snapshot = this.snapshotStore.load(gameId) + .orElseGet(() -> this.snapshotStore.createInitial(gameId)); + this.gameAggregate = aggregateFactory.create(gameId, snapshot); + } - public void tell(GameCommand cmd) { - loop.execute(() -> handle(cmd)); - } + public void tell(GameCommand cmd) { + loop.execute(() -> handle(cmd)); + } - public GameState getCurrentState() { - return gameAggregate.getGameState(); - } + public GameState getCurrentState() { + return gameAggregate.getGameState(); + } - private void handle(GameCommand cmd) { - var result = gameAggregate.handle(cmd); - this.gameAggregate = result.next(); - for (var event : result.events()) { - eventSink.publish(event); - } - snapshotStore.save(gameId, gameAggregate.getGameState()); + private void handle(GameCommand cmd) { + var result = gameAggregate.handle(cmd); + this.gameAggregate = result.next(); + for (var event : result.events()) { + eventSink.publish(event); } + snapshotStore.save(gameId, gameAggregate.getGameState()); + } - public void shutdown() { - scheduler.shutdownNow(); - loop.shutdownNow(); - } + public void shutdown() { + scheduler.shutdownNow(); + loop.shutdownNow(); + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java index afad92a..714ef24 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java @@ -1,27 +1,41 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import java.util.UUID; import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; -import java.util.UUID; - @Singleton public class GameRunnerFactory { - private final EventSink eventSink; - private final SnapshotStore snapshotStore; - private final GameAggregateFactory aggregateFactory; + private final EventSink eventSink; + private final SnapshotStore snapshotStore; + private final GameAggregateFactory aggregateFactory; - @Inject - public GameRunnerFactory(EventSink eventSink, SnapshotStore snapshotStore, GameAggregateFactory aggregateFactory) { - this.eventSink = eventSink; - this.snapshotStore = snapshotStore; - this.aggregateFactory = aggregateFactory; - } + @Inject + public GameRunnerFactory(EventSink eventSink, SnapshotStore snapshotStore, + GameAggregateFactory aggregateFactory) { + this.eventSink = eventSink; + this.snapshotStore = snapshotStore; + this.aggregateFactory = aggregateFactory; + } - public GameRunner create(UUID gameId) { - return new GameRunner(gameId, eventSink, snapshotStore, aggregateFactory); - } + public GameRunner create(UUID gameId) { + return new GameRunner(gameId, eventSink, snapshotStore, aggregateFactory); + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java index 6b08e06..168f099 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java @@ -1,7 +1,20 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game; import jakarta.inject.Singleton; - import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -9,24 +22,26 @@ @Singleton public class GameRunnerRegistry { - private final GameRunnerFactory factory; - private final Map runners = new ConcurrentHashMap<>(); + private final GameRunnerFactory factory; + private final Map runners = new ConcurrentHashMap<>(); - public GameRunnerRegistry(GameRunnerFactory factory) { - this.factory = factory; - } + public GameRunnerRegistry(GameRunnerFactory factory) { + this.factory = factory; + } - public GameRunner getOrCreate(UUID gameId) { - return runners.computeIfAbsent(gameId, factory::create); - } + public GameRunner getOrCreate(UUID gameId) { + return runners.computeIfAbsent(gameId, factory::create); + } - public Optional find(UUID gameId) { - return Optional.ofNullable(runners.get(gameId)); - } + public Optional find(UUID gameId) { + return Optional.ofNullable(runners.get(gameId)); + } - public void stop(UUID gameId) { - var r = runners.remove(gameId); - if (r != null) r.shutdown(); + public void stop(UUID gameId) { + var r = runners.remove(gameId); + if (r != null) { + r.shutdown(); } + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 38d7ad6..2cbd20f 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -1,7 +1,23 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.event; import jakarta.annotation.Nonnull; import jakarta.inject.Singleton; +import java.util.Optional; +import java.util.stream.Collectors; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.event.GameEvent; @@ -10,35 +26,32 @@ import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.model.player.GamePlayer; -import java.util.Optional; -import java.util.stream.Collectors; - @Singleton public class EventMapper { - public Optional fromInternal(InternalGameEvent internalEvent) { - return switch (internalEvent) { - case InternalGameEvent.GameCreated ignored -> Optional.empty(); + public Optional fromInternal(InternalGameEvent internalEvent) { + return switch (internalEvent) { + case InternalGameEvent.GameCreated ignored -> Optional.empty(); - case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( - new PlayerJoinEvent(playerJoined.gameId(), fromGamePlayer(playerJoined.player())) - ); - }; - } + case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( + new PlayerJoinEvent(playerJoined.gameId(), fromGamePlayer(playerJoined.player())) + ); + }; + } - private Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { - var players = new Players( - gamePlayers.others().stream() - .map(this::fromGamePlayer) - .collect(Collectors.toSet()), - fromGamePlayer(gamePlayers.admin()) - ); - return Optional.of(players); - } + private Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { + var players = new Players( + gamePlayers.others().stream() + .map(this::fromGamePlayer) + .collect(Collectors.toSet()), + fromGamePlayer(gamePlayers.admin()) + ); + return Optional.of(players); + } - private Player fromGamePlayer(@Nonnull GamePlayer player) { - return switch (player) { - case EphemeralPlayer(var name, var ignored, var ignored2) -> (new Player(name)); - }; - } + private Player fromGamePlayer(@Nonnull GamePlayer player) { + return switch (player) { + case EphemeralPlayer(var name, var ignored, var ignored2) -> (new Player(name)); + }; + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java index 5ecf116..b53b36f 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventSink.java @@ -1,5 +1,19 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.event; public interface EventSink { - void publish(InternalGameEvent event); -} \ No newline at end of file + void publish(InternalGameEvent event); +} diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index eb9c973..41cd295 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -1,14 +1,25 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.event; -import org.developerden.codosseum.dto.Player; +import java.util.UUID; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; -import org.developerden.codosseum.model.GamePlayers; import org.developerden.codosseum.model.player.GamePlayer; import org.developerden.codosseum.service.game.GameCommand; -import java.util.UUID; - /// An event that happened to a [Game] /// This is the internal version of [GameEvent], and is used for internal messaging. /// @@ -16,12 +27,12 @@ /// /// See also [GameCommand] public sealed interface InternalGameEvent { - UUID gameId(); + UUID gameId(); - /// Emitted when a new game is created and the lobby is opened - record GameCreated(UUID gameId) implements InternalGameEvent { - } + /// Emitted when a new game is created and the lobby is opened + record GameCreated(UUID gameId) implements InternalGameEvent { + } - record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { - } + record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java index e7d2e13..5fc2e7c 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -1,8 +1,25 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.event; import io.micronaut.http.sse.Event; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import org.developerden.codosseum.event.GameEvent; import org.reactivestreams.Publisher; import org.slf4j.Logger; @@ -10,48 +27,47 @@ import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - @Singleton public class SseEventSink implements EventSink { - private final Map> sinks = new ConcurrentHashMap<>(); - private final EventMapper eventMapper; - private final Logger logger = LoggerFactory.getLogger(SseEventSink.class); + private final Map> sinks = new ConcurrentHashMap<>(); + private final EventMapper eventMapper; + private final Logger logger = LoggerFactory.getLogger(SseEventSink.class); - @Inject - public SseEventSink(EventMapper eventMapper) { - this.eventMapper = eventMapper; - } + @Inject + public SseEventSink(EventMapper eventMapper) { + this.eventMapper = eventMapper; + } - @Override - public void publish(InternalGameEvent event) { - UUID gameId = event.gameId(); - logger.atInfo().log("Publishing event {} for game {}", event, gameId); - var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); - sink.tryEmitNext(event); - } + @Override + public void publish(InternalGameEvent event) { + UUID gameId = event.gameId(); + logger.atInfo().log("Publishing event {} for game {}", event, gameId); + var sink = sinks.computeIfAbsent(gameId, + ignored -> Sinks.many().multicast().onBackpressureBuffer()); + sink.tryEmitNext(event); + } - public Publisher> subscribeToSse(UUID gameId) { - var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); - return sink.asFlux().map(event -> Event.of(event).name(event.getClass().getSimpleName())); - } + public Publisher> subscribeToSse(UUID gameId) { + var sink = sinks.computeIfAbsent(gameId, + ignored -> Sinks.many().multicast().onBackpressureBuffer()); + return sink.asFlux().map(event -> Event.of(event).name(event.getClass().getSimpleName())); + } - public Publisher> subscribeToPublicSSE(UUID gameId) { - var sink = sinks.computeIfAbsent(gameId, __ -> Sinks.many().multicast().onBackpressureBuffer()); - return sink.asFlux() - .flatMap(e -> eventMapper.fromInternal(e) - .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) - .map(Mono::just) - .orElse(Mono.empty()) - ); - } + public Publisher> subscribeToPublicSse(UUID gameId) { + var sink = sinks.computeIfAbsent(gameId, + ignored -> Sinks.many().multicast().onBackpressureBuffer()); + return sink.asFlux() + .flatMap(e -> eventMapper.fromInternal(e) + .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) + .map(Mono::just) + .orElse(Mono.empty()) + ); + } - public void close(UUID gameId) { - var sink = sinks.remove(gameId); - if (sink != null) { - sink.tryEmitComplete(); - } + public void close(UUID gameId) { + var sink = sinks.remove(gameId); + if (sink != null) { + sink.tryEmitComplete(); } + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java index fe455bb..3d08e4f 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java @@ -1,22 +1,35 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.state; import jakarta.inject.Singleton; +import java.util.UUID; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; -import java.util.UUID; - /** * Default initial state: lobby waiting for players. */ @Singleton public class DefaultInitialGameStateProvider implements InitialGameStateProvider { - @Override - public GameState create(UUID gameId) { - return GameStateBuilder.builder() - .gameId(gameId) - .phase(GamePhase.WAITING_FOR_PLAYERS) - .build(); - } -} \ No newline at end of file + @Override + public GameState create(UUID gameId) { + return GameStateBuilder.builder() + .gameId(gameId) + .phase(GamePhase.WAITING_FOR_PLAYERS) + .build(); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java index 28579cf..d1e1a5e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/InMemorySnapshotStore.java @@ -1,37 +1,50 @@ + +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.service.game.state; import jakarta.inject.Singleton; -import org.developerden.codosseum.model.GameState; - import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.function.Function; +import org.developerden.codosseum.model.GameState; @Singleton public final class InMemorySnapshotStore implements SnapshotStore { - private final ConcurrentMap store = new ConcurrentHashMap<>(); - private final InitialGameStateProvider initialProvider; - - public InMemorySnapshotStore(InitialGameStateProvider initialProvider) { - this.initialProvider = initialProvider; - } - - @Override - public Optional load(UUID gameId) { - return Optional.ofNullable(store.get(gameId)); - } - - @Override - public void save(UUID gameId, GameState state) { - store.put(gameId, state); - } - - @Override - public GameState createInitial(UUID gameId) { - var state = initialProvider.create(gameId); - store.put(gameId, state); - return state; - } -} \ No newline at end of file + private final ConcurrentMap store = new ConcurrentHashMap<>(); + private final InitialGameStateProvider initialProvider; + + public InMemorySnapshotStore(InitialGameStateProvider initialProvider) { + this.initialProvider = initialProvider; + } + + @Override + public Optional load(UUID gameId) { + return Optional.ofNullable(store.get(gameId)); + } + + @Override + public void save(UUID gameId, GameState state) { + store.put(gameId, state); + } + + @Override + public GameState createInitial(UUID gameId) { + var state = initialProvider.create(gameId); + store.put(gameId, state); + return state; + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java index 6351357..779093e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/InitialGameStateProvider.java @@ -1,13 +1,26 @@ -package org.developerden.codosseum.service.game.state; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.model.GameState; +package org.developerden.codosseum.service.game.state; import java.util.UUID; +import org.developerden.codosseum.model.GameState; /** * Produces the initial GameState for a given game id. */ @FunctionalInterface public interface InitialGameStateProvider { - GameState create(UUID gameId); -} \ No newline at end of file + GameState create(UUID gameId); +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java index d23c964..a0bac80 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java @@ -1,16 +1,31 @@ -package org.developerden.codosseum.service.game.state; +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ -import org.developerden.codosseum.model.GameState; +package org.developerden.codosseum.service.game.state; import java.util.Optional; import java.util.UUID; +import org.developerden.codosseum.model.GameState; /** * Persistence boundary for GameState snapshots. * Pure IO: load/save by gameId. No domain logic or timers here. */ public interface SnapshotStore { - Optional load(UUID gameId); - void save(UUID gameId, GameState state); - GameState createInitial(UUID gameId); -} \ No newline at end of file + Optional load(UUID gameId); + + void save(UUID gameId, GameState state); + + GameState createInitial(UUID gameId); +} diff --git a/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java b/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java index 0151c50..8ad5d39 100644 --- a/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java +++ b/src/main/java/org/developerden/codosseum/utils/CollectionUtils.java @@ -1,16 +1,29 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.utils; import jakarta.validation.constraints.NotEmpty; - import java.util.Collection; public class CollectionUtils { - public static E pickRandom(@NotEmpty Collection collection) { - if (collection.isEmpty()) { - throw new IllegalArgumentException("Collection is empty"); - } - - return collection.stream().skip((int) (Math.random() * collection.size())).findFirst() - .orElseThrow(); + public static E pickRandom(@NotEmpty Collection collection) { + if (collection.isEmpty()) { + throw new IllegalArgumentException("Collection is empty"); } + + return collection.stream().skip((int) (Math.random() * collection.size())).findFirst() + .orElseThrow(); + } } From 2c87f2da1992a2dfb08650e6d3738921573948f2 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 12:39:27 +0100 Subject: [PATCH 42/95] make all code REUSE compliant --- openapi.properties | 5 ++- .../codosseum/auth/GameAuthorizationRule.java | 15 ++++---- .../codosseum/auth/GameAuthorized.java | 15 ++++---- .../codosseum/auth/GameKeyTokenReader.java | 15 ++++---- .../developerden/codosseum/auth/GameRole.java | 15 ++++---- .../codosseum/auth/PlayerAuthentication.java | 15 ++++---- .../codosseum/auth/package-info.java | 15 ++++---- .../codosseum/challenge/Challenge.java | 15 ++++---- .../challenge/input/package-info.java | 15 ++++---- .../codosseum/challenge/package-info.java | 15 ++++---- .../challenge/template/package-info.java | 15 ++++---- .../challenge/validation/SpdxId.java | 15 ++++---- .../challenge/validation/package-info.java | 15 ++++---- .../codosseum/config/ChallengeDirectory.java | 15 ++++---- .../codosseum/config/ChallengeRepository.java | 15 ++++---- .../codosseum/config/ChallengeSource.java | 15 ++++---- .../codosseum/config/CodosseumConfig.java | 15 ++++---- .../codosseum/config/package-info.java | 15 ++++---- .../codosseum/controller/InfoController.java | 15 ++++---- .../codosseum/controller/RoundController.java | 15 ++++---- .../controller/SolutionController.java | 15 ++++---- .../codosseum/controller/package-info.java | 15 ++++---- .../codosseum/dto/ChallengeInfo.java | 15 ++++---- .../codosseum/dto/GameJoinResponse.java | 15 ++++---- .../developerden/codosseum/dto/Player.java | 15 ++++---- .../codosseum/dto/PlayerGameResult.java | 15 ++++---- .../org/developerden/codosseum/dto/Round.java | 15 ++++---- .../codosseum/dto/Submission.java | 15 ++++---- .../codosseum/dto/TestResponse.java | 15 ++++---- .../codosseum/dto/TestResult.java | 15 ++++---- .../codosseum/dto/package-info.java | 15 ++++---- .../codosseum/event/EliminatedEvent.java | 15 ++++---- .../codosseum/event/GameEvent.java | 15 ++++---- .../codosseum/event/GameOverEvent.java | 15 ++++---- .../event/PlayerCodeRevealEvent.java | 15 ++++---- .../codosseum/event/PlayerSubmitEvent.java | 15 ++++---- .../codosseum/event/RoundStartEvent.java | 15 ++++---- .../codosseum/event/SyncEvent.java | 15 ++++---- .../codosseum/event/TestResultEvent.java | 15 ++++---- .../codosseum/event/package-info.java | 15 ++++---- .../exception/GameNotFoundException.java | 15 ++++---- .../codosseum/execution/package-info.java | 15 ++++---- .../codosseum/mode/GameModeFactory.java | 15 ++++---- .../codosseum/mode/GameModeType.java | 14 ++++++++ .../codosseum/mode/package-info.java | 15 ++++---- .../codosseum/model/package-info.java | 15 ++++---- .../model/player/EphemeralPlayer.java | 15 ++++---- .../codosseum/repository/AuthRepository.java | 15 ++++---- .../codosseum/repository/package-info.java | 15 ++++---- .../codosseum/service/EventService.java | 15 ++++---- .../codosseum/service/SubmissionService.java | 15 ++++---- .../codosseum/service/package-info.java | 15 ++++---- src/main/resources/logback.xml | 31 +++++++---------- .../codosseum/OpenApiExposedTest.java | 34 ++++++++++++++----- .../codosseum/OpenApiGeneratedTest.java | 32 ++++++++++++----- .../codosseum/SwaggerUiGeneratedTest.java | 33 +++++++++++++----- .../developerden/codosseum/SwaggerUiTest.java | 34 ++++++++++++++----- 57 files changed, 430 insertions(+), 503 deletions(-) diff --git a/openapi.properties b/openapi.properties index 0f25672..b758198 100644 --- a/openapi.properties +++ b/openapi.properties @@ -1 +1,4 @@ -swagger-ui.enabled=true \ No newline at end of file +# SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) +# +# SPDX-License-Identifier: AGPL-3.0-or-later +swagger-ui.enabled=true diff --git a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java index 77bb244..5a619e8 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java +++ b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; diff --git a/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java b/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java index 45581a2..2dbd97b 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java +++ b/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java index b8fc3e9..7c0982e 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; diff --git a/src/main/java/org/developerden/codosseum/auth/GameRole.java b/src/main/java/org/developerden/codosseum/auth/GameRole.java index c7bc648..f4256f8 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameRole.java +++ b/src/main/java/org/developerden/codosseum/auth/GameRole.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; diff --git a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java index 5f7b4c3..e1ac830 100644 --- a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java +++ b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.auth; diff --git a/src/main/java/org/developerden/codosseum/auth/package-info.java b/src/main/java/org/developerden/codosseum/auth/package-info.java index fdfe0b0..48c58b5 100644 --- a/src/main/java/org/developerden/codosseum/auth/package-info.java +++ b/src/main/java/org/developerden/codosseum/auth/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/challenge/Challenge.java b/src/main/java/org/developerden/codosseum/challenge/Challenge.java index c433875..507ae0e 100644 --- a/src/main/java/org/developerden/codosseum/challenge/Challenge.java +++ b/src/main/java/org/developerden/codosseum/challenge/Challenge.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.challenge; diff --git a/src/main/java/org/developerden/codosseum/challenge/input/package-info.java b/src/main/java/org/developerden/codosseum/challenge/input/package-info.java index d625135..33049b2 100644 --- a/src/main/java/org/developerden/codosseum/challenge/input/package-info.java +++ b/src/main/java/org/developerden/codosseum/challenge/input/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/challenge/package-info.java b/src/main/java/org/developerden/codosseum/challenge/package-info.java index 0151cb5..2a48cf9 100644 --- a/src/main/java/org/developerden/codosseum/challenge/package-info.java +++ b/src/main/java/org/developerden/codosseum/challenge/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/challenge/template/package-info.java b/src/main/java/org/developerden/codosseum/challenge/template/package-info.java index 480d34b..91740be 100644 --- a/src/main/java/org/developerden/codosseum/challenge/template/package-info.java +++ b/src/main/java/org/developerden/codosseum/challenge/template/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java index 515e620..e2a6110 100644 --- a/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java +++ b/src/main/java/org/developerden/codosseum/challenge/validation/SpdxId.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.challenge.validation; diff --git a/src/main/java/org/developerden/codosseum/challenge/validation/package-info.java b/src/main/java/org/developerden/codosseum/challenge/validation/package-info.java index f9aafbe..436f16f 100644 --- a/src/main/java/org/developerden/codosseum/challenge/validation/package-info.java +++ b/src/main/java/org/developerden/codosseum/challenge/validation/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java b/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java index ce5698d..2091b19 100644 --- a/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java +++ b/src/main/java/org/developerden/codosseum/config/ChallengeDirectory.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.config; diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java b/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java index f6acdca..d861f4b 100644 --- a/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java +++ b/src/main/java/org/developerden/codosseum/config/ChallengeRepository.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.config; diff --git a/src/main/java/org/developerden/codosseum/config/ChallengeSource.java b/src/main/java/org/developerden/codosseum/config/ChallengeSource.java index 8adcfcb..174812f 100644 --- a/src/main/java/org/developerden/codosseum/config/ChallengeSource.java +++ b/src/main/java/org/developerden/codosseum/config/ChallengeSource.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.config; diff --git a/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java b/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java index 9fdd067..c0180a4 100644 --- a/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java +++ b/src/main/java/org/developerden/codosseum/config/CodosseumConfig.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.config; diff --git a/src/main/java/org/developerden/codosseum/config/package-info.java b/src/main/java/org/developerden/codosseum/config/package-info.java index 41c92d4..82ee863 100644 --- a/src/main/java/org/developerden/codosseum/config/package-info.java +++ b/src/main/java/org/developerden/codosseum/config/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/controller/InfoController.java b/src/main/java/org/developerden/codosseum/controller/InfoController.java index c0e413a..80ef664 100644 --- a/src/main/java/org/developerden/codosseum/controller/InfoController.java +++ b/src/main/java/org/developerden/codosseum/controller/InfoController.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.controller; diff --git a/src/main/java/org/developerden/codosseum/controller/RoundController.java b/src/main/java/org/developerden/codosseum/controller/RoundController.java index f71cce8..5997eef 100644 --- a/src/main/java/org/developerden/codosseum/controller/RoundController.java +++ b/src/main/java/org/developerden/codosseum/controller/RoundController.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.controller; diff --git a/src/main/java/org/developerden/codosseum/controller/SolutionController.java b/src/main/java/org/developerden/codosseum/controller/SolutionController.java index 0ffcc30..93ecc6a 100644 --- a/src/main/java/org/developerden/codosseum/controller/SolutionController.java +++ b/src/main/java/org/developerden/codosseum/controller/SolutionController.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.controller; diff --git a/src/main/java/org/developerden/codosseum/controller/package-info.java b/src/main/java/org/developerden/codosseum/controller/package-info.java index c6f35db..c3a0022 100644 --- a/src/main/java/org/developerden/codosseum/controller/package-info.java +++ b/src/main/java/org/developerden/codosseum/controller/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/dto/ChallengeInfo.java b/src/main/java/org/developerden/codosseum/dto/ChallengeInfo.java index 99be388..4843bfb 100644 --- a/src/main/java/org/developerden/codosseum/dto/ChallengeInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/ChallengeInfo.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java b/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java index 7803054..91884fe 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/Player.java b/src/main/java/org/developerden/codosseum/dto/Player.java index 4e242a4..05d798d 100644 --- a/src/main/java/org/developerden/codosseum/dto/Player.java +++ b/src/main/java/org/developerden/codosseum/dto/Player.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java b/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java index 79badb1..543e8a8 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/Round.java b/src/main/java/org/developerden/codosseum/dto/Round.java index 3c09e62..c64a67a 100644 --- a/src/main/java/org/developerden/codosseum/dto/Round.java +++ b/src/main/java/org/developerden/codosseum/dto/Round.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/Submission.java b/src/main/java/org/developerden/codosseum/dto/Submission.java index 4d43b44..cb5c1f4 100644 --- a/src/main/java/org/developerden/codosseum/dto/Submission.java +++ b/src/main/java/org/developerden/codosseum/dto/Submission.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/TestResponse.java b/src/main/java/org/developerden/codosseum/dto/TestResponse.java index 4478dbc..b69c483 100644 --- a/src/main/java/org/developerden/codosseum/dto/TestResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/TestResponse.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/TestResult.java b/src/main/java/org/developerden/codosseum/dto/TestResult.java index 0658641..4383a55 100644 --- a/src/main/java/org/developerden/codosseum/dto/TestResult.java +++ b/src/main/java/org/developerden/codosseum/dto/TestResult.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.dto; diff --git a/src/main/java/org/developerden/codosseum/dto/package-info.java b/src/main/java/org/developerden/codosseum/dto/package-info.java index 10d0e67..77ce99b 100644 --- a/src/main/java/org/developerden/codosseum/dto/package-info.java +++ b/src/main/java/org/developerden/codosseum/dto/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/event/EliminatedEvent.java b/src/main/java/org/developerden/codosseum/event/EliminatedEvent.java index 89afafb..79b0e8e 100644 --- a/src/main/java/org/developerden/codosseum/event/EliminatedEvent.java +++ b/src/main/java/org/developerden/codosseum/event/EliminatedEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/GameEvent.java b/src/main/java/org/developerden/codosseum/event/GameEvent.java index 612aa87..2ad6a3a 100644 --- a/src/main/java/org/developerden/codosseum/event/GameEvent.java +++ b/src/main/java/org/developerden/codosseum/event/GameEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/GameOverEvent.java b/src/main/java/org/developerden/codosseum/event/GameOverEvent.java index 12ebbda..297a201 100644 --- a/src/main/java/org/developerden/codosseum/event/GameOverEvent.java +++ b/src/main/java/org/developerden/codosseum/event/GameOverEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/PlayerCodeRevealEvent.java b/src/main/java/org/developerden/codosseum/event/PlayerCodeRevealEvent.java index 200bc6d..efcd419 100644 --- a/src/main/java/org/developerden/codosseum/event/PlayerCodeRevealEvent.java +++ b/src/main/java/org/developerden/codosseum/event/PlayerCodeRevealEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/PlayerSubmitEvent.java b/src/main/java/org/developerden/codosseum/event/PlayerSubmitEvent.java index e8aeb44..ed157ab 100644 --- a/src/main/java/org/developerden/codosseum/event/PlayerSubmitEvent.java +++ b/src/main/java/org/developerden/codosseum/event/PlayerSubmitEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java index 196bd73..bdd9daa 100644 --- a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java +++ b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/SyncEvent.java b/src/main/java/org/developerden/codosseum/event/SyncEvent.java index e055655..1df63b4 100644 --- a/src/main/java/org/developerden/codosseum/event/SyncEvent.java +++ b/src/main/java/org/developerden/codosseum/event/SyncEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/TestResultEvent.java b/src/main/java/org/developerden/codosseum/event/TestResultEvent.java index 08f9447..a5e87fe 100644 --- a/src/main/java/org/developerden/codosseum/event/TestResultEvent.java +++ b/src/main/java/org/developerden/codosseum/event/TestResultEvent.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.event; diff --git a/src/main/java/org/developerden/codosseum/event/package-info.java b/src/main/java/org/developerden/codosseum/event/package-info.java index 439403a..bf84e65 100644 --- a/src/main/java/org/developerden/codosseum/event/package-info.java +++ b/src/main/java/org/developerden/codosseum/event/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java b/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java index 2dadf02..0907481 100644 --- a/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java +++ b/src/main/java/org/developerden/codosseum/exception/GameNotFoundException.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.exception; diff --git a/src/main/java/org/developerden/codosseum/execution/package-info.java b/src/main/java/org/developerden/codosseum/execution/package-info.java index 1774f20..26c4fb1 100644 --- a/src/main/java/org/developerden/codosseum/execution/package-info.java +++ b/src/main/java/org/developerden/codosseum/execution/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java index 74b9f7d..f999e89 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeFactory.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.mode; diff --git a/src/main/java/org/developerden/codosseum/mode/GameModeType.java b/src/main/java/org/developerden/codosseum/mode/GameModeType.java index 252425d..0c8d791 100644 --- a/src/main/java/org/developerden/codosseum/mode/GameModeType.java +++ b/src/main/java/org/developerden/codosseum/mode/GameModeType.java @@ -1,3 +1,17 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum.mode; public enum GameModeType { diff --git a/src/main/java/org/developerden/codosseum/mode/package-info.java b/src/main/java/org/developerden/codosseum/mode/package-info.java index 42c0513..a70cdbf 100644 --- a/src/main/java/org/developerden/codosseum/mode/package-info.java +++ b/src/main/java/org/developerden/codosseum/mode/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/model/package-info.java b/src/main/java/org/developerden/codosseum/model/package-info.java index 65296e4..677f599 100644 --- a/src/main/java/org/developerden/codosseum/model/package-info.java +++ b/src/main/java/org/developerden/codosseum/model/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index bece652..b5ba822 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.model.player; diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java index 3b4be20..a6252be 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.repository; diff --git a/src/main/java/org/developerden/codosseum/repository/package-info.java b/src/main/java/org/developerden/codosseum/repository/package-info.java index 4662569..1d66409 100644 --- a/src/main/java/org/developerden/codosseum/repository/package-info.java +++ b/src/main/java/org/developerden/codosseum/repository/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/java/org/developerden/codosseum/service/EventService.java b/src/main/java/org/developerden/codosseum/service/EventService.java index f6da885..d328cac 100644 --- a/src/main/java/org/developerden/codosseum/service/EventService.java +++ b/src/main/java/org/developerden/codosseum/service/EventService.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.service; diff --git a/src/main/java/org/developerden/codosseum/service/SubmissionService.java b/src/main/java/org/developerden/codosseum/service/SubmissionService.java index 77865bf..ebb1d58 100644 --- a/src/main/java/org/developerden/codosseum/service/SubmissionService.java +++ b/src/main/java/org/developerden/codosseum/service/SubmissionService.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ package org.developerden.codosseum.service; diff --git a/src/main/java/org/developerden/codosseum/service/package-info.java b/src/main/java/org/developerden/codosseum/service/package-info.java index d1593a8..0ed6f07 100644 --- a/src/main/java/org/developerden/codosseum/service/package-info.java +++ b/src/main/java/org/developerden/codosseum/service/package-info.java @@ -1,18 +1,15 @@ /* - * SPDX-FileCopyrightText: 2023 JohnnyJayJay - * SPDX-License-Identifier: AGPL-3.0-or-later + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later * - * This program is free software: you can redistribute it and/or modify it under the terms of - * the GNU Affero General Public License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . - * */ /** diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index d0196c7..eddba0d 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,21 +1,16 @@ - + diff --git a/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java b/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java index a59fd93..a9fd778 100644 --- a/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java +++ b/src/test/java/org/developerden/codosseum/OpenApiExposedTest.java @@ -1,19 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + import io.micronaut.http.client.BlockingHttpClient; import io.micronaut.http.client.HttpClient; import io.micronaut.http.client.annotation.Client; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -@MicronautTest +@MicronautTest class OpenApiExposedTest { - @Test - void openApi(@Client("/") HttpClient httpClient) { - BlockingHttpClient client = httpClient.toBlocking(); - assertDoesNotThrow(() -> client.exchange("/swagger/codosseum-0.0.yml")); - } -} \ No newline at end of file + @Test + void openApi(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + assertDoesNotThrow(() -> client.exchange("/swagger/codosseum-0.0.yml")); + } +} diff --git a/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java b/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java index 6271e40..d1fb756 100644 --- a/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java +++ b/src/test/java/org/developerden/codosseum/OpenApiGeneratedTest.java @@ -1,16 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum; +import static org.junit.jupiter.api.Assertions.assertTrue; + import io.micronaut.core.io.ResourceLoader; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@MicronautTest(startApplication = false) +@MicronautTest(startApplication = false) class OpenApiGeneratedTest { - @Test - void buildGeneratesOpenApi(ResourceLoader resourceLoader) { - assertTrue(resourceLoader.getResource("META-INF/swagger/codosseum-0.0.yml").isPresent()); - } -} \ No newline at end of file + @Test + void buildGeneratesOpenApi(ResourceLoader resourceLoader) { + assertTrue(resourceLoader.getResource("META-INF/swagger/codosseum-0.0.yml").isPresent()); + } +} diff --git a/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java b/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java index 00a0265..53066ed 100644 --- a/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java +++ b/src/test/java/org/developerden/codosseum/SwaggerUiGeneratedTest.java @@ -1,16 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum; +import static org.junit.jupiter.api.Assertions.assertTrue; + import io.micronaut.core.io.ResourceLoader; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@MicronautTest(startApplication = false) +@MicronautTest(startApplication = false) class SwaggerUiGeneratedTest { - @Test - void buildGeneratesOpenApi(ResourceLoader resourceLoader) { - assertTrue(resourceLoader.getResource("META-INF/swagger/views/swagger-ui/index.html").isPresent()); - } -} \ No newline at end of file + @Test + void buildGeneratesOpenApi(ResourceLoader resourceLoader) { + assertTrue( + resourceLoader.getResource("META-INF/swagger/views/swagger-ui/index.html").isPresent()); + } +} diff --git a/src/test/java/org/developerden/codosseum/SwaggerUiTest.java b/src/test/java/org/developerden/codosseum/SwaggerUiTest.java index 7931513..028212b 100644 --- a/src/test/java/org/developerden/codosseum/SwaggerUiTest.java +++ b/src/test/java/org/developerden/codosseum/SwaggerUiTest.java @@ -1,19 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of + * the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + package org.developerden.codosseum; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + import io.micronaut.http.client.BlockingHttpClient; import io.micronaut.http.client.HttpClient; import io.micronaut.http.client.annotation.Client; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -@MicronautTest +@MicronautTest class SwaggerUiTest { - @Test - void openApi(@Client("/") HttpClient httpClient) { - BlockingHttpClient client = httpClient.toBlocking(); - assertDoesNotThrow(() -> client.exchange("/swagger-ui/index.html")); - } -} \ No newline at end of file + @Test + void openApi(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + assertDoesNotThrow(() -> client.exchange("/swagger-ui/index.html")); + } +} From 84cf33daeb7ae2245822a3710a471170957330d5 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 12:42:25 +0100 Subject: [PATCH 43/95] only REUSE lint staged files in pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45e3313..9f063e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,4 +18,4 @@ repos: - repo: https://github.com/fsfe/reuse-tool rev: v6.1.2 hooks: - - id: reuse + - id: reuse-lint-file From 5e769dbc84b49cc0f629fa75bf2a7d04f24c438b Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 13:56:37 +0100 Subject: [PATCH 44/95] update RecordBuilder to latest version --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d8afa8c..afb207c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { annotationProcessor("io.micronaut.serde:micronaut-serde-processor") annotationProcessor("io.micronaut.validation:micronaut-validation-processor") annotationProcessor("io.micronaut.security:micronaut-security-annotations") - annotationProcessor("io.soabase.record-builder:record-builder-processor:37") + annotationProcessor("io.soabase.record-builder:record-builder-processor:49") implementation("io.micronaut.reactor:micronaut-reactor") implementation("io.micronaut.cache:micronaut-cache-caffeine") @@ -31,7 +31,7 @@ dependencies { implementation("io.micronaut.validation:micronaut-validation") implementation("io.micronaut.security:micronaut-security-oauth2") implementation("io.micronaut.security:micronaut-security-jwt") - implementation("io.soabase.record-builder:record-builder-core:37") + implementation("io.soabase.record-builder:record-builder-core:49") implementation("io.micronaut.serde:micronaut-serde-jackson") implementation("io.swagger.core.v3:swagger-annotations") implementation("io.micronaut:micronaut-http-client") From 0bda44fdb1e7f699ff6ed6f891c90224a9d2fa78 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 13:57:11 +0100 Subject: [PATCH 45/95] fix DefaultInitialGameStateProvider incorrectly creating GameStates --- .../service/game/state/DefaultInitialGameStateProvider.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java index 3d08e4f..6052ec1 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java @@ -15,8 +15,10 @@ package org.developerden.codosseum.service.game.state; import jakarta.inject.Singleton; +import java.util.HashSet; import java.util.UUID; import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.model.GamePlayers; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; @@ -30,6 +32,7 @@ public GameState create(UUID gameId) { return GameStateBuilder.builder() .gameId(gameId) .phase(GamePhase.WAITING_FOR_PLAYERS) + .players(new GamePlayers(null, new HashSet<>())) .build(); } } From c7aeb4a3370ea1c4e9b8c16f14ffc64ea289221b Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 14:01:34 +0100 Subject: [PATCH 46/95] make GamePlayers#admin nullable to allow no players --- .../java/org/developerden/codosseum/model/GamePlayers.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java index c0b6a49..6300488 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePlayers.java +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -16,6 +16,8 @@ import io.soabase.recordbuilder.core.RecordBuilder; import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.developerden.codosseum.model.player.GamePlayer; /** @@ -29,5 +31,5 @@ useImmutableCollections = true, addSingleItemCollectionBuilders = true ) -public record GamePlayers(GamePlayer admin, Set others) { +public record GamePlayers(@Nullable GamePlayer admin, @Nonnull Set others) { } From 9695986a5cc357d1889016ff1c1c791806a4782e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 14:08:06 +0100 Subject: [PATCH 47/95] add basic game joining logic --- .pre-commit-config.yaml | 2 + .../controller/PlayerController.java | 10 ++++- .../codosseum/dto/GameJoinResponse.java | 2 + .../developerden/codosseum/dto/Players.java | 5 +++ .../codosseum/model/GameState.java | 10 +++-- .../codosseum/service/GameService.java | 38 ++++++++++++++++++- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f063e7..8c44dc6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,3 +19,5 @@ repos: rev: v6.1.2 hooks: - id: reuse-lint-file + additional_dependencies: + - click diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayerController.java index f28358b..e859bc7 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayerController.java @@ -59,12 +59,18 @@ public HttpResponse getPlayers(@PathVariable("id") UUID id, } @Post + @Secured(SecurityRule.IS_ANONYMOUS) public HttpResponse joinGame( @PathVariable("id") UUID id, @Parameter(hidden = true) @GameParam Game game, @Valid @Body Player player ) { - - return HttpResponse.notFound(); + try { + return gameService.addPlayer(game.id(), player) + .map(HttpResponse::ok) + .orElse(HttpResponse.notFound()); + } catch (IllegalStateException e) { + return HttpResponse.status(409, e.getMessage()); + } } diff --git a/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java b/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java index 91884fe..e16f796 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java +++ b/src/main/java/org/developerden/codosseum/dto/GameJoinResponse.java @@ -14,10 +14,12 @@ package org.developerden.codosseum.dto; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; @RecordBuilder +@Serdeable public record GameJoinResponse( @Nonnull String key diff --git a/src/main/java/org/developerden/codosseum/dto/Players.java b/src/main/java/org/developerden/codosseum/dto/Players.java index 0bee54b..ccab57a 100644 --- a/src/main/java/org/developerden/codosseum/dto/Players.java +++ b/src/main/java/org/developerden/codosseum/dto/Players.java @@ -18,6 +18,7 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; import java.util.Set; +import java.util.stream.Stream; @RecordBuilder() @RecordBuilder.Options( @@ -33,4 +34,8 @@ public record Players( Player admin ) { + public Stream allPlayers() { + return Stream.concat(players.stream(), Stream.of(admin)); + } + } diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 1add7bc..6b5f550 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -15,6 +15,7 @@ package org.developerden.codosseum.model; import io.soabase.recordbuilder.core.RecordBuilder; +import jakarta.annotation.Nonnull; import java.util.UUID; /** @@ -25,8 +26,9 @@ * @param players the players involved in the game */ @RecordBuilder -public record GameState(UUID gameId, - GamePhase phase, - GamePlayers players -) { +@RecordBuilder.Options(defaultNotNull = true) +public record GameState(@Nonnull UUID gameId, + @Nonnull GamePhase phase, + @Nonnull GamePlayers players +) implements GameStateBuilder.With { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 57362d9..cc89b2e 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -25,6 +25,7 @@ import org.developerden.codosseum.dto.GameCreateRequest; import org.developerden.codosseum.dto.GameCreateResponse; import org.developerden.codosseum.dto.GameInfo; +import org.developerden.codosseum.dto.GameJoinResponse; import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.Players; @@ -33,6 +34,7 @@ import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunner; @@ -145,7 +147,39 @@ public GameCreateResponse restartGame(String gameId) { throw new UnsupportedOperationException(); } - public Optional addPlayer(UUID id, @Valid Player player) { - return null; + public Optional addPlayer(UUID id, @Valid Player player) { + var gameOpt = gameRepository.findGameById(id); + if (gameOpt.isEmpty()) { + return Optional.empty(); + } + + var game = gameOpt.get(); + + if (game.players().allPlayers().anyMatch(p -> p.name().equals(player.name()))) { + throw new IllegalStateException( + "Player with name " + player.name() + " already exists in game"); + } +// +// if (game.players().players().size() >= game.settings().maxPlayers()) { +// throw new IllegalStateException("Game is full"); +// } + + var runner = gameRunnerRegistry + .getOrCreate(id); + var state = runner + .getCurrentState(); + if (state.phase() != GamePhase.WAITING_FOR_PLAYERS) { + throw new IllegalStateException("Game is already running or finished"); + } + + var playerKey = generateAdminKey(); + runner.tell(new GameCommand.AddPlayer(game.id(), new EphemeralPlayer( + player.name(), + playerKey, + false + ))); + + return Optional.of(new GameJoinResponse(playerKey)); + } } From dcc2a4b3084b7989545098af43e37f5cfef9b530 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Wed, 8 Oct 2025 17:37:08 +0100 Subject: [PATCH 48/95] setup Groovy + Spock for testing, add a basic test for GameController --- build.gradle.kts | 13 +++++- .../codosseum/service/GameService.java | 4 +- src/main/resources/application-test.yml | 16 +++++++ .../codosseum/GameControllerSpec.groovy | 42 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/application-test.yml create mode 100644 src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy diff --git a/build.gradle.kts b/build.gradle.kts index afb207c..fc89526 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("io.micronaut.application") version "4.5.5" id("io.micronaut.aot") version "4.5.5" id("io.micronaut.openapi") version "4.5.5" + id("groovy") checkstyle } @@ -43,8 +44,14 @@ dependencies { runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("org.yaml:snakeyaml") + testAnnotationProcessor("io.micronaut:micronaut-inject-java") testImplementation("org.junit.jupiter:junit-jupiter:5.7.1") + testImplementation("io.micronaut.test:micronaut-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("io.micronaut.test:micronaut-test-spock") + testImplementation(platform("org.spockframework:spock-bom:+")) + testImplementation("org.spockframework:spock-core") + } @@ -53,8 +60,9 @@ application { } java { - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } checkstyle { @@ -102,4 +110,5 @@ micronaut { tasks.named("test") { useJUnitPlatform() + outputs.upToDateWhen { false } } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index cc89b2e..aceb677 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -169,14 +169,14 @@ public Optional addPlayer(UUID id, @Valid Player player) { var state = runner .getCurrentState(); if (state.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException("Game is already running or finished"); + throw new IllegalStateException("Game is already running or finished"); } var playerKey = generateAdminKey(); runner.tell(new GameCommand.AddPlayer(game.id(), new EphemeralPlayer( player.name(), playerKey, - false + false ))); return Optional.of(new GameJoinResponse(playerKey)); diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..2c3be98 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 Alex Wood +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify it under the terms of +# the GNU Affero General Public License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. +# If not, see . +micronaut: + security: + enabled: false diff --git a/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy new file mode 100644 index 0000000..78f3764 --- /dev/null +++ b/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy @@ -0,0 +1,42 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum + +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@MicronautTest +class GameControllerSpec extends Specification { + + @Inject + @Client("/") + HttpClient http; + + def "GET /games/{id} not found returns 404"() { + given: + def id = UUID.randomUUID() + + when: + http.toBlocking().exchange("/games/${id}", String) + + then: + def e = thrown(HttpClientResponseException) + e.status.code == 404 + } +} From c9d87d4de2774f8153cb317da0b749123aba7a28 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 11:45:24 +0100 Subject: [PATCH 49/95] add MapStruct, improve controller tests, refactor internal player joining process --- build.gradle.kts | 3 + .../developerden/codosseum/dto/Player.java | 6 ++ .../codosseum/dto/PlayerGameResult.java | 4 ++ .../developerden/codosseum/dto/Players.java | 8 ++- .../codosseum/dto/PlayersMapper.java | 43 +++++++++++++ .../developerden/codosseum/model/Game.java | 15 +++-- .../codosseum/model/GamePlayers.java | 2 +- .../model/player/EphemeralPlayer.java | 7 +++ .../codosseum/service/GameService.java | 40 ++++++------ .../codosseum/service/game/GameAggregate.java | 12 +++- .../codosseum/service/game/GameCommand.java | 3 +- .../service/game/event/EventMapper.java | 6 +- .../service/game/event/InternalGameEvent.java | 2 +- .../service/game/event/SseEventSink.java | 2 +- .../codosseum/GameControllerSpec.groovy | 47 +++++++++++++- .../codosseum/PlayerControllerSpec.groovy | 62 +++++++++++++++++++ 16 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/dto/PlayersMapper.java create mode 100644 src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy diff --git a/build.gradle.kts b/build.gradle.kts index fc89526..71b475e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { annotationProcessor("io.micronaut.validation:micronaut-validation-processor") annotationProcessor("io.micronaut.security:micronaut-security-annotations") annotationProcessor("io.soabase.record-builder:record-builder-processor:49") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") implementation("io.micronaut.reactor:micronaut-reactor") implementation("io.micronaut.cache:micronaut-cache-caffeine") @@ -41,9 +42,11 @@ dependencies { implementation("org.spdx:java-spdx-library:(,2.0]") { exclude("org.apache.logging.log4j") } + implementation("org.mapstruct:mapstruct:1.6.3") runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("org.yaml:snakeyaml") + testAnnotationProcessor("io.micronaut:micronaut-inject-java") testImplementation("org.junit.jupiter:junit-jupiter:5.7.1") testImplementation("io.micronaut.test:micronaut-test-junit5") diff --git a/src/main/java/org/developerden/codosseum/dto/Player.java b/src/main/java/org/developerden/codosseum/dto/Player.java index 05d798d..9a45d5d 100644 --- a/src/main/java/org/developerden/codosseum/dto/Player.java +++ b/src/main/java/org/developerden/codosseum/dto/Player.java @@ -19,9 +19,15 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; +/** + * A player in a game. + * + * @param name the chosen name of the player. + */ @RecordBuilder @Introspected @Serdeable +// TODO: Store whether the player is an admin public record Player( @Nonnull String name diff --git a/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java b/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java index 543e8a8..ae7d28f 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayerGameResult.java @@ -14,9 +14,13 @@ package org.developerden.codosseum.dto; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; @RecordBuilder +@Introspected +@Serdeable public record PlayerGameResult( double score ) { diff --git a/src/main/java/org/developerden/codosseum/dto/Players.java b/src/main/java/org/developerden/codosseum/dto/Players.java index ccab57a..759312a 100644 --- a/src/main/java/org/developerden/codosseum/dto/Players.java +++ b/src/main/java/org/developerden/codosseum/dto/Players.java @@ -17,6 +17,8 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -26,16 +28,18 @@ addSingleItemCollectionBuilders = true ) @Serdeable + public record Players( @Nonnull Set players, - @Nonnull + @Nullable Player admin ) { public Stream allPlayers() { - return Stream.concat(players.stream(), Stream.of(admin)); + return Stream.concat(players.stream(), Stream.of(admin).filter(Objects::nonNull)) + .distinct(); } } diff --git a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java new file mode 100644 index 0000000..762df45 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java @@ -0,0 +1,43 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.dto; + +import org.developerden.codosseum.model.GamePlayers; +import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.model.player.GamePlayer; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.SubclassExhaustiveStrategy; +import org.mapstruct.SubclassMapping; + +@Mapper( + componentModel = "jsr330" +) +public interface PlayersMapper { + + @Mapping(target = "name", source = "player.name") + Player toDto(EphemeralPlayer player); + + @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.COMPILE_ERROR) + @SubclassMapping(target = Player.class, source = EphemeralPlayer.class) + Player toDto(GamePlayer player); + + @Mapping(target = "players", source = "others") + Players toDto(GamePlayers gamePlayers); + + EphemeralPlayer toEphemeral(GamePlayer player); + +} diff --git a/src/main/java/org/developerden/codosseum/model/Game.java b/src/main/java/org/developerden/codosseum/model/Game.java index 1635187..96a72ab 100644 --- a/src/main/java/org/developerden/codosseum/model/Game.java +++ b/src/main/java/org/developerden/codosseum/model/Game.java @@ -17,16 +17,23 @@ import io.soabase.recordbuilder.core.RecordBuilder; import java.util.UUID; import org.developerden.codosseum.dto.GameSettings; -import org.developerden.codosseum.dto.Players; import org.developerden.codosseum.mode.GameMode; -// TODO: 19/08/23 internal model +/** + * Internal model representing the immutable only data about a game. + * All mutable data is held in {@link GameState}. + * + * @param id the unique identifier of the game. + * @param adminKey the admin key for the game, used to authenticate admin actions. + * @param settings the settings for the game. + * @param mode the game mode. + */ @RecordBuilder public record Game( UUID id, - String adminKey, + // TODO: this should be removed and handled via players' individual keys + {@link EphemeralPlayer#admin()} + @Deprecated String adminKey, GameSettings settings, - Players players, GameMode mode ) { } diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java index 6300488..6d4ab9a 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePlayers.java +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -24,7 +24,7 @@ * Internal model representing the players in a game. * * @param admin the admin player - * @param others the other players + * @param others the other players. For future proofing in the case of multi-admin games, this will also include the {@link #admin} */ @RecordBuilder() @RecordBuilder.Options( diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index b5ba822..0d81588 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -14,5 +14,12 @@ package org.developerden.codosseum.model.player; +/** + * An ephemeral player, not tied to any persistent identity. + * + * @param name the name of the player. + * @param key a unique key for the player, used to identify them in the game. + * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. + */ public record EphemeralPlayer(String name, String key, boolean admin) implements GamePlayer { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index aceb677..6f0733b 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -19,7 +19,6 @@ import jakarta.validation.Valid; import java.util.ArrayList; import java.util.EnumSet; -import java.util.HashSet; import java.util.Optional; import java.util.UUID; import org.developerden.codosseum.dto.GameCreateRequest; @@ -28,12 +27,11 @@ import org.developerden.codosseum.dto.GameJoinResponse; import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.dto.Player; -import org.developerden.codosseum.dto.Players; +import org.developerden.codosseum.dto.PlayersMapper; import org.developerden.codosseum.mode.GameModeFactory; import org.developerden.codosseum.mode.GameModeType; import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.GamePhase; -import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; @@ -51,18 +49,20 @@ public class GameService { private final SnapshotStore snapshotStore; private final EventSink eventSink; + private final PlayersMapper playersMapper; public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, - EventSink eventSink) { + EventSink eventSink, PlayersMapper playersMapper) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; this.snapshotStore = snapshotStore; this.eventSink = eventSink; + this.playersMapper = playersMapper; } - private String generateAdminKey() { + private String generateFreshKey() { return UUID.randomUUID().toString(); } @@ -75,15 +75,14 @@ public GameCreateResponse createGame(GameCreateRequest request) { var gameModeType = CollectionUtils.pickRandom(gameModeTypes); var gameMode = gameModeFactory.fromType(gameModeType); - var game = new Game(UUID.randomUUID(), generateAdminKey(), request.settings(), new Players( - new HashSet<>(), - request.player() - ), gameMode); + var game = new Game(UUID.randomUUID(), generateFreshKey(), request.settings(), gameMode); gameRepository.insertGame(game); gameRunnerRegistry.getOrCreate(game.id()) - .tell(new GameCommand.CreateGame(game.id())); + .tell(new GameCommand.CreateGame(game.id(), new EphemeralPlayer(request.player().name(), + generateFreshKey(), true + ))); return new GameCreateResponse(game.adminKey(), game.id()); } @@ -106,17 +105,18 @@ public Optional getGame(UUID id) { var stateOpt = gameRunnerRegistry .find(id) .map(GameRunner::getCurrentState) - .or(() -> snapshotStore.load(id)); - - var phase = stateOpt.map(GameState::phase) + .or(() -> snapshotStore.load(id)) .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); + var phase = stateOpt.phase(); + + return gameOpt .map(game -> new GameInfo( game.settings(), game.id(), game.mode(), - game.players(), + playersMapper.toDto(stateOpt.players()), phase, 0, 0, @@ -154,11 +154,11 @@ public Optional addPlayer(UUID id, @Valid Player player) { } var game = gameOpt.get(); - - if (game.players().allPlayers().anyMatch(p -> p.name().equals(player.name()))) { - throw new IllegalStateException( - "Player with name " + player.name() + " already exists in game"); - } +// if (game.players().allPlayers().anyMatch(p -> p.name().equals(player.name()))) { +// throw new IllegalStateException( +// "Player with name " + player.name() + " already exists in game"); +// } +// // // if (game.players().players().size() >= game.settings().maxPlayers()) { // throw new IllegalStateException("Game is full"); @@ -172,7 +172,7 @@ public Optional addPlayer(UUID id, @Valid Player player) { throw new IllegalStateException("Game is already running or finished"); } - var playerKey = generateAdminKey(); + var playerKey = generateFreshKey(); runner.tell(new GameCommand.AddPlayer(game.id(), new EphemeralPlayer( player.name(), playerKey, diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index ee14a26..5faf2a2 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -51,12 +51,12 @@ private List decide(GameCommand cmd) { } return switch (cmd) { - case GameCommand.CreateGame(var id) -> { + case GameCommand.CreateGame(var id, var creator) -> { if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { throw new IllegalStateException( "Cannot create game that is not in WAITING_FOR_PLAYERS phase"); } else { - yield List.of(new InternalGameEvent.GameCreated(gameId)); + yield List.of(new InternalGameEvent.GameCreated(gameId, creator)); } } case GameCommand.StartGame(var id) -> { @@ -99,7 +99,13 @@ private GameState apply(GameState state, InternalGameEvent event) { .addOthers(player) .build() ); - case InternalGameEvent.GameCreated(var gameId) -> state; // no-op for now + case InternalGameEvent.GameCreated(var gameId, var player) -> GameStateBuilder.from(state) + .withPlayers( + GamePlayersBuilder.builder(state.players()) + .admin(player) + .addOthers(player) + .build() + ); }; } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 290a8be..240db99 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -15,6 +15,7 @@ package org.developerden.codosseum.service.game; import java.util.UUID; +import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.model.player.GamePlayer; /** @@ -24,7 +25,7 @@ public sealed interface GameCommand { UUID gameId(); - record CreateGame(UUID gameId) implements GameCommand { + record CreateGame(UUID gameId, GamePlayer creator) implements GameCommand { } /** diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 2cbd20f..72d49b3 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -39,17 +39,17 @@ public Optional fromInternal(InternalGameEvent internalEvent) { }; } - private Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { + public Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { var players = new Players( gamePlayers.others().stream() .map(this::fromGamePlayer) .collect(Collectors.toSet()), - fromGamePlayer(gamePlayers.admin()) + gamePlayers.admin() == null ? null : fromGamePlayer(gamePlayers.admin()) ); return Optional.of(players); } - private Player fromGamePlayer(@Nonnull GamePlayer player) { + public Player fromGamePlayer(@Nonnull GamePlayer player) { return switch (player) { case EphemeralPlayer(var name, var ignored, var ignored2) -> (new Player(name)); }; diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 41cd295..27b75eb 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -30,7 +30,7 @@ public sealed interface InternalGameEvent { UUID gameId(); /// Emitted when a new game is created and the lobby is opened - record GameCreated(UUID gameId) implements InternalGameEvent { + record GameCreated(UUID gameId, GamePlayer creator) implements InternalGameEvent { } record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java index 5fc2e7c..9c25020 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -41,7 +41,7 @@ public SseEventSink(EventMapper eventMapper) { @Override public void publish(InternalGameEvent event) { UUID gameId = event.gameId(); - logger.atInfo().log("Publishing event {} for game {}", event, gameId); + logger.info("Publishing event {} for game {}", event, gameId); var sink = sinks.computeIfAbsent(gameId, ignored -> Sinks.many().multicast().onBackpressureBuffer()); sink.tryEmitNext(event); diff --git a/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy index 78f3764..020003e 100644 --- a/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameControllerSpec.groovy @@ -14,11 +14,14 @@ package org.developerden.codosseum +import io.micronaut.http.HttpRequest import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject +import org.developerden.codosseum.dto.* +import org.developerden.codosseum.mode.GameModeType import spock.lang.Specification @MicronautTest @@ -26,7 +29,7 @@ class GameControllerSpec extends Specification { @Inject @Client("/") - HttpClient http; + HttpClient http def "GET /games/{id} not found returns 404"() { given: @@ -39,4 +42,46 @@ class GameControllerSpec extends Specification { def e = thrown(HttpClientResponseException) e.status.code == 404 } + + def "POST /games returns 201 and has a valid body"() { + when: + def response = http.toBlocking().exchange(HttpRequest.POST("/games", + new GameCreateRequest( + GameSettingsBuilder.builder().allowedGameModes(List.of(GameModeType.FASTEST)).build(), + new Player("test player") + ) + ), GameCreateResponse) + + then: + response.status.code == 201 + response.body().id() != null + } + + def "POST /games returns 201 and GET /games/{id} returns 200"() { + when: + def response = http.toBlocking().exchange(HttpRequest.POST("/games", + new GameCreateRequest( + GameSettingsBuilder.builder().allowedGameModes(List.of(GameModeType.FASTEST)).build(), + new Player("test player") + ) + ), GameCreateResponse) + + then: + response.status.code == 201 + def body = response.body() + + body.id() != null + def id = body.id() + + when: + def getResponse = http.toBlocking().exchange("/games/${id}", GameInfo) + then: + getResponse.status.code == 200 + def info = getResponse.body() + info.id() == id + info.players().allPlayers().count() == 1 + info.settings().allowedGameModes() == [GameModeType.FASTEST] + } + + } diff --git a/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy b/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy new file mode 100644 index 0000000..49b981a --- /dev/null +++ b/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy @@ -0,0 +1,62 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum + +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.developerden.codosseum.dto.* +import org.developerden.codosseum.mode.GameModeType +import spock.lang.Specification + +@MicronautTest +class PlayerControllerSpec extends Specification { + @Inject + @Client("/") + HttpClient http + + def "Players can join game"() { + given: + def createResponse = http.toBlocking().exchange(HttpRequest.POST("/games", + new GameCreateRequest( + GameSettingsBuilder.builder().allowedGameModes(List.of(GameModeType.FASTEST)).build(), + new Player("test player") + ) + ), GameCreateResponse) + def gameId = createResponse.body().id() + + when: + def joinResponse = http.toBlocking().exchange(HttpRequest.POST("/games/${gameId}/players", + new Player("second player")), GameJoinResponse) + + then: + joinResponse.status.code == 200 + def joinBody = joinResponse.body() + joinBody.key() != null + + when: + def getResponse = http.toBlocking().exchange("/games/${gameId}", GameInfo) + then: + getResponse.status.code == 200 + def info = getResponse.body() + info.id() == gameId + info.players().allPlayers().count() == 2 + info.players().allPlayers().find { it.name() == "second player" } != null + info.settings().allowedGameModes() == [GameModeType.FASTEST] + + } +} From b7980e9f18e598cce03edc82d3e95555c5e4fa48 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 12:02:44 +0100 Subject: [PATCH 50/95] tidy DTO mapping code --- .../codosseum/dto/PlayersMapper.java | 2 -- .../model/player/EphemeralPlayer.java | 5 ++- .../service/game/event/EventMapper.java | 33 +++++-------------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java index 762df45..40aa7f1 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java @@ -38,6 +38,4 @@ public interface PlayersMapper { @Mapping(target = "players", source = "others") Players toDto(GamePlayers gamePlayers); - EphemeralPlayer toEphemeral(GamePlayer player); - } diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index 0d81588..7d83758 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -14,6 +14,8 @@ package org.developerden.codosseum.model.player; +import javax.annotation.Nonnull; + /** * An ephemeral player, not tied to any persistent identity. * @@ -21,5 +23,6 @@ * @param key a unique key for the player, used to identify them in the game. * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. */ -public record EphemeralPlayer(String name, String key, boolean admin) implements GamePlayer { +public record EphemeralPlayer(@Nonnull String name, @Nonnull String key, boolean admin) + implements GamePlayer { } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 72d49b3..51eb348 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -14,44 +14,29 @@ package org.developerden.codosseum.service.game.event; -import jakarta.annotation.Nonnull; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.Optional; -import java.util.stream.Collectors; -import org.developerden.codosseum.dto.Player; -import org.developerden.codosseum.dto.Players; +import org.developerden.codosseum.dto.PlayersMapper; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.event.PlayerJoinEvent; -import org.developerden.codosseum.model.GamePlayers; -import org.developerden.codosseum.model.player.EphemeralPlayer; -import org.developerden.codosseum.model.player.GamePlayer; @Singleton public class EventMapper { + private final PlayersMapper playersMapper; + + @Inject + public EventMapper(PlayersMapper playersMapper) { + this.playersMapper = playersMapper; + } public Optional fromInternal(InternalGameEvent internalEvent) { return switch (internalEvent) { case InternalGameEvent.GameCreated ignored -> Optional.empty(); case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( - new PlayerJoinEvent(playerJoined.gameId(), fromGamePlayer(playerJoined.player())) + new PlayerJoinEvent(playerJoined.gameId(), playersMapper.toDto(playerJoined.player())) ); }; } - - public Optional fromGamePlayers(@Nonnull GamePlayers gamePlayers) { - var players = new Players( - gamePlayers.others().stream() - .map(this::fromGamePlayer) - .collect(Collectors.toSet()), - gamePlayers.admin() == null ? null : fromGamePlayer(gamePlayers.admin()) - ); - return Optional.of(players); - } - - public Player fromGamePlayer(@Nonnull GamePlayer player) { - return switch (player) { - case EphemeralPlayer(var name, var ignored, var ignored2) -> (new Player(name)); - }; - } } From 30cece19a3de57c090f68cdfa54bcc1f8f108461 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 13:20:26 +0100 Subject: [PATCH 51/95] fix compile errors and send SyncEvent when warmup starts --- .../codosseum/controller/GameController.java | 29 +++++ .../codosseum/dto/PlayersMapper.java | 1 + .../codosseum/service/GameService.java | 7 ++ .../codosseum/service/game/GameAggregate.java | 106 ++++++++++++++---- .../codosseum/service/game/GameCommand.java | 9 +- .../codosseum/service/game/GameRunner.java | 78 ++++++++++++- .../service/game/GameRunnerFactory.java | 7 +- .../service/game/effect/SideEffect.java | 36 ++++++ .../service/game/event/EventMapper.java | 11 ++ .../service/game/event/InternalGameEvent.java | 7 ++ .../service/game/state/SnapshotStore.java | 2 +- 11 files changed, 261 insertions(+), 32 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/service/game/effect/SideEffect.java diff --git a/src/main/java/org/developerden/codosseum/controller/GameController.java b/src/main/java/org/developerden/codosseum/controller/GameController.java index 724afa7..4ec0596 100644 --- a/src/main/java/org/developerden/codosseum/controller/GameController.java +++ b/src/main/java/org/developerden/codosseum/controller/GameController.java @@ -33,6 +33,7 @@ import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -45,11 +46,13 @@ import java.util.UUID; import org.developerden.codosseum.auth.GameAuthorized; import org.developerden.codosseum.auth.GameRole; +import org.developerden.codosseum.controller.binder.GameParam; import org.developerden.codosseum.dto.GameCreateRequest; import org.developerden.codosseum.dto.GameCreateResponse; import org.developerden.codosseum.dto.GameInfo; import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.event.GameEvent; +import org.developerden.codosseum.model.Game; import org.developerden.codosseum.service.GameService; import org.developerden.codosseum.service.game.event.SseEventSink; import org.reactivestreams.Publisher; @@ -116,6 +119,32 @@ public HttpResponse deleteGame(Principal principal, @PathVariable("id") St return HttpResponse.noContent(); } + @Post("/{id}/warmup") + @GameAuthorized(GameRole.ADMIN) + @Operation(operationId = "beginWarmup", + summary = "Force the warmup phase of a game to begin", + description = "Starts the warmup phase of a game, regardless of player-count" + ) + @ApiResponse( + responseCode = "204", + description = "Successfully started the warmup phase. " + + "Further info will be received via server-sent events." + ) + @ApiResponse( + responseCode = "409", + description = "Game is already running or finished") + public HttpResponse beginWarmup(Principal principal, + @PathVariable("id") UUID id, + @Parameter(hidden = true) @GameParam Game game) { + try { + gameService.beginWarmup(game); + return HttpResponse.noContent(); + } catch (IllegalStateException e) { + return HttpResponse.status(HttpStatus.CONFLICT); + } + } + + @Post("/{id}/start") @GameAuthorized(GameRole.ADMIN) @Operation(operationId = "startGame", diff --git a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java index 40aa7f1..cba939c 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java @@ -33,6 +33,7 @@ public interface PlayersMapper { @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.COMPILE_ERROR) @SubclassMapping(target = Player.class, source = EphemeralPlayer.class) + Player toDto(GamePlayer player); @Mapping(target = "players", source = "others") diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 6f0733b..010c8d8 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -182,4 +182,11 @@ public Optional addPlayer(UUID id, @Valid Player player) { return Optional.of(new GameJoinResponse(playerKey)); } + + public void beginWarmup(Game game) { + var runner = gameRunnerRegistry + .getOrCreate(game.id()); + + runner.tell(new GameCommand.StartWarmup(game.id())); + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 5faf2a2..9a8489c 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -14,15 +14,21 @@ package org.developerden.codosseum.service.game; +import java.time.Duration; +import java.util.Arrays; import java.util.List; import java.util.UUID; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GamePlayersBuilder; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; +import org.developerden.codosseum.service.game.effect.SideEffect; import org.developerden.codosseum.service.game.event.InternalGameEvent; public class GameAggregate { + private static final Duration DEFAULT_WARMUP_DURATION = Duration.ofSeconds(5); + private static final String KEY_WARMUP_TO_START = "warmup->start"; + private final UUID gameId; private final GameState gameState; @@ -37,46 +43,59 @@ public GameState getGameState() { } public Result handle(GameCommand cmd) { - var events = decide(cmd); - var newState = applyAll(gameState, events); + var decision = decide(cmd); + var newState = applyAll(gameState, decision.events()); var next = new GameAggregate(gameId, newState); - return new Result(events, next); + return new Result(decision.events(), decision.effects(), next); } - private List decide(GameCommand cmd) { + private void requirePhase(GamePhase gamePhase) { + if (gameState.phase() != gamePhase) { + throw new IllegalStateException( + "Game is not in required phase: " + gamePhase + ", current phase: " + gameState.phase()); + } + } + + private Decision decide(GameCommand cmd) { if (!cmd.gameId().equals(gameId)) { throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); } return switch (cmd) { case GameCommand.CreateGame(var id, var creator) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException( - "Cannot create game that is not in WAITING_FOR_PLAYERS phase"); - } else { - yield List.of(new InternalGameEvent.GameCreated(gameId, creator)); - } + requirePhase(GamePhase.WAITING_FOR_PLAYERS); + yield Decision.pure( + new InternalGameEvent.GameCreated(gameId, creator) + ); + } case GameCommand.StartGame(var id) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException( - "Cannot start game that is not in WAITING_FOR_PLAYERS phase"); - } else { - // TODO: check if enough players and actually do something - yield List.of(); - } + requirePhase(GamePhase.WARMUP); + + yield Decision.pure( + new InternalGameEvent.GameStarted(gameId) + ).withEffects(new SideEffect.CancelScheduled(KEY_WARMUP_TO_START)); } case GameCommand.AddPlayer(var id, var player) -> { - if (gameState.phase() != GamePhase.WAITING_FOR_PLAYERS) { - throw new IllegalStateException( - "Cannot join game that is not in WAITING_FOR_PLAYERS phase"); - } else { - yield List.of(new InternalGameEvent.PlayerJoined(gameId, player)); - } + requirePhase(GamePhase.WAITING_FOR_PLAYERS); + yield Decision.pure(new InternalGameEvent.PlayerJoined(gameId, player)); } + case GameCommand.StartWarmup(var id) -> { + requirePhase(GamePhase.WAITING_FOR_PLAYERS); + // Emit warmup started and schedule transition to start after countdown + yield Decision.pure( + new InternalGameEvent.WarmupStarted(gameId, DEFAULT_WARMUP_DURATION) + ).withEffects( + new SideEffect.ScheduleAfter( + KEY_WARMUP_TO_START, + DEFAULT_WARMUP_DURATION, + new GameCommand.StartGame(gameId) + ) + ); + } }; } @@ -106,12 +125,51 @@ private GameState apply(GameState state, InternalGameEvent event) { .addOthers(player) .build() ); + case InternalGameEvent.WarmupStarted(var gameId, var duration) -> GameStateBuilder.from(state) + .withPhase(GamePhase.WARMUP); + case InternalGameEvent.GameStarted(var gameId) -> GameStateBuilder.from(state) + .withPhase(GamePhase.IN_PROGRESS); }; } + /** + * The result of deciding how to handle a command. + * + * @param events the events that were produced. + * @param effects the side effects that should be executed. + */ + private record Decision(List events, List effects) { + static Decision empty() { + return new Decision(List.of(), List.of()); + } + + static Decision pure(List events) { + return new Decision(events, List.of()); + } + + static Decision pure(InternalGameEvent... events) { + return new Decision(Arrays.asList(events), List.of()); + } + + public Decision withEffects(List effects) { + return new Decision(events, effects); + } + + public Decision withEffects(SideEffect... effects) { + return new Decision(events, Arrays.asList(effects)); + } + } - public record Result(List events, GameAggregate next) { + /** + * The result of handling a command. + * + * @param events the events that were produced + * @param effects the side effects that should be executed + * @param next the next state of the aggregate + */ + public record Result(List events, List effects, + GameAggregate next) { } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 240db99..bf0620b 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -15,7 +15,6 @@ package org.developerden.codosseum.service.game; import java.util.UUID; -import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.model.player.GamePlayer; /** @@ -28,6 +27,14 @@ public sealed interface GameCommand { record CreateGame(UUID gameId, GamePlayer creator) implements GameCommand { } + /** + * Command to start the warmup phase of a game. + * + * @param gameId the id of the game to start the warmup for + */ + record StartWarmup(UUID gameId) implements GameCommand { + } + /** * Command to start a game. * diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java index c20c471..9d07e7e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java @@ -14,26 +14,41 @@ package org.developerden.codosseum.service.game; +import io.micronaut.scheduling.TaskScheduler; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import org.developerden.codosseum.model.GameState; +import org.developerden.codosseum.service.game.effect.SideEffect; import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +/** + * Overseer of a single game's state and progression. + * Runs all game logic in a single-threaded executor. + * + */ public class GameRunner { + private static final Logger log = LoggerFactory.getLogger(GameRunner.class); private final UUID gameId; private final ExecutorService loop; - private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final TaskScheduler scheduler; private final EventSink eventSink; private final SnapshotStore snapshotStore; + private final Map> scheduled = new ConcurrentHashMap<>(); private volatile GameAggregate gameAggregate; - public GameRunner(UUID gameId, EventSink eventSink, SnapshotStore snapshotStore, + public GameRunner(UUID gameId, TaskScheduler scheduler, EventSink eventSink, + SnapshotStore snapshotStore, GameAggregateFactory aggregateFactory) { this.gameId = gameId; + this.scheduler = scheduler; this.eventSink = eventSink; this.snapshotStore = snapshotStore; @@ -44,7 +59,13 @@ public GameRunner(UUID gameId, EventSink eventSink, SnapshotStore snapshotStore, this.gameAggregate = aggregateFactory.create(gameId, snapshot); } + /** + * Send a command to this game runner. + * + * @param cmd the command to handle. + */ public void tell(GameCommand cmd) { + log.debug("tell game command: {}", cmd); loop.execute(() -> handle(cmd)); } @@ -52,17 +73,66 @@ public GameState getCurrentState() { return gameAggregate.getGameState(); } + /** + * Handle a command. Must be called from the game loop thread for safety. + * + *

The command is first sent to the {@link GameAggregate} for handling, which produces events and side effects. + * Any events are published to the event sink, and side effects are executed. + * Finally, the new game state is persisted in {@link SnapshotStore}. + * + * @param cmd the command to handle. + */ private void handle(GameCommand cmd) { + log.debug("handle command: {}", cmd); var result = gameAggregate.handle(cmd); this.gameAggregate = result.next(); for (var event : result.events()) { eventSink.publish(event); } + // execute side effects after state mutation and publication + for (var effect : result.effects()) { + execute(effect); + } snapshotStore.save(gameId, gameAggregate.getGameState()); } + private void execute(SideEffect effect) { + log.debug("execute effect: {}", effect); + switch (effect) { + case SideEffect.ScheduleAfter(var key, var delay, var command) -> { + // cancel any existing scheduled task for the same key + var existing = scheduled.remove(key); + if (existing != null) { + log.info("Cancelling existing scheduled task with key {} in game {}", key, gameId); + existing.cancel(false); + } + var future = scheduler.schedule(delay, () -> tell(command)); + scheduled.put(key, future); + log.info("Scheduled task with key {} in game {} to run after {}", key, gameId, delay); + } + case SideEffect.CancelScheduled(var key) -> { + var existing = scheduled.remove(key); + if (existing != null) { + existing.cancel(false); + log.info("Cancelled scheduled task with key {} in game {}", key, gameId); + } + } + } + } + + /** + * Shutdown this game runner, cancelling any scheduled tasks. + * This should be called when the game is over and the runner is no longer needed. + */ public void shutdown() { - scheduler.shutdownNow(); + // cancel scheduled tasks + for (var entry : scheduled.entrySet()) { + boolean cancel = entry.getValue().cancel(false); + if (!cancel) { + log.warn("Could not cancel scheduled task {} in game {}", entry.getKey(), gameId); + } + } + scheduled.clear(); loop.shutdownNow(); } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java index 714ef24..db3b583 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerFactory.java @@ -14,6 +14,7 @@ package org.developerden.codosseum.service.game; +import io.micronaut.scheduling.TaskScheduler; import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.UUID; @@ -26,16 +27,18 @@ public class GameRunnerFactory { private final EventSink eventSink; private final SnapshotStore snapshotStore; private final GameAggregateFactory aggregateFactory; + private final TaskScheduler taskScheduler; @Inject public GameRunnerFactory(EventSink eventSink, SnapshotStore snapshotStore, - GameAggregateFactory aggregateFactory) { + GameAggregateFactory aggregateFactory, TaskScheduler taskScheduler) { this.eventSink = eventSink; this.snapshotStore = snapshotStore; this.aggregateFactory = aggregateFactory; + this.taskScheduler = taskScheduler; } public GameRunner create(UUID gameId) { - return new GameRunner(gameId, eventSink, snapshotStore, aggregateFactory); + return new GameRunner(gameId, taskScheduler, eventSink, snapshotStore, aggregateFactory); } } diff --git a/src/main/java/org/developerden/codosseum/service/game/effect/SideEffect.java b/src/main/java/org/developerden/codosseum/service/game/effect/SideEffect.java new file mode 100644 index 0000000..721a0ac --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/effect/SideEffect.java @@ -0,0 +1,36 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.service.game.effect; + +import java.time.Duration; +import org.developerden.codosseum.service.game.GameCommand; + +/** + * Side effects that a GameAggregate requests the runner to perform. + * Effects are executed by the {@code GameRunner} and are not part of state mutation. + */ +public sealed interface SideEffect permits SideEffect.ScheduleAfter, SideEffect.CancelScheduled { + + /** + * Schedule a command to be sent to this game after the given delay. + * The key is used for idempotency and cancellation/replacement. + */ + record ScheduleAfter(String key, Duration delay, GameCommand command) implements SideEffect {} + + /** + * Cancel a previously scheduled command by key. + */ + record CancelScheduled(String key) implements SideEffect {} +} diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 51eb348..fa8a1d7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -20,6 +20,8 @@ import org.developerden.codosseum.dto.PlayersMapper; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.event.PlayerJoinEvent; +import org.developerden.codosseum.event.SyncEvent; +import org.developerden.codosseum.model.GamePhase; @Singleton public class EventMapper { @@ -37,6 +39,15 @@ public Optional fromInternal(InternalGameEvent internalEvent) { case InternalGameEvent.PlayerJoined playerJoined -> Optional.of( new PlayerJoinEvent(playerJoined.gameId(), playersMapper.toDto(playerJoined.player())) ); + + case InternalGameEvent.WarmupStarted(var ignored, var warmupLength) -> Optional.of( + new SyncEvent(GamePhase.WARMUP, + // how long until warmup ends + Math.toIntExact(warmupLength.toSeconds()) + ) + ); + + case InternalGameEvent.GameStarted ignored -> Optional.empty(); }; } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 27b75eb..100bbc0 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -14,6 +14,7 @@ package org.developerden.codosseum.service.game.event; +import java.time.Duration; import java.util.UUID; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; @@ -35,4 +36,10 @@ record GameCreated(UUID gameId, GamePlayer creator) implements InternalGameEvent record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { } + + /** Emitted when the warmup countdown begins. */ + record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGameEvent {} + + /** Emitted when the game transitions to in-progress. */ + record GameStarted(UUID gameId) implements InternalGameEvent {} } diff --git a/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java index a0bac80..e294b5a 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/SnapshotStore.java @@ -20,7 +20,7 @@ /** * Persistence boundary for GameState snapshots. - * Pure IO: load/save by gameId. No domain logic or timers here. + * Implementations must be thread-safe. */ public interface SnapshotStore { Optional load(UUID gameId); From 8d4e9f265ca0018f2a3f1828b8bd3d0e679656af Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 13:35:35 +0100 Subject: [PATCH 52/95] fix main method which broke for some reason --- src/main/java/org/developerden/codosseum/Application.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/Application.java b/src/main/java/org/developerden/codosseum/Application.java index 8286634..49543f0 100644 --- a/src/main/java/org/developerden/codosseum/Application.java +++ b/src/main/java/org/developerden/codosseum/Application.java @@ -25,8 +25,7 @@ ) ) public class Application { - - static void main(String[] args) { + public static void main(String[] args) { Micronaut.run(Application.class, args); } } From e3b55e919e7cee5541656b733d27fccecdb5b774 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 13:47:14 +0100 Subject: [PATCH 53/95] start to add a more involved test --- src/main/resources/application-test.yml | 4 +- .../codosseum/GameFlowSpec.groovy | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 2c3be98..957be07 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -13,4 +13,6 @@ # If not, see . micronaut: security: - enabled: false + authentication: bearer + oauth2: + enabled: false diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy new file mode 100644 index 0000000..45cc693 --- /dev/null +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -0,0 +1,61 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum + +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.developerden.codosseum.dto.GameCreateRequest +import org.developerden.codosseum.dto.GameCreateResponse +import org.developerden.codosseum.dto.GameSettingsBuilder +import org.developerden.codosseum.dto.Player +import org.developerden.codosseum.mode.GameModeType +import spock.lang.Specification + +@MicronautTest +class GameFlowSpec extends Specification { + @Inject + @Client("/") + HttpClient http + + def "Warmup phase starts correctly"() { + when: + def response = http.toBlocking().exchange(HttpRequest.POST("/games", + new GameCreateRequest( + GameSettingsBuilder.builder().allowedGameModes(List.of(GameModeType.FASTEST)).build(), + new Player("test player") + ) + ), GameCreateResponse) + + then: + response.status.code == 201 + response.body().id() != null + + def gameId = response.body().id() + def key = response.body().adminKey() + + when: + def getResponse = http.toBlocking() + .exchange(HttpRequest.POST("/games/${gameId}/warmup", null) + .header("Authorization", key), Void) + + then: + getResponse.status.code == 204 + + + } +} From 255da8c0d09415ab9b21747cb5dd89d6c3fbafe8 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 13:55:09 +0100 Subject: [PATCH 54/95] fix GameKeyTokenReader providing incorrect prefix --- .../org/developerden/codosseum/auth/GameKeyTokenReader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java index 7c0982e..35dad15 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenReader.java @@ -21,7 +21,7 @@ public class GameKeyTokenReader extends HttpHeaderTokenReader { @Override protected String getPrefix() { - return "Game "; + return "Game"; } @Override From 424e5b43f56dbc943c1bfcbc50d2522b0cf2f42e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 14:09:29 +0100 Subject: [PATCH 55/95] implement a repository for player information, and fix auth based on player info --- .../codosseum/auth/GameKeyTokenValidator.java | 13 ++++- .../codosseum/auth/PlayerAuthentication.java | 13 ++++- .../model/player/EphemeralPlayer.java | 14 +++-- .../codosseum/repository/AuthRepository.java | 19 ++++++- .../repository/AuthRepositoryImpl.java | 56 +++++++++++++++++++ .../codosseum/repository/GameRepository.java | 1 + .../codosseum/service/GameService.java | 38 ++++++------- src/main/resources/application-test.yml | 2 +- .../codosseum/GameFlowSpec.groovy | 2 +- 9 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java index 75131b4..3af435f 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java @@ -19,14 +19,25 @@ import io.micronaut.security.authentication.Authentication; import io.micronaut.security.token.validator.TokenValidator; import jakarta.inject.Singleton; +import java.util.Optional; +import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.repository.AuthRepository; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; @Singleton public class GameKeyTokenValidator implements TokenValidator> { + private final AuthRepository authRepository; + + public GameKeyTokenValidator(AuthRepository authRepository) { + this.authRepository = authRepository; + } + @Override public Publisher validateToken(String token, @Nullable HttpRequest request) { - return Mono.empty(); + Optional playerByGameKey = authRepository.findPlayerByGameKey(token); + return Mono.justOrEmpty(playerByGameKey) + .map(PlayerAuthentication::buildFrom); } } diff --git a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java index e1ac830..558c686 100644 --- a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java +++ b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java @@ -16,14 +16,17 @@ import io.micronaut.security.authentication.Authentication; import jakarta.annotation.Nonnull; +import java.util.EnumSet; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; +import org.developerden.codosseum.model.player.EphemeralPlayer; public final class PlayerAuthentication { public static Authentication build( - @Nonnull String name, @Nonnull String activeGameId, @Nonnull Set roles) { + @Nonnull String name, @Nonnull UUID activeGameId, @Nonnull Set roles) { return Authentication.build( name, roles.stream().map(Enum::name).collect(Collectors.toSet()), @@ -31,4 +34,12 @@ public static Authentication build( ); } + public static Authentication buildFrom(EphemeralPlayer ephemeralPlayer) { + return build( + ephemeralPlayer.name(), + ephemeralPlayer.gameId(), + ephemeralPlayer.admin() ? EnumSet.of(GameRole.ADMIN, GameRole.PLAYER) : EnumSet.of(GameRole.PLAYER) + ); + } + } diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index 7d83758..f8bf581 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -14,15 +14,21 @@ package org.developerden.codosseum.model.player; +import java.util.UUID; import javax.annotation.Nonnull; +import org.checkerframework.common.aliasing.qual.Unique; /** * An ephemeral player, not tied to any persistent identity. * - * @param name the name of the player. - * @param key a unique key for the player, used to identify them in the game. - * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. + * @param name the name of the player, must be unique within a game. + * @param gameId the ID of the game the player is in. + * @param key a unique key for the player, used to identify them in the game. + * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. */ -public record EphemeralPlayer(@Nonnull String name, @Nonnull String key, boolean admin) +public record EphemeralPlayer(@Nonnull @Unique String name, + @Nonnull UUID gameId, + @Nonnull String key, boolean admin) implements GamePlayer { + } diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java index a6252be..8dd1c8c 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java @@ -14,10 +14,27 @@ package org.developerden.codosseum.repository; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.model.player.GamePlayer; public interface AuthRepository { - EphemeralPlayer findPlayerByGameKey(String gameKey); + /** + * Find a player by its game/admin key. + * + * @param gameKey The game/admin key. + * @return The player, or null if not found. + */ + Optional findPlayerByGameKey(String gameKey); + + Collection allPlayers(); + + void save(Game game, EphemeralPlayer player); + + Optional findPlayerByNameAndGameId(String name, UUID id); } diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java new file mode 100644 index 0000000..23d7c04 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java @@ -0,0 +1,56 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.repository; + +import jakarta.inject.Singleton; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.developerden.codosseum.model.Game; +import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.model.player.GamePlayer; + +@Singleton +public class AuthRepositoryImpl implements AuthRepository { + + private final ConcurrentMap players = new ConcurrentHashMap<>(); + + @Override + public Optional findPlayerByGameKey(String gameKey) { + return players.values().stream() + .filter(player -> player.key().equals(gameKey)) + .findFirst(); // TODO: not very efficient + } + + @Override + public Collection allPlayers() { + return players.values(); + } + + @Override + public void save(Game game, EphemeralPlayer player) { + players.put(new PlayerKey(game.id(), player.name()), player); + } + + @Override + public Optional findPlayerByNameAndGameId(String name, UUID id) { + return Optional.ofNullable(players.get(new PlayerKey(id, name))); + } + + private record PlayerKey(UUID gameId, String playerName) { + } +} diff --git a/src/main/java/org/developerden/codosseum/repository/GameRepository.java b/src/main/java/org/developerden/codosseum/repository/GameRepository.java index a1188dd..a7f5f43 100644 --- a/src/main/java/org/developerden/codosseum/repository/GameRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/GameRepository.java @@ -22,6 +22,7 @@ public interface GameRepository { Optional findGameById(UUID id); + void insertGame(Game game); } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 010c8d8..fba854b 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -33,6 +33,7 @@ import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.repository.AuthRepository; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunner; @@ -49,16 +50,19 @@ public class GameService { private final SnapshotStore snapshotStore; private final EventSink eventSink; + private final AuthRepository authRepository; private final PlayersMapper playersMapper; public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, - EventSink eventSink, PlayersMapper playersMapper) { + EventSink eventSink, AuthRepository authRepository, + PlayersMapper playersMapper) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; this.snapshotStore = snapshotStore; this.eventSink = eventSink; + this.authRepository = authRepository; this.playersMapper = playersMapper; } @@ -79,12 +83,13 @@ public GameCreateResponse createGame(GameCreateRequest request) { gameRepository.insertGame(game); + var ephemeralPlayer = + new EphemeralPlayer(request.player().name(), game.id(), generateFreshKey(), true); + authRepository.save(game, ephemeralPlayer); gameRunnerRegistry.getOrCreate(game.id()) - .tell(new GameCommand.CreateGame(game.id(), new EphemeralPlayer(request.player().name(), - generateFreshKey(), true - ))); + .tell(new GameCommand.CreateGame(game.id(), ephemeralPlayer)); - return new GameCreateResponse(game.adminKey(), game.id()); + return new GameCreateResponse(ephemeralPlayer.key(), game.id()); } public GameInfo updateGame(String gameId, GameSettings settings) { @@ -154,15 +159,11 @@ public Optional addPlayer(UUID id, @Valid Player player) { } var game = gameOpt.get(); -// if (game.players().allPlayers().anyMatch(p -> p.name().equals(player.name()))) { -// throw new IllegalStateException( -// "Player with name " + player.name() + " already exists in game"); -// } -// -// -// if (game.players().players().size() >= game.settings().maxPlayers()) { -// throw new IllegalStateException("Game is full"); -// } + authRepository.findPlayerByNameAndGameId(player.name(), game.id()) + .ifPresent(p -> { + throw new IllegalStateException( + "Player with name " + player.name() + " already exists in game"); + }); var runner = gameRunnerRegistry .getOrCreate(id); @@ -173,11 +174,10 @@ public Optional addPlayer(UUID id, @Valid Player player) { } var playerKey = generateFreshKey(); - runner.tell(new GameCommand.AddPlayer(game.id(), new EphemeralPlayer( - player.name(), - playerKey, - false - ))); + var ephemeralPlayer = new EphemeralPlayer(player.name(), game.id(), playerKey, false); + authRepository.save(game, ephemeralPlayer); + + runner.tell(new GameCommand.AddPlayer(game.id(), ephemeralPlayer)); return Optional.of(new GameJoinResponse(playerKey)); diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 957be07..a060799 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -13,6 +13,6 @@ # If not, see . micronaut: security: - authentication: bearer + authentication: idtoken oauth2: enabled: false diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index 45cc693..56706ad 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -51,7 +51,7 @@ class GameFlowSpec extends Specification { when: def getResponse = http.toBlocking() .exchange(HttpRequest.POST("/games/${gameId}/warmup", null) - .header("Authorization", key), Void) + .header("Authorization", "Game $key"), Void) then: getResponse.status.code == 204 From 07039353c91d20ef06fea7efc5b108487401e3ca Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 14:11:31 +0100 Subject: [PATCH 56/95] improve GameFlowSpec --- .../developerden/codosseum/GameFlowSpec.groovy | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index 56706ad..559b98e 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -19,11 +19,9 @@ import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject -import org.developerden.codosseum.dto.GameCreateRequest -import org.developerden.codosseum.dto.GameCreateResponse -import org.developerden.codosseum.dto.GameSettingsBuilder -import org.developerden.codosseum.dto.Player +import org.developerden.codosseum.dto.* import org.developerden.codosseum.mode.GameModeType +import org.developerden.codosseum.model.GamePhase import spock.lang.Specification @MicronautTest @@ -56,6 +54,15 @@ class GameFlowSpec extends Specification { then: getResponse.status.code == 204 + when: + sleep(5000) + def infoResponse = http.toBlocking().exchange("/games/${gameId}", GameInfo) + then: + infoResponse.status.code == 200 + def info = infoResponse.body() + info.id() == gameId + info.state() == GamePhase.IN_PROGRESS + info.players().allPlayers().count() == 1 } } From 678769e99f12574114c0f536b9368a2779c6a479 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 14:13:53 +0100 Subject: [PATCH 57/95] run junit in parallel --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 71b475e..12dff34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -114,4 +114,7 @@ micronaut { tasks.named("test") { useJUnitPlatform() outputs.upToDateWhen { false } + systemProperties["junit.jupiter.execution.parallel.enabled"] = true + systemProperties["junit.jupiter.execution.parallel.mode.default"] = "concurrent" + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) } From aad7af271677667c89c2d84e65c84afad93bfa02 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Thu, 9 Oct 2025 15:07:38 +0100 Subject: [PATCH 58/95] refactor EventSink to use Micronauts event system --- .../codosseum/service/game/GameAggregate.java | 22 +++++++++++- .../codosseum/service/game/GameRunner.java | 5 ++- .../game/event/MicronautEventSink.java | 33 +++++++++++++++++ .../service/game/event/SseEventSink.java | 26 +++++++++----- .../game/process/GameStartedHandler.java | 36 +++++++++++++++++++ 5 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/service/game/event/MicronautEventSink.java create mode 100644 src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 9a8489c..83c78d1 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -25,6 +25,15 @@ import org.developerden.codosseum.service.game.effect.SideEffect; import org.developerden.codosseum.service.game.event.InternalGameEvent; +/** + * Handles commands, produces events, and applies events to the game state. + * + *

There should be one instance of this class per game. + * + * @see GameState + * @see GameCommand + * @see InternalGameEvent + */ public class GameAggregate { private static final Duration DEFAULT_WARMUP_DURATION = Duration.ofSeconds(5); private static final String KEY_WARMUP_TO_START = "warmup->start"; @@ -42,6 +51,12 @@ public GameState getGameState() { return gameState; } + /** + * Handle a command, producing events and side effects, and returning the next state of the aggregate. + * + * @param cmd the command to handle. + * @return a result containing the events, side effects, and next state. + */ public Result handle(GameCommand cmd) { var decision = decide(cmd); var newState = applyAll(gameState, decision.events()); @@ -51,6 +66,11 @@ public Result handle(GameCommand cmd) { return new Result(decision.events(), decision.effects(), next); } + /** + * Require that the game is in the given phase, throwing an exception if not. + * + * @param gamePhase the required phase. + */ private void requirePhase(GamePhase gamePhase) { if (gameState.phase() != gamePhase) { throw new IllegalStateException( @@ -76,7 +96,7 @@ private Decision decide(GameCommand cmd) { yield Decision.pure( new InternalGameEvent.GameStarted(gameId) - ).withEffects(new SideEffect.CancelScheduled(KEY_WARMUP_TO_START)); + ); } case GameCommand.AddPlayer(var id, var player) -> { requirePhase(GamePhase.WAITING_FOR_PLAYERS); diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java index 9d07e7e..a8f5a96 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunner.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunner.java @@ -106,7 +106,10 @@ private void execute(SideEffect effect) { log.info("Cancelling existing scheduled task with key {} in game {}", key, gameId); existing.cancel(false); } - var future = scheduler.schedule(delay, () -> tell(command)); + var future = scheduler.schedule(delay, () -> { + scheduled.remove(key); + tell(command); + }); scheduled.put(key, future); log.info("Scheduled task with key {} in game {} to run after {}", key, gameId, delay); } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/MicronautEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/MicronautEventSink.java new file mode 100644 index 0000000..ad3d5c7 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/event/MicronautEventSink.java @@ -0,0 +1,33 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.service.game.event; + +import io.micronaut.context.event.ApplicationEventPublisher; +import jakarta.inject.Singleton; + +@Singleton +public class MicronautEventSink implements EventSink { + + private final ApplicationEventPublisher publisher; + + public MicronautEventSink(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + @Override + public void publish(InternalGameEvent event) { + publisher.publishEvent(event); + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java index 9c25020..830431c 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -15,6 +15,7 @@ package org.developerden.codosseum.service.game.event; import io.micronaut.http.sse.Event; +import io.micronaut.runtime.event.annotation.EventListener; import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.Map; @@ -27,35 +28,42 @@ import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +/** + * Publishes {@link InternalGameEvent}s to subscribed SSE clients. + */ @Singleton -public class SseEventSink implements EventSink { +public class SseEventSink { private final Map> sinks = new ConcurrentHashMap<>(); private final EventMapper eventMapper; private final Logger logger = LoggerFactory.getLogger(SseEventSink.class); + @Inject public SseEventSink(EventMapper eventMapper) { this.eventMapper = eventMapper; } - @Override - public void publish(InternalGameEvent event) { + + @EventListener + public void on(InternalGameEvent event) { UUID gameId = event.gameId(); logger.info("Publishing event {} for game {}", event, gameId); - var sink = sinks.computeIfAbsent(gameId, - ignored -> Sinks.many().multicast().onBackpressureBuffer()); + var sink = sink(gameId); sink.tryEmitNext(event); } - public Publisher> subscribeToSse(UUID gameId) { - var sink = sinks.computeIfAbsent(gameId, + private Sinks.Many sink(UUID gameId) { + return sinks.computeIfAbsent(gameId, ignored -> Sinks.many().multicast().onBackpressureBuffer()); + } + + public Publisher> subscribeToSse(UUID gameId) { + var sink = sink(gameId); return sink.asFlux().map(event -> Event.of(event).name(event.getClass().getSimpleName())); } public Publisher> subscribeToPublicSse(UUID gameId) { - var sink = sinks.computeIfAbsent(gameId, - ignored -> Sinks.many().multicast().onBackpressureBuffer()); + var sink = sink(gameId); return sink.asFlux() .flatMap(e -> eventMapper.fromInternal(e) .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java new file mode 100644 index 0000000..30ab50b --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java @@ -0,0 +1,36 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.service.game.process; + +import io.micronaut.context.event.ApplicationEventListener; +import io.micronaut.runtime.event.annotation.EventListener; +import jakarta.inject.Singleton; +import org.developerden.codosseum.service.game.event.InternalGameEvent; + +@Singleton +public class GameStartedHandler implements ApplicationEventListener { + + @Override + public void onApplicationEvent(InternalGameEvent event) { + var gameStarted = (InternalGameEvent.GameStarted) event; + + System.out.println("Game " + gameStarted.gameId() + " has started!"); + } + + @Override + public boolean supports(InternalGameEvent event) { + return event instanceof InternalGameEvent.GameStarted; + } +} From e77bf81c010badd882a8b187228f3708dc6e670d Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Fri, 10 Oct 2025 20:41:27 +0100 Subject: [PATCH 59/95] start wiring up the challenges service --- build.gradle.kts | 33 +++++++++++++++- .../codosseum/model/GameState.java | 5 ++- .../codosseum/service/game/GameAggregate.java | 5 +++ .../codosseum/service/game/GameCommand.java | 4 ++ .../service/game/event/EventMapper.java | 1 + .../service/game/event/InternalGameEvent.java | 5 +++ .../game/process/GameStartedHandler.java | 39 ++++++++++++++++++- 7 files changed, 88 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 12dff34..7c1ef47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import de.undercouch.gradle.tasks.download.Download + + // SPDX-FileCopyrightText: 2023 Alex Wood // SPDX-License-Identifier: AGPL-3.0-or-later plugins { @@ -5,6 +8,7 @@ plugins { id("io.micronaut.aot") version "4.5.5" id("io.micronaut.openapi") version "4.5.5" id("groovy") + id("de.undercouch.download") version "5.6.0" checkstyle } @@ -87,6 +91,7 @@ tasks { graalvmNative.toolchainDetection.set(false) micronaut { + runtime("netty") testRuntime("junit5") processing { @@ -105,11 +110,37 @@ micronaut { optimizeNetty.set(true) } - openapi { + val provider: Provider = project.provider { + + RegularFile { downloadChallengesServiceOpenApi.get().outputFiles[0] } + } + openapi { + client( + "challenges-service", + provider + ) { + apiPackageName = "org.developerden.codosseum.challenges.client.api" + modelPackageName = "org.developerden.codosseum.challenges.client.model" + useOptional = true + } } } +val downloadChallengesServiceOpenApi by tasks.registering(Download::class) { + src("https://raw.githubusercontent.com/codosseum-org/challenges-service/refs/heads/openapi/openapi.yaml") + dest(layout.buildDirectory.dir("openapi")) + overwrite(true) + onlyIfModified(false) +} + +tasks.named("generateChallenges-serviceOpenApiModels") { // task created by micronaut-openapi plugin + dependsOn(downloadChallengesServiceOpenApi) +} + +tasks.named("generateChallenges-serviceOpenApiApis") { // task created by micronaut-openapi plugin + dependsOn(downloadChallengesServiceOpenApi) +} tasks.named("test") { useJUnitPlatform() diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 6b5f550..dd2f94b 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -16,7 +16,9 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.Info; /** * Internal model representing the current state of a game. @@ -29,6 +31,7 @@ @RecordBuilder.Options(defaultNotNull = true) public record GameState(@Nonnull UUID gameId, @Nonnull GamePhase phase, - @Nonnull GamePlayers players + @Nonnull GamePlayers players, + @Nullable Info currentChallengeInfo ) implements GameStateBuilder.With { } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 83c78d1..c5fc1ce 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -116,6 +116,9 @@ private Decision decide(GameCommand cmd) { ) ); } + case GameCommand.SetChallengeInfo(var id, var challenge) -> Decision.pure( + new InternalGameEvent.ChallengeSet(gameId, challenge) + ); }; } @@ -149,6 +152,8 @@ private GameState apply(GameState state, InternalGameEvent event) { .withPhase(GamePhase.WARMUP); case InternalGameEvent.GameStarted(var gameId) -> GameStateBuilder.from(state) .withPhase(GamePhase.IN_PROGRESS); + case InternalGameEvent.ChallengeSet challengeSet -> GameStateBuilder.from(state) + .withCurrentChallengeInfo(challengeSet.challengeInfo()); }; } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index bf0620b..04d6a4c 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -15,6 +15,7 @@ package org.developerden.codosseum.service.game; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.model.player.GamePlayer; /** @@ -46,5 +47,8 @@ record StartGame(UUID gameId) implements GameCommand { record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { } + record SetChallengeInfo(UUID gameId, Info info) implements GameCommand { + } + } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index fa8a1d7..e591cf3 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -48,6 +48,7 @@ public Optional fromInternal(InternalGameEvent internalEvent) { ); case InternalGameEvent.GameStarted ignored -> Optional.empty(); + case InternalGameEvent.ChallengeSet challengeSet -> Optional.empty(); }; } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 100bbc0..d987851 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -16,6 +16,7 @@ import java.time.Duration; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.player.GamePlayer; @@ -42,4 +43,8 @@ record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGame /** Emitted when the game transitions to in-progress. */ record GameStarted(UUID gameId) implements InternalGameEvent {} + + record ChallengeSet(UUID gameId, Info challengeInfo) implements InternalGameEvent { + + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java index 30ab50b..cf72bd5 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java @@ -15,18 +15,53 @@ package org.developerden.codosseum.service.game.process; import io.micronaut.context.event.ApplicationEventListener; -import io.micronaut.runtime.event.annotation.EventListener; +import io.micronaut.scheduling.annotation.Async; import jakarta.inject.Singleton; +import java.util.List; +import org.developerden.codosseum.challenges.client.api.DefaultApi; +import org.developerden.codosseum.challenges.client.model.Info; +import org.developerden.codosseum.repository.GameRepository; +import org.developerden.codosseum.service.game.GameCommand; +import org.developerden.codosseum.service.game.GameRunnerRegistry; import org.developerden.codosseum.service.game.event.InternalGameEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Singleton public class GameStartedHandler implements ApplicationEventListener { + private final DefaultApi defaultApi; + private final GameRepository gameRepository; + private final GameRunnerRegistry gameRunnerRegistry; + private final Logger log = LoggerFactory.getLogger(GameStartedHandler.class); + + public GameStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, + GameRunnerRegistry gameRunnerRegistry) { + this.defaultApi = defaultApi; + this.gameRepository = gameRepository; + this.gameRunnerRegistry = gameRunnerRegistry; + } + @Override + @Async public void onApplicationEvent(InternalGameEvent event) { var gameStarted = (InternalGameEvent.GameStarted) event; - System.out.println("Game " + gameStarted.gameId() + " has started!"); + var game = gameRepository.findGameById(gameStarted.gameId()) + .orElseThrow(); + + + Info info = defaultApi.challengesRandomGet( + List.of(), + List.of() + ).block(); + + gameRunnerRegistry.find(game.id()) + .ifPresentOrElse( + runner -> // use ifPresent in case of race condition where the game has ended + runner.tell(new GameCommand.SetChallengeInfo(game.id(), info)), + () -> log.warn("Game runner does not exist for game {}", game.id())); + } @Override From ad6fb4b87873fe5868830b9c17411f4b79e0ec7f Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Fri, 10 Oct 2025 20:43:00 +0100 Subject: [PATCH 60/95] rename Info -> ChallengeInfo --- src/main/java/org/developerden/codosseum/model/GameState.java | 3 ++- .../org/developerden/codosseum/service/game/GameCommand.java | 3 ++- .../codosseum/service/game/event/InternalGameEvent.java | 3 ++- .../codosseum/service/game/process/GameStartedHandler.java | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index dd2f94b..a77bcfd 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -18,6 +18,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.challenges.client.model.Info; /** @@ -32,6 +33,6 @@ public record GameState(@Nonnull UUID gameId, @Nonnull GamePhase phase, @Nonnull GamePlayers players, - @Nullable Info currentChallengeInfo + @Nullable ChallengeInfo currentChallengeInfo ) implements GameStateBuilder.With { } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 04d6a4c..76b87c7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -15,6 +15,7 @@ package org.developerden.codosseum.service.game; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.model.player.GamePlayer; @@ -47,7 +48,7 @@ record StartGame(UUID gameId) implements GameCommand { record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { } - record SetChallengeInfo(UUID gameId, Info info) implements GameCommand { + record SetChallengeInfo(UUID gameId, ChallengeInfo info) implements GameCommand { } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index d987851..04596a8 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -16,6 +16,7 @@ import java.time.Duration; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; @@ -44,7 +45,7 @@ record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGame /** Emitted when the game transitions to in-progress. */ record GameStarted(UUID gameId) implements InternalGameEvent {} - record ChallengeSet(UUID gameId, Info challengeInfo) implements InternalGameEvent { + record ChallengeSet(UUID gameId, ChallengeInfo challengeInfo) implements InternalGameEvent { } } diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java index cf72bd5..49e1d35 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java @@ -19,6 +19,7 @@ import jakarta.inject.Singleton; import java.util.List; import org.developerden.codosseum.challenges.client.api.DefaultApi; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; @@ -51,7 +52,7 @@ public void onApplicationEvent(InternalGameEvent event) { .orElseThrow(); - Info info = defaultApi.challengesRandomGet( + ChallengeInfo info = defaultApi.challengesRandomGet( List.of(), List.of() ).block(); From 7c788d6b06c39ee77f9888773a9ce4ce0b192744 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Fri, 10 Oct 2025 21:04:25 +0100 Subject: [PATCH 61/95] fix incorrect usage of the challengesRandomGet api --- .../codosseum/service/game/process/GameStartedHandler.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java index 49e1d35..5bf86bf 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java @@ -44,7 +44,6 @@ public GameStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, } @Override - @Async public void onApplicationEvent(InternalGameEvent event) { var gameStarted = (InternalGameEvent.GameStarted) event; @@ -53,8 +52,8 @@ public void onApplicationEvent(InternalGameEvent event) { ChallengeInfo info = defaultApi.challengesRandomGet( - List.of(), - List.of() + null, + null ).block(); gameRunnerRegistry.find(game.id()) From 6d471505ca7a5a09bb9a8c2771768089196135cf Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Fri, 10 Oct 2025 21:15:59 +0100 Subject: [PATCH 62/95] configure base-path --- src/main/resources/application-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a060799..c172c76 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -16,3 +16,6 @@ micronaut: authentication: idtoken oauth2: enabled: false + +openapi-micronaut-client: + base-path: http://localhost:8081 From c22474b785ce77b4ea9da3639ba3afc5f9bcba37 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 20:56:05 +0100 Subject: [PATCH 63/95] map ChallengeSet -> RoundStartEvent --- .../org/developerden/codosseum/event/RoundStartEvent.java | 2 +- .../developerden/codosseum/service/game/GameCommand.java | 1 - .../codosseum/service/game/event/EventMapper.java | 8 +++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java index bdd9daa..4f386e6 100644 --- a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java +++ b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java @@ -16,7 +16,7 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; -import org.developerden.codosseum.dto.ChallengeInfo; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; @RecordBuilder public record RoundStartEvent( diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 76b87c7..2be2061 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -16,7 +16,6 @@ import java.util.UUID; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; -import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.model.player.GamePlayer; /** diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index e591cf3..2157605 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -20,6 +20,7 @@ import org.developerden.codosseum.dto.PlayersMapper; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.event.PlayerJoinEvent; +import org.developerden.codosseum.event.RoundStartEvent; import org.developerden.codosseum.event.SyncEvent; import org.developerden.codosseum.model.GamePhase; @@ -48,7 +49,12 @@ public Optional fromInternal(InternalGameEvent internalEvent) { ); case InternalGameEvent.GameStarted ignored -> Optional.empty(); - case InternalGameEvent.ChallengeSet challengeSet -> Optional.empty(); + case InternalGameEvent.ChallengeSet challengeSet -> Optional.of( + new RoundStartEvent( + challengeSet.challengeInfo(), + 1 + ) + ); }; } } From 44104a56e722b5f4ca46c1e55f99bc5c882f6819 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:10:39 +0100 Subject: [PATCH 64/95] refactor SseEventSink to allow subscribing to all events --- .../service/game/event/SseEventSink.java | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java index 830431c..9e8da6d 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -18,9 +18,7 @@ import io.micronaut.runtime.event.annotation.EventListener; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import org.developerden.codosseum.event.GameEvent; import org.reactivestreams.Publisher; import org.slf4j.Logger; @@ -33,7 +31,8 @@ */ @Singleton public class SseEventSink { - private final Map> sinks = new ConcurrentHashMap<>(); + private final Sinks.Many eventSink = + Sinks.many().multicast().onBackpressureBuffer(); private final EventMapper eventMapper; private final Logger logger = LoggerFactory.getLogger(SseEventSink.class); @@ -48,34 +47,30 @@ public SseEventSink(EventMapper eventMapper) { public void on(InternalGameEvent event) { UUID gameId = event.gameId(); logger.info("Publishing event {} for game {}", event, gameId); - var sink = sink(gameId); - sink.tryEmitNext(event); - } - private Sinks.Many sink(UUID gameId) { - return sinks.computeIfAbsent(gameId, - ignored -> Sinks.many().multicast().onBackpressureBuffer()); + eventSink.tryEmitNext(event); } - public Publisher> subscribeToSse(UUID gameId) { - var sink = sink(gameId); - return sink.asFlux().map(event -> Event.of(event).name(event.getClass().getSimpleName())); + /** + * Subscribe to all events across all games. + * This is primarily for testing or logging purposes. + */ + public Publisher> allEvents() { + return eventSink.asFlux() + .flatMap(this::fromInternal); } + public Publisher> subscribeToPublicSse(UUID gameId) { - var sink = sink(gameId); - return sink.asFlux() - .flatMap(e -> eventMapper.fromInternal(e) - .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) - .map(Mono::just) - .orElse(Mono.empty()) - ); + return eventSink.asFlux() + .filter(e -> e.gameId().equals(gameId)) + .flatMap(this::fromInternal); } - public void close(UUID gameId) { - var sink = sinks.remove(gameId); - if (sink != null) { - sink.tryEmitComplete(); - } + private Mono> fromInternal(InternalGameEvent internalEvent) { + return eventMapper.fromInternal(internalEvent) + .map(publicEvent -> Event.of(publicEvent).name(publicEvent.getClass().getSimpleName())) + .map(Mono::just) + .orElse(Mono.empty()); } } From 47318b881b6822016d627bc4a2d03be6832890e8 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:19:15 +0100 Subject: [PATCH 65/95] make SseEventSink safer if emitting fails --- .../service/game/event/SseEventSink.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java index 9e8da6d..c5a477a 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/SseEventSink.java @@ -48,7 +48,20 @@ public void on(InternalGameEvent event) { UUID gameId = event.gameId(); logger.info("Publishing event {} for game {}", event, gameId); - eventSink.tryEmitNext(event); + eventSink.emitNext(event, (signalType, emitResult) -> + switch (emitResult) { + case FAIL_NON_SERIALIZED -> true; // retry until serialized + case FAIL_OVERFLOW -> { + logger.warn("Dropping event {} due to overflow", event); + yield false; + } + default -> { + if (emitResult.isFailure()) { + logger.debug("Emit failed: {}", emitResult); + } + yield false; + } + }); } /** From 6dee4972c702a788c521537a508588058e12fb51 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:26:12 +0100 Subject: [PATCH 66/95] improve GameFlowSpec, checking for events --- .../codosseum/GameFlowSpec.groovy | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index 559b98e..24f7cde 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -14,15 +14,26 @@ package org.developerden.codosseum + import io.micronaut.http.HttpRequest import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client +import io.micronaut.http.sse.Event import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject import org.developerden.codosseum.dto.* +import org.developerden.codosseum.event.GameEvent +import org.developerden.codosseum.event.RoundStartEvent +import org.developerden.codosseum.event.SyncEvent import org.developerden.codosseum.mode.GameModeType import org.developerden.codosseum.model.GamePhase +import org.developerden.codosseum.service.game.event.SseEventSink +import org.reactivestreams.Subscription +import reactor.core.publisher.BaseSubscriber import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import java.util.concurrent.CopyOnWriteArrayList @MicronautTest class GameFlowSpec extends Specification { @@ -30,6 +41,28 @@ class GameFlowSpec extends Specification { @Client("/") HttpClient http + @Inject + SseEventSink eventSink + + // has to be thread safe as events come from another thread + List eventsReceived = new CopyOnWriteArrayList<>() + + + void setup() { + eventsReceived.clear() + eventSink.allEvents().subscribe(new BaseSubscriber>() { + @Override + protected void hookOnSubscribe(Subscription subscription) { + request(Long.MAX_VALUE) + } + + @Override + protected void hookOnNext(Event value) { + eventsReceived.add(value.data) + } + }) + } + def "Warmup phase starts correctly"() { when: def response = http.toBlocking().exchange(HttpRequest.POST("/games", @@ -54,8 +87,15 @@ class GameFlowSpec extends Specification { then: getResponse.status.code == 204 - when: - sleep(5000) + and: "wait until both events arrive" + def conditions = new PollingConditions(timeout: 6, initialDelay: 0.1, delay: 0.1) + conditions.eventually { + assert eventsReceived.size() >= 2 + assert eventsReceived[0] instanceof SyncEvent + assert (eventsReceived[0] as SyncEvent).state() == GamePhase.WARMUP + assert eventsReceived[1] instanceof RoundStartEvent + } + def infoResponse = http.toBlocking().exchange("/games/${gameId}", GameInfo) then: @@ -64,5 +104,6 @@ class GameFlowSpec extends Specification { info.id() == gameId info.state() == GamePhase.IN_PROGRESS info.players().allPlayers().count() == 1 + } } From ed6440cd54f141afce008bf592d10af9327c188a Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:31:29 +0100 Subject: [PATCH 67/95] make GameStartedHandler#onApplicationEvent async --- .../codosseum/service/game/process/GameStartedHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java index 5bf86bf..999c675 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java @@ -17,10 +17,8 @@ import io.micronaut.context.event.ApplicationEventListener; import io.micronaut.scheduling.annotation.Async; import jakarta.inject.Singleton; -import java.util.List; import org.developerden.codosseum.challenges.client.api.DefaultApi; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; -import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunnerRegistry; @@ -44,6 +42,7 @@ public GameStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, } @Override + @Async public void onApplicationEvent(InternalGameEvent event) { var gameStarted = (InternalGameEvent.GameStarted) event; From 6e250339ea16f4d62dde658d531fa0ec511cc865 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:39:58 +0100 Subject: [PATCH 68/95] fix checkstyle checking generated code --- build.gradle.kts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7c1ef47..f931256 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,7 +75,13 @@ java { checkstyle { toolVersion = "11.1.0" configFile = configDirectory.file("google_checks.xml").get().asFile - sourceSets = emptySet() + sourceSets = listOf(project.sourceSets.main.get()) +} + +tasks.withType { + exclude { + it.file.path.contains("generated/openapi") // TODO: this could be slow? + } } tasks { From 8559cda7e2f4457e3e02acdd3e66671ec931f253 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:44:23 +0100 Subject: [PATCH 69/95] update to latest micronaut version --- gradle.properties | 2 +- .../developerden/codosseum/model/player/EphemeralPlayer.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index 4f96351..f9e02d7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ # SPDX-FileCopyrightText: 2023 Alex Wood # SPDX-License-Identifier: AGPL-3.0-or-later -micronautVersion=4.0.1 +micronautVersion=4.9.4 diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index f8bf581..2e7de56 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -16,7 +16,7 @@ import java.util.UUID; import javax.annotation.Nonnull; -import org.checkerframework.common.aliasing.qual.Unique; + /** * An ephemeral player, not tied to any persistent identity. @@ -26,7 +26,7 @@ * @param key a unique key for the player, used to identify them in the game. * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. */ -public record EphemeralPlayer(@Nonnull @Unique String name, +public record EphemeralPlayer(@Nonnull String name, @Nonnull UUID gameId, @Nonnull String key, boolean admin) implements GamePlayer { From 9c97fc228c314a0a15811482409e7bfff4e90cd7 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:46:12 +0100 Subject: [PATCH 70/95] remove dead imports --- src/main/java/org/developerden/codosseum/model/GameState.java | 1 - .../codosseum/service/game/event/InternalGameEvent.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index a77bcfd..cc765fe 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -19,7 +19,6 @@ import jakarta.annotation.Nullable; import java.util.UUID; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; -import org.developerden.codosseum.challenges.client.model.Info; /** * Internal model representing the current state of a game. diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 04596a8..c1aa429 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -17,7 +17,6 @@ import java.time.Duration; import java.util.UUID; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; -import org.developerden.codosseum.challenges.client.model.Info; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.player.GamePlayer; From 929b3a2a972d61dfb3f7734773fe21b3d5fafe94 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:50:18 +0100 Subject: [PATCH 71/95] remove use of deprecated code --- .../developerden/codosseum/auth/GameAuthorizationRule.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java index 5a619e8..75c0e37 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java +++ b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java @@ -15,13 +15,13 @@ package org.developerden.codosseum.auth; import io.micronaut.core.annotation.Nullable; -import io.micronaut.http.HttpAttributes; import io.micronaut.http.HttpRequest; import io.micronaut.security.authentication.Authentication; import io.micronaut.security.rules.AbstractSecurityRule; import io.micronaut.security.rules.SecurityRuleResult; import io.micronaut.security.token.RolesFinder; import io.micronaut.web.router.MethodBasedRouteMatch; +import io.micronaut.web.router.RouteAttributes; import io.micronaut.web.router.RouteMatch; import jakarta.inject.Singleton; import java.util.Arrays; @@ -38,7 +38,8 @@ protected GameAuthorizationRule(RolesFinder rolesFinder) { @Override public Publisher check(@Nullable HttpRequest request, @Nullable Authentication authentication) { - RouteMatch routeMatch = request.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class) + + RouteMatch routeMatch = RouteAttributes.getRouteMatch(request) .orElse(null); if (routeMatch instanceof MethodBasedRouteMatch methodMatch && methodMatch.hasAnnotation(GameAuthorized.class)) { From 467613735e3268f0e8ca40825327314fe5784610 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Sat, 11 Oct 2025 22:52:36 +0100 Subject: [PATCH 72/95] remove MapStruct warning --- src/main/java/org/developerden/codosseum/dto/PlayersMapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java index cba939c..a52bbd8 100644 --- a/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java +++ b/src/main/java/org/developerden/codosseum/dto/PlayersMapper.java @@ -33,7 +33,7 @@ public interface PlayersMapper { @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.COMPILE_ERROR) @SubclassMapping(target = Player.class, source = EphemeralPlayer.class) - + @Mapping(target = "name", ignore = true) // Handled in EphemeralPlayer mapping Player toDto(GamePlayer player); @Mapping(target = "players", source = "others") From 62a2e4fb8a89715c2014b249bc6e710caf24c321 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 10:58:42 +0100 Subject: [PATCH 73/95] improve documentation of GameCommand --- .../codosseum/service/game/GameCommand.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 2be2061..2ef0042 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -44,9 +44,21 @@ record StartWarmup(UUID gameId) implements GameCommand { record StartGame(UUID gameId) implements GameCommand { } + /** + * Command to add a player to a game. + * @param gameId the id of the game to add the player to + * @param player the player to add + */ record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { } + /** + * Command to set the current challenge for a game. + * It is undefined what this does if the game is not in a state to accept a new challenge (i.e. is already in progress). + * + * @param gameId the id of the game to set the challenge for + * @param info the challenge info to set + */ record SetChallengeInfo(UUID gameId, ChallengeInfo info) implements GameCommand { } From c6420da33ab2b9afb48eb77e72d4444be4e551f2 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 11:40:59 +0100 Subject: [PATCH 74/95] start processing game rounds --- .../codosseum/event/RoundStartEvent.java | 5 +- .../codosseum/model/GameState.java | 14 +++-- .../codosseum/service/game/GameAggregate.java | 29 ++++++++++ .../codosseum/service/game/GameCommand.java | 12 ++++ .../service/game/event/EventMapper.java | 9 ++- .../service/game/event/InternalGameEvent.java | 25 +++++++-- .../game/process/ChallengeSetHandler.java | 55 +++++++++++++++++++ .../DefaultInitialGameStateProvider.java | 1 + .../codosseum/GameFlowSpec.groovy | 4 +- 9 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java diff --git a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java index 4f386e6..c33b56c 100644 --- a/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java +++ b/src/main/java/org/developerden/codosseum/event/RoundStartEvent.java @@ -16,6 +16,7 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; +import java.time.Duration; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; @RecordBuilder @@ -23,6 +24,8 @@ public record RoundStartEvent( @Nonnull ChallengeInfo challenge, - int round + int round, + + Duration roundLength ) implements GameEvent { } diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index cc765fe..17e6b7a 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -21,17 +21,21 @@ import org.developerden.codosseum.challenges.client.model.ChallengeInfo; /** - * Internal model representing the current state of a game. + * Internal model representing the current mutable state of a game. * - * @param gameId the unique identifier of the game - * @param phase the current phase of the game - * @param players the players involved in the game + * @param gameId the unique identifier of the game + * @param phase the current phase of the game + * @param players the players involved in the game + * @param currentChallengeInfo the current challenge information, if a challenge is active + * @param currentRound the current round number of the game, or -1 if the game hasn't started yet */ @RecordBuilder @RecordBuilder.Options(defaultNotNull = true) public record GameState(@Nonnull UUID gameId, @Nonnull GamePhase phase, @Nonnull GamePlayers players, - @Nullable ChallengeInfo currentChallengeInfo + @Nullable ChallengeInfo currentChallengeInfo, + int currentRound, + boolean acceptingSolutions ) implements GameStateBuilder.With { } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index c5fc1ce..7301291 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -17,7 +17,9 @@ import java.time.Duration; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.UUID; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GamePlayersBuilder; import org.developerden.codosseum.model.GameState; @@ -119,6 +121,30 @@ private Decision decide(GameCommand cmd) { case GameCommand.SetChallengeInfo(var id, var challenge) -> Decision.pure( new InternalGameEvent.ChallengeSet(gameId, challenge) ); + case GameCommand.StartRound(var id) -> { + requirePhase(GamePhase.IN_PROGRESS); + + var nextRound = Math.max(gameState.currentRound() + 1, 1); + var roundLength = Duration.ofMinutes(5); // TODO: make configurable / dynamic + ChallengeInfo challenge = Objects.requireNonNull(getGameState().currentChallengeInfo(), + "Cannot start round without a challenge set"); + yield Decision.pure( + new InternalGameEvent.RoundStarted(gameId, challenge, + nextRound, roundLength) + ).withEffects( + new SideEffect.ScheduleAfter( + "round->over", + roundLength, + new GameCommand.EndRound(gameId) + )); + } + case GameCommand.EndRound endRound -> { + requirePhase(GamePhase.IN_PROGRESS); + // For now, ending a round is a no-op + yield Decision.pure( + new InternalGameEvent.RoundEnded(gameId, gameState.currentRound()) + ); + } }; } @@ -154,6 +180,9 @@ private GameState apply(GameState state, InternalGameEvent event) { .withPhase(GamePhase.IN_PROGRESS); case InternalGameEvent.ChallengeSet challengeSet -> GameStateBuilder.from(state) .withCurrentChallengeInfo(challengeSet.challengeInfo()); + case InternalGameEvent.RoundEnded roundEnded -> state; // TODO: implement round end logic + case InternalGameEvent.RoundStarted roundStarted -> GameStateBuilder.from(state) + .withCurrentRound(roundStarted.roundNumber()); }; } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 2ef0042..74cd2fa 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -16,6 +16,7 @@ import java.util.UUID; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; +import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.player.GamePlayer; /** @@ -46,6 +47,7 @@ record StartGame(UUID gameId) implements GameCommand { /** * Command to add a player to a game. + * * @param gameId the id of the game to add the player to * @param player the player to add */ @@ -62,5 +64,15 @@ record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { record SetChallengeInfo(UUID gameId, ChallengeInfo info) implements GameCommand { } + /** + * Command to start a new round in a game. + * The game phase should be {@link GamePhase#IN_PROGRESS} for this to have any effect. + * @param gameId the id of the game to start the round for + */ + record StartRound(UUID gameId) implements GameCommand { + } + + record EndRound(UUID gameId) implements GameCommand { + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 2157605..45b80ab 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -49,12 +49,15 @@ public Optional fromInternal(InternalGameEvent internalEvent) { ); case InternalGameEvent.GameStarted ignored -> Optional.empty(); - case InternalGameEvent.ChallengeSet challengeSet -> Optional.of( + case InternalGameEvent.ChallengeSet ignored -> Optional.empty(); + case InternalGameEvent.RoundStarted roundStarted -> Optional.of( new RoundStartEvent( - challengeSet.challengeInfo(), - 1 + roundStarted.challenge(), + roundStarted.roundNumber(), + roundStarted.roundLength() ) ); + case InternalGameEvent.RoundEnded roundEnded -> Optional.empty(); }; } } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index c1aa429..3aacc9e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -15,7 +15,10 @@ package org.developerden.codosseum.service.game.event; import java.time.Duration; +import java.util.List; import java.util.UUID; + +import javax.annotation.Nonnull; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.event.GameEvent; import org.developerden.codosseum.model.Game; @@ -38,13 +41,27 @@ record GameCreated(UUID gameId, GamePlayer creator) implements InternalGameEvent record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent { } - /** Emitted when the warmup countdown begins. */ - record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGameEvent {} + /** + * Emitted when the warmup countdown begins. + */ + record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGameEvent { + } - /** Emitted when the game transitions to in-progress. */ - record GameStarted(UUID gameId) implements InternalGameEvent {} + /** + * Emitted when the game transitions to in-progress. + */ + record GameStarted(UUID gameId) implements InternalGameEvent { + } record ChallengeSet(UUID gameId, ChallengeInfo challengeInfo) implements InternalGameEvent { } + + record RoundStarted(UUID gameId, @Nonnull ChallengeInfo challenge, int roundNumber, + @Nonnull Duration roundLength) + implements InternalGameEvent { + } + + record RoundEnded(UUID gameId, int roundNumber) implements InternalGameEvent { + } } diff --git a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java new file mode 100644 index 0000000..7a3091d --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java @@ -0,0 +1,55 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.service.game.process; + +import io.micronaut.context.event.ApplicationEventListener; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.developerden.codosseum.service.game.GameCommand; +import org.developerden.codosseum.service.game.GameRunnerRegistry; +import org.developerden.codosseum.service.game.event.InternalGameEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Listens for challenges being set and + */ +@Singleton +public class ChallengeSetHandler implements ApplicationEventListener { + private final GameRunnerRegistry gameRunnerRegistry; + + private final Logger log = LoggerFactory.getLogger(ChallengeSetHandler.class); + + @Inject + public ChallengeSetHandler(GameRunnerRegistry gameRunnerRegistry) { + this.gameRunnerRegistry = gameRunnerRegistry; + } + + @Override + public void onApplicationEvent(InternalGameEvent event) { + if (event instanceof InternalGameEvent.ChallengeSet challengeSet) { + gameRunnerRegistry.find(challengeSet.gameId()) + .ifPresentOrElse(runner -> runner.tell(new GameCommand.StartRound( + challengeSet.gameId() + )), () -> log.warn("Tried to start round for non-existent game {}", + challengeSet.gameId())); + } + } + + @Override + public boolean supports(InternalGameEvent event) { + return event instanceof InternalGameEvent.ChallengeSet; + } +} diff --git a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java index 6052ec1..c20fd36 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java @@ -33,6 +33,7 @@ public GameState create(UUID gameId) { .gameId(gameId) .phase(GamePhase.WAITING_FOR_PLAYERS) .players(new GamePlayers(null, new HashSet<>())) + .currentRound(-1) .build(); } } diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index 24f7cde..73ba985 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -90,10 +90,12 @@ class GameFlowSpec extends Specification { and: "wait until both events arrive" def conditions = new PollingConditions(timeout: 6, initialDelay: 0.1, delay: 0.1) conditions.eventually { - assert eventsReceived.size() >= 2 + assert eventsReceived.size() == 2 assert eventsReceived[0] instanceof SyncEvent assert (eventsReceived[0] as SyncEvent).state() == GamePhase.WARMUP assert eventsReceived[1] instanceof RoundStartEvent + assert (eventsReceived[1] as RoundStartEvent).round() == 1 + } From 877f1f61daa397e2b4385e32b7623cade6cabb4d Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 11:41:57 +0100 Subject: [PATCH 75/95] update CI workflows to work on any branch --- .github/workflows/compliance.yml | 2 -- .github/workflows/gradle.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 0356dfa..7c58a03 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -1,9 +1,7 @@ name: compliance tasks on: push: - branches: [ main, develop ] pull_request: - branches: [ main, develop ] permissions: contents: read diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 18f8414..f22985a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,9 +1,7 @@ name: Gradle build on: push: - branches: [ main, develop ] pull_request: - branches: [ main, develop ] permissions: contents: read From 9ec03219f9f976e320e2196e4ab963b92d93c96e Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 11:49:46 +0100 Subject: [PATCH 76/95] make checkstyle happy --- .../org/developerden/codosseum/service/game/GameCommand.java | 1 + .../codosseum/service/game/event/InternalGameEvent.java | 2 -- .../codosseum/service/game/process/ChallengeSetHandler.java | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 74cd2fa..3f6e33e 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -67,6 +67,7 @@ record SetChallengeInfo(UUID gameId, ChallengeInfo info) implements GameCommand /** * Command to start a new round in a game. * The game phase should be {@link GamePhase#IN_PROGRESS} for this to have any effect. + * * @param gameId the id of the game to start the round for */ record StartRound(UUID gameId) implements GameCommand { diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 3aacc9e..39ca5b7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -15,9 +15,7 @@ package org.developerden.codosseum.service.game.event; import java.time.Duration; -import java.util.List; import java.util.UUID; - import javax.annotation.Nonnull; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.event.GameEvent; diff --git a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java index 7a3091d..89ad1a9 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java @@ -24,7 +24,7 @@ import org.slf4j.LoggerFactory; /** - * Listens for challenges being set and + * Listens for challenges being set for a game, and starts the round when they are. */ @Singleton public class ChallengeSetHandler implements ApplicationEventListener { From f88573731a7b7e72e0c5b2a7af471e59445f4f0b Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 11:51:55 +0100 Subject: [PATCH 77/95] update gradle workflow to not use ancient action --- .github/workflows/gradle.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index f22985a..85e14af 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -16,10 +16,12 @@ jobs: with: distribution: temurin java-version: 25 + - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - with: - arguments: build --no-daemon + uses: gradle/actions/setup-gradle@v5 + + - name: Build with Gradle + run: ./gradlew build --no-daemon checkstyle: runs-on: ubuntu-latest steps: @@ -30,6 +32,7 @@ jobs: distribution: temurin java-version: 25 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - with: - arguments: checkstyleMain checkstyleTest --no-daemon + uses: gradle/actions/setup-gradle@v5 + + - name: Run Checkstyle + run: ./gradlew checkstyleMain checkstyleTest --no-daemon From 5f0648e3727acb786a3a000cb662de743159c714 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 11:57:26 +0100 Subject: [PATCH 78/95] don't use configuration cache in actions --- .github/workflows/gradle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 85e14af..53042ff 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,7 +21,7 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Build with Gradle - run: ./gradlew build --no-daemon + run: ./gradlew build --no-daemon --no-configuration-cache checkstyle: runs-on: ubuntu-latest steps: @@ -35,4 +35,4 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Run Checkstyle - run: ./gradlew checkstyleMain checkstyleTest --no-daemon + run: ./gradlew checkstyleMain checkstyleTest --no-daemon --no-configuration-cache From 9d136c4576031c2d4707b68ed980ffd1b9fdbf37 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 12:00:03 +0100 Subject: [PATCH 79/95] debug actions --- .github/workflows/gradle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 53042ff..4b1d324 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,7 +21,7 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Build with Gradle - run: ./gradlew build --no-daemon --no-configuration-cache + run: ./gradlew build --no-daemon --no-configuration-cache --stacktrace --info checkstyle: runs-on: ubuntu-latest steps: From ae273f0edb499cd869d2758de8af63b9f31eaf59 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 12:06:04 +0100 Subject: [PATCH 80/95] fix gradle build not being isolatable from incorrect provider usage --- .github/workflows/gradle.yml | 4 ++-- build.gradle.kts | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4b1d324..85e14af 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,7 +21,7 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Build with Gradle - run: ./gradlew build --no-daemon --no-configuration-cache --stacktrace --info + run: ./gradlew build --no-daemon checkstyle: runs-on: ubuntu-latest steps: @@ -35,4 +35,4 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Run Checkstyle - run: ./gradlew checkstyleMain checkstyleTest --no-daemon --no-configuration-cache + run: ./gradlew checkstyleMain checkstyleTest --no-daemon diff --git a/build.gradle.kts b/build.gradle.kts index f931256..a7daaf7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ plugins { version = "0.1.0" group = "org.developerden" +val challengesOpenApiSpec: Provider = layout.buildDirectory.file("openapi/challenges-openapi.yaml") repositories { maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") { @@ -116,15 +117,10 @@ micronaut { optimizeNetty.set(true) } - val provider: Provider = project.provider { - - RegularFile { downloadChallengesServiceOpenApi.get().outputFiles[0] } - } - openapi { client( "challenges-service", - provider + challengesOpenApiSpec ) { apiPackageName = "org.developerden.codosseum.challenges.client.api" modelPackageName = "org.developerden.codosseum.challenges.client.model" @@ -133,9 +129,10 @@ micronaut { } } + val downloadChallengesServiceOpenApi by tasks.registering(Download::class) { src("https://raw.githubusercontent.com/codosseum-org/challenges-service/refs/heads/openapi/openapi.yaml") - dest(layout.buildDirectory.dir("openapi")) + dest(challengesOpenApiSpec) overwrite(true) onlyIfModified(false) } From 24278c00f99aefb9ad6fa6bb1c83e6c90cbb7611 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 13:45:27 +0100 Subject: [PATCH 81/95] use a mock in the GameFlowTest rather than calling the real challenges service --- build.gradle.kts | 1 + .../codosseum/GameFlowSpec.groovy | 24 +++++-- .../codosseum/PlayerControllerSpec.groovy | 2 + .../developerden/codosseum/stubs/Stubs.java | 62 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/developerden/codosseum/stubs/Stubs.java diff --git a/build.gradle.kts b/build.gradle.kts index a7daaf7..5c15d9f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -125,6 +125,7 @@ micronaut { apiPackageName = "org.developerden.codosseum.challenges.client.api" modelPackageName = "org.developerden.codosseum.challenges.client.model" useOptional = true + useSealed = true } } } diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index 73ba985..faa59a6 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -14,13 +14,15 @@ package org.developerden.codosseum - import io.micronaut.http.HttpRequest import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client import io.micronaut.http.sse.Event +import io.micronaut.test.annotation.MockBean import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject +import org.developerden.codosseum.challenges.client.api.DefaultApi +import org.developerden.codosseum.challenges.client.model.Difficulty import org.developerden.codosseum.dto.* import org.developerden.codosseum.event.GameEvent import org.developerden.codosseum.event.RoundStartEvent @@ -28,8 +30,10 @@ import org.developerden.codosseum.event.SyncEvent import org.developerden.codosseum.mode.GameModeType import org.developerden.codosseum.model.GamePhase import org.developerden.codosseum.service.game.event.SseEventSink +import org.developerden.codosseum.stubs.Stubs import org.reactivestreams.Subscription import reactor.core.publisher.BaseSubscriber +import reactor.core.publisher.Mono import spock.lang.Specification import spock.util.concurrent.PollingConditions @@ -43,6 +47,8 @@ class GameFlowSpec extends Specification { @Inject SseEventSink eventSink + @Inject + DefaultApi defaultApi // has to be thread safe as events come from another thread List eventsReceived = new CopyOnWriteArrayList<>() @@ -64,6 +70,10 @@ class GameFlowSpec extends Specification { } def "Warmup phase starts correctly"() { + given: + def challenge = Stubs.fakeChallengeInfo() + 1 * defaultApi.challengesRandomGet(_, _) >> Mono.just(challenge) + when: def response = http.toBlocking().exchange(HttpRequest.POST("/games", new GameCreateRequest( @@ -79,16 +89,17 @@ class GameFlowSpec extends Specification { def gameId = response.body().id() def key = response.body().adminKey() + when: - def getResponse = http.toBlocking() + def warmupResponse = http.toBlocking() .exchange(HttpRequest.POST("/games/${gameId}/warmup", null) .header("Authorization", "Game $key"), Void) then: - getResponse.status.code == 204 + warmupResponse.status.code == 204 and: "wait until both events arrive" - def conditions = new PollingConditions(timeout: 6, initialDelay: 0.1, delay: 0.1) + def conditions = new PollingConditions(timeout: 7, initialDelay: 0.1, delay: 0.1) conditions.eventually { assert eventsReceived.size() == 2 assert eventsReceived[0] instanceof SyncEvent @@ -108,4 +119,9 @@ class GameFlowSpec extends Specification { info.players().allPlayers().count() == 1 } + + @MockBean(DefaultApi) + DefaultApi defaultApi() { + Mock(DefaultApi) + } } diff --git a/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy b/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy index 49b981a..9c9cd2d 100644 --- a/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy @@ -59,4 +59,6 @@ class PlayerControllerSpec extends Specification { info.settings().allowedGameModes() == [GameModeType.FASTEST] } + + } diff --git a/src/test/java/org/developerden/codosseum/stubs/Stubs.java b/src/test/java/org/developerden/codosseum/stubs/Stubs.java new file mode 100644 index 0000000..343518d --- /dev/null +++ b/src/test/java/org/developerden/codosseum/stubs/Stubs.java @@ -0,0 +1,62 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.stubs; + +import java.util.List; +import org.developerden.codosseum.challenges.client.model.Author; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; +import org.developerden.codosseum.challenges.client.model.Contact; +import org.developerden.codosseum.challenges.client.model.Difficulty; +import org.developerden.codosseum.challenges.client.model.Example; +import org.developerden.codosseum.challenges.client.model.Solution; +import org.developerden.codosseum.challenges.client.model.Test; + +public class Stubs { + + public static ChallengeInfo fakeChallengeInfo() { + return new ChallengeInfo( + "empty-schema", + new Author( + "Author", + List.of( + new Contact( + "Author Contact Type", + "Author Contact Value" + ) + ) + ), + "License", + "Language", + "Test Challenge", + Difficulty.EASY, + List.of("tag1", "tag2"), + "This is a test challenge.", + "Input Format", + List.of(new Example( + List.of("Example Input"), + List.of("Example Output") + )), + List.of(new Test( + "Test", + List.of("Test Input"), + List.of("Test Output" + ))), + new Solution( + "Solution Language", + "solution file" + ) + ); + } +} From 8bda20c9cf7e4af9d71445e07c75f57ee28dd244 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 14:09:02 +0100 Subject: [PATCH 82/95] add logging to ChallengeSetHandler --- .../service/game/process/ChallengeSetHandler.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java index 89ad1a9..e1a2020 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java @@ -41,9 +41,12 @@ public ChallengeSetHandler(GameRunnerRegistry gameRunnerRegistry) { public void onApplicationEvent(InternalGameEvent event) { if (event instanceof InternalGameEvent.ChallengeSet challengeSet) { gameRunnerRegistry.find(challengeSet.gameId()) - .ifPresentOrElse(runner -> runner.tell(new GameCommand.StartRound( - challengeSet.gameId() - )), () -> log.warn("Tried to start round for non-existent game {}", + .ifPresentOrElse(runner -> { + runner.tell(new GameCommand.StartRound( + challengeSet.gameId() + )); + log.info("Started round for game {}", challengeSet.gameId()); + }, () -> log.warn("Tried to start round for non-existent game {}", challengeSet.gameId())); } } From 8d1b6029e4080de333580bfa2e93729d1dca1c23 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 14:15:08 +0100 Subject: [PATCH 83/95] remove Game#adminKey --- src/main/java/org/developerden/codosseum/model/Game.java | 3 --- .../org/developerden/codosseum/service/GameService.java | 7 ++----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/model/Game.java b/src/main/java/org/developerden/codosseum/model/Game.java index 96a72ab..b0ee00b 100644 --- a/src/main/java/org/developerden/codosseum/model/Game.java +++ b/src/main/java/org/developerden/codosseum/model/Game.java @@ -24,15 +24,12 @@ * All mutable data is held in {@link GameState}. * * @param id the unique identifier of the game. - * @param adminKey the admin key for the game, used to authenticate admin actions. * @param settings the settings for the game. * @param mode the game mode. */ @RecordBuilder public record Game( UUID id, - // TODO: this should be removed and handled via players' individual keys + {@link EphemeralPlayer#admin()} - @Deprecated String adminKey, GameSettings settings, GameMode mode ) { diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index fba854b..33c16cd 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -38,7 +38,6 @@ import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunner; import org.developerden.codosseum.service.game.GameRunnerRegistry; -import org.developerden.codosseum.service.game.event.EventSink; import org.developerden.codosseum.service.game.state.SnapshotStore; import org.developerden.codosseum.utils.CollectionUtils; @@ -49,19 +48,17 @@ public class GameService { private final GameRunnerRegistry gameRunnerRegistry; private final SnapshotStore snapshotStore; - private final EventSink eventSink; private final AuthRepository authRepository; private final PlayersMapper playersMapper; public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, - EventSink eventSink, AuthRepository authRepository, + AuthRepository authRepository, PlayersMapper playersMapper) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; this.snapshotStore = snapshotStore; - this.eventSink = eventSink; this.authRepository = authRepository; this.playersMapper = playersMapper; } @@ -79,7 +76,7 @@ public GameCreateResponse createGame(GameCreateRequest request) { var gameModeType = CollectionUtils.pickRandom(gameModeTypes); var gameMode = gameModeFactory.fromType(gameModeType); - var game = new Game(UUID.randomUUID(), generateFreshKey(), request.settings(), gameMode); + var game = new Game(UUID.randomUUID(), request.settings(), gameMode); gameRepository.insertGame(game); From 7cea138dc6ab457d37d0521bf17f6080894ade83 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 14:31:53 +0100 Subject: [PATCH 84/95] start refactoring GameInfo DTO --- .../developerden/codosseum/dto/GameInfo.java | 32 +++++++++++++++++-- .../model/player/EphemeralPlayer.java | 3 +- .../codosseum/service/GameService.java | 27 +++++++--------- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index 2d23d2b..727dbd6 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -16,6 +16,7 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.List; @@ -23,8 +24,22 @@ import org.developerden.codosseum.mode.GameMode; import org.developerden.codosseum.model.GamePhase; +/** + * Public information about a game. + * + * @param timeLeft A generic countdown timer, in seconds. Meaning depends on the game phase: + *

    + *
  • {@link GamePhase#WARMUP}: Time until game starts
  • + *
  • {@link GamePhase#IN_PROGRESS}: Time until current round ends
  • + *
  • Other phases are undefined and the value should be ignored
  • + *
+ */ @RecordBuilder @Serdeable +@Schema( + description = "Public information about a game", + title = "GameInfo" +) public record GameInfo( @Nonnull GameSettings settings, @@ -41,10 +56,23 @@ public record GameInfo( @Nonnull GamePhase state, + @Schema( + description = """ + A generic countdown timer, in seconds. Meaning depends on the game phase: + - *WARMUP*: Time until game starts + - *IN_PROGRESS*: Time until current round ends + - Other phases are undefined and the value should be ignored + """, + example = "120", + minimum = "0" + ) int timeLeft, - long nextStateAt, - + @Schema( + description = "The current round number, or null if the game hasn't started yet", + example = "1", + minimum = "1" + ) @Nullable Integer round, diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index 2e7de56..faa45cd 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -28,7 +28,8 @@ */ public record EphemeralPlayer(@Nonnull String name, @Nonnull UUID gameId, - @Nonnull String key, boolean admin) + @Nonnull String key, + boolean admin) implements GamePlayer { } diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 33c16cd..1ad2385 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -103,6 +103,7 @@ public Optional getGame(UUID id) { if (gameOpt.isEmpty()) { return Optional.empty(); } + var game = gameOpt.get(); var stateOpt = gameRunnerRegistry .find(id) @@ -110,21 +111,17 @@ public Optional getGame(UUID id) { .or(() -> snapshotStore.load(id)) .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); - var phase = stateOpt.phase(); - - - return gameOpt - .map(game -> new GameInfo( - game.settings(), - game.id(), - game.mode(), - playersMapper.toDto(stateOpt.players()), - phase, - 0, - 0, - 1, - new ArrayList<>() - )); + + return Optional.of(new GameInfo( + game.settings(), + game.id(), + game.mode(), + playersMapper.toDto(stateOpt.players()), + stateOpt.phase(), + 0, + stateOpt.currentRound() == -1 ? null : stateOpt.currentRound() + 1, + new ArrayList<>() + )); } public void startGame(UUID gameId) { From 33cf6c0785c40544c493be16e4c5733e0441d0bc Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 19:30:05 +0100 Subject: [PATCH 85/95] refactor phase system, separating dto and model unions --- .../developerden/codosseum/dto/GameInfo.java | 36 +--------- .../codosseum/dto/phase/ApiGamePhase.java | 29 ++++++++ .../dto/phase/ApiInProgressPhase.java | 26 +++++++ .../dto/phase/ApiUndefinedPhase.java | 26 +++++++ .../dto/phase/ApiWaitingForPlayersPhase.java | 27 +++++++ .../codosseum/dto/phase/ApiWarmupPhase.java | 26 +++++++ .../codosseum/dto/phase/PhaseMapper.java | 49 +++++++++++++ .../codosseum/model/GamePlayers.java | 5 ++ .../codosseum/model/GameState.java | 32 +++++++-- .../codosseum/model/phase/GamePhase.java | 25 +++++++ .../codosseum/model/phase/GamePhaseKind.java | 34 +++++++++ .../model/phase/InProgressPhase.java | 37 ++++++++++ .../codosseum/model/phase/UndefinedPhase.java | 25 +++++++ .../model/phase/WaitingForPlayersPhase.java | 33 +++++++++ .../codosseum/model/phase/WarmupPhase.java | 33 +++++++++ .../model/phase/WithPlayersPhase.java | 25 +++++++ .../codosseum/service/GameService.java | 25 ++++--- .../codosseum/service/game/GameAggregate.java | 70 ++++++++++++------- .../DefaultInitialGameStateProvider.java | 9 +-- .../codosseum/GameFlowSpec.groovy | 4 +- 20 files changed, 496 insertions(+), 80 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/ApiGamePhase.java create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/ApiInProgressPhase.java create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/ApiUndefinedPhase.java create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java create mode 100644 src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/GamePhase.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/GamePhaseKind.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java create mode 100644 src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java diff --git a/src/main/java/org/developerden/codosseum/dto/GameInfo.java b/src/main/java/org/developerden/codosseum/dto/GameInfo.java index 727dbd6..5d28aa5 100644 --- a/src/main/java/org/developerden/codosseum/dto/GameInfo.java +++ b/src/main/java/org/developerden/codosseum/dto/GameInfo.java @@ -18,22 +18,12 @@ import io.soabase.recordbuilder.core.RecordBuilder; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; import java.util.List; import java.util.UUID; import org.developerden.codosseum.mode.GameMode; -import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.dto.phase.ApiGamePhase; + -/** - * Public information about a game. - * - * @param timeLeft A generic countdown timer, in seconds. Meaning depends on the game phase: - *
    - *
  • {@link GamePhase#WARMUP}: Time until game starts
  • - *
  • {@link GamePhase#IN_PROGRESS}: Time until current round ends
  • - *
  • Other phases are undefined and the value should be ignored
  • - *
- */ @RecordBuilder @Serdeable @Schema( @@ -54,27 +44,7 @@ public record GameInfo( Players players, @Nonnull - GamePhase state, - - @Schema( - description = """ - A generic countdown timer, in seconds. Meaning depends on the game phase: - - *WARMUP*: Time until game starts - - *IN_PROGRESS*: Time until current round ends - - Other phases are undefined and the value should be ignored - """, - example = "120", - minimum = "0" - ) - int timeLeft, - - @Schema( - description = "The current round number, or null if the game hasn't started yet", - example = "1", - minimum = "1" - ) - @Nullable - Integer round, + ApiGamePhase phase, @Nonnull List results diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiGamePhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiGamePhase.java new file mode 100644 index 0000000..3e5c972 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiGamePhase.java @@ -0,0 +1,29 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.dto.phase; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; + +@Serdeable +@Introspected +@Schema(description = "The current phase of the game and any associated data.", + discriminatorProperty = "kind") +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind", visible = true) +public sealed interface ApiGamePhase + permits ApiUndefinedPhase, ApiWaitingForPlayersPhase, ApiWarmupPhase, ApiInProgressPhase { +} diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiInProgressPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiInProgressPhase.java new file mode 100644 index 0000000..4fb91c5 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiInProgressPhase.java @@ -0,0 +1,26 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ +package org.developerden.codosseum.dto.phase; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; +import org.developerden.codosseum.model.phase.GamePhaseKind; + +@Serdeable +@Schema(description = "A round is in progress") +@JsonTypeName("IN_PROGRESS") +public record ApiInProgressPhase(int currentRound) implements ApiGamePhase { + +} diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiUndefinedPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiUndefinedPhase.java new file mode 100644 index 0000000..44b63ac --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiUndefinedPhase.java @@ -0,0 +1,26 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ +package org.developerden.codosseum.dto.phase; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; +import org.developerden.codosseum.model.phase.GamePhaseKind; + +@Serdeable +@Schema(description = "Undefined phase (error state; should rarely be seen)") +@JsonTypeName("UNDEFINED") +public record ApiUndefinedPhase() implements ApiGamePhase { + +} diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java new file mode 100644 index 0000000..54dfae5 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java @@ -0,0 +1,27 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.dto.phase; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; +import org.developerden.codosseum.model.phase.GamePhaseKind; + +@Serdeable +@Schema(description = "Waiting for players to join before the game can begin") +@JsonTypeName("WAITING_FOR_PLAYERS") +public record ApiWaitingForPlayersPhase() implements ApiGamePhase { + +} diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java new file mode 100644 index 0000000..37dfc87 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java @@ -0,0 +1,26 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ +package org.developerden.codosseum.dto.phase; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.developerden.codosseum.model.phase.GamePhaseKind; + +@Serdeable +@Schema(description = "Warmup countdown before the game starts") +@JsonTypeName("WARMUP") +public record ApiWarmupPhase(Instant warmupEndsAt) implements ApiGamePhase { +} diff --git a/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java new file mode 100644 index 0000000..0a6a928 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java @@ -0,0 +1,49 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.dto.phase; + +import jakarta.inject.Singleton; +import org.developerden.codosseum.model.phase.GamePhase; +import org.developerden.codosseum.model.phase.InProgressPhase; +import org.developerden.codosseum.model.phase.UndefinedPhase; +import org.developerden.codosseum.model.phase.WaitingForPlayersPhase; +import org.developerden.codosseum.model.phase.WarmupPhase; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.SubclassExhaustiveStrategy; +import org.mapstruct.SubclassMapping; + +@Singleton +@Mapper( + componentModel = "jsr330" +) +public interface PhaseMapper { + + ApiUndefinedPhase toDto(UndefinedPhase gamePhase); + + ApiWaitingForPlayersPhase toDto(WaitingForPlayersPhase phase); + + ApiWarmupPhase toDto(WarmupPhase phase); + + ApiInProgressPhase toDto(InProgressPhase phase); + + @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.COMPILE_ERROR) + @SubclassMapping(source = InProgressPhase.class, target = ApiInProgressPhase.class) + @SubclassMapping(source = UndefinedPhase.class, target = ApiUndefinedPhase.class) + @SubclassMapping(source = WaitingForPlayersPhase.class, target = ApiWaitingForPlayersPhase.class) + @SubclassMapping(source = WarmupPhase.class, target = ApiWarmupPhase.class) + ApiGamePhase toDto(GamePhase phase); + +} diff --git a/src/main/java/org/developerden/codosseum/model/GamePlayers.java b/src/main/java/org/developerden/codosseum/model/GamePlayers.java index 6d4ab9a..7d2134d 100644 --- a/src/main/java/org/developerden/codosseum/model/GamePlayers.java +++ b/src/main/java/org/developerden/codosseum/model/GamePlayers.java @@ -14,6 +14,7 @@ package org.developerden.codosseum.model; +import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import java.util.Set; import javax.annotation.Nonnull; @@ -32,4 +33,8 @@ addSingleItemCollectionBuilders = true ) public record GamePlayers(@Nullable GamePlayer admin, @Nonnull Set others) { + + public GamePlayersBuilder builder() { + return GamePlayersBuilder.builder(this); + } } diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 17e6b7a..09e1c57 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -17,25 +17,47 @@ import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import jakarta.validation.constraints.Positive; import java.util.UUID; +import java.util.function.UnaryOperator; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; +import org.developerden.codosseum.model.phase.GamePhase; +import org.developerden.codosseum.model.phase.WithPlayersPhase; /** * Internal model representing the current mutable state of a game. * * @param gameId the unique identifier of the game * @param phase the current phase of the game - * @param players the players involved in the game * @param currentChallengeInfo the current challenge information, if a challenge is active - * @param currentRound the current round number of the game, or -1 if the game hasn't started yet + * @param currentRound the current round number of the game */ @RecordBuilder @RecordBuilder.Options(defaultNotNull = true) public record GameState(@Nonnull UUID gameId, @Nonnull GamePhase phase, - @Nonnull GamePlayers players, @Nullable ChallengeInfo currentChallengeInfo, - int currentRound, - boolean acceptingSolutions + boolean acceptingSolutions, + @Nullable @Positive Integer currentRound ) implements GameStateBuilder.With { + + public GameState updatePlayers(UnaryOperator mutate) { + var phase = phase(); + if (phase instanceof WithPlayersPhase wp) { + var newPlayers = mutate.apply(wp.players()); + var newPhase = (GamePhase) wp.withPlayers(newPlayers); + return GameStateBuilder.from(this).withPhase(newPhase); + } + throw new IllegalStateException( + "Phase does not contain players: " + phase.getClass().getSimpleName()); + } + + public GamePlayers players() { + var phase = phase(); + if (phase instanceof WithPlayersPhase wp) { + return wp.players(); + } + throw new IllegalStateException( + "Phase does not contain players: " + phase.getClass().getSimpleName()); + } } diff --git a/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java b/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java new file mode 100644 index 0000000..e16356b --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java @@ -0,0 +1,25 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; + +@Serdeable +@Schema(description = "A phase of the game.") +public sealed interface GamePhase + permits InProgressPhase, UndefinedPhase, WaitingForPlayersPhase, WarmupPhase, WithPlayersPhase { + GamePhaseKind getKind(); +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/GamePhaseKind.java b/src/main/java/org/developerden/codosseum/model/phase/GamePhaseKind.java new file mode 100644 index 0000000..c7e27a2 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/GamePhaseKind.java @@ -0,0 +1,34 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum GamePhaseKind { + @Schema(description = """ + The game phase is not defined, usually indicating an error state. + Clients should generally not have to handle this state.""") + UNDEFINED, + @Schema(description = "The game has not yet started and is waiting for more players before it can begin") + WAITING_FOR_PLAYERS, + @Schema(description = "The game is in a warmup phase and is ready to begin") + WARMUP, + @Schema(description = "The game is currently in progress") + IN_PROGRESS, + @Schema(description = "The current round of the game is over") + ROUND_OVER, + @Schema(description = "The game has ended") + GAME_OVER +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java new file mode 100644 index 0000000..9944766 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java @@ -0,0 +1,37 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; +import io.soabase.recordbuilder.core.RecordBuilder; +import jakarta.validation.constraints.Positive; +import org.developerden.codosseum.challenges.client.model.ChallengeInfo; +import org.developerden.codosseum.model.GamePlayers; + +@Serdeable +@RecordBuilder +public record InProgressPhase(GamePlayers players, ChallengeInfo currentChallenge, + @Positive int currentRound) + implements GamePhase, WithPlayersPhase { + @Override + public GamePhaseKind getKind() { + return GamePhaseKind.IN_PROGRESS; + } + + @Override + public WithPlayersPhase withPlayers(GamePlayers players) { + return new InProgressPhase(players, currentChallenge, currentRound); + } +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java b/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java new file mode 100644 index 0000000..8e12d17 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java @@ -0,0 +1,25 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public record UndefinedPhase() implements GamePhase { + @Override + public GamePhaseKind getKind() { + return GamePhaseKind.UNDEFINED; + } +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java new file mode 100644 index 0000000..73ccbf4 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java @@ -0,0 +1,33 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; +import org.developerden.codosseum.model.GamePlayers; + +@Serdeable +public record WaitingForPlayersPhase(@NotNull GamePlayers players) + implements GamePhase, WithPlayersPhase { + @Override + public GamePhaseKind getKind() { + return GamePhaseKind.WAITING_FOR_PLAYERS; + } + + @Override + public WithPlayersPhase withPlayers(GamePlayers players) { + return new WaitingForPlayersPhase(players); + } +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java new file mode 100644 index 0000000..5567b7b --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java @@ -0,0 +1,33 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; +import java.time.Instant; +import org.developerden.codosseum.model.GamePlayers; + +@Serdeable +public record WarmupPhase(GamePlayers players, Instant warmupEndsAt) + implements GamePhase, WithPlayersPhase { + @Override + public GamePhaseKind getKind() { + return GamePhaseKind.WARMUP; + } + + @Override + public WithPlayersPhase withPlayers(GamePlayers players) { + return new WarmupPhase(players, warmupEndsAt); + } +} diff --git a/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java new file mode 100644 index 0000000..0e2eb0e --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java @@ -0,0 +1,25 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.phase; + +import io.micronaut.serde.annotation.Serdeable; +import org.developerden.codosseum.model.GamePlayers; +@Serdeable +public sealed interface WithPlayersPhase extends GamePhase + permits InProgressPhase, WaitingForPlayersPhase, WarmupPhase { + GamePlayers players(); + + WithPlayersPhase withPlayers(GamePlayers players); +} diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 1ad2385..e0bf6b5 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -28,10 +28,12 @@ import org.developerden.codosseum.dto.GameSettings; import org.developerden.codosseum.dto.Player; import org.developerden.codosseum.dto.PlayersMapper; +import org.developerden.codosseum.dto.phase.PhaseMapper; import org.developerden.codosseum.mode.GameModeFactory; import org.developerden.codosseum.mode.GameModeType; import org.developerden.codosseum.model.Game; -import org.developerden.codosseum.model.GamePhase; +import org.developerden.codosseum.model.phase.GamePhaseKind; +import org.developerden.codosseum.model.phase.WithPlayersPhase; import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.repository.AuthRepository; import org.developerden.codosseum.repository.GameRepository; @@ -50,17 +52,20 @@ public class GameService { private final AuthRepository authRepository; private final PlayersMapper playersMapper; + private final PhaseMapper phaseMapper; public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, AuthRepository authRepository, - PlayersMapper playersMapper) { + PlayersMapper playersMapper, + PhaseMapper phaseMapper) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; this.snapshotStore = snapshotStore; this.authRepository = authRepository; this.playersMapper = playersMapper; + this.phaseMapper = phaseMapper; } private String generateFreshKey() { @@ -105,28 +110,30 @@ public Optional getGame(UUID id) { } var game = gameOpt.get(); - var stateOpt = gameRunnerRegistry + var state = gameRunnerRegistry .find(id) .map(GameRunner::getCurrentState) .or(() -> snapshotStore.load(id)) .orElseThrow(() -> new IllegalStateException("No game state found for game " + id)); + var phase = state.phase(); + if (!(phase instanceof WithPlayersPhase playersPhase)) { + throw new IllegalStateException("Game phase does not have players: " + phase); + } return Optional.of(new GameInfo( game.settings(), game.id(), game.mode(), - playersMapper.toDto(stateOpt.players()), - stateOpt.phase(), - 0, - stateOpt.currentRound() == -1 ? null : stateOpt.currentRound() + 1, + playersMapper.toDto(playersPhase.players()), + phaseMapper.toDto(state.phase()), new ArrayList<>() )); } public void startGame(UUID gameId) { GameRunner runner = gameRunnerRegistry.getOrCreate(gameId); - if (runner.getCurrentState().phase() != GamePhase.WAITING_FOR_PLAYERS) { + if (runner.getCurrentState().phase().getKind() != GamePhaseKind.WAITING_FOR_PLAYERS) { throw new IllegalStateException("Game is already running or finished"); } @@ -163,7 +170,7 @@ public Optional addPlayer(UUID id, @Valid Player player) { .getOrCreate(id); var state = runner .getCurrentState(); - if (state.phase() != GamePhase.WAITING_FOR_PLAYERS) { + if (state.phase().getKind() != GamePhaseKind.WAITING_FOR_PLAYERS) { throw new IllegalStateException("Game is already running or finished"); } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 7301291..2bde0ad 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -15,15 +15,17 @@ package org.developerden.codosseum.service.game; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.UUID; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; -import org.developerden.codosseum.model.GamePhase; -import org.developerden.codosseum.model.GamePlayersBuilder; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; +import org.developerden.codosseum.model.phase.GamePhaseKind; +import org.developerden.codosseum.model.phase.InProgressPhase; +import org.developerden.codosseum.model.phase.WarmupPhase; import org.developerden.codosseum.service.game.effect.SideEffect; import org.developerden.codosseum.service.game.event.InternalGameEvent; @@ -73,13 +75,23 @@ public Result handle(GameCommand cmd) { * * @param gamePhase the required phase. */ - private void requirePhase(GamePhase gamePhase) { - if (gameState.phase() != gamePhase) { + private void requirePhase(GamePhaseKind gamePhase) { + if (gameState.phase().getKind() != gamePhase) { throw new IllegalStateException( "Game is not in required phase: " + gamePhase + ", current phase: " + gameState.phase()); } } + private

P requirePhase( + Class

phaseClass) { + if (!phaseClass.isInstance(gameState.phase())) { + throw new IllegalStateException( + "Game is not in required phase: " + phaseClass.getSimpleName() + ", current phase: " + + gameState.phase()); + } + return phaseClass.cast(gameState.phase()); + } + private Decision decide(GameCommand cmd) { if (!cmd.gameId().equals(gameId)) { throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); @@ -87,26 +99,26 @@ private Decision decide(GameCommand cmd) { return switch (cmd) { case GameCommand.CreateGame(var id, var creator) -> { - requirePhase(GamePhase.WAITING_FOR_PLAYERS); + requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); yield Decision.pure( new InternalGameEvent.GameCreated(gameId, creator) ); } case GameCommand.StartGame(var id) -> { - requirePhase(GamePhase.WARMUP); + requirePhase(GamePhaseKind.WARMUP); yield Decision.pure( new InternalGameEvent.GameStarted(gameId) ); } case GameCommand.AddPlayer(var id, var player) -> { - requirePhase(GamePhase.WAITING_FOR_PLAYERS); + requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); yield Decision.pure(new InternalGameEvent.PlayerJoined(gameId, player)); } case GameCommand.StartWarmup(var id) -> { - requirePhase(GamePhase.WAITING_FOR_PLAYERS); + requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); // Emit warmup started and schedule transition to start after countdown yield Decision.pure( new InternalGameEvent.WarmupStarted(gameId, DEFAULT_WARMUP_DURATION) @@ -122,9 +134,9 @@ private Decision decide(GameCommand cmd) { new InternalGameEvent.ChallengeSet(gameId, challenge) ); case GameCommand.StartRound(var id) -> { - requirePhase(GamePhase.IN_PROGRESS); + var phase = requirePhase(InProgressPhase.class); - var nextRound = Math.max(gameState.currentRound() + 1, 1); + var nextRound = phase.currentRound() + 1; var roundLength = Duration.ofMinutes(5); // TODO: make configurable / dynamic ChallengeInfo challenge = Objects.requireNonNull(getGameState().currentChallengeInfo(), "Cannot start round without a challenge set"); @@ -139,10 +151,10 @@ private Decision decide(GameCommand cmd) { )); } case GameCommand.EndRound endRound -> { - requirePhase(GamePhase.IN_PROGRESS); + var phase = requirePhase(InProgressPhase.class); // For now, ending a round is a no-op yield Decision.pure( - new InternalGameEvent.RoundEnded(gameId, gameState.currentRound()) + new InternalGameEvent.RoundEnded(gameId, phase.currentRound()) ); } }; @@ -161,23 +173,27 @@ private GameState apply(GameState state, InternalGameEvent event) { throw new IllegalArgumentException("Event gameId does not match aggregate gameId"); } return switch (event) { - case InternalGameEvent.PlayerJoined(var gameId, var player) -> GameStateBuilder.from(state) - .withPlayers( - GamePlayersBuilder.builder(state.players()) - .addOthers(player) - .build() - ); - case InternalGameEvent.GameCreated(var gameId, var player) -> GameStateBuilder.from(state) - .withPlayers( - GamePlayersBuilder.builder(state.players()) - .admin(player) - .addOthers(player) - .build() - ); + case InternalGameEvent.PlayerJoined(var gameId, var player) -> + state.updatePlayers(players -> players + .builder() + .addOthers(player).build()); + case InternalGameEvent.GameCreated(var gameId, var player) -> state.updatePlayers(players -> + players.builder() + .admin(player) + .addOthers(player) + .build() + ); case InternalGameEvent.WarmupStarted(var gameId, var duration) -> GameStateBuilder.from(state) - .withPhase(GamePhase.WARMUP); + .withPhase(new WarmupPhase( + state.players(), + Instant.now().plus(duration) + )); case InternalGameEvent.GameStarted(var gameId) -> GameStateBuilder.from(state) - .withPhase(GamePhase.IN_PROGRESS); + .withPhase(new InProgressPhase( + state.players(), + state.currentChallengeInfo(), + 0 + )); case InternalGameEvent.ChallengeSet challengeSet -> GameStateBuilder.from(state) .withCurrentChallengeInfo(challengeSet.challengeInfo()); case InternalGameEvent.RoundEnded roundEnded -> state; // TODO: implement round end logic diff --git a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java index c20fd36..062eeb5 100644 --- a/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java +++ b/src/main/java/org/developerden/codosseum/service/game/state/DefaultInitialGameStateProvider.java @@ -17,10 +17,10 @@ import jakarta.inject.Singleton; import java.util.HashSet; import java.util.UUID; -import org.developerden.codosseum.model.GamePhase; import org.developerden.codosseum.model.GamePlayers; import org.developerden.codosseum.model.GameState; import org.developerden.codosseum.model.GameStateBuilder; +import org.developerden.codosseum.model.phase.WaitingForPlayersPhase; /** * Default initial state: lobby waiting for players. @@ -31,9 +31,10 @@ public class DefaultInitialGameStateProvider implements InitialGameStateProvider public GameState create(UUID gameId) { return GameStateBuilder.builder() .gameId(gameId) - .phase(GamePhase.WAITING_FOR_PLAYERS) - .players(new GamePlayers(null, new HashSet<>())) - .currentRound(-1) + .phase(new WaitingForPlayersPhase( + new GamePlayers(null, new HashSet<>()) + )) + .currentRound(null) .build(); } } diff --git a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy index faa59a6..eed7772 100644 --- a/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/GameFlowSpec.groovy @@ -22,8 +22,8 @@ import io.micronaut.test.annotation.MockBean import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject import org.developerden.codosseum.challenges.client.api.DefaultApi -import org.developerden.codosseum.challenges.client.model.Difficulty import org.developerden.codosseum.dto.* +import org.developerden.codosseum.dto.phase.ApiInProgressPhase import org.developerden.codosseum.event.GameEvent import org.developerden.codosseum.event.RoundStartEvent import org.developerden.codosseum.event.SyncEvent @@ -115,7 +115,7 @@ class GameFlowSpec extends Specification { infoResponse.status.code == 200 def info = infoResponse.body() info.id() == gameId - info.state() == GamePhase.IN_PROGRESS + info.phase() instanceof ApiInProgressPhase info.players().allPlayers().count() == 1 } From 81641bc372cd6df89dcf827ae11138542a0b998d Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 19:58:55 +0100 Subject: [PATCH 86/95] simplify game flow to remove murky state in between GameStarted and RoundStarted --- .../model/phase/InProgressPhase.java | 10 ++- .../codosseum/service/game/GameAggregate.java | 69 ++++++++++++------- .../service/game/event/EventMapper.java | 1 - .../service/game/event/InternalGameEvent.java | 6 -- .../game/process/ChallengeSetHandler.java | 58 ---------------- ...Handler.java => WarmupStartedHandler.java} | 16 +++-- 6 files changed, 65 insertions(+), 95 deletions(-) delete mode 100644 src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java rename src/main/java/org/developerden/codosseum/service/game/process/{GameStartedHandler.java => WarmupStartedHandler.java} (79%) diff --git a/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java index 9944766..d97e30c 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java @@ -20,9 +20,17 @@ import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.model.GamePlayers; +/** + * The game is in progress. + * Due to the internal workings of the game loop, it is possible for the game to be in progress + * @param players + * @param currentChallenge + * @param currentRound + */ @Serdeable @RecordBuilder -public record InProgressPhase(GamePlayers players, ChallengeInfo currentChallenge, +public record InProgressPhase(GamePlayers players, + ChallengeInfo currentChallenge, @Positive int currentRound) implements GamePhase, WithPlayersPhase { @Override diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 2bde0ad..5bfaed7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -28,6 +28,8 @@ import org.developerden.codosseum.model.phase.WarmupPhase; import org.developerden.codosseum.service.game.effect.SideEffect; import org.developerden.codosseum.service.game.event.InternalGameEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Handles commands, produces events, and applies events to the game state. @@ -41,7 +43,9 @@ public class GameAggregate { private static final Duration DEFAULT_WARMUP_DURATION = Duration.ofSeconds(5); private static final String KEY_WARMUP_TO_START = "warmup->start"; - + private static final String KEY_START_RETRY = "warmup->await-challenge"; + private static final String KEY_ROUND_OVER = "round->over"; + private static final Logger log = LoggerFactory.getLogger(GameAggregate.class); private final UUID gameId; private final GameState gameState; @@ -108,9 +112,14 @@ private Decision decide(GameCommand cmd) { case GameCommand.StartGame(var id) -> { requirePhase(GamePhaseKind.WARMUP); - yield Decision.pure( - new InternalGameEvent.GameStarted(gameId) - ); + if (getGameState().currentChallengeInfo() == null) { + log.warn("Cannot start game {}, no challenge set, retrying...", gameId); + yield Decision.empty().withEffects( + new SideEffect.ScheduleAfter(KEY_START_RETRY, Duration.ofMillis(250), + new GameCommand.StartGame(gameId)) + ); + } + yield startRound(1); } case GameCommand.AddPlayer(var id, var player) -> { requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); @@ -137,18 +146,8 @@ private Decision decide(GameCommand cmd) { var phase = requirePhase(InProgressPhase.class); var nextRound = phase.currentRound() + 1; - var roundLength = Duration.ofMinutes(5); // TODO: make configurable / dynamic - ChallengeInfo challenge = Objects.requireNonNull(getGameState().currentChallengeInfo(), - "Cannot start round without a challenge set"); - yield Decision.pure( - new InternalGameEvent.RoundStarted(gameId, challenge, - nextRound, roundLength) - ).withEffects( - new SideEffect.ScheduleAfter( - "round->over", - roundLength, - new GameCommand.EndRound(gameId) - )); + + yield startRound(nextRound); } case GameCommand.EndRound endRound -> { var phase = requirePhase(InProgressPhase.class); @@ -160,6 +159,29 @@ private Decision decide(GameCommand cmd) { }; } + + private Decision startRound(int roundNumber) { + var length = getRoundLength(); + ChallengeInfo challenge = Objects.requireNonNull( + getGameState().currentChallengeInfo(), + "Cannot start round without a challenge set" + ); + return Decision.pure( + new InternalGameEvent.RoundStarted(gameId, challenge, roundNumber, length) + ).withEffects( + new SideEffect.ScheduleAfter( + KEY_ROUND_OVER, + length, + new GameCommand.EndRound(gameId) + ) + ); + } + + private Duration getRoundLength() { + // TODO: make configurable + return Duration.ofMinutes(5); + } + private GameState applyAll(GameState state, List events) { var newState = state; for (var event : events) { @@ -188,17 +210,18 @@ private GameState apply(GameState state, InternalGameEvent event) { state.players(), Instant.now().plus(duration) )); - case InternalGameEvent.GameStarted(var gameId) -> GameStateBuilder.from(state) - .withPhase(new InProgressPhase( - state.players(), - state.currentChallengeInfo(), - 0 - )); + case InternalGameEvent.ChallengeSet challengeSet -> GameStateBuilder.from(state) .withCurrentChallengeInfo(challengeSet.challengeInfo()); case InternalGameEvent.RoundEnded roundEnded -> state; // TODO: implement round end logic + case InternalGameEvent.RoundStarted roundStarted -> GameStateBuilder.from(state) - .withCurrentRound(roundStarted.roundNumber()); + .withCurrentChallengeInfo(roundStarted.challenge()) + .withPhase(new InProgressPhase( + state.players(), + roundStarted.challenge(), + roundStarted.roundNumber() + )); }; } diff --git a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java index 45b80ab..cf4d2bb 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/EventMapper.java @@ -48,7 +48,6 @@ public Optional fromInternal(InternalGameEvent internalEvent) { ) ); - case InternalGameEvent.GameStarted ignored -> Optional.empty(); case InternalGameEvent.ChallengeSet ignored -> Optional.empty(); case InternalGameEvent.RoundStarted roundStarted -> Optional.of( new RoundStartEvent( diff --git a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java index 39ca5b7..8c341af 100644 --- a/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java +++ b/src/main/java/org/developerden/codosseum/service/game/event/InternalGameEvent.java @@ -45,12 +45,6 @@ record PlayerJoined(UUID gameId, GamePlayer player) implements InternalGameEvent record WarmupStarted(UUID gameId, Duration warmupLength) implements InternalGameEvent { } - /** - * Emitted when the game transitions to in-progress. - */ - record GameStarted(UUID gameId) implements InternalGameEvent { - } - record ChallengeSet(UUID gameId, ChallengeInfo challengeInfo) implements InternalGameEvent { } diff --git a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java deleted file mode 100644 index e1a2020..0000000 --- a/src/main/java/org/developerden/codosseum/service/game/process/ChallengeSetHandler.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) - * # SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License along with this program. - * If not, see . - */ - -package org.developerden.codosseum.service.game.process; - -import io.micronaut.context.event.ApplicationEventListener; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import org.developerden.codosseum.service.game.GameCommand; -import org.developerden.codosseum.service.game.GameRunnerRegistry; -import org.developerden.codosseum.service.game.event.InternalGameEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Listens for challenges being set for a game, and starts the round when they are. - */ -@Singleton -public class ChallengeSetHandler implements ApplicationEventListener { - private final GameRunnerRegistry gameRunnerRegistry; - - private final Logger log = LoggerFactory.getLogger(ChallengeSetHandler.class); - - @Inject - public ChallengeSetHandler(GameRunnerRegistry gameRunnerRegistry) { - this.gameRunnerRegistry = gameRunnerRegistry; - } - - @Override - public void onApplicationEvent(InternalGameEvent event) { - if (event instanceof InternalGameEvent.ChallengeSet challengeSet) { - gameRunnerRegistry.find(challengeSet.gameId()) - .ifPresentOrElse(runner -> { - runner.tell(new GameCommand.StartRound( - challengeSet.gameId() - )); - log.info("Started round for game {}", challengeSet.gameId()); - }, () -> log.warn("Tried to start round for non-existent game {}", - challengeSet.gameId())); - } - } - - @Override - public boolean supports(InternalGameEvent event) { - return event instanceof InternalGameEvent.ChallengeSet; - } -} diff --git a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java similarity index 79% rename from src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java rename to src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java index 999c675..afa10ec 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/GameStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java @@ -19,6 +19,7 @@ import jakarta.inject.Singleton; import org.developerden.codosseum.challenges.client.api.DefaultApi; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; +import org.developerden.codosseum.model.phase.WarmupPhase; import org.developerden.codosseum.repository.GameRepository; import org.developerden.codosseum.service.game.GameCommand; import org.developerden.codosseum.service.game.GameRunnerRegistry; @@ -26,16 +27,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Listens for games going into their {@link WarmupPhase} and queries a random challenge for them. + */ @Singleton -public class GameStartedHandler implements ApplicationEventListener { +public class WarmupStartedHandler implements ApplicationEventListener { private final DefaultApi defaultApi; private final GameRepository gameRepository; private final GameRunnerRegistry gameRunnerRegistry; - private final Logger log = LoggerFactory.getLogger(GameStartedHandler.class); + private final Logger log = LoggerFactory.getLogger(WarmupStartedHandler.class); - public GameStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, - GameRunnerRegistry gameRunnerRegistry) { + public WarmupStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, + GameRunnerRegistry gameRunnerRegistry) { this.defaultApi = defaultApi; this.gameRepository = gameRepository; this.gameRunnerRegistry = gameRunnerRegistry; @@ -44,7 +48,7 @@ public GameStartedHandler(DefaultApi defaultApi, GameRepository gameRepository, @Override @Async public void onApplicationEvent(InternalGameEvent event) { - var gameStarted = (InternalGameEvent.GameStarted) event; + var gameStarted = (InternalGameEvent.WarmupStarted) event; var game = gameRepository.findGameById(gameStarted.gameId()) .orElseThrow(); @@ -65,6 +69,6 @@ public void onApplicationEvent(InternalGameEvent event) { @Override public boolean supports(InternalGameEvent event) { - return event instanceof InternalGameEvent.GameStarted; + return event instanceof InternalGameEvent.WarmupStarted; } } From 77d28fb497f94aba8104aeafabf4c8ac6022682c Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:00:49 +0100 Subject: [PATCH 87/95] remove dead methods --- .../developerden/codosseum/service/game/GameAggregate.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 5bfaed7..4a7f6e8 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -237,17 +237,10 @@ static Decision empty() { return new Decision(List.of(), List.of()); } - static Decision pure(List events) { - return new Decision(events, List.of()); - } - static Decision pure(InternalGameEvent... events) { return new Decision(Arrays.asList(events), List.of()); } - public Decision withEffects(List effects) { - return new Decision(events, effects); - } public Decision withEffects(SideEffect... effects) { return new Decision(events, Arrays.asList(effects)); From 212bcb7d654a10d9638d8864cffda51727e7bc11 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:21:59 +0100 Subject: [PATCH 88/95] improve documentation and checkstyle compliance --- .../dto/phase/ApiWaitingForPlayersPhase.java | 1 - .../codosseum/dto/phase/ApiWarmupPhase.java | 2 +- .../codosseum/dto/phase/PhaseMapper.java | 3 + .../codosseum/model/GameState.java | 2 +- .../codosseum/model/phase/GamePhase.java | 21 +++++- .../model/phase/InProgressPhase.java | 12 ++-- .../codosseum/model/phase/UndefinedPhase.java | 2 + .../model/phase/WaitingForPlayersPhase.java | 7 ++ .../codosseum/model/phase/WarmupPhase.java | 2 + .../model/phase/WithPlayersPhase.java | 1 + .../codosseum/service/GameService.java | 5 +- .../codosseum/service/game/GameAggregate.java | 26 ++++++- .../game/GameAggregateFactoryImpl.java | 4 ++ .../codosseum/service/game/GameCommand.java | 23 ++++++- .../service/game/GameRunnerRegistry.java | 69 ++++++++++++++++++- .../game/process/WarmupStartedHandler.java | 7 +- 16 files changed, 161 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java index 54dfae5..c27bb6f 100644 --- a/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiWaitingForPlayersPhase.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import io.micronaut.serde.annotation.Serdeable; import io.swagger.v3.oas.annotations.media.Schema; -import org.developerden.codosseum.model.phase.GamePhaseKind; @Serdeable @Schema(description = "Waiting for players to join before the game can begin") diff --git a/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java b/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java index 37dfc87..85d12d8 100644 --- a/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java +++ b/src/main/java/org/developerden/codosseum/dto/phase/ApiWarmupPhase.java @@ -11,13 +11,13 @@ * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see . */ + package org.developerden.codosseum.dto.phase; import com.fasterxml.jackson.annotation.JsonTypeName; import io.micronaut.serde.annotation.Serdeable; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; -import org.developerden.codosseum.model.phase.GamePhaseKind; @Serdeable @Schema(description = "Warmup countdown before the game starts") diff --git a/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java index 0a6a928..bc8784b 100644 --- a/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java +++ b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java @@ -25,6 +25,9 @@ import org.mapstruct.SubclassExhaustiveStrategy; import org.mapstruct.SubclassMapping; +/** + * MapStruct Mapper for converting game phases to their API DTO representations. + */ @Singleton @Mapper( componentModel = "jsr330" diff --git a/src/main/java/org/developerden/codosseum/model/GameState.java b/src/main/java/org/developerden/codosseum/model/GameState.java index 09e1c57..c27da22 100644 --- a/src/main/java/org/developerden/codosseum/model/GameState.java +++ b/src/main/java/org/developerden/codosseum/model/GameState.java @@ -45,7 +45,7 @@ public GameState updatePlayers(UnaryOperator mutate) { var phase = phase(); if (phase instanceof WithPlayersPhase wp) { var newPlayers = mutate.apply(wp.players()); - var newPhase = (GamePhase) wp.withPlayers(newPlayers); + var newPhase = wp.withPlayers(newPlayers); return GameStateBuilder.from(this).withPhase(newPhase); } throw new IllegalStateException( diff --git a/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java b/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java index e16356b..2fa1440 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/GamePhase.java @@ -14,12 +14,27 @@ package org.developerden.codosseum.model.phase; -import io.micronaut.serde.annotation.Serdeable; import io.swagger.v3.oas.annotations.media.Schema; +import javax.annotation.Nonnull; +import org.developerden.codosseum.dto.phase.ApiGamePhase; +import org.developerden.codosseum.model.GameState; -@Serdeable +/** + * Internal model of a phase of the game. + * The phase is the state of a game that is specific to the current part of the game lifecycle. + * For example, the state needed while waiting for players to join is different to the state needed while the game is in progress. + * + * @see GamePhaseKind for the different kinds of phases. + * @see GameState for state across the entire game lifecycle. + * @see ApiGamePhase for the DTO representation of this interface. + */ @Schema(description = "A phase of the game.") public sealed interface GamePhase permits InProgressPhase, UndefinedPhase, WaitingForPlayersPhase, WarmupPhase, WithPlayersPhase { - GamePhaseKind getKind(); + /** + * The kind of phase this is. Serves as a simple discriminator for serialization or for checking the type of phase. + * + * @return the kind of phase, which should generally be unique across implementations of this interface. + */ + @Nonnull GamePhaseKind getKind(); } diff --git a/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java index d97e30c..4f28645 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/InProgressPhase.java @@ -17,15 +17,17 @@ import io.micronaut.serde.annotation.Serdeable; import io.soabase.recordbuilder.core.RecordBuilder; import jakarta.validation.constraints.Positive; +import javax.annotation.Nonnull; import org.developerden.codosseum.challenges.client.model.ChallengeInfo; import org.developerden.codosseum.model.GamePlayers; /** * The game is in progress. - * Due to the internal workings of the game loop, it is possible for the game to be in progress - * @param players - * @param currentChallenge - * @param currentRound + * This phase includes information about the current challenge and round + * + * @param players the players in the game + * @param currentChallenge the current challenge + * @param currentRound the current round number (1-based) */ @Serdeable @RecordBuilder @@ -33,6 +35,8 @@ public record InProgressPhase(GamePlayers players, ChallengeInfo currentChallenge, @Positive int currentRound) implements GamePhase, WithPlayersPhase { + + @Nonnull @Override public GamePhaseKind getKind() { return GamePhaseKind.IN_PROGRESS; diff --git a/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java b/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java index 8e12d17..8fcd0a3 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/UndefinedPhase.java @@ -15,9 +15,11 @@ package org.developerden.codosseum.model.phase; import io.micronaut.serde.annotation.Serdeable; +import javax.annotation.Nonnull; @Serdeable public record UndefinedPhase() implements GamePhase { + @Nonnull @Override public GamePhaseKind getKind() { return GamePhaseKind.UNDEFINED; diff --git a/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java index 73ccbf4..5377e66 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/WaitingForPlayersPhase.java @@ -16,11 +16,18 @@ import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.NotNull; +import javax.annotation.Nonnull; import org.developerden.codosseum.model.GamePlayers; +/** + * Phase representing waiting for players to join, i.e. a "lobby" + * + * @param players the players in the game so far + */ @Serdeable public record WaitingForPlayersPhase(@NotNull GamePlayers players) implements GamePhase, WithPlayersPhase { + @Nonnull @Override public GamePhaseKind getKind() { return GamePhaseKind.WAITING_FOR_PLAYERS; diff --git a/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java index 5567b7b..6e301d6 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/WarmupPhase.java @@ -16,11 +16,13 @@ import io.micronaut.serde.annotation.Serdeable; import java.time.Instant; +import javax.annotation.Nonnull; import org.developerden.codosseum.model.GamePlayers; @Serdeable public record WarmupPhase(GamePlayers players, Instant warmupEndsAt) implements GamePhase, WithPlayersPhase { + @Nonnull @Override public GamePhaseKind getKind() { return GamePhaseKind.WARMUP; diff --git a/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java b/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java index 0e2eb0e..e74b0b4 100644 --- a/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java +++ b/src/main/java/org/developerden/codosseum/model/phase/WithPlayersPhase.java @@ -16,6 +16,7 @@ import io.micronaut.serde.annotation.Serdeable; import org.developerden.codosseum.model.GamePlayers; + @Serdeable public sealed interface WithPlayersPhase extends GamePhase permits InProgressPhase, WaitingForPlayersPhase, WarmupPhase { diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index e0bf6b5..14bb343 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -185,9 +185,6 @@ public Optional addPlayer(UUID id, @Valid Player player) { } public void beginWarmup(Game game) { - var runner = gameRunnerRegistry - .getOrCreate(game.id()); - - runner.tell(new GameCommand.StartWarmup(game.id())); + gameRunnerRegistry.sendCommand(new GameCommand.StartWarmup(game.id())); } } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java index 4a7f6e8..abd3246 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregate.java @@ -50,7 +50,7 @@ public class GameAggregate { private final GameState gameState; - public GameAggregate(UUID gameId, GameState gameState) { + GameAggregate(UUID gameId, GameState gameState) { this.gameId = gameId; this.gameState = gameState; } @@ -90,18 +90,26 @@ private

P requirePh Class

phaseClass) { if (!phaseClass.isInstance(gameState.phase())) { throw new IllegalStateException( - "Game is not in required phase: " + phaseClass.getSimpleName() + ", current phase: " + - gameState.phase()); + "Game is not in required phase: " + phaseClass.getSimpleName() + ", current phase: " + + gameState.phase()); } return phaseClass.cast(gameState.phase()); } + /** + * Decide how to handle a command, producing events to send and side effects. + * + * @param cmd the command to handle. + * @return the decision containing events and side effects. + */ private Decision decide(GameCommand cmd) { if (!cmd.gameId().equals(gameId)) { throw new IllegalArgumentException("Command gameId does not match aggregate gameId"); } return switch (cmd) { + // Create Game command can only be handled in WAITING_FOR_PLAYERS phase + // and produces a GameCreated event case GameCommand.CreateGame(var id, var creator) -> { requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); yield Decision.pure( @@ -109,6 +117,12 @@ private Decision decide(GameCommand cmd) { ); } + /* + Start Game command can only be handled in WARMUP phase + and produces a RoundStarted event for round 1 + If no challenge is set yet, it retries after a short delay in case the challenges service query is slow + The challenge is set by [WarmupStartedHandler] as soon as the warmup starts + */ case GameCommand.StartGame(var id) -> { requirePhase(GamePhaseKind.WARMUP); @@ -121,11 +135,15 @@ private Decision decide(GameCommand cmd) { } yield startRound(1); } + // Add Player command can only be handled in WAITING_FOR_PLAYERS phase + // and produces a PlayerJoined event case GameCommand.AddPlayer(var id, var player) -> { requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); yield Decision.pure(new InternalGameEvent.PlayerJoined(gameId, player)); } + // Start Warmup command can only be handled in WAITING_FOR_PLAYERS phase + // and produces a WarmupStarted event and schedules a StartGame command after the warmup duration case GameCommand.StartWarmup(var id) -> { requirePhase(GamePhaseKind.WAITING_FOR_PLAYERS); // Emit warmup started and schedule transition to start after countdown @@ -139,6 +157,8 @@ private Decision decide(GameCommand cmd) { ) ); } + // Set Challenge Info command can be handled in any phase (TODO: is this correct?) + // and produces a ChallengeSet event case GameCommand.SetChallengeInfo(var id, var challenge) -> Decision.pure( new InternalGameEvent.ChallengeSet(gameId, challenge) ); diff --git a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java index 5fd55d7..d09f2e3 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameAggregateFactoryImpl.java @@ -18,8 +18,12 @@ import java.util.UUID; import org.developerden.codosseum.model.GameState; +/** + * Implementation of {@link GameAggregateFactory}, used to create new instances of {@link GameAggregate}. + */ @Singleton public class GameAggregateFactoryImpl implements GameAggregateFactory { + @Override public GameAggregate create(UUID gameId, GameState snapshot) { return new GameAggregate(gameId, snapshot); diff --git a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java index 3f6e33e..4e7306a 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameCommand.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameCommand.java @@ -20,12 +20,23 @@ import org.developerden.codosseum.model.player.GamePlayer; /** - * A command that can be executed on a {@link org.developerden.codosseum.model.Game}. + * A command that can be executed on a {@link org.developerden.codosseum.model.Game} by sending it to its {@link GameRunner}. */ public sealed interface GameCommand { + /** + * The id of the game this command is for. + * + * @return the game id + */ UUID gameId(); + /** + * Command to create a new game, triggering initial state setup. + * + * @param gameId the id of the game to create. This should be unique. + * @param creator the player who created the game. + */ record CreateGame(UUID gameId, GamePlayer creator) implements GameCommand { } @@ -57,6 +68,7 @@ record AddPlayer(UUID gameId, GamePlayer player) implements GameCommand { /** * Command to set the current challenge for a game. * It is undefined what this does if the game is not in a state to accept a new challenge (i.e. is already in progress). + * Generally, this command is only safe to send if the game is in the {@link GamePhase#WAITING_FOR_PLAYERS}, {@link GamePhase#WARMUP}, or {@link GamePhase#ROUND_OVER} phases. * * @param gameId the id of the game to set the challenge for * @param info the challenge info to set @@ -66,13 +78,20 @@ record SetChallengeInfo(UUID gameId, ChallengeInfo info) implements GameCommand /** * Command to start a new round in a game. - * The game phase should be {@link GamePhase#IN_PROGRESS} for this to have any effect. + * The game phase should be {@link GamePhase#IN_PROGRESS} for this to have any effect, + * and an error may be thrown if it is not. * * @param gameId the id of the game to start the round for */ record StartRound(UUID gameId) implements GameCommand { } + /** + * Command to end the current round in a game. + * The game phase should be {@link GamePhase#IN_PROGRESS} for this to have any effect. + * + * @param gameId the id of the game to end the round for + */ record EndRound(UUID gameId) implements GameCommand { } diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java index 168f099..178f3d7 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java @@ -14,30 +14,97 @@ package org.developerden.codosseum.service.game; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nonnull; +/** + * Registry for active game runners. + * Uses a concurrent hash map to store the runners, allowing for thread-safe access and modification. + * + *

This also provides a convenient way to send commands to the appropriate game runner without needing to manually look it up each time - see {@link #sendCommand(GameCommand)} and {@link #trySendCommand(GameCommand)}. + * + * @see GameRunner + */ @Singleton public class GameRunnerRegistry { private final GameRunnerFactory factory; private final Map runners = new ConcurrentHashMap<>(); - public GameRunnerRegistry(GameRunnerFactory factory) { + @Inject + private GameRunnerRegistry(GameRunnerFactory factory) { this.factory = factory; } + /** + * Get an existing game runner or create a new one if it doesn't exist. + * This method should be used sparingly, considering whether a runner is expected to exist already or not. + * + *

For example, when creating a game, it's expected that no runner exists yet, so creating one is appropriate. + * Conversely, when sending a command to an existing game, it's expected that the runner already exists, and its absence should be treated as an error. + * As a law of thumb, assume that a runner should exist unless you are in the process of creating a new game. + * + *

It is generally better to use {@link #find(UUID)} or {@link #sendCommand(GameCommand)} depending on the context. + * + * @param gameId the id of the game + * @return the game runner + */ public GameRunner getOrCreate(UUID gameId) { return runners.computeIfAbsent(gameId, factory::create); } + /** + * Find an existing game runner by game id. + * Returns an empty optional if no runner exists for the given game id. + * + * @param gameId the id of the game + * @return an optional containing the game runner if it exists, or empty if it doesn't + */ public Optional find(UUID gameId) { return Optional.ofNullable(runners.get(gameId)); } + /** + * Send a command to the game runner for the given game id, looking up the {@link GameRunner} from the registry automatically. + * If no runner exists for the given game id, an {@link IllegalStateException} is thrown. + * + *

This is the preferred method to use when sending commands to existing games, as it enforces the expectation that the game runner should already exist. + * + * @param command the command to send. + * @see #getOrCreate(UUID) for information about when a runner is expected to exist or not. + */ + public void sendCommand(@Nonnull GameCommand command) { + var runner = runners.get(command.gameId()); + if (runner != null) { + runner.tell(command); + } else { + throw new IllegalStateException("No game runner found for game id: " + command.gameId()); + } + } + + /** + * Try to send a command to the game runner for the given game id. + * If no runner exists for the given game id, the command is silently ignored. + * + * @param command the command to send. + */ + public void trySendCommand(@Nonnull GameCommand command) { + var runner = runners.get(command.gameId()); + if (runner != null) { + runner.tell(command); + } + } + + /** + * Stop and remove the game runner for the given game id. + * + * @param gameId the id of the game + */ public void stop(UUID gameId) { var r = runners.remove(gameId); if (r != null) { diff --git a/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java b/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java index afa10ec..dda52f2 100644 --- a/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java +++ b/src/main/java/org/developerden/codosseum/service/game/process/WarmupStartedHandler.java @@ -59,12 +59,7 @@ public void onApplicationEvent(InternalGameEvent event) { null ).block(); - gameRunnerRegistry.find(game.id()) - .ifPresentOrElse( - runner -> // use ifPresent in case of race condition where the game has ended - runner.tell(new GameCommand.SetChallengeInfo(game.id(), info)), - () -> log.warn("Game runner does not exist for game {}", game.id())); - + gameRunnerRegistry.sendCommand(new GameCommand.SetChallengeInfo(game.id(), info)); } @Override From 19200ba7c65cb0a70dc28e2409eea51f4e6c967a Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:29:13 +0100 Subject: [PATCH 89/95] add utility method for easier querying of random enum values --- .../codosseum/service/GameService.java | 27 +++++++------ .../codosseum/utils/EnumUtils.java | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/utils/EnumUtils.java diff --git a/src/main/java/org/developerden/codosseum/service/GameService.java b/src/main/java/org/developerden/codosseum/service/GameService.java index 14bb343..dfac0a4 100644 --- a/src/main/java/org/developerden/codosseum/service/GameService.java +++ b/src/main/java/org/developerden/codosseum/service/GameService.java @@ -18,7 +18,6 @@ import jakarta.inject.Singleton; import jakarta.validation.Valid; import java.util.ArrayList; -import java.util.EnumSet; import java.util.Optional; import java.util.UUID; import org.developerden.codosseum.dto.GameCreateRequest; @@ -41,7 +40,7 @@ import org.developerden.codosseum.service.game.GameRunner; import org.developerden.codosseum.service.game.GameRunnerRegistry; import org.developerden.codosseum.service.game.state.SnapshotStore; -import org.developerden.codosseum.utils.CollectionUtils; +import org.developerden.codosseum.utils.EnumUtils; @Singleton public class GameService { @@ -54,11 +53,12 @@ public class GameService { private final PlayersMapper playersMapper; private final PhaseMapper phaseMapper; - public @Inject GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, - GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, - AuthRepository authRepository, - PlayersMapper playersMapper, - PhaseMapper phaseMapper) { + @Inject + GameService(GameRepository gameRepository, GameModeFactory gameModeFactory, + GameRunnerRegistry gameRunnerRegistry, SnapshotStore snapshotStore, + AuthRepository authRepository, + PlayersMapper playersMapper, + PhaseMapper phaseMapper) { this.gameRepository = gameRepository; this.gameModeFactory = gameModeFactory; this.gameRunnerRegistry = gameRunnerRegistry; @@ -72,13 +72,14 @@ private String generateFreshKey() { return UUID.randomUUID().toString(); } + /** + * Create a new game with the given settings and player as the creator. + * + * @param request the game creation request + * @return the response containing the game ID and player key + */ public GameCreateResponse createGame(GameCreateRequest request) { - var gameModeTypes = EnumSet.allOf(GameModeType.class); - if (Optional.ofNullable(request.settings().allowedGameModes()).map(modes -> !modes.isEmpty()) - .orElse(false)) { - gameModeTypes.retainAll(request.settings().allowedGameModes()); - } - var gameModeType = CollectionUtils.pickRandom(gameModeTypes); + var gameModeType = EnumUtils.random(GameModeType.class, request.settings().allowedGameModes()); var gameMode = gameModeFactory.fromType(gameModeType); var game = new Game(UUID.randomUUID(), request.settings(), gameMode); diff --git a/src/main/java/org/developerden/codosseum/utils/EnumUtils.java b/src/main/java/org/developerden/codosseum/utils/EnumUtils.java new file mode 100644 index 0000000..7bc96e1 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/utils/EnumUtils.java @@ -0,0 +1,40 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.utils; + +import java.util.Collection; +import java.util.SplittableRandom; + +public class EnumUtils { + + private static final SplittableRandom RANDOM = new SplittableRandom(); + + /** + * Picks a random enum value from the given enum type, optionally including only the specified values. + * + * @param enumType the enum type to pick from. + * @param including the enum values to include, or an empty collection to include all values. + * @param the enum type. + * @return a random enum value from the given enum type. + */ + public static synchronized > E random(Class enumType, Collection including) { + if (including.isEmpty()) { + int index = RANDOM.nextInt(0, enumType.getEnumConstants().length); + return enumType.getEnumConstants()[index]; + } + + return CollectionUtils.pickRandom(including); + } +} From ec0a13340346e63c6bc51908aadfb003ac08ac09 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:29:39 +0100 Subject: [PATCH 90/95] remove invalid @Singleton annotation from PhaseMapper --- .../java/org/developerden/codosseum/dto/phase/PhaseMapper.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java index bc8784b..7555cfb 100644 --- a/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java +++ b/src/main/java/org/developerden/codosseum/dto/phase/PhaseMapper.java @@ -28,7 +28,6 @@ /** * MapStruct Mapper for converting game phases to their API DTO representations. */ -@Singleton @Mapper( componentModel = "jsr330" ) From 9924cb32fd265fca9aa74770115a9e027b54d64c Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:37:49 +0100 Subject: [PATCH 91/95] improve documentation and slightly refactor AuthRepository --- .../model/player/EphemeralPlayer.java | 2 +- .../codosseum/repository/AuthRepository.java | 31 ++++++++++++++++++- .../repository/AuthRepositoryImpl.java | 6 ++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index faa45cd..4fc5ba6 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -21,7 +21,7 @@ /** * An ephemeral player, not tied to any persistent identity. * - * @param name the name of the player, must be unique within a game. + * @param name the name of the player, which must be unique within a game. * @param gameId the ID of the game the player is in. * @param key a unique key for the player, used to identify them in the game. * @param admin whether the player is an admin - if this is true, the {@link #key} can be also used to authenticate admin actions. diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java index 8dd1c8c..128f612 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java @@ -21,6 +21,17 @@ import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.model.player.GamePlayer; +/** + * Repository for storing and retrieving authentication-related data. + * + *

There are 2 methods of authentication supported by Codosseum: + *

    + *
  • Anonymous: When creating or joining a game, a player is assigned a name of their choosing, and a unique game key, which is used to authenticate them for the duration of the game. There is no long term persistence
  • + *
  • Registered: A user signs up by providing email and password, or uses OIDC (e.g. to sign up with GitHub). This creates a long term user account, which can be used to create and join games. Users authenticated through this method can be in multiple games at once with the same key.
  • + *
+ * + *

This type provides state management for both types of authentication. + */ public interface AuthRepository { /** @@ -32,9 +43,27 @@ public interface AuthRepository { Optional findPlayerByGameKey(String gameKey); - Collection allPlayers(); + /** + * Get all players in the repository. + * + * @return all players. + */ + Iterable allPlayers(); + /** + * Save a player to the repository. + * + * @param game the game the player is in. + * @param player the player to save. + */ void save(Game game, EphemeralPlayer player); + /** + * Find a player by its name and game id. + * + * @param name the name of the player. + * @param id the id of the game. + * @return a player in the given game going by the given name, or empty if not found. + */ Optional findPlayerByNameAndGameId(String name, UUID id); } diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java index 23d7c04..e0997b3 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java @@ -15,7 +15,7 @@ package org.developerden.codosseum.repository; import jakarta.inject.Singleton; -import java.util.Collection; +import java.util.HashSet; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -37,8 +37,8 @@ public Optional findPlayerByGameKey(String gameKey) { } @Override - public Collection allPlayers() { - return players.values(); + public Iterable allPlayers() { + return new HashSet<>(players.values()); } @Override From 98fcbacade8a7923809a1f574c6bb9a916e1f98c Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Mon, 13 Oct 2025 20:51:41 +0100 Subject: [PATCH 92/95] fix GameRunnerRegistry constructor being too hidden --- .../developerden/codosseum/service/game/GameRunnerRegistry.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java index 178f3d7..850c768 100644 --- a/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java +++ b/src/main/java/org/developerden/codosseum/service/game/GameRunnerRegistry.java @@ -36,7 +36,7 @@ public class GameRunnerRegistry { private final Map runners = new ConcurrentHashMap<>(); @Inject - private GameRunnerRegistry(GameRunnerFactory factory) { + GameRunnerRegistry(GameRunnerFactory factory) { this.factory = factory; } From 6f7cd9f28f85ea73b3b727a9685c0fc149ce350f Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 14 Oct 2025 10:50:27 +0100 Subject: [PATCH 93/95] implement bearer based auth --- .../codosseum/auth/CurrentPlayer.java | 30 ++++++ .../codosseum/auth/GameKeyTokenValidator.java | 11 ++- .../auth/OidcToGameAuthenticationMapper.java | 41 +++++++++ .../codosseum/auth/PlayerAuthentication.java | 4 +- ...Controller.java => PlayersController.java} | 8 +- .../codosseum/controller/UserController.java | 60 ++++++++++++ .../developerden/codosseum/dto/user/User.java | 41 +++++++++ .../codosseum/model/player/CodosseumUser.java | 24 +++++ .../model/player/EphemeralPlayer.java | 2 +- .../codosseum/model/player/GamePlayer.java | 2 +- .../model/player/RegisteredPlayer.java | 36 ++++++++ .../model/player/RegisteredUser.java | 31 +++++++ .../codosseum/model/player/UserMapper.java | 40 ++++++++ .../codosseum/repository/AuthRepository.java | 10 +- .../repository/AuthRepositoryImpl.java | 28 +++++- .../codosseum/service/AuthService.java | 62 +++++++++++++ ...ec.groovy => PlayersControllerSpec.groovy} | 2 +- .../org/developerden/codosseum/JwtAuthIT.java | 88 ++++++++++++++++++ .../auth/GameKeyAuthIntegrationTest.java | 92 +++++++++++++++++++ .../auth/JwtAuthIntegrationTest.java | 88 ++++++++++++++++++ 20 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/developerden/codosseum/auth/CurrentPlayer.java create mode 100644 src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java rename src/main/java/org/developerden/codosseum/controller/{PlayerController.java => PlayersController.java} (97%) create mode 100644 src/main/java/org/developerden/codosseum/controller/UserController.java create mode 100644 src/main/java/org/developerden/codosseum/dto/user/User.java create mode 100644 src/main/java/org/developerden/codosseum/model/player/CodosseumUser.java create mode 100644 src/main/java/org/developerden/codosseum/model/player/RegisteredPlayer.java create mode 100644 src/main/java/org/developerden/codosseum/model/player/RegisteredUser.java create mode 100644 src/main/java/org/developerden/codosseum/model/player/UserMapper.java create mode 100644 src/main/java/org/developerden/codosseum/service/AuthService.java rename src/test/groovy/org/developerden/codosseum/{PlayerControllerSpec.groovy => PlayersControllerSpec.groovy} (97%) create mode 100644 src/test/java/org/developerden/codosseum/JwtAuthIT.java create mode 100644 src/test/java/org/developerden/codosseum/auth/GameKeyAuthIntegrationTest.java create mode 100644 src/test/java/org/developerden/codosseum/auth/JwtAuthIntegrationTest.java diff --git a/src/main/java/org/developerden/codosseum/auth/CurrentPlayer.java b/src/main/java/org/developerden/codosseum/auth/CurrentPlayer.java new file mode 100644 index 0000000..f71e680 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/auth/CurrentPlayer.java @@ -0,0 +1,30 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.auth; + +import java.util.Set; +import java.util.UUID; + +/** + * Request-scoped view of the authenticated caller in the context of a game. + * Values are parsed and normalised from {@link io.micronaut.security.authentication.Authentication} attributes. + */ +public record CurrentPlayer( + String name, + UUID userId, // present for oauth users + UUID activeGameId, // present for game-scoped operations + Set roles +) { +} diff --git a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java index 3af435f..05411c1 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java +++ b/src/main/java/org/developerden/codosseum/auth/GameKeyTokenValidator.java @@ -28,13 +28,22 @@ @Singleton public class GameKeyTokenValidator implements TokenValidator> { private final AuthRepository authRepository; + private final GameKeyTokenReader gameKeyTokenReader; - public GameKeyTokenValidator(AuthRepository authRepository) { + public GameKeyTokenValidator(AuthRepository authRepository, + GameKeyTokenReader gameKeyTokenReader) { this.authRepository = authRepository; + this.gameKeyTokenReader = gameKeyTokenReader; } @Override public Publisher validateToken(String token, @Nullable HttpRequest request) { + if (request == null || token == null || token.isBlank()) { + return Mono.empty(); + } + if (gameKeyTokenReader.findToken(request).isEmpty()) { + return Mono.empty(); // not applicable here + } Optional playerByGameKey = authRepository.findPlayerByGameKey(token); return Mono.justOrEmpty(playerByGameKey) .map(PlayerAuthentication::buildFrom); diff --git a/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java b/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java new file mode 100644 index 0000000..017f5a9 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java @@ -0,0 +1,41 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.auth; + +import com.nimbusds.jwt.JWT; +import io.micronaut.context.annotation.Primary; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.jwt.validator.DefaultJwtAuthenticationFactory; +import io.micronaut.security.token.jwt.validator.JwtAuthenticationFactory; +import jakarta.inject.Singleton; +import java.util.Optional; + +@Singleton +@Primary +public class OidcToGameAuthenticationMapper implements JwtAuthenticationFactory { + + private final JwtAuthenticationFactory mainFactory; + + public OidcToGameAuthenticationMapper(DefaultJwtAuthenticationFactory mainFactory) { + this.mainFactory = mainFactory; + } + + + @Override + public Optional createAuthentication(JWT token) { + return mainFactory.createAuthentication(token) + .map(auth -> auth); + } +} diff --git a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java index 558c686..1ecc425 100644 --- a/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java +++ b/src/main/java/org/developerden/codosseum/auth/PlayerAuthentication.java @@ -25,12 +25,14 @@ public final class PlayerAuthentication { + public static final String ACTIVE_GAME_ID = "activeGameId"; + public static Authentication build( @Nonnull String name, @Nonnull UUID activeGameId, @Nonnull Set roles) { return Authentication.build( name, roles.stream().map(Enum::name).collect(Collectors.toSet()), - Map.of("activeGameId", activeGameId) + Map.of(ACTIVE_GAME_ID, activeGameId) ); } diff --git a/src/main/java/org/developerden/codosseum/controller/PlayerController.java b/src/main/java/org/developerden/codosseum/controller/PlayersController.java similarity index 97% rename from src/main/java/org/developerden/codosseum/controller/PlayerController.java rename to src/main/java/org/developerden/codosseum/controller/PlayersController.java index e859bc7..4a82ef7 100644 --- a/src/main/java/org/developerden/codosseum/controller/PlayerController.java +++ b/src/main/java/org/developerden/codosseum/controller/PlayersController.java @@ -40,12 +40,14 @@ @Validated @Controller("/games/{id}/players") @Secured(SecurityRule.IS_AUTHENTICATED) -public class PlayerController { +public class PlayersController { private final GameService gameService; - public PlayerController(GameService gameService) { + + public PlayersController(GameService gameService) { this.gameService = gameService; + } @Get @@ -73,11 +75,11 @@ public HttpResponse joinGame( } } - @Delete("/@self") @GameAuthorized(GameRole.PLAYER) public HttpResponse leaveGame(Principal principal, @PathVariable("id") Game game) { throw new UnsupportedOperationException(); } + } diff --git a/src/main/java/org/developerden/codosseum/controller/UserController.java b/src/main/java/org/developerden/codosseum/controller/UserController.java new file mode 100644 index 0000000..e808de1 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/controller/UserController.java @@ -0,0 +1,60 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.controller; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.validation.Validated; +import jakarta.inject.Inject; +import org.developerden.codosseum.auth.GameAuthorized; +import org.developerden.codosseum.auth.GameRole; +import org.developerden.codosseum.dto.PlayersMapper; +import org.developerden.codosseum.dto.user.User; +import org.developerden.codosseum.model.player.UserMapper; +import org.developerden.codosseum.repository.AuthRepository; +import org.developerden.codosseum.service.AuthService; + +@Validated +@Controller("/users") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class UserController { + private final AuthRepository authRepository; + private final PlayersMapper playersMapper; + + private final AuthService authService; + private final UserMapper userMapper; + + @Inject + UserController(AuthRepository authRepository, PlayersMapper playersMapper, + AuthService authService, UserMapper userMapper) { + this.authRepository = authRepository; + this.playersMapper = playersMapper; + this.authService = authService; + this.userMapper = userMapper; + } + + @Get("/@self") + @GameAuthorized(GameRole.PLAYER) + public HttpResponse getSelf(Authentication principal) { + return authService.getUserInfoFromAuth(principal) + .map(userMapper::toDto) + .map(HttpResponse::ok) + .orElse(HttpResponse.unauthorized()); + } +} diff --git a/src/main/java/org/developerden/codosseum/dto/user/User.java b/src/main/java/org/developerden/codosseum/dto/user/User.java new file mode 100644 index 0000000..065d77e --- /dev/null +++ b/src/main/java/org/developerden/codosseum/dto/user/User.java @@ -0,0 +1,41 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.dto.user; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.UUID; +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.model.player.CodosseumUser; +import org.developerden.codosseum.model.player.EphemeralPlayer; +import org.developerden.codosseum.model.player.RegisteredUser; + +/** + * DTO for a user, with minimal game-specific information. + * + * @param id the unique identifier of the user, or null if the user is not a {@link RegisteredUser}. + * @param name the display name of the user. + * @param gameId the unique identifier of the game the user is registered in, if the user is a {@link EphemeralPlayer}, null otherwise + * @see CodosseumUser the internal representation of this type + * @see Player the game-specific DTO representation of a user + */ +@Serdeable +public record User( + @Nullable UUID id, + @Nonnull String name, + @Nullable UUID gameId +) { +} diff --git a/src/main/java/org/developerden/codosseum/model/player/CodosseumUser.java b/src/main/java/org/developerden/codosseum/model/player/CodosseumUser.java new file mode 100644 index 0000000..6621841 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/player/CodosseumUser.java @@ -0,0 +1,24 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.player; + +/** + * Internal representation of a user, as in, someone with some information registered on the system. + * This can be either a {@link RegisteredUser}, who has an account, or an {@link EphemeralPlayer}, who does not. + * + * @apiNote This is distinct from a {@link GamePlayer}, which is a user in the context of a specific game. + */ +public sealed interface CodosseumUser permits EphemeralPlayer, RegisteredUser { +} diff --git a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java index 4fc5ba6..6f691e4 100644 --- a/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/EphemeralPlayer.java @@ -30,6 +30,6 @@ public record EphemeralPlayer(@Nonnull String name, @Nonnull UUID gameId, @Nonnull String key, boolean admin) - implements GamePlayer { + implements GamePlayer, CodosseumUser { } diff --git a/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java index 3fec16c..f44a646 100644 --- a/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java +++ b/src/main/java/org/developerden/codosseum/model/player/GamePlayer.java @@ -14,6 +14,6 @@ package org.developerden.codosseum.model.player; -public sealed interface GamePlayer permits EphemeralPlayer { +public sealed interface GamePlayer permits EphemeralPlayer, RegisteredPlayer { String name(); } diff --git a/src/main/java/org/developerden/codosseum/model/player/RegisteredPlayer.java b/src/main/java/org/developerden/codosseum/model/player/RegisteredPlayer.java new file mode 100644 index 0000000..8a6be18 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/player/RegisteredPlayer.java @@ -0,0 +1,36 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.player; + +import java.util.Set; +import java.util.UUID; +import org.developerden.codosseum.auth.GameRole; +import org.developerden.codosseum.model.Game; + +/** + * Membership of a {@link RegisteredUser} in a specific game. + * + * @param userId the unique identifier of the {@link RegisteredUser} + * @param gameId the unique identifier of the {@link Game} + * @param name the display name of the player in the game + * @param roles the roles assigned to the player in the game + */ +public record RegisteredPlayer( + UUID userId, + UUID gameId, + String name, + Set roles +) implements GamePlayer { +} diff --git a/src/main/java/org/developerden/codosseum/model/player/RegisteredUser.java b/src/main/java/org/developerden/codosseum/model/player/RegisteredUser.java new file mode 100644 index 0000000..66bbe2f --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/player/RegisteredUser.java @@ -0,0 +1,31 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.player; + +import java.util.UUID; + +/** + * Registered user linked to an OAuth/OIDC identity. + * + * @param id the unique identifier of the user. + * @param provider the identity provider (e.g., "google", "github"). + * @param subject the subject identifier from the identity provider. + * @param displayName the display name of the user. + */ +public record RegisteredUser(UUID id, + String provider, + String subject, + String displayName) implements CodosseumUser { +} diff --git a/src/main/java/org/developerden/codosseum/model/player/UserMapper.java b/src/main/java/org/developerden/codosseum/model/player/UserMapper.java new file mode 100644 index 0000000..6c27a63 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/model/player/UserMapper.java @@ -0,0 +1,40 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.model.player; + +import org.developerden.codosseum.dto.user.User; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.SubclassExhaustiveStrategy; +import org.mapstruct.SubclassMapping; + +@Mapper(componentModel = "jsr330") +public interface UserMapper { + @Mapping(source = "gameId", target = "gameId") + @Mapping(source = "name", target = "name") + @Mapping(target = "id", ignore = true) + User toDto(EphemeralPlayer ephemeralPlayer); + + @Mapping(source = "id", target = "id") + @Mapping(source = "displayName", target = "name") + @Mapping(target = "gameId", ignore = true) + User toDto(RegisteredUser registeredUser); + + @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.COMPILE_ERROR) + @SubclassMapping(source = EphemeralPlayer.class, target = User.class) + @SubclassMapping(source = RegisteredUser.class, target = User.class) + User toDto(CodosseumUser user); +} diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java index 128f612..76364c4 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepository.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepository.java @@ -14,12 +14,12 @@ package org.developerden.codosseum.repository; -import java.util.Collection; import java.util.Optional; import java.util.UUID; import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.model.player.GamePlayer; +import org.developerden.codosseum.model.player.RegisteredUser; /** * Repository for storing and retrieving authentication-related data. @@ -65,5 +65,11 @@ public interface AuthRepository { * @param id the id of the game. * @return a player in the given game going by the given name, or empty if not found. */ - Optional findPlayerByNameAndGameId(String name, UUID id); + Optional findPlayerByNameAndGameId(String name, UUID id); + + + Optional findRegisteredUserById(UUID id); + + void saveRegisteredUser(RegisteredUser user); + } diff --git a/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java index e0997b3..abd7ffb 100644 --- a/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java +++ b/src/main/java/org/developerden/codosseum/repository/AuthRepositoryImpl.java @@ -23,11 +23,13 @@ import org.developerden.codosseum.model.Game; import org.developerden.codosseum.model.player.EphemeralPlayer; import org.developerden.codosseum.model.player.GamePlayer; +import org.developerden.codosseum.model.player.RegisteredUser; @Singleton public class AuthRepositoryImpl implements AuthRepository { private final ConcurrentMap players = new ConcurrentHashMap<>(); + private final ConcurrentMap registeredUsers = new ConcurrentHashMap<>(); @Override public Optional findPlayerByGameKey(String gameKey) { @@ -47,10 +49,34 @@ public void save(Game game, EphemeralPlayer player) { } @Override - public Optional findPlayerByNameAndGameId(String name, UUID id) { + public Optional findPlayerByNameAndGameId(String name, UUID id) { return Optional.ofNullable(players.get(new PlayerKey(id, name))); } + @Override + public Optional findRegisteredUserById(UUID id) { + return Optional.ofNullable(registeredUsers.get(id)); + } + + @Override + public void saveRegisteredUser(RegisteredUser user) { + registeredUsers.put(user.id(), user); + } + + + public Optional findOrCreateRegisteredUser(String provider, String subject, + String displayName) { + return registeredUsers.values().stream() + .filter(user -> user.provider().equals(provider) && user.subject().equals(subject)) + .findFirst() + .or(() -> { + RegisteredUser newUser = + new RegisteredUser(UUID.randomUUID(), provider, subject, displayName); + registeredUsers.put(newUser.id(), newUser); + return Optional.of(newUser); + }); + } + private record PlayerKey(UUID gameId, String playerName) { } } diff --git a/src/main/java/org/developerden/codosseum/service/AuthService.java b/src/main/java/org/developerden/codosseum/service/AuthService.java new file mode 100644 index 0000000..5797597 --- /dev/null +++ b/src/main/java/org/developerden/codosseum/service/AuthService.java @@ -0,0 +1,62 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + +package org.developerden.codosseum.service; + +import io.micronaut.security.authentication.Authentication; +import jakarta.inject.Singleton; +import java.util.Optional; +import java.util.UUID; +import org.developerden.codosseum.auth.PlayerAuthentication; +import org.developerden.codosseum.model.player.CodosseumUser; +import org.developerden.codosseum.model.player.RegisteredUser; +import org.developerden.codosseum.repository.AuthRepository; + +@Singleton +public class AuthService { + private final AuthRepository authRepository; + + public AuthService(AuthRepository authRepository) { + this.authRepository = authRepository; + } + + public Optional getUserInfoFromAuth(Authentication authentication) { + if (authentication.getAttributes().containsKey(PlayerAuthentication.ACTIVE_GAME_ID)) { + // it's a GameKey auth + return authRepository.findPlayerByNameAndGameId( + authentication.getName(), + (UUID) authentication.getAttributes().get(PlayerAuthentication.ACTIVE_GAME_ID)) + .map(x -> x); // lol + } + // otherwise assume it's an OAuth user + UUID uuid; + try { + uuid = UUID.fromString(authentication.getName()); + } catch (Exception e) { + return Optional.empty(); + } + return authRepository.findRegisteredUserById(uuid) + .or(() -> { + var user = new RegisteredUser( + uuid, + (String) authentication.getAttributes().get("iss"), + (String) authentication.getAttributes().get("sub"), + (String) authentication.getAttributes().get("name") + ); + authRepository.saveRegisteredUser(user); + return Optional.of(user); + }) + .map(x -> (CodosseumUser) x); + } +} diff --git a/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy b/src/test/groovy/org/developerden/codosseum/PlayersControllerSpec.groovy similarity index 97% rename from src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy rename to src/test/groovy/org/developerden/codosseum/PlayersControllerSpec.groovy index 9c9cd2d..4fce271 100644 --- a/src/test/groovy/org/developerden/codosseum/PlayerControllerSpec.groovy +++ b/src/test/groovy/org/developerden/codosseum/PlayersControllerSpec.groovy @@ -24,7 +24,7 @@ import org.developerden.codosseum.mode.GameModeType import spock.lang.Specification @MicronautTest -class PlayerControllerSpec extends Specification { +class PlayersControllerSpec extends Specification { @Inject @Client("/") HttpClient http diff --git a/src/test/java/org/developerden/codosseum/JwtAuthIT.java b/src/test/java/org/developerden/codosseum/JwtAuthIT.java new file mode 100644 index 0000000..46858f2 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/JwtAuthIT.java @@ -0,0 +1,88 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + + +package org.developerden.codosseum; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.security.token.generator.TokenGenerator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.test.support.TestPropertyProvider; +import jakarta.inject.Inject; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.developerden.codosseum.dto.user.User; +import org.junit.jupiter.api.Test; + +@MicronautTest +class JwtAuthIT implements TestPropertyProvider { + + private static final URI SELF_URI = UriBuilder.of("/users").path("@self").build(); + @Inject + @Client("/") + HttpClient client; + @Inject + TokenGenerator tokenGenerator; + + @Override + public Map getProperties() { + Map p = new HashMap<>(); + p.put("micronaut.security.enabled", "true"); + p.put("micronaut.security.authentication", "idtoken"); // use ID Token cookie + p.put("micronaut.security.oauth2.enabled", "false"); // no real IdP in tests + p.put("micronaut.security.token.jwt.enabled", "true"); + p.put("micronaut.security.token.jwt.cookie.enabled", "true"); // cookie name defaults to JWT + p.put("micronaut.security.token.jwt.signatures.secret.generator.secret", + "please-change-test-secret"); + p.put("micronaut.security.token.roles-name", "roles"); // map 'roles' claim to authorities + return p; + } + + private String jwt(Map claims) { + return tokenGenerator.generateToken(claims).orElseThrow(); + } + + @Test + void allows_with_required_role() { + // Match your route annotations: use "ADMIN" for @Secured("ADMIN") or "ROLE_ADMIN" for @Secured("ROLE_ADMIN") + UUID userId = UUID.randomUUID(); + String token = jwt(Map.of("sub", userId.toString(), "name", "Test User")); + HttpRequest req = HttpRequest.GET(SELF_URI).cookie(Cookie.of("JWT", token)); + HttpResponse resp = client.toBlocking().exchange(req, User.class); + assertEquals(HttpStatus.OK, resp.getStatus()); + assertEquals(userId, resp.body().id()); + assertEquals("Test User", resp.body().name()); + assertNull(resp.body().gameId()); + } + + @Test + void unauthorized_without_token() { + HttpClientResponseException e = assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().exchange(HttpRequest.GET(SELF_URI), String.class)); + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); + } +} diff --git a/src/test/java/org/developerden/codosseum/auth/GameKeyAuthIntegrationTest.java b/src/test/java/org/developerden/codosseum/auth/GameKeyAuthIntegrationTest.java new file mode 100644 index 0000000..107a1a7 --- /dev/null +++ b/src/test/java/org/developerden/codosseum/auth/GameKeyAuthIntegrationTest.java @@ -0,0 +1,92 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + + +package org.developerden.codosseum.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.developerden.codosseum.dto.GameCreateRequest; +import org.developerden.codosseum.dto.GameCreateResponse; +import org.developerden.codosseum.dto.GameSettingsBuilder; +import org.developerden.codosseum.dto.Player; +import org.developerden.codosseum.dto.user.User; +import org.developerden.codosseum.mode.GameModeType; +import org.junit.jupiter.api.Test; + +@MicronautTest +class GameKeyAuthIntegrationTest { + + private static final URI SELF_URI = UriBuilder.of("/users").path("@self").build(); + @Inject + @Client("/") + HttpClient client; + + + @Test + void allows_with_valid_game_key() { + HttpResponse testPlayer = + client.toBlocking().exchange(HttpRequest.POST("/games", + new GameCreateRequest( + GameSettingsBuilder.builder().allowedGameModes(List.of(GameModeType.FASTEST)) + .build(), + new Player("test player") + ) + ), GameCreateResponse.class); + + String key = testPlayer.body().adminKey(); + + HttpResponse response = client.toBlocking().exchange( + HttpRequest.GET(SELF_URI) + .header(HttpHeaders.AUTHORIZATION, "Game " + key), + User.class); + assertEquals(HttpStatus.OK, response.getStatus()); + assertEquals("test player", response.body().name()); + assertEquals(testPlayer.body().id(), response.body().gameId()); + } + + @Test + void disallows_with_valid_game_key() { + String key = UUID.randomUUID().toString(); + + HttpClientResponseException e = + assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange( + HttpRequest.GET(SELF_URI) + .header(HttpHeaders.AUTHORIZATION, "Game " + key))); + + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); + } + + + @Test + void unauthorized_without_token() { + HttpClientResponseException e = assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().exchange(HttpRequest.GET(SELF_URI), String.class)); + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); + } +} diff --git a/src/test/java/org/developerden/codosseum/auth/JwtAuthIntegrationTest.java b/src/test/java/org/developerden/codosseum/auth/JwtAuthIntegrationTest.java new file mode 100644 index 0000000..cc3d18e --- /dev/null +++ b/src/test/java/org/developerden/codosseum/auth/JwtAuthIntegrationTest.java @@ -0,0 +1,88 @@ +/* + * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) + * # SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along with this program. + * If not, see . + */ + + +package org.developerden.codosseum.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.security.token.generator.TokenGenerator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.test.support.TestPropertyProvider; +import jakarta.inject.Inject; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.developerden.codosseum.dto.user.User; +import org.junit.jupiter.api.Test; + +@MicronautTest +class JwtAuthIntegrationTest implements TestPropertyProvider { + + private static final URI SELF_URI = UriBuilder.of("/users").path("@self").build(); + @Inject + @Client("/") + HttpClient client; + @Inject + TokenGenerator tokenGenerator; + + @Override + public Map getProperties() { + Map p = new HashMap<>(); + p.put("micronaut.security.enabled", "true"); + p.put("micronaut.security.authentication", "idtoken"); // use ID Token cookie + p.put("micronaut.security.oauth2.enabled", "false"); // no real IdP in tests + p.put("micronaut.security.token.jwt.enabled", "true"); + p.put("micronaut.security.token.jwt.cookie.enabled", "true"); // cookie name defaults to JWT + p.put("micronaut.security.token.jwt.signatures.secret.generator.secret", + "please-change-test-secret"); + p.put("micronaut.security.token.roles-name", "roles"); // map 'roles' claim to authorities + return p; + } + + private String jwt(Map claims) { + return tokenGenerator.generateToken(claims).orElseThrow(); + } + + @Test + void allows_with_required_role() { + // Match your route annotations: use "ADMIN" for @Secured("ADMIN") or "ROLE_ADMIN" for @Secured("ROLE_ADMIN") + UUID userId = UUID.randomUUID(); + String token = jwt(Map.of("sub", userId.toString(), "name", "Test User")); + HttpRequest req = HttpRequest.GET(SELF_URI).cookie(Cookie.of("JWT", token)); + HttpResponse resp = client.toBlocking().exchange(req, User.class); + assertEquals(HttpStatus.OK, resp.getStatus()); + assertEquals(userId, resp.body().id()); + assertEquals("Test User", resp.body().name()); + assertNull(resp.body().gameId()); + } + + @Test + void unauthorized_without_token() { + HttpClientResponseException e = assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().exchange(HttpRequest.GET(SELF_URI), String.class)); + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); + } +} From aa5ad19ad5641bd6c5b14c7f8d7d94582f653611 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 14 Oct 2025 11:21:45 +0100 Subject: [PATCH 94/95] make GameAuthorizationRule high priority to make sure it isn't overshadowed by normal auth --- .../codosseum/auth/GameAuthorizationRule.java | 9 ++++++++- .../org/developerden/codosseum/auth/GameAuthorized.java | 5 +++++ .../codosseum/controller/UserController.java | 3 --- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java index 75c0e37..117d4cd 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java +++ b/src/main/java/org/developerden/codosseum/auth/GameAuthorizationRule.java @@ -35,6 +35,11 @@ protected GameAuthorizationRule(RolesFinder rolesFinder) { super(rolesFinder); } + @Override + public int getOrder() { + return Integer.MIN_VALUE + 100; + } + @Override public Publisher check(@Nullable HttpRequest request, @Nullable Authentication authentication) { @@ -64,6 +69,8 @@ public Publisher check(@Nullable HttpRequest request, } private boolean matchesGameId(Authentication authentication, String gameId) { - return gameId.equals(authentication.getAttributes().get("activeGameId")); + return gameId.equals( + String.valueOf(authentication.getAttributes().get(PlayerAuthentication.ACTIVE_GAME_ID)) + ); } } diff --git a/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java b/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java index 2dbd97b..577d489 100644 --- a/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java +++ b/src/main/java/org/developerden/codosseum/auth/GameAuthorized.java @@ -19,6 +19,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Annotation to specify that a route requires the user to have one of the specified roles in a game. + * If the user does not have one of the specified roles, a 403 Forbidden response will be returned. + * + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface GameAuthorized { diff --git a/src/main/java/org/developerden/codosseum/controller/UserController.java b/src/main/java/org/developerden/codosseum/controller/UserController.java index e808de1..07a8e28 100644 --- a/src/main/java/org/developerden/codosseum/controller/UserController.java +++ b/src/main/java/org/developerden/codosseum/controller/UserController.java @@ -22,8 +22,6 @@ import io.micronaut.security.rules.SecurityRule; import io.micronaut.validation.Validated; import jakarta.inject.Inject; -import org.developerden.codosseum.auth.GameAuthorized; -import org.developerden.codosseum.auth.GameRole; import org.developerden.codosseum.dto.PlayersMapper; import org.developerden.codosseum.dto.user.User; import org.developerden.codosseum.model.player.UserMapper; @@ -50,7 +48,6 @@ public class UserController { } @Get("/@self") - @GameAuthorized(GameRole.PLAYER) public HttpResponse getSelf(Authentication principal) { return authService.getUserInfoFromAuth(principal) .map(userMapper::toDto) From 11a94487fdee4ff80cc0d8ac5e6ee8a441f98fb9 Mon Sep 17 00:00:00 2001 From: Alexander Wood Date: Tue, 14 Oct 2025 11:23:20 +0100 Subject: [PATCH 95/95] remove some dead code --- .../auth/OidcToGameAuthenticationMapper.java | 41 --------- .../org/developerden/codosseum/JwtAuthIT.java | 88 ------------------- 2 files changed, 129 deletions(-) delete mode 100644 src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java delete mode 100644 src/test/java/org/developerden/codosseum/JwtAuthIT.java diff --git a/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java b/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java deleted file mode 100644 index 017f5a9..0000000 --- a/src/main/java/org/developerden/codosseum/auth/OidcToGameAuthenticationMapper.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) - * # SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License along with this program. - * If not, see . - */ - -package org.developerden.codosseum.auth; - -import com.nimbusds.jwt.JWT; -import io.micronaut.context.annotation.Primary; -import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.token.jwt.validator.DefaultJwtAuthenticationFactory; -import io.micronaut.security.token.jwt.validator.JwtAuthenticationFactory; -import jakarta.inject.Singleton; -import java.util.Optional; - -@Singleton -@Primary -public class OidcToGameAuthenticationMapper implements JwtAuthenticationFactory { - - private final JwtAuthenticationFactory mainFactory; - - public OidcToGameAuthenticationMapper(DefaultJwtAuthenticationFactory mainFactory) { - this.mainFactory = mainFactory; - } - - - @Override - public Optional createAuthentication(JWT token) { - return mainFactory.createAuthentication(token) - .map(auth -> auth); - } -} diff --git a/src/test/java/org/developerden/codosseum/JwtAuthIT.java b/src/test/java/org/developerden/codosseum/JwtAuthIT.java deleted file mode 100644 index 46858f2..0000000 --- a/src/test/java/org/developerden/codosseum/JwtAuthIT.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * # SPDX-FileCopyrightText: 2025 Alexander Wood (BristerMitten) - * # SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License along with this program. - * If not, see . - */ - - -package org.developerden.codosseum; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.micronaut.http.HttpRequest; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.client.HttpClient; -import io.micronaut.http.client.annotation.Client; -import io.micronaut.http.client.exceptions.HttpClientResponseException; -import io.micronaut.http.cookie.Cookie; -import io.micronaut.http.uri.UriBuilder; -import io.micronaut.security.token.generator.TokenGenerator; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.micronaut.test.support.TestPropertyProvider; -import jakarta.inject.Inject; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; -import org.developerden.codosseum.dto.user.User; -import org.junit.jupiter.api.Test; - -@MicronautTest -class JwtAuthIT implements TestPropertyProvider { - - private static final URI SELF_URI = UriBuilder.of("/users").path("@self").build(); - @Inject - @Client("/") - HttpClient client; - @Inject - TokenGenerator tokenGenerator; - - @Override - public Map getProperties() { - Map p = new HashMap<>(); - p.put("micronaut.security.enabled", "true"); - p.put("micronaut.security.authentication", "idtoken"); // use ID Token cookie - p.put("micronaut.security.oauth2.enabled", "false"); // no real IdP in tests - p.put("micronaut.security.token.jwt.enabled", "true"); - p.put("micronaut.security.token.jwt.cookie.enabled", "true"); // cookie name defaults to JWT - p.put("micronaut.security.token.jwt.signatures.secret.generator.secret", - "please-change-test-secret"); - p.put("micronaut.security.token.roles-name", "roles"); // map 'roles' claim to authorities - return p; - } - - private String jwt(Map claims) { - return tokenGenerator.generateToken(claims).orElseThrow(); - } - - @Test - void allows_with_required_role() { - // Match your route annotations: use "ADMIN" for @Secured("ADMIN") or "ROLE_ADMIN" for @Secured("ROLE_ADMIN") - UUID userId = UUID.randomUUID(); - String token = jwt(Map.of("sub", userId.toString(), "name", "Test User")); - HttpRequest req = HttpRequest.GET(SELF_URI).cookie(Cookie.of("JWT", token)); - HttpResponse resp = client.toBlocking().exchange(req, User.class); - assertEquals(HttpStatus.OK, resp.getStatus()); - assertEquals(userId, resp.body().id()); - assertEquals("Test User", resp.body().name()); - assertNull(resp.body().gameId()); - } - - @Test - void unauthorized_without_token() { - HttpClientResponseException e = assertThrows(HttpClientResponseException.class, - () -> client.toBlocking().exchange(HttpRequest.GET(SELF_URI), String.class)); - assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); - } -}