diff --git a/README.md b/README.md new file mode 100644 index 0000000..663b336 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ + + +## License +Dictionaries taken from [this project](https://github.com/LandazuriPaul/Codenames) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 407c492..2d3fe06 100644 --- a/build.gradle +++ b/build.gradle @@ -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:_' diff --git a/flake.nix b/flake.nix index f1292c4..f28bf10 100644 --- a/flake.nix +++ b/flake.nix @@ -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; [ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index fcaf524..f6be882 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -103,17 +103,30 @@ - - - + + + - - + + - - - + + + + + + + + + + + + + + + + @@ -280,6 +293,17 @@ + + + + + + + + + + + @@ -1044,6 +1068,14 @@ + + + + + + + + diff --git a/src/main/apworld/items.py b/src/main/apworld/items.py index ffec7df..0980540 100644 --- a/src/main/apworld/items.py +++ b/src/main/apworld/items.py @@ -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"), diff --git a/src/main/apworld/options.py b/src/main/apworld/options.py index 8d8a904..4ee9633 100644 --- a/src/main/apworld/options.py +++ b/src/main/apworld/options.py @@ -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, diff --git a/src/main/apworld/world.py b/src/main/apworld/world.py index 5b11c82..f033b0d 100644 --- a/src/main/apworld/world.py +++ b/src/main/apworld/world.py @@ -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( diff --git a/src/main/java/com/bernard/nodecames/HttpController.java b/src/main/java/com/bernard/nodecames/HttpController.java index 6249e8a..52bb45f 100644 --- a/src/main/java/com/bernard/nodecames/HttpController.java +++ b/src/main/java/com/bernard/nodecames/HttpController.java @@ -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 g = gRepository.findById(UUID.fromString(id)); + if(!g.isPresent()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + + try { + CompletableFuture 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 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); } - Grid newGrid = wm.resetGrid(g.get(), w.get()); - return new RedirectView("/room/"+newGrid.getId()+"/grid" + "?player="+player); + 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); + } } } diff --git a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java index 6190572..57d08d0 100644 --- a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java +++ b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java @@ -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); - } - - 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'); + Set 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 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 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) + ); + } + } } diff --git a/src/main/java/com/bernard/nodecames/game/ArchipelagoClient.java b/src/main/java/com/bernard/nodecames/game/ArchipelagoClient.java new file mode 100644 index 0000000..eca80d0 --- /dev/null +++ b/src/main/java/com/bernard/nodecames/game/ArchipelagoClient.java @@ -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 = 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'"); + } + + +} diff --git a/src/main/java/com/bernard/nodecames/game/ArchipelagoManager.java b/src/main/java/com/bernard/nodecames/game/ArchipelagoManager.java new file mode 100644 index 0000000..5e0d103 --- /dev/null +++ b/src/main/java/com/bernard/nodecames/game/ArchipelagoManager.java @@ -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 clients = new HashMap<>(); + + GameManager gm; + CardRepository cRepository; + WorldRepository wRepository; + + private static final Logger log = LoggerFactory.getLogger(ArchipelagoManager.class); + + public CompletableFuture 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 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) {} + } + +} diff --git a/src/main/java/com/bernard/nodecames/game/GameManager.java b/src/main/java/com/bernard/nodecames/game/GameManager.java index 8b1ed33..a9a9a00 100644 --- a/src/main/java/com/bernard/nodecames/game/GameManager.java +++ b/src/main/java/com/bernard/nodecames/game/GameManager.java @@ -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 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> 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 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 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 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 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 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 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 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 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) {} + } } diff --git a/src/main/java/com/bernard/nodecames/game/GridManager.java b/src/main/java/com/bernard/nodecames/game/GridManager.java index db02368..450248b 100644 --- a/src/main/java/com/bernard/nodecames/game/GridManager.java +++ b/src/main/java/com/bernard/nodecames/game/GridManager.java @@ -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> 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 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,9 +280,9 @@ 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(); if(guessedColor == Color.BLACK){ @@ -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"); diff --git a/src/main/java/com/bernard/nodecames/model/Grid.java b/src/main/java/com/bernard/nodecames/model/Grid.java index 17de405..750e525 100644 --- a/src/main/java/com/bernard/nodecames/model/Grid.java +++ b/src/main/java/com/bernard/nodecames/model/Grid.java @@ -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; diff --git a/src/main/java/com/bernard/nodecames/model/GridConfig.java b/src/main/java/com/bernard/nodecames/model/GridConfig.java index a44a639..c7d8965 100644 --- a/src/main/java/com/bernard/nodecames/model/GridConfig.java +++ b/src/main/java/com/bernard/nodecames/model/GridConfig.java @@ -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 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 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; diff --git a/src/main/java/com/bernard/nodecames/model/World.java b/src/main/java/com/bernard/nodecames/model/World.java index 0b4dea1..849683b 100644 --- a/src/main/java/com/bernard/nodecames/model/World.java +++ b/src/main/java/com/bernard/nodecames/model/World.java @@ -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() { diff --git a/src/main/java/com/bernard/nodecames/observer/GridObserver.java b/src/main/java/com/bernard/nodecames/observer/GridObserver.java new file mode 100644 index 0000000..e435bf8 --- /dev/null +++ b/src/main/java/com/bernard/nodecames/observer/GridObserver.java @@ -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); + +} diff --git a/src/main/java/com/bernard/nodecames/observer/WorldObserver.java b/src/main/java/com/bernard/nodecames/observer/WorldObserver.java new file mode 100644 index 0000000..f33d33e --- /dev/null +++ b/src/main/java/com/bernard/nodecames/observer/WorldObserver.java @@ -0,0 +1,5 @@ +package com.bernard.nodecames.observer; + +public interface WorldObserver extends GridObserver { + +} diff --git a/src/main/resources/db/migration/V1_1_0__world_locking.sql b/src/main/resources/db/migration/V1_1_0__world_locking.sql new file mode 100644 index 0000000..0191a9b --- /dev/null +++ b/src/main/resources/db/migration/V1_1_0__world_locking.sql @@ -0,0 +1 @@ +alter table if exists world add column locked boolean not null default false; diff --git a/src/main/resources/static/js/grid.js b/src/main/resources/static/js/grid.js index 60cec55..61d86ab 100644 --- a/src/main/resources/static/js/grid.js +++ b/src/main/resources/static/js/grid.js @@ -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) }); } diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html index 0467ca7..59c63e4 100644 --- a/src/main/resources/templates/map.html +++ b/src/main/resources/templates/map.html @@ -12,8 +12,10 @@
diff --git a/versions.properties b/versions.properties index 06110f9..04ea12a 100644 --- a/versions.properties +++ b/versions.properties @@ -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