Compare commits

...
5 Commits
Author SHA1 Message Date
Mysaa Java 44e5a01727 Fix sudden death 2026-09-11 14:52:58 +02:00
Mysaa Java 8fca1c2573 Fix frontend update with onlyoneGreenGreen 2026-09-11 14:27:28 +02:00
Mysaa Java 498503ae59 Added a config page 2026-09-11 03:04:02 +02:00
Mysaa Java 920e378dd5 Implemented config options 2026-09-08 02:28:36 +02:00
Mysaa Java 3b68f3f42f Prepared grid config logic 2026-09-04 22:42:04 +02:00
14 changed files with 1082 additions and 291 deletions
+1
View File
@@ -69,6 +69,7 @@
})
];
shellHook = ''
export LOGGING_LEVEL_ROOT="DEBUG"
echo "Starting Gradle daemon ..."
gradle
echo "Gradle daemon started."
@@ -1,21 +1,34 @@
package com.bernard.nodecames;
import java.util.Map.Entry;
import java.util.Random;
import java.util.UUID;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
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.servlet.view.RedirectView;
import com.bernard.nodecames.frontend.GridConfigEdit;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.game.IllegalGameActionException;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
@AllArgsConstructor
@RequiredArgsConstructor
@Controller
public class HttpController {
GameManager gm;
private final GameManager gm;
private final Random rand = new Random();
@GetMapping("/")
public String index() {
@@ -27,9 +40,36 @@ public class HttpController {
return "grid";
}
@GetMapping("/room/{id}/config")
public String gridConfig(@PathVariable("id") String id, Model model) {
GridConfig gc = gm.findGrid(id).getConfig();
model.addAttribute("gridConfig", GridConfigEdit.of(gc));
return "grid-config";
}
@PostMapping("/room/{id}/config")
public String 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()) {
try {
gm.unlock(g, e.getKey(), e.getValue());
} catch (IllegalGameActionException ige) {
br.addError(new ObjectError(e.getKey().name(), ige.getMessage()));
}
}
model.addAttribute("contactForm", gce);
return "grid-config";
}
@GetMapping("/create-room")
public RedirectView createRoom() {
UUID uuid = gm.newGrid();
Random r = new Random(rand.nextLong());
UUID uuid = gm.newGrid(GridConfig.ZERO, r);
return new RedirectView("/room/"+uuid.toString()+"/grid");
}
}
@@ -0,0 +1,80 @@
package com.bernard.nodecames.frontend;
import java.util.EnumMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class GridConfigEdit {
private int maxHint;
private String availableHintWordCountStr; // Comma separated integers
private boolean oneMoreGuess;
private int hintMaxLength;
private boolean endGuessing;
private boolean onlyOneGreenGreen;
private boolean suddenDeath;
private boolean hintsInARow;
public static GridConfigEdit of(GridConfig gc) {
return new GridConfigEdit(
gc.getMaxHint(),
gc.getAvailableHintWordCount()
.stream()
.sorted()
.map(Object::toString)
.collect(Collectors.joining(",")),
gc.isOneMoreGuess(),
gc.getHintMaxLength(),
gc.isEndGuessing(),
gc.isOnlyOneGreenGreen(),
gc.isSuddenDeath(),
gc.isHintsInARow()
);
}
public Set<Integer> getAvailableHintWordCount() {
return Stream.of(this.getAvailableHintWordCountStr().split(","))
.map(Integer::parseInt)
.collect(Collectors.toSet());
}
public Map<MutableOption, Object> getMutableOptions() {
return Map.of(
MutableOption.MAX_HINT, this.getMaxHint(),
MutableOption.AVAILABLE_HINT_WORD_COUNT, this.getAvailableHintWordCount(),
MutableOption.ONE_MORE_GUESS, this.isOneMoreGuess(),
MutableOption.HINT_MAX_LENGTH, this.getHintMaxLength(),
MutableOption.END_GUESSING, this.isEndGuessing(),
MutableOption.ONLY_ONE_GREEN_GREEN, this.isOnlyOneGreenGreen(),
MutableOption.SUDDEN_DEATH, this.isSuddenDeath(),
MutableOption.HINTS_IN_A_ROW, this.isHintsInARow()
);
}
/**
* Returns the list of modified in this edit compared to the GridConfig
*/
public Map<MutableOption, Object> getModifications(GridConfig orig) {
Map<MutableOption, Object> out = new EnumMap<>(MutableOption.class);
Map<MutableOption, Object> selfOpts = this.getMutableOptions();
for(Entry<MutableOption, Object> e : orig.getMutableOptions().entrySet()) {
Object newValue = selfOpts.get(e.getKey());
if(!newValue.equals(e.getValue())) {
out.put(e.getKey(), newValue);
}
}
return out;
}
}
@@ -0,0 +1,179 @@
package com.bernard.nodecames.frontend;
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.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 tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@Service
public class JsonDataService {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
public JsonNode cardData(Card c, char player) {
ObjectNode cardNode = jsn.objectNode();
cardNode.set("word", jsn.stringNode(c.getWord()));
cardNode.set("color", jsn.stringNode((player=='A'?c.getColorA():c.getColorB()).name().toLowerCase()));
if((player=='A' && c.isColorBPublic()) || (player=='B' && c.isColorAPublic()))
cardNode.set("otherColor", jsn.stringNode((player=='A'?c.getColorB():c.getColorA()).name().toLowerCase()));
cardNode.set("revealed-a", jsn.booleanNode(c.isColorBPublic()));
cardNode.set("revealed-b", jsn.booleanNode(c.isColorAPublic()));
return cardNode;
}
public JsonNode gridConfig(GridConfig gc) {
ObjectNode gcNode = jsn.objectNode();
gcNode.set("max-max-hint", jsn.numberNode(gc.getMaxMaxHint()));
gcNode.set("structured-grid", jsn.booleanNode(gc.isStructuredGrid()));
gcNode.set("anyone-starts", jsn.booleanNode(gc.isAnyoneStarts()));
ArrayNode wordDistributionNode = jsn.arrayNode(gc.getWordDistribution().size());
for(CardColors c : gc.getWordDistribution()) {
ObjectNode ccNode = jsn.objectNode();
ccNode.set("face-a", jsn.stringNode(c.getFaceA().name()));
ccNode.set("face-b", jsn.stringNode(c.getFaceB().name()));
wordDistributionNode.add(ccNode);
}
gcNode.set("word-distribution", wordDistributionNode);
gcNode.set("maybe-sudden-death", jsn.booleanNode(gc.isMaybeSuddenDeath()));
gcNode.set("maybe-one-more-guess", jsn.booleanNode(gc.isMaybeOneMoreGuess()));
gcNode.set("only-one-green-green-prevent-death", jsn.booleanNode(gc.isOnlyOneGreenGreenPreventDeath()));
gcNode.set("max-hint", jsn.numberNode(gc.getMaxHint()));
ArrayNode availableHintWordCountNode = jsn.arrayNode(gc.getAvailableHintWordCount().size());
gc.getAvailableHintWordCount().stream().sorted().forEach(i -> availableHintWordCountNode.add(jsn.numberNode(i)));
gcNode.set("available-hint-word-count", availableHintWordCountNode);
gcNode.set("one-more-guess", jsn.booleanNode(gc.isOneMoreGuess()));
gcNode.set("hint-max-length", jsn.numberNode(gc.getHintMaxLength()));
gcNode.set("end-guessing", jsn.booleanNode(gc.isEndGuessing()));
gcNode.set("only-one-green-green", jsn.booleanNode(gc.isOnlyOneGreenGreen()));
gcNode.set("sudden-death", jsn.booleanNode(gc.isSuddenDeath()));
gcNode.set("hints-in-a-row", jsn.booleanNode(gc.isHintsInARow()));
return gcNode;
}
public Object gridConfigUpdateData(MutableOption cc, Object data) {
ObjectNode config = jsn.objectNode();
switch(cc) {
case MAX_HINT:
config.set("max-hint", jsn.numberNode((Integer)data));
break;
case AVAILABLE_HINT_WORD_COUNT:
ArrayNode availableHintWordCountNode = jsn.arrayNode(((Set<?>) data).size());
((Set<?>) data).stream().sorted().forEach(i -> availableHintWordCountNode.add(jsn.numberNode((Integer)i)));
config.set("available-hint-word-count", availableHintWordCountNode);
break;
case ONE_MORE_GUESS:
config.set("one-more-guess", jsn.booleanNode((Boolean)data));
break;
case HINT_MAX_LENGTH:
config.set("hint-max-length", jsn.numberNode((Integer)data));
break;
case END_GUESSING:
config.set("end-guessing", jsn.booleanNode((Boolean)data));
break;
case ONLY_ONE_GREEN_GREEN:
config.set("only-one-green-green", jsn.booleanNode((Boolean)data));
break;
case SUDDEN_DEATH:
config.set("sudden-death", jsn.booleanNode((Boolean)data));
break;
case HINTS_IN_A_ROW:
config.set("hints-in-a-row", jsn.booleanNode((Boolean)data));
break;
}
return config;
}
public JsonNode eventData(Grid g, GameEvent ge) {
ObjectNode geNode = jsn.objectNode();
geNode.set("type", jsn.stringNode(ge.getType().name().toLowerCase()));
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 phaseData(Phase p) {
ObjectNode out = jsn.objectNode();
out.set("name", jsn.stringNode(p.name().toLowerCase()));
out.set("a-guessing", jsn.booleanNode(p.isGuessing('A')));
out.set("b-guessing", jsn.booleanNode(p.isGuessing('B')));
out.set("a-hinting", jsn.booleanNode(p.isHinting('A')));
out.set("b-hinting", jsn.booleanNode(p.isHinting('B')));
out.set("sudden-death", jsn.booleanNode(p.isSuddenDeath()));
out.set("game-ended", jsn.booleanNode(p.isGameEnded()));
out.set("locked", jsn.booleanNode(p.isLocked()));
return out;
}
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));
ArrayNode eventsNode = jsn.arrayNode(g.getGameEvents().size());
for(GameEvent ge : g.getGameEvents())
eventsNode.add(eventData(g, 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()));
}
ObjectNode out = jsn.objectNode();
out.set("cards", cardsNode);
out.set("phase", phaseData(g.getPhase()));
out.set("events", eventsNode);
if(g.getCurrentHint() != null)
out.set("current-hint", currentHintNode);
out.set("current-guess-count", jsn.numberNode(g.getCurrentGuessCount()));
out.set("used-hints", jsn.numberNode(g.getUsedHints()));
out.set("used-whites", jsn.numberNode(g.getUsedWhites()));
out.set("config", gridConfig(g.getConfig()));
return out;
}
}
@@ -13,38 +13,44 @@ 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.IllegalGameActionException;
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.MutableOption;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
@Controller
public class WebSocketController {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
private GameManager gm;
private SimpMessagingTemplate template;
private JsonDataService json;
public WebSocketController(GameManager gm, SimpMessagingTemplate template) {
public WebSocketController(GameManager gm, SimpMessagingTemplate template, JsonDataService json) {
this.gm = gm;
this.template = template;
this.json = json;
gm.setWsc(this);
}
public void onNewEvent(Grid g, GameEvent ge) {
this.template.convertAndSend("/topic/new-event",
gm.eventData(g, ge)
json.eventData(g, ge)
);
}
public void onNewPhase(Grid g, Phase p) {
public void onNewPhase(Grid g) {
this.template.convertAndSend(
"/topic/new-phase",
gm.phaseData(g.getPhase())
json.phaseData(g.getPhase())
);
}
public void onConfigChange(Grid g, MutableOption cc, Object data) {
this.template.convertAndSend("/topic/config-change",
json.gridConfigUpdateData(cc, data)
);
}
@@ -52,7 +58,7 @@ public class WebSocketController {
Card c = g.getCards()[cardIndex];
this.template.convertAndSend(
"/topic/update-card/" + Character.toLowerCase(player),
gm.cardData(c, player),
json.cardData(c, player),
Map.of("cardIndex", cardIndex)
);
}
@@ -61,6 +67,10 @@ public class WebSocketController {
publishCardUpdateToPlayer(g, cardIndex, 'B');
}
public void publishError(char issuer, IllegalGameActionException e) {
//TODO open error channel
}
@GetMapping("/room/{id}/game/{player}")
public Object grid(@PathVariable("id") String gridId, @PathVariable("player") String playerStr) {
char player;
@@ -73,7 +83,7 @@ public class WebSocketController {
Grid g = gm.findGrid(gridId);
return new ResponseEntity<>(gm.gridData(g, player), HttpStatus.OK);
return new ResponseEntity<>(json.gridData(g, player), HttpStatus.OK);
}
@MessageMapping("submit-hint")
@@ -83,7 +93,12 @@ public class WebSocketController {
Grid g = gm.findGrid(roomId);
gm.proposeHint(g, player.charAt(0), hint, wordCount);
try {
gm.proposeHint(g, player.charAt(0), hint, wordCount);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
}
}
@MessageMapping("point-card")
@@ -91,13 +106,34 @@ public class WebSocketController {
int cardIndex = content.asObject().get("cardIndex").intValue();
Grid g = gm.findGrid(roomId);
gm.pointCard(g, player.charAt(0), cardIndex);
publishCardUpdate(g, cardIndex);
try {
gm.pointCard(g, player.charAt(0), cardIndex);
publishCardUpdate(g, cardIndex);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
}
}
@MessageMapping("end-guessing")
public void endGuessing(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
gm.endGuessing(g, player.charAt(0));
try {
gm.endGuessing(g, player.charAt(0));
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
}
}
@MessageMapping("enter-sudden-death")
public void enterSuddenDeath(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
try {
gm.enterSuddenDeath(g);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
}
}
}
@@ -0,0 +1,54 @@
package com.bernard.nodecames.game;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Scanner;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import lombok.Setter;
@Service
public class DictionnariesService {
@Value("classpath:dictionaries/fr.txt")
@Setter
Resource dictFr;
//TODO do Spring boot magic to only generate this once
public List<String> allWords() {
try (Scanner wordsScanner = new Scanner(dictFr.getInputStream())) {
List<String> out = new ArrayList<>();
while (wordsScanner.hasNextLine())
out.add(wordsScanner.nextLine());
return Collections.unmodifiableList(out);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public String[] randomWords(int count, Random r) {
List<String> allWords = allWords();
if(count > allWords.size())
throw new IllegalArgumentException("Not enough words in the dictionary");
String[] out = new String[count];
Set<Integer> already = new HashSet<>();
int k;
for(int i = 0; i<count; i++) {
do {
k = r.nextInt(allWords.size());
} while (already.contains(k));
out[i] = allWords.get(k);
already.add(k);
}
return out;
}
}
@@ -1,21 +1,19 @@
package com.bernard.nodecames.game;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Scanner;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.frontend.WebSocketController;
@@ -24,224 +22,143 @@ 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;
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 GameManager {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
public static final int MAX_HINT_COUNT = 9;
//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<>();
@Setter
WebSocketController wsc;
private WebSocketController wsc = null;
@Value("classpath:dictionaries/fr.txt")
Resource dictFr;
private final DictionnariesService dictionnaries;
private static final Logger log = LoggerFactory.getLogger(GameManager.class);
private void newGameEvent(Grid g, GameEvent ge) {
g.newGameEvent(ge);
log.debug("New game event : {}", ge);
wsc.onNewEvent(g, ge);
}
private void setPhase(Grid g, Phase phase) {
g.setPhase(phase);
log.debug("New phase : {}", phase.name());
if(!phase.isAnyoneGuessing()) {
g.setCurrentHint(null);
}
wsc.onNewPhase(g, phase);
wsc.onNewPhase(g);
}
//TODO do Spring boot magic to only generate this once
public List<String> allWords() {
try (Scanner wordsScanner = new Scanner(dictFr.getInputStream())) {
List<String> out = new ArrayList<>();
while (wordsScanner.hasNextLine())
out.add(wordsScanner.nextLine());
return Collections.unmodifiableList(out);
} catch (IOException e) {
throw new IllegalStateException("Could not read internal dictionnary", e);
}
}
public String[] randomWords(int count) {
List<String> allWords = allWords();
//TODO seed the games
Random r = new Random();
if(count > allWords.size())
throw new IllegalArgumentException("Not enough words in the dictionary");
String[] out = new String[count];
Set<Integer> already = new HashSet<>();
int k;
for(int i = 0; i<count; i++) {
do {
k = r.nextInt(allWords.size());
} while (already.contains(k));
out[i] = allWords.get(k);
already.add(k);
}
return out;
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() {
public UUID newGrid(GridConfig gc, Random r) {
UUID uuid = UUID.randomUUID();
String[] words = randomWords(25);
List<Card> cards = new ArrayList<>(25);
int i = 0;
cards.add(new Card(words[i++], Color.BLACK, Color.BLACK));
for (;i < 4; i++)
cards.add(new Card(words[i], Color.GREEN, Color.GREEN));
for (;i < 9; i++)
cards.add(new Card(words[i], Color.WHITE, Color.GREEN));
for (;i < 14; i++)
cards.add(new Card(words[i], Color.GREEN, Color.WHITE));
cards.add(new Card(words[i++], Color.GREEN, Color.BLACK));
cards.add(new Card(words[i++], Color.BLACK, Color.GREEN));
cards.add(new Card(words[i++], Color.WHITE, Color.BLACK));
cards.add(new Card(words[i++], Color.BLACK, Color.WHITE));
for (;i < 25; i++)
cards.add(new Card(words[i], Color.WHITE, Color.WHITE));
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();
} 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);
Collections.shuffle(cards);
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()]));
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;
}
/**********************************
* JSON ACCESSORS *
**********************************/
public JsonNode cardData(Card c, char player) {
ObjectNode cardNode = jsn.objectNode();
cardNode.set("word", jsn.stringNode(c.getWord()));
cardNode.set("color", jsn.stringNode((player=='A'?c.getColorA():c.getColorB()).name().toLowerCase()));
if((player=='A' && c.isColorBPublic()) || (player=='B' && c.isColorAPublic()))
cardNode.set("otherColor", jsn.stringNode((player=='A'?c.getColorB():c.getColorA()).name().toLowerCase()));
cardNode.set("revealed-a", jsn.booleanNode(c.isColorBPublic()));
cardNode.set("revealed-b", jsn.booleanNode(c.isColorAPublic()));
return cardNode;
}
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 phaseData(Phase p) {
ObjectNode out = jsn.objectNode();
out.set("a-guessing", jsn.booleanNode(p.isAGuessing()));
out.set("b-guessing", jsn.booleanNode(p.isBGuessing()));
out.set("a-hinting", jsn.booleanNode(p.isAHinting()));
out.set("b-hinting", jsn.booleanNode(p.isBHinting()));
out.set("sudden-death", jsn.booleanNode(p.isSuddenDeath()));
out.set("game-ended", jsn.booleanNode(p.isGameEnded()));
return out;
}
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));
ArrayNode eventsNode = jsn.arrayNode(g.getGameEvents().size());
for(GameEvent ge : g.getGameEvents())
eventsNode.add(eventData(g, 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()));
}
ObjectNode out = jsn.objectNode();
out.set("cards", cardsNode);
out.set("phase", phaseData(g.getPhase()));
out.set("events", eventsNode);
if(g.getCurrentHint() != null)
out.set("current-hint", currentHintNode);
out.set("current-guess-count", jsn.numberNode(g.getCurrentGuessCount()));
out.set("used-hints", jsn.numberNode(g.getUsedHints()));
out.set("used-whites", jsn.numberNode(g.getUsedWhites()));
return out;
}
/*################################*
* GAME LOGIC *
*################################*/
/**
@returns true iff all green cards of this player have been revealed either side
*/
public boolean allGreenRevealed(Grid g, char player) {
for(Card c : g.getCards()) {
if (c.getColor(player) == Color.GREEN && !c.isColorPublic(player) &&
!(c.getColor(other(player)) == Color.GREEN && c.isColorPublic(other(player))))
return false;
}
return true;
}
/**********************************
* GAME ACTIONS *
**********************************/
private void endGame(Grid g, boolean win) {
g.setWon(Optional.of(win));
setPhase(g, Phase.gameEnded());
setPhase(g, Phase.GAME_ENDED);
newGameEvent(g, GameEvent.newGameEndEvent(win));
}
public void proposeHint(Grid g, char player, String hint, int wordCount) {
if (!g.getPhase().isHinting(player)) {
throw new RuntimeException("This action is not allowed now");
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);
@@ -252,36 +169,68 @@ public class GameManager {
setPhase(g, Phase.guessing(other(player)));
}
/**
* Returns 'A', 'B', '_' depending on who should be the next ones to hint if we enter a hinting phase
*/
private char whoWouldHintNext(Grid g, char player) {
if(allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
// I can't hint anymore
return other(player);
} else {
if(g.getConfig().isHintsInARow() && !allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen())) {
return '_';
} else {
return player;
}
}
}
/**
* Enters the sudden death phase
*/
private void 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() >= MAX_HINT_COUNT) {
// We're entering sudden death
newGameEvent(g, GameEvent.newSuddenDeath());
if(allGreenRevealed(g, player)) {
// If all my greens are revealed, i'm the only one to guess
setPhase(g, Phase.suddenDeathOnly(player));
if(g.getUsedHints() >= g.getConfig().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 {
// Else, we are both guessing
setPhase(g, Phase.suddenDeathBoth());
// We are waiting for more hints
if(g.getConfig().isSuddenDeath()) {
setPhase(g, Phase.needMoreHints(whoWouldHintNext(g, player)));
} else {
setPhase(g, Phase.needMoreHintsOrSuddenDeath(whoWouldHintNext(g, player)));
}
}
} else {
// Next regular hinting round
if(allGreenRevealed(g, player)) {
// I can't hint anymore
setPhase(g, Phase.hinting(other(player)));
} else {
setPhase(g, Phase.hinting(player));
}
setPhase(g, Phase.hinting(whoWouldHintNext(g, player)));
}
}
public Card.Color pointCard(Grid g, char player, int cardIndex) {
if (!g.getPhase().isGuessing(player)) {
throw new RuntimeException("This action is not allowed now");
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");
@@ -289,19 +238,27 @@ public class GameManager {
Card c = g.getCards()[cardIndex];
if(c.isColorPublic(other(player)) || (c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player)))
throw new RuntimeException("Card has already been revealed");
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)
endGame(g, false);
else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE)
endGame(g, false);
else if (allGreenRevealed(g, other(player)) && guessedColor == Color.GREEN) {
if(guessedColor == Color.BLACK){
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE) {
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// i.e. i made my last guess
if (allGreenRevealed(g, player)) {
if (allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
endGame(g, true);
} else if (g.getPhase().isSuddenDeath()) {
// Sudden death with only the other
@@ -309,11 +266,16 @@ public class GameManager {
} else {
endGuessingRound(g, player);
}
} else if (!allGreenRevealed(g, other(player)) && guessedColor == Color.GREEN) {
} else if (!allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// There is still cards i should find
if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount())) {
// We're done with this round of guesses
endGuessingRound(g, player);
if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), g.getConfig().isOneMoreGuess())) {
if(!g.getConfig().isOneMoreGuess() && g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), true)) {
// Having one more guess could make us continue
setPhase(g, Phase.waitingForOneMore(player));
} else {
// We're done with this round of guesses anyway
endGuessingRound(g, player);
}
} else {
// I continue guessing cards in this round
}
@@ -322,18 +284,137 @@ public class GameManager {
} else {
throw new IllegalStateException("This should logically never happen");
}
if(!g.getPhase().isAnyoneGuessing())
if(!g.getPhase().isAnyoneGuessing() && !g.getPhase().isWaitingForOneMore())
// It means this pick ended the guessing turn
newGameEvent(g, GameEvent.newEndGuessingEvent(false, player));
return guessedColor;
}
public void endGuessing(Grid g, char player) {
if (!g.getPhase().isGuessing(player)) {
throw new RuntimeException("This action is not allowed now");
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);
}
}
}
@@ -0,0 +1,16 @@
package com.bernard.nodecames.game;
import com.bernard.nodecames.model.Grid;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class IllegalGameActionException extends Exception {
private final Grid g;
private final String message;
}
@@ -1,11 +1,9 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -30,9 +28,7 @@ public class Grid {
Phase phase;
Optional<Boolean> won;
public Grid(String name, Card[] cards) {
this(name, cards, new ArrayList<>(), null, 0, 0, 0, Phase.bothHinting(), Optional.empty());
}
GridConfig config;
public void incrementHintCount() {
this.setUsedHints(this.getUsedHints()+1);
@@ -50,58 +46,78 @@ public class Grid {
return this.getCards().length;
}
@EqualsAndHashCode
@AllArgsConstructor
@Getter
public static class Phase {
private boolean aHinting;
private boolean bHinting;
private boolean aGuessing;
private boolean bGuessing;
public enum Phase {
HINTING_A('A', ' ', false, false, false),
HINTING_B('B', ' ', false, false, false),
GUESSING_A(' ', 'A', false, false, false),
GUESSING_B(' ', 'B', false, false, false),
HINTING_BOTH('_', ' ', false, false, false),
SUDDEN_DEATH_BOTH(' ', '_', true, false, false),
SUDDEN_DEATH_A(' ', 'A', true, false, false),
SUDDEN_DEATH_B(' ', 'B', true, false, false),
GAME_ENDED(' ', ' ', false, true, false),
// We could go to sudden death OR wait for more hints
NEED_MORE_HINTS_A('A', ' ', false, false, false),
NEED_MORE_HINTS_B('B', ' ', false, false, false),
// We can wait for SUDDEN_DEATH to be unlocked, or wait for more hints
NEED_MORE_HINTS_OR_SUDDEN_DEATH_A('A', ' ', false, false, true),
NEED_MORE_HINTS_OR_SUDDEN_DEATH_B('B', ' ', false, false, true),
// We can only wait for suddenDeath to be unlocked
NEED_SUDDEN_DEATH(' ', ' ', true, false, true),
// We can either end guessing, or wait for possibility to do one more guess
WAITING_FOR_ONE_MORE_A(' ', 'A', false, false, false),
WAITING_FOR_ONE_MORE_B(' ', 'B', false, false, false),
// We should have lost the game, we win it if we unlock onlyOneGreenGreen
WIN_IF_GREEN_GREEN(' ', ' ', false, false, true);
// ' ' is none, 'A' is A, 'B' is B, '_' is both
private char hinting;
private char guessing;
private boolean suddenDeath;
boolean gameEnded;
private Phase(boolean aHinting,
boolean bHinting,
boolean aGuessing,
boolean bGuessing,
boolean suddenDeath,
boolean gameEnded) {
this.aHinting = aHinting;
this.bHinting = bHinting;
this.aGuessing = aGuessing;
this.bGuessing = bGuessing;
this.suddenDeath = suddenDeath;
this.gameEnded = gameEnded;
}
private boolean gameEnded;
private boolean locked;
public boolean isHinting(char player) {
return (player == 'A')?this.isAHinting():this.isBHinting();
return (this == HINTING_BOTH) || (this == ((player == 'A')?HINTING_A:HINTING_B));
}
public boolean isGuessing(char player) {
return (player == 'A')?this.isAGuessing():this.isBGuessing();
return this == guessing(player) || this == suddenDeathOnly(player) || this == SUDDEN_DEATH_BOTH;
}
public boolean isAnyoneGuessing() {
return this.isAGuessing() || this.isBGuessing();
}
public static Phase bothHinting() {
return new Phase(true, true, false, false, false, false);
return isGuessing('A') || isGuessing('B');
}
public static Phase hinting(char player) {
return new Phase((player == 'A'), (player == 'B'), false, false, false, false);
return (player == '_')?HINTING_BOTH:((player == 'A')?HINTING_A:HINTING_B);
}
public static Phase guessing(char player) {
return new Phase(false, false, (player == 'A'), (player == 'B'), false, false);
}
public static Phase suddenDeathBoth() {
return new Phase(false, false, true, true, true, false);
return (player == 'A')?GUESSING_A:GUESSING_B;
}
public static Phase suddenDeathOnly(char player) {
return new Phase(false, false, player == 'A', player == 'B', true, false);
return (player == 'A')?SUDDEN_DEATH_A:SUDDEN_DEATH_B;
}
public static Phase gameEnded() {
return new Phase(false,false,false,false,false,true);
public static Phase needMoreHints(char player) {
return (player == 'A')?NEED_MORE_HINTS_A:NEED_MORE_HINTS_B;
}
public static Phase needMoreHintsOrSuddenDeath(char player) {
return (player == 'A')?NEED_MORE_HINTS_OR_SUDDEN_DEATH_A:NEED_MORE_HINTS_OR_SUDDEN_DEATH_B;
}
public static Phase waitingForOneMore(char player) {
return (player == 'A')?WAITING_FOR_ONE_MORE_A:WAITING_FOR_ONE_MORE_B;
}
public boolean isWaitingForOneMore() {
return this == WAITING_FOR_ONE_MORE_A || this == WAITING_FOR_ONE_MORE_B;
}
public boolean canPlay() {
return !this.isGameEnded() && !this.isLocked();
}
public boolean isNeedMoreHints() {
return this == NEED_MORE_HINTS_A || this == NEED_MORE_HINTS_B;
}
public boolean isNeedMoreHintsOrSuddenDeath() {
return this == NEED_MORE_HINTS_OR_SUDDEN_DEATH_A || this == NEED_MORE_HINTS_OR_SUDDEN_DEATH_B;
}
}
@@ -0,0 +1,156 @@
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 java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.bernard.nodecames.model.Card.Color;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Value;
@Getter
@AllArgsConstructor
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 Set<Integer> BASE_HINTWORDCOUNT = Set.of(
0,1,2,3,4,5,6,7,8,9,-1
);
public static final GridConfig CODENAMES_CLASSICAL = new GridConfig(
9, true, true, CLASSICAL25, true,
true, true, 9, BASE_HINTWORDCOUNT, true,
Integer.MAX_VALUE, true, true,
true, false);
public static final GridConfig ZERO = new GridConfig(
9, false, false, CLASSICAL25, true,
true, false, 1, Set.of(1), false,
3, false, false,
false, false);
// Immutable options
// Maximum of maximom number of hints
// This is to trigger end of game
private int maxMaxHint;
private boolean structuredGrid;
private boolean anyoneStarts;
private List<CardColors> wordDistribution;
private boolean maybeSuddenDeath; //TODO
private boolean maybeOneMoreGuess; //TODO
// If onlyOneGreenGreen is false and having it true would have meant we won,
// prevend death and enter WIN_IF_GREEN_GREEN state, else just die
private boolean onlyOneGreenGreenPreventDeath;
// Mutable options
private int maxHint;
private Set<Integer> availableHintWordCount;
private boolean oneMoreGuess;
private int hintMaxLength;
private boolean endGuessing;
private boolean onlyOneGreenGreen;
private boolean suddenDeath;
private boolean hintsInARow;
public int getWordCount() {
return wordDistribution.size();
}
public void set(MutableOption opt, Object value) {
try{
switch(opt) {
case MAX_HINT:
this.maxHint = (Integer) value;
return;
case AVAILABLE_HINT_WORD_COUNT:
// We check value is a set of integers
this.availableHintWordCount = ((Set<?>)value).stream().map(i -> (Integer)i).collect(Collectors.toUnmodifiableSet());
return;
case ONE_MORE_GUESS:
this.oneMoreGuess = (Boolean) value;
return;
case HINT_MAX_LENGTH:
this.hintMaxLength = (Integer) value;
return;
case END_GUESSING:
this.endGuessing = (Boolean) value;
return;
case ONLY_ONE_GREEN_GREEN:
this.onlyOneGreenGreen = (Boolean) value;
return;
case SUDDEN_DEATH:
this.suddenDeath = (Boolean) value;
return;
case HINTS_IN_A_ROW:
this.hintsInARow = (Boolean) value;
return;
}
} catch (ClassCastException e) {
throw new IllegalArgumentException("Given value does not have the right type: "+opt.getDataClass().toGenericString(),e);
}
}
public Map<MutableOption, Object> getMutableOptions() {
return Map.of(
MutableOption.MAX_HINT, this.getMaxHint(),
MutableOption.AVAILABLE_HINT_WORD_COUNT, this.getAvailableHintWordCount(),
MutableOption.ONE_MORE_GUESS, this.isOneMoreGuess(),
MutableOption.HINT_MAX_LENGTH, this.getHintMaxLength(),
MutableOption.END_GUESSING, this.isEndGuessing(),
MutableOption.ONLY_ONE_GREEN_GREEN, this.isOnlyOneGreenGreen(),
MutableOption.SUDDEN_DEATH, this.isSuddenDeath(),
MutableOption.HINTS_IN_A_ROW, this.isHintsInARow()
);
}
public static final boolean isGenericallyValidHintWordCount(int i) {
return 0 <= i || i == -1;
}
@Value(staticConstructor = "of")
public static final class CardColors {
Color faceA;
Color faceB;
public static final CardColors WW = CardColors.of(Color.WHITE, Color.WHITE);
public static final CardColors WB = CardColors.of(Color.WHITE, Color.BLACK);
public static final CardColors BW = CardColors.of(Color.BLACK, Color.WHITE);
public static final CardColors WG = CardColors.of(Color.WHITE, Color.GREEN);
public static final CardColors GW = CardColors.of(Color.GREEN, Color.WHITE);
public static final CardColors GG = CardColors.of(Color.GREEN, Color.GREEN);
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);
}
@AllArgsConstructor
@Getter
public enum MutableOption {
MAX_HINT(Integer.class),
AVAILABLE_HINT_WORD_COUNT(Set.class),
ONE_MORE_GUESS(Boolean.class),
HINT_MAX_LENGTH(Integer.class),
END_GUESSING(Boolean.class),
ONLY_ONE_GREEN_GREEN(Boolean.class),
SUDDEN_DEATH(Boolean.class),
HINTS_IN_A_ROW(Boolean.class);
Class<?> dataClass;
}
}
@@ -10,8 +10,8 @@ public class Hint {
String word;
int wordCount;
public boolean canStillGuess(int guessCount) {
return (wordCount == -1) || (guessCount < (wordCount + 1));
public boolean canStillGuess(int guessCount, boolean oneMoreGuess) {
return (wordCount == -1) || (guessCount < (wordCount + (oneMoreGuess?1:0)));
}
}
+107 -15
View File
@@ -3,7 +3,14 @@ var player = '_'
var roomId = '_'
var wsHeaders = {}
var cards = []
var gc = {}
var currentHint = null
var currentHintCount = -1
var currentGuessCount = -1
var phase = {}
// This variable is here to check for updates to availableHintWordCount, as updating it is a bit expensive
var registeredAvailableHintWordCountInvalid = true
function getRoomId() {
const urlRegex = /\/room\/([a-f0-9-]{36})\/grid$/
const res = urlRegex.exec(window.location.href)
@@ -14,39 +21,73 @@ function getRoomId() {
/*
* Update html to match state
* This is idempotent
*/
function updatePhase(phase) {
function updatePhase() {
$('#hint-counter-num').text(currentHintCount)
$('#hint-counter-den').text(gc["max-hint"])
if(currentHint) {
$('#hint-text').text(currentHint['word']+" in "+currentHint['wordCount'])
$('#guess-counter-num').text(currentGuessCount)
if(gc['one-more-guess']) {
$('#guess-counter-den').text(currentHint['wordCount'] + "(+1)")
} else {
$('#guess-counter-den').text(currentHint['wordCount'])
}
}
if(registeredAvailableHintWordCountInvalid) {
$('#submit-hint-wordcount').empty()
for (let i = 0; i < gc['available-hint-word-count'].length; i++) {
const e = gc['available-hint-word-count'][i];
$('#submit-hint-wordcount').append($(`<option value="${e}">${e}</option>`))
}
registeredAvailableHintWordCountInvalid = false
}
$('#submit-hint-text').attr('maxlength', gc['hint-max-length'])
p = player.toLowerCase()
o = (player=='A')?'b':'a'
if(phase[p+'-guessing']) {
$('.card-button').css('visibility', 'visible')
$('.card-button.untouchable').css('visibility', 'hidden')
$('#end-guessing-button').show()
} else {
$('.card-button').css('visibility', 'hidden')
$('#end-guessing-button').hide()
}
if(phase['name'] == 'need_more_hints_a' || phase['name'] == 'need_more_hints_b') {
$('#enter-sudden-death-button').show()
} else {
$('#enter-sudden-death-button').hide()
}
if((phase['name'] == 'waiting_for_one_more_'+p) ||
(phase[p+'-guessing'] &&
(gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['wordCount'])))) {
$('#end-guessing-button').show()
} else {
$('#end-guessing-button').hide()
}
if(phase[p+'-guessing'] || phase[o+'-guessing']) {
$('#hint-text').show()
$('#guess-counter').show()
} else {
$('#hint-text').hide()
$('#guess-counter').hide()
}
if(phase[p+'-hinting']) {
$('#submit-hint').show()
} else {
$('#submit-hint').hide()
}
if(!phase[p+'-guessing'] & !phase[p+'-hinting']) {
if(!phase[p+'-guessing'] && !phase[p+'-hinting']) {
$('#other-playing').show()
} else {
$('#other-playing').hide()
}
}
function setHint(hint, wordCount) {
$('#hint-text').text(hint+" in "+wordCount)
}
function createCard(i) {
html = `
<li class="card ${cards[i].color}" id="card-${i}">
@@ -75,8 +116,10 @@ function updateCard(i) {
} else {
$(`#card-${i}-a-public`).css('visibility', 'hidden')
}
cardDone = (card['revealed-a'] && ((player=='A')?card.otherColor:card.color == "green")) ||
(card['revealed-b'] && ((player=='B')?card.otherColor:card.color == "green")) ||
cardDone = (
gc["only-one-green-green"] &&
((card['revealed-a'] && ((player=='A')?card.otherColor:card.color == "green")) ||
(card['revealed-b'] && ((player=='B')?card.otherColor:card.color == "green")))) ||
(card['revealed-a'] && card['revealed-b']);
if(cardDone) $(`#card-${i}`).addClass('card-done')
cantTouch = cardDone ||
@@ -146,19 +189,29 @@ function addEvent(e) {
switch(e.type) {
case "hint":
addHintEvent(e.issuer, e.word, e.wordCount)
setHint(e.word, e.wordCount)
currentHint = {word: e.word, wordCount: e.wordCount}
currentHintCount += 1
currentGuessCount = 0
updatePhase()
break
case "guess":
addPointEvent(e.issuer, e['card-index'], e.color)
currentGuessCount += 1
break
case "end_guessing":
addEndGuessingEvent(e.issuer, e.manual)
currentHint = null
currentGuessCount = 0
break
case "game_end":
addEndGameEvent(e.issuer, e.win)
currentHint = null
currentGuessCount = 0
break
case "sudden_death":
addSuddenDeathEvent()
currentHint = null
currentGuessCount = 0
break
}
}
@@ -167,23 +220,29 @@ function addEvent(e) {
/*
* Listeners
*/
function initGame(data) {
cards = data['cards']
gc = data.config
cards = data.cards
$('#cards-list').empty()
for(var i = 0; i<cards.length; i++) {
createCard(i)
updateCard(i)
}
updatePhase(data['phase'])
phase = data['phase']
events = data.events
for(var i = 0; i<events.length; i++) {
e = events[i]
addEvent(e)
}
if(data['current-hint']) {
setHint(data['current-hint'].word, data['current-hint'].wordCount)
currentHint = data['current-hint']
}
currentGuessCount = data['current-guess-count']
currentHintCount = data['used-hints']
updatePhase()
}
function selectPlayer(e) {
@@ -193,6 +252,7 @@ function selectPlayer(e) {
"player": player
}
$('#player-name').text(player)
$('#player-selector').hide()
$('#game-panel').show()
$('#side-panel').show()
@@ -208,6 +268,7 @@ function selectPlayer(e) {
socket.subscribe('/topic/new-phase', onNewPhase);
socket.subscribe('/topic/new-event', onNewEvent);
socket.subscribe('/topic/update-card/' + player.toLowerCase(), onUpdateCard);
socket.subscribe('/topic/config-change', onConfigChange)
});
}
@@ -228,9 +289,12 @@ function pointCard(e) {
function endGuessing(e) {
socket.send('/app/end-guessing', wsHeaders, "")
}
function enterSuddenDeath(e) {
socket.send('/app/enter-sudden-death', wsHeaders, "")
}
function onNewPhase(m) {
phase = JSON.parse(m.body)
updatePhase(phase)
updatePhase()
}
function onNewEvent(m) {
e = JSON.parse(m.body)
@@ -244,11 +308,39 @@ function onUpdateCard(m) {
updateCard(i)
}
function onConfigChange(m) {
data = JSON.parse(m.body)
for(const [key, value] of Object.entries(data)) {
console.log("New config option : ", key, "=", value)
gc[key] = value
switch(key) {
case "available-hint-word-count":
registeredAvailableHintWordCountInvalid = true
updatePhase()
break
case "only-one-green-green":
for(var i = 0; i < cards.length; i++)
updateCard(i)
case "max-hint":
case "one-more-guess":
case "hint-max-length":
case "end-guessing":
case "sudden-death":
updatePhase()
break
case "hints-in-a-row":
// All the changes are made with phases
break;
}
}
}
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()
@@ -0,0 +1,38 @@
<!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>
<form method="POST"
action="" th:object="${gridConfig}">
<ul th:if="${#fields.hasErrors('global')}">
<li th:each="error : ${#fields.errors('global')}" th:text="${error}">error</li>
</ul>
<label th:field="maxHint" for="field-max-hint">Maximum number of hints</label>
<input th:field="*{maxHint}" id="field-max-hint"/> <br/>
<label th:field="availableHintWordCountStr" for="field-available-hint-word-count">Available hint's word counts<label>
<input th:field="*{availableHintWordCountStr}" id="field-available-hint-word-count"/> <br/>
<label th:field="oneMoreGuess" for="field-one-more-guess">Can do one more guess than hint word count</label>
<input type="checkbox" th:field="*{oneMoreGuess}" id="field-one-more-guess"/> <br/>
<label th:field="hintMaxLength" for="field-hint-max-length">Maximum length of hint words</label>
<input th:field="*{hintMaxLength}" id="field-hint-max-length"/> <br/>
<label th:field="endGuessing" for="field-end-guessing">Can press end guessing before the number of words</label>
<input type="checkbox" th:field="*{endGuessing}" id="field-end-guessing"/> <br/>
<label th:field="onlyOneGreenGreen" for="field-only-one-green-green">Green-Green cards only have to be pressed by one side</label>
<input type="checkbox" th:field="*{onlyOneGreenGreen}" id="field-only-one-green-green"/> <br/>
<label th:field="suddenDeath" for="field-sudden-death">Before losing beccause no more hints, goes to sudden death</label>
<input type="checkbox" th:field="*{suddenDeath}" id="field-sudden-death"/> <br/>
<label th:field="hintsInARow" for="field-hints-in-a-row">Someone can propose two hints in a row</label>
<input type="checkbox" th:field="*{hintsInARow}" id="field-hints-in-a-row"/> <br/>
<input type="submit" value="Change config" />
</form>
</main>
</body>
</html>
+12 -10
View File
@@ -27,24 +27,26 @@
<div id="submit-hint" class="centered-bar">
<input type="text" id="submit-hint-text"/>
<select id="submit-hint-wordcount">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
</select>
<button>Submit Hint</button>
</div>
<div id="end-guessing" class="centered-bar">
<button id="end-guessing-button">End Guessing</button>
</div>
<div id="enter-sudden-death" class="centered-bar">
<button id="enter-sudden-death-button">Enter Sudden Death</button>
</div>
</div>
<div id="side-panel">
<div id="identity-pane">
You are player <span id="player-name">?</span>
</div>
<div id="hint-counter">
Hint <span id="hint-counter-num">?</span>/<span id="hint-counter-den">?</span>
</div>
<div id="guess-counter">
Guess <span id="guess-counter-num">?</span>/<span id="guess-counter-den">?</span>
</div>
<div id="other-playing" class="centered-bar">
Waiting for the other side to play
</div>