From 920e378dd526ec7e7a448f61f9ba43fa006fde3f Mon Sep 17 00:00:00 2001 From: Mysaa Java Date: Tue, 8 Sep 2026 02:28:36 +0200 Subject: [PATCH] Implemented config options --- flake.nix | 1 + .../com/bernard/nodecames/HttpController.java | 27 +-- .../nodecames/frontend/JsonDataService.java | 83 ++++++- .../frontend/WebSocketController.java | 7 + .../nodecames/game/DictionnariesService.java | 3 - .../bernard/nodecames/game/GameManager.java | 229 +++++++++++++++--- .../com/bernard/nodecames/model/Grid.java | 95 +++++--- .../bernard/nodecames/model/GridConfig.java | 108 ++++++++- .../com/bernard/nodecames/model/Hint.java | 4 +- src/main/resources/static/js/grid.js | 106 +++++++- src/main/resources/templates/grid.html | 19 +- 11 files changed, 543 insertions(+), 139 deletions(-) diff --git a/flake.nix b/flake.nix index d51564e..744494e 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,7 @@ }) ]; shellHook = '' + export LOGGING_LEVEL_ROOT="DEBUG" echo "Starting Gradle daemon ..." gradle echo "Gradle daemon started." diff --git a/src/main/java/com/bernard/nodecames/HttpController.java b/src/main/java/com/bernard/nodecames/HttpController.java index 2a42fde..d936bc5 100644 --- a/src/main/java/com/bernard/nodecames/HttpController.java +++ b/src/main/java/com/bernard/nodecames/HttpController.java @@ -1,18 +1,6 @@ package com.bernard.nodecames; -import static com.bernard.nodecames.model.GridConfig.CardColors.BB; -import static com.bernard.nodecames.model.GridConfig.CardColors.BG; -import static com.bernard.nodecames.model.GridConfig.CardColors.BW; -import static com.bernard.nodecames.model.GridConfig.CardColors.GB; -import static com.bernard.nodecames.model.GridConfig.CardColors.GG; -import static com.bernard.nodecames.model.GridConfig.CardColors.GW; -import static com.bernard.nodecames.model.GridConfig.CardColors.WB; -import static com.bernard.nodecames.model.GridConfig.CardColors.WG; -import static com.bernard.nodecames.model.GridConfig.CardColors.WW; - -import java.util.List; import java.util.Random; -import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Controller; @@ -22,7 +10,6 @@ import org.springframework.web.servlet.view.RedirectView; import com.bernard.nodecames.game.GameManager; import com.bernard.nodecames.model.GridConfig; -import com.bernard.nodecames.model.GridConfig.CardColors; import lombok.RequiredArgsConstructor; @@ -43,22 +30,12 @@ public class HttpController { return "grid"; } - public static final List CLASSICAL25 = List.of( - BB,BW,WB,BG,GB,GG,GG,GG,WG,WG,WG,WG,WG,GW,GW,GW,GW,GW,WW,WW,WW,WW,WW,WW,WW - ); - public static final Set BASE_HINTWORDCOUNT = Set.of( - 0,1,2,3,4,5,6,7,8,9,-1 - ); - public static final GridConfig CODENAMES_CLASSICAL = new GridConfig( - 9, true, true, CLASSICAL25, - 9, BASE_HINTWORDCOUNT, true, - Integer.MAX_VALUE, true, false, - true, false); + @GetMapping("/create-room") public RedirectView createRoom() { Random r = new Random(rand.nextLong()); - UUID uuid = gm.newGrid(CODENAMES_CLASSICAL, r); + UUID uuid = gm.newGrid(GridConfig.ZERO, r); return new RedirectView("/room/"+uuid.toString()+"/grid"); } } diff --git a/src/main/java/com/bernard/nodecames/frontend/JsonDataService.java b/src/main/java/com/bernard/nodecames/frontend/JsonDataService.java index 328d82d..f7b2a3c 100644 --- a/src/main/java/com/bernard/nodecames/frontend/JsonDataService.java +++ b/src/main/java/com/bernard/nodecames/frontend/JsonDataService.java @@ -1,5 +1,6 @@ package com.bernard.nodecames.frontend; +import java.util.Set; import java.util.stream.IntStream; import org.springframework.stereotype.Service; @@ -7,6 +8,9 @@ import org.springframework.stereotype.Service; import com.bernard.nodecames.model.Card; import com.bernard.nodecames.model.GameEvent; import com.bernard.nodecames.model.Grid; +import com.bernard.nodecames.model.GridConfig; +import com.bernard.nodecames.model.GridConfig.CardColors; +import com.bernard.nodecames.model.GridConfig.MutableOption; import com.bernard.nodecames.model.Grid.Phase; import com.bernard.nodecames.model.Hint; @@ -31,6 +35,73 @@ public class JsonDataService { return cardNode; } + public JsonNode gridConfig(GridConfig gc) { + ObjectNode gcNode = jsn.objectNode(); + gcNode.set("max-max-hint", jsn.numberNode(gc.getMaxMaxHint())); + gcNode.set("structured-grid", jsn.booleanNode(gc.isStructuredGrid())); + gcNode.set("anyone-starts", jsn.booleanNode(gc.isAnyoneStarts())); + ArrayNode wordDistributionNode = jsn.arrayNode(gc.getWordDistribution().size()); + for(CardColors c : gc.getWordDistribution()) { + ObjectNode ccNode = jsn.objectNode(); + ccNode.set("face-a", jsn.stringNode(c.getFaceA().name())); + ccNode.set("face-b", jsn.stringNode(c.getFaceB().name())); + wordDistributionNode.add(ccNode); + } + gcNode.set("word-distribution", wordDistributionNode); + gcNode.set("maybe-sudden-death", jsn.booleanNode(gc.isMaybeSuddenDeath())); + gcNode.set("maybe-one-more-guess", jsn.booleanNode(gc.isMaybeOneMoreGuess())); + gcNode.set("only-one-green-green-prevent-death", jsn.booleanNode(gc.isOnlyOneGreenGreenPreventDeath())); + + + + gcNode.set("max-hint", jsn.numberNode(gc.getMaxHint())); + ArrayNode availableHintWordCountNode = jsn.arrayNode(gc.getAvailableHintWordCount().size()); + gc.getAvailableHintWordCount().stream().sorted().forEach(i -> availableHintWordCountNode.add(jsn.numberNode(i))); + gcNode.set("available-hint-word-count", availableHintWordCountNode); + gcNode.set("one-more-guess", jsn.booleanNode(gc.isOneMoreGuess())); + gcNode.set("hint-max-length", jsn.numberNode(gc.getHintMaxLength())); + gcNode.set("end-guessing", jsn.booleanNode(gc.isEndGuessing())); + gcNode.set("only-one-green-green", jsn.booleanNode(gc.isOnlyOneGreenGreen())); + gcNode.set("sudden-death", jsn.booleanNode(gc.isSuddenDeath())); + gcNode.set("hints-in-a-row", jsn.booleanNode(gc.isHintsInARow())); + + return gcNode; + } + + + public Object gridConfigUpdateData(MutableOption cc, Object data) { + ObjectNode config = jsn.objectNode(); + switch(cc) { + case MAX_HINT: + config.set("max-hint", jsn.numberNode((Integer)data)); + break; + case AVAILABLE_HINT_WORD_COUNT: + ArrayNode availableHintWordCountNode = jsn.arrayNode(((Set) data).size()); + ((Set) data).stream().sorted().forEach(i -> availableHintWordCountNode.add(jsn.numberNode((Integer)i))); + config.set("available-hint-word-count", availableHintWordCountNode); + break; + case ONE_MORE_GUESS: + config.set("one-more-guess", jsn.booleanNode((Boolean)data)); + break; + case HINT_MAX_LENGTH: + config.set("hint-max-length", jsn.numberNode((Integer)data)); + break; + case END_GUESSING: + config.set("end-guessing", jsn.booleanNode((Boolean)data)); + break; + case ONLY_ONE_GREEN_GREEN: + config.set("only-one-green-green", jsn.booleanNode((Boolean)data)); + break; + case SUDDEN_DEATH: + config.set("sudden-death", jsn.booleanNode((Boolean)data)); + break; + case HINTS_IN_A_ROW: + config.set("hints-in-a-row", jsn.booleanNode((Boolean)data)); + break; + } + return config; + } + public JsonNode eventData(Grid g, GameEvent ge) { ObjectNode geNode = jsn.objectNode(); geNode.set("type", jsn.stringNode(ge.getType().name().toLowerCase())); @@ -64,12 +135,14 @@ public class JsonDataService { public JsonNode phaseData(Phase p) { ObjectNode out = jsn.objectNode(); - out.set("a-guessing", jsn.booleanNode(p.isAGuessing())); - out.set("b-guessing", jsn.booleanNode(p.isBGuessing())); - out.set("a-hinting", jsn.booleanNode(p.isAHinting())); - out.set("b-hinting", jsn.booleanNode(p.isBHinting())); + out.set("name", jsn.stringNode(p.name().toLowerCase())); + out.set("a-guessing", jsn.booleanNode(p.isGuessing('A'))); + out.set("b-guessing", jsn.booleanNode(p.isGuessing('B'))); + out.set("a-hinting", jsn.booleanNode(p.isHinting('A'))); + out.set("b-hinting", jsn.booleanNode(p.isHinting('B'))); out.set("sudden-death", jsn.booleanNode(p.isSuddenDeath())); out.set("game-ended", jsn.booleanNode(p.isGameEnded())); + out.set("locked", jsn.booleanNode(p.isLocked())); return out; } @@ -99,6 +172,8 @@ public class JsonDataService { out.set("used-hints", jsn.numberNode(g.getUsedHints())); out.set("used-whites", jsn.numberNode(g.getUsedWhites())); + out.set("config", gridConfig(g.getConfig())); + return out; } } diff --git a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java index 0746bc8..c51aceb 100644 --- a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java +++ b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java @@ -17,6 +17,7 @@ import com.bernard.nodecames.game.IllegalGameActionException; import com.bernard.nodecames.model.Card; import com.bernard.nodecames.model.GameEvent; import com.bernard.nodecames.model.Grid; +import com.bernard.nodecames.model.GridConfig.MutableOption; import tools.jackson.databind.JsonNode; @@ -47,6 +48,12 @@ public class WebSocketController { ); } + public void onConfigChange(Grid g, MutableOption cc, Object data) { + this.template.convertAndSend("/topic/config-change", + json.gridConfigUpdateData(cc, data) + ); + } + public void publishCardUpdateToPlayer(Grid g, int cardIndex, char player) { Card c = g.getCards()[cardIndex]; this.template.convertAndSend( diff --git a/src/main/java/com/bernard/nodecames/game/DictionnariesService.java b/src/main/java/com/bernard/nodecames/game/DictionnariesService.java index bd6147d..04286d6 100644 --- a/src/main/java/com/bernard/nodecames/game/DictionnariesService.java +++ b/src/main/java/com/bernard/nodecames/game/DictionnariesService.java @@ -9,13 +9,10 @@ import java.util.Random; import java.util.Scanner; import java.util.Set; -import javax.management.RuntimeErrorException; - import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; -import lombok.AllArgsConstructor; import lombok.Setter; @Service diff --git a/src/main/java/com/bernard/nodecames/game/GameManager.java b/src/main/java/com/bernard/nodecames/game/GameManager.java index b036618..bf9b6d1 100644 --- a/src/main/java/com/bernard/nodecames/game/GameManager.java +++ b/src/main/java/com/bernard/nodecames/game/GameManager.java @@ -7,8 +7,13 @@ 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.stream.Collectors; +import java.util.stream.IntStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import com.bernard.nodecames.frontend.WebSocketController; @@ -19,6 +24,7 @@ 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.CardColors; +import com.bernard.nodecames.model.GridConfig.MutableOption; import com.bernard.nodecames.model.Hint; import lombok.RequiredArgsConstructor; @@ -36,20 +42,29 @@ public class GameManager { private WebSocketController wsc = null; private final DictionnariesService dictionnaries; + + private static final Logger log = LoggerFactory.getLogger(GameManager.class); private void newGameEvent(Grid g, GameEvent ge) { g.newGameEvent(ge); + log.debug("New game event : {}", ge); wsc.onNewEvent(g, ge); } private void setPhase(Grid g, Phase phase) { g.setPhase(phase); + log.debug("New phase : {}", phase.name()); if(!phase.isAnyoneGuessing()) { g.setCurrentHint(null); } wsc.onNewPhase(g); } + private void changeConfig(Grid g, MutableOption cc, Object data) { + g.getConfig().set(cc, data); + log.debug("Changed config option: {} -> new value: {}", cc.name(), data); + wsc.onConfigChange(g, cc, data); + } private char other(char player) { return (player=='A')?'B':'A'; @@ -57,15 +72,23 @@ public class GameManager { /** @returns true iff all green cards of this player have been revealed either side + if onlyoneGreenGreen is false, checks only the side of this player */ - private boolean allGreenRevealed(Grid g, char player) { + private boolean allGreenRevealed(Grid g, char player, boolean onlyOneGreenGreen) { for(Card c : g.getCards()) { - if (c.getColor(player) == Color.GREEN && !c.isColorPublic(player) && - !(c.getColor(other(player)) == Color.GREEN && c.isColorPublic(other(player)))) + if ((c.getColor(player) == Color.GREEN && !c.isColorPublic(player)) && + (!onlyOneGreenGreen || (c.getColor(player) == Color.GREEN && !c.isColorPublic(player)))) return false; } return true; } + + /** + * Returns true iff the game would have been won if the rule onlyOneGreenGreen was true + */ + private boolean onlyOneGreenGreenWouldWin(Grid g) { + return !g.getConfig().isOnlyOneGreenGreen() && allGreenRevealed(g, 'A', true) && allGreenRevealed(g, 'B', true); + } public Grid findGrid(String gridId) { return games.get(UUID.fromString(gridId)); @@ -76,7 +99,17 @@ public class GameManager { randoms.put(uuid, r); String[] words = dictionnaries.randomWords(gc.getWordCount(), r); List cards = new ArrayList<>(gc.getWordCount()); - List distrib = gc.getWordDistribution(); + List origDistrib = gc.getWordDistribution(); + List distrib; + if(!gc.isStructuredGrid()) { + List bColors = origDistrib.stream().map(cc -> cc.getFaceB()).collect(Collectors.toList()); + Collections.shuffle(bColors, r); + distrib = IntStream.range(0, gc.getWordCount()) + .mapToObj(i -> CardColors.of(origDistrib.get(i).getFaceA(), bColors.get(i))) + .toList(); + } else { + distrib = origDistrib; + } for (int j = 0; j < distrib.size(); j++) { cards.add(new Card(words[j], distrib.get(j).getFaceA(), distrib.get(j).getFaceB())); } @@ -84,7 +117,7 @@ public class GameManager { Phase startPhase; if(gc.isAnyoneStarts()) { - startPhase = Phase.bothHinting(); + startPhase = Phase.HINTING_BOTH; } else { if(r.nextBoolean()) startPhase = Phase.hinting('A'); @@ -113,14 +146,20 @@ public class GameManager { **********************************/ private void endGame(Grid g, boolean win) { g.setWon(Optional.of(win)); - setPhase(g, Phase.gameEnded()); + setPhase(g, Phase.GAME_ENDED); newGameEvent(g, GameEvent.newGameEndEvent(win)); } public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException { - if (!g.getPhase().isHinting(player)) { + if (!g.getPhase().canPlay() && !g.getPhase().isHinting(player)) { throw new IllegalGameActionException(g, "This action is not allowed now"); } + if (!g.getConfig().getAvailableHintWordCount().contains(wordCount)) { + throw new IllegalGameActionException(g, "Unavailable word count"); + } + if (hint.length() > g.getConfig().getHintMaxLength()) { + throw new IllegalGameActionException(g, "Hint is too long"); + } Hint h = new Hint(hint, wordCount); g.incrementHintCount(); @@ -130,6 +169,36 @@ public class GameManager { setPhase(g, Phase.guessing(other(player))); } + /** + * Returns 'A', 'B', '_' depending on who should be the next ones to hint if we enter a hinting phase + */ + private char whoWouldHintNext(Grid g, char player) { + if(allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) { + // I can't hint anymore + return other(player); + } else { + if(g.getConfig().isHintsInARow() && !allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen())) { + return '_'; + } else { + return player; + } + } + } + + /** + * Enters the sudden death phase + */ + private void enterSuddenDeath(Grid g) { + newGameEvent(g, GameEvent.newSuddenDeath()); + if(allGreenRevealed(g, 'A', g.getConfig().isOnlyOneGreenGreen())) { + // If all 'A''s greens are revealed, A is the only one to guess + setPhase(g, Phase.suddenDeathOnly('A')); + } else { + // Else, we are both guessing + setPhase(g, Phase.SUDDEN_DEATH_BOTH); + } + } + /** Sets the phase of the grid when player ends its guessing round. @param g Must neither be ended neither be in a sudden death @@ -137,28 +206,30 @@ public class GameManager { private void endGuessingRound(Grid g, char player) { // We're done with this round of guesses if(g.getUsedHints() >= g.getConfig().getMaxHint()) { - // We're entering sudden death - newGameEvent(g, GameEvent.newSuddenDeath()); - if(allGreenRevealed(g, player)) { - // If all my greens are revealed, i'm the only one to guess - setPhase(g, Phase.suddenDeathOnly(player)); + if(g.getUsedHints() >= g.getConfig().getMaxMaxHint()) { + if(!g.getConfig().isSuddenDeath()) { + // We're entering sudden death + enterSuddenDeath(g); + } else { + // Only sudden death can get us out + setPhase(g, Phase.NEED_SUDDEN_DEATH); + } } else { - // Else, we are both guessing - setPhase(g, Phase.suddenDeathBoth()); + // We are waiting for more hints + if(g.getConfig().isSuddenDeath()) { + setPhase(g, Phase.needMoreHints(whoWouldHintNext(g, player))); + } else { + setPhase(g, Phase.needMoreHintsOrSuddenDeath(whoWouldHintNext(g, player))); + } } } else { // Next regular hinting round - if(allGreenRevealed(g, player)) { - // I can't hint anymore - setPhase(g, Phase.hinting(other(player))); - } else { - setPhase(g, Phase.hinting(player)); - } + setPhase(g, Phase.hinting(whoWouldHintNext(g, player))); } } public Card.Color pointCard(Grid g, char player, int cardIndex) throws IllegalGameActionException { - if (!g.getPhase().isGuessing(player)) { + if (!g.getPhase().canPlay() || !g.getPhase().isGuessing(player)) { throw new IllegalGameActionException(g, "This action is not allowed now"); } if (cardIndex<0 || cardIndex>g.getCardCount()) { @@ -173,13 +244,21 @@ public class GameManager { newGameEvent(g, GameEvent.newGuessEvent(c, player)); g.incrementCurrentGuessCount(); - if(guessedColor == Color.BLACK) - endGame(g, false); - else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE) - endGame(g, false); - else if (allGreenRevealed(g, other(player)) && guessedColor == Color.GREEN) { + if(guessedColor == Color.BLACK){ + if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) { + setPhase(g, Phase.WIN_IF_GREEN_GREEN); + } else { + endGame(g, false); + } + } else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE) { + if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) { + setPhase(g, Phase.WIN_IF_GREEN_GREEN); + } else { + endGame(g, false); + } + } else if (allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) { // i.e. i made my last guess - if (allGreenRevealed(g, player)) { + if (allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) { endGame(g, true); } else if (g.getPhase().isSuddenDeath()) { // Sudden death with only the other @@ -187,11 +266,16 @@ public class GameManager { } else { endGuessingRound(g, player); } - } else if (!allGreenRevealed(g, other(player)) && guessedColor == Color.GREEN) { + } else if (!allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) { // There is still cards i should find - if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount())) { - // We're done with this round of guesses - endGuessingRound(g, player); + if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), g.getConfig().isOneMoreGuess())) { + if(!g.getConfig().isOneMoreGuess() && g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), true)) { + // Having one more guess could make us continue + setPhase(g, Phase.waitingForOneMore(player)); + } else { + // We're done with this round of guesses anyway + endGuessingRound(g, player); + } } else { // I continue guessing cards in this round } @@ -200,18 +284,97 @@ public class GameManager { } else { throw new IllegalStateException("This should logically never happen"); } - if(!g.getPhase().isAnyoneGuessing()) + if(!g.getPhase().isAnyoneGuessing() && !g.getPhase().isWaitingForOneMore()) // It means this pick ended the guessing turn newGameEvent(g, GameEvent.newEndGuessingEvent(false, player)); return guessedColor; } public void endGuessing(Grid g, char player) throws IllegalGameActionException { - if (!g.getPhase().isGuessing(player)) { + if (!g.getPhase().canPlay() || (!g.getPhase().isGuessing(player) && !g.getPhase().isWaitingForOneMore())) { throw new IllegalGameActionException(g, "This action is not allowed now"); } + if (!g.getConfig().isEndGuessing() && !g.getPhase().isWaitingForOneMore()) { + throw new IllegalGameActionException(g, "Cannot end guessing in this game"); + } endGuessingRound(g, player); newGameEvent(g, GameEvent.newEndGuessingEvent(true, player)); } + + /** + * UPDATE GAME CONFIG + */ + public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException { + if(newHintCount > g.getConfig().getMaxMaxHint() || newHintCount <= g.getConfig().getMaxHint()) { + throw new IllegalGameActionException(g, "Illegal hint count"); + } + changeConfig(g, MutableOption.MAX_HINT, newHintCount); + if(g.getPhase().isNeedMoreHints() || g.getPhase().isNeedMoreHintsOrSuddenDeath()) { + setPhase(g, Phase.hinting(g.getPhase().getHinting())); + } + } + + public void unlockNewWordCounts(Grid g, Set newAvailableHintWordCounts) throws IllegalGameActionException { + if (!newAvailableHintWordCounts.containsAll(g.getConfig().getAvailableHintWordCount()) + || g.getConfig().getAvailableHintWordCount().containsAll(newAvailableHintWordCounts)) { + // i.e. If given data is not a strict superset + throw new IllegalGameActionException(g, "Can only add hint word options"); + } + changeConfig(g, MutableOption.AVAILABLE_HINT_WORD_COUNT, newAvailableHintWordCounts); + } + + public void unlockOneMoreGuess(Grid g) throws IllegalGameActionException { + if(g.getConfig().isOneMoreGuess()) + throw new IllegalGameActionException(g, "Cannot set value, already set"); + changeConfig(g, MutableOption.ONE_MORE_GUESS, true); + if(g.getPhase().isWaitingForOneMore()) { + setPhase(g, Phase.guessing(g.getPhase().getGuessing())); + } + } + + public void unlockHintMaxLength(Grid g, int newHintMaxLength) throws IllegalGameActionException { + if(g.getConfig().getHintMaxLength() >= newHintMaxLength) { + throw new IllegalGameActionException(g, "Can only raise max hint length"); + } + changeConfig(g, MutableOption.HINT_MAX_LENGTH, newHintMaxLength); + } + + public void unlockEndGuessing(Grid g) throws IllegalGameActionException { + if(g.getConfig().isEndGuessing()) + throw new IllegalGameActionException(g, "Cannot set value, already set"); + changeConfig(g, MutableOption.END_GUESSING, true); + } + + public void unlockOnlyOneGreenGreen(Grid g) throws IllegalGameActionException { + if(g.getConfig().isOnlyOneGreenGreen()) + throw new IllegalGameActionException(g, "Cannot set value, already set"); + changeConfig(g, MutableOption.ONLY_ONE_GREEN_GREEN, true); + if(g.getPhase() == Phase.WIN_IF_GREEN_GREEN) { + endGame(g, true); + } + } + + public void unlockSuddenDeath(Grid g) throws IllegalGameActionException { + if(g.getConfig().isSuddenDeath()) + throw new IllegalGameActionException(g, "Cannot set value, already set"); + changeConfig(g, MutableOption.SUDDEN_DEATH,true); + if (g.getPhase().isNeedMoreHintsOrSuddenDeath()) { + setPhase(g, Phase.needMoreHints(g.getPhase().getHinting())); + } else if (g.getPhase() == Phase.NEED_SUDDEN_DEATH) { + // We're entering sudden death + enterSuddenDeath(g); + } + } + + public void unlockHintsInARow(Grid g) throws IllegalGameActionException { + if(g.getConfig().isHintsInARow()) + throw new IllegalGameActionException(g, "Cannot set value, already set"); + changeConfig(g, MutableOption.HINTS_IN_A_ROW,true); + if ((g.getPhase() == Phase.HINTING_A || g.getPhase() == Phase.HINTING_B) + && !allGreenRevealed(g, 'A', g.getConfig().isOnlyOneGreenGreen()) + && !allGreenRevealed(g, 'B', g.getConfig().isOnlyOneGreenGreen())) { + setPhase(g, Phase.HINTING_BOTH); + } + } } diff --git a/src/main/java/com/bernard/nodecames/model/Grid.java b/src/main/java/com/bernard/nodecames/model/Grid.java index 9a04c67..ff1746b 100644 --- a/src/main/java/com/bernard/nodecames/model/Grid.java +++ b/src/main/java/com/bernard/nodecames/model/Grid.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Optional; import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -47,58 +46,78 @@ public class Grid { return this.getCards().length; } - @EqualsAndHashCode + @AllArgsConstructor @Getter - public static class Phase { - private boolean aHinting; - private boolean bHinting; - private boolean aGuessing; - private boolean bGuessing; + public enum Phase { + + HINTING_A('A', ' ', false, false, false), + HINTING_B('B', ' ', false, false, false), + GUESSING_A(' ', 'A', false, false, false), + GUESSING_B(' ', 'B', false, false, false), + HINTING_BOTH('_', ' ', false, false, false), + SUDDEN_DEATH_BOTH(' ', '_', true, false, false), + SUDDEN_DEATH_A(' ', 'A', true, false, false), + SUDDEN_DEATH_B(' ', 'B', true, false, false), + GAME_ENDED(' ', ' ', false, true, false), + // We could go to sudden death OR wait for more hints + NEED_MORE_HINTS_A('A', ' ', false, false, false), + NEED_MORE_HINTS_B('B', ' ', false, false, false), + // We can wait for SUDDEN_DEATH to be unlocked, or wait for more hints + NEED_MORE_HINTS_OR_SUDDEN_DEATH_A('A', ' ', false, false, true), + NEED_MORE_HINTS_OR_SUDDEN_DEATH_B('B', ' ', false, false, true), + // We can only wait for suddenDeath to be unlocked + NEED_SUDDEN_DEATH(' ', ' ', true, false, true), + // We can either end guessing, or wait for possibility to do one more guess + WAITING_FOR_ONE_MORE_A(' ', 'A', false, false, false), + WAITING_FOR_ONE_MORE_B(' ', 'B', false, false, false), + // We should have lost the game, we win it if we unlock onlyOneGreenGreen + WIN_IF_GREEN_GREEN(' ', ' ', false, false, true); + + // ' ' is none, 'A' is A, 'B' is B, '_' is both + private char hinting; + private char guessing; private boolean suddenDeath; - - boolean gameEnded; - - private Phase(boolean aHinting, - boolean bHinting, - boolean aGuessing, - boolean bGuessing, - boolean suddenDeath, - boolean gameEnded) { - this.aHinting = aHinting; - this.bHinting = bHinting; - this.aGuessing = aGuessing; - this.bGuessing = bGuessing; - this.suddenDeath = suddenDeath; - this.gameEnded = gameEnded; - } + private boolean gameEnded; + private boolean locked; public boolean isHinting(char player) { - return (player == 'A')?this.isAHinting():this.isBHinting(); + return (this == HINTING_BOTH) || (this == ((player == 'A')?HINTING_A:HINTING_B)); } public boolean isGuessing(char player) { - return (player == 'A')?this.isAGuessing():this.isBGuessing(); + return (this == ((player == 'A')?GUESSING_A:GUESSING_B)); } public boolean isAnyoneGuessing() { - return this.isAGuessing() || this.isBGuessing(); - } - - public static Phase bothHinting() { - return new Phase(true, true, false, false, false, false); + return isGuessing('A') || isGuessing('B'); } public static Phase hinting(char player) { - return new Phase((player == 'A'), (player == 'B'), false, false, false, false); + return (player == '_')?HINTING_BOTH:((player == 'A')?HINTING_A:HINTING_B); } public static Phase guessing(char player) { - return new Phase(false, false, (player == 'A'), (player == 'B'), false, false); - } - public static Phase suddenDeathBoth() { - return new Phase(false, false, true, true, true, false); + return (player == 'A')?GUESSING_A:GUESSING_B; } public static Phase suddenDeathOnly(char player) { - return new Phase(false, false, player == 'A', player == 'B', true, false); + return (player == 'A')?SUDDEN_DEATH_A:SUDDEN_DEATH_B; } - public static Phase gameEnded() { - return new Phase(false,false,false,false,false,true); + public static Phase needMoreHints(char player) { + return (player == 'A')?NEED_MORE_HINTS_A:NEED_MORE_HINTS_B; + } + public static Phase needMoreHintsOrSuddenDeath(char player) { + return (player == 'A')?NEED_MORE_HINTS_OR_SUDDEN_DEATH_A:NEED_MORE_HINTS_OR_SUDDEN_DEATH_B; + } + public static Phase waitingForOneMore(char player) { + return (player == 'A')?WAITING_FOR_ONE_MORE_A:WAITING_FOR_ONE_MORE_B; + } + public boolean isWaitingForOneMore() { + return this == WAITING_FOR_ONE_MORE_A || this == WAITING_FOR_ONE_MORE_B; + } + public boolean canPlay() { + return !this.isGameEnded() && !this.isLocked(); + } + public boolean isNeedMoreHints() { + return this == NEED_MORE_HINTS_A || this == NEED_MORE_HINTS_B; + } + public boolean isNeedMoreHintsOrSuddenDeath() { + return this == NEED_MORE_HINTS_OR_SUDDEN_DEATH_A || this == NEED_MORE_HINTS_OR_SUDDEN_DEATH_B; } } diff --git a/src/main/java/com/bernard/nodecames/model/GridConfig.java b/src/main/java/com/bernard/nodecames/model/GridConfig.java index cd6070c..68a6341 100644 --- a/src/main/java/com/bernard/nodecames/model/GridConfig.java +++ b/src/main/java/com/bernard/nodecames/model/GridConfig.java @@ -2,40 +2,111 @@ package com.bernard.nodecames.model; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import com.bernard.nodecames.model.Card.Color; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; import lombok.Value; +import lombok.With; + +import static com.bernard.nodecames.model.GridConfig.CardColors.BB; +import static com.bernard.nodecames.model.GridConfig.CardColors.BG; +import static com.bernard.nodecames.model.GridConfig.CardColors.BW; +import static com.bernard.nodecames.model.GridConfig.CardColors.GB; +import static com.bernard.nodecames.model.GridConfig.CardColors.GG; +import static com.bernard.nodecames.model.GridConfig.CardColors.GW; +import static com.bernard.nodecames.model.GridConfig.CardColors.WB; +import static com.bernard.nodecames.model.GridConfig.CardColors.WG; +import static com.bernard.nodecames.model.GridConfig.CardColors.WW; @Getter @AllArgsConstructor public class GridConfig { + public static final List CLASSICAL25 = List.of( + BB,BW,WB,BG,GB,GG,GG,GG,WG,WG,WG,WG,WG,GW,GW,GW,GW,GW,WW,WW,WW,WW,WW,WW,WW + ); + public static final Set BASE_HINTWORDCOUNT = Set.of( + 0,1,2,3,4,5,6,7,8,9,-1 + ); + public static final GridConfig CODENAMES_CLASSICAL = new GridConfig( + 9, true, true, CLASSICAL25, 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); + + // Immutable options // Maximum of maximom number of hints // This is to trigger end of game - private int maxMaxHint;//TODO - private boolean structuredGrid;//TODO + private int maxMaxHint; + private boolean structuredGrid; private boolean anyoneStarts; private List wordDistribution; + private boolean maybeSuddenDeath; //TODO + private boolean maybeOneMoreGuess; //TODO + // If onlyOneGreenGreen is false and having it true would have meant we won, + // prevend death and enter WIN_IF_GREEN_GREEN state, else just die + private boolean onlyOneGreenGreenPreventDeath; + // Mutable options - private int maxHint;//TODO - private Set availableHintWordCount;//TODO - private boolean oneMoreGuess;//TODO - - private int hintMaxLength;//TODO - private boolean endGuessing;//TODO - private boolean onlyOneGreenGreen;//TODO - private boolean suddenDeath;//TODO - private boolean hintsInARow;//TODO + private int maxHint; + private Set availableHintWordCount; + private boolean oneMoreGuess; + private int hintMaxLength; + private boolean endGuessing; + private boolean onlyOneGreenGreen; + private boolean suddenDeath; + private boolean hintsInARow; public int getWordCount() { return wordDistribution.size(); } + public void set(MutableOption opt, Object value) { + try{ + switch(opt) { + case MAX_HINT: + this.maxHint = (Integer) value; + return; + case AVAILABLE_HINT_WORD_COUNT: + // We check value is a set of integers + this.availableHintWordCount = ((Set)value).stream().map(i -> (Integer)i).collect(Collectors.toUnmodifiableSet()); + return; + case ONE_MORE_GUESS: + this.oneMoreGuess = (Boolean) value; + return; + case HINT_MAX_LENGTH: + this.hintMaxLength = (Integer) value; + return; + case END_GUESSING: + this.endGuessing = (Boolean) value; + return; + case ONLY_ONE_GREEN_GREEN: + this.onlyOneGreenGreen = (Boolean) value; + return; + case SUDDEN_DEATH: + this.suddenDeath = (Boolean) value; + return; + case HINTS_IN_A_ROW: + this.hintsInARow = (Boolean) value; + return; + } + } catch (ClassCastException e) { + throw new IllegalArgumentException("Given value does not have the right type: "+opt.getDataClass().toGenericString(),e); + } + } + @Value(staticConstructor = "of") public static final class CardColors { Color faceA; @@ -51,4 +122,19 @@ public class GridConfig { public static final CardColors BG = CardColors.of(Color.BLACK, Color.GREEN); public static final CardColors BB = CardColors.of(Color.BLACK, Color.BLACK); } + + @AllArgsConstructor + @Getter + public enum MutableOption { + MAX_HINT(Integer.class), + AVAILABLE_HINT_WORD_COUNT(Set.class), + ONE_MORE_GUESS(Boolean.class), + HINT_MAX_LENGTH(Integer.class), + END_GUESSING(Boolean.class), + ONLY_ONE_GREEN_GREEN(Boolean.class), + SUDDEN_DEATH(Boolean.class), + HINTS_IN_A_ROW(Boolean.class); + + Class dataClass; + } } diff --git a/src/main/java/com/bernard/nodecames/model/Hint.java b/src/main/java/com/bernard/nodecames/model/Hint.java index 14a1404..24f9dec 100644 --- a/src/main/java/com/bernard/nodecames/model/Hint.java +++ b/src/main/java/com/bernard/nodecames/model/Hint.java @@ -10,8 +10,8 @@ public class Hint { String word; int wordCount; - public boolean canStillGuess(int guessCount) { - return (wordCount == -1) || (guessCount < (wordCount + 1)); + public boolean canStillGuess(int guessCount, boolean oneMoreGuess) { + return (wordCount == -1) || (guessCount < (wordCount + (oneMoreGuess?1:0))); } } diff --git a/src/main/resources/static/js/grid.js b/src/main/resources/static/js/grid.js index 244c99d..65242d3 100644 --- a/src/main/resources/static/js/grid.js +++ b/src/main/resources/static/js/grid.js @@ -3,7 +3,14 @@ var player = '_' var roomId = '_' var wsHeaders = {} var cards = [] +var gc = {} +var currentHint = null +var currentHintCount = -1 +var currentGuessCount = -1 +var phase = {} +// This variable is here to check for updates to availableHintWordCount, as updating it is a bit expensive +var registeredAvailableHintWordCountInvalid = true function getRoomId() { const urlRegex = /\/room\/([a-f0-9-]{36})\/grid$/ const res = urlRegex.exec(window.location.href) @@ -14,39 +21,68 @@ function getRoomId() { /* * Update html to match state + * This is idempotent */ -function updatePhase(phase) { +function updatePhase() { + + + $('#hint-counter-num').text(currentHintCount) + $('#hint-counter-den').text(gc["max-hint"]) + + if(currentHint) { + $('#hint-text').text(currentHint['word']+" in "+currentHint['wordCount']) + $('#guess-counter-num').text(currentGuessCount) + if(gc['one-more-guess']) { + $('#guess-counter-den').text(currentHint['wordCount'] + "(+1)") + } else { + $('#guess-counter-den').text(currentHint['wordCount']) + } + } + if(registeredAvailableHintWordCountInvalid) { + $('#submit-hint-wordcount').empty() + for (let i = 0; i < gc['available-hint-word-count'].length; i++) { + const e = gc['available-hint-word-count'][i]; + $('#submit-hint-wordcount').append($(``)) + } + registeredAvailableHintWordCountInvalid = false + } + $('#submit-hint-text').attr('maxlength', gc['hint-max-length']) + p = player.toLowerCase() o = (player=='A')?'b':'a' if(phase[p+'-guessing']) { $('.card-button').css('visibility', 'visible') $('.card-button.untouchable').css('visibility', 'hidden') - $('#end-guessing-button').show() } else { $('.card-button').css('visibility', 'hidden') $('#end-guessing-button').hide() } + if((phase['name'] == 'waiting_for_one_more_'+p) || + (phase[p+'-guessing'] && + (gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['wordCount'])))) { + $('#end-guessing-button').show() + } else { + $('#end-guessing-button').hide() + } if(phase[p+'-guessing'] || phase[o+'-guessing']) { $('#hint-text').show() + $('#guess-counter').show() } else { $('#hint-text').hide() + $('#guess-counter').hide() } if(phase[p+'-hinting']) { $('#submit-hint').show() } else { $('#submit-hint').hide() } - if(!phase[p+'-guessing'] & !phase[p+'-hinting']) { + if(!phase[p+'-guessing'] && !phase[p+'-hinting']) { $('#other-playing').show() } else { $('#other-playing').hide() } } -function setHint(hint, wordCount) { - $('#hint-text').text(hint+" in "+wordCount) -} - function createCard(i) { html = `
  • @@ -146,19 +182,29 @@ function addEvent(e) { switch(e.type) { case "hint": addHintEvent(e.issuer, e.word, e.wordCount) - setHint(e.word, e.wordCount) + currentHint = {word: e.word, wordCount: e.wordCount} + currentHintCount += 1 + currentGuessCount = 0 + updatePhase() break case "guess": addPointEvent(e.issuer, e['card-index'], e.color) + currentGuessCount += 1 break case "end_guessing": addEndGuessingEvent(e.issuer, e.manual) + currentHint = null + currentGuessCount = 0 break case "game_end": addEndGameEvent(e.issuer, e.win) + currentHint = null + currentGuessCount = 0 break case "sudden_death": addSuddenDeathEvent() + currentHint = null + currentGuessCount = 0 break } } @@ -167,23 +213,29 @@ function addEvent(e) { /* * Listeners */ - function initGame(data) { - cards = data['cards'] + gc = data.config + cards = data.cards $('#cards-list').empty() for(var i = 0; i @@ -45,6 +35,15 @@
    +
    + You are player ? +
    +
    + Hint ?/? +
    +
    + Guess ?/? +
    Waiting for the other side to play