Observer pattern & Archipelago

This commit is contained in:
Mysaa Java
2026-09-29 21:32:22 +02:00
parent 0bb880d434
commit 466da86969
22 changed files with 695 additions and 118 deletions
@@ -1,11 +1,16 @@
package com.bernard.nodecames;
import java.net.URISyntaxException;
import java.util.Comparator;
import java.util.List;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.view.RedirectView;
import com.bernard.nodecames.frontend.GridConfigEdit;
import com.bernard.nodecames.game.ArchipelagoManager;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
@@ -33,6 +39,7 @@ import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.GridRepository;
import com.bernard.nodecames.repository.WorldRepository;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@@ -41,6 +48,7 @@ public class HttpController {
private final GridManager gm;
private final GameManager wm;
private final ArchipelagoManager am;
private final GridRepository gridRepository;
private final GameRepository gRepository;
private final WorldRepository wRepository;
@@ -78,6 +86,7 @@ public class HttpController {
model.addAttribute("gridConfig", GridConfigEdit.of(gc));
return "grid-config";
}
@PostMapping("/room/{id}/config")
public Object setGridConfig(@PathVariable("id") String id,
@ModelAttribute("gridConfig") GridConfigEdit gce,
@@ -138,6 +147,38 @@ public class HttpController {
return "map";
}
@GetMapping("/game/{id}/archipelago")
public Object gridConfig(@PathVariable("id") String id, @RequestParam("address") String address,
@RequestParam("slot") String slot, Model model) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
try {
CompletableFuture<ConnectionResult> connectionResultFuture = am.openClient(g.get(), address, slot);
ConnectionResult connectionResult = connectionResultFuture.get(5, TimeUnit.SECONDS);
if (connectionResult != ConnectionResult.Success) {
return new ResponseEntity<>("Could not connect to archipelago server: "+connectionResult.name(), HttpStatus.NOT_FOUND);
} else {
return new ResponseEntity<>("Connection successful", HttpStatus.OK);
}
} catch (TimeoutException e) {
return new ResponseEntity<>("Timed out connecting to archipelago", HttpStatus.REQUEST_TIMEOUT);
} catch (URISyntaxException e) {
return new ResponseEntity<>("URI malformed : "+e.getMessage(), HttpStatus.BAD_REQUEST);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
return new ResponseEntity<>("Interrupted", HttpStatus.INTERNAL_SERVER_ERROR);
} catch (ExecutionException e) {
e.printStackTrace();
return new ResponseEntity<>("ExecutionException while connecting to Archipelago : "+e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/game/{id}/grid/{world}")
public Object gridRedirect(@PathVariable("id") String id, @PathVariable("world") String world, @RequestParam("player") String player) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
@@ -148,7 +189,12 @@ public class HttpController {
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Grid grid = wm.getGrid(g.get(), w.get());
Grid grid;
try {
grid = wm.getGrid(g.get(), w.get());
} catch (IllegalGameActionException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
return new RedirectView("/room/"+grid.getId()+"/grid" + "?player="+player);
}
@@ -162,7 +208,11 @@ public class HttpController {
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Grid newGrid = wm.resetGrid(g.get(), w.get());
return new RedirectView("/room/"+newGrid.getId()+"/grid" + "?player="+player);
try {
Grid newGrid = wm.resetGrid(g.get(), w.get());
return new RedirectView("/room/"+newGrid.getId()+"/grid" + "?player="+player);
} catch (IllegalGameActionException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
}
@@ -1,7 +1,9 @@
package com.bernard.nodecames.frontend;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.http.HttpStatus;
@@ -17,14 +19,20 @@ import org.springframework.web.bind.annotation.PathVariable;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.repository.GridRepository;
import lombok.AllArgsConstructor;
import tools.jackson.databind.JsonNode;
@Controller
@AllArgsConstructor
public class WebSocketController {
private final GridManager gm;
@@ -32,49 +40,19 @@ public class WebSocketController {
private final JsonDataService json;
private final GridRepository gRepository;
public WebSocketController(GridManager gm, SimpMessagingTemplate template, JsonDataService json, GridRepository gRepository) {
this.gm = gm;
this.template = template;
this.json = json;
this.gRepository = gRepository;
gm.setWsc(this);
}
public void onNewEvent(Grid g, GameEvent ge) {
this.template.convertAndSend("/topic/new-event",
json.eventData(ge)
);
}
public void onNewPhase(Grid g) {
this.template.convertAndSend(
"/topic/new-phase",
json.phaseData(g.getPhase())
);
}
public void onConfigChange(Grid g, MutableOption cc, Object data) {
this.template.convertAndSend("/topic/config-change",
json.gridConfigUpdateData(cc, data)
);
}
public void publishCardUpdateToPlayer(Grid g, Card c, char player) {
this.template.convertAndSend(
"/topic/update-card/" + Character.toLowerCase(player),
json.cardData(c, player),
Map.of("cardIndex", c.getIndex())
);
}
public void publishCardUpdate(Grid g, Card card) {
publishCardUpdateToPlayer(g, card, 'A');
publishCardUpdateToPlayer(g, card, 'B');
Set<UUID> observed = new HashSet<>();
private void registerObserver(Grid g) {
if(!observed.contains(g.getId())) {
observed.add(g.getId());
gm.addObserver(g, new GObserver(g));
}
}
public void publishError(char issuer, IllegalGameActionException e) {
//TODO open error channel
}
//TODO We should do this with websocket protocol
@GetMapping("/room/{id}/game/{player}")
public Object grid(@PathVariable("id") String gridId, @PathVariable("player") String playerStr) {
char player;
@@ -86,14 +64,19 @@ public class WebSocketController {
throw new IllegalArgumentException("Unknown player type");
Optional<Grid> g = gRepository.findById(UUID.fromString(gridId));
if(g.isPresent())
return new ResponseEntity<>(json.gridData(g.get(), player), HttpStatus.OK);
else
if(g.isPresent()){
Grid grid = g.get();
registerObserver(grid);
return new ResponseEntity<>(json.gridData(grid, player), HttpStatus.OK);
} else
return new ResponseEntity<>("Could not find grid with given ID",HttpStatus.NOT_FOUND);
}
@MessageMapping("submit-hint")
public void submitHint(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
//TODO handle websockets per grid
@MessageMapping("/submit-hint")
public void submitHint(@Header("room") String roomId,
@Header("player") String player,
@Payload JsonNode content) {
String hint = content.asObject().get("hint").stringValue();
int wordCount = content.asObject().get("hintWordCount").intValue();
@@ -101,7 +84,6 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.proposeHint(g.get(), player.charAt(0), hint, wordCount);
} catch (IllegalGameActionException e) {
@@ -118,10 +100,8 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.pointCard(g.get(), player.charAt(0), cardIndex);
publishCardUpdate(g.get(), gm.getCard(g.get(), cardIndex));
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -134,7 +114,6 @@ public class WebSocketController {
if(!g.isPresent())
// 404
return;
try {
gm.endGuessing(g.get(), player.charAt(0));
} catch (IllegalGameActionException e) {
@@ -156,4 +135,56 @@ public class WebSocketController {
e.printStackTrace();
}
}
@AllArgsConstructor
private class GObserver implements GridObserver {
Grid grid;
public void publishCardUpdateToPlayer(Card c, char player) {
template.convertAndSend(
"/topic/%s/update-card/%s".formatted(grid.getId(), Character.toLowerCase(player)),
json.cardData(c, player),
Map.of("cardIndex", c.getIndex())
);
}
public void publishCardUpdate(Card card) {
publishCardUpdateToPlayer(card, 'A');
publishCardUpdateToPlayer(card, 'B');
}
@Override
public void onPointCard(char player, Card card, Color color) {
publishCardUpdate(card);
}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGridEnd(boolean won) {}
@Override
public void onGameEvent(GameEvent ge) {
template.convertAndSend(
"/topic/%s/new-event".formatted(grid.getId()),
json.eventData(ge)
);
}
@Override
public void onNewPhase(Phase p) {
template.convertAndSend(
"/topic/%s/new-phase".formatted(grid.getId()),
json.phaseData(p)
);
}
@Override
public void onConfigChange(MutableOption cc, Object data) {
template.convertAndSend(
"/topic/%s/config-change".formatted(grid.getId()),
json.gridConfigUpdateData(cc, data)
);
}
}
}
@@ -0,0 +1,141 @@
package com.bernard.nodecames.game;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import io.github.archipelagomw.APResult.ResultCode;
import io.github.archipelagomw.Client;
import io.github.archipelagomw.ClientStatus;
import io.github.archipelagomw.events.ArchipelagoEventListener;
import io.github.archipelagomw.events.ConnectionResultEvent;
import io.github.archipelagomw.events.ReceiveItemEvent;
import io.github.archipelagomw.flags.ItemsHandling;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.Getter;
public class ArchipelagoClient extends Client {
Game game;
GameManager gm;
private static final Logger log = LoggerFactory.getLogger(ArchipelagoClient.class);
public ArchipelagoClient(Game game, GameManager gm, String slot) {
super();
this.gm = gm;
this.game = game;
this.setGame("nodecames");
this.setName(slot);
client.setItemsHandlingFlags(ItemsHandling.SEND_ITEMS | ItemsHandling.SEND_OWN_ITEMS);
this.getEventManager().registerListener(this);
}
@ArchipelagoEventListener
public void onReceiveItem(ReceiveItemEvent e) throws IllegalGameActionException {
log.debug("Received item : {}", e.getItemName());
switch(e.getItemName()) {
case "progressive_hint_count":
gm.unlockMoreHintCount(this.game);
break;
case "progressive_hint_word_length":
gm.unlockMoreHintWordLengthCount(this.game);
break;
case "progressive_hint_word_count":
gm.unlockMoreHintWordCount(this.game);
break;
case "zero_hint":
gm.unlockHintWordCount(this.game, 0);
break;
case "infinity_hint":
gm.unlockHintWordCount(this.game, -1);
break;
case "one_more_guess":
gm.unlockMutable(game, MutableOption.ONE_MORE_GUESS, true);
break;
case "end_guessing":
gm.unlockMutable(game, MutableOption.END_GUESSING, true);
break;
case "only_one_green_green":
gm.unlockMutable(game, MutableOption.ONLY_ONE_GREEN_GREEN, true);
break;
case "sudden_death":
gm.unlockMutable(game, MutableOption.SUDDEN_DEATH, true);
break;
case "structured_grid":
gm.unlockStructuredGrid(game);
break;
case "hints_in_a_row":
gm.unlockMutable(game, MutableOption.HINTS_IN_A_ROW, true);
break;
case "anyone_starts":
gm.unlockAnyoneCanStart(game);
break;
case "filler":
// We do nothing if we receive a filler item
break;
default:
if (e.getItemName().startsWith("unlock_world_")) {
int worldIndex = Integer.parseInt(e.getItemName().substring("unlock_world_".length()))-1;
String worldName = "%d-%d".formatted((worldIndex/3)+1, (worldIndex%3)+1);
gm.unlockWorld(game, worldName);
} else {
throw new IllegalArgumentException("Received unknown item : "+e.getItemName());
}
}
}
@Getter
private CompletableFuture<ConnectionResult> connectionResult = new CompletableFuture<>();
@ArchipelagoEventListener
public void onConnectResult(ConnectionResultEvent e) {
connectionResult.complete(e.getResult());
if(e.getResult() != ConnectionResult.Success) {
throw new IllegalStateException("Could not connect to archipelago : %s".formatted(e.getResult().name()));
} else {
if(this.setGameState(ClientStatus.CLIENT_PLAYING).getCode() != ResultCode.SUCCESS)
throw new IllegalStateException("Colud not set state to playing");
else {
client.sendChat("Hello, here is Nodecames AP client, nice to meet you");
}
}
}
public static int[] GREEN_COUNT_PER_WORLD = new int[]{6, 9, 9, 12, 16, 16, 20, 25, 25, 30, 36, 36};
public static String[] WORLDS = new String[]{"1-1","1-2","1-3","2-1","2-2","2-3","3-1","3-2","3-3","4-1","4-2","4-3"};
public void checkWordInWorld(String worldName, int wordIndex) {
//TODO binarySearch is okay for now, but we'll have to remove this at some point
int worldIndex = Arrays.binarySearch(WORLDS, 0, WORLDS.length, worldName);
if(wordIndex>GREEN_COUNT_PER_WORLD[wordIndex]-1)return;//We don't have that many checks
//TODO this should be stored, not computed, in archipelagoconfig
long locationId = IntStream.range(0, worldIndex).map(w -> GREEN_COUNT_PER_WORLD[w]).sum()+1+wordIndex;
this.checkLocation(locationId);
}
public void checkWorldComplete(String worldName) {
//TODO binarySearch is okay for now, but we'll have to remove this at some point
int worldIndex = Arrays.binarySearch(WORLDS, 0, WORLDS.length, worldName);
long locationId = IntStream.range(0, worldIndex+1).map(w -> GREEN_COUNT_PER_WORLD[w]).sum()+1;
this.checkLocation(locationId);
}
@Override
public void onError(Exception e) {
//TODO make a real handler
throw new IllegalStateException("Archipelago error :", e);
}
@Override
public void onClose(String Reason, int attemptingReconnect) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'onClose'");
}
}
@@ -0,0 +1,92 @@
package com.bernard.nodecames.game;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.observer.WorldObserver;
import com.bernard.nodecames.repository.CardRepository;
import com.bernard.nodecames.repository.WorldRepository;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class ArchipelagoManager {
Map<UUID, ArchipelagoClient> clients = new HashMap<>();
GameManager gm;
CardRepository cRepository;
WorldRepository wRepository;
private static final Logger log = LoggerFactory.getLogger(ArchipelagoManager.class);
public CompletableFuture<ConnectionResult> openClient(Game g, String address, String slot) throws URISyntaxException{
ArchipelagoClient client = new ArchipelagoClient(g, gm, slot);
client.connect(address);
clients.put(g.getId(), client);
Set<World> worlds = wRepository.findByGame(g);
log.debug("Registering observer for {} worlds", worlds.size());
for(World w : worlds) {
gm.addObserver(w, new WObserver(w, client));
}
return client.getConnectionResult();
}
@AllArgsConstructor
private class WObserver implements WorldObserver {
private World world;
ArchipelagoClient client;
@Override
public void onPointCard(char player, Card card, Color color) {
// We compute the count of green cards
//TODO right now, we only do the strategy of green green cards once
//TODO decide wether we or they should have the logic of wether to send the ith check
Card[] cards = cRepository.findByGridOrderById_Index(world.getCurrentGrid());
int greenCount = (int)Arrays.stream(cards).filter(c ->
(c.isColorAPublic() && c.getColorA() == Color.GREEN) ||
(c.isColorBPublic() && c.getColorB() == Color.GREEN)
).count();
log.info("Pointed card, {}th", greenCount);
client.checkWordInWorld(world.getName(), greenCount);
}
@Override
public void onGridEnd(boolean win) {
if(win) {
client.checkWorldComplete(world.getName());
}
}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGameEvent(GameEvent ge) {}
@Override
public void onNewPhase(Phase p) {}
@Override
public void onConfigChange(MutableOption cc, Object data) {}
}
}
@@ -2,38 +2,75 @@ package com.bernard.nodecames.game;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.model.World.WorldId;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.observer.WorldObserver;
import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.WorldRepository;
import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class GameManager {
// Reverse map allowing to pass grid events to the game
Map<UUID, World> currentGrids = new HashMap<>();
private final GridManager gm;
private final GameRepository gRepository;
private final WorldRepository wRepository;
public GameManager(GridManager gm, GameRepository gRepository, WorldRepository wRepository) {
this.gm = gm;
this.gRepository = gRepository;
this.wRepository = wRepository;
gm.setWm(this);
//TODO try putting "World" here instead of WorldId
private Map<WorldId, Set<WorldObserver>> observers = new HashMap<>();
public void addObserver(World w, WorldObserver observer) {
if(!observers.containsKey(w.getId()))
observers.put(w.getId(), new CopyOnWriteArraySet<>());
observers.get(w.getId()).add(observer);
if(w.getCurrentGrid() != null)
gm.addObserver(w.getCurrentGrid(), observer);
}
private void forEachObserver(World w, Consumer<WorldObserver> action) {
observers.getOrDefault(w.getId(), Set.of()).forEach(action);
}
/*
* In case of server restart, registers to GridManager observers
*/
@PostConstruct
private void registerRunningObservers() {
for(World w : wRepository.findAll()) {
if(w.getCurrentGrid() != null && w.getCurrentGrid().getWon() != null) {
registerObserver(w, w.getCurrentGrid());
}
}
}
private void registerObserver(World w, Grid g) {
gm.addObserver(g, new GObserver(g, w));
forEachObserver(w, o -> gm.addObserver(g, o));
}
public Game createGame(String name) {
@@ -42,10 +79,9 @@ public class GameManager {
Game g = new Game(uuid, name);
gRepository.save(g);
for (Entry<String, GridConfig> wConfig : Game.DEFAULT_WORLDS_CONFIG.entrySet()) {
World w = new World(g, wConfig.getKey(), wConfig.getValue(), null, false);
World w = new World(g, wConfig.getKey(), wConfig.getValue(), null, false, true);
wRepository.save(w);
}
return g;
}
@@ -57,22 +93,26 @@ public class GameManager {
return wRepository.findByGame(g);
}
private Grid newGrid(Game g, World w) {
private Grid newGrid(Game g, World w) throws IllegalGameActionException {
if(w.isLocked())
//TODO separate IllegaleGameAction and IllegalGridAction
throw new IllegalGameActionException(null, "Cannot create a grid in a locked world");
GridConfig gc = w.getInitialConfig();
//TODO seed random
Grid grid = gm.newGrid(w, gc, new Random());
w.setCurrentGrid(grid);
wRepository.save(w);
currentGrids.put(grid.getId(), w);
registerObserver(w, grid);
return grid;
}
public Grid resetGrid(Game g, World world) {
public Grid resetGrid(Game g, World world) throws IllegalGameActionException{
return newGrid(g, world);
}
public Grid getGrid(Game g, World w) {
public Grid getGrid(Game g, World w) throws IllegalGameActionException{
if(w.getCurrentGrid() != null) {
return w.getCurrentGrid();
} else {
@@ -80,11 +120,100 @@ public class GameManager {
}
}
public void onGameWon(Grid g) {
World w = currentGrids.get(g.getId());
if(w != null) {
w.setWon(true);
/**
* UPDATE GAME CONFIG
*/
public void unlockMutable(Game g, MutableOption opt, Object o) throws IllegalGameActionException {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.set(opt, o);
w.setInitialConfig(initialConfig);
wRepository.save(w);
Grid grid = w.getCurrentGrid();
if(grid != null) {
gm.unlock(grid, opt, o);
}
}
}
public void unlockStructuredGrid(Game g) {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.setStructuredGrid(true);
w.setInitialConfig(initialConfig);
wRepository.save(w);
}
}
public void unlockAnyoneCanStart(Game g) {
Set<World> worlds = wRepository.findByGame(g);
for(World w : worlds) {
GridConfig initialConfig = w.getInitialConfig();
initialConfig.setAnyoneStarts(true);
w.setInitialConfig(initialConfig);
wRepository.save(w);
}
}
public void unlockWorld(Game g, String name) throws IllegalGameActionException {
Optional<World> ow = wRepository.findByGameAndId_Name(g, name);
if(!ow.isPresent())
throw new IllegalGameActionException(null, "Could not find world with name %s".formatted(name));
World w = ow.get();
w.setLocked(false);
wRepository.save(w);
}
public void unlockMoreHintCount(Game game) throws IllegalGameActionException {
//TODO Store current hint count elsewhere
int currentHintCount = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getMaxHint();
unlockMutable(game, MutableOption.MAX_HINT, currentHintCount+1);
}
public void unlockMoreHintWordLengthCount(Game game) throws IllegalGameActionException {
//TODO Store current hint word length elsewhere
int currentHintWordLength = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getHintMaxLength();
unlockMutable(game, MutableOption.HINT_MAX_LENGTH, currentHintWordLength+1);
}
public void unlockMoreHintWordCount(Game game) throws IllegalGameActionException {
//TODO Store current hint word count elsewhere
int currentMaxWordCount = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getAvailableHintWordCount().stream().mapToInt(i -> i).max().getAsInt();
unlockHintWordCount(game, currentMaxWordCount+1);
}
public void unlockHintWordCount(Game game, int i) throws IllegalGameActionException {
Set<Integer> currentWordCounts = wRepository.findByGame(game).stream().findAny().get().getInitialConfig().getAvailableHintWordCount();
if(currentWordCounts.contains(i))
throw new IllegalGameActionException(null, "Word count already unlocked : %d".formatted(i));
Set<Integer> newWordCounts = new HashSet<>(currentWordCounts);
newWordCounts.add(i);
unlockMutable(game, MutableOption.AVAILABLE_HINT_WORD_COUNT, newWordCounts);
}
@AllArgsConstructor
private class GObserver implements GridObserver {
private Grid grid;
private World world;
@Override
public void onGridEnd(boolean win) {
if(win) {
world.setWon(true);
wRepository.save(world);
}
}
@Override
public void onPointCard(char player, Card card, Color color) {}
@Override
public void onSubmitHint(char player, Hint hint) {}
@Override
public void onGameEvent(GameEvent ge) {}
@Override
public void onNewPhase(Phase p) {}
@Override
public void onConfigChange(MutableOption cc, Object data) {}
}
}
@@ -2,11 +2,15 @@ package com.bernard.nodecames.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -14,10 +18,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.frontend.WebSocketController;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig;
@@ -25,21 +28,18 @@ import com.bernard.nodecames.model.GridConfig.CardColors;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.observer.GridObserver;
import com.bernard.nodecames.repository.CardRepository;
import com.bernard.nodecames.repository.GridRepository;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Service
@RequiredArgsConstructor
public class GridManager {
@Setter
private WebSocketController wsc = null;
@Setter
private GameManager wm = null;
private Map<UUID, Set<GridObserver>> observers = new HashMap<>();
private final GridRepository gRepository;
private final CardRepository cRepository;
@@ -48,11 +48,22 @@ public class GridManager {
private static final Logger log = LoggerFactory.getLogger(GridManager.class);
public void addObserver(Grid g, GridObserver observer) {
if(!observers.containsKey(g.getId()))
observers.put(g.getId(), new CopyOnWriteArraySet<>());
observers.get(g.getId()).add(observer);
}
private void forEachObserver(Grid g, Consumer<GridObserver> action) {
log.debug("The current observers are : {}", observers.get(g.getId()));
observers.getOrDefault(g.getId(), Set.of()).forEach(action);
}
private void newGameEvent(Grid g, GameEvent ge) {
log.debug("New game event : {}", ge);
g.newGameEvent(ge);
gRepository.save(g);
wsc.onNewEvent(g, ge);
forEachObserver(g, o -> o.onGameEvent(ge));
}
private void setPhase(Grid g, Phase phase) {
@@ -62,33 +73,40 @@ public class GridManager {
g.setCurrentHint(null);
}
gRepository.save(g);
wsc.onNewPhase(g);
forEachObserver(g, o -> o.onNewPhase(phase));
}
private void setWon(Grid g, boolean won) {
log.debug("Game won: {}", won);
g.setWonOpt(Optional.of(won));
gRepository.save(g);
forEachObserver(g, o -> o.onGridEnd(won));
// Grid will no longer be updated, therefore we stop observing it
observers.remove(g.getId());
}
private void setCardPublic(Card c, char player) {
private void setCardPointed(Grid g, Card c, char player, Color col) {
log.debug("Card set public, index {}", c.getIndex());
c.setColorPublic(player, true);
cRepository.save(c);
forEachObserver(c.getGrid(), o -> o.onPointCard(player, c, col));
g.incrementCurrentGuessCount();
gRepository.save(g);
}
private void newHint(Grid g, Hint h) {
private void newHint(Grid g, char player, Hint h) {
log.debug("New hint: {}", h);
g.incrementHintCount();
g.setCurrentHint(h);
g.setCurrentGuessCount(0);
gRepository.save(g);
forEachObserver(g, o -> o.onSubmitHint(player, h));
}
private void changeConfig(Grid g, MutableOption cc, Object data) {
log.debug("Changed config option: {} -> new value: {}", cc.name(), data);
g.getConfig().set(cc, data);
gRepository.save(g);
wsc.onConfigChange(g, cc, data);
forEachObserver(g, o -> o.onConfigChange(cc, data));
}
@@ -169,12 +187,10 @@ public class GridManager {
* GAME ACTIONS *
**********************************/
private void endGame(Grid g, boolean win) {
setWon(g, win);
setPhase(g, Phase.GAME_ENDED);
newGameEvent(g, GameEvent.newGameEndEvent(win));
if(win) {
wm.onGameWon(g);
}
// This must be the last step as it removes observers
setWon(g, win);
}
public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException {
@@ -189,7 +205,7 @@ public class GridManager {
}
Hint h = new Hint(hint, wordCount);
newHint(g, h);
newHint(g, player, h);
newGameEvent(g, GameEvent.newHintEvent(h, player));
setPhase(g, Phase.guessing(other(player)));
}
@@ -264,9 +280,9 @@ public class GridManager {
if(c.isColorPublic(other(player)) || (g.getConfig().isOnlyOneGreenGreen() && c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player)))
throw new IllegalGameActionException(g, "Card has already been revealed");
setCardPublic(c, other(player));
Card.Color guessedColor = c.getColor(other(player));
setCardPointed(g, c, other(player), guessedColor);
newGameEvent(g, GameEvent.newGuessEvent(c.getIndex(), player, c.getColor(other(player))));
g.incrementCurrentGuessCount();
if(guessedColor == Color.BLACK){
@@ -369,7 +385,7 @@ public class GridManager {
}
public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException {
if(newHintCount > g.getConfig().getMaxMaxHint()) {
throw new IllegalGameActionException(g, "Illegal hint count");
throw new IllegalGameActionException(g, "Hint count higher than highest hint count");
}
if(newHintCount <= g.getConfig().getMaxHint()) {
throw new IllegalGameActionException(g, "Cannot lower hint count");
@@ -3,6 +3,7 @@ package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
@@ -10,12 +11,15 @@ import org.hibernate.type.SqlTypes;
import jakarta.annotation.Nullable;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -21,6 +21,7 @@ import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.Value;
@Getter
@@ -50,27 +51,24 @@ public class GridConfig {
0,1,2,3,4,5,6,7,8,9,-1
);
public static final GridConfig BIG_CLASSICAL = new GridConfig(
12, true, true, CUSTOM36, true,
true, true, 12, BASE_HINTWORDCOUNT, true,
12, CUSTOM36, true, true,
true, true, true, 12, BASE_HINTWORDCOUNT, true,
Integer.MAX_VALUE, true, true,
true, false);
public static final GridConfig CODENAMES_CLASSICAL = new GridConfig(
9, true, true, CLASSICAL25, true,
9, CLASSICAL25, true, true, true,
true, true, 9, BASE_HINTWORDCOUNT, true,
Integer.MAX_VALUE, true, true,
true, false);
public static final GridConfig ZERO = new GridConfig(
9, false, false, CLASSICAL25, true,
true, false, 1, Set.of(1), false,
3, false, false,
false, false);
public static final GridConfig ZERO = zeroWithSize(CLASSICAL25);
//TODO this 9 should be config
public static final GridConfig zeroWithSize(Map<CardColors, Integer> colors) {
return new GridConfig(
9, false, false, colors, true,
true, false, 1, Set.of(1), false,
9, colors, true, true,
false, false, false, 1, Set.of(1), false,
3, false, false,
false, false);
}
@@ -79,8 +77,6 @@ public class GridConfig {
// Maximum of maximom number of hints
// This is to trigger end of game
private int maxMaxHint;
private boolean structuredGrid;
private boolean anyoneStarts;
@Convert(converter = WordDistributionConverter.class)
private Map<CardColors, Integer> wordDistribution;
private boolean maybeSuddenDeath; //TODO
@@ -89,6 +85,11 @@ public class GridConfig {
// prevend death and enter WIN_IF_GREEN_GREEN state, else just die
private boolean onlyOneGreenGreenPreventDeath;
// Game mutable options
@Setter
private boolean structuredGrid;
@Setter
private boolean anyoneStarts;
// Mutable options
private int maxHint;
@@ -34,12 +34,15 @@ public class World {
boolean won;
public World(Game g, String name, GridConfig initConfig, Grid currentGrid, boolean won) {
boolean locked;
public World(Game g, String name, GridConfig initConfig, Grid currentGrid, boolean won, boolean locked) {
this.id = new WorldId(g.getId(), name);
this.game = g;
this.initialConfig = initConfig;
this.currentGrid = currentGrid;
this.won = won;
this.locked = locked;
}
public String getName() {
@@ -0,0 +1,20 @@
package com.bernard.nodecames.observer;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.GameEvent;
import com.bernard.nodecames.model.Hint;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Grid.Phase;
import com.bernard.nodecames.model.GridConfig.MutableOption;
public interface GridObserver {
public void onPointCard(char player, Card card, Color color);
public void onSubmitHint(char player, Hint hint);
public void onGridEnd(boolean won);
public void onGameEvent(GameEvent ge);
public void onNewPhase(Phase p);
public void onConfigChange(MutableOption cc, Object data);
}
@@ -0,0 +1,5 @@
package com.bernard.nodecames.observer;
public interface WorldObserver extends GridObserver {
}