diff --git a/src/main/java/com/bernard/nodecames/HttpController.java b/src/main/java/com/bernard/nodecames/HttpController.java index 96e2f27..b593e9c 100644 --- a/src/main/java/com/bernard/nodecames/HttpController.java +++ b/src/main/java/com/bernard/nodecames/HttpController.java @@ -1,5 +1,7 @@ package com.bernard.nodecames; +import java.util.Comparator; +import java.util.List; import java.util.Map.Entry; import java.util.Random; import java.util.UUID; @@ -12,14 +14,18 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +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.GameManager; +import com.bernard.nodecames.game.GridManager; import com.bernard.nodecames.game.IllegalGameActionException; +import com.bernard.nodecames.game.GameManager; +import com.bernard.nodecames.model.Game; import com.bernard.nodecames.model.Grid; import com.bernard.nodecames.model.GridConfig; import com.bernard.nodecames.model.GridConfig.MutableOption; +import com.bernard.nodecames.model.World; import lombok.RequiredArgsConstructor; @@ -27,16 +33,26 @@ import lombok.RequiredArgsConstructor; @Controller public class HttpController { - private final GameManager gm; + private final GridManager gm; + private final GameManager wm; private final Random rand = new Random(); @GetMapping("/") - public String index() { + public String index(Model m) { + List games = wm.allGames().stream().sorted(Comparator.comparing(Game::getName)).toList(); + m.addAttribute("games", games); return "index.html"; } @GetMapping("/room/{id}/grid") - public String grid(@PathVariable("id") String id) { + public String grid(@PathVariable("id") String id, @RequestParam(value = "player", required = false) String player, Model m) { + if(player == null) { + m.addAttribute("redirect", "/room/"+id+"/grid"); + return "player-select"; + } + Grid grid = gm.findGrid(id); + m.addAttribute("player", player); + m.addAttribute("grid", grid); return "grid"; } @@ -69,7 +85,45 @@ public class HttpController { @GetMapping("/create-room") public RedirectView createRoom() { Random r = new Random(rand.nextLong()); - UUID uuid = gm.newGrid(GridConfig.ZERO, r); - return new RedirectView("/room/"+uuid.toString()+"/grid"); + Grid g = gm.newGrid(GridConfig.ZERO, r); + return new RedirectView("/room/"+g.getId()+"/grid"); + } + @GetMapping("/create-game") + public RedirectView createGame(@RequestParam("name") String name) { + UUID uuid = wm.createGame(name); + return new RedirectView("/game/"+uuid.toString()+"/map"); + } + + @GetMapping("/game/{id}/map") + public String getMap(@PathVariable("id") String id, @RequestParam(value = "player", required = false) String player, Model m) { + if(player == null) { + m.addAttribute("redirect", "/game/"+id+"/map"); + return "player-select"; + } + Game g = wm.getGame(id); + List worlds = g.getWorlds() + .entrySet() + .stream() + .map(Entry::getValue) + .sorted(Comparator.comparing(World::getName)) + .toList(); + m.addAttribute("game", g); + m.addAttribute("worlds", worlds); + m.addAttribute("player", player); + return "map"; + } + + @GetMapping("/game/{id}/grid/{world}") + public Object gridRedirect(@PathVariable("id") String id, @PathVariable("world") String world, @RequestParam("player") String player) { + Game g = wm.getGame(id); + Grid grid = wm.getGrid(g, world); + return new RedirectView("/room/"+grid.getId()+"/grid" + "?player="+player); + } + + @GetMapping("/game/{id}/reset/{world}") + public Object resetWorld(@PathVariable("id") String id, @PathVariable("world") String world, @RequestParam("player") String player) { + Game g = wm.getGame(id); + Grid newGrid = wm.resetGrid(g, world); + return new RedirectView("/room/"+newGrid.getId()+"/grid" + "?player="+player); } } diff --git a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java index 96ffef7..3f15fa0 100644 --- a/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java +++ b/src/main/java/com/bernard/nodecames/frontend/WebSocketController.java @@ -12,7 +12,7 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import com.bernard.nodecames.game.GameManager; +import com.bernard.nodecames.game.GridManager; import com.bernard.nodecames.game.IllegalGameActionException; import com.bernard.nodecames.model.Card; import com.bernard.nodecames.model.GameEvent; @@ -24,11 +24,11 @@ import tools.jackson.databind.JsonNode; @Controller public class WebSocketController { - private GameManager gm; + private GridManager gm; private SimpMessagingTemplate template; private JsonDataService json; - public WebSocketController(GameManager gm, SimpMessagingTemplate template, JsonDataService json) { + public WebSocketController(GridManager gm, SimpMessagingTemplate template, JsonDataService json) { this.gm = gm; this.template = template; this.json = json; diff --git a/src/main/java/com/bernard/nodecames/game/GameManager.java b/src/main/java/com/bernard/nodecames/game/GameManager.java index b43a8f0..aa3c8ad 100644 --- a/src/main/java/com/bernard/nodecames/game/GameManager.java +++ b/src/main/java/com/bernard/nodecames/game/GameManager.java @@ -1,420 +1,92 @@ package com.bernard.nodecames.game; -import java.util.ArrayList; -import java.util.Collections; +import java.util.Collection; 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.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; -import com.bernard.nodecames.model.Card; -import com.bernard.nodecames.model.Card.Color; -import com.bernard.nodecames.model.GameEvent; +import com.bernard.nodecames.model.Game; 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; -import lombok.Setter; +import com.bernard.nodecames.model.World; @Service -@RequiredArgsConstructor public class GameManager { - - //TODO Remove static, make something more Spring Boot-y - private static Map games = new HashMap<>(); - private static Map randoms = new HashMap<>(); - - @Setter - private WebSocketController wsc = null; - - private final DictionnariesService dictionnaries; - private static final Logger log = LoggerFactory.getLogger(GameManager.class); + Map games = new HashMap<>(); + Map randoms = new HashMap<>(); - private void newGameEvent(Grid g, GameEvent ge) { - g.newGameEvent(ge); - log.debug("New game event : {}", ge); - wsc.onNewEvent(g, ge); + // Reverse map allowing to pass grid events to the game + Map currentGrids = new HashMap<>(); + + private final GridManager gm; + + public GameManager(GridManager gm) { + this.gm = gm; + gm.setWm(this); } - 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'; - } - - /** - @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, boolean onlyOneGreenGreen) { - for(Card c : g.getCards()) { - 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)); - } - - public UUID newGrid(GridConfig gc, Random r) { + public UUID createGame(String name) { UUID uuid = UUID.randomUUID(); - randoms.put(uuid, r); - String[] words = dictionnaries.randomWords(gc.getWordCount(), r); - List cards = new ArrayList<>(gc.getWordCount()); - 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())); - } - Collections.shuffle(cards, r); + randoms.put(uuid, new Random()); - Phase startPhase; - if(gc.isAnyoneStarts()) { - startPhase = Phase.HINTING_BOTH; - } else { - if(r.nextBoolean()) - startPhase = Phase.hinting('A'); - else - startPhase = Phase.hinting('B'); - } + Game g = new Game(uuid, name, null); + Map worlds = Game.DEFAULT_WORLDS_CONFIG + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> new World(g, e.getKey(), e.getValue(), null, false) + )); + g.setWorlds(worlds); - Grid g = new Grid(uuid.toString(), - (Card[])cards.toArray(new Card[cards.size()]), - new ArrayList<>(), - null, - 0, - 0, - 0, - startPhase, - Optional.empty(), - gc); games.put(uuid, g); return uuid; } - /********************************** - * GAME ACTIONS * - **********************************/ - private void endGame(Grid g, boolean win) { - g.setWon(Optional.of(win)); - setPhase(g, Phase.GAME_ENDED); - newGameEvent(g, GameEvent.newGameEndEvent(win)); + public Collection allGames() { + return games.values(); } - public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException { - 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"); - } + private Grid newGrid(Game g, String world) { - Hint h = new Hint(hint, wordCount); - g.incrementHintCount(); - g.setCurrentHint(h); - g.setCurrentGuessCount(0); - newGameEvent(g, GameEvent.newHintEvent(h, player)); - setPhase(g, Phase.guessing(other(player))); - } + World w = g.getWorld(world); + GridConfig gc = w.getInitialConfig(); + Grid grid = gm.newGrid(gc, new Random(randoms.get(g.getId()).nextLong())); - /** - * 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 enterSuddenDeathInternal(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 - */ - private void endGuessingRound(Grid g, char player) { - // We're done with this round of guesses - if(g.getUsedHints() >= g.getConfig().getMaxHint()) { - if(g.getUsedHints() >= g.getConfig().getMaxMaxHint()) { - if(!g.getConfig().isSuddenDeath()) { - // We're entering sudden death - enterSuddenDeathInternal(g); - } else { - // Only sudden death can get us out - setPhase(g, Phase.NEED_SUDDEN_DEATH); - } - } else { - // 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 - setPhase(g, Phase.hinting(whoWouldHintNext(g, player))); - } - } - - public Card.Color pointCard(Grid g, char player, int cardIndex) throws IllegalGameActionException { - if (!g.getPhase().canPlay() || !g.getPhase().isGuessing(player)) { - throw new IllegalGameActionException(g, "This action is not allowed now"); - } - if (cardIndex<0 || cardIndex>g.getCardCount()) { - throw new IllegalArgumentException("The given card index is invalid"); - } - Card c = g.getCards()[cardIndex]; + w.setCurrentGrid(grid); + currentGrids.put(grid.getId(), w); - if(c.isColorPublic(other(player)) || (c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player))) - throw new IllegalGameActionException(g, "Card has already been revealed"); - c.setColorPublic(other(player), true); - Card.Color guessedColor = c.getColor(other(player)); + return grid; + } - newGameEvent(g, GameEvent.newGuessEvent(c, player)); - g.incrementCurrentGuessCount(); - 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, g.getConfig().isOnlyOneGreenGreen())) { - endGame(g, true); - } else if (g.getPhase().isSuddenDeath()) { - // Sudden death with only the other - setPhase(g, Phase.suddenDeathOnly(other(player))); - } else { - endGuessingRound(g, player); - } - } 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(), 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 - } - } else if (guessedColor == Color.WHITE && !g.getPhase().isSuddenDeath()){ - endGuessingRound(g, player); + public Grid resetGrid(Game g, String world) { + return newGrid(g, world); + } + + public Grid getGrid(Game g, String world) { + World w = g.getWorld(world); + if(w.getCurrentGrid() != null) { + return w.getCurrentGrid(); } else { - throw new IllegalStateException("This should logically never happen"); - } - 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().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)); - } - - public void enterSuddenDeath(Grid g) throws IllegalGameActionException { - if(!g.getPhase().isNeedMoreHints()) { - throw new IllegalGameActionException(g, "You can only enter sudden death when being out of hints"); - } - enterSuddenDeathInternal(g); - } - - - /** - * UPDATE GAME CONFIG - */ - public void unlock(Grid g, MutableOption opt, Object o) throws IllegalGameActionException { - switch(opt) { - case MutableOption.MAX_HINT: - unlockHintCount(g, (Integer)o); - break; - case MutableOption.AVAILABLE_HINT_WORD_COUNT: - unlockNewWordCounts(g, (Set)o); - break; - case MutableOption.ONE_MORE_GUESS: - unlockOneMoreGuess(g); - break; - case MutableOption.HINT_MAX_LENGTH: - unlockHintMaxLength(g, (Integer)o); - break; - case MutableOption.END_GUESSING: - unlockEndGuessing(g); - break; - case MutableOption.ONLY_ONE_GREEN_GREEN: - unlockOnlyOneGreenGreen(g); - break; - case MutableOption.SUDDEN_DEATH: - unlockSuddenDeath(g); - break; - case MutableOption.HINTS_IN_A_ROW: - unlockHintsInARow(g); - break; - default: - throw new IllegalStateException("Unknown MutableOption to edit"); - } - } - public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException { - if(newHintCount > g.getConfig().getMaxMaxHint()) { - throw new IllegalGameActionException(g, "Illegal hint count"); - } - if(newHintCount <= g.getConfig().getMaxHint()) { - throw new IllegalGameActionException(g, "Cannot lower hint count"); - } - changeConfig(g, MutableOption.MAX_HINT, newHintCount); - if(g.getPhase().isNeedMoreHints() || g.getPhase().isNeedMoreHintsOrSuddenDeath()) { - setPhase(g, Phase.hinting(g.getPhase().getHinting())); + return newGrid(g, world); } } - 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 Game getGame(String id) { + return games.get(UUID.fromString(id)); } - public void unlockOneMoreGuess(Grid g) throws IllegalGameActionException { - if(g.getConfig().isOneMoreGuess()) - throw new IllegalGameActionException(g, "Cannot unlock oneMoreGuess as it is already unlocked"); - 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 unlock endGuessing as it is already unlocked"); - changeConfig(g, MutableOption.END_GUESSING, true); - } - - public void unlockOnlyOneGreenGreen(Grid g) throws IllegalGameActionException { - if(g.getConfig().isOnlyOneGreenGreen()) - throw new IllegalGameActionException(g, "Cannot unlock onlyOneGreenGreen as it is already unlocked"); - 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 unlock suddenDeath as it is already unlocked"); - 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 - enterSuddenDeathInternal(g); - } - } - - public void unlockHintsInARow(Grid g) throws IllegalGameActionException { - if(g.getConfig().isHintsInARow()) - throw new IllegalGameActionException(g, "Cannot unlock hintsInARow as it is already unlocked"); - 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); + public void onGameWon(Grid g) { + World w = currentGrids.get(g.getId()); + if(w != null) { + w.setWon(true); } } } diff --git a/src/main/java/com/bernard/nodecames/game/GridManager.java b/src/main/java/com/bernard/nodecames/game/GridManager.java new file mode 100644 index 0000000..7e2d04d --- /dev/null +++ b/src/main/java/com/bernard/nodecames/game/GridManager.java @@ -0,0 +1,425 @@ +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.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; +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; +import com.bernard.nodecames.model.GridConfig.CardColors; +import com.bernard.nodecames.model.GridConfig.MutableOption; +import com.bernard.nodecames.model.Hint; + +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Service +@RequiredArgsConstructor +public class GridManager { + + //TODO Remove static, make something more Spring Boot-y + private static Map games = new HashMap<>(); + private static Map randoms = new HashMap<>(); + + @Setter + private WebSocketController wsc = null; + @Setter + private GameManager wm = null; + + private final DictionnariesService dictionnaries; + + private static final Logger log = LoggerFactory.getLogger(GridManager.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'; + } + + /** + @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, boolean onlyOneGreenGreen) { + for(Card c : g.getCards()) { + 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)); + } + + public Grid newGrid(GridConfig gc, Random r) { + UUID uuid = UUID.randomUUID(); + randoms.put(uuid, r); + String[] words = dictionnaries.randomWords(gc.getWordCount(), r); + List cards = new ArrayList<>(gc.getWordCount()); + 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())); + } + Collections.shuffle(cards, r); + + Phase startPhase; + if(gc.isAnyoneStarts()) { + startPhase = Phase.HINTING_BOTH; + } else { + if(r.nextBoolean()) + startPhase = Phase.hinting('A'); + else + startPhase = Phase.hinting('B'); + } + + Grid g = new Grid(uuid, + (Card[])cards.toArray(new Card[cards.size()]), + new ArrayList<>(), + null, + 0, + 0, + 0, + startPhase, + Optional.empty(), + gc); + + games.put(uuid, g); + + return g; + } + + /********************************** + * GAME ACTIONS * + **********************************/ + private void endGame(Grid g, boolean win) { + g.setWon(Optional.of(win)); + setPhase(g, Phase.GAME_ENDED); + newGameEvent(g, GameEvent.newGameEndEvent(win)); + if(win) { + wm.onGameWon(g); + } + } + + public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException { + 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(); + g.setCurrentHint(h); + g.setCurrentGuessCount(0); + newGameEvent(g, GameEvent.newHintEvent(h, player)); + 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 enterSuddenDeathInternal(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 + */ + private void endGuessingRound(Grid g, char player) { + // We're done with this round of guesses + if(g.getUsedHints() >= g.getConfig().getMaxHint()) { + if(g.getUsedHints() >= g.getConfig().getMaxMaxHint()) { + if(!g.getConfig().isSuddenDeath()) { + // We're entering sudden death + enterSuddenDeathInternal(g); + } else { + // Only sudden death can get us out + setPhase(g, Phase.NEED_SUDDEN_DEATH); + } + } else { + // 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 + setPhase(g, Phase.hinting(whoWouldHintNext(g, player))); + } + } + + public Card.Color pointCard(Grid g, char player, int cardIndex) throws IllegalGameActionException { + if (!g.getPhase().canPlay() || !g.getPhase().isGuessing(player)) { + throw new IllegalGameActionException(g, "This action is not allowed now"); + } + if (cardIndex<0 || cardIndex>g.getCardCount()) { + throw new IllegalArgumentException("The given card index is invalid"); + } + Card c = g.getCards()[cardIndex]; + + 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"); + c.setColorPublic(other(player), true); + Card.Color guessedColor = c.getColor(other(player)); + + newGameEvent(g, GameEvent.newGuessEvent(c, player)); + g.incrementCurrentGuessCount(); + 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, g.getConfig().isOnlyOneGreenGreen())) { + endGame(g, true); + } else if (g.getPhase().isSuddenDeath()) { + // Sudden death with only the other + setPhase(g, Phase.suddenDeathOnly(other(player))); + } else { + endGuessingRound(g, player); + } + } 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(), 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 + } + } else if (guessedColor == Color.WHITE && !g.getPhase().isSuddenDeath()){ + endGuessingRound(g, player); + } else { + throw new IllegalStateException("This should logically never happen"); + } + 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().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)); + } + + public void enterSuddenDeath(Grid g) throws IllegalGameActionException { + if(!g.getPhase().isNeedMoreHints()) { + throw new IllegalGameActionException(g, "You can only enter sudden death when being out of hints"); + } + enterSuddenDeathInternal(g); + } + + + /** + * UPDATE GAME CONFIG + */ + public void unlock(Grid g, MutableOption opt, Object o) throws IllegalGameActionException { + switch(opt) { + case MutableOption.MAX_HINT: + unlockHintCount(g, (Integer)o); + break; + case MutableOption.AVAILABLE_HINT_WORD_COUNT: + unlockNewWordCounts(g, (Set)o); + break; + case MutableOption.ONE_MORE_GUESS: + unlockOneMoreGuess(g); + break; + case MutableOption.HINT_MAX_LENGTH: + unlockHintMaxLength(g, (Integer)o); + break; + case MutableOption.END_GUESSING: + unlockEndGuessing(g); + break; + case MutableOption.ONLY_ONE_GREEN_GREEN: + unlockOnlyOneGreenGreen(g); + break; + case MutableOption.SUDDEN_DEATH: + unlockSuddenDeath(g); + break; + case MutableOption.HINTS_IN_A_ROW: + unlockHintsInARow(g); + break; + default: + throw new IllegalStateException("Unknown MutableOption to edit"); + } + } + public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException { + if(newHintCount > g.getConfig().getMaxMaxHint()) { + throw new IllegalGameActionException(g, "Illegal hint count"); + } + if(newHintCount <= g.getConfig().getMaxHint()) { + throw new IllegalGameActionException(g, "Cannot lower 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 unlock oneMoreGuess as it is already unlocked"); + 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 unlock endGuessing as it is already unlocked"); + changeConfig(g, MutableOption.END_GUESSING, true); + } + + public void unlockOnlyOneGreenGreen(Grid g) throws IllegalGameActionException { + if(g.getConfig().isOnlyOneGreenGreen()) + throw new IllegalGameActionException(g, "Cannot unlock onlyOneGreenGreen as it is already unlocked"); + 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 unlock suddenDeath as it is already unlocked"); + 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 + enterSuddenDeathInternal(g); + } + } + + public void unlockHintsInARow(Grid g) throws IllegalGameActionException { + if(g.getConfig().isHintsInARow()) + throw new IllegalGameActionException(g, "Cannot unlock hintsInARow as it is already unlocked"); + 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/Game.java b/src/main/java/com/bernard/nodecames/model/Game.java index 86e8158..0cf3aa1 100644 --- a/src/main/java/com/bernard/nodecames/model/Game.java +++ b/src/main/java/com/bernard/nodecames/model/Game.java @@ -1,6 +1,8 @@ package com.bernard.nodecames.model; -import jakarta.persistence.Entity; +import java.util.Map; +import java.util.UUID; + import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import lombok.AllArgsConstructor; @@ -8,7 +10,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -@Entity @NoArgsConstructor @AllArgsConstructor @Getter @@ -17,7 +18,28 @@ public class Game { @Id @GeneratedValue - private Long id; + private UUID id; String name; + + Map worlds; + + public static final Map DEFAULT_WORLDS_CONFIG = Map.ofEntries( + Map.entry("1-1", GridConfig.zeroWithSize(GridConfig.CUSTOM6)), + Map.entry("1-2", GridConfig.zeroWithSize(GridConfig.CUSTOM9)), + Map.entry("1-3", GridConfig.zeroWithSize(GridConfig.CUSTOM9)), + Map.entry("2-1", GridConfig.zeroWithSize(GridConfig.CUSTOM12)), + Map.entry("2-2", GridConfig.zeroWithSize(GridConfig.CUSTOM16)), + Map.entry("2-3", GridConfig.zeroWithSize(GridConfig.CUSTOM16)), + Map.entry("3-1", GridConfig.zeroWithSize(GridConfig.CUSTOM20)), + Map.entry("3-2", GridConfig.zeroWithSize(GridConfig.CLASSICAL25)), + Map.entry("3-3", GridConfig.zeroWithSize(GridConfig.CLASSICAL25)), + Map.entry("4-1", GridConfig.zeroWithSize(GridConfig.CUSTOM30)), + Map.entry("4-2", GridConfig.zeroWithSize(GridConfig.CUSTOM36)), + Map.entry("4-3", GridConfig.zeroWithSize(GridConfig.CUSTOM36)) + ); + + public World getWorld(String world) { + return this.getWorlds().get(world); + } } diff --git a/src/main/java/com/bernard/nodecames/model/Grid.java b/src/main/java/com/bernard/nodecames/model/Grid.java index b39969a..5117ff1 100644 --- a/src/main/java/com/bernard/nodecames/model/Grid.java +++ b/src/main/java/com/bernard/nodecames/model/Grid.java @@ -2,7 +2,9 @@ package com.bernard.nodecames.model; import java.util.List; import java.util.Optional; +import java.util.UUID; +import jakarta.persistence.Id; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -14,7 +16,8 @@ import lombok.Setter; @Setter public class Grid { - String id; + @Id + UUID id; Card[] cards; diff --git a/src/main/java/com/bernard/nodecames/model/GridConfig.java b/src/main/java/com/bernard/nodecames/model/GridConfig.java index dbf946b..b566032 100644 --- a/src/main/java/com/bernard/nodecames/model/GridConfig.java +++ b/src/main/java/com/bernard/nodecames/model/GridConfig.java @@ -1,15 +1,9 @@ package com.bernard.nodecames.model; -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 static com.bernard.nodecames.model.GridConfig.CardColors.makeDistribution; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -24,10 +18,24 @@ import lombok.Value; @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 List CUSTOM6 = + makeDistribution(0, 0, 2, 1, 1, 0); + public static final List CUSTOM9 = + makeDistribution(0, 1, 1, 2, 2, 1); + public static final List CUSTOM12 = + makeDistribution(1, 1, 1, 2, 3, 1); + public static final List CUSTOM16 = + makeDistribution(1, 1, 2, 2, 5, 1); + public static final List CUSTOM20 = + makeDistribution(1, 1, 4, 2, 5, 1); + public static final List CLASSICAL25 = + makeDistribution(1, 1, 5, 3, 7, 1); + public static final List CUSTOM30 = + makeDistribution(2, 2, 5, 3, 7, 1); + public static final List CUSTOM36 = + makeDistribution(2, 2, 6, 5, 9, 2); + public static final Set BASE_HINTWORDCOUNT = Set.of( 0,1,2,3,4,5,6,7,8,9,-1 ); @@ -43,6 +51,13 @@ public class GridConfig { 3, false, false, false, false); + public static final GridConfig zeroWithSize(List colors) { + return new GridConfig( + 9, false, false, colors, true, + true, false, 1, Set.of(1), false, + 3, false, false, + false, false); + } // Immutable options // Maximum of maximom number of hints @@ -137,6 +152,22 @@ public class GridConfig { public static final CardColors GB = CardColors.of(Color.GREEN, Color.BLACK); public static final CardColors BG = CardColors.of(Color.BLACK, Color.GREEN); public static final CardColors BB = CardColors.of(Color.BLACK, Color.BLACK); + + + public static final List makeDistribution( + int wbbwCount, int bggbCount, int wggwCount, + int ggCount, int wwCount, int bbCount + ) { + int total = (wbbwCount + bggbCount + wggwCount)*2 + ggCount + wwCount + bbCount; + List out = new ArrayList<>(total); + for(int i=0;i -
- Which player are you ?
- - -
    @@ -56,7 +51,12 @@
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 15c4dee..ef5ac2f 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -9,6 +9,25 @@ Vous voulez jouer à Node Cames ? Ben c'est pas encore prêt :/
(Bon, en vrai, vous pouvez créer une room en cliquant ici) + +
+ +
+ + + +
+ + +
+ Currently running games: +
    +
  • + +
  • + +
+
\ No newline at end of file diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html new file mode 100644 index 0000000..0467ca7 --- /dev/null +++ b/src/main/resources/templates/map.html @@ -0,0 +1,22 @@ + + + +
+ + + + + + + +
+ +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/player-select.html b/src/main/resources/templates/player-select.html new file mode 100644 index 0000000..2033ce4 --- /dev/null +++ b/src/main/resources/templates/player-select.html @@ -0,0 +1,18 @@ + + + +
+ + + + + + + +
+ Which player are you ?
+ Player A + Player B +
+ + \ No newline at end of file