Compare commits

...
2 Commits
Author SHA1 Message Date
Mysaa Java 217c98a94a Moved to Hibernate 2026-09-17 14:27:28 +02:00
Mysaa Java 6127508211 Added games 2026-09-12 13:34:24 +02:00
24 changed files with 1191 additions and 554 deletions
@@ -1,9 +1,14 @@
package com.bernard.nodecames;
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 org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@@ -12,14 +17,21 @@ 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.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 com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.GridRepository;
import com.bernard.nodecames.repository.WorldRepository;
import lombok.RequiredArgsConstructor;
@@ -27,35 +39,58 @@ import lombok.RequiredArgsConstructor;
@Controller
public class HttpController {
private final GameManager gm;
private final GridManager gm;
private final GameManager wm;
private final GridRepository gridRepository;
private final GameRepository gRepository;
private final WorldRepository wRepository;
private final Random rand = new Random();
@GetMapping("/")
public String index() {
public String index(Model m) {
List<Game> 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 Object 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";
}
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
m.addAttribute("player", player);
m.addAttribute("grid", grid.get());
return "grid";
}
@GetMapping("/room/{id}/config")
public String gridConfig(@PathVariable("id") String id, Model model) {
GridConfig gc = gm.findGrid(id).getConfig();
public Object gridConfig(@PathVariable("id") String id, Model model) {
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
GridConfig gc = grid.get().getConfig();
model.addAttribute("gridConfig", GridConfigEdit.of(gc));
return "grid-config";
}
@PostMapping("/room/{id}/config")
public String setGridConfig(@PathVariable("id") String id,
public Object setGridConfig(@PathVariable("id") String id,
@ModelAttribute("gridConfig") GridConfigEdit gce,
BindingResult br,
Model model
) {
Grid g = gm.findGrid(id);
for(Entry<MutableOption, Object> e : gce.getModifications(g.getConfig()).entrySet()) {
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
for(Entry<MutableOption, Object> e : gce.getModifications(grid.get().getConfig()).entrySet()) {
try {
gm.unlock(g, e.getKey(), e.getValue());
gm.unlock(grid.get(), e.getKey(), e.getValue());
} catch (IllegalGameActionException ige) {
br.addError(new ObjectError(e.getKey().name(), ige.getMessage()));
}
@@ -64,12 +99,64 @@ public class HttpController {
return "grid-config";
}
@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(null, GridConfig.ZERO, r);
return new RedirectView("/room/"+g.getId()+"/grid");
}
@GetMapping("/create-game")
public RedirectView createGame(@RequestParam("name") String name) {
Game g = wm.createGame(name);
return new RedirectView("/game/"+g.getId().toString()+"/map");
}
@GetMapping("/game/{id}/map")
public Object 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";
}
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
List<World> worlds = wm.allWorlds(g.get())
.stream()
.sorted(Comparator.comparing(World::getName))
.toList();
m.addAttribute("game", g.get());
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) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Optional<World> w = wRepository.findByGameAndId_Name(g.get(), world);
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Grid grid = wm.getGrid(g.get(), w.get());
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) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Optional<World> w = wRepository.findByGameAndId_Name(g.get(), world);
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);
}
}
@@ -2,6 +2,9 @@ package com.bernard.nodecames;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.node.JsonNodeFactory;
@SpringBootApplication
public class NodecamesApplication {
@@ -10,4 +13,9 @@ public class NodecamesApplication {
SpringApplication.run(NodecamesApplication.class, args);
}
@Bean
public JsonNodeFactory theJsonNodeFactory() {
return JsonNodeFactory.instance;
}
}
@@ -1,28 +1,31 @@
package com.bernard.nodecames.frontend;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.IntStream;
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.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.Grid.Phase;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.repository.CardRepository;
import lombok.RequiredArgsConstructor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@Service
@RequiredArgsConstructor
public class JsonDataService {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
private final JsonNodeFactory jsn;
private final CardRepository cRepository;
public JsonNode cardData(Card c, char player) {
ObjectNode cardNode = jsn.objectNode();
@@ -41,10 +44,11 @@ public class JsonDataService {
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()) {
for(Entry<CardColors, Integer> e : gc.getWordDistribution().entrySet()) {
ObjectNode ccNode = jsn.objectNode();
ccNode.set("face-a", jsn.stringNode(c.getFaceA().name()));
ccNode.set("face-b", jsn.stringNode(c.getFaceB().name()));
ccNode.set("face-a", jsn.stringNode(e.getKey().getFaceA().name()));
ccNode.set("face-b", jsn.stringNode(e.getKey().getFaceB().name()));
ccNode.set("count", jsn.numberNode(e.getValue()));
wordDistributionNode.add(ccNode);
}
gcNode.set("word-distribution", wordDistributionNode);
@@ -102,35 +106,8 @@ public class JsonDataService {
return config;
}
public JsonNode eventData(Grid g, GameEvent ge) {
ObjectNode geNode = jsn.objectNode();
geNode.set("type", jsn.stringNode(ge.getType().name().toLowerCase()));
geNode.set("issuer", jsn.stringNode(Character.toString(ge.getIssuer())));
switch (ge.getType()) {
case GameEvent.Type.HINT:
Hint hint = (Hint)ge.getData();
geNode.set("word", jsn.stringNode(hint.getWord()));
geNode.set("wordCount", jsn.numberNode(hint.getWordCount()));
break;
case GameEvent.Type.GUESS:
Card c = (Card)ge.getData();
Integer pos = IntStream.range(0, g.getCardCount())
.filter(i -> c == g.getCards()[i])
.findFirst()
.getAsInt();
geNode.set("card-index", jsn.numberNode(pos));
geNode.set("color", jsn.stringNode(((ge.getIssuer()=='A')?c.getColorB():c.getColorA()).name().toLowerCase()));
break;
case GameEvent.Type.END_GUESSING:
geNode.set("manual", jsn.booleanNode((Boolean)ge.getData()));
break;
case GameEvent.Type.GAME_END:
geNode.set("win", jsn.booleanNode((Boolean)ge.getData()));
break;
case GameEvent.Type.SUDDEN_DEATH:
break;
}
return geNode;
public JsonNode eventData(GameEvent ge) {
return GameEvent.toJsonNode(ge);
}
public JsonNode phaseData(Phase p) {
@@ -148,18 +125,19 @@ public class JsonDataService {
public JsonNode gridData(Grid g, char player) {
ArrayNode cardsNode = jsn.arrayNode(g.getCardCount());
for (int i = 0; i < g.getCardCount(); i++)
cardsNode.add(cardData(g.getCards()[i], player));
Card[] cards = cRepository.findByGridOrderById_Index(g);
ArrayNode cardsNode = jsn.arrayNode(cards.length);
for (int i = 0; i < cards.length; i++)
cardsNode.add(cardData(cards[i], player));
ArrayNode eventsNode = jsn.arrayNode(g.getGameEvents().size());
for(GameEvent ge : g.getGameEvents())
eventsNode.add(eventData(g, ge));
eventsNode.add(eventData(ge));
ObjectNode currentHintNode = jsn.objectNode();
if(g.getCurrentHint() != null) {
currentHintNode.set("word", jsn.stringNode(g.getCurrentHint().getWord()));
currentHintNode.set("wordCount", jsn.numberNode(g.getCurrentHint().getWordCount()));
currentHintNode.set("word-count", jsn.numberNode(g.getCurrentHint().getWordCount()));
}
ObjectNode out = jsn.objectNode();
@@ -1,6 +1,8 @@
package com.bernard.nodecames.frontend;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -12,32 +14,35 @@ 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;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.repository.GridRepository;
import tools.jackson.databind.JsonNode;
@Controller
public class WebSocketController {
private GameManager gm;
private SimpMessagingTemplate template;
private JsonDataService json;
private final GridManager gm;
private final SimpMessagingTemplate template;
private final JsonDataService json;
private final GridRepository gRepository;
public WebSocketController(GameManager gm, SimpMessagingTemplate template, JsonDataService json) {
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(g, ge)
json.eventData(ge)
);
}
@@ -54,17 +59,16 @@ public class WebSocketController {
);
}
public void publishCardUpdateToPlayer(Grid g, int cardIndex, char player) {
Card c = g.getCards()[cardIndex];
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", cardIndex)
Map.of("cardIndex", c.getIndex())
);
}
public void publishCardUpdate(Grid g, int cardIndex) {
publishCardUpdateToPlayer(g, cardIndex, 'A');
publishCardUpdateToPlayer(g, cardIndex, 'B');
public void publishCardUpdate(Grid g, Card card) {
publishCardUpdateToPlayer(g, card, 'A');
publishCardUpdateToPlayer(g, card, 'B');
}
public void publishError(char issuer, IllegalGameActionException e) {
@@ -81,9 +85,11 @@ public class WebSocketController {
else
throw new IllegalArgumentException("Unknown player type");
Grid g = gm.findGrid(gridId);
return new ResponseEntity<>(json.gridData(g, player), HttpStatus.OK);
Optional<Grid> g = gRepository.findById(UUID.fromString(gridId));
if(g.isPresent())
return new ResponseEntity<>(json.gridData(g.get(), player), HttpStatus.OK);
else
return new ResponseEntity<>("Could not find grid with given ID",HttpStatus.NOT_FOUND);
}
@MessageMapping("submit-hint")
@@ -91,10 +97,13 @@ public class WebSocketController {
String hint = content.asObject().get("hint").stringValue();
int wordCount = content.asObject().get("hintWordCount").intValue();
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.proposeHint(g, player.charAt(0), hint, wordCount);
gm.proposeHint(g.get(), player.charAt(0), hint, wordCount);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -105,10 +114,14 @@ public class WebSocketController {
public void pointCard(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
int cardIndex = content.asObject().get("cardIndex").intValue();
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.pointCard(g, player.charAt(0), cardIndex);
publishCardUpdate(g, cardIndex);
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();
@@ -117,9 +130,13 @@ public class WebSocketController {
@MessageMapping("end-guessing")
public void endGuessing(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.endGuessing(g, player.charAt(0));
gm.endGuessing(g.get(), player.charAt(0));
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -128,9 +145,12 @@ public class WebSocketController {
@MessageMapping("enter-sudden-death")
public void enterSuddenDeath(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.enterSuddenDeath(g);
gm.enterSuddenDeath(g.get());
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -1,420 +1,90 @@
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.Map.Entry;
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 com.bernard.nodecames.model.World;
import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.WorldRepository;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import jakarta.validation.constraints.NotNull;
@Service
@RequiredArgsConstructor
public class GameManager {
//TODO Remove static, make something more Spring Boot-y
private static Map<UUID, Grid> games = new HashMap<>();
private static Map<UUID, Random> randoms = new HashMap<>();
// Reverse map allowing to pass grid events to the game
Map<UUID, World> currentGrids = new HashMap<>();
@Setter
private WebSocketController wsc = null;
private final GridManager gm;
private final GameRepository gRepository;
private final WorldRepository wRepository;
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);
public GameManager(GridManager gm, GameRepository gRepository, WorldRepository wRepository) {
this.gm = gm;
this.gRepository = gRepository;
this.wRepository = wRepository;
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 Game createGame(String name) {
UUID uuid = UUID.randomUUID();
randoms.put(uuid, r);
String[] words = dictionnaries.randomWords(gc.getWordCount(), r);
List<Card> cards = new ArrayList<>(gc.getWordCount());
List<CardColors> origDistrib = gc.getWordDistribution();
List<CardColors> distrib;
if(!gc.isStructuredGrid()) {
List<Color> 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();
Game g = new Game(uuid, name);
gRepository.save(g);
for (Entry<String, GridConfig> wConfig : Game.DEFAULT_WORLDS_CONFIG.entrySet()) {
World w = new World(g, wConfig.getKey(), wConfig.getValue(), null, false);
wRepository.save(w);
}
return g;
}
public Collection<Game> allGames() {
return gRepository.findAll();
}
public Set<World> allWorlds(@NotNull Game g) {
return wRepository.findByGame(g);
}
private Grid newGrid(Game g, World w) {
GridConfig gc = w.getInitialConfig();
Grid grid = gm.newGrid(w, gc, new Random());
w.setCurrentGrid(grid);
wRepository.save(w);
currentGrids.put(grid.getId(), w);
return grid;
}
public Grid resetGrid(Game g, World world) {
return newGrid(g, world);
}
public Grid getGrid(Game g, World w) {
if(w.getCurrentGrid() != null) {
return w.getCurrentGrid();
} 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.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 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;
}
return newGrid(g, w);
}
}
/**
* 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)) || (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<Integer>)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<Integer> 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);
public void onGameWon(Grid g) {
World w = currentGrids.get(g.getId());
if(w != null) {
w.setWon(true);
wRepository.save(w);
}
}
}
@@ -0,0 +1,439 @@
package com.bernard.nodecames.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
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.GameEvent;
import com.bernard.nodecames.model.Card.Color;
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 com.bernard.nodecames.model.World;
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 final GridRepository gRepository;
private final CardRepository cRepository;
private final DictionnariesService dictionnaries;
private static final Logger log = LoggerFactory.getLogger(GridManager.class);
private void newGameEvent(Grid g, GameEvent ge) {
log.debug("New game event : {}", ge);
g.newGameEvent(ge);
gRepository.save(g);
wsc.onNewEvent(g, ge);
}
private void setPhase(Grid g, Phase phase) {
log.debug("New phase : {}", phase.name());
g.setPhase(phase);
if(!phase.isAnyoneGuessing()) {
g.setCurrentHint(null);
}
gRepository.save(g);
wsc.onNewPhase(g);
}
private void setWon(Grid g, boolean won) {
log.debug("Game won: {}", won);
g.setWonOpt(Optional.of(won));
gRepository.save(g);
}
private void newHint(Grid g, Hint h) {
log.debug("New hint: {}", h);
g.incrementHintCount();
g.setCurrentHint(h);
g.setCurrentGuessCount(0);
gRepository.save(g);
}
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);
}
public int getCardCount(Grid g) {
return cRepository.countByGrid(g);
}
public Card[] getCards(Grid g) {
return cRepository.findByGridOrderById_Index(g);
}
public Card getCard(Grid g, int index) {
return cRepository.findByGridAndId_Index(g, index);
}
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 : getCards(g)) {
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 newGrid(@Nullable World world, GridConfig gc, Random r) {
UUID uuid = UUID.randomUUID();
String[] words = dictionnaries.randomWords(gc.getWordCount(), r);
List<CardColors> origDistrib = gc.getWordDistributionAsList();
List<CardColors> distrib;
if(!gc.isStructuredGrid()) {
List<Color> 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;
}
distrib = new ArrayList<>(distrib);
Collections.shuffle(distrib, 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, world, startPhase, gc);
gRepository.save(g);
for (int j = 0; j < distrib.size(); j++) {
Card card = new Card(g, j, words[j], distrib.get(j).getFaceA(), distrib.get(j).getFaceB());
cRepository.save(card);
}
return g;
}
/**********************************
* 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);
}
}
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);
newHint(g, h);
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>getCardCount(g)) {
throw new IllegalArgumentException("The given card index is invalid");
}
Card c = getCard(g, 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);//TODO XXX BDD
Card.Color guessedColor = c.getColor(other(player));
newGameEvent(g, GameEvent.newGuessEvent(c.getIndex(), player, c.getColor(other(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).stream().map(i -> (Integer)i).collect(Collectors.toSet()));
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<Integer> 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);
}
}
}
@@ -1,14 +1,31 @@
package com.bernard.nodecames.model;
import java.util.UUID;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Card {
@EmbeddedId
CardId id;
@MapsId("gridId")
@ManyToOne
Grid grid;
String word;
Color colorA;
@@ -16,8 +33,12 @@ public class Card {
boolean colorAPublic;
boolean colorBPublic;
public Card(String word, Color colorA, Color colorB) {
this(word, colorA, colorB, false, false);
public Card(Grid g, int index, String word, Color colorA, Color colorB) {
this(new CardId(g.getId(), index), g, word, colorA, colorB, false, false);
}
public int getIndex() {
return this.getId().getIndex();
}
public Color getColor(char player) {
@@ -40,9 +61,29 @@ public class Card {
isColorAPublic()?'O':'X', isColorBPublic()?'O':'X');
}
@AllArgsConstructor
@Getter
public enum Color {
GREEN,
WHITE,
BLACK;
GREEN('G'),
WHITE('W'),
BLACK('B');
private char letter;
public static final Color findByLetter(char letter) {
for(Color c : Color.values()) {
if(c.letter == letter)
return c;
}
return null;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CardId {
UUID gridId;
int index;
}
}
@@ -1,5 +1,8 @@
package com.bernard.nodecames.model;
import java.util.Map;
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@@ -8,16 +11,30 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Game {
@Id
@GeneratedValue
private Long id;
private UUID id;
String name;
public static final Map<String, GridConfig> 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))
);
}
@@ -1,49 +1,92 @@
package com.bernard.nodecames.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import com.bernard.nodecames.model.Card.Color;
@Getter
public class GameEvent {
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
Type type;
Object data;
// 'A' for player A, 'B' for player B, '_' for none
char issuer;
private GameEvent(Type type, Object data, char issuer){
this.data = data;
this.issuer = issuer;
this.type = type;
}
public sealed interface GameEvent {
public static GameEvent newHintEvent(Hint h, char issuer) {
return new GameEvent(Type.HINT, h, issuer);
return new HintEvent(issuer, h);
}
public static GameEvent newGuessEvent(Card c, char issuer) {
return new GameEvent(Type.GUESS, c, issuer);
public static GameEvent newGuessEvent(int index, char issuer, Color color) {
return new GuessEvent(issuer, index, color);
}
public static GameEvent newEndGuessingEvent(boolean manual, char issuer) {
return new GameEvent(Type.END_GUESSING, manual, issuer);
return new EndGuessingEvent(issuer, manual);
}
public static GameEvent newGameEndEvent(boolean won) {
return new GameEvent(Type.GAME_END, won, '_');
return new GameEndEvent(won);
}
public static GameEvent newSuddenDeath() {
return new GameEvent(Type.SUDDEN_DEATH, Object.class, '_');
return new EnterSuddenDeathEvent();
}
@AllArgsConstructor
public enum Type {
HINT(Hint.class),
GUESS(Card.class),
END_GUESSING(Boolean.class),
GAME_END(Boolean.class),
SUDDEN_DEATH(Object.class);
Class<?> dataType;
public static JsonNode toJsonNode(GameEvent ge) {
JsonNodeFactory jsn = JsonNodeFactory.instance;
ObjectNode o = jsn.objectNode();
o.set("type", jsn.stringNode(ge.getClass().getSimpleName()));
switch (ge) {
case HintEvent(char issuer, Hint hint):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("word", jsn.stringNode(hint.getWord()));
o.set("word-count", jsn.numberNode(hint.getWordCount()));
break;
case GuessEvent(char issuer, int index, Color color):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("card-index", jsn.numberNode(index));
o.set("color", jsn.stringNode(color.name().toLowerCase()));
break;
case EndGuessingEvent(char issuer, boolean manual):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("manual", jsn.booleanNode(manual));
break;
case GameEndEvent(boolean win):
o.set("win", jsn.booleanNode(win));
break;
case EnterSuddenDeathEvent():
break;
}
return o;
}
public static GameEvent fromJsonNode(JsonNode o) {
String type = o.get("type").stringValue();
return switch(type) {
case "HintEvent" ->
new HintEvent(
o.get("issuer").asString().charAt(0),
new Hint(
o.get("word").stringValue(),
o.get("word-count").intValue()
)
);
case "GuessEvent" ->
new GuessEvent(
o.get("issuer").asString().charAt(0),
o.get("card-index").intValue(),
Color.valueOf(o.get("color").stringValue().toUpperCase())
);
case "EndGuessingEvent" ->
new EndGuessingEvent(
o.get("issuer").asString().charAt(0),
o.get("manual").booleanValue()
);
case "GameEndEvent" ->
new GameEndEvent(
o.get("win").booleanValue()
);
case "EnterSuddenDeathEvent" ->
new EnterSuddenDeathEvent();
default -> throw new IllegalArgumentException("Unknown event type : " + type);
};
}
}
record HintEvent(char issuer, Hint hint) implements GameEvent {}
record GuessEvent(char issuer, int index, Color color) implements GameEvent {}
record EndGuessingEvent(char issuer, boolean manual) implements GameEvent {}
record GameEndEvent(boolean win) implements GameEvent {}
record EnterSuddenDeathEvent() implements GameEvent {}
@@ -1,24 +1,49 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import jakarta.annotation.Nullable;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Grid {
String id;
@Id
UUID id;
Card[] cards;
@ManyToOne(optional = true)
World w;
//@Type(JsonType.class)
@Column(columnDefinition = "json")
@Convert(converter = GameEventListAdapter.class)
@JdbcTypeCode(SqlTypes.JSON)
List<GameEvent> gameEvents;
@Embedded
Hint currentHint = null;
int currentGuessCount;
@@ -26,10 +51,18 @@ public class Grid {
int usedWhites;
Phase phase;
Optional<Boolean> won;
//TODO make this parameter a real Optional<Boolean> with Jdbc understanding that optional means nullable
@Nullable
Boolean won;
@Embedded
GridConfig config;
public Grid(UUID id, World w, Phase phase, GridConfig config) {
this(id, w, List.of(), null, 0, 0, 0, phase, null, config);
}
public void incrementHintCount() {
this.setUsedHints(this.getUsedHints()+1);
}
@@ -40,10 +73,14 @@ public class Grid {
this.setCurrentGuessCount(this.getCurrentGuessCount()+1);
}
public void newGameEvent(GameEvent event) {
this.gameEvents = new ArrayList<>(gameEvents);
this.gameEvents.add(event);
}
public int getCardCount() {
return this.getCards().length;
public Optional<Boolean> isWonOpt() {
return Optional.ofNullable(this.won);
}
public void setWonOpt(Optional<Boolean> won) {
this.won = won.orElse(null);
}
@AllArgsConstructor
@@ -121,4 +158,24 @@ public class Grid {
}
}
@AllArgsConstructor
public static class GameEventListAdapter implements AttributeConverter<List<GameEvent>, JsonNode> {
JsonNodeFactory jsn;
@Override
public JsonNode convertToDatabaseColumn(List<GameEvent> attribute) {
ArrayNode out = jsn.arrayNode(attribute.size());
for(GameEvent ge : attribute) {
out.add(GameEvent.toJsonNode(ge));
}
return out;
}
@Override
public List<GameEvent> convertToEntityAttribute(JsonNode dbData) {
return dbData.asArray().valueStream().map(GameEvent::fromJsonNode).toList();
}
}
}
@@ -1,33 +1,51 @@
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.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.Collectors;
import com.bernard.nodecames.model.Card.Color;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Convert;
import jakarta.persistence.Converter;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Value;
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Embeddable
public class GridConfig {
public static final List<CardColors> 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 Map<CardColors, Integer> CUSTOM6 =
makeDistribution(0, 0, 2, 1, 1, 0);
public static final Map<CardColors, Integer> CUSTOM9 =
makeDistribution(0, 1, 1, 2, 2, 1);
public static final Map<CardColors, Integer> CUSTOM12 =
makeDistribution(1, 1, 1, 2, 3, 1);
public static final Map<CardColors, Integer> CUSTOM16 =
makeDistribution(1, 1, 2, 2, 5, 1);
public static final Map<CardColors, Integer> CUSTOM20 =
makeDistribution(1, 1, 4, 2, 5, 1);
public static final Map<CardColors, Integer> CLASSICAL25 =
makeDistribution(1, 1, 5, 3, 7, 1);
public static final Map<CardColors, Integer> CUSTOM30 =
makeDistribution(2, 2, 5, 3, 7, 1);
public static final Map<CardColors, Integer> CUSTOM36 =
makeDistribution(2, 2, 6, 5, 9, 2);
public static final Set<Integer> BASE_HINTWORDCOUNT = Set.of(
0,1,2,3,4,5,6,7,8,9,-1
);
@@ -43,6 +61,13 @@ public class GridConfig {
3, false, false,
false, false);
public static final GridConfig zeroWithSize(Map<CardColors, Integer> 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
@@ -50,7 +75,8 @@ public class GridConfig {
private int maxMaxHint;
private boolean structuredGrid;
private boolean anyoneStarts;
private List<CardColors> wordDistribution;
@Convert(converter = WordDistributionConverter.class)
private Map<CardColors, Integer> wordDistribution;
private boolean maybeSuddenDeath; //TODO
private boolean maybeOneMoreGuess; //TODO
// If onlyOneGreenGreen is false and having it true would have meant we won,
@@ -68,8 +94,18 @@ public class GridConfig {
private boolean suddenDeath;
private boolean hintsInARow;
public List<CardColors> getWordDistributionAsList() {
List<CardColors> out = new ArrayList<>();
for(Entry<CardColors, Integer> e : this.getWordDistribution().entrySet()) {
for(int i=0;i<e.getValue();i++) {
out.add(e.getKey());
}
}
return Collections.unmodifiableList(out);
}
public int getWordCount() {
return wordDistribution.size();
return (int)this.getWordDistribution().values().stream().collect(Collectors.summarizingInt(i -> i)).getSum();
}
public void set(MutableOption opt, Object value) {
@@ -124,9 +160,9 @@ public class GridConfig {
}
@Value(staticConstructor = "of")
public static final class CardColors {
Color faceA;
Color faceB;
public static final class CardColors implements Serializable, Comparable<CardColors> {
private Color faceA;
private Color faceB;
public static final CardColors WW = CardColors.of(Color.WHITE, Color.WHITE);
public static final CardColors WB = CardColors.of(Color.WHITE, Color.BLACK);
@@ -137,6 +173,30 @@ 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 Map<CardColors, Integer> makeDistribution(
int wbbwCount, int bggbCount, int wggwCount,
int ggCount, int wwCount, int bbCount
) {
return Map.of(
BB, bbCount,
WW, wwCount,
GG, ggCount,
WB, wbbwCount,
BW, wbbwCount,
GB, bggbCount,
BG, bggbCount,
GW, wggwCount,
WG, wggwCount
);
}
@Override
public int compareTo(CardColors o) {
int i = this.getFaceA().compareTo(o.getFaceA());
return i!=0?i:this.getFaceB().compareTo(o.getFaceB());
}
}
@AllArgsConstructor
@@ -153,4 +213,52 @@ public class GridConfig {
Class<?> dataClass;
}
@NoArgsConstructor
@Converter
public static class WordDistributionConverter implements AttributeConverter<Map<CardColors, Integer>, String> {
private String cardColorsToString(CardColors cc, Integer i) {
return "%s%s%d".formatted(cc.getFaceA().getLetter(), cc.getFaceB().getLetter(), i);
}
private CardColors parseCardColors(String s) {
if(s.length()<3)
throw new IllegalArgumentException("Malformed string");
char lA = s.charAt(0);
char lB = s.charAt(1);
Color cA = Color.findByLetter(lA);
Color cB = Color.findByLetter(lB);
if (cA == null)
throw new IllegalArgumentException("Could not find color corresponding to '"+lA+"' in string '" + s + "'");
if (cB == null)
throw new IllegalArgumentException("Could not find color corresponding to '"+lB+"'");
return CardColors.of(cA, cB);
}
private Integer parseCount(String s) {
if(s.length()<3)
throw new IllegalArgumentException("Malformed string");
String number = s.substring(2);
return Integer.parseInt(number, 10);
}
@Override
public String convertToDatabaseColumn(Map<CardColors, Integer> attribute) {
return attribute.entrySet().stream()
.sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey()))
.map(e -> cardColorsToString(e.getKey(), e.getValue()))
.collect(Collectors.joining(";"));
}
@Override
public Map<CardColors, Integer> convertToEntityAttribute(String dbData) {
return Arrays.stream(dbData.split(";"))
.collect(Collectors.toMap(
this::parseCardColors,
this::parseCount
));
}
}
}
@@ -1,10 +1,16 @@
package com.bernard.nodecames.model;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Embeddable
public class Hint {
String word;
@@ -0,0 +1,58 @@
package com.bernard.nodecames.model;
import java.util.UUID;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Entity
@NoArgsConstructor
public class World {
@EmbeddedId
WorldId id;
@MapsId("gameId")
@ManyToOne
Game game;
// Config used when resetting this world
GridConfig initialConfig;
@ManyToOne(optional = true)
Grid currentGrid;
boolean won;
public World(Game g, String name, GridConfig initConfig, Grid currentGrid, boolean won) {
this.id = new WorldId(g.getId(), name);
this.game = g;
this.initialConfig = initConfig;
this.currentGrid = currentGrid;
this.won = won;
}
public String getName() {
return this.getId().getName();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public static class WorldId {
UUID gameId;
String name;
}
}
@@ -0,0 +1,13 @@
package com.bernard.nodecames.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Card.CardId;
public interface CardRepository extends JpaRepository<Card, CardId> {
public Card[] findByGridOrderById_Index(Grid g);
public Card findByGridAndId_Index(Grid g, int index);
public int countByGrid(Grid g);
}
@@ -0,0 +1,10 @@
package com.bernard.nodecames.repository;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Game;
public interface GameRepository extends JpaRepository<Game, UUID> {
}
@@ -0,0 +1,10 @@
package com.bernard.nodecames.repository;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Grid;
public interface GridRepository extends JpaRepository<Grid, UUID> {
}
@@ -0,0 +1,15 @@
package com.bernard.nodecames.repository;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.model.World.WorldId;
public interface WorldRepository extends JpaRepository<World, WorldId>{
public Optional<World> findByGameAndId_Name(Game g, String name);
public Set<World> findByGame(Game g);
}
@@ -0,0 +1,8 @@
create table card (index integer not null, colora smallint check ((colora between 0 and 2)), colorapublic boolean not null, colorb smallint check ((colorb between 0 and 2)), colorbpublic boolean not null, word varchar(255), grid_id uuid not null, primary key (grid_id, index));
create table game (id uuid not null, name varchar(255), primary key (id));
create table grid (id uuid not null, anyone_starts boolean, available_hint_word_count integer array, end_guessing boolean, hint_max_length integer, hints_inarow boolean, max_hint integer, max_max_hint integer, maybe_one_more_guess boolean, maybe_sudden_death boolean, one_more_guess boolean, only_one_green_green boolean, only_one_green_green_prevent_death boolean, structured_grid boolean, sudden_death boolean, word_distribution varchar(255), current_guess_count integer not null, word varchar(255), word_count integer, game_events json, phase smallint check ((phase between 0 and 16)), used_hints integer not null, used_whites integer not null, won boolean, w_game_id uuid, w_name varchar(255), primary key (id));
create table world (name varchar(255) not null, anyone_starts boolean, available_hint_word_count integer array, end_guessing boolean, hint_max_length integer, hints_inarow boolean, max_hint integer, max_max_hint integer, maybe_one_more_guess boolean, maybe_sudden_death boolean, one_more_guess boolean, only_one_green_green boolean, only_one_green_green_prevent_death boolean, structured_grid boolean, sudden_death boolean, word_distribution varchar(255), won boolean not null, game_id uuid not null, current_grid_id uuid, primary key (game_id, name));
alter table if exists card add constraint FK5x40wglgnltvcrme1tqa8ilsx foreign key (grid_id) references grid;
alter table if exists grid add constraint FK583ks7m95vxertpybyo2s5h6n foreign key (w_game_id, w_name) references world;
alter table if exists world add constraint FKo96aqpsvpf22bjeqk2w6l8jvx foreign key (current_grid_id) references grid;
alter table if exists world add constraint FKe9tajxpu9c9llbtdaldt6t18s foreign key (game_id) references game;
+12 -25
View File
@@ -1,6 +1,5 @@
var socket = null
var player = '_'
var roomId = '_'
var wsHeaders = {}
var cards = []
var gc = {}
@@ -11,13 +10,6 @@ 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)
if (res == null)
console.log("Could not understand room id from url")
return res[1]
}
/*
* Update html to match state
@@ -30,12 +22,12 @@ function updatePhase() {
$('#hint-counter-den').text(gc["max-hint"])
if(currentHint) {
$('#hint-text').text(currentHint['word']+" in "+currentHint['wordCount'])
$('#hint-text').text(currentHint['word']+" in "+currentHint['word-count'])
$('#guess-counter-num').text(currentGuessCount)
if(gc['one-more-guess']) {
$('#guess-counter-den').text(currentHint['wordCount'] + "(+1)")
$('#guess-counter-den').text(currentHint['word-count'] + "(+1)")
} else {
$('#guess-counter-den').text(currentHint['wordCount'])
$('#guess-counter-den').text(currentHint['word-count'])
}
}
if(registeredAvailableHintWordCountInvalid) {
@@ -64,7 +56,7 @@ function updatePhase() {
}
if((phase['name'] == 'waiting_for_one_more_'+p) ||
(phase[p+'-guessing'] &&
(gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['wordCount'])))) {
(gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['word-count'])))) {
$('#end-guessing-button').show()
} else {
$('#end-guessing-button').hide()
@@ -187,28 +179,28 @@ function addEndGameEvent(issuer, won) {
function addEvent(e) {
switch(e.type) {
case "hint":
addHintEvent(e.issuer, e.word, e.wordCount)
currentHint = {word: e.word, wordCount: e.wordCount}
case "HintEvent":
addHintEvent(e.issuer, e.word, e['word-count'])
currentHint = {word: e.word, 'word-count': e['word-count']}
currentHintCount += 1
currentGuessCount = 0
updatePhase()
break
case "guess":
case "GuessEvent":
addPointEvent(e.issuer, e['card-index'], e.color)
currentGuessCount += 1
break
case "end_guessing":
case "EndGuessingEvent":
addEndGuessingEvent(e.issuer, e.manual)
currentHint = null
currentGuessCount = 0
break
case "game_end":
case "GameEndEvent":
addEndGameEvent(e.issuer, e.win)
currentHint = null
currentGuessCount = 0
break
case "sudden_death":
case "EnterSuddenDeathEvent":
addSuddenDeathEvent()
currentHint = null
currentGuessCount = 0
@@ -245,8 +237,7 @@ function initGame(data) {
updatePhase()
}
function selectPlayer(e) {
player = (e.target.id == "select-player-a") ? 'A' : 'B'
function selectPlayer(player) {
wsHeaders = {
"room": roomId,
"player": player
@@ -336,14 +327,10 @@ function onConfigChange(m) {
}
function initialize() {
$('#select-player-a').on('click', selectPlayer)
$('#select-player-b').on('click', selectPlayer)
$('#submit-hint button').on('click', submitHint)
$('#end-guessing-button').on('click', endGuessing)
$('#enter-sudden-death-button').on('click', enterSuddenDeath)
roomId = getRoomId()
$('#game-panel').hide()
$('#side-panel').hide()
}
+5 -5
View File
@@ -9,11 +9,6 @@
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<div id="player-selector">
Which player are you ? <br/>
<button id="select-player-a">Player A</button>
<button id="select-player-b">Player B</button>
</div>
<main>
<div id="game-panel">
<ul id="cards-list">
@@ -56,7 +51,12 @@
</div>
</main>
<script>
/*<![CDATA[*/
var player = '[(${player})]';
var roomId = '[(${grid.id})]';
/*]]>*/
initialize()
selectPlayer(player)
</script>
</body>
+19
View File
@@ -9,6 +9,25 @@
Vous voulez jouer à Node Cames ? Ben c'est pas encore prêt :/
<br/>
<a href="/create-room">(Bon, en vrai, vous pouvez créer une room en cliquant ici)</a>
<br/>
<form method="GET" th:action="@{/create-game}">
<label for="create-game-name">Name : </label>
<input type="text" id="create-game-name" name="name"/>
<input type="submit" value="Create game"/>
</form>
<div>
Currently running games:
<ul>
<li th:each="game : ${games}">
<a th:href="@{/game/{gid}/map(gid=${game.id})}" th:text="${game.name}"></a>
</li>
</ul>
</div>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<div th:replace="~{html-head}"/>
<link rel="stylesheet" th:href="@{/css/grid.css}"/>
<script th:src="@{/js/jquery-4.0.0.min.js}" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js" integrity="sha512-1QvjE7BtotQjkq8PxLeF6P46gEpBRXuskzIVgjFpekzFVF4yjRgrQvTG1MTOJ3yQgvTteKAcO7DSZI92+u/yZw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js" integrity="sha512-iKDtgDyTHjAitUDdLljGhenhPwrbBfqTKWO1mkhSFH3A7blITC9MhYon6SjnMhp4o0rADGw9yAC6EW4t5a4K3g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<main>
<ul>
<li th:each="world : ${worlds}">
<a th:href="@{/game/{gid}/grid/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">World <span th:text="${world.name}"/></a>
<a th:href="@{/game/{gid}/reset/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">reset</a>
<span th:if="${world.won}">Gagné !</span>
</li>
</ul>
</main>
</body>
</html>
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<div th:replace="~{html-head}"/>
<link rel="stylesheet" th:href="@{/css/grid.css}"/>
<script th:src="@{/js/jquery-4.0.0.min.js}" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js" integrity="sha512-1QvjE7BtotQjkq8PxLeF6P46gEpBRXuskzIVgjFpekzFVF4yjRgrQvTG1MTOJ3yQgvTteKAcO7DSZI92+u/yZw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js" integrity="sha512-iKDtgDyTHjAitUDdLljGhenhPwrbBfqTKWO1mkhSFH3A7blITC9MhYon6SjnMhp4o0rADGw9yAC6EW4t5a4K3g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<div>
Which player are you ? <br/>
<a th:href="@{{redirect}?player=A(redirect=${redirect})}">Player A</a>
<a th:href="@{{redirect}?player=B(redirect=${redirect})}">Player B</a>
</div>
</body>
</html>
@@ -2,7 +2,10 @@ package com.bernard.nodecames;
import org.springframework.boot.test.context.SpringBootTest;
import lombok.RequiredArgsConstructor;
@SpringBootTest
@RequiredArgsConstructor
class NodeCamesApplicationTests {
}