Compare commits

..
9 Commits
Author SHA1 Message Date
Mysaa Java d23aedb924 Added logic, can still be locked 2026-10-09 14:37:08 +02:00
Mysaa Java 741f60bb3b World config and inventories 2026-10-08 11:46:28 +02:00
Mysaa Java 466da86969 Observer pattern & Archipelago 2026-09-29 21:32:22 +02:00
Mysaa Java 0bb880d434 Added create grid config option 2026-09-26 02:33:48 +02:00
Mysaa Java fa82900770 Card update was not commited to database 2026-09-26 02:17:42 +02:00
Mysaa Java 89cac6a0c6 Adding first apworld 2026-09-26 02:15:02 +02:00
Mysaa Java 90f3f7afb0 Added ressources in nix package 2026-09-19 02:38:17 +02:00
Mysaa Java 217c98a94a Moved to Hibernate 2026-09-17 14:27:28 +02:00
Mysaa Java 6127508211 Added games 2026-09-12 13:34:24 +02:00
54 changed files with 3119 additions and 634 deletions
+4
View File
@@ -0,0 +1,4 @@
## License
Dictionaries taken from [this project](https://github.com/LandazuriPaul/Codenames)
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation 'org.flywaydb:flyway-core:_'
implementation 'org.flywaydb:flyway-database-postgresql:_'
implementation 'jakarta.validation:jakarta.validation-api:_'
implementation 'io.github.archipelagomw:Java-Client:_'
//TODO use webjars
runtimeOnly 'org.webjars:jquery:_'
+38 -1
View File
@@ -15,12 +15,18 @@
gradle = pkgs.gradle;
version = "1.0";
nodecames-apworld = pkgs.runCommand "nodecames.apworld" {} ''
cp -r ${./src/main/apworld} ./nodecames/
${pkgs.zip}/bin/zip -r $out ./nodecames/
'';
nodecames = pkgs.buildGradleArtifact {
pname = "nodecames";
src = ./.;
buildTask = "bootJar";
installPhase = ''
cp -r "build/libs/" "$out"
cp -r ./build/resources/ $out/resources/
'';
inherit version jdk gradle;
};
@@ -33,20 +39,49 @@
echo "Launching NodeCames"
${jdk}/bin/java -jar ${nodecames}/nodecames-${version}.jar --spring.datasource.url=$NODECAMES_DATABASE --spring.datasource.password=$NODECAMES_PASSWORD "$@"
'';
postgres = pkgs.postgresql_18;
# TODO make his more nix
postgres-server = pkgs.writeShellScriptBin "postgres-server" ''
mkdir -p .postgres
${pkgs.podman}/bin/podman run -e POSTGRES_USER=nodecames -e POSTGRES_PASSWORD=nodecames-dev -e POSTGRES_DB=nodecames --volume ./.postgres:/var/lib/postgresql -p 127.0.0.1:5432:5432 postgres:18.4 &> .postgres/postgres.log &
'';
test-archipelago = pkgs.writeShellApplication {
name = "archipelago";
text = ''
TMP_HOME=$(mktemp -d)
HOME=$TMP_HOME ${pkgs.archipelago}/bin/archipelago --update_settings
ln -s ${nodecames-apworld} "$TMP_HOME/.local/share/Archipelago/worlds/nodecames.apworld"
chmod +w "$TMP_HOME/.local/share/Archipelago/Players/"
ln -s ${./src/main/resources/apworld/test.yml} "$TMP_HOME/.local/share/Archipelago/Players/Tester.yaml"
HOME=$TMP_HOME ${pkgs.archipelago}/bin/archipelago "$@"
'';
};
archipelago-host = pkgs.writeShellApplication {
name = "archipelago-host";
text = ''
TMP_HOME=$(mktemp -d)
HOME=$TMP_HOME ${pkgs.archipelago}/bin/archipelago --update_settings
ln -s ${nodecames-apworld} "$TMP_HOME/.local/share/Archipelago/worlds/nodecames.apworld"
chmod +w "$TMP_HOME/.local/share/Archipelago/Players/"
ln -s ${./src/main/resources/apworld/test.yml} "$TMP_HOME/.local/share/Archipelago/Players/Tester.yaml"
HOME=$TMP_HOME ${pkgs.archipelago}/bin/archipelago Generate
GENFILE=$(find "$TMP_HOME/.local/share/Archipelago/output/" -name "*.zip")
HOME=$TMP_HOME ${pkgs.archipelago}/bin/archipelago Host "$GENFILE"
'';
};
in {
packages.${system} = {
default = self.packages.${system}.nodecames;
inherit nodecames;
inherit nodecames nodecames-apworld;
};
apps.${system} = {
default = self.apps.${system}.nodecames;
nodecames = { type = "app"; program = "${nodecames-launcher}/bin/nodecames"; };
postgres-server = { type = "app"; program = "${postgres-server}/bin/postgres-server"; };
archipelago = { type = "app"; program = "${test-archipelago}/bin/archipelago"; };
archipelago-host = { type = "app"; program = "${archipelago-host}/bin/archipelago-host"; };
};
devShells.${system} = {
@@ -54,10 +89,12 @@
packages = [
jdk
gradle
postgres
(pkgs.vscode-with-extensions.override {
vscode = pkgs.vscodium;
vscodeExtensions = with pkgs.vscode-extensions; [
redhat.java
ms-python.python
vscjava.vscode-java-debug
vscjava.vscode-java-test
vscjava.vscode-gradle
+40 -8
View File
@@ -103,17 +103,30 @@
<sha256 value="19889dbdf1b254b2601a5ee645b8147a974644882297684c798afe5d63d78dfe" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.38.0">
<artifact name="error_prone_annotations-2.38.0.jar">
<sha256 value="6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889" origin="Generated by Gradle"/>
<component group="com.google.code.gson" name="gson" version="2.13.2">
<artifact name="gson-2.13.2.jar">
<sha256 value="dd0ce1b55a3ed2080cb70f9c655850cda86c206862310009dcb5e5c95265a5e0" origin="Generated by Gradle"/>
</artifact>
<artifact name="error_prone_annotations-2.38.0.pom">
<sha256 value="3007bef8aff3ae8ea12d81c3feacb4f15979b2cf5c3e38fc9189a98dea141167" origin="Generated by Gradle"/>
<artifact name="gson-2.13.2.pom">
<sha256 value="3aa06aa7c0f9af092961a42d09578e4324be146348a0ee6ed47857f7c2677b76" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_parent" version="2.38.0">
<artifact name="error_prone_parent-2.38.0.pom">
<sha256 value="e62458a6a3e63081bc7c57b3c0fac9f04f76ce32f606532db69fe2b3d47b934c" origin="Generated by Gradle"/>
<component group="com.google.code.gson" name="gson-parent" version="2.13.2">
<artifact name="gson-parent-2.13.2.pom">
<sha256 value="83ab528a9d50fd76aeb8ad6f727b3ee9cb766586255774ed16ca8c4c76d9dacd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.41.0">
<artifact name="error_prone_annotations-2.41.0.jar">
<sha256 value="a56e782b5b50811ac204073a355a21d915a2107fce13ec711331ad036f660fcc" origin="Generated by Gradle"/>
</artifact>
<artifact name="error_prone_annotations-2.41.0.pom">
<sha256 value="a151df1e2e0b48618d8b06a180748a29b3abb39b1b2396f6a1c879a727488c6e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_parent" version="2.41.0">
<artifact name="error_prone_parent-2.41.0.pom">
<sha256 value="c538388d760a5c1c98dcf06f6ed3cfe5f11a651827db5cbd2ed8288c795cad42" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.protobuf" name="protobuf-bom" version="4.35.1">
@@ -280,6 +293,17 @@
<sha256 value="7c490839bb11df9ae7c37f9c31994a4b423bd826245d34abe6c864f5e61d46e9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.github.archipelagomw" name="Java-Client" version="0.2.1">
<artifact name="Java-Client-0.2.1.jar">
<sha256 value="9f3ff0a1a3cd99cbc9090e819c00d3890b775e17cbac47201677972953337a33" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-Client-0.2.1.module">
<sha256 value="c4b35dff4ed88b82e4a9133efdfbc6efb6a23bca3f9124e1b606a37b10240c4f" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-Client-0.2.1.pom">
<sha256 value="812e1984b148cb856afb290029c224de7dfb99a11a28dd9b6a402d2ed761dd06" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.grpc" name="grpc-bom" version="1.83.1">
<artifact name="grpc-bom-1.83.1.pom">
<sha256 value="58cc1c0c0e6dc3aa28a6b8f6c8cc1cbdbdc47843cfba282d07f694c2b1af4f8b" origin="Generated by Gradle"/>
@@ -1044,6 +1068,14 @@
<sha256 value="aeabde04eca66e1ac30f9df3cc36c9b07a25a37c52043b33418e274ce5a23616" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.java-websocket" name="Java-WebSocket" version="1.6.0">
<artifact name="Java-WebSocket-1.6.0.jar">
<sha256 value="eae29213e4f16515639c28957200f011b3967fffcada1962cf0255d24919c22f" origin="Generated by Gradle"/>
</artifact>
<artifact name="Java-WebSocket-1.6.0.pom">
<sha256 value="a8e84d553c34d793e2ea7e4a54b073f44ddb0bb442f4dd6fad0cecd755d1ceac" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jboss" name="jboss-parent" version="52">
<artifact name="jboss-parent-52.pom">
<sha256 value="0615b429e3a21a0d6bd925d09ede2937892034272b4cd04bb9ab617d57e72b42" origin="Generated by Gradle"/>
+1
View File
@@ -0,0 +1 @@
from .world import NodecamesWorld
+6
View File
@@ -0,0 +1,6 @@
{
"game": "NodeCames",
"minimum_ap_version": "0.6.8",
"world_version": "1.0.0",
"authors": ["Mysaa"]
}
+51
View File
@@ -0,0 +1,51 @@
import re
MAX_WORLD_COUNT=100
MAX_WORD_PER_WORLD=99
def parseWordDistribution(s):
components = s.split(';')
out = {}
for comp in components:
p = re.match("^([GWB])([GWB])([0-9]+)$", comp)
if not p:
raise ValueError("Invalid component of word distribution")
colora = p.group(1)
colorb = p.group(2)
count = int(p.group(3))
if (colora + colorb in out):
raise ValueError(f"Component {colora}{colorb} defined twice")
out[colora + colorb] = count
return out
filler_item = "filler"
ITEM_NAME_TO_ID = {
"progressive_hint_count" : 1,
"zero_hint" : 2,
"infinity_hint" : 3,
"one_more_guess" : 4,
"hint_max_length" : 5,
"end_guessing" : 6,
"only_one_green_green" : 7,
"sudden_death" : 8,
"structured_grid" : 9,
"hints_in_a_row" : 10,
"anyone_starts" : 11,
"progressive_hint_word_count": 12,
"progressive_hint_word_length": 13,
"filler": 14
}
for w in range(MAX_WORLD_COUNT):
ITEM_NAME_TO_ID[f"unlock_world_{w+1}"] = 100+w
def location_id_of_world_and_word(world, word):
return 10000 + (world-1)*(MAX_WORD_PER_WORLD+1) + word
def world_and_word_of_location(id):
world = (id - 10000)//(MAX_WORD_PER_WORLD+1) + 1
word = (id - 10000) % (MAX_WORD_PER_WORLD+1)
return (world, word)
def location_name_of_world_and_word(world, word):
return f"World {world} word {word}"
LOCATION_NAME_TO_ID = {location_name_of_world_and_word(world,word) : location_id_of_world_and_word(world,word) for world in range(1,MAX_WORLD_COUNT+1) for word in range(0,MAX_WORD_PER_WORLD+1)}
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .world import NodecamesWorld
from BaseClasses import Item, ItemClassification
from . import const as c
class NodecamesItem(Item):
game = "Nodecames"
def get_random_filler_item_name(world: NodecamesWorld) -> str:
return c.filler_item
def create_item_with_correct_classification(
world: NodecamesWorld, name: str
) -> NodecamesItem:
classification = ItemClassification.progression
if name == c.filler_item:
classification = ItemClassification.filler
return NodecamesItem(name, classification, c.ITEM_NAME_TO_ID[name], world.player)
def create_all_items(world: NodecamesWorld) -> None:
itempool: list[Item] = []
hintItemCount = world.options.max_hint_count - world.options.start_hint_count
itempool += [world.create_item("progressive_hint_count") for _ in range(hintItemCount)]
hintWordLengthItemCount = world.options.max_hint_length - world.options.start_hint_length
itempool += [world.create_item("progressive_hint_word_length") for _ in range(hintWordLengthItemCount)]
hintWordCountItemCount = world.options.max_hint_word_count - world.options.start_hint_word_count
itempool += [world.create_item("progressive_hint_word_count") for _ in range(hintWordCountItemCount)]
itempool += [
world.create_item("zero_hint"),
world.create_item("infinity_hint"),
world.create_item("one_more_guess"),
world.create_item("hint_max_length"),
world.create_item("end_guessing"),
world.create_item("only_one_green_green"),
world.create_item("sudden_death"),
world.create_item("structured_grid"),
world.create_item("hints_in_a_row"),
world.create_item("anyone_starts"),
]
for wi,wn in enumerate(world.options.worlds.keys()):
w = world.options.worlds[wn]
if w['locked']:
itempool.append(world.create_item(f"unlock_world_{wi}"))
to_complete = len(world.multiworld.get_unfilled_locations(world.player)) - len(itempool)
print(to_complete,"items to fill")
itempool += [world.create_filler() for _ in range(to_complete)]
world.multiworld.itempool += itempool
+338
View File
@@ -0,0 +1,338 @@
from dataclasses import dataclass
from Options import Choice, OptionGroup, PerGameCommonOptions, Range, DefaultOnToggle, OptionDict
from schema import Schema, And
from . import const as c
class WorldsLocked(DefaultOnToggle):
"""
Access to worlds need to be unlocked by an item
"""
display_name = "Worlds locked"
class HintWordLogicRatio(Range):
"""
How many hints per word count in the grid is needed as a minimum.
Used to compute the logic
Divide by 10
"""
range_start = 10
range_end = 80
display_name = "Hint Word Logic Ration"
class StartHintCount(Range):
"""
Hint count at the beginning of the game
"""
display_name = "Start hint count"
range_start = 1
range_end = 20
default = 1
class MaxHintCount(Range):
"""
Maximum hint count we can get
"""
display_name = "Max hint count"
range_start = 1
range_end = 20
default = 10
class StartHintLength(Range):
"""
Maximal Hint length at the beginning of the game
"""
display_name = "Start Hint Length"
range_start = 3
range_end = 30
default = 3
class MaxHintLength(Range):
"""
Maximal maximum hint length we can get. Once it is reached, maximal length is now infinity.
"""
display_name = "Max Hint Length"
range_start = 3
range_end = 30
default = 10
class StartHintWordCount(Range):
"""
Hint Word count available at the beginning of the game
"""
display_name = "Start Hint Word Count"
range_start = 1
range_end = 16
default = 1
class MaxHintWordCount(Range):
"""
Maximal hint word count we can get.
"""
display_name = "Max Hint Word Count"
range_start = 1
range_end = 16
default = 16
class OnOffDefault(Choice):
option_Off = 0
option_Item = 1
option_On = 2
default = 1
class ZeroHint(OnOffDefault):
"""
Can give zero as a hint
"""
display_name = "Hint Count 0"
class InfinityHint(OnOffDefault):
"""
Can give infinity as a hint
"""
display_name = "Hint Count infty"
class OneMoreGuess(OnOffDefault):
"""
Can guess one word more than hint count
"""
display_name = "One More Guess"
class EndGuessing(OnOffDefault):
"""
Can end guessing before touching the given hint count
"""
display_name = "End Guessing"
class OnlyOneGreenGreen(OnOffDefault):
"""
Green-Green cards only have to be pointed by one of the players
"""
display_name = "Only One Green Green"
class SuddenDeath(OnOffDefault):
"""
Sudden death triggered when no more hints can be given
"""
display_name = "Sudden Death"
class StructuredGrid(OnOffDefault):
"""
Are front and back colors of the cards linked
"""
display_name = "Structured Grid"
class HintsInARow(OnOffDefault):
"""
Players can give multiple hints in a row
"""
display_name = "HintsInARow"
class AnyoneStarts(OnOffDefault):
"""
Both players can start the game
"""
display_name = "AnyoneStarts"
class WorldCount(Range):
"""
How many worlds to generate
"""
display_name = "World Count"
range_start = 1
range_end = 12
default = 12
class VictoryCondition(Choice):
"""
What is needed to complete game
"""
option_AllWorlds = 1
option_LastWorld = 2
default = 2
class WorldsOption(OptionDict):
"""
Which nodecames worlds will be in the game and their options
It takes the following parameters:
locked: wether the world is unlocked by default, or needs an item to be unlocked
word_distribution: a string describing the distributions of colors on the grid
"""
display_name = "Worlds"
schema = Schema({
str: {
"locked": bool,
"word_distribution": And(str, c.parseWordDistribution),
"required": bool
}})
# We must now define a dataclass inheriting from PerGameCommonOptions that we put all our options in.
# This is in the format "option_name_in_snake_case: OptionClassName".
@dataclass
class NodecamesOptions(PerGameCommonOptions):
worlds_locked: WorldsLocked
hint_word_logic_ratio: HintWordLogicRatio
victory_condition: VictoryCondition
start_hint_count: StartHintCount
max_hint_count: MaxHintCount
start_hint_length: StartHintLength
max_hint_length: MaxHintLength
start_hint_word_count: StartHintWordCount
max_hint_word_count: MaxHintWordCount
zero_hint: ZeroHint
infinity_hint: InfinityHint
one_more_guess: OneMoreGuess
end_guessing: EndGuessing
only_one_green_green: OnlyOneGreenGreen
sudden_death: SuddenDeath
structured_grid: StructuredGrid
hints_in_a_row: HintsInARow
anyone_starts: AnyoneStarts
worlds: WorldsOption
# If we want to group our options by similar type, we can do so as well. This looks nice on the website.
option_groups = [
OptionGroup(
"General Options",
[WorldsLocked, VictoryCondition, HintWordLogicRatio],
),
OptionGroup(
"Grid Options",
[
StartHintCount,
MaxHintCount,
StartHintLength,
MaxHintLength,
ZeroHint,
InfinityHint,
OneMoreGuess,
EndGuessing,
OnlyOneGreenGreen,
SuddenDeath,
StructuredGrid,
HintsInARow,
AnyoneStarts,
],
),
OptionGroup("Worlds Options", [WorldsOption]),
]
# Finally, we can define some option presets if we want the player to be able to quickly choose a specific "mode".
option_presets = {
"default": {
"hint_word_logic_ratio": 25,
"worlds_locked": True,
"start_hint_count": 1,
"max_hint_count": 10,
"start_hint_length": 3,
"max_hint_length": 12,
"start_hint_word_count": 1,
"max_hint_word_count": 16,
"zero_hint": 1,
"infinity_hint": 1,
"one_more_guess": 1,
"end_guessing": 1,
"only_one_green_green": 1,
"sudden_death": 1,
"structured_grid": 1,
"hints_in_a_row": 1,
"anyone_starts": 1,
"worlds": {
"1-1": {
"locked": False,
"word_distribution": "GG1;GW2;GB0;WG2;WW1;WB0;BG0;BW0;BB0"
},
"1-2": {
"locked": True,
"word_distribution": "GG2;GW1;GB1;WG1;WW2;WB0;BG1;BW0;BB1"
},
"1-3": {
"locked": True,
"word_distribution": "GG2;GW1;GB1;WG1;WW2;WB0;BG1;BW0;BB1"
},
"2-1": {
"locked": True,
"word_distribution": "GG2;GW1;GB1;WG1;WW3;WB1;BG1;BW1;BB1"
},
"2-2": {
"locked": True,
"word_distribution": "GG2;GW2;GB1;WG2;WW5;WB1;BG1;BW1;BB1"
},
"2-3": {
"locked": True,
"word_distribution": "GG2;GW2;GB1;WG2;WW5;WB1;BG1;BW1;BB1"
},
"3-1": {
"locked": True,
"word_distribution": "GG2;GW4;GB1;WG4;WW5;WB1;BG1;BW1;BB1"
},
"3-2": {
"locked": True,
"word_distribution": "GG3;GW5;GB1;WG5;WW7;WB1;BG1;BW1;BB1"
},
"3-3": {
"locked": True,
"word_distribution": "GG3;GW5;GB1;WG5;WW7;WB1;BG1;BW1;BB1"
},
"4-1": {
"locked": True,
"word_distribution": "GG3;GW5;GB2;WG5;WW7;WB2;BG2;BW2;BB1"
},
"4-2": {
"locked": True,
"word_distribution": "GG5;GW6;GB2;WG6;WW9;WB2;BG2;BW2;BB2"
},
"4-3": {
"locked": True,
"word_distribution": "GG5;GW6;GB2;WG6;WW9;WB2;BG2;BW2;BB2"
}
},
"victory_condition": 2,
},
}
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .world import NodecamesWorld
from rule_builder.options import OptionFilter
from rule_builder.rules import Has, HasAll, Rule, True_
from BaseClasses import Region, Location
from . import const as c
import math
from . import items
def print_graph(world : NodecamesWorld):
for region in world.multiworld.regions:
print(region)
for exit in region.exits:
print("->",exit,"--",exit.access_rule)
class NodecamesLocation(Location):
game = "Nodecames"
def total_word_count(distribution):
return distribution['GB']+distribution['BG']+distribution['GW']+distribution['WG']+distribution['GG']
def can_access_word(options, word):
ratio = options.hint_word_logic_ratio/10
min_hint_count = math.ceil(word/ratio)
min_hint_word_count = math.ceil(word/min_hint_count) # min_hint_count * min_hint_word_count >= word
return Has("progressive_hint_word_count", count=max(min_hint_word_count-options.start_hint_word_count,0)) & Has("progressive_hint_count", count=max(min_hint_count-options.start_hint_count,0))
def can_access_world(options, wn, wi):
if options.worlds[wn]['locked']:
return Has(f"unlock_world_{wi}")
else:
return True_()
def create_all_regions(world: NodecamesWorld) -> None:
#TODO make a helper to add new regions
owmap = Region("Map", world.player, world.multiworld)
wregions = dict()
toComplete = list()
for wi,wn in enumerate(world.options.worlds.keys()):
w = world.options.worlds[wn]
distrib = c.parseWordDistribution(w['word_distribution'])
wregions[wn] = list()
for i in range(total_word_count(distrib)+1):
name = c.location_name_of_world_and_word(wi+1,i)
wregions[wn].append(Region(name, world.player, world.multiworld))
if i>0:
wregions[wn][i-1].connect(wregions[wn][i], rule = can_access_word(world.options, i))
else:
owmap.connect(wregions[wn][i], rule = can_access_world(world.options, wn, wi))
loc = NodecamesLocation(
world.player,
name,
c.LOCATION_NAME_TO_ID[name],
wregions[wn][i]
)
wregions[wn][i].locations.append(loc)
wregions[wn][-1].add_event(
f"World {wn} complete",
f"World {wn} completion item",
location_type = NodecamesLocation,
item_type = items.NodecamesItem)
if w['required']:
toComplete.append(f"World {wn} completion item")
world.set_completion_rule(HasAll(*toComplete))
allregions = [owmap] + [wrxv for wrv in wregions.values() for wrxv in wrv]
print(f"Generated {len(allregions)} regions")
print(f"Generated {sum([len(reg.locations) for reg in allregions])} locations")
world.multiworld.regions += allregions
+46
View File
@@ -0,0 +1,46 @@
from collections.abc import Mapping
from typing import Any
from worlds.AutoWorld import World
from . import items, rules
from . import options as nodecames_options
from . import const as c
class NodecamesWorld(World):
"""
Nodecames is a word game, inspired by Code Names by Vlaada Chvátil
"""
game = "nodecames"
options_dataclass = nodecames_options.NodecamesOptions
options: nodecames_options.NodecamesOptions
location_name_to_id = c.LOCATION_NAME_TO_ID
item_name_to_id = c.ITEM_NAME_TO_ID
origin_region_name = "Map"
# Create regions, locations and rules
def create_regions(self) -> None:
rules.create_all_regions(self)
def create_items(self) -> None:
items.create_all_items(self)
# Create an item from name
def create_item(self, name: str) -> items.NodecamesItem:
return items.create_item_with_correct_classification(self, name)
# Create one filler item
def get_filler_item_name(self) -> str:
return items.get_random_filler_item_name(self)
# Generates the output file
def generate_output(self, outdir : str) -> None:
pass
# Data sent to the client
def fill_slot_data(self) -> Mapping[str, Any]:
return []
@@ -1,9 +1,19 @@
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;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@@ -12,50 +22,85 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.view.RedirectView;
import com.bernard.nodecames.frontend.GridConfigEdit;
import com.bernard.nodecames.game.ArchipelagoManager;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameConfig;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.GridRepository;
import com.bernard.nodecames.repository.WorldRepository;
import io.github.archipelagomw.network.ConnectionResult;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
public class HttpController {
private final GameManager gm;
private final GridManager gm;
private final GameManager wm;
private final ArchipelagoManager am;
private final GridRepository gridRepository;
private final GameRepository gRepository;
private final WorldRepository wRepository;
private final Random rand = new Random();
@GetMapping("/")
public String index() {
public String index(Model m) {
List<Game> games = wm.allGames().stream().sorted(Comparator.comparing(Game::getName)).toList();
m.addAttribute("games", games);
return "index.html";
}
@GetMapping("/room/{id}/grid")
public String grid(@PathVariable("id") String id) {
public Object grid(@PathVariable("id") String id, @RequestParam(value = "player", required = false) String player, Model m) {
if(player == null) {
m.addAttribute("redirect", "/room/"+id+"/grid");
return "player-select";
}
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
m.addAttribute("player", player);
m.addAttribute("grid", grid.get());
return "grid";
}
@GetMapping("/room/{id}/config")
public String gridConfig(@PathVariable("id") String id, Model model) {
GridConfig gc = gm.findGrid(id).getConfig();
public Object gridConfig(@PathVariable("id") String id, Model model) {
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
GridConfig gc = grid.get().getConfig();
model.addAttribute("gridConfig", GridConfigEdit.of(gc));
return "grid-config";
}
@PostMapping("/room/{id}/config")
public String setGridConfig(@PathVariable("id") String id,
public Object setGridConfig(@PathVariable("id") String id,
@ModelAttribute("gridConfig") GridConfigEdit gce,
BindingResult br,
Model model
) {
Grid g = gm.findGrid(id);
for(Entry<MutableOption, Object> e : gce.getModifications(g.getConfig()).entrySet()) {
Optional<Grid> grid = gridRepository.findById(UUID.fromString(id));
if(!grid.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
for(Entry<MutableOption, Object> e : gce.getModifications(grid.get().getConfig()).entrySet()) {
try {
gm.unlock(g, e.getKey(), e.getValue());
gm.unlock(grid.get(), e.getKey(), e.getValue());
} catch (IllegalGameActionException ige) {
br.addError(new ObjectError(e.getKey().name(), ige.getMessage()));
}
@@ -64,12 +109,117 @@ public class HttpController {
return "grid-config";
}
@GetMapping("/create-room")
public RedirectView createRoom() {
public RedirectView createRoom(@RequestParam("type") String type) {
GridConfig cfg = switch(type) {
case "zero" -> GridConfig.ZERO;
case "base" -> GridConfig.REGULAR;
case "big" -> GridConfig.BIG;
default -> GridConfig.REGULAR;
};
Random r = new Random(rand.nextLong());
UUID uuid = gm.newGrid(GridConfig.ZERO, r);
return new RedirectView("/room/"+uuid.toString()+"/grid");
Grid g = gm.newGrid(null, cfg, r);
return new RedirectView("/room/"+g.getId()+"/grid");
}
@GetMapping("/create-game")
public RedirectView createGame(@RequestParam("name") String name, @RequestParam("type") String type) {
GameConfig cfg = switch(type) {
case "default" -> GameConfig.DEFAULT_GAME_CONFIG;
case "regular" -> GameConfig.REGULAR_GAME_CONFIG;
default -> GameConfig.DEFAULT_GAME_CONFIG;
};
Game g = wm.createGame(name, cfg);
return new RedirectView("/game/"+g.getId().toString()+"/map");
}
@GetMapping("/game/{id}/map")
public Object getMap(@PathVariable("id") String id, @RequestParam(value = "player", required = false) String player, Model m) {
if(player == null) {
m.addAttribute("redirect", "/game/"+id+"/map");
return "player-select";
}
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
List<World> worlds = wm.allWorlds(g.get())
.stream()
.sorted(Comparator.comparing(World::getName))
.toList();
m.addAttribute("game", g.get());
m.addAttribute("worlds", worlds);
m.addAttribute("items", g.get().getInventory().getReceivedItems());
m.addAttribute("player", player);
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));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Optional<World> w = wRepository.findByGameAndId_Name(g.get(), world);
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Grid grid;
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);
}
@GetMapping("/game/{id}/reset/{world}")
public Object resetWorld(@PathVariable("id") String id, @PathVariable("world") String world, @RequestParam("player") String player) {
Optional<Game> g = gRepository.findById(UUID.fromString(id));
if(!g.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Optional<World> w = wRepository.findByGameAndId_Name(g.get(), world);
if(!w.isPresent()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
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);
}
}
}
@@ -2,6 +2,9 @@ package com.bernard.nodecames;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.node.JsonNodeFactory;
@SpringBootApplication
public class NodecamesApplication {
@@ -10,4 +13,9 @@ public class NodecamesApplication {
SpringApplication.run(NodecamesApplication.class, args);
}
@Bean
public JsonNodeFactory theJsonNodeFactory() {
return JsonNodeFactory.instance;
}
}
@@ -1,28 +1,31 @@
package com.bernard.nodecames.frontend;
import java.util.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 com.bernard.nodecames.model.GridConfig;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.bernard.nodecames.model.converter.ColorDistributionConverter;
import com.bernard.nodecames.repository.CardRepository;
import lombok.AllArgsConstructor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@Service
@AllArgsConstructor
public class JsonDataService {
private static final JsonNodeFactory jsn = JsonNodeFactory.instance;
private final JsonNodeFactory jsn;
private final CardRepository cRepository;
private final ColorDistributionConverter wdc;
public JsonNode cardData(Card c, char player) {
ObjectNode cardNode = jsn.objectNode();
@@ -40,14 +43,7 @@ public class JsonDataService {
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("color-distribution", jsn.stringNode(wdc.convertToDatabaseColumn(gc.getColorDistribution())));
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()));
@@ -102,35 +98,8 @@ public class JsonDataService {
return config;
}
public JsonNode eventData(Grid g, GameEvent ge) {
ObjectNode geNode = jsn.objectNode();
geNode.set("type", jsn.stringNode(ge.getType().name().toLowerCase()));
geNode.set("issuer", jsn.stringNode(Character.toString(ge.getIssuer())));
switch (ge.getType()) {
case GameEvent.Type.HINT:
Hint hint = (Hint)ge.getData();
geNode.set("word", jsn.stringNode(hint.getWord()));
geNode.set("wordCount", jsn.numberNode(hint.getWordCount()));
break;
case GameEvent.Type.GUESS:
Card c = (Card)ge.getData();
Integer pos = IntStream.range(0, g.getCardCount())
.filter(i -> c == g.getCards()[i])
.findFirst()
.getAsInt();
geNode.set("card-index", jsn.numberNode(pos));
geNode.set("color", jsn.stringNode(((ge.getIssuer()=='A')?c.getColorB():c.getColorA()).name().toLowerCase()));
break;
case GameEvent.Type.END_GUESSING:
geNode.set("manual", jsn.booleanNode((Boolean)ge.getData()));
break;
case GameEvent.Type.GAME_END:
geNode.set("win", jsn.booleanNode((Boolean)ge.getData()));
break;
case GameEvent.Type.SUDDEN_DEATH:
break;
}
return geNode;
public JsonNode eventData(GameEvent ge) {
return GameEvent.toJsonNode(ge);
}
public JsonNode phaseData(Phase p) {
@@ -148,18 +117,19 @@ public class JsonDataService {
public JsonNode gridData(Grid g, char player) {
ArrayNode cardsNode = jsn.arrayNode(g.getCardCount());
for (int i = 0; i < g.getCardCount(); i++)
cardsNode.add(cardData(g.getCards()[i], player));
Card[] cards = cRepository.findByGridOrderById_Index(g);
ArrayNode cardsNode = jsn.arrayNode(cards.length);
for (int i = 0; i < cards.length; i++)
cardsNode.add(cardData(cards[i], player));
ArrayNode eventsNode = jsn.arrayNode(g.getGameEvents().size());
for(GameEvent ge : g.getGameEvents())
eventsNode.add(eventData(g, ge));
eventsNode.add(eventData(ge));
ObjectNode currentHintNode = jsn.objectNode();
if(g.getCurrentHint() != null) {
currentHintNode.set("word", jsn.stringNode(g.getCurrentHint().getWord()));
currentHintNode.set("wordCount", jsn.numberNode(g.getCurrentHint().getWordCount()));
currentHintNode.set("word-count", jsn.numberNode(g.getCurrentHint().getWordCount()));
}
ObjectNode out = jsn.objectNode();
@@ -1,6 +1,10 @@
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;
import org.springframework.http.ResponseEntity;
@@ -12,65 +16,43 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import com.bernard.nodecames.game.GameManager;
import com.bernard.nodecames.game.GridManager;
import com.bernard.nodecames.game.IllegalGameActionException;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.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 GameManager gm;
private SimpMessagingTemplate template;
private JsonDataService json;
private final GridManager gm;
private final SimpMessagingTemplate template;
private final JsonDataService json;
private final GridRepository gRepository;
public WebSocketController(GameManager gm, SimpMessagingTemplate template, JsonDataService json) {
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",
json.eventData(g, 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, int cardIndex, char player) {
Card c = g.getCards()[cardIndex];
this.template.convertAndSend(
"/topic/update-card/" + Character.toLowerCase(player),
json.cardData(c, player),
Map.of("cardIndex", cardIndex)
);
}
public void publishCardUpdate(Grid g, int cardIndex) {
publishCardUpdateToPlayer(g, cardIndex, 'A');
publishCardUpdateToPlayer(g, cardIndex, '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;
@@ -81,20 +63,29 @@ public class WebSocketController {
else
throw new IllegalArgumentException("Unknown player type");
Grid g = gm.findGrid(gridId);
return new ResponseEntity<>(json.gridData(g, player), HttpStatus.OK);
Optional<Grid> g = gRepository.findById(UUID.fromString(gridId));
if(g.isPresent()){
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();
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.proposeHint(g, player.charAt(0), hint, wordCount);
gm.proposeHint(g.get(), player.charAt(0), hint, wordCount);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -104,11 +95,13 @@ public class WebSocketController {
@MessageMapping("point-card")
public void pointCard(@Header("room") String roomId, @Header("player") String player, @Payload JsonNode content) {
int cardIndex = content.asObject().get("cardIndex").intValue();
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.pointCard(g, player.charAt(0), cardIndex);
publishCardUpdate(g, cardIndex);
gm.pointCard(g.get(), player.charAt(0), cardIndex);
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -117,9 +110,12 @@ public class WebSocketController {
@MessageMapping("end-guessing")
public void endGuessing(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.endGuessing(g, player.charAt(0));
gm.endGuessing(g.get(), player.charAt(0));
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
@@ -128,12 +124,67 @@ public class WebSocketController {
@MessageMapping("enter-sudden-death")
public void enterSuddenDeath(@Header("room") String roomId, @Header("player") String player) {
Grid g = gm.findGrid(roomId);
Optional<Grid> g = gRepository.findById(UUID.fromString(roomId));
if(!g.isPresent())
// 404
return;
try {
gm.enterSuddenDeath(g);
gm.enterSuddenDeath(g.get());
} catch (IllegalGameActionException e) {
publishError(player.charAt(0), e);
e.printStackTrace();
}
}
@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,94 @@
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 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) {
log.debug("Received item : {}", e.getItemName());
if(e.getIndex() > game.getInventory().getReceivedItems().size())
gm.giveItem(game, e.getItemName());
// Else, the item has already been received
}
@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) {}
}
}
@@ -1,420 +1,164 @@
package com.bernard.nodecames.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.Map.Entry;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.frontend.WebSocketController;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Card.Color;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.GameConfig;
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 com.bernard.nodecames.model.Inventory;
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 lombok.RequiredArgsConstructor;
import lombok.Setter;
import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
@Service
@RequiredArgsConstructor
@AllArgsConstructor
public class GameManager {
//TODO Remove static, make something more Spring Boot-y
private static Map<UUID, Grid> games = new HashMap<>();
private static Map<UUID, Random> randoms = new HashMap<>();
private final GridManager gm;
private final GameRepository gRepository;
private final WorldRepository wRepository;
@Setter
private WebSocketController wsc = null;
private final DictionnariesService dictionnaries;
private static final Logger log = LoggerFactory.getLogger(GameManager.class);
private void newGameEvent(Grid g, GameEvent ge) {
g.newGameEvent(ge);
log.debug("New game event : {}", ge);
wsc.onNewEvent(g, ge);
//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 setPhase(Grid g, Phase phase) {
g.setPhase(phase);
log.debug("New phase : {}", phase.name());
if(!phase.isAnyoneGuessing()) {
g.setCurrentHint(null);
}
wsc.onNewPhase(g);
private void forEachObserver(World w, Consumer<WorldObserver> action) {
observers.getOrDefault(w.getId(), Set.of()).forEach(action);
}
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
/*
* In case of server restart, registers to GridManager observers
*/
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;
@PostConstruct
private void registerRunningObservers() {
for(World w : wRepository.findAll()) {
if(w.getCurrentGrid() != null && w.getCurrentGrid().getWon() != null) {
registerObserver(w, w.getCurrentGrid());
}
}
return true;
}
private void registerObserver(World w, Grid g) {
gm.addObserver(g, new GObserver(g, w));
forEachObserver(w, o -> gm.addObserver(g, o));
}
/**
* 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 Game createGame(String name, GameConfig cfg) {
Game g = cfg.newGame(gRepository, wRepository, name);
return g;
}
public Collection<Game> allGames() {
return gRepository.findAll();
}
public Grid findGrid(String gridId) {
return games.get(UUID.fromString(gridId));
public Set<World> allWorlds(@NotNull Game g) {
return wRepository.findByGame(g);
}
public UUID newGrid(GridConfig gc, Random r) {
UUID uuid = UUID.randomUUID();
randoms.put(uuid, r);
String[] words = dictionnaries.randomWords(gc.getWordCount(), r);
List<Card> cards = new ArrayList<>(gc.getWordCount());
List<CardColors> origDistrib = gc.getWordDistribution();
List<CardColors> distrib;
if(!gc.isStructuredGrid()) {
List<Color> bColors = origDistrib.stream().map(cc -> cc.getFaceB()).collect(Collectors.toList());
Collections.shuffle(bColors, r);
distrib = IntStream.range(0, gc.getWordCount())
.mapToObj(i -> CardColors.of(origDistrib.get(i).getFaceA(), bColors.get(i)))
.toList();
} 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);
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.makeGridConfig();
//TODO seed random
Grid grid = gm.newGrid(w, gc, new Random());
Phase startPhase;
if(gc.isAnyoneStarts()) {
startPhase = Phase.HINTING_BOTH;
} else {
if(r.nextBoolean())
startPhase = Phase.hinting('A');
else
startPhase = Phase.hinting('B');
}
Grid g = new Grid(uuid.toString(),
(Card[])cards.toArray(new Card[cards.size()]),
new ArrayList<>(),
null,
0,
0,
0,
startPhase,
Optional.empty(),
gc);
games.put(uuid, g);
return uuid;
}
/**********************************
* GAME ACTIONS *
**********************************/
private void endGame(Grid g, boolean win) {
g.setWon(Optional.of(win));
setPhase(g, Phase.GAME_ENDED);
newGameEvent(g, GameEvent.newGameEndEvent(win));
}
public void proposeHint(Grid g, char player, String hint, int wordCount) throws IllegalGameActionException {
if (!g.getPhase().canPlay() && !g.getPhase().isHinting(player)) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (!g.getConfig().getAvailableHintWordCount().contains(wordCount)) {
throw new IllegalGameActionException(g, "Unavailable word count");
}
if (hint.length() > g.getConfig().getHintMaxLength()) {
throw new IllegalGameActionException(g, "Hint is too long");
}
Hint h = new Hint(hint, wordCount);
g.incrementHintCount();
g.setCurrentHint(h);
g.setCurrentGuessCount(0);
newGameEvent(g, GameEvent.newHintEvent(h, player));
setPhase(g, Phase.guessing(other(player)));
}
/**
* Returns 'A', 'B', '_' depending on who should be the next ones to hint if we enter a hinting phase
*/
private char whoWouldHintNext(Grid g, char player) {
if(allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
// I can't hint anymore
return other(player);
} else {
if(g.getConfig().isHintsInARow() && !allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen())) {
return '_';
} else {
return player;
}
}
}
/**
* Enters the sudden death phase
*/
private void enterSuddenDeathInternal(Grid g) {
newGameEvent(g, GameEvent.newSuddenDeath());
if(allGreenRevealed(g, 'A', g.getConfig().isOnlyOneGreenGreen())) {
// If all 'A''s greens are revealed, A is the only one to guess
setPhase(g, Phase.suddenDeathOnly('A'));
} else {
// Else, we are both guessing
setPhase(g, Phase.SUDDEN_DEATH_BOTH);
}
}
/**
Sets the phase of the grid when player ends its guessing round.
@param g Must neither be ended neither be in a sudden death
*/
private void endGuessingRound(Grid g, char player) {
// We're done with this round of guesses
if(g.getUsedHints() >= g.getConfig().getMaxHint()) {
if(g.getUsedHints() >= g.getConfig().getMaxMaxHint()) {
if(!g.getConfig().isSuddenDeath()) {
// We're entering sudden death
enterSuddenDeathInternal(g);
} else {
// Only sudden death can get us out
setPhase(g, Phase.NEED_SUDDEN_DEATH);
}
} else {
// We are waiting for more hints
if(g.getConfig().isSuddenDeath()) {
setPhase(g, Phase.needMoreHints(whoWouldHintNext(g, player)));
} else {
setPhase(g, Phase.needMoreHintsOrSuddenDeath(whoWouldHintNext(g, player)));
}
}
} else {
// Next regular hinting round
setPhase(g, Phase.hinting(whoWouldHintNext(g, player)));
}
}
public Card.Color pointCard(Grid g, char player, int cardIndex) throws IllegalGameActionException {
if (!g.getPhase().canPlay() || !g.getPhase().isGuessing(player)) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (cardIndex<0 || cardIndex>g.getCardCount()) {
throw new IllegalArgumentException("The given card index is invalid");
}
Card c = g.getCards()[cardIndex];
w.setCurrentGrid(grid);
wRepository.save(w);
registerObserver(w, grid);
if(c.isColorPublic(other(player)) || (c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player)))
throw new IllegalGameActionException(g, "Card has already been revealed");
c.setColorPublic(other(player), true);
Card.Color guessedColor = c.getColor(other(player));
return grid;
}
newGameEvent(g, GameEvent.newGuessEvent(c, player));
g.incrementCurrentGuessCount();
if(guessedColor == Color.BLACK){
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE) {
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// i.e. i made my last guess
if (allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
endGame(g, true);
} else if (g.getPhase().isSuddenDeath()) {
// Sudden death with only the other
setPhase(g, Phase.suddenDeathOnly(other(player)));
} else {
endGuessingRound(g, player);
}
} else if (!allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// There is still cards i should find
if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), g.getConfig().isOneMoreGuess())) {
if(!g.getConfig().isOneMoreGuess() && g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), true)) {
// Having one more guess could make us continue
setPhase(g, Phase.waitingForOneMore(player));
} else {
// We're done with this round of guesses anyway
endGuessingRound(g, player);
}
} else {
// I continue guessing cards in this round
}
} else if (guessedColor == Color.WHITE && !g.getPhase().isSuddenDeath()){
endGuessingRound(g, player);
public Grid resetGrid(Game g, World world) throws IllegalGameActionException{
return newGrid(g, world);
}
public Grid getGrid(Game g, World w) throws IllegalGameActionException{
if(w.getCurrentGrid() != null) {
return w.getCurrentGrid();
} else {
throw new IllegalStateException("This should logically never happen");
}
if(!g.getPhase().isAnyoneGuessing() && !g.getPhase().isWaitingForOneMore())
// It means this pick ended the guessing turn
newGameEvent(g, GameEvent.newEndGuessingEvent(false, player));
return guessedColor;
}
public void endGuessing(Grid g, char player) throws IllegalGameActionException {
if (!g.getPhase().canPlay() || (!g.getPhase().isGuessing(player) && !g.getPhase().isWaitingForOneMore())) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (!g.getConfig().isEndGuessing() && !g.getPhase().isWaitingForOneMore()) {
throw new IllegalGameActionException(g, "Cannot end guessing in this game");
}
endGuessingRound(g, player);
newGameEvent(g, GameEvent.newEndGuessingEvent(true, player));
}
public void enterSuddenDeath(Grid g) throws IllegalGameActionException {
if(!g.getPhase().isNeedMoreHints()) {
throw new IllegalGameActionException(g, "You can only enter sudden death when being out of hints");
}
enterSuddenDeathInternal(g);
}
/**
* UPDATE GAME CONFIG
*/
public void unlock(Grid g, MutableOption opt, Object o) throws IllegalGameActionException {
switch(opt) {
case MutableOption.MAX_HINT:
unlockHintCount(g, (Integer)o);
break;
case MutableOption.AVAILABLE_HINT_WORD_COUNT:
unlockNewWordCounts(g, (Set<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()));
return newGrid(g, w);
}
}
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");
public void giveItem(Game g, String item) {
g.getInventory().receiveItem(item);
Inventory inv = g.getInventory();
for(World w : allWorlds(g)) {
if(w.getCurrentGrid() == null) continue;
Grid grid = w.getCurrentGrid();
Map<MutableOption, Object> updates = w.getConfig().updateConfig(grid.getConfig(), inv);
log.debug("Updating option for world {} : {}", w.getName(), updates);
for(Entry<MutableOption, Object> e : updates.entrySet()) {
try {
gm.unlock(grid, e.getKey(), e.getValue());
} catch (IllegalGameActionException ex) {
throw new IllegalStateException("World Config sent a set of changes that got us out of game logic", ex);
}
}
}
changeConfig(g, MutableOption.AVAILABLE_HINT_WORD_COUNT, newAvailableHintWordCounts);
gRepository.save(g);
}
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()));
@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);
}
}
}
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);
}
@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) {}
}
}
@@ -0,0 +1,464 @@
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;
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.ColorDistribution.CardColors;
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.observer.GridObserver;
import com.bernard.nodecames.repository.CardRepository;
import com.bernard.nodecames.repository.GridRepository;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class GridManager {
private Map<UUID, Set<GridObserver>> observers = new HashMap<>();
private final GridRepository gRepository;
private final CardRepository cRepository;
private final DictionnariesService dictionnaries;
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);
forEachObserver(g, o -> o.onGameEvent(ge));
}
private void setPhase(Grid g, Phase phase) {
log.debug("New phase : {}", phase.name());
g.setPhase(phase);
if(!phase.isAnyoneGuessing()) {
g.setCurrentHint(null);
}
gRepository.save(g);
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 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, 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);
forEachObserver(g, o -> o.onConfigChange(cc, data));
}
public int getCardCount(Grid g) {
return cRepository.countByGrid(g);
}
public Card[] getCards(Grid g) {
return cRepository.findByGridOrderById_Index(g);
}
public Card getCard(Grid g, int index) {
return cRepository.findByGridAndId_Index(g, index);
}
private char other(char player) {
return (player=='A')?'B':'A';
}
/**
@returns true iff all green cards of this player have been revealed either side
if onlyoneGreenGreen is false, checks only the side of this player
*/
private boolean allGreenRevealed(Grid g, char player, boolean onlyOneGreenGreen) {
for(Card c : getCards(g)) {
if ((c.getColor(player) == Color.GREEN && !c.isColorPublic(player)) &&
(!onlyOneGreenGreen || (c.getColor(player) == Color.GREEN && !c.isColorPublic(player))))
return false;
}
return true;
}
/**
* Returns true iff the game would have been won if the rule onlyOneGreenGreen was true
*/
private boolean onlyOneGreenGreenWouldWin(Grid g) {
return !g.getConfig().isOnlyOneGreenGreen() && allGreenRevealed(g, 'A', true) && allGreenRevealed(g, 'B', true);
}
public Grid newGrid(@Nullable World world, GridConfig gc, Random r) {
UUID uuid = UUID.randomUUID();
String[] words = dictionnaries.randomWords(gc.getColorDistribution().wordCount(), r);
List<CardColors> origDistrib = gc.getColorDistribution().asList();
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.getColorDistribution().wordCount())
.mapToObj(i -> CardColors.of(origDistrib.get(i).getFaceA(), bColors.get(i)))
.toList();
} else {
distrib = origDistrib;
}
distrib = new ArrayList<>(distrib);
Collections.shuffle(distrib, r);
Phase startPhase;
if(gc.isAnyoneStarts()) {
startPhase = Phase.HINTING_BOTH;
} else {
if(r.nextBoolean())
startPhase = Phase.hinting('A');
else
startPhase = Phase.hinting('B');
}
Grid g = new Grid(uuid, world, startPhase, gc);
gRepository.save(g);
for (int j = 0; j < distrib.size(); j++) {
Card card = new Card(g, j, words[j], distrib.get(j).getFaceA(), distrib.get(j).getFaceB());
cRepository.save(card);
}
return g;
}
/**********************************
* GAME ACTIONS *
**********************************/
private void endGame(Grid g, boolean win) {
setPhase(g, Phase.GAME_ENDED);
newGameEvent(g, GameEvent.newGameEndEvent(win));
// 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 {
if (!g.getPhase().canPlay() && !g.getPhase().isHinting(player)) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (!g.getConfig().getAvailableHintWordCount().contains(wordCount)) {
throw new IllegalGameActionException(g, "Unavailable word count");
}
if (hint.length() > g.getConfig().getHintMaxLength()) {
throw new IllegalGameActionException(g, "Hint is too long");
}
Hint h = new Hint(hint, wordCount);
newHint(g, player, h);
newGameEvent(g, GameEvent.newHintEvent(h, player));
setPhase(g, Phase.guessing(other(player)));
}
/**
* Returns 'A', 'B', '_' depending on who should be the next ones to hint if we enter a hinting phase
*/
private char whoWouldHintNext(Grid g, char player) {
if(allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
// I can't hint anymore
return other(player);
} else {
if(g.getConfig().isHintsInARow() && !allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen())) {
return '_';
} else {
return player;
}
}
}
/**
* Enters the sudden death phase
*/
private void enterSuddenDeathInternal(Grid g) {
newGameEvent(g, GameEvent.newSuddenDeath());
if(allGreenRevealed(g, 'A', g.getConfig().isOnlyOneGreenGreen())) {
// If all 'A''s greens are revealed, A is the only one to guess
setPhase(g, Phase.suddenDeathOnly('A'));
} else {
// Else, we are both guessing
setPhase(g, Phase.SUDDEN_DEATH_BOTH);
}
}
/**
Sets the phase of the grid when player ends its guessing round.
@param g Must neither be ended neither be in a sudden death
*/
private void endGuessingRound(Grid g, char player) {
// We're done with this round of guesses
if(g.getUsedHints() >= g.getConfig().getMaxHint()) {
if(g.getUsedHints() >= g.getConfig().getMaxMaxHint()) {
if(!g.getConfig().isSuddenDeath()) {
// We're entering sudden death
enterSuddenDeathInternal(g);
} else {
// Only sudden death can get us out
setPhase(g, Phase.NEED_SUDDEN_DEATH);
}
} else {
// We are waiting for more hints
if(g.getConfig().isSuddenDeath()) {
setPhase(g, Phase.needMoreHints(whoWouldHintNext(g, player)));
} else {
setPhase(g, Phase.needMoreHintsOrSuddenDeath(whoWouldHintNext(g, player)));
}
}
} else {
// Next regular hinting round
setPhase(g, Phase.hinting(whoWouldHintNext(g, player)));
}
}
public Card.Color pointCard(Grid g, char player, int cardIndex) throws IllegalGameActionException {
if (!g.getPhase().canPlay() || !g.getPhase().isGuessing(player)) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (cardIndex<0 || cardIndex>getCardCount(g)) {
throw new IllegalArgumentException("The given card index is invalid");
}
Card c = getCard(g, cardIndex);
if(c.isColorPublic(other(player)) || (g.getConfig().isOnlyOneGreenGreen() && c.getColor(player) == Card.Color.GREEN && c.isColorPublic(player)))
throw new IllegalGameActionException(g, "Card has already been revealed");
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){
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (g.getPhase().isSuddenDeath() && guessedColor == Color.WHITE) {
if(g.getConfig().isOnlyOneGreenGreenPreventDeath() && onlyOneGreenGreenWouldWin(g)) {
setPhase(g, Phase.WIN_IF_GREEN_GREEN);
} else {
endGame(g, false);
}
} else if (allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// i.e. i made my last guess
if (allGreenRevealed(g, player, g.getConfig().isOnlyOneGreenGreen())) {
endGame(g, true);
} else if (g.getPhase().isSuddenDeath()) {
// Sudden death with only the other
setPhase(g, Phase.suddenDeathOnly(other(player)));
} else {
endGuessingRound(g, player);
}
} else if (!allGreenRevealed(g, other(player), g.getConfig().isOnlyOneGreenGreen()) && guessedColor == Color.GREEN) {
// There is still cards i should find
if (!g.getPhase().isSuddenDeath() && !g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), g.getConfig().isOneMoreGuess())) {
if(!g.getConfig().isOneMoreGuess() && g.getCurrentHint().canStillGuess(g.getCurrentGuessCount(), true)) {
// Having one more guess could make us continue
setPhase(g, Phase.waitingForOneMore(player));
} else {
// We're done with this round of guesses anyway
endGuessingRound(g, player);
}
} else {
// I continue guessing cards in this round
}
} else if (guessedColor == Color.WHITE && !g.getPhase().isSuddenDeath()){
endGuessingRound(g, player);
} else {
throw new IllegalStateException("This should logically never happen");
}
if(!g.getPhase().isAnyoneGuessing() && !g.getPhase().isWaitingForOneMore())
// It means this pick ended the guessing turn
newGameEvent(g, GameEvent.newEndGuessingEvent(false, player));
return guessedColor;
}
public void endGuessing(Grid g, char player) throws IllegalGameActionException {
if (!g.getPhase().canPlay() || (!g.getPhase().isGuessing(player) && !g.getPhase().isWaitingForOneMore())) {
throw new IllegalGameActionException(g, "This action is not allowed now");
}
if (!g.getConfig().isEndGuessing() && !g.getPhase().isWaitingForOneMore()) {
throw new IllegalGameActionException(g, "Cannot end guessing in this game");
}
endGuessingRound(g, player);
newGameEvent(g, GameEvent.newEndGuessingEvent(true, player));
}
public void enterSuddenDeath(Grid g) throws IllegalGameActionException {
if(!g.getPhase().isNeedMoreHints()) {
throw new IllegalGameActionException(g, "You can only enter sudden death when being out of hints");
}
enterSuddenDeathInternal(g);
}
/**
* UPDATE GAME CONFIG
*/
public void unlock(Grid g, MutableOption opt, Object o) throws IllegalGameActionException {
switch(opt) {
case MutableOption.MAX_HINT:
unlockHintCount(g, (Integer)o);
break;
case MutableOption.AVAILABLE_HINT_WORD_COUNT:
unlockNewWordCounts(g, ((Set<?>)o).stream().map(i -> (Integer)i).collect(Collectors.toSet()));
break;
case MutableOption.ONE_MORE_GUESS:
unlockOneMoreGuess(g);
break;
case MutableOption.HINT_MAX_LENGTH:
unlockHintMaxLength(g, (Integer)o);
break;
case MutableOption.END_GUESSING:
unlockEndGuessing(g);
break;
case MutableOption.ONLY_ONE_GREEN_GREEN:
unlockOnlyOneGreenGreen(g);
break;
case MutableOption.SUDDEN_DEATH:
unlockSuddenDeath(g);
break;
case MutableOption.HINTS_IN_A_ROW:
unlockHintsInARow(g);
break;
default:
throw new IllegalStateException("Unknown MutableOption to edit");
}
}
public void unlockHintCount(Grid g, int newHintCount) throws IllegalGameActionException {
if(newHintCount > g.getConfig().getMaxMaxHint()) {
throw new IllegalGameActionException(g, "Hint count higher than highest 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 (%s) is not a strict superset of (%s)"
.formatted(newAvailableHintWordCounts, g.getConfig().getAvailableHintWordCount())
);
}
changeConfig(g, MutableOption.AVAILABLE_HINT_WORD_COUNT, newAvailableHintWordCounts);
}
public void unlockOneMoreGuess(Grid g) throws IllegalGameActionException {
if(g.getConfig().isOneMoreGuess())
throw new IllegalGameActionException(g, "Cannot unlock oneMoreGuess as it is already unlocked");
changeConfig(g, MutableOption.ONE_MORE_GUESS, true);
if(g.getPhase().isWaitingForOneMore()) {
setPhase(g, Phase.guessing(g.getPhase().getGuessing()));
}
}
public void unlockHintMaxLength(Grid g, int newHintMaxLength) throws IllegalGameActionException {
if(g.getConfig().getHintMaxLength() >= newHintMaxLength) {
throw new IllegalGameActionException(g, "Can only raise max hint length");
}
changeConfig(g, MutableOption.HINT_MAX_LENGTH, newHintMaxLength);
}
public void unlockEndGuessing(Grid g) throws IllegalGameActionException {
if(g.getConfig().isEndGuessing())
throw new IllegalGameActionException(g, "Cannot unlock endGuessing as it is already unlocked");
changeConfig(g, MutableOption.END_GUESSING, true);
}
public void unlockOnlyOneGreenGreen(Grid g) throws IllegalGameActionException {
if(g.getConfig().isOnlyOneGreenGreen())
throw new IllegalGameActionException(g, "Cannot unlock onlyOneGreenGreen as it is already unlocked");
changeConfig(g, MutableOption.ONLY_ONE_GREEN_GREEN, true);
if(g.getPhase() == Phase.WIN_IF_GREEN_GREEN) {
endGame(g, true);
}
}
public void unlockSuddenDeath(Grid g) throws IllegalGameActionException {
if(g.getConfig().isSuddenDeath())
throw new IllegalGameActionException(g, "Cannot unlock suddenDeath as it is already unlocked");
changeConfig(g, MutableOption.SUDDEN_DEATH,true);
if (g.getPhase().isNeedMoreHintsOrSuddenDeath()) {
setPhase(g, Phase.needMoreHints(g.getPhase().getHinting()));
} else if (g.getPhase() == Phase.NEED_SUDDEN_DEATH) {
// We're entering sudden death
enterSuddenDeathInternal(g);
}
}
public void unlockHintsInARow(Grid g) throws IllegalGameActionException {
if(g.getConfig().isHintsInARow())
throw new IllegalGameActionException(g, "Cannot unlock hintsInARow as it is already unlocked");
changeConfig(g, MutableOption.HINTS_IN_A_ROW,true);
if ((g.getPhase() == Phase.HINTING_A || g.getPhase() == Phase.HINTING_B)
&& !allGreenRevealed(g, 'A', g.getConfig().isOnlyOneGreenGreen())
&& !allGreenRevealed(g, 'B', g.getConfig().isOnlyOneGreenGreen())) {
setPhase(g, Phase.HINTING_BOTH);
}
}
}
@@ -1,14 +1,31 @@
package com.bernard.nodecames.model;
import java.util.UUID;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Card {
@EmbeddedId
CardId id;
@MapsId("gridId")
@ManyToOne
Grid grid;
String word;
Color colorA;
@@ -16,8 +33,12 @@ public class Card {
boolean colorAPublic;
boolean colorBPublic;
public Card(String word, Color colorA, Color colorB) {
this(word, colorA, colorB, false, false);
public Card(Grid g, int index, String word, Color colorA, Color colorB) {
this(new CardId(g.getId(), index), g, word, colorA, colorB, false, false);
}
public int getIndex() {
return this.getId().getIndex();
}
public Color getColor(char player) {
@@ -40,9 +61,29 @@ public class Card {
isColorAPublic()?'O':'X', isColorBPublic()?'O':'X');
}
@AllArgsConstructor
@Getter
public enum Color {
GREEN,
WHITE,
BLACK;
GREEN('G'),
WHITE('W'),
BLACK('B');
private char letter;
public static final Color findByLetter(char letter) {
for(Color c : Color.values()) {
if(c.letter == letter)
return c;
}
return null;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CardId {
UUID gridId;
int index;
}
}
@@ -0,0 +1,167 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import com.bernard.nodecames.model.Card.Color;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Value;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.deser.std.StdDeserializer;
@Value(staticConstructor = "of")
@JsonDeserialize(using = ColorDistribution.Deserializer.class)
public class ColorDistribution {
public static final ColorDistribution CUSTOM6 =
makeDistribution(0, 0, 2, 1, 1, 0);
public static final ColorDistribution CUSTOM9 =
makeDistribution(0, 1, 1, 2, 2, 1);
public static final ColorDistribution CUSTOM12 =
makeDistribution(1, 1, 1, 2, 3, 1);
public static final ColorDistribution CUSTOM16 =
makeDistribution(1, 1, 2, 2, 5, 1);
public static final ColorDistribution CUSTOM20 =
makeDistribution(1, 1, 4, 2, 5, 1);
public static final ColorDistribution CLASSICAL25 =
makeDistribution(1, 1, 5, 3, 7, 1);
public static final ColorDistribution CUSTOM30 =
makeDistribution(2, 2, 5, 3, 7, 1);
public static final ColorDistribution CUSTOM36 =
makeDistribution(2, 2, 6, 5, 9, 2);
private final Map<CardColors, Integer> colors;
public List<CardColors> asList() {
List<CardColors> out = new ArrayList<>();
for(Entry<CardColors, Integer> e : this.colors.entrySet()) {
for(int i=0;i<e.getValue();i++) {
out.add(e.getKey());
}
}
return Collections.unmodifiableList(out);
}
public int anyGreenCount() {
return this.colors.get(CardColors.GG) +
this.colors.get(CardColors.WG) +
this.colors.get(CardColors.GW) +
this.colors.get(CardColors.GB) +
this.colors.get(CardColors.BG);
}
public int wordCount() {
return (int)this.colors.values().stream().collect(Collectors.summarizingInt(i -> i)).getSum();
}
public static final ColorDistribution makeDistribution(
int wbbwCount, int bggbCount, int wggwCount,
int ggCount, int wwCount, int bbCount
) {
return new ColorDistribution(Map.of(
CardColors.BB, bbCount,
CardColors.WW, wwCount,
CardColors.GG, ggCount,
CardColors.WB, wbbwCount,
CardColors.BW, wbbwCount,
CardColors.GB, bggbCount,
CardColors.BG, bggbCount,
CardColors.GW, wggwCount,
CardColors.WG, wggwCount
));
}
@Value(staticConstructor = "of")
public static final class CardColors implements Comparable<CardColors> {
private Color faceA;
private Color faceB;
public static final CardColors WW = CardColors.of(Color.WHITE, Color.WHITE);
public static final CardColors WB = CardColors.of(Color.WHITE, Color.BLACK);
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);
@Override
public int compareTo(CardColors o) {
int i = this.getFaceA().compareTo(o.getFaceA());
return i!=0?i:this.getFaceB().compareTo(o.getFaceB());
}
}
/*
* JSON conversion
*/
private static String cardColorsToString(CardColors cc, Integer i) {
return "%s%s%d".formatted(cc.getFaceA().getLetter(), cc.getFaceB().getLetter(), i);
}
private static CardColors parseCardColors(String s) {
if(s.length()<3)
throw new IllegalArgumentException("Malformed string");
char lA = s.charAt(0);
char lB = s.charAt(1);
Color cA = Color.findByLetter(lA);
Color cB = Color.findByLetter(lB);
if (cA == null)
throw new IllegalArgumentException("Could not find color corresponding to '"+lA+"' in string '" + s + "'");
if (cB == null)
throw new IllegalArgumentException("Could not find color corresponding to '"+lB+"'");
return CardColors.of(cA, cB);
}
private static Integer parseCount(String s) {
if(s.length()<3)
throw new IllegalArgumentException("Malformed string");
String number = s.substring(2);
return Integer.parseInt(number, 10);
}
@JsonValue
@Override
public String toString() {
return this.getColors().entrySet().stream()
.sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey()))
.map(e -> cardColorsToString(e.getKey(), e.getValue()))
.collect(Collectors.joining(";"));
}
@JsonCreator
public static ColorDistribution ofString(String str) {
return ColorDistribution.of(Arrays.stream(str.split(";"))
.collect(Collectors.toMap(
ColorDistribution::parseCardColors,
ColorDistribution::parseCount
)));
}
public static class Deserializer extends StdDeserializer<ColorDistribution> {
public Deserializer() {
super(ColorDistribution.class);
}
@Override
public ColorDistribution deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
String s = p.getString();
return ColorDistribution.ofString(s);
}
}
}
@@ -1,23 +1,43 @@
package com.bernard.nodecames.model;
import java.util.Set;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import jakarta.persistence.OneToMany;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Game {
@Id
@GeneratedValue
private Long id;
private UUID id;
private String name;
@JdbcTypeCode(SqlTypes.JSON)
private GameConfig config;
@JdbcTypeCode(SqlTypes.JSON)
private Inventory inventory = Inventory.empty();
@OneToMany(mappedBy = "game")
Set<World> worlds = null;
public Game(UUID id, String name, GameConfig config) {
this();
this.setId(id);
this.setName(name);
this.setConfig(config);
}
String name;
}
@@ -0,0 +1,57 @@
package com.bernard.nodecames.model;
import java.util.Map;
import java.util.UUID;
import com.bernard.nodecames.repository.GameRepository;
import com.bernard.nodecames.repository.WorldRepository;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.Value;
@Value
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class GameConfig {
@JsonProperty("worlds")
private final Map<String, WorldConfig> worlds;
//TODO we might have a solution without passing repositories as parameters
public Game newGame(GameRepository gRepository, WorldRepository wRepository, String name) {
UUID id = UUID.randomUUID();
Game g = new Game(id, name, this);
gRepository.save(g);
for(String wName : worlds.keySet()) {
WorldConfig wc = worlds.get(wName);
World w = wc.newWorld(g, wName);
wRepository.save(w);
}
return g;
}
public static final GameConfig DEFAULT_GAME_CONFIG = new GameConfig(Map.ofEntries(
Map.entry("1-1", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM6)),
Map.entry("1-2", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM9)),
Map.entry("1-3", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM9)),
Map.entry("2-1", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM12)),
Map.entry("2-2", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM16)),
Map.entry("2-3", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM16)),
Map.entry("3-1", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM20)),
Map.entry("3-2", WorldConfig.zeroWithSize(ColorDistribution.CLASSICAL25)),
Map.entry("3-3", WorldConfig.zeroWithSize(ColorDistribution.CLASSICAL25)),
Map.entry("4-1", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM30)),
Map.entry("4-2", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM36)),
Map.entry("4-3", WorldConfig.zeroWithSize(ColorDistribution.CUSTOM36))
));
public static final GameConfig REGULAR_GAME_CONFIG = new GameConfig(Map.ofEntries(
Map.entry("1", WorldConfig.classicalWithSize(ColorDistribution.CUSTOM9, 4)),
Map.entry("2", WorldConfig.classicalWithSize(ColorDistribution.CUSTOM16, 7)),
Map.entry("3", WorldConfig.classicalWithSize(ColorDistribution.CLASSICAL25, 9)),
Map.entry("4", WorldConfig.classicalWithSize(ColorDistribution.CUSTOM36, 15))
));
}
@@ -1,49 +1,92 @@
package com.bernard.nodecames.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import com.bernard.nodecames.model.Card.Color;
@Getter
public class GameEvent {
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
Type type;
Object data;
// 'A' for player A, 'B' for player B, '_' for none
char issuer;
private GameEvent(Type type, Object data, char issuer){
this.data = data;
this.issuer = issuer;
this.type = type;
}
public sealed interface GameEvent {
public static GameEvent newHintEvent(Hint h, char issuer) {
return new GameEvent(Type.HINT, h, issuer);
return new HintEvent(issuer, h);
}
public static GameEvent newGuessEvent(Card c, char issuer) {
return new GameEvent(Type.GUESS, c, issuer);
public static GameEvent newGuessEvent(int index, char issuer, Color color) {
return new GuessEvent(issuer, index, color);
}
public static GameEvent newEndGuessingEvent(boolean manual, char issuer) {
return new GameEvent(Type.END_GUESSING, manual, issuer);
return new EndGuessingEvent(issuer, manual);
}
public static GameEvent newGameEndEvent(boolean won) {
return new GameEvent(Type.GAME_END, won, '_');
return new GameEndEvent(won);
}
public static GameEvent newSuddenDeath() {
return new GameEvent(Type.SUDDEN_DEATH, Object.class, '_');
return new EnterSuddenDeathEvent();
}
@AllArgsConstructor
public enum Type {
HINT(Hint.class),
GUESS(Card.class),
END_GUESSING(Boolean.class),
GAME_END(Boolean.class),
SUDDEN_DEATH(Object.class);
Class<?> dataType;
public static JsonNode toJsonNode(GameEvent ge) {
JsonNodeFactory jsn = JsonNodeFactory.instance;
ObjectNode o = jsn.objectNode();
o.set("type", jsn.stringNode(ge.getClass().getSimpleName()));
switch (ge) {
case HintEvent(char issuer, Hint hint):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("word", jsn.stringNode(hint.getWord()));
o.set("word-count", jsn.numberNode(hint.getWordCount()));
break;
case GuessEvent(char issuer, int index, Color color):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("card-index", jsn.numberNode(index));
o.set("color", jsn.stringNode(color.name().toLowerCase()));
break;
case EndGuessingEvent(char issuer, boolean manual):
o.set("issuer", jsn.stringNode(Character.toString(issuer)));
o.set("manual", jsn.booleanNode(manual));
break;
case GameEndEvent(boolean win):
o.set("win", jsn.booleanNode(win));
break;
case EnterSuddenDeathEvent():
break;
}
return o;
}
public static GameEvent fromJsonNode(JsonNode o) {
String type = o.get("type").stringValue();
return switch(type) {
case "HintEvent" ->
new HintEvent(
o.get("issuer").asString().charAt(0),
new Hint(
o.get("word").stringValue(),
o.get("word-count").intValue()
)
);
case "GuessEvent" ->
new GuessEvent(
o.get("issuer").asString().charAt(0),
o.get("card-index").intValue(),
Color.valueOf(o.get("color").stringValue().toUpperCase())
);
case "EndGuessingEvent" ->
new EndGuessingEvent(
o.get("issuer").asString().charAt(0),
o.get("manual").booleanValue()
);
case "GameEndEvent" ->
new GameEndEvent(
o.get("win").booleanValue()
);
case "EnterSuddenDeathEvent" ->
new EnterSuddenDeathEvent();
default -> throw new IllegalArgumentException("Unknown event type : " + type);
};
}
}
record HintEvent(char issuer, Hint hint) implements GameEvent {}
record GuessEvent(char issuer, int index, Color color) implements GameEvent {}
record EndGuessingEvent(char issuer, boolean manual) implements GameEvent {}
record GameEndEvent(boolean win) implements GameEvent {}
record EnterSuddenDeathEvent() implements GameEvent {}
@@ -1,24 +1,50 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import jakarta.annotation.Nullable;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Converter;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Grid {
String id;
@Id
UUID id;
Card[] cards;
@ManyToOne(optional = true)
World w;
@Column(columnDefinition = "json")
@Convert(converter = GameEventListAdapter.class)
@JdbcTypeCode(SqlTypes.JSON)
List<GameEvent> gameEvents;
@Embedded
Hint currentHint = null;
int currentGuessCount;
@@ -26,10 +52,18 @@ public class Grid {
int usedWhites;
Phase phase;
Optional<Boolean> won;
//TODO make this parameter a real Optional<Boolean> with Jdbc understanding that optional means nullable
@Nullable
Boolean won;
@Embedded
GridConfig config;
public Grid(UUID id, World w, Phase phase, GridConfig config) {
this(id, w, List.of(), null, 0, 0, 0, phase, null, config);
}
public void incrementHintCount() {
this.setUsedHints(this.getUsedHints()+1);
}
@@ -40,10 +74,14 @@ public class Grid {
this.setCurrentGuessCount(this.getCurrentGuessCount()+1);
}
public void newGameEvent(GameEvent event) {
this.gameEvents = new ArrayList<>(gameEvents);
this.gameEvents.add(event);
}
public int getCardCount() {
return this.getCards().length;
public Optional<Boolean> isWonOpt() {
return Optional.ofNullable(this.won);
}
public void setWonOpt(Optional<Boolean> won) {
this.won = won.orElse(null);
}
@AllArgsConstructor
@@ -121,4 +159,25 @@ public class Grid {
}
}
@RequiredArgsConstructor
@Converter
public static class GameEventListAdapter implements AttributeConverter<List<GameEvent>, JsonNode> {
private final JsonNodeFactory jsn;
@Override
public JsonNode convertToDatabaseColumn(List<GameEvent> attribute) {
ArrayNode out = jsn.arrayNode(attribute.size());
for(GameEvent ge : attribute) {
out.add(GameEvent.toJsonNode(ge));
}
return out;
}
@Override
public List<GameEvent> convertToEntityAttribute(JsonNode dbData) {
return dbData.asArray().valueStream().map(GameEvent::fromJsonNode).toList();
}
}
}
@@ -1,62 +1,48 @@
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 com.bernard.nodecames.model.converter.ColorDistributionConverter;
import jakarta.persistence.Convert;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Value;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Embeddable
public class GridConfig {
public static final List<CardColors> CLASSICAL25 = List.of(
BB,BW,WB,BG,GB,GG,GG,GG,WG,WG,WG,WG,WG,GW,GW,GW,GW,GW,WW,WW,WW,WW,WW,WW,WW
);
public static final 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);
public static final GridConfig ZERO =
WorldConfig.zeroWithSize(ColorDistribution.CLASSICAL25).makeGridConfig(Inventory.empty());
public static final GridConfig REGULAR =
WorldConfig.classicalWithSize(ColorDistribution.CLASSICAL25, 9).makeGridConfig(Inventory.empty());
public static final GridConfig BIG =
WorldConfig.classicalWithSize(ColorDistribution.CLASSICAL25, 9).makeGridConfig(Inventory.empty());
// 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;
@Convert(converter = ColorDistributionConverter.class)
private ColorDistribution colorDistribution;
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;
// Game mutable options
@Setter
private boolean structuredGrid;
@Setter
private boolean anyoneStarts;
// Mutable options
private int maxHint;
@@ -68,10 +54,6 @@ public class GridConfig {
private boolean suddenDeath;
private boolean hintsInARow;
public int getWordCount() {
return wordDistribution.size();
}
public void set(MutableOption opt, Object value) {
try{
switch(opt) {
@@ -123,24 +105,9 @@ public class GridConfig {
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
//TODO do record type
public enum MutableOption {
MAX_HINT(Integer.class),
AVAILABLE_HINT_WORD_COUNT(Set.class),
@@ -1,10 +1,16 @@
package com.bernard.nodecames.model;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Embeddable
public class Hint {
String word;
@@ -0,0 +1,131 @@
package com.bernard.nodecames.model;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
@Getter
public class Inventory {
// List of items ids in reception order
private List<String> receivedItems = new ArrayList<>();
private int extraHintCount = 0;
private boolean extraStructuredGrid = false;
private boolean extraAnyoneStarts = false;
private Set<Integer> extraHintWordCounts = new HashSet<>();
private boolean extraOneMoreGuess = false;
private int extraHintMaxLength = 0;
private boolean extraEndGuessing = false;
private boolean extraOnlyOneGreenGreen = false;
private boolean extraSuddenDeath = false;
private boolean extraHintsInARow = false;
private Set<String> unlockedWorlds = new HashSet<>();
public Inventory(List<String> items) {
receivedItems = new ArrayList<>(items);
for(String item : items)
receiveItem(item);
}
private Inventory() {}
public static Inventory empty() {
return new Inventory();
}
private static final String UNLOCK_WORLD_ITEM_PREFIX = "unlock_world_";
public void receiveItem(String item) {
receivedItems.add(item);
switch(item) {
case "progressive_hint_count":
extraHintCount++;
break;
case "progressive_hint_word_length":
extraHintMaxLength++;
break;
case "progressive_hint_word_count":
//TODO get default value from game config
int currentMaxHintWordCount = extraHintWordCounts.stream().mapToInt(i -> i).max().orElse(1);
extraHintWordCounts.add(currentMaxHintWordCount+1);
break;
case "zero_hint":
extraHintWordCounts.add(0);
break;
case "infinity_hint":
extraHintWordCounts.add(-1);
break;
case "one_more_guess":
extraOneMoreGuess = true;
break;
case "end_guessing":
extraEndGuessing = true;
break;
case "only_one_green_green":
extraOnlyOneGreenGreen = true;
break;
case "sudden_death":
extraSuddenDeath = true;
break;
case "structured_grid":
extraStructuredGrid = true;
break;
case "hints_in_a_row":
extraHintsInARow = true;
break;
case "anyone_starts":
extraAnyoneStarts = true;
break;
case "filler":
// We do nothing if we receive a filler item
break;
default:
if (item.startsWith(UNLOCK_WORLD_ITEM_PREFIX)) {
String worldName = item.substring(UNLOCK_WORLD_ITEM_PREFIX.length());
this.unlockedWorlds.add(worldName);
} else {
throw new IllegalArgumentException("Received unknown item : "+item);
}
}
}
@RequiredArgsConstructor
@Converter
public static class InventoryConverter implements AttributeConverter<Inventory, JsonNode> {
private final JsonNodeFactory jsn;
@Override
public JsonNode convertToDatabaseColumn(Inventory inv) {
ArrayNode out = jsn.arrayNode();
for(String s : inv.receivedItems) {
out.add(jsn.stringNode(s));
}
return out;
}
@Override
public Inventory convertToEntityAttribute(JsonNode json) {
ArrayNode arr = json.asArray();
List<String> items = new ArrayList<>(arr.size());
for(JsonNode itemNode : arr)
items.add(itemNode.stringValue());
return new Inventory(items);
}
}
}
@@ -0,0 +1,66 @@
package com.bernard.nodecames.model;
import java.util.UUID;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Entity
@NoArgsConstructor
public class World {
@EmbeddedId
WorldId id;
@MapsId("gameId")
@ManyToOne
Game game;
@ManyToOne(optional = true)
Grid currentGrid;
boolean won;
boolean locked;
public World(Game g, String name, Grid currentGrid, boolean won, boolean locked) {
this.id = new WorldId(g.getId(), name);
this.game = g;
this.currentGrid = currentGrid;
this.won = won;
this.locked = locked;
}
public String getName() {
return this.getId().getName();
}
public WorldConfig getConfig() {
return this.getGame().getConfig().getWorlds().get(this.getName());
}
public GridConfig makeGridConfig() {
WorldConfig wc = getConfig();
return wc.makeGridConfig(this.getGame().getInventory());
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public static class WorldId {
UUID gameId;
String name;
}
}
@@ -0,0 +1,205 @@
package com.bernard.nodecames.model;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import com.bernard.nodecames.model.GridConfig.MutableOption;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.Value;
@Value
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class WorldConfig {
@JsonProperty("color_distribution")
private ColorDistribution colorDistribution;
// Base is with no received items
// Max is either a cap when items are received
@JsonProperty("base_hint_count")
private int baseHintCount;
@JsonProperty("max_hint_count")
private int maxHintCount;
@JsonProperty("base_structured_grid")
private boolean baseStructuredGrid;
@JsonProperty("max_structured_grid")
private boolean maxStructuredGrid;
@JsonProperty("base_anyone_starts")
private boolean baseAnyoneStarts;
@JsonProperty("max_anyone_starts")
private boolean maxAnyoneStarts;
@JsonProperty("base_hint_word_counts")
private Set<Integer> baseHintWordCounts;
@JsonProperty("max_hint_word_counts")
private Set<Integer> maxHintWordCounts;
@JsonProperty("base_one_more_guess")
private boolean baseOneMoreGuess;
@JsonProperty("max_one_more_guess")
private boolean maxOneMoreGuess;
@JsonProperty("base_hint_max_length")
private int baseHintMaxLength;
@JsonProperty("max_hint_max_length")
private int maxHintMaxLength;
@JsonProperty("base_end_guessing")
private boolean baseEndGuessing;
@JsonProperty("max_end_guessing")
private boolean maxEndGuessing;
@JsonProperty("base_only_one_green_green")
private boolean baseOnlyOneGreenGreen;
@JsonProperty("max_only_one_green_green")
private boolean maxOnlyOneGreenGreen;
@JsonProperty("base_sudden_death")
private boolean baseSuddenDeath;
@JsonProperty("max_sudden_death")
private boolean maxSuddenDeath;
@JsonProperty("base_hints_in_a_row")
private boolean baseHintsInARow;
@JsonProperty("max_hints_in_a_row")
private boolean maxHintsInARow;
@JsonProperty("base_unlocked")
private boolean baseUnlocked;
@JsonProperty("only_one_green_green_prevents_death")
private boolean onlyOneGreenGreenPreventsDeath;
public boolean isStructuredGrid(Inventory i) {
return this.baseStructuredGrid || this.maxStructuredGrid && i.isExtraStructuredGrid();
}
public boolean isAnyoneStarts(Inventory i) {
return this.baseAnyoneStarts || this.maxAnyoneStarts && i.isExtraAnyoneStarts();
}
public boolean isEndGuessing(Inventory i) {
return this.baseEndGuessing || this.maxEndGuessing && i.isExtraEndGuessing();
}
public boolean isOnlyOneGreenGreen(Inventory i) {
return this.baseOnlyOneGreenGreen || this.maxOnlyOneGreenGreen && i.isExtraOnlyOneGreenGreen();
}
public boolean isSuddenDeath(Inventory i) {
return this.baseSuddenDeath || this.maxSuddenDeath && i.isExtraSuddenDeath();
}
public boolean isOneMoreGuess(Inventory i) {
return this.baseOneMoreGuess || this.maxOneMoreGuess && i.isExtraOneMoreGuess();
}
public boolean isHintsInARow(Inventory i) {
return this.baseHintsInARow || this.maxHintsInARow && i.isExtraHintsInARow();
}
public int getHintCount(Inventory i) {
return Math.min(this.baseHintCount + i.getExtraHintCount(), this.maxHintCount);
}
public int getHintMaxLength(Inventory i) {
return Math.min(this.baseHintMaxLength + i.getExtraHintMaxLength(), this.maxHintMaxLength);
}
public Set<Integer> getHintWordCounts(Inventory i) {
HashSet<Integer> out = new HashSet<>();
out.addAll(baseHintWordCounts);
out.addAll(i.getExtraHintWordCounts());
out.retainAll(maxHintWordCounts);
return Collections.unmodifiableSet(out);
}
/**
* Makes a GridConfig for the given inventory
*/
public GridConfig makeGridConfig(Inventory inv) {
return new GridConfig(
maxHintCount,
colorDistribution,
maxSuddenDeath,
maxOneMoreGuess,
onlyOneGreenGreenPreventsDeath,
isStructuredGrid(inv),
isAnyoneStarts(inv),
getHintCount(inv),
getHintWordCounts(inv),
isOneMoreGuess(inv),
getHintMaxLength(inv),
isEndGuessing(inv),
isOnlyOneGreenGreen(inv),
isSuddenDeath(inv),
isHintsInARow(inv)
);
}
/**
* Returns the set of mutable options to be modified according to this new inventory
*/
public Map<MutableOption, Object> updateConfig(GridConfig b, Inventory i) {
Map<MutableOption, Object> out = new EnumMap<>(MutableOption.class);
int hintCount = this.getHintCount(i);
if(b.getMaxHint() != hintCount)
out.put(MutableOption.MAX_HINT, hintCount);
Set<Integer> availableHintWordCount = this.getHintWordCounts(i);
if(!b.getAvailableHintWordCount().containsAll(availableHintWordCount))
out.put(MutableOption.AVAILABLE_HINT_WORD_COUNT, availableHintWordCount);
int hintMaxLength = this.getHintMaxLength(i);
if(b.getHintMaxLength() != hintMaxLength)
out.put(MutableOption.HINT_MAX_LENGTH, hintMaxLength);
boolean oneMoreGuess = this.isOneMoreGuess(i);
if(b.isOneMoreGuess() != oneMoreGuess)
out.put(MutableOption.ONE_MORE_GUESS, oneMoreGuess);
boolean endGuessing = this.isEndGuessing(i);
if(b.isEndGuessing() != endGuessing)
out.put(MutableOption.END_GUESSING, endGuessing);
boolean onlyOneGreenGreen = this.isOnlyOneGreenGreen(i);
if(b.isOnlyOneGreenGreen() != onlyOneGreenGreen)
out.put(MutableOption.ONLY_ONE_GREEN_GREEN, onlyOneGreenGreen);
boolean suddenDeath = this.isSuddenDeath(i);
if(b.isSuddenDeath() != suddenDeath)
out.put(MutableOption.SUDDEN_DEATH, suddenDeath);
boolean hintsInARow = this.isHintsInARow(i);
if(b.isHintsInARow() != hintsInARow)
out.put(MutableOption.HINTS_IN_A_ROW, hintsInARow);
return out;
}
public static final WorldConfig zeroWithSize(ColorDistribution colors) {
return new WorldConfig(
colors,
1, 15,
false, true,
false, true,
Set.of(1), IntStream.range(-1, colors.anyGreenCount()+1).mapToObj(i->i).collect(Collectors.toSet()),
false, true,
3, 20,
false, true,
false, true,
false, true,
false, true,
true, false);
}
public static final WorldConfig classicalWithSize(ColorDistribution colors, int hintCount) {
int cardsToFind = colors.anyGreenCount();
Set<Integer> hintWordCounts = IntStream.range(-1, cardsToFind+1).mapToObj(i -> i).collect(Collectors.toSet());
return new WorldConfig(
colors,
hintCount, hintCount,
true, true,
true, true,
hintWordCounts, hintWordCounts,
true, true,
100, 100,
true, true,
true, true,
true, true,
false, false,
true, false);
}
public World newWorld(Game g, String name) {
return new World(g, name, null, false, !baseUnlocked);
}
}
@@ -0,0 +1,22 @@
package com.bernard.nodecames.model.converter;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.ColorDistribution;
import jakarta.persistence.AttributeConverter;
@Service
public class ColorDistributionConverter implements AttributeConverter<ColorDistribution, String> {
@Override
public String convertToDatabaseColumn(ColorDistribution attribute) {
return attribute.toString();
}
@Override
public ColorDistribution convertToEntityAttribute(String dbData) {
return ColorDistribution.ofString(dbData);
}
}
@@ -0,0 +1,42 @@
package com.bernard.nodecames.model.converter;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.GameConfig;
import com.bernard.nodecames.model.WorldConfig;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.AllArgsConstructor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@AllArgsConstructor
@Converter
@Service
public class GameConfigConverter implements AttributeConverter<GameConfig, JsonNode> {
private final JsonNodeFactory jsn;
private final WorldConfigConverter wcc;
@Override
public JsonNode convertToDatabaseColumn(GameConfig gc) {
ObjectNode o = jsn.objectNode();
for(Entry<String,WorldConfig> e : gc.getWorlds().entrySet())
o.set(e.getKey(), wcc.convertToDatabaseColumn(e.getValue()));
return o;
}
@Override
public GameConfig convertToEntityAttribute(JsonNode json) {
Map<String, WorldConfig> configs = json.asObject().propertyStream()
.collect(Collectors.toMap(Entry::getKey, e -> wcc.convertToEntityAttribute(e.getValue())));
return new GameConfig(configs);
}
}
@@ -0,0 +1,25 @@
package com.bernard.nodecames.model.converter;
import org.springframework.stereotype.Service;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service
@Converter
public class JsonNodeConverter implements AttributeConverter<JsonNode, String> {
ObjectMapper om = new ObjectMapper();
@Override
public String convertToDatabaseColumn(JsonNode attribute) {
return om.writeValueAsString(attribute);
}
@Override
public JsonNode convertToEntityAttribute(String dbData) {
return om.readTree(dbData);
}
}
@@ -0,0 +1,92 @@
package com.bernard.nodecames.model.converter;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.bernard.nodecames.model.WorldConfig;
import com.bernard.nodecames.util.Utils;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.AllArgsConstructor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@AllArgsConstructor
@Converter
@Service
public class WorldConfigConverter implements AttributeConverter<WorldConfig, JsonNode> {
private final JsonNodeFactory jsn;
private final ColorDistributionConverter cdwdc;
public JsonNode hintWordCountsToJson(Set<Integer> hintWordCounts) {
return hintWordCounts.stream().sorted().map(jsn::numberNode).collect(Utils.jsonNodeCollector(jsn));
}
public Set<Integer> hintWordCountsOfJson(JsonNode json) {
return json.asArray().valueStream().map(JsonNode::intValue).collect(Collectors.toSet());
}
@Override
public JsonNode convertToDatabaseColumn(WorldConfig wc) {
ObjectNode out = jsn.objectNode();
out.set("color_distribution", jsn.stringNode(cdwdc.convertToDatabaseColumn(wc.getColorDistribution())));
out.set("base_hint_count", jsn.numberNode(wc.getBaseHintCount()));
out.set("max_hint_count", jsn.numberNode(wc.getMaxHintCount()));
out.set("base_structured_grid", jsn.booleanNode(wc.isBaseStructuredGrid()));
out.set("max_structured_grid", jsn.booleanNode(wc.isMaxStructuredGrid()));
out.set("base_anyone_starts", jsn.booleanNode(wc.isBaseAnyoneStarts()));
out.set("max_anyone_starts", jsn.booleanNode(wc.isMaxAnyoneStarts()));
out.set("base_hint_word_counts", hintWordCountsToJson(wc.getBaseHintWordCounts()));
out.set("max_hint_word_counts", hintWordCountsToJson(wc.getMaxHintWordCounts()));
out.set("base_one_more_guess", jsn.booleanNode(wc.isBaseOneMoreGuess()));
out.set("max_one_more_guess", jsn.booleanNode(wc.isMaxOneMoreGuess()));
out.set("base_hint_max_length", jsn.numberNode(wc.getBaseHintMaxLength()));
out.set("max_hint_max_length", jsn.numberNode(wc.getMaxHintMaxLength()));
out.set("base_end_guessing", jsn.booleanNode(wc.isBaseEndGuessing()));
out.set("max_end_guessing", jsn.booleanNode(wc.isMaxEndGuessing()));
out.set("base_only_one_green_green", jsn.booleanNode(wc.isBaseOnlyOneGreenGreen()));
out.set("max_only_one_green_green", jsn.booleanNode(wc.isMaxOnlyOneGreenGreen()));
out.set("base_sudden_death", jsn.booleanNode(wc.isBaseSuddenDeath()));
out.set("max_sudden_death", jsn.booleanNode(wc.isMaxSuddenDeath()));
out.set("base_hints_in_a_row", jsn.booleanNode(wc.isBaseHintsInARow()));
out.set("max_hints_in_a_row", jsn.booleanNode(wc.isMaxHintsInARow()));
out.set("base_unlocked", jsn.booleanNode(wc.isBaseUnlocked()));
out.set("only_one_green_green_prevents_death", jsn.booleanNode(wc.isOnlyOneGreenGreenPreventsDeath()));
return out;
}
@Override
public WorldConfig convertToEntityAttribute(JsonNode json) {
ObjectNode o = json.asObject();
return new WorldConfig(
cdwdc.convertToEntityAttribute(o.get("color_distribution").stringValue()),
o.get("base_hint_count").intValue(),
o.get("max_hint_count").intValue(),
o.get("base_structured_grid").booleanValue(),
o.get("max_structured_grid").booleanValue(),
o.get("base_anyone starts").booleanValue(),
o.get("max_anyone_starts").booleanValue(),
hintWordCountsOfJson(o.get("base_hint_word_counts")),
hintWordCountsOfJson(o.get("max_hint_word_counts")),
o.get("base_one_more_guess").booleanValue(),
o.get("max_one_more_guess").booleanValue(),
o.get("base_hint_max_length").intValue(),
o.get("max_hint_max_length").intValue(),
o.get("base_end_guessing").booleanValue(),
o.get("max_end_guessing").booleanValue(),
o.get("base_only_one_green_green").booleanValue(),
o.get("max_only_one_green_green").booleanValue(),
o.get("base_sudden_death").booleanValue(),
o.get("max_sudden_death").booleanValue(),
o.get("base_hints_in_a_row").booleanValue(),
o.get("max_hints_in_a_row").booleanValue(),
o.get("base_unlocked").booleanValue(),
o.get("only_one_green_green_prevents_death").booleanValue()
);
}
}
@@ -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 {
}
@@ -0,0 +1,13 @@
package com.bernard.nodecames.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Card;
import com.bernard.nodecames.model.Grid;
import com.bernard.nodecames.model.Card.CardId;
public interface CardRepository extends JpaRepository<Card, CardId> {
public Card[] findByGridOrderById_Index(Grid g);
public Card findByGridAndId_Index(Grid g, int index);
public int countByGrid(Grid g);
}
@@ -0,0 +1,10 @@
package com.bernard.nodecames.repository;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Game;
public interface GameRepository extends JpaRepository<Game, UUID> {
}
@@ -0,0 +1,10 @@
package com.bernard.nodecames.repository;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Grid;
public interface GridRepository extends JpaRepository<Grid, UUID> {
}
@@ -0,0 +1,15 @@
package com.bernard.nodecames.repository;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bernard.nodecames.model.Game;
import com.bernard.nodecames.model.World;
import com.bernard.nodecames.model.World.WorldId;
public interface WorldRepository extends JpaRepository<World, WorldId>{
public Optional<World> findByGameAndId_Name(Game g, String name);
public Set<World> findByGame(Game g);
}
@@ -0,0 +1,47 @@
package com.bernard.nodecames.util;
import java.util.EnumSet;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
public class Utils {
private Utils() {}
public static final Collector<JsonNode,ArrayNode,ArrayNode> jsonNodeCollector(JsonNodeFactory jsn) {
return new Collector<JsonNode,ArrayNode,ArrayNode>() {
@Override
public Supplier<ArrayNode> supplier() {
return jsn::arrayNode;
}
@Override
public BiConsumer<ArrayNode, JsonNode> accumulator() {
return ArrayNode::add;
}
@Override
public BinaryOperator<ArrayNode> combiner() {
return (a,b) -> {
a.addAll(b);
return a;
};
}
@Override
public Function<ArrayNode, ArrayNode> finisher() {
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
return EnumSet.of(Characteristics.IDENTITY_FINISH);
}
};
}
}
+63
View File
@@ -0,0 +1,63 @@
name: Tester
game: nodecames
nodecames:
progression_balancing: normal
accessibility: full
hint_word_logic_ratio: 25
start_hint_count: 1
max_hint_count: 10
start_hint_length: 3
max_hint_length: 12
start_hint_word_count: 1
max_hint_word_count: 16
worlds:
1-1:
locked: false
word_distribution: "GG1;GW2;GB0;WG2;WW1;WB0;BG0;BW0;BB0"
required: false
1-2:
locked: true
word_distribution: "GG2;GW1;GB1;WG1;WW2;WB0;BG1;BW0;BB1"
required: false
1-3:
locked: true
word_distribution: "GG2;GW1;GB1;WG1;WW2;WB0;BG1;BW0;BB1"
required: true
2-1:
locked: true
word_distribution: "GG2;GW1;GB1;WG1;WW3;WB1;BG1;BW1;BB1"
required: false
2-2:
locked: true
word_distribution: "GG2;GW2;GB1;WG2;WW5;WB1;BG1;BW1;BB1"
required: false
2-3:
locked: true
word_distribution: "GG2;GW2;GB1;WG2;WW5;WB1;BG1;BW1;BB1"
required: true
3-1:
locked: true
word_distribution: "GG2;GW4;GB1;WG4;WW5;WB1;BG1;BW1;BB1"
required: false
3-2:
locked: true
word_distribution: "GG3;GW5;GB1;WG5;WW7;WB1;BG1;BW1;BB1"
required: false
3-3:
locked: true
word_distribution: "GG3;GW5;GB1;WG5;WW7;WB1;BG1;BW1;BB1"
required: true
4-1:
locked: true
word_distribution: "GG3;GW5;GB2;WG5;WW7;WB2;BG2;BW2;BB1"
required: false
4-2:
locked: true
word_distribution: "GG5;GW6;GB2;WG6;WW9;WB2;BG2;BW2;BB2"
required: false
4-3:
locked: true
word_distribution: "GG5;GW6;GB2;WG6;WW9;WB2;BG2;BW2;BB2"
required: true
@@ -0,0 +1,8 @@
create table card (index integer not null, colora smallint check ((colora between 0 and 2)), colorapublic boolean not null, colorb smallint check ((colorb between 0 and 2)), colorbpublic boolean not null, word varchar(255), grid_id uuid not null, primary key (grid_id, index));
create table game (id uuid not null, name varchar(255), primary key (id));
create table grid (id uuid not null, anyone_starts boolean, available_hint_word_count integer array, end_guessing boolean, hint_max_length integer, hints_inarow boolean, max_hint integer, max_max_hint integer, maybe_one_more_guess boolean, maybe_sudden_death boolean, one_more_guess boolean, only_one_green_green boolean, only_one_green_green_prevent_death boolean, structured_grid boolean, sudden_death boolean, word_distribution varchar(255), current_guess_count integer not null, word varchar(255), word_count integer, game_events json, phase smallint check ((phase between 0 and 16)), used_hints integer not null, used_whites integer not null, won boolean, w_game_id uuid, w_name varchar(255), primary key (id));
create table world (name varchar(255) not null, anyone_starts boolean, available_hint_word_count integer array, end_guessing boolean, hint_max_length integer, hints_inarow boolean, max_hint integer, max_max_hint integer, maybe_one_more_guess boolean, maybe_sudden_death boolean, one_more_guess boolean, only_one_green_green boolean, only_one_green_green_prevent_death boolean, structured_grid boolean, sudden_death boolean, word_distribution varchar(255), won boolean not null, game_id uuid not null, current_grid_id uuid, primary key (game_id, name));
alter table if exists card add constraint FK5x40wglgnltvcrme1tqa8ilsx foreign key (grid_id) references grid;
alter table if exists grid add constraint FK583ks7m95vxertpybyo2s5h6n foreign key (w_game_id, w_name) references world;
alter table if exists world add constraint FKo96aqpsvpf22bjeqk2w6l8jvx foreign key (current_grid_id) references grid;
alter table if exists world add constraint FKe9tajxpu9c9llbtdaldt6t18s foreign key (game_id) references game;
@@ -0,0 +1 @@
alter table if exists world add column locked boolean not null default false;
@@ -0,0 +1,19 @@
alter table if exists game add column config json;
alter table if exists game add column inventory json;
alter table if exists grid rename column word_distribution to color_distribution;
alter table if exists world drop column anyone_starts;
alter table if exists world drop column available_hint_word_count;
alter table if exists world drop column end_guessing;
alter table if exists world drop column hint_max_length;
alter table if exists world drop column hints_inarow;
alter table if exists world drop column max_hint;
alter table if exists world drop column max_max_hint;
alter table if exists world drop column maybe_one_more_guess;
alter table if exists world drop column maybe_sudden_death;
alter table if exists world drop column one_more_guess;
alter table if exists world drop column only_one_green_green;
alter table if exists world drop column only_one_green_green_prevent_death;
alter table if exists world drop column structured_grid;
alter table if exists world drop column sudden_death;
alter table if exists world drop column word_distribution;
+4
View File
@@ -14,4 +14,8 @@ main{
font-size: 24px;
flex-direction: column;
margin-top: 115px;
}
main section {
padding: 1em;
}
+16 -29
View File
@@ -1,6 +1,5 @@
var socket = null
var player = '_'
var roomId = '_'
var wsHeaders = {}
var cards = []
var gc = {}
@@ -11,13 +10,6 @@ var phase = {}
// This variable is here to check for updates to availableHintWordCount, as updating it is a bit expensive
var registeredAvailableHintWordCountInvalid = true
function getRoomId() {
const urlRegex = /\/room\/([a-f0-9-]{36})\/grid$/
const res = urlRegex.exec(window.location.href)
if (res == null)
console.log("Could not understand room id from url")
return res[1]
}
/*
* Update html to match state
@@ -30,12 +22,12 @@ function updatePhase() {
$('#hint-counter-den').text(gc["max-hint"])
if(currentHint) {
$('#hint-text').text(currentHint['word']+" in "+currentHint['wordCount'])
$('#hint-text').text(currentHint['word']+" in "+currentHint['word-count'])
$('#guess-counter-num').text(currentGuessCount)
if(gc['one-more-guess']) {
$('#guess-counter-den').text(currentHint['wordCount'] + "(+1)")
$('#guess-counter-den').text(currentHint['word-count'] + "(+1)")
} else {
$('#guess-counter-den').text(currentHint['wordCount'])
$('#guess-counter-den').text(currentHint['word-count'])
}
}
if(registeredAvailableHintWordCountInvalid) {
@@ -64,7 +56,7 @@ function updatePhase() {
}
if((phase['name'] == 'waiting_for_one_more_'+p) ||
(phase[p+'-guessing'] &&
(gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['wordCount'])))) {
(gc['end-guessing'] || (currentHint && currentGuessCount >= currentHint['word-count'])))) {
$('#end-guessing-button').show()
} else {
$('#end-guessing-button').hide()
@@ -187,28 +179,28 @@ function addEndGameEvent(issuer, won) {
function addEvent(e) {
switch(e.type) {
case "hint":
addHintEvent(e.issuer, e.word, e.wordCount)
currentHint = {word: e.word, wordCount: e.wordCount}
case "HintEvent":
addHintEvent(e.issuer, e.word, e['word-count'])
currentHint = {word: e.word, 'word-count': e['word-count']}
currentHintCount += 1
currentGuessCount = 0
updatePhase()
break
case "guess":
case "GuessEvent":
addPointEvent(e.issuer, e['card-index'], e.color)
currentGuessCount += 1
break
case "end_guessing":
case "EndGuessingEvent":
addEndGuessingEvent(e.issuer, e.manual)
currentHint = null
currentGuessCount = 0
break
case "game_end":
case "GameEndEvent":
addEndGameEvent(e.issuer, e.win)
currentHint = null
currentGuessCount = 0
break
case "sudden_death":
case "EnterSuddenDeathEvent":
addSuddenDeathEvent()
currentHint = null
currentGuessCount = 0
@@ -245,8 +237,7 @@ function initGame(data) {
updatePhase()
}
function selectPlayer(e) {
player = (e.target.id == "select-player-a") ? 'A' : 'B'
function selectPlayer(player) {
wsHeaders = {
"room": roomId,
"player": player
@@ -265,10 +256,10 @@ function selectPlayer(e) {
socket = Stomp.over(new SockJS('/socket'));
socket.connect({}, function (frame) {
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)
socket.subscribe('/topic/'+roomId+'/new-phase', onNewPhase);
socket.subscribe('/topic/'+roomId+'/new-event', onNewEvent);
socket.subscribe('/topic/'+roomId+'/update-card/' + player.toLowerCase(), onUpdateCard);
socket.subscribe('/topic/'+roomId+'/config-change', onConfigChange)
});
}
@@ -336,14 +327,10 @@ function onConfigChange(m) {
}
function initialize() {
$('#select-player-a').on('click', selectPlayer)
$('#select-player-b').on('click', selectPlayer)
$('#submit-hint button').on('click', submitHint)
$('#end-guessing-button').on('click', endGuessing)
$('#enter-sudden-death-button').on('click', enterSuddenDeath)
roomId = getRoomId()
$('#game-panel').hide()
$('#side-panel').hide()
}
+5 -5
View File
@@ -9,11 +9,6 @@
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<div id="player-selector">
Which player are you ? <br/>
<button id="select-player-a">Player A</button>
<button id="select-player-b">Player B</button>
</div>
<main>
<div id="game-panel">
<ul id="cards-list">
@@ -56,7 +51,12 @@
</div>
</main>
<script>
/*<![CDATA[*/
var player = '[(${player})]';
var roomId = '[(${grid.id})]';
/*]]>*/
initialize()
selectPlayer(player)
</script>
</body>
+27 -2
View File
@@ -6,9 +6,34 @@
<body>
<main>
Vous voulez jouer à Node Cames ? Ben c'est pas encore prêt :/
Vous voulez jouer à Node Cames ? Ben c'est là.
<br/>
<a href="/create-room">(Bon, en vrai, vous pouvez créer une room en cliquant ici)</a>
<a href="/create-room?type=zero">Grille Zéro</a>
<a href="/create-room?type=base">Grille Normale</a>
<a href="/create-room?type=big">Grille Grosse</a>
<br/>
<form method="GET" th:action="@{/create-game}">
<label for="create-game-name">Name : </label>
<input type="text" id="create-game-name" name="name"/>
<select name="type">
<option value="default">Default</option>
<option value="regular">Regular</option>
</select>
<input type="submit" value="Create game"/>
</form>
<div>
Currently running games:
<ul>
<li th:each="game : ${games}">
<a th:href="@{/game/{gid}/map(gid=${game.id})}" th:text="${game.name}"></a>
</li>
</ul>
</div>
</main>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
<!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>
<section>
<h2>Worlds</h2><br/>
<ul>
<li th:each="world : ${worlds}">
<!-- TODO remove this duplication of World span -->
<span th:if="${world.locked}"> World <span th:text="${world.name}"/> </span>
<a th:if="!${world.locked}" th:href="@{/game/{gid}/grid/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">World <span th:text="${world.name}"/></a>
<a th:if="!${world.locked}" th:href="@{/game/{gid}/reset/{wname}?player={player}(gid=${game.id},wname=${world.name},player=${player})}">reset</a>
<span th:if="${world.won}">Gagné !</span>
</li>
</ul>
</section>
<section>
<h2>Received Items</h2><br/>
<ul>
<li th:each="item : ${items}">
<span th:text="${item}"/>
</li>
</ul>
</section>
</main>
</body>
</html>
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<div th:replace="~{html-head}"/>
<link rel="stylesheet" th:href="@{/css/grid.css}"/>
<script th:src="@{/js/jquery-4.0.0.min.js}" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js" integrity="sha512-1QvjE7BtotQjkq8PxLeF6P46gEpBRXuskzIVgjFpekzFVF4yjRgrQvTG1MTOJ3yQgvTteKAcO7DSZI92+u/yZw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js" integrity="sha512-iKDtgDyTHjAitUDdLljGhenhPwrbBfqTKWO1mkhSFH3A7blITC9MhYon6SjnMhp4o0rADGw9yAC6EW4t5a4K3g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script th:src="@{/js/grid.js}" type="text/javascript"></script>
</head>
<body>
<div>
Which player are you ? <br/>
<a th:href="@{{redirect}?player=A(redirect=${redirect})}">Player A</a>
<a th:href="@{{redirect}?player=B(redirect=${redirect})}">Player B</a>
</div>
</body>
</html>
@@ -0,0 +1,30 @@
package com.bernard.nodecames;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import com.bernard.nodecames.model.GameConfig;
import lombok.RequiredArgsConstructor;
import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
class JsonTests {
ObjectMapper om = new ObjectMapper();
@Test
void serializeGameConfig() {
GameConfig g = GameConfig.DEFAULT_GAME_CONFIG;
String json = om.writeValueAsString(g);
System.out.println("Got json : "+json);
GameConfig g2 = om.readValue(json, GameConfig.class);
assertEquals(g, g2);
}
}
@@ -2,7 +2,10 @@ package com.bernard.nodecames;
import org.springframework.boot.test.context.SpringBootTest;
import lombok.RequiredArgsConstructor;
@SpringBootTest
@RequiredArgsConstructor
class NodeCamesApplicationTests {
}
+2
View File
@@ -11,6 +11,8 @@ version.com.fasterxml.jackson.core..jackson-databind=2.22.2
version.com.fasterxml.jackson.datatype..jackson-datatype-hibernate5=2.22.2
version.io.github.archipelagomw..Java-Client=0.2.1
version.jakarta.validation..jakarta.validation-api=3.1.1
## # available=4.0.0-M1