Observer pattern & Archipelago

This commit is contained in:
Mysaa Java
2026-09-29 21:32:22 +02:00
parent 0bb880d434
commit 466da86969
22 changed files with 695 additions and 118 deletions
+4
View File
@@ -0,0 +1,4 @@
## License
Dictionaries taken from [this project](https://github.com/LandazuriPaul/Codenames)
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation 'org.flywaydb:flyway-core:_'
implementation 'org.flywaydb:flyway-database-postgresql:_'
implementation 'jakarta.validation:jakarta.validation-api:_'
implementation 'io.github.archipelagomw:Java-Client:_'
//TODO use webjars
runtimeOnly 'org.webjars:jquery:_'
+3
View File
@@ -39,6 +39,8 @@
echo "Launching NodeCames"
${jdk}/bin/java -jar ${nodecames}/nodecames-${version}.jar --spring.datasource.url=$NODECAMES_DATABASE --spring.datasource.password=$NODECAMES_PASSWORD "$@"
'';
postgres = pkgs.postgresql_18;
# TODO make his more nix
postgres-server = pkgs.writeShellScriptBin "postgres-server" ''
mkdir -p .postgres
${pkgs.podman}/bin/podman run -e POSTGRES_USER=nodecames -e POSTGRES_PASSWORD=nodecames-dev -e POSTGRES_DB=nodecames --volume ./.postgres:/var/lib/postgresql -p 127.0.0.1:5432:5432 postgres:18.4 &> .postgres/postgres.log &
@@ -61,6 +63,7 @@
packages = [
jdk
gradle
postgres
(pkgs.vscode-with-extensions.override {
vscode = pkgs.vscodium;
vscodeExtensions = with pkgs.vscode-extensions; [
+40 -8
View File
@@ -103,17 +103,30 @@
<sha256 value="19889dbdf1b254b2601a5ee645b8147a974644882297684c798afe5d63d78dfe" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.38.0">
<artifact name="error_prone_annotations-2.38.0.jar">
<sha256 value="6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889" origin="Generated by Gradle"/>
<component group="com.google.code.gson" name="gson" version="2.13.2">
<artifact name="gson-2.13.2.jar">
<sha256 value="dd0ce1b55a3ed2080cb70f9c655850cda86c206862310009dcb5e5c95265a5e0" origin="Generated by Gradle"/>
</artifact>
<artifact name="error_prone_annotations-2.38.0.pom">
<sha256 value="3007bef8aff3ae8ea12d81c3feacb4f15979b2cf5c3e38fc9189a98dea141167" origin="Generated by Gradle"/>
<artifact name="gson-2.13.2.pom">
<sha256 value="3aa06aa7c0f9af092961a42d09578e4324be146348a0ee6ed47857f7c2677b76" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_parent" version="2.38.0">
<artifact name="error_prone_parent-2.38.0.pom">
<sha256 value="e62458a6a3e63081bc7c57b3c0fac9f04f76ce32f606532db69fe2b3d47b934c" origin="Generated by Gradle"/>
<component group="com.google.code.gson" name="gson-parent" version="2.13.2">
<artifact name="gson-parent-2.13.2.pom">
<sha256 value="83ab528a9d50fd76aeb8ad6f727b3ee9cb766586255774ed16ca8c4c76d9dacd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.41.0">
<artifact name="error_prone_annotations-2.41.0.jar">
<sha256 value="a56e782b5b50811ac204073a355a21d915a2107fce13ec711331ad036f660fcc" origin="Generated by Gradle"/>
</artifact>
<artifact name="error_prone_annotations-2.41.0.pom">
<sha256 value="a151df1e2e0b48618d8b06a180748a29b3abb39b1b2396f6a1c879a727488c6e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_parent" version="2.41.0">
<artifact name="error_prone_parent-2.41.0.pom">
<sha256 value="c538388d760a5c1c98dcf06f6ed3cfe5f11a651827db5cbd2ed8288c795cad42" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.protobuf" name="protobuf-bom" version="4.35.1">
@@ -280,6 +293,17 @@
<sha256 value="7c490839bb11df9ae7c37f9c31994a4b423bd826245d34abe6c864f5e61d46e9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.github.archipelagomw" name="Java-Client" version="0.2.1">
<artifact name="Java-Client-0.2.1.jar">
<sha256 value="9f3ff0a1a3cd99cbc9090e819c00d3890b775e17cbac47201677972953337a33" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-Client-0.2.1.module">
<sha256 value="c4b35dff4ed88b82e4a9133efdfbc6efb6a23bca3f9124e1b606a37b10240c4f" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-Client-0.2.1.pom">
<sha256 value="812e1984b148cb856afb290029c224de7dfb99a11a28dd9b6a402d2ed761dd06" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.grpc" name="grpc-bom" version="1.83.1">
<artifact name="grpc-bom-1.83.1.pom">
<sha256 value="58cc1c0c0e6dc3aa28a6b8f6c8cc1cbdbdc47843cfba282d07f694c2b1af4f8b" origin="Generated by Gradle"/>
@@ -1044,6 +1068,14 @@
<sha256 value="aeabde04eca66e1ac30f9df3cc36c9b07a25a37c52043b33418e274ce5a23616" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.java-websocket" name="Java-WebSocket" version="1.6.0">
<artifact name="Java-WebSocket-1.6.0.jar">
<sha256 value="eae29213e4f16515639c28957200f011b3967fffcada1962cf0255d24919c22f" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-WebSocket-1.6.0.pom">
<sha256 value="a8e84d553c34d793e2ea7e4a54b073f44ddb0bb442f4dd6fad0cecd755d1ceac" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jboss" name="jboss-parent" version="52">
<artifact name="jboss-parent-52.pom">
<sha256 value="0615b429e3a21a0d6bd925d09ede2937892034272b4cd04bb9ab617d57e72b42" origin="Generated by Gradle"/>
+12 -3
View File
@@ -19,7 +19,9 @@ ITEM_NAME_TO_ID = {
"structured_grid" : 9,
"hints_in_a_row" : 10,
"anyone_starts" : 11,
"filler": 12
"progressive_hint_word_count": 12,
"progressive_hint_word_length": 13,
"filler": 14
}
for w in range(1,12+1):
ITEM_NAME_TO_ID[f"unlock_world_{w}"] = 100+w
@@ -35,14 +37,21 @@ def get_random_filler_item_name(world: NodecamesWorld) -> str:
def create_item_with_correct_classification(
world: NodecamesWorld, name: str
) -> NodecamesItem:
return NodecamesItem(name, ItemClassification.progression, ITEM_NAME_TO_ID[name], world.player)
classification = ItemClassification.progression
if name == "filler":
classification = ItemClassification.filler
return NodecamesItem(name, classification, ITEM_NAME_TO_ID[name], world.player)
def create_all_items(world: NodecamesWorld) -> None:
itempool: list[Item] = []
hintItemCount = world.options.max_hint_count - world.options.max_hint_length
hintItemCount = world.options.max_hint_count - world.options.start_hint_count
itempool += [world.create_item("progressive_hint_count") for _ in range(hintItemCount)]
hintWordLengthItemCount = world.options.max_hint_length - world.options.start_hint_length
itempool += [world.create_item("progressive_hint_word_length") for _ in range(hintWordLengthItemCount)]
hintWordCountItemCount = world.options.max_hint_word_count - world.options.start_hint_word_count
itempool += [world.create_item("progressive_hint_word_count") for _ in range(hintWordCountItemCount)]
itempool += [
world.create_item("zero_hint"),
world.create_item("infinity_hint"),
+27
View File
@@ -58,6 +58,29 @@ class MaxHintLength(Range):
range_end = 30
default = 10
class StartHintWordCount(Range):
"""
Hint Word count available at the beginning of the game
"""
display_name = "Start Hint Word Count"
range_start = 1
range_end = 16
default = 1
class MaxHintWordCount(Range):
"""
Maximal hint word count we can get.
"""
display_name = "Max Hint Word Count"
range_start = 1
range_end = 16
default = 16
class OnOffDefault(Choice):
option_Off = 0
@@ -171,6 +194,8 @@ class NodecamesOptions(PerGameCommonOptions):
max_hint_count: MaxHintCount
start_hint_length: StartHintLength
max_hint_length: MaxHintLength
start_hint_word_count: StartHintWordCount
max_hint_word_count: MaxHintWordCount
zero_hint: ZeroHint
infinity_hint: InfinityHint
one_more_guess: OneMoreGuess
@@ -218,6 +243,8 @@ option_presets = {
"max_hint_count": 10,
"start_hint_length": 3,
"max_hint_length": 12,
"start_hint_word_count": 1,
"max_hint_word_count": 16,
"zero_hint": 1,
"infinity_hint": 1,
"one_more_guess": 1,
+5 -1
View File
@@ -12,7 +12,7 @@ class NodecamesWorld(World):
Nodecames is a word game, inspired by Code Names by Vlaada Chvátil
"""
game = "Nodecames"
game = "nodecames"
options_dataclass = nodecames_options.NodecamesOptions
options: nodecames_options.NodecamesOptions
@@ -40,6 +40,10 @@ class NodecamesWorld(World):
def get_filler_item_name(self) -> str:
return items.get_random_filler_item_name(self)
def generate_output(self, outdir : str) -> None:
# Generates the output file
pass
# Data sent to the client
def fill_slot_data(self) -> Mapping[str, Any]:
return self.options.as_dict(
@@ -1,11 +1,16 @@
package com.bernard.nodecames;
import java.net.URISyntaxException;
import java.util.Comparator;
import java.util.List;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.view.RedirectView;
import com.bernard.nodecames.frontend.GridConfigEdit;
import com.bernard.nodecames.game.ArchipelagoManager;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
@@ -33,6 +39,7 @@ import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.GridRepository;
import com.bernard.nodecames.repository.WorldRepository;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@@ -41,6 +48,7 @@ public class HttpController {
private final GridManager gm;
private final GameManager wm;
private final ArchipelagoManager am;
private final GridRepository gridRepository;
private final GameRepository gRepository;
private final WorldRepository wRepository;
@@ -78,6 +86,7 @@ public class HttpController {
model.addAttribute("gridConfig", GridConfigEdit.of(gc));
return "grid-config";
}
@PostMapping("/room/{id}/config")
public Object setGridConfig(@PathVariable("id") String id,
@ModelAttribute("gridConfig") GridConfigEdit gce,
@@ -138,6 +147,38 @@ public class HttpController {
return "map";
}
@GetMapping("/game/{id}/archipelago")
public Object gridConfig(@PathVariable("id") String id, @RequestParam("address") String address,
@RequestParam("slot") String slot, Model model) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
try {
CompletableFuture<ConnectionResult> connectionResultFuture = am.openClient(g.get(), address, slot);
ConnectionResult connectionResult = connectionResultFuture.get(5, TimeUnit.SECONDS);
if (connectionResult != ConnectionResult.Success) {
return new ResponseEntity<>("Could not connect to archipelago server: "+connectionResult.name(), HttpStatus.NOT_FOUND);
} else {
return new ResponseEntity<>("Connection successful", HttpStatus.OK);
}
} catch (TimeoutException e) {
return new ResponseEntity<>("Timed out connecting to archipelago", HttpStatus.REQUEST_TIMEOUT);
} catch (URISyntaxException e) {
return new ResponseEntity<>("URI malformed : "+e.getMessage(), HttpStatus.BAD_REQUEST);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
return new ResponseEntity<>("Interrupted", HttpStatus.INTERNAL_SERVER_ERROR);
} catch (ExecutionException e) {
e.printStackTrace();
return new ResponseEntity<>("ExecutionException while connecting to Archipelago : "+e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/game/{id}/grid/{world}")
public Object gridRedirect(@PathVariable("id") String id, @PathVariable("world") String world, @RequestParam("player") String player) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
@@ -148,7 +189,12 @@ public class HttpController {
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Grid grid = wm.getGrid(g.get(), w.get());
Grid grid;
try {
grid = wm.getGrid(g.get(), w.get());
} catch (IllegalGameActionException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
return new RedirectView("/room/"+grid.getId()+"/grid" + "?player="+player);
}
@@ -162,7 +208,11 @@ public class HttpController {
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
try {
Grid newGrid = wm.resetGrid(g.get(), w.get());
return new RedirectView("/room/"+newGrid.getId()+"/grid" + "?player="+player);
} catch (IllegalGameActionException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
}
@@ -1,7 +1,9 @@
package com.bernard.nodecames.frontend;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.http.HttpStatus;
@@ -17,14 +19,20 @@ import org.springframework.web.bind.annotation.PathVariable;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.repository.GridRepository;
import lombok.AllArgsConstructor;
import tools.jackson.databind.JsonNode;
@Controller
@AllArgsConstructor
public class WebSocketController {
private final GridManager gm;
@@ -32,49 +40,19 @@ public class WebSocketController {
private final JsonDataService json;
private final GridRepository gRepository;
public WebSocketController(GridManager gm, SimpMessagingTemplate template, JsonDataService json, GridRepository gRepository) {
this.gm = gm;
this.template = template;
this.json = json;
this.gRepository = gRepository;
gm.setWsc(this);
Set<UUID> observed = new HashSet<>();
private void registerObserver(Grid g) {
if(!observed.contains(g.getId())) {
observed.add(g.getId());
gm.addObserver(g, new GObserver(g));
}
public void onNewEvent(Grid g, GameEvent ge) {
this.template.convertAndSend("/topic/new-event",
json.eventData(ge)
);
}
public void onNewPhase(Grid g) {
this.template.convertAndSend(
"/topic/new-phase",
json.phaseData(g.getPhase())
);
}
public void onConfigChange(Grid g, MutableOption cc, Object data) {
this.template.convertAndSend("/topic/config-change",
json.gridConfigUpdateData(cc, data)
);
}
public void publishCardUpdateToPlayer(Grid g, Card c, char player) {
this.template.convertAndSend(
"/topic/update-card/" + Character.toLowerCase(player),
json.cardData(c, player),
Map.of("cardIndex", c.getIndex())
);
}
public void publishCardUpdate(Grid g, Card card) {
publishCardUpdateToPlayer(g, card, 'A');
publishCardUpdateToPlayer(g, card, 'B');
}
public void publishError(char issuer, IllegalGameActionException e) {
//TODO open error channel
}
//TODO We should do this with websocket protocol
@GetMapping("/room/{id}/game/{player}")
public Object grid(@PathVariable("id") String gridId, @PathVariable("player") String playerStr) {
char player;
@@ -86,14 +64,19 @@ public class WebSocketController {
throw new IllegalArgumentException("Unknown player type");
Optional<Grid> g = gRepository.findById(UUID.fromString(gridId));
if(g.isPresent())
return new ResponseEntity<>(json.gridData(g.get(), player), HttpStatus.OK);
else
if(g.isPresent()){
Grid grid = g.get();
registerObserver(grid);
return new ResponseEntity<>(json.gridData(grid, player), HttpStatus.OK);
} else
return new ResponseEntity<>("Could not find grid with given ID",HttpStatus.NOT_FOUND);
}
@MessageMapping("submit-hint")
public void submitHint(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
//TODO handle websockets per grid
@MessageMapping("/submit-hint")
public void submitHint(@Header("room") String roomId,
@Header("player") String player,
@Payload JsonNode content) {
String hint = content.asObject().get("hint").stringValue();
int wordCount = content.asObject().get("hintWordCount").intValue();
@@ -101,7 +84,6 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.proposeHint(g.get(), player.charAt(0), hint, wordCount);
} catch (IllegalGameActionException e) {
@@ -118,10 +100,8 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.pointCard(g.get(), player.charAt(0), cardIndex);
publishCardUpdate(g.get(), gm.getCard(g.get(), cardIndex));
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -134,7 +114,6 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.endGuessing(g.get(), player.charAt(0));
} catch (IllegalGameActionException e) {
@@ -156,4 +135,56 @@ public class WebSocketController {
e.printStackTrace();
}
}
@AllArgsConstructor
private class GObserver implements GridObserver {
Grid grid;
public void publishCardUpdateToPlayer(Card c, char player) {
template.convertAndSend(
"/topic/%s/update-card/%s".formatted(grid.getId(), Character.toLowerCase(player)),
json.cardData(c, player),
Map.of("cardIndex", c.getIndex())
);
}
public void publishCardUpdate(Card card) {
publishCardUpdateToPlayer(card, 'A');
publishCardUpdateToPlayer(card, 'B');
}
@Override
public void onPointCard(char player, Card card, Color color) {
publishCardUpdate(card);
}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGridEnd(boolean won) {}
@Override
public void onGameEvent(GameEvent ge) {
template.convertAndSend(
"/topic/%s/new-event".formatted(grid.getId()),
json.eventData(ge)
);
}
@Override
public void onNewPhase(Phase p) {
template.convertAndSend(
"/topic/%s/new-phase".formatted(grid.getId()),
json.phaseData(p)
);
}
@Override
public void onConfigChange(MutableOption cc, Object data) {
template.convertAndSend(
"/topic/%s/config-change".formatted(grid.getId()),
json.gridConfigUpdateData(cc, data)
);
}
}
}
@@ -0,0 +1,141 @@
package com.bernard.nodecames.game;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import io.github.archipelagomw.APResult.ResultCode;
import io.github.archipelagomw.Client;
import io.github.archipelagomw.ClientStatus;
import io.github.archipelagomw.events.ArchipelagoEventListener;
import io.github.archipelagomw.events.ConnectionResultEvent;
import io.github.archipelagomw.events.ReceiveItemEvent;
import io.github.archipelagomw.flags.ItemsHandling;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.Getter;
public class ArchipelagoClient extends Client {
Game game;
GameManager gm;
private static final Logger log = LoggerFactory.getLogger(ArchipelagoClient.class);
public ArchipelagoClient(Game game, GameManager gm, String slot) {
super();
this.gm = gm;
this.game = game;
this.setGame("nodecames");
this.setName(slot);
client.setItemsHandlingFlags(ItemsHandling.SEND_ITEMS | ItemsHandling.SEND_OWN_ITEMS);
this.getEventManager().registerListener(this);
}
@ArchipelagoEventListener
public void onReceiveItem(ReceiveItemEvent e) throws IllegalGameActionException {
log.debug("Received item : {}", e.getItemName());
switch(e.getItemName()) {
case "progressive_hint_count":
gm.unlockMoreHintCount(this.game);
break;
case "progressive_hint_word_length":
gm.unlockMoreHintWordLengthCount(this.game);
break;
case "progressive_hint_word_count":
gm.unlockMoreHintWordCount(this.game);
break;
case "zero_hint":
gm.unlockHintWordCount(this.game, 0);
break;
case "infinity_hint":
gm.unlockHintWordCount(this.game, -1);
break;
case "one_more_guess":
gm.unlockMutable(game, MutableOption.ONE_MORE_GUESS, true);
break;
case "end_guessing":
gm.unlockMutable(game, MutableOption.END_GUESSING, true);
break;
case "only_one_green_green":
gm.unlockMutable(game, MutableOption.ONLY_ONE_GREEN_GREEN, true);
break;
case "sudden_death":
gm.unlockMutable(game, MutableOption.SUDDEN_DEATH, true);
break;
case "structured_grid":
gm.unlockStructuredGrid(game);
break;
case "hints_in_a_row":
gm.unlockMutable(game, MutableOption.HINTS_IN_A_ROW, true);
break;
case "anyone_starts":
gm.unlockAnyoneCanStart(game);
break;
case "filler":
// We do nothing if we receive a filler item
break;
default:
if (e.getItemName().startsWith("unlock_world_")) {
int worldIndex = Integer.parseInt(e.getItemName().substring("unlock_world_".length()))-1;
String worldName = "%d-%d".formatted((worldIndex/3)+1, (worldIndex%3)+1);
gm.unlockWorld(game, worldName);
} else {
throw new IllegalArgumentException("Received unknown item : "+e.getItemName());
}
}
}
@Getter
private CompletableFuture<ConnectionResult> connectionResult = new CompletableFuture<>();
@ArchipelagoEventListener
public void onConnectResult(ConnectionResultEvent e) {
connectionResult.complete(e.getResult());
if(e.getResult() != ConnectionResult.Success) {
throw new IllegalStateException("Could not connect to archipelago : %s".formatted(e.getResult().name()));
} else {
if(this.setGameState(ClientStatus.CLIENT_PLAYING).getCode() != ResultCode.SUCCESS)
throw new IllegalStateException("Colud not set state to playing");
else {
client.sendChat("Hello, here is Nodecames AP client, nice to meet you");
}
}
}
public static int[] GREEN_COUNT_PER_WORLD = new int[]{6, 9, 9, 12, 16, 16, 20, 25, 25, 30, 36, 36};
public static String[] WORLDS = new String[]{"1-1","1-2","1-3","2-1","2-2","2-3","3-1","3-2","3-3","4-1","4-2","4-3"};
public void checkWordInWorld(String worldName, int wordIndex) {
//TODO binarySearch is okay for now, but we'll have to remove this at some point
int worldIndex = Arrays.binarySearch(WORLDS, 0, WORLDS.length, worldName);
if(wordIndex>GREEN_COUNT_PER_WORLD[wordIndex]-1)return;//We don't have that many checks
//TODO this should be stored, not computed, in archipelagoconfig
long locationId = IntStream.range(0, worldIndex).map(w -> GREEN_COUNT_PER_WORLD[w]).sum()+1+wordIndex;
this.checkLocation(locationId);
}
public void checkWorldComplete(String worldName) {
//TODO binarySearch is okay for now, but we'll have to remove this at some point
int worldIndex = Arrays.binarySearch(WORLDS, 0, WORLDS.length, worldName);
long locationId = IntStream.range(0, worldIndex+1).map(w -> GREEN_COUNT_PER_WORLD[w]).sum()+1;
this.checkLocation(locationId);
}
@Override
public void onError(Exception e) {
//TODO make a real handler
throw new IllegalStateException("Archipelago error :", e);
}
@Override
public void onClose(String Reason, int attemptingReconnect) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'onClose'");
}
}
@@ -0,0 +1,92 @@
package com.bernard.nodecames.game;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.observer.WorldObserver;
import com.bernard.nodecames.repository.CardRepository;
import com.bernard.nodecames.repository.WorldRepository;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class ArchipelagoManager {
Map<UUID, ArchipelagoClient> clients = new HashMap<>();
GameManager gm;
CardRepository cRepository;
WorldRepository wRepository;
private static final Logger log = LoggerFactory.getLogger(ArchipelagoManager.class);
public CompletableFuture<ConnectionResult> openClient(Game g, String address, String slot) throws URISyntaxException{
ArchipelagoClient client = new ArchipelagoClient(g, gm, slot);
client.connect(address);
clients.put(g.getId(), client);
Set<World> worlds = wRepository.findByGame(g);
log.debug("Registering observer for {} worlds", worlds.size());
for(World w : worlds) {
gm.addObserver(w, new WObserver(w, client));
}
return client.getConnectionResult();
}
@AllArgsConstructor
private class WObserver implements WorldObserver {
private World world;
ArchipelagoClient client;
@Override
public void onPointCard(char player, Card card, Color color) {
// We compute the count of green cards
//TODO right now, we only do the strategy of green green cards once
//TODO decide wether we or they should have the logic of wether to send the ith check
Card[] cards = cRepository.findByGridOrderById_Index(world.getCurrentGrid());
int greenCount = (int)Arrays.stream(cards).filter(c ->
(c.isColorAPublic() && c.getColorA() == Color.GREEN) ||
(c.isColorBPublic() && c.getColorB() == Color.GREEN)
).count();
log.info("Pointed card, {}th", greenCount);
client.checkWordInWorld(world.getName(), greenCount);
}
@Override
public void onGridEnd(boolean win) {
if(win) {
client.checkWorldComplete(world.getName());
}
}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGameEvent(GameEvent ge) {}
@Override
public void onNewPhase(Phase p) {}
@Override
public void onConfigChange(MutableOption cc, Object data) {}
}
}
@@ -2,38 +2,75 @@ package com.bernard.nodecames.game;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.model.World.WorldId;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.observer.WorldObserver;
import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.WorldRepository;
import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class GameManager {
// Reverse map allowing to pass grid events to the game
Map<UUID, World> currentGrids = new HashMap<>();
private final GridManager gm;
private final GameRepository gRepository;
private final WorldRepository wRepository;
public GameManager(GridManager gm, GameRepository gRepository, WorldRepository wRepository) {
this.gm = gm;
this.gRepository = gRepository;
this.wRepository = wRepository;
gm.setWm(this);
//TODO try putting "World" here instead of WorldId
private Map<WorldId, Set<WorldObserver>> observers = new HashMap<>();
public void addObserver(World w, WorldObserver observer) {
if(!observers.containsKey(w.getId()))
observers.put(w.getId(), new CopyOnWriteArraySet<>());
observers.get(w.getId()).add(observer);
if(w.getCurrentGrid() != null)
gm.addObserver(w.getCurrentGrid(), observer);
}
private void forEachObserver(World w, Consumer<WorldObserver> action) {
observers.getOrDefault(w.getId(), Set.of()).forEach(action);
}
/*
* In case of server restart, registers to GridManager observers
*/
@PostConstruct
private void registerRunningObservers() {
for(World w : wRepository.findAll()) {
if(w.getCurrentGrid() != null && w.getCurrentGrid().getWon() != null) {
registerObserver(w, w.getCurrentGrid());
}
}
}
private void registerObserver(World w, Grid g) {
gm.addObserver(g, new GObserver(g, w));
forEachObserver(w, o -> gm.addObserver(g, o));
}
public Game createGame(String name) {
@@ -42,10 +79,9 @@ public class GameManager {
Game g = new Game(uuid, name);
gRepository.save(g);
for (Entry<String, GridConfig> wConfig : Game.DEFAULT_WORLDS_CONFIG.entrySet()) {
World w = new World(g, wConfig.getKey(), wConfig.getValue(), null, false);
World w = new World(g, wConfig.getKey(), wConfig.getValue(), null, false, true);
wRepository.save(w);
}
return g;
}
@@ -57,22 +93,26 @@ public class GameManager {
return wRepository.findByGame(g);
}
private Grid newGrid(Game g, World w) {
private Grid newGrid(Game g, World w) throws IllegalGameActionException {
if(w.isLocked())
//TODO separate IllegaleGameAction and IllegalGridAction
throw new IllegalGameActionException(null, "Cannot create a grid in a locked world");
GridConfig gc = w.getInitialConfig();
//TODO seed random
Grid grid = gm.newGrid(w, gc, new Random());
w.setCurrentGrid(grid);
wRepository.save(w);
currentGrids.put(grid.getId(), w);
registerObserver(w, grid);
return grid;
}
public Grid resetGrid(Game g, World world) {
public Grid resetGrid(Game g, World world) throws IllegalGameActionException{
return newGrid(g, world);
}
public Grid getGrid(Game g, World w) {
public Grid getGrid(Game g, World w) throws IllegalGameActionException{
if(w.getCurrentGrid() != null) {
return w.getCurrentGrid();
} else {
@@ -80,11 +120,100 @@ public class GameManager {
}
}
public void onGameWon(Grid g) {
World w = currentGrids.get(g.getId());
if(w != null) {
w.setWon(true);
/**
* UPDATE GAME CONFIG
*/
public void unlockMutable(Game g, MutableOption opt, Object o) throws IllegalGameActionException {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.set(opt, o);
w.setInitialConfig(initialConfig);
wRepository.save(w);
Grid grid = w.getCurrentGrid();
if(grid != null) {
gm.unlock(grid, opt, o);
}
}
}
public void unlockStructuredGrid(Game g) {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.setStructuredGrid(true);
w.setInitialConfig(initialConfig);
wRepository.save(w);
}
}
public void unlockAnyoneCanStart(Game g) {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.setAnyoneStarts(true);
w.setInitialConfig(initialConfig);
wRepository.save(w);
}
}
public void unlockWorld(Game g, String name) throws IllegalGameActionException {
Optional<World> ow = wRepository.findByGameAndId_Name(g, name);
if(!ow.isPresent())
throw new IllegalGameActionException(null, "Could not find world with name %s".formatted(name));
World w = ow.get();
w.setLocked(false);
wRepository.save(w);
}
public void unlockMoreHintCount(Game game) throws IllegalGameActionException {
//TODO Store current hint count elsewhere
int currentHintCount = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getMaxHint();
unlockMutable(game, MutableOption.MAX_HINT, currentHintCount+1);
}
public void unlockMoreHintWordLengthCount(Game game) throws IllegalGameActionException {
//TODO Store current hint word length elsewhere
int currentHintWordLength = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getHintMaxLength();
unlockMutable(game, MutableOption.HINT_MAX_LENGTH, currentHintWordLength+1);
}
public void unlockMoreHintWordCount(Game game) throws IllegalGameActionException {
//TODO Store current hint word count elsewhere
int currentMaxWordCount = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getAvailableHintWordCount().stream().mapToInt(i -> i).max().getAsInt();
unlockHintWordCount(game, currentMaxWordCount+1);
}
public void unlockHintWordCount(Game game, int i) throws IllegalGameActionException {
Set<Integer> currentWordCounts = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getAvailableHintWordCount();
if(currentWordCounts.contains(i))
throw new IllegalGameActionException(null, "Word count already unlocked : %d".formatted(i));
Set<Integer> newWordCounts = new HashSet<>(currentWordCounts);
newWordCounts.add(i);
unlockMutable(game, MutableOption.AVAILABLE_HINT_WORD_COUNT, newWordCounts);
}
@AllArgsConstructor
private class GObserver implements GridObserver {
private Grid grid;
private World world;
@Override
public void onGridEnd(boolean win) {
if(win) {
world.setWon(true);
wRepository.save(world);
}
}
@Override
public void onPointCard(char player, Card card, Color color) {}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGameEvent(GameEvent ge) {}
@Override
public void onNewPhase(Phase p) {}
@Override
public void onConfigChange(MutableOption cc, Object data) {}
}
}
@@ -2,11 +2,15 @@ package com.bernard.nodecames.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -14,10 +18,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.frontend.WebSocketController;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig;
@@ -25,21 +28,18 @@ import com.bernard.nodecames.model.GridConfig.CardColors;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.repository.CardRepository;
import com.bernard.nodecames.repository.GridRepository;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Service
@RequiredArgsConstructor
public class GridManager {
@Setter
private WebSocketController wsc = null;
@Setter
private GameManager wm = null;
private Map<UUID, Set<GridObserver>> observers = new HashMap<>();
private final GridRepository gRepository;
private final CardRepository cRepository;
@@ -48,11 +48,22 @@ public class GridManager {
private static final Logger log = LoggerFactory.getLogger(GridManager.class);
public void addObserver(Grid g, GridObserver observer) {
if(!observers.containsKey(g.getId()))
observers.put(g.getId(), new CopyOnWriteArraySet<>());
observers.get(g.getId()).add(observer);
}
private void forEachObserver(Grid g, Consumer<GridObserver> action) {
log.debug("The current observers are : {}", observers.get(g.getId()));
observers.getOrDefault(g.getId(), Set.of()).forEach(action);
}
private void newGameEvent(Grid g, GameEvent ge) {
log.debug("New game event : {}", ge);
g.newGameEvent(ge);
gRepository.save(g);
wsc.onNewEvent(g, ge);
forEachObserver(g, o -> o.onGameEvent(ge));
}
private void setPhase(Grid g, Phase phase) {
@@ -62,33 +73,40 @@ public class GridManager {
g.setCurrentHint(null);
}
gRepository.save(g);
wsc.onNewPhase(g);
forEachObserver(g, o -> o.onNewPhase(phase));
}
private void setWon(Grid g, boolean won) {
log.debug("Game won: {}", won);
g.setWonOpt(Optional.of(won));
gRepository.save(g);
forEachObserver(g, o -> o.onGridEnd(won));
// Grid will no longer be updated, therefore we stop observing it
observers.remove(g.getId());
}
private void setCardPublic(Card c, char player) {
private void setCardPointed(Grid g, Card c, char player, Color col) {
log.debug("Card set public, index {}", c.getIndex());
c.setColorPublic(player, true);
cRepository.save(c);
forEachObserver(c.getGrid(), o -> o.onPointCard(player, c, col));
g.incrementCurrentGuessCount();
gRepository.save(g);
}
private void newHint(Grid g, Hint h) {
private void newHint(Grid g, char player, Hint h) {
log.debug("New hint: {}", h);
g.incrementHintCount();
g.setCurrentHint(h);
g.setCurrentGuessCount(0);
gRepository.save(g);
forEachObserver(g, o -> o.onSubmitHint(player, h));
}
private void changeConfig(Grid g, MutableOption cc, Object data) {
log.debug("Changed config option: {} -> new value: {}", cc.name(), data);
g.getConfig().set(cc, data);
gRepository.save(g);
wsc.onConfigChange(g, cc, data);
forEachObserver(g, o -> o.onConfigChange(cc, data));
}
@@ -169,12 +187,10 @@ public class GridManager {
* GAME ACTIONS *
**********************************/
private void endGame(Grid g, boolean win) {
setWon(g, win);
setPhase(g, Phase.GAME_ENDED);
newGameEvent(g, GameEvent.newGameEndEvent(win));
if(win) {
wm.onGameWon(g);
}
// This must be the last step as it removes observers
setWon(g, win);
}
public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException {
@@ -189,7 +205,7 @@ public class GridManager {
}
Hint h = new Hint(hint, wordCount);
newHint(g, h);
newHint(g, player, h);
newGameEvent(g, GameEvent.newHintEvent(h, player));
setPhase(g, Phase.guessing(other(player)));
}
@@ -264,8 +280,8 @@ public class GridManager {
if(c.isColorPublic(other(player)) || (g.getConfig().isOnlyOneGreenGreen() && c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player)))
throw new IllegalGameActionException(g, "Card has already been revealed");
setCardPublic(c, other(player));
Card.Color guessedColor = c.getColor(other(player));
setCardPointed(g, c, other(player), guessedColor);
newGameEvent(g, GameEvent.newGuessEvent(c.getIndex(), player, c.getColor(other(player))));
g.incrementCurrentGuessCount();
@@ -369,7 +385,7 @@ public class GridManager {
}
public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException {
if(newHintCount > g.getConfig().getMaxMaxHint()) {
throw new IllegalGameActionException(g, "Illegal hint count");
throw new IllegalGameActionException(g, "Hint count higher than highest hint count");
}
if(newHintCount <= g.getConfig().getMaxHint()) {
throw new IllegalGameActionException(g, "Cannot lower hint count");
@@ -3,6 +3,7 @@ package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
@@ -10,12 +11,15 @@ import org.hibernate.type.SqlTypes;
import jakarta.annotation.Nullable;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -21,6 +21,7 @@ import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.Value;
@Getter
@@ -50,27 +51,24 @@ public class GridConfig {
0,1,2,3,4,5,6,7,8,9,-1
);
public static final GridConfig BIG_CLASSICAL = new GridConfig(
12, true, true, CUSTOM36, true,
true, true, 12, BASE_HINTWORDCOUNT, true,
12, CUSTOM36, true, true,
true, true, true, 12, BASE_HINTWORDCOUNT, true,
Integer.MAX_VALUE, true, true,
true, false);
public static final GridConfig CODENAMES_CLASSICAL = new GridConfig(
9, true, true, CLASSICAL25, true,
9, CLASSICAL25, true, true, true,
true, true, 9, BASE_HINTWORDCOUNT, true,
Integer.MAX_VALUE, true, true,
true, false);
public static final GridConfig ZERO = new GridConfig(
9, false, false, CLASSICAL25, true,
true, false, 1, Set.of(1), false,
3, false, false,
false, false);
public static final GridConfig ZERO = zeroWithSize(CLASSICAL25);
//TODO this 9 should be config
public static final GridConfig zeroWithSize(Map<CardColors, Integer> colors) {
return new GridConfig(
9, false, false, colors, true,
true, false, 1, Set.of(1), false,
9, colors, true, true,
false, false, false, 1, Set.of(1), false,
3, false, false,
false, false);
}
@@ -79,8 +77,6 @@ public class GridConfig {
// Maximum of maximom number of hints
// This is to trigger end of game
private int maxMaxHint;
private boolean structuredGrid;
private boolean anyoneStarts;
@Convert(converter = WordDistributionConverter.class)
private Map<CardColors, Integer> wordDistribution;
private boolean maybeSuddenDeath; //TODO
@@ -89,6 +85,11 @@ public class GridConfig {
// prevend death and enter WIN_IF_GREEN_GREEN state, else just die
private boolean onlyOneGreenGreenPreventDeath;
// Game mutable options
@Setter
private boolean structuredGrid;
@Setter
private boolean anyoneStarts;
// Mutable options
private int maxHint;
@@ -34,12 +34,15 @@ public class World {
boolean won;
public World(Game g, String name, GridConfig initConfig, Grid currentGrid, boolean won) {
boolean locked;
public World(Game g, String name, GridConfig initConfig, Grid currentGrid, boolean won, boolean locked) {
this.id = new WorldId(g.getId(), name);
this.game = g;
this.initialConfig = initConfig;
this.currentGrid = currentGrid;
this.won = won;
this.locked = locked;
}
public String getName() {
@@ -0,0 +1,20 @@
package com.bernard.nodecames.observer;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
public interface GridObserver {
public void onPointCard(char player, Card card, Color color);
public void onSubmitHint(char player, Hint hint);
public void onGridEnd(boolean won);
public void onGameEvent(GameEvent ge);
public void onNewPhase(Phase p);
public void onConfigChange(MutableOption cc, Object data);
}
@@ -0,0 +1,5 @@
package com.bernard.nodecames.observer;
public interface WorldObserver extends GridObserver {
}
@@ -0,0 +1 @@
alter table if exists world add column locked boolean not null default false;
+4 -4
View File
@@ -256,10 +256,10 @@ function selectPlayer(player) {
socket = Stomp.over(new SockJS('/socket'));
socket.connect({}, function (frame) {
socket.subscribe('/topic/new-phase', onNewPhase);
socket.subscribe('/topic/new-event', onNewEvent);
socket.subscribe('/topic/update-card/' + player.toLowerCase(), onUpdateCard);
socket.subscribe('/topic/config-change', onConfigChange)
socket.subscribe('/topic/'+roomId+'/new-phase', onNewPhase);
socket.subscribe('/topic/'+roomId+'/new-event', onNewEvent);
socket.subscribe('/topic/'+roomId+'/update-card/' + player.toLowerCase(), onUpdateCard);
socket.subscribe('/topic/'+roomId+'/config-change', onConfigChange)
});
}
+4 -2
View File
@@ -12,8 +12,10 @@
<main>
<ul>
<li th:each="world : ${worlds}">
<a th:href="@{/game/{gid}/grid/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">World <span th:text="${world.name}"/></a>
<a th:href="@{/game/{gid}/reset/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">reset</a>
<!-- TODO remove this duplication of World span -->
<span th:if="${world.locked}"> World <span th:text="${world.name}"/> </span>
<a th:if="!${world.locked}" th:href="@{/game/{gid}/grid/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">World <span th:text="${world.name}"/></a>
<a th:if="!${world.locked}" th:href="@{/game/{gid}/reset/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">reset</a>
<span th:if="${world.won}">Gagné !</span>
</li>
</ul>
+2
View File
@@ -11,6 +11,8 @@ version.com.fasterxml.jackson.core..jackson-databind=2.22.2
version.com.fasterxml.jackson.datatype..jackson-datatype-hibernate5=2.22.2
version.io.github.archipelagomw..Java-Client=0.2.1
version.jakarta.validation..jakarta.validation-api=3.1.1
## # available=4.0.0-M1