Compare commits

...

11 Commits

Author SHA1 Message Date
54de8672dc small improvements 2025-11-16 10:05:31 +01:00
26108fe073 small improvements 2025-11-16 10:05:02 +01:00
fe8b93e8a8 add session management to the ui through cookies 2025-11-15 14:53:13 +01:00
3ffb2c5785 avoid session deadlock 2025-11-14 23:26:11 +01:00
5b337e3168 update backend logic 2025-11-14 17:21:42 +01:00
8939c6ffb5 keep session id history 2025-11-14 16:47:01 +01:00
8d194c0dff keep session id history 2025-11-14 15:26:45 +01:00
372e246124 more frontend fixes 2025-11-14 15:17:31 +01:00
9f02bc1888 more frontend fixes 2025-11-13 18:41:34 +01:00
48872f93ec more ui improvements 2025-11-13 18:13:37 +01:00
e60491984b syncing 2025-11-13 12:25:07 +01:00
28 changed files with 5290 additions and 16 deletions

View File

@ -1,2 +1,28 @@
# multiplayer_crosswords # multiplayer_crosswords
This project is a web-based multiplayer crossword puzzle game that allows multiple users to collaborate in solving crossword puzzles in real-time. It features a user-friendly interface, session management, and real-time updates to enhance the collaborative experience.
## installation
1. Clone the repository:
```bash
git clone https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords.git
cd multiplayer_crosswords
```
2. Install this repository as a package:
```bash
pip install .
```
## start the server
```bash
python -m multiplayer_crosswords.server.main
```
## start the webui
```bash
python -m multiplayer_crosswords.server.serve_frontend
```

View File

@ -1,8 +1,11 @@
from enum import Enum from enum import Enum
from typing import List, Tuple, Optional from typing import Dict, List, Tuple, Optional
from multiplayer_crosswords.dictionary import Dictionary, Word from multiplayer_crosswords.dictionary import Dictionary, Word
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
import random import random
import logging
logger = logging.getLogger(__name__)
from multiplayer_crosswords.utils import load_de_dictionary from multiplayer_crosswords.utils import load_de_dictionary
@ -14,7 +17,7 @@ class CrosswordWord:
start_x: int, start_x: int,
start_y: int, start_y: int,
orientation: Orientation, orientation: Orientation,
hist: str, hint: str,
index: Optional[int], index: Optional[int],
solved: bool = False, solved: bool = False,
@ -23,7 +26,7 @@ class CrosswordWord:
self.start_x = start_x self.start_x = start_x
self.start_y = start_y self.start_y = start_y
self.orientation = orientation self.orientation = orientation
self.hist = hist self.hint = hint
self.length = len(word) self.length = len(word)
self.index: Optional[int] = index self.index: Optional[int] = index
self.solved = solved self.solved = solved
@ -43,6 +46,14 @@ class Crossword:
def words(self) -> List[CrosswordWord]: def words(self) -> List[CrosswordWord]:
return self._words return self._words
@property
def current_grid(self) -> List[List[Optional[str]]]:
return self._current_grid
@property
def solution_word_positions(self) -> Optional[List[Tuple[int, int]]]:
return self._solution_word_positions
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]: def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
"""Get the list of CrosswordWord objects that start at position (x, y).""" """Get the list of CrosswordWord objects that start at position (x, y)."""
@ -67,7 +78,7 @@ class Crossword:
start_x=wdata.get("start_x"), start_x=wdata.get("start_x"),
start_y=wdata.get("start_y"), start_y=wdata.get("start_y"),
orientation=Orientation[wdata.get("orientation")], orientation=Orientation[wdata.get("orientation")],
hist=wdata.get("hint", ""), hint=wdata.get("hint", ""),
index=wdata.get("index", None), index=wdata.get("index", None),
solved=wdata.get("solved", False) solved=wdata.get("solved", False)
) )
@ -111,13 +122,69 @@ class Crossword:
) )
if final_step is None: if final_step is None:
logger.warning("Crossword generation failed for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
return None return None
return Crossword( logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
# Build letter position index for efficient lookup
letter_to_positions: Dict[str, List[Tuple[int, int]]] = {}
for r in range(len(final_step.grid)):
for c in range(len(final_step.grid[0])):
cell = final_step.grid[r][c]
if cell and cell != '#':
if cell not in letter_to_positions:
letter_to_positions[cell] = []
letter_to_positions[cell].append((r, c))
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
solution_word_positions: Optional[List[Tuple[int, int]]] = None
max_solution_word_attempts = 20
for i in range(max_solution_word_attempts):
random_length = random.randint(10, 27 - (i // 2))
possible_words = dictionary.find_by_pattern('*' * random_length)
if not possible_words:
continue
chosen_word = random.choice(possible_words)
letter_positions = []
used_positions = set()
for letter in chosen_word.word:
if letter not in letter_to_positions:
letter_positions = []
break
# Pick random position for this letter that's not already used
available = [p for p in letter_to_positions[letter] if p not in used_positions]
if not available:
letter_positions = []
break
chosen_position = random.choice(available)
letter_positions.append(chosen_position)
used_positions.add(chosen_position)
if len(letter_positions) == random_length:
solution_word_positions = letter_positions
break
if solution_word_positions is None:
logger.warning("Failed to find a solution word for the generated crossword after %d attempts", max_solution_word_attempts)
return None
cw = Crossword(
dictionary=dictionary, dictionary=dictionary,
grid=final_step.grid, grid=final_step.grid,
solution_word_positions=solution_word_positions
) )
logger.debug("Generated Crossword: \n\n%s", cw)
return cw
def __init__( def __init__(
self, self,
@ -125,7 +192,19 @@ class Crossword:
grid: List[List[Optional[str]]], grid: List[List[Optional[str]]],
current_grid: Optional[List[List[Optional[str]]]] = None, current_grid: Optional[List[List[Optional[str]]]] = None,
words: Optional[List[CrosswordWord]] = None, words: Optional[List[CrosswordWord]] = None,
solution_word_positions: Optional[List[Tuple[int, int]]] = None,
): ):
"""
Initialize a Crossword object.
Args:
dictionary (Dictionary): The dictionary containing words and hints.
grid (List[List[Optional[str]]]): The solved crossword grid.
current_grid (Optional[List[List[Optional[str]]]]): The current state of the crossword grid.
words (Optional[List[CrosswordWord]]): Pre-extracted list of CrosswordWord objects.
solution_word_positions (Optional[List[Tuple[int, int]]]): Positions of letters building the solution word.
"""
self._dictionary = dictionary self._dictionary = dictionary
self._solved_grid = grid self._solved_grid = grid
self._words: List[CrosswordWord] = [] self._words: List[CrosswordWord] = []
@ -133,6 +212,8 @@ class Crossword:
self._horizontal_words_by_y_x_position = {} self._horizontal_words_by_y_x_position = {}
self._vertical_words_by_y_x_position = {} self._vertical_words_by_y_x_position = {}
self._solution_word_positions = solution_word_positions
if current_grid is not None: if current_grid is not None:
self._current_grid = current_grid self._current_grid = current_grid
@ -164,7 +245,41 @@ class Crossword:
for i in range(cw.length): for i in range(cw.length):
self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw
def get_clues(self) -> Tuple[Dict[int, str], Dict[int, str]]:
"""Get the clues for horizontal and vertical words.
Returns:
Tuple[Dict[int, str], Dict[int, str]]: Two dictionaries,
each containing (index, hint) for horizontal and vertical words respectively.
"""
clues_across = {}
clues_down = {}
for word in self._words:
if word.orientation == Orientation.HORIZONTAL:
clues_across[str(word.index)] = word.hint
else:
clues_down[str(word.index)] = word.hint
return clues_across, clues_down
def get_clue_positions(self) -> Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]:
"""Get the starting positions for horizontal and vertical clues.
Returns:
Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]: Two dictionaries,
each containing (index, (x, y)) for horizontal and vertical words respectively.
"""
positions_across = {}
positions_down = {}
for word in self._words:
if word.orientation == Orientation.HORIZONTAL:
positions_across[str(word.index)] = (word.start_x, word.start_y)
else:
positions_down[str(word.index)] = (word.start_x, word.start_y)
return positions_across, positions_down
def extract_words(self): def extract_words(self):
"""Extract all fully placed words from the grid and store them in self._words. """Extract all fully placed words from the grid and store them in self._words.
@ -172,7 +287,7 @@ class Crossword:
A "placed" word is a slot (horizontal or vertical, length >= 2) where every A "placed" word is a slot (horizontal or vertical, length >= 2) where every
cell contains a letter (not '' and not '#'). For each found word we try to cell contains a letter (not '' and not '#'). For each found word we try to
retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint) retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint)
and store it on the CrosswordWord.hist field (empty string if no hint found). and store it on the CrosswordWord.hint field (empty string if no hint found).
""" """
self._words = [] self._words = []
@ -221,7 +336,7 @@ class Crossword:
start_x=slot.col, start_x=slot.col,
start_y=slot.row, start_y=slot.row,
orientation=slot.orientation, orientation=slot.orientation,
hist=hint_str, hint=hint_str,
index=word_index, index=word_index,
) )
word_index += 1 word_index += 1
@ -240,7 +355,7 @@ class Crossword:
"start_x": w.start_x, "start_x": w.start_x,
"start_y": w.start_y, "start_y": w.start_y,
"orientation": w.orientation.name, "orientation": w.orientation.name,
"hint": w.hist, "hint": w.hint,
"length": w.length, "length": w.length,
"solved": w.solved, "solved": w.solved,
"index": w.index, "index": w.index,
@ -259,6 +374,7 @@ class Crossword:
Returns: Returns:
bool: True if the letter was placed successfully, False otherwise. bool: True if the letter was placed successfully, False otherwise.
""" """
letter = letter.lower()
if y < 0 or y >= len(self._current_grid): if y < 0 or y >= len(self._current_grid):
return False return False
if x < 0 or x >= len(self._current_grid[0]): if x < 0 or x >= len(self._current_grid[0]):
@ -290,10 +406,22 @@ class Crossword:
for i in range(crossword_word.length): for i in range(crossword_word.length):
r = crossword_word.start_y + dr * i r = crossword_word.start_y + dr * i
c = crossword_word.start_x + dc * i c = crossword_word.start_x + dc * i
if self._current_grid[r][c] != crossword_word.word[i]: if self._current_grid[r][c].lower() != crossword_word.word[i].lower():
return False return False
return True return True
def get_words_at_position(self, x: int, y: int) -> List[CrosswordWord]:
"""
get horizontal and vertical words at position (x, y)
"""
result = []
horizontal_cw = self._horizontal_words_by_y_x_position.get((y, x))
if horizontal_cw is not None:
result.append(horizontal_cw)
vertical_cw = self._vertical_words_by_y_x_position.get((y, x))
if vertical_cw is not None:
result.append(vertical_cw)
return result
def __str__(self): def __str__(self):
# Simple string representation for debugging # Simple string representation for debugging
@ -323,15 +451,15 @@ if __name__ == "__main__":
crossword = Crossword.generate( crossword = Crossword.generate(
dictionary=dictionary, dictionary=dictionary,
seed=None, seed=None,
grid_width=20, grid_width=25,
grid_height=20, grid_height=25,
grid_block_ratio=0.38 grid_block_ratio=0.38
) )
crossword.extract_words() crossword.extract_words()
for word in crossword.words: for word in crossword.words:
print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hist}") print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hint}")
print(crossword) print(crossword)

View File

@ -0,0 +1,42 @@
from pydantic import BaseModel
from .server_config import ServerConfig
from .server_utils import Languages, BoardSizePreset
class ClientMessageBase(BaseModel):
@classmethod
def get_type(cls) -> str:
return cls.model_fields["type"].default
type: str
class RequestAvailableSessionPropertiesClientMessage(ClientMessageBase):
type: str = "get_available_session_properties"
class NewMultiplayerSessionClientMessage(ClientMessageBase):
type: str = "new_multiplayer_session"
lang: Languages | str
# grid_w can be between 10 and 30
grid_w: int
# grid_h can be between 10 and 30
grid_h: int
# verify that grid_w and grid_h are within bounds
def validate(self):
server_config = ServerConfig.get_config()
if not (server_config.MIN_GRID_SIZE <= self.grid_w <= server_config.MAX_GRID_SIZE):
raise ValueError(f"grid_w must be between {server_config.MIN_GRID_SIZE} and {server_config.MAX_GRID_SIZE}")
if not (server_config.MIN_GRID_SIZE <= self.grid_h <= server_config.MAX_GRID_SIZE):
raise ValueError(f"grid_h must be between {server_config.MIN_GRID_SIZE} and {server_config.MAX_GRID_SIZE}")
class SubscribeSessionClientMessage(ClientMessageBase):
type: str = "subscribe_session"
session_id: str
class UpdateLetterClientMessage(ClientMessageBase):
type: str = "update_letter"
session_id: str
row: int
col: int
letter: str # single character string, uppercase A-Z or empty string for clearing the cell

View File

@ -0,0 +1,40 @@
import argparse
from multiplayer_crosswords.server.websocket_crossword_server import WebsocketCrosswordServer
from multiplayer_crosswords.server.server_config import (
ServerConfig,
DEFAULT_WEBSOCKET_HOST,
DEFAULT_WEBSOCKET_PORT,
DEFAULT_MIN_GRID_SIZE,
DEFAULT_MAX_GRID_SIZE,
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS,
DEFAULT_GRID_BLOCK_RATIO
)
def main():
parser = argparse.ArgumentParser(description="Multiplayer Crossword WebSocket Server")
parser.add_argument("--host", type=str, default=DEFAULT_WEBSOCKET_HOST, help="WebSocket server host")
parser.add_argument("--port", type=int, default=DEFAULT_WEBSOCKET_PORT, help="WebSocket server port")
parser.add_argument("--min-grid-size", type=int, default=DEFAULT_MIN_GRID_SIZE, help="Minimum grid size")
parser.add_argument("--max-grid-size", type=int, default=DEFAULT_MAX_GRID_SIZE, help="Maximum grid size")
parser.add_argument("--max-session-idle-time-seconds", type=int, default=DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS, help="Maximum session idle time in seconds")
parser.add_argument("--grid-block-ratio", type=float, default=DEFAULT_GRID_BLOCK_RATIO, help="Grid block ratio")
args = parser.parse_args()
ServerConfig.setup(
min_grid_size=args.min_grid_size,
max_grid_size=args.max_grid_size,
max_session_idle_time_seconds=args.max_session_idle_time_seconds,
grid_block_ratio=args.grid_block_ratio,
host=args.host,
port=args.port
)
config = ServerConfig.get_config()
websocket_server = WebsocketCrosswordServer(host=config.HOST, port=config.PORT)
websocket_server.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,35 @@
from pathlib import Path
import argparse
from http import server as http
import http.server
BASE_DIR = Path(__file__).resolve().parent.parent / "webui"
def main():
parser = argparse.ArgumentParser(description="Serve Multiplayer Crossword Frontend")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--no-file-list", action="store_true", help="Disable directory listing")
args = parser.parse_args()
path = BASE_DIR
host = args.host
port = args.port
no_file_list = args.no_file_list
class CustomHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(path), **kwargs)
if no_file_list:
def list_directory(self, path):
self.send_error(403, "Directory listing not allowed")
return None
server_address = (host, port)
httpd = http.server.HTTPServer(server_address, CustomHandler)
print(f"Serving frontend at http://{host}:{port}/ from {path}")
httpd.serve_forever()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,50 @@
from pydantic import BaseModel
DEFAULT_WEBSOCKET_HOST = "0.0.0.0"
DEFAULT_WEBSOCKET_PORT = 8765
DEFAULT_MIN_GRID_SIZE = 12
DEFAULT_MAX_GRID_SIZE = 25
DEFAULT_GRID_BLOCK_RATIO = 0.39
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS = 3600 * 48 # 2 days
class ServerConfig(BaseModel):
@classmethod
def setup(cls,
min_grid_size: int = DEFAULT_MIN_GRID_SIZE,
max_grid_size: int = DEFAULT_MAX_GRID_SIZE,
max_session_idle_time_seconds: int = DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS,
grid_block_ratio: float = DEFAULT_GRID_BLOCK_RATIO,
host: str = DEFAULT_WEBSOCKET_HOST,
port: int = DEFAULT_WEBSOCKET_PORT
) -> "ServerConfig":
cls._singleton = cls(
MIN_GRID_SIZE=min_grid_size,
MAX_GRID_SIZE=max_grid_size,
MAX_SESSION_IDLE_TIME_SECONDS=max_session_idle_time_seconds,
GRID_BLOCK_RATIO=grid_block_ratio,
HOST=host,
PORT=port,
)
return cls._singleton
@classmethod
def get_config(cls) -> "ServerConfig":
if not hasattr(cls, "_singleton"):
raise ValueError("ServerConfig singleton not initialized. Call setup() first.")
return cls._singleton
MAX_GRID_SIZE: int
MIN_GRID_SIZE: int
MAX_SESSION_IDLE_TIME_SECONDS: int
GRID_BLOCK_RATIO: float
HOST: str
PORT: int

View File

@ -0,0 +1,44 @@
from pydantic import BaseModel
from .server_config import ServerConfig
from .server_utils import Languages
class ServerMessageBase(BaseModel):
@classmethod
def get_type(cls) -> str:
return cls.model_fields["type"].default
type: str
class ServerErrorMessage(ServerMessageBase):
type: str = "error"
error_message: str
class AvailableSessionPropertiesServerMessage(ServerMessageBase):
type: str = "available_session_properties"
supported_languages: list[str] # list of language codes, e.g., ["en", "de"]
min_grid_size: int
max_grid_size: int
board_size_presets: dict[str, tuple[int, int]] # mapping from preset
class SessionCreatedServerMessage(ServerMessageBase):
type: str = "session_created"
session_id: str
class SendFullSessionStateServerMessage(ServerMessageBase):
type: str = "full_session_state"
session_id: str
grid: list[list[str]] # 2D array representing the current grid state
clues_across: dict[str, str] # mapping from clue number to clue text for across clues
clues_down: dict[str, str] # mapping from clue number to clue text for down clues
clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
solved_positions: list[tuple[int, int]] # list of (row, col) positions that are solved
solution_word_positions: list[tuple[int, int]] # list of (row, col) positions that are part of solution word
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
type: str = "letter_update"
session_id: str
row: int
col: int
letter: str # single character string, uppercase A-Z or empty string for clearing the cell
is_solved: bool

View File

@ -0,0 +1,43 @@
from enum import Enum
from multiplayer_crosswords.dictionary import Dictionary
from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary
from .server_config import ServerConfig
class Languages(str, Enum):
EN = "en"
DE = "de"
def load_dictionary(self) -> Dictionary:
if self == Languages.EN:
return load_en_dictionary()
elif self == Languages.DE:
return load_de_dictionary()
else:
raise ValueError(f"Unsupported language: {self}")
class BoardSizePreset(str, Enum):
VERY_SMALL = "very_small"
SMALL = "small"
MEDIUM = "medium"
LARGE = "large"
VERY_LARGE = "very_large"
def to_dimensions(self) -> tuple[int, int]:
server_config = ServerConfig.get_config()
min_size = server_config.MIN_GRID_SIZE
max_size = server_config.MAX_GRID_SIZE
if self == BoardSizePreset.VERY_SMALL:
return (min_size, min_size)
elif self == BoardSizePreset.SMALL:
return (min_size + (max_size - min_size) // 4, min_size + (max_size - min_size) // 4)
elif self == BoardSizePreset.MEDIUM:
return (min_size + (max_size - min_size) // 2, min_size + (max_size - min_size) // 2)
elif self == BoardSizePreset.LARGE:
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
elif self == BoardSizePreset.VERY_LARGE:
return (max_size, max_size)
else:
raise ValueError(f"Unsupported board size preset: {self}")

View File

@ -0,0 +1,92 @@
import logging
import json
import traceback
from pydantic import BaseModel
import websockets
from .server_config import ServerConfig
from .server_utils import Languages
from .client_messages import ClientMessageBase
from .server_messages import ServerMessageBase, ServerErrorMessage
logger = logging.getLogger(__name__)
class WebsocketConnectionHandler:
message_handlers: dict[str, callable] = {}
message_models: dict[str, type[BaseModel]] = {}
@classmethod
def register_message_handler(cls, model: type[ClientMessageBase]):
def decorator(func: callable):
message_type = model.get_type()
cls.message_handlers[message_type] = func
cls.message_models[message_type] = model
return func
return decorator
@property
def websocket(self) -> websockets.WebSocketServerProtocol:
return self._websocket
def __init__(self, websocket: websockets.WebSocketServerProtocol):
self._websocket = websocket
self._is_closed = False
def is_closed(self) -> bool:
"""Check if the connection is closed"""
return self._is_closed
async def handle_message(self, message: dict):
try:
message_type = message.get("type")
if message_type not in self.message_handlers:
raise ValueError(f"Unknown message type: {message_type}")
model_cls = self.message_models[message_type]
model_instance = model_cls(**message)
handler = self.message_handlers[message_type]
await handler(self, model_instance)
except Exception as e:
logger.warning("error handling message: %s", str(e))
logger.warning("stack trace: %s", traceback.format_exc())
error_response = ServerErrorMessage(
error_message=f"Error handling message: {str(e)}\n {traceback.format_exc()}"
)
await self.send(message=error_response.model_dump())
async def send(self, message: dict):
string_message = json.dumps(message)
logger.debug("sending message: %s", string_message)
try:
await self._websocket.send(string_message)
except Exception as e:
logger.warning("error sending message: %s", str(e))
logger.warning("stack trace: %s", traceback.format_exc())
async def close(self):
try:
if not self._is_closed:
self._is_closed = True
await self._websocket.close()
logger.debug("WebSocket closed")
except Exception as e:
logger.warning("error closing websocket: %s", str(e))
logger.warning("stack trace: %s", traceback.format_exc())
async def run(self):
try:
async for message in self._websocket:
try:
json_message = json.loads(message)
await self.handle_message(json_message)
except ValueError as e:
logger.warning("received unprocessable message %s", str(e))
logger.warning("stack trace: %s", traceback.format_exc())
except Exception as e:
logger.warning("error in websocket connection: %s", str(e))
logger.warning("stack trace: %s", traceback.format_exc())
self._is_closed = True
finally:
self._is_closed = True

View File

@ -0,0 +1,391 @@
import websockets
import asyncio
import uuid
from enum import Enum
import time
from pydantic import BaseModel
import logging
import json
import traceback
from multiplayer_crosswords.crossword import Crossword
from multiplayer_crosswords.crossword_algorithm import Orientation
from multiplayer_crosswords.dictionary import Dictionary, Word
from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary
from .server_config import ServerConfig
from .server_utils import Languages
from .websocket_connection_handler import WebsocketConnectionHandler
from . import client_messages
from . import server_messages
logger = logging.getLogger(__name__)
# set global logging level to DEBUG for demonstration purposes
logging.basicConfig(level=logging.DEBUG)
class MultiplayerSession:
@property
def crossword(self) -> Crossword:
self._assure_is_locked()
return self._crossword
@property
def session_id(self) -> str:
self._assure_is_locked()
return self._session_id
@property
def clients(self) -> set:
self._assure_is_locked()
return self._clients
@property
def lock(self) -> asyncio.Lock:
self.update_activity()
return self._lock
@property
def is_idle(self) -> bool:
idle_time = time.monotonic() - self._last_active_time
return idle_time > self._max_idle_time_seconds
def _assure_is_locked(self):
if not self._lock.locked():
raise RuntimeError("MultiplayerSession methods that modify state must be called while holding the session lock.")
def __init__(self, session_id: str, crossword: Crossword):
self._session_id = session_id
self._crossword = crossword
self._last_active_time = time.monotonic()
self._clients = set()
self._max_idle_time_seconds = ServerConfig.get_config().MAX_SESSION_IDLE_TIME_SECONDS
self._lock = asyncio.Lock()
def update_activity(self):
self._last_active_time = time.monotonic()
def add_client(self, client: WebsocketConnectionHandler):
self._assure_is_locked()
self._clients.add(client)
def remove_client(self, client: WebsocketConnectionHandler):
self._assure_is_locked()
self._clients.discard(client)
def remove_unconnected_clients(self):
self._assure_is_locked()
self._clients = {c for c in self._clients if not c.is_closed()}
async def send_message_to_all_clients(self, message: dict):
logger.debug("Broadcasting message to all clients in session %s: %s. #clients: %s", self._session_id, message, len(self._clients))
for client in self._clients.copy():
if client.is_closed():
logger.debug("Removing closed client %s from session %s", client, self._session_id)
self._clients.discard(client)
continue
try:
await client.send(message=message)
logger.debug("Message sent to client %s: %s", client, message)
except Exception as e:
logger.warning("error sending message to client %s: %s", client, str(e))
logger.warning("stack trace: %s", traceback.format_exc())
class MultiplayerSessionManager(object):
@classmethod
def instance(cls) -> "MultiplayerSessionManager":
if not hasattr(cls, "_singleton_instance"):
cls._singleton_instance = cls()
return cls._singleton_instance
def __init__(self):
self.__class__._singleton_instance = self
self._sessions: dict[str, MultiplayerSession] = {}
self._sessions_lock = asyncio.Lock()
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
async def remove_idle_sessions(self):
"""Remove all idle sessions that have exceeded their max idle time."""
async with self._sessions_lock:
idle_session_ids = [
session_id for session_id, session in self._sessions.items()
if session.is_idle
]
for session_id in idle_session_ids:
logger.info("Removing idle session %s", session_id)
del self._sessions[session_id]
if idle_session_ids:
logger.info("Removed %d idle sessions", len(idle_session_ids))
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
# Remove idle sessions before creating a new one
await self.remove_idle_sessions()
async with self._sessions_lock:
if isinstance(lang, str):
lang = Languages(lang)
dictionary = lang.load_dictionary()
max_tries = 4
for i in range(max_tries):
crossword = Crossword.generate(
dictionary=dictionary,
seed=None,
grid_width=grid_w,
grid_height=grid_h,
grid_block_ratio=self._grid_block_ratio,
)
if crossword is not None:
break
if crossword is None:
raise RuntimeError("Failed to generate crossword for the given parameters.")
session_id = str(uuid.uuid4())
session = MultiplayerSession(session_id=session_id, crossword=crossword)
self._sessions[session_id] = session
return session
class WebsocketCrosswordServer(object):
@staticmethod
def ensure_valid_letter(letter: str):
if len(letter) != 1 and letter != "":
raise ValueError("Letter must be a single character A-Z or empty string.")
if letter != "" and (not letter.isalpha()):
raise ValueError("Letter must be a single character A-Z or empty string.")
return letter.upper()
@WebsocketConnectionHandler.register_message_handler(
model=client_messages.RequestAvailableSessionPropertiesClientMessage
)
@staticmethod
async def handle_request_available_session_properties(handler: WebsocketConnectionHandler, message: client_messages.RequestAvailableSessionPropertiesClientMessage):
server_config = ServerConfig.get_config()
response = server_messages.AvailableSessionPropertiesServerMessage(
supported_languages=list(reversed([lang.value for lang in Languages])),
min_grid_size=server_config.MIN_GRID_SIZE,
max_grid_size=server_config.MAX_GRID_SIZE,
board_size_presets={
preset.name.lower(): preset.to_dimensions()
for preset in client_messages.BoardSizePreset
}
)
await handler.send(message=response.model_dump())
@WebsocketConnectionHandler.register_message_handler(
model=client_messages.NewMultiplayerSessionClientMessage
)
@staticmethod
async def handle_new_multiplayer_session(handler: WebsocketConnectionHandler, message: client_messages.NewMultiplayerSessionClientMessage):
message.validate()
try:
session = await MultiplayerSessionManager.instance().create_session(
lang=message.lang,
grid_w=message.grid_w,
grid_h=message.grid_h,
)
except Exception as e:
error_response = server_messages.ServerErrorMessage(
error_message=f"Error creating session: {str(e)}"
)
await handler.send(message=error_response.model_dump())
return
async with session.lock:
session.add_client(handler)
response = server_messages.SessionCreatedServerMessage(
session_id=session.session_id
)
await handler.send(message=response.model_dump())
@WebsocketConnectionHandler.register_message_handler(
model=client_messages.SubscribeSessionClientMessage
)
@staticmethod
async def handle_request_full_session_state(handler: WebsocketConnectionHandler, message: client_messages.SubscribeSessionClientMessage):
session_id = message.session_id
session_manager = MultiplayerSessionManager.instance()
async with session_manager._sessions_lock:
if session_id not in session_manager._sessions:
error_response = server_messages.ServerErrorMessage(
error_message=f"Session with id {session_id} not found."
)
await handler.send(message=error_response.model_dump())
return
session = session_manager._sessions[session_id]
async with session.lock:
grid_state = session.crossword.current_grid
# make all letters uppercase for the client
for row in range(len(grid_state)):
for col in range(len(grid_state[0])):
grid_state[row][col] = grid_state[row][col].upper()
clues_across, clues_down = session.crossword.get_clues()
clue_positions_across, clue_positions_down = session.crossword.get_clue_positions()
# get all sovled positions by looping over all words
solved_positions = set()
for cw in session.crossword.words:
if cw.solved:
for i in range(len(cw.word)):
if cw.orientation == Orientation.HORIZONTAL:
row = cw.start_y
col = cw.start_x + i
else:
row = cw.start_y + i
col = cw.start_x
solved_positions.add((col, row))
solved_positions = list(solved_positions)
solution_word_positions = []
positions = session.crossword.solution_word_positions
for pos in positions:
# Convert from (row, col) to (col, row) for client
row, col = pos
solution_word_positions.append((col, row))
response = server_messages.SendFullSessionStateServerMessage(
session_id=session.session_id,
grid=grid_state,
clues_across=clues_across,
clues_down=clues_down,
clue_positions_across=clue_positions_across,
clue_positions_down=clue_positions_down,
solved_positions=solved_positions,
solution_word_positions=solution_word_positions,
)
# register the client to the session
session.add_client(handler)
await handler.send(message=response.model_dump())
@WebsocketConnectionHandler.register_message_handler(
model=client_messages.UpdateLetterClientMessage
)
@staticmethod
async def handle_update_letter(handler: WebsocketConnectionHandler, message: client_messages.UpdateLetterClientMessage):
session_id = message.session_id
session_manager = MultiplayerSessionManager.instance()
async with session_manager._sessions_lock:
if session_id not in session_manager._sessions:
error_response = server_messages.ServerErrorMessage(
error_message=f"Session with id {session_id} not found."
)
await handler.send(message=error_response.model_dump())
return
session = session_manager._sessions[session_id]
async with session.lock:
crossword = session.crossword
msg_letter = WebsocketCrosswordServer.ensure_valid_letter(message.letter)
current_grid = crossword.current_grid
if not (0 <= message.row < len(current_grid)) or not (0 <= message.col < len(current_grid[0])):
error_response = server_messages.ServerErrorMessage(
error_message=f"Row or column out of bounds."
)
await handler.send(message=error_response.model_dump())
return
current_grid_letter = current_grid[message.row][message.col]
if current_grid_letter.upper() == msg_letter.upper():
# No change
return
# check if the letter already is solved, if so, ignore the update
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
if any(cw.solved for cw in words_at_position):
logger.info("Ignoring update to already solved position (%d, %d) in session %s", message.col, message.row, session.session_id)
# send letter again to client to ensure they have the correct letter
msg = server_messages.LetterUpdateBroadcastServerMessage(
session_id=session.session_id,
row=message.row,
col=message.col,
letter=msg_letter.upper(),
is_solved=is_solved
)
messages = [msg]
else:
# also check if the position is
crossword.place_letter(
x=message.col,
y=message.row,
letter=msg_letter.lower(),
)
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
is_solved = any(cw.solved for cw in words_at_position)
if is_solved:
logger.info("Word solved at position (%d, %d) in session %s", message.col, message.row, session.session_id)
messages = []
for cw in words_at_position:
if cw.solved:
logger.info("Solved word: %s", cw.word)
# go through each letter in the word and create a message
for i in range(len(cw.word)):
if cw.orientation == Orientation.HORIZONTAL:
row = cw.start_y
col = cw.start_x + i
else:
row = cw.start_y + i
col = cw.start_x
letter = cw.word[i].upper()
msg = server_messages.LetterUpdateBroadcastServerMessage(
session_id=session.session_id,
row=row,
col=col,
letter=letter,
is_solved=True
)
messages.append(msg)
else:
msg = server_messages.LetterUpdateBroadcastServerMessage(
session_id=session.session_id,
row=message.row,
col=message.col,
letter=msg_letter.upper(),
is_solved=is_solved
)
messages = [msg]
# NOTE: we do this purposefully outside of the session lock to avoid
# potential deadlocks if sending messages takes time.
# this could cause clients to receive messages slightly out of order
for broadcast_message in messages:
await session.send_message_to_all_clients(message=broadcast_message.model_dump())
def __init__(self, host: str, port: int):
self._host = host
self._port = port
def run(self):
# Newer versions of the `websockets` library call the handler with a
# single `connection` argument (the WebSocketServerProtocol). Accept
# that and pass it into our connection handler wrapper.
async def connection_handler(connection: websockets.WebSocketServerProtocol):
handler = WebsocketConnectionHandler(websocket=connection)
await handler.run()
async def _main():
logger.info("Starting WebSocket server on %s:%d", self._host, self._port)
# Use the async context manager for the server; this will keep
# the server running until cancelled. Using asyncio.run() to
# start the loop ensures we don't depend on an existing running
# event loop (avoids "no running event loop" errors).
async with websockets.serve(
handler=connection_handler,
host=self._host,
port=self._port,
):
# Wait forever (until process is killed or cancelled)
await asyncio.Event().wait()
# Start the server with a fresh event loop
asyncio.run(_main())

View File

@ -3,6 +3,11 @@ import json
from pathlib import Path from pathlib import Path
def load_dictionary(p: str | Path) -> Dictionary: def load_dictionary(p: str | Path) -> Dictionary:
if not hasattr(load_dictionary, "_cache"):
load_dictionary._cache = {}
cache_key = str(p)
if cache_key in load_dictionary._cache:
return load_dictionary._cache[cache_key]
p = Path(p) p = Path(p)
if not p.exists(): if not p.exists():
raise FileNotFoundError(f"Dictionary file not found: {p}") raise FileNotFoundError(f"Dictionary file not found: {p}")
@ -16,7 +21,49 @@ def load_dictionary(p: str | Path) -> Dictionary:
if not word.isalpha(): if not word.isalpha():
continue continue
word = word.lower() word = word.lower()
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
hints = []
try:
if "senses" in obj:
for sense in obj["senses"]:
text = sense
if word in text.lower():
continue
hints.append(text)
except Exception:
pass
try:
if "synonyms" in obj:
for synonym in obj["synonyms"]:
text = synonym
if word in text.lower():
continue
hints.append( text)
except Exception:
pass
try:
if "antonyms" in obj:
for antonym in obj["antonyms"]:
text = antonym
if word in text.lower():
continue
if "de" in p.name:
text = "Gegenteil von " + text
else:
text = "Opposite of " + text
hints.append( text)
except Exception:
pass
if len(hints) > 0:
w = Word(word=word,
hints=hints,
difficulty=1)
dict_obj.add_word(w)
load_dictionary._cache[cache_key] = dict_obj
return dict_obj return dict_obj
def load_en_dictionary() -> Dictionary: def load_en_dictionary() -> Dictionary:

View File

@ -0,0 +1,15 @@
window.addEventListener('load', e => {
registerSW();
});
async function registerSW() {
if ('serviceWorker' in navigator) {
try {
await navigator.serviceWorker.register('./sw.js');
} catch (e) {
alert('ServiceWorker registration failed. Sorry about that.');
}
} else {
document.querySelector('.alert').removeAttribute('hidden');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1,414 @@
import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module';
export class ClueArea extends LitElement {
createRenderRoot() {
return this;
}
static get properties() {
return {
cluesAcross: { type: Object },
cluesDown: { type: Object },
cluePositionsAcross: { type: Object },
cluePositionsDown: { type: Object },
selectedRow: { type: Number },
selectedCol: { type: Number },
selectedMode: { type: String }, // 'horizontal' or 'vertical'
grid: { type: Array }, // 2D grid from server (needed to find walls)
gridData: { type: Object }, // { rows, cols, walls, solvedCells }
_showAllCluesAcross: { state: true },
_showAllCluesDown: { state: true },
_solvedCluesAcross: { state: true }, // Set of solved clue numbers
_solvedCluesDown: { state: true } // Set of solved clue numbers
};
}
constructor() {
super();
this.cluesAcross = {};
this.cluesDown = {};
this.cluePositionsAcross = {};
this.cluePositionsDown = {};
this.selectedRow = 0;
this.selectedCol = 0;
this.selectedMode = 'horizontal';
this.grid = [];
this.gridData = { rows: 0, cols: 0, walls: new Set(), solvedCells: new Set() };
this._showAllCluesAcross = false;
this._showAllCluesDown = false;
this._solvedCluesAcross = new Set();
this._solvedCluesDown = new Set();
}
/**
* Find a horizontal clue that contains the given cell
* NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)!
*/
_getCellAcrossClue(row, col) {
console.log(`[ACROSS] Looking for clue at [row=${row}, col=${col}]`);
if (!this.grid || this.grid.length === 0) {
console.log('[ACROSS] No grid data');
return null;
}
const gridHeight = this.grid.length;
const gridWidth = this.grid[0]?.length || 0;
console.log(`[ACROSS] Grid: ${gridHeight}x${gridWidth}`);
// Find all across clues on this row
const cluesOnRow = [];
for (const [clueNum, position] of Object.entries(this.cluePositionsAcross)) {
// Server sends (x, y) = (col, row)!
const clueCol = position[0];
const clueRow = position[1];
console.log(`[ACROSS] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`);
if (clueRow === row) {
cluesOnRow.push({ clueNum, clueCol });
console.log(`[ACROSS] → On same row!`);
}
}
if (cluesOnRow.length === 0) {
console.log('[ACROSS] No clues on this row');
return null;
}
// Sort by column
cluesOnRow.sort((a, b) => a.clueCol - b.clueCol);
console.log(`[ACROSS] Sorted clues on row ${row}:`, cluesOnRow);
// Find which clue this cell belongs to
for (let i = 0; i < cluesOnRow.length; i++) {
const startCol = cluesOnRow[i].clueCol;
const endCol = i + 1 < cluesOnRow.length
? cluesOnRow[i + 1].clueCol - 1
: gridWidth - 1;
console.log(`[ACROSS] Clue ${cluesOnRow[i].clueNum}: cols ${startCol}-${endCol}`);
// Check if cell is within this clue range
if (col >= startCol && col <= endCol) {
console.log(`[ACROSS] ✓ FOUND: Clue ${cluesOnRow[i].clueNum}`);
return {
number: cluesOnRow[i].clueNum,
direction: 'across',
text: this.cluesAcross[cluesOnRow[i].clueNum] || ''
};
}
}
console.log('[ACROSS] Cell not in any clue range');
return null;
}
/**
* Find a vertical clue that contains the given cell
* NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)!
*/
_getCellDownClue(row, col) {
console.log(`[DOWN] Looking for clue at [row=${row}, col=${col}]`);
if (!this.grid || this.grid.length === 0) {
console.log('[DOWN] No grid data');
return null;
}
const gridHeight = this.grid.length;
const gridWidth = this.grid[0]?.length || 0;
console.log(`[DOWN] Grid: ${gridHeight}x${gridWidth}`);
// Find all down clues in this column
const cluesInCol = [];
for (const [clueNum, position] of Object.entries(this.cluePositionsDown)) {
// Server sends (x, y) = (col, row)!
const clueCol = position[0];
const clueRow = position[1];
console.log(`[DOWN] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`);
if (clueCol === col) {
cluesInCol.push({ clueNum, clueRow });
console.log(`[DOWN] → On same column!`);
}
}
if (cluesInCol.length === 0) {
console.log('[DOWN] No clues in this column');
return null;
}
// Sort by row
cluesInCol.sort((a, b) => a.clueRow - b.clueRow);
console.log(`[DOWN] Sorted clues in column ${col}:`, cluesInCol);
// Find which clue this cell belongs to
for (let i = 0; i < cluesInCol.length; i++) {
const startRow = cluesInCol[i].clueRow;
const endRow = i + 1 < cluesInCol.length
? cluesInCol[i + 1].clueRow - 1
: gridHeight - 1;
console.log(`[DOWN] Clue ${cluesInCol[i].clueNum}: rows ${startRow}-${endRow}`);
// Check if cell is within this clue range
if (row >= startRow && row <= endRow) {
console.log(`[DOWN] ✓ FOUND: Clue ${cluesInCol[i].clueNum}`);
return {
number: cluesInCol[i].clueNum,
direction: 'down',
text: this.cluesDown[cluesInCol[i].clueNum] || ''
};
}
}
console.log('[DOWN] Cell not in any clue range');
return null;
}
/**
* Get current clue based on selected cell and mode
*/
_getCurrentClue() {
// Check if we're clicking on a wall - if so, no clue
if (this.grid && this.grid[this.selectedRow] && this.grid[this.selectedRow][this.selectedCol] === '#') {
return null;
}
if (this.selectedMode === 'horizontal') {
return this._getCellAcrossClue(this.selectedRow, this.selectedCol);
} else {
return this._getCellDownClue(this.selectedRow, this.selectedCol);
}
}
_toggleShowAllCluesAcross() {
this._showAllCluesAcross = !this._showAllCluesAcross;
}
_toggleShowAllCluesDown() {
this._showAllCluesDown = !this._showAllCluesDown;
}
/**
* Find the starting row,col of an across clue by clue number
*/
_getAcrossClueStart(clueNum) {
const position = this.cluePositionsAcross[clueNum];
if (!position) return null;
// Server sends (x, y) = (col, row)
const col = position[0];
const row = position[1];
return { row, col };
}
/**
* Find the starting row,col of a down clue by clue number
*/
_getDownClueStart(clueNum) {
const position = this.cluePositionsDown[clueNum];
if (!position) return null;
// Server sends (x, y) = (col, row)
const col = position[0];
const row = position[1];
return { row, col };
}
/**
* Get all cells that belong to an across clue
*/
_getAcrossCluesCells(clueNum) {
const startPos = this._getAcrossClueStart(clueNum);
if (!startPos) return [];
const { row, col } = startPos;
const cells = [];
// Expand right until we hit a wall
for (let c = col; c < this.gridData.cols; c++) {
if (this.gridData.walls.has(`${row},${c}`)) {
break;
}
cells.push({ row, col: c });
}
return cells;
}
/**
* Get all cells that belong to a down clue
*/
_getDownCluesCells(clueNum) {
const startPos = this._getDownClueStart(clueNum);
if (!startPos) return [];
const { row, col } = startPos;
const cells = [];
// Expand down until we hit a wall
for (let r = row; r < this.gridData.rows; r++) {
if (this.gridData.walls.has(`${r},${col}`)) {
break;
}
cells.push({ row: r, col });
}
return cells;
}
/**
* Check if a clue is fully solved
*/
_isCluesSolved(clueNum, direction) {
const cells = direction === 'across'
? this._getAcrossCluesCells(clueNum)
: this._getDownCluesCells(clueNum);
return cells.length > 0 && cells.every(cell =>
this.gridData.solvedCells.has(`${cell.row},${cell.col}`)
);
}
/**
* Update which clues are solved
*/
_updateSolvedClues() {
this._solvedCluesAcross = new Set();
this._solvedCluesDown = new Set();
for (const clueNum of Object.keys(this.cluesAcross)) {
if (this._isCluesSolved(clueNum, 'across')) {
this._solvedCluesAcross.add(clueNum);
}
}
for (const clueNum of Object.keys(this.cluesDown)) {
if (this._isCluesSolved(clueNum, 'down')) {
this._solvedCluesDown.add(clueNum);
}
}
}
/**
* Handle clue item click - focus the cell and set orientation
*/
_onClueItemClick(clueNum, direction) {
let startPos;
let mode;
if (direction === 'across') {
startPos = this._getAcrossClueStart(clueNum);
mode = 'horizontal';
} else {
startPos = this._getDownClueStart(clueNum);
mode = 'vertical';
}
if (!startPos) return;
// Update selected cell and mode in parent grid
this.selectedRow = startPos.row;
this.selectedCol = startPos.col;
this.selectedMode = mode;
// Dispatch event to notify grid component
this.dispatchEvent(new CustomEvent('clue-selected', {
detail: {
row: startPos.row,
col: startPos.col,
mode: mode
},
bubbles: true,
composed: true
}));
// Close the all-clues view and return to default view
this._showAllCluesAcross = false;
this._showAllCluesDown = false;
}
render() {
const currentClue = this._getCurrentClue();
// Show across clues
if (this._showAllCluesAcross) {
return html`
<div class="clue-area expanded">
<div class="clue-header">
<h3>Across Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}">
<span class="chevron">✕</span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesAcross).map(([num, text]) => html`
<div class="clue-item ${this._solvedCluesAcross.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'across')}" style="cursor: pointer;">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
`)}
</div>
</div>
</div>
`;
}
// Show down clues
if (this._showAllCluesDown) {
return html`
<div class="clue-area expanded">
<div class="clue-header">
<h3>Down Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
<span class="chevron">✕</span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesDown).map(([num, text]) => html`
<div class="clue-item ${this._solvedCluesDown.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'down')}" style="cursor: pointer;">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
`)}
</div>
</div>
</div>
`;
}
// Default view with both buttons
return html`
<div class="clue-area">
<div class="clue-header">
${currentClue ? html`
<div class="current-clue">
<span class="clue-number">${currentClue.direction === 'across' ? '▶' : '▼'} ${currentClue.number}</span>
</div>
<div class="clue-text">${currentClue.text}</div>
` : html`
<div class="clue-text empty">No clue for this cell</div>
`}
<div class="clue-toggle-group">
<div class="clue-text empty">Clues:</div>
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}" title="Show all across clues">
<span class="chevron">▶</span>
</button>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
<span class="chevron">▼</span>
</button>
</div>
</div>
</div>
`;
}
}
customElements.define('clue-area', ClueArea);

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,734 @@
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
import wsManager from './websocket.js';
/**
* <crossword-grid>
* Simple Lit-based grid component for testing the crossword UI.
* - Attributes: rows, cols (numbers)
* - Public behavior: listens for 'key-press' CustomEvents on window and
* places letters / navigates accordingly.
* - Dispatches 'cell-selected' when user taps a cell.
*/
export class CrosswordGrid extends LitElement {
static properties = {
rows: { type: Number },
cols: { type: Number },
_grid: { state: true },
_selected: { state: true },
_inputMode: { state: true }, // 'horizontal' or 'vertical'
_solvedCells: { state: true }, // tracks which cells are solved
_clueNumbers: { state: true }, // map of "row,col" -> { across: number, down: number }
_solutionIndices: { state: true }, // map of "row,col" -> solution index
_solutionWordPositions: { state: true }, // list of [col, row] positions for solution word
_solutionWordValues: { state: true }, // map of index -> letter for solution word
_solutionWordSolved: { state: true }, // set of solution word indices that are solved
};
// styles moved to webui/styles.css; render into light DOM so external CSS applies
constructor() {
super();
this.rows = 10;
this.cols = 10;
this._grid = [];
this._selected = { r: 0, c: 0 };
this._inputMode = 'horizontal'; // default input mode
this._solvedCells = new Set(); // set of "r,c" strings for solved cells
this._clueNumbers = new Map(); // map of "row,col" -> { across: number, down: number }
this._solutionIndices = new Map(); // map of "row,col" -> solution index (1-indexed)
this._solutionWordPositions = []; // list of [col, row] positions
this._solutionWordValues = new Map(); // map of index -> letter
this._solutionWordSolved = new Set(); // set of solution word indices that are solved
this.sessionId = null; // Session ID for sending updates to server
}
createRenderRoot() { return this; }
connectedCallback() {
super.connectedCallback();
// initialize empty grid
this._ensureGrid();
// listen for keyboard (mobile) events
this._keyHandler = (e) => this._onKeyPress(e.detail);
window.addEventListener('key-press', this._keyHandler);
this._computeCellSize();
this._resizeHandler = () => this._computeCellSize();
window.addEventListener('resize', this._resizeHandler);
// make the element focusable so it can receive physical keyboard events
this.setAttribute('tabindex', '0');
this._keydownHandler = (e) => this._onKeydown(e);
this.addEventListener('keydown', this._keydownHandler);
// Listen for letter updates from server
this._letterUpdateHandler = (msg) => this._onLetterUpdateFromServer(msg);
wsManager.onMessage('letter_update', this._letterUpdateHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('key-press', this._keyHandler);
this.removeEventListener('keydown', this._keydownHandler);
window.removeEventListener('resize', this._resizeHandler);
wsManager.offMessage('letter_update', this._letterUpdateHandler);
}
_ensureGrid() {
if (!this._grid || this._grid.length !== this.rows) {
this._grid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ''));
}
}
render() {
this._ensureGrid();
// set CSS variables for cell-size and column count; layout done in external stylesheet
return html`
<div class="main-grid-scroll-container">
<div class="grid-container ${this._isSolutionWordComplete() ? 'complete' : ''}">
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
</div>
</div>
</div>
${this._solutionWordPositions.length > 0 ? html`
<div class="solution-scroll-container">
<h2 style="text-align: center;">Solution Word</h2>
<div class="grid solution-word-grid" style="--cell-size: 40px; --cols: ${this._solutionWordPositions.length};">
${this._solutionWordPositions.map((pos, i) => this._renderSolutionCell(i, pos))}
</div>
</div>
` : ''}
`;
}
updated(changedProperties) {
super.updated(changedProperties);
// Set pulse animation delays when grid becomes complete
if (this._isSolutionWordComplete()) {
this._setPulseDelays();
}
}
_setPulseDelays() {
const gridContainer = this.querySelector('.grid-container');
if (gridContainer && gridContainer.classList.contains('complete')) {
const cells = gridContainer.querySelectorAll('.cell');
cells.forEach((cell, index) => {
// Calculate row and column from index
const row = Math.floor(index / this.cols);
const col = index % this.cols;
// Diagonal wave: delay based on row + col (top-left to bottom-right)
const diagonalIndex = row + col;
cell.style.setProperty('--pulse-delay', `${diagonalIndex * 0.1}s`);
});
}
}
_renderSolutionCell(index, position) {
const letter = this._solutionWordValues.get(index) || '';
const isSolved = this._solutionWordSolved.has(index);
const classes = ['cell'];
if (isSolved) classes.push('solved');
return html`
<div class="${classes.join(' ')}" data-solution-index="${index}" data-row="${position[1]}" data-col="${position[0]}" @click=${() => this._onSolutionCellClick(index, position)}>
<div class="solution-circle"></div>
<span class="solution-index">${index + 1}</span>
<span class="cell-letter">${letter}</span>
</div>
`;
}
_computeCellSize() {
// compute a comfortable cell size depending on viewport width and number of columns
try {
const maxWidth = Math.min(window.innerWidth * 0.92, 720); // keep some margin
let size = Math.floor((maxWidth - 16) / this.cols);
// clamp sizes
if (window.innerWidth <= 450) {
// on very small screens, allow larger tap targets up to 56px
size = Math.min(Math.max(size, 44), 64);
} else if (window.innerWidth <= 900) {
size = Math.min(Math.max(size, 40), 56);
} else {
size = Math.min(Math.max(size, 40), 48);
}
this._cellSize = size;
this.requestUpdate();
} catch (e) {
// fallback
this._cellSize = 44;
}
}
_renderCell(r, c, value) {
const classes = ['cell'];
const cellKey = `${r},${c}`;
const isSolved = this._solvedCells.has(cellKey);
if (value === '#') classes.push('wall');
if (isSolved) classes.push('solved');
if (this._selected.r === r && this._selected.c === c) classes.push('selected');
// Check if this cell is in the highlighted row/column based on input mode
// But only if it's not a wall
if (value !== '#') {
if (this._inputMode === 'horizontal' && r === this._selected.r && this._isInHorizontalLine(r, c)) {
classes.push('mode-highlighted');
} else if (this._inputMode === 'vertical' && c === this._selected.c && this._isInVerticalLine(r, c)) {
classes.push('mode-highlighted');
}
}
// Get clue numbers for this cell
const clueInfo = this._clueNumbers.get(cellKey);
let clueNumberDisplay = '';
if (clueInfo) {
if (clueInfo.across !== null && clueInfo.down !== null) {
// Both across and down clues: show "across/down" format
clueNumberDisplay = `${clueInfo.across}/${clueInfo.down}`;
} else if (clueInfo.across !== null) {
// Only across clue
clueNumberDisplay = String(clueInfo.across);
} else if (clueInfo.down !== null) {
// Only down clue
clueNumberDisplay = String(clueInfo.down);
}
}
// Get solution index for this cell
const solutionIndex = this._solutionIndices.get(cellKey);
const cellContent = clueNumberDisplay
? html`<span class="clue-number">${clueNumberDisplay}</span><span class="cell-letter">${value}</span>`
: html`<span class="cell-letter">${value}</span>`;
const cellHTML = solutionIndex !== undefined
? html`${cellContent}<div class="solution-circle"></div><span class="solution-index">${solutionIndex}</span>`
: cellContent;
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${cellHTML}</div>`;
}
/**
* Get the length of the horizontal line at the selected cell
*/
_getHorizontalLineLength(r = this._selected.r, c = this._selected.c) {
let start = c, end = c;
// Expand left
while (start > 0 && this._grid[r][start - 1] !== '#') {
start--;
}
// Expand right
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
end++;
}
return end - start + 1;
}
/**
* Check if cell (r, c) is part of the horizontal line from the selected cell
* (i.e., same row and not blocked by walls before/after this cell)
*/
_isInHorizontalLine(r, c) {
const selectedRow = this._selected.r;
if (r !== selectedRow) return false;
const selectedCol = this._selected.c;
// Find the start and end of the continuous line in this row
let start = selectedCol, end = selectedCol;
// Expand left
while (start > 0 && this._grid[r][start - 1] !== '#') {
start--;
}
// Expand right
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
end++;
}
return c >= start && c <= end;
}
/**
* Get the length of the vertical line at the selected cell
*/
_getVerticalLineLength(r = this._selected.r, c = this._selected.c) {
let start = r, end = r;
// Expand up
while (start > 0 && this._grid[start - 1][c] !== '#') {
start--;
}
// Expand down
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
end++;
}
return end - start + 1;
}
/**
* Check if the entire solution word is solved
*/
_isSolutionWordComplete() {
if (this._solutionWordPositions.length === 0) return false;
return this._solutionWordPositions.every((_, i) => this._solutionWordSolved.has(i));
}
/**
* Check if cell (r, c) is part of the vertical line from the selected cell
* (i.e., same column and not blocked by walls above/below this cell)
*/
_isInVerticalLine(r, c) {
const selectedCol = this._selected.c;
if (c !== selectedCol) return false;
const selectedRow = this._selected.r;
// Find the start and end of the continuous line in this column
let start = selectedRow, end = selectedRow;
// Expand up
while (start > 0 && this._grid[start - 1][c] !== '#') {
start--;
}
// Expand down
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
end++;
}
return r >= start && r <= end;
}
_onCellClick(r, c, preferredMode = null) {
// if same cell is clicked again, toggle the input mode
if (this._selected.r === r && this._selected.c === c) {
// If a preferred mode is provided, use it (don't toggle)
if (preferredMode) {
this._inputMode = preferredMode;
} else {
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
}
} else {
// select a new cell
this._selected = { r, c };
// Use preferred mode if provided, otherwise auto-select based on line lengths
if (preferredMode) {
this._inputMode = preferredMode;
} else {
// auto-select mode based on line lengths
const horizontalLength = this._getHorizontalLineLength(r, c);
const verticalLength = this._getVerticalLineLength(r, c);
// if one mode only has 1 cell but the other has multiple, use the one with multiple
if (horizontalLength === 1 && verticalLength > 1) {
this._inputMode = 'vertical';
} else if (verticalLength === 1 && horizontalLength > 1) {
this._inputMode = 'horizontal';
}
// otherwise keep current mode (both >1 or both =1)
}
}
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true }));
// focus the element so keyboard input goes to the grid
this.focus();
}
_onSolutionCellClick(index, position) {
// When clicking a solution word cell, select the corresponding grid cell
const [col, row] = position;
this._onCellClick(row, col);
}
_onKeydown(e) {
// Only handle keys when the grid has focus
// Map letters, arrows and backspace to our handlers
const key = e.key;
if (!key) return;
// letters (accept single-character a-z)
if (/^[a-zA-Z]$/.test(key)) {
e.preventDefault();
this._onKeyPress({ type: 'letter', value: key.toLowerCase() });
return;
}
// spacebar - treat as a letter input (empty string)
if (key === ' ') {
e.preventDefault();
this._onKeyPress({ type: 'letter', value: ' ' });
return;
}
// navigation arrows
if (key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown') {
e.preventDefault();
const dir = key.replace('Arrow', '').toLowerCase();
this._onKeyPress({ type: 'navigate', value: dir });
return;
}
// backspace/delete
if (key === 'Backspace' || key === 'Delete') {
e.preventDefault();
this._onKeyPress({ type: 'backspace' });
return;
}
} _onKeyPress(detail) {
if (!detail) return;
const { type, value } = detail;
if (type === 'letter') {
this._placeLetter(value);
} else if (type === 'navigate') {
this._navigate(value);
} else if (type === 'backspace') {
this._handleBackspace();
}
}
_handleBackspace() {
const { r, c } = this._selected;
const cellKey = `${r},${c}`;
// ignore walls
if (this._grid[r][c] === '#') return;
// If it's a solved cell, just navigate back without changing it
if (this._solvedCells.has(cellKey)) {
if (this._inputMode === 'horizontal') {
this._moveToNextCell(r, c, 'left');
} else { // vertical
this._moveToNextCell(r, c, 'up');
}
return;
}
// delete the letter at current cell (only for non-solved cells)
this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? '' : cell)));
// Send letter update to server (empty string for cleared cell)
if (this.sessionId && wsManager.isConnected()) {
const message = {
type: 'update_letter',
session_id: this.sessionId,
row: r,
col: c,
letter: ''
};
wsManager.send(message);
}
// move to previous cell based on input mode
if (this._inputMode === 'horizontal') {
this._moveToNextCell(r, c, 'left');
} else { // vertical
this._moveToNextCell(r, c, 'up');
}
this.requestUpdate();
}
_placeLetter(letter) {
const { r, c } = this._selected;
const cellKey = `${r},${c}`;
const currentLetter = this._grid[r][c];
// ignore walls
if (currentLetter === '#') return;
// For solved cells, only navigate (don't send updates)
if (this._solvedCells.has(cellKey)) {
// Only move if space or letter matches
if (letter === ' ' || letter.toUpperCase() === currentLetter.toUpperCase()) {
if (this._inputMode === 'horizontal') {
this._moveToNextCell(r, c, 'right');
} else { // vertical
this._moveToNextCell(r, c, 'down');
}
}
return;
}
// Check if the letter is the same as what's already there (case insensitive)
const isSameLetter = letter !== ' ' && letter.toUpperCase() === currentLetter.toUpperCase();
// Only update grid and send message if it's a different letter
if (!isSameLetter) {
this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? letter : cell)));
// Send letter update to server (empty string for space)
if (this.sessionId && wsManager.isConnected()) {
const message = {
type: 'update_letter',
session_id: this.sessionId,
row: r,
col: c,
letter: letter === ' ' ? '' : letter
};
wsManager.send(message);
}
}
// move to next cell if:
// - space was pressed, OR
// - the letter is the same as what was already there, OR
// - a new letter was placed
const shouldMove = letter === ' ' || isSameLetter || letter !== '';
if (shouldMove) {
if (this._inputMode === 'horizontal') {
this._moveToNextCell(r, c, 'right');
} else { // vertical
this._moveToNextCell(r, c, 'down');
}
}
this.requestUpdate();
}
/**
* Move from current cell to next valid cell in the given direction
*/
_moveToNextCell(r, c, direction) {
let nr = r, nc = c;
if (direction === 'right') nc += 1;
else if (direction === 'left') nc -= 1;
else if (direction === 'down') nr += 1;
else if (direction === 'up') nr -= 1;
// check bounds and walls
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols && this._grid[nr][nc] !== '#') {
this._selected = { r: nr, c: nc };
}
}
_navigate(direction) {
// Check if the arrow direction matches the current input mode
// If not, switch modes instead of navigating
const isHorizontalArrow = direction === 'left' || direction === 'right';
const isVerticalArrow = direction === 'up' || direction === 'down';
if (this._inputMode === 'horizontal' && isVerticalArrow) {
// User pressed up/down arrow but mode is horizontal, switch to vertical
this._inputMode = 'vertical';
this.requestUpdate();
// Emit event for mode change
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true }));
return;
}
if (this._inputMode === 'vertical' && isHorizontalArrow) {
// User pressed left/right arrow but mode is vertical, switch to horizontal
this._inputMode = 'horizontal';
this.requestUpdate();
// Emit event for mode change
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true }));
return;
}
// Direction matches mode, navigate normally
const { r, c } = this._selected;
let nr = r, nc = c;
if (direction === 'left') nc = Math.max(0, c - 1);
if (direction === 'right') nc = Math.min(this.cols - 1, c + 1);
if (direction === 'up') nr = Math.max(0, r - 1);
if (direction === 'down') nr = Math.min(this.rows - 1, r + 1);
// Check for walls
if (this._grid[nr] && this._grid[nr][nc] === '#') {
return; // Don't navigate into walls
}
this._selected = { r: nr, c: nc };
this.requestUpdate();
// Emit event for navigation
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: nr, col: nc, mode: this._inputMode }, bubbles: true, composed: true }));
}
// convenience method to set grid walls (for demo)
setWalls(wallPositions = []) {
wallPositions.forEach(([r, c]) => {
if (r >= 0 && r < this.rows && c >= 0 && c < this.cols) {
this._grid[r][c] = '#';
}
});
this.requestUpdate();
}
/**
* Calculate completion ratio as percentage (0-100)
*/
_calculateCompletionRatio() {
let totalNonWallCells = 0;
let solvedCells = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (this._grid[r][c] !== '#') {
totalNonWallCells++;
const cellKey = `${r},${c}`;
if (this._solvedCells.has(cellKey)) {
solvedCells++;
}
}
}
}
if (totalNonWallCells === 0) return 0;
return Math.round((solvedCells / totalNonWallCells) * 100);
}
/**
* Get current completion ratio (public method)
*/
getCompletionRatio() {
return this._calculateCompletionRatio();
}
/**
* Handle letter updates from server (broadcast messages from other players)
*/
_onLetterUpdateFromServer(message) {
const { row, col, letter, is_solved } = message;
// Update grid if within bounds
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
this._grid = this._grid.map((gridRow, ri) =>
gridRow.map((cell, ci) => (ri === row && ci === col ? letter : cell))
);
// Update solved status
const cellKey = `${row},${col}`;
if (is_solved) {
this._solvedCells.add(cellKey);
} else {
this._solvedCells.delete(cellKey);
}
// Update solution word if this position is part of it
for (let i = 0; i < this._solutionWordPositions.length; i++) {
const [col_sw, row_sw] = this._solutionWordPositions[i];
if (row === row_sw && col === col_sw) {
this._solutionWordValues.set(i, letter);
// Mark solution word cell as solved
if (is_solved) {
this._solutionWordSolved.add(i);
} else {
this._solutionWordSolved.delete(i);
}
break;
}
}
this.requestUpdate();
// Calculate and emit completion ratio update
const completionRatio = this._calculateCompletionRatio();
this.dispatchEvent(new CustomEvent('completion-ratio-changed', {
detail: { completionRatio },
bubbles: true,
composed: true
}));
// Trigger animation if solution word just completed
if (this._isSolutionWordComplete()) {
this.updateComplete.then(() => {
const gridContainer = this.querySelector('.solution-word-grid');
if (gridContainer) {
// Force reflow to trigger animation
gridContainer.offsetHeight;
gridContainer.classList.remove('complete');
gridContainer.offsetHeight;
gridContainer.classList.add('complete');
}
});
}
// Emit a letter-changed event so solution word can update
this.dispatchEvent(new CustomEvent('letter-changed', {
detail: { row, col, letter, is_solved },
bubbles: true,
composed: true
}));
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
}
}
/**
* Populate clue numbers from server data
* @param {Object} cluePositionsAcross - dict of clue_number -> [col, row]
* @param {Object} cluePositionsDown - dict of clue_number -> [col, row]
*/
populateClueNumbers(cluePositionsAcross = {}, cluePositionsDown = {}) {
this._clueNumbers.clear();
// Add across clues
for (const [clueNum, position] of Object.entries(cluePositionsAcross)) {
const [col, row] = position;
const cellKey = `${row},${col}`;
if (!this._clueNumbers.has(cellKey)) {
this._clueNumbers.set(cellKey, { across: null, down: null });
}
this._clueNumbers.get(cellKey).across = parseInt(clueNum);
}
// Add down clues
for (const [clueNum, position] of Object.entries(cluePositionsDown)) {
const [col, row] = position;
const cellKey = `${row},${col}`;
if (!this._clueNumbers.has(cellKey)) {
this._clueNumbers.set(cellKey, { across: null, down: null });
}
this._clueNumbers.get(cellKey).down = parseInt(clueNum);
}
this.requestUpdate();
}
/**
* Populate solution word indices from server data
* @param {Array} solutionPositions - list of [col, row] positions in order
*/
populateSolutionIndices(solutionPositions = []) {
this._solutionIndices.clear();
this._solutionWordPositions = solutionPositions;
this._solutionWordValues.clear();
this._solutionWordSolved.clear();
for (let i = 0; i < solutionPositions.length; i++) {
const [col, row] = solutionPositions[i];
const cellKey = `${row},${col}`;
this._solutionIndices.set(cellKey, i + 1); // 1-indexed
// Initialize solution word value with current grid letter
const letter = this._grid[row][col] || '';
this._solutionWordValues.set(i, letter);
// Check if this position is already solved
if (this._solvedCells.has(cellKey)) {
this._solutionWordSolved.add(i);
}
}
console.log('Solution word initialized. Solved:', this._solutionWordSolved.size, 'Total:', this._solutionWordPositions.length);
this.requestUpdate();
// Trigger animation on init if already complete
if (this._isSolutionWordComplete()) {
this.updateComplete.then(() => {
const gridContainer = this.querySelector('.solution-word-grid');
if (gridContainer) {
// Force reflow to trigger animation
gridContainer.offsetHeight;
gridContainer.classList.remove('complete');
gridContainer.offsetHeight;
gridContainer.classList.add('complete');
}
});
}
}
}
customElements.define('crossword-grid', CrosswordGrid);

View File

@ -0,0 +1,518 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="./favicon.png" />
<link rel="manifest" href="./manifest.json">
<!-- Polyfills only needed for Firefox and Edge. -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@latest/webcomponents-loader.js"></script>
<!-- Works only on browsers that support Javascript modules like
Chrome, Safari, Firefox 60, Edge 17 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<notification-area id="notification-area"></notification-area>
<main id="main-container">
<!-- Menu will be shown first -->
<crossword-menu id="menu"></crossword-menu>
<!-- Grid will be shown after session creation -->
<div id="grid-container" style="display: none;">
<div class="container">
<h2>Crossword Grid</h2>
<crossword-grid id="grid" rows="10" cols="10"></crossword-grid>
</div>
</div>
</main>
<mobile-keyboard id="keyboard" style="display: none;"></mobile-keyboard>
<script type="module">
import './menu.js';
import './grid.js';
import './keyboard.js';
import './notification-area.js';
import './clue_area.js';
import wsManager from './websocket.js';
import notificationManager from './notification-manager.js';
const menu = document.getElementById('menu');
const gridContainer = document.getElementById('grid-container');
const keyboard = document.getElementById('keyboard');
let currentSessionId = null;
let clueArea = null;
let gridElement = null;
let isClosingGame = false; // Flag to prevent popstate from reloading session
// Test notifications
notificationManager.success('App loaded successfully');
// Helper function to get session ID from URL params
function getSessionIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('session_id');
}
// Helper function to update URL with session ID
function updateUrlWithSessionId(sessionId) {
const params = new URLSearchParams(window.location.search);
params.set('session_id', sessionId);
window.history.pushState({ sessionId }, '', `${window.location.pathname}?${params.toString()}`);
}
// Helper function to subscribe to a session
function subscribeToSession(sessionId) {
console.log('Subscribing to session:', sessionId);
currentSessionId = sessionId;
// Update URL with session ID
updateUrlWithSessionId(sessionId);
// Show game UI immediately
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
gridContainer.innerHTML = '<div class="loading-spinner">Loading session...</div>';
const message = {
type: 'subscribe_session',
session_id: sessionId
};
wsManager.send(message);
notificationManager.info('Loading session...');
}
// Make subscribeToSession available globally for the menu component
window.subscribeToSession = subscribeToSession;
// Handle session creation response
wsManager.onMessage('session_created', (message) => {
console.log('Session created:', message);
currentSessionId = message.session_id;
// Update URL with session ID
updateUrlWithSessionId(message.session_id);
// Hide menu, show loading state
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
// Show loading indicator
gridContainer.innerHTML = '<div class="loading-spinner">Loading game...</div>';
notificationManager.info('Session created, loading game...');
// Subscribe to session
subscribeToSession(message.session_id);
});
// Handle full session state (grid, clues, etc.)
wsManager.onMessage('full_session_state', (message) => {
console.log('Full session state received:', message);
if (message.session_id !== currentSessionId) {
console.warn('Received session state for different session, ignoring');
return;
}
// Destroy existing clue area if it exists
if (clueArea && clueArea.parentNode) {
clueArea.remove();
clueArea = null;
}
// Create grid from session state
const gridRows = message.grid.length;
const gridCols = message.grid[0].length;
// Create container with close button
gridContainer.innerHTML = `
<div class="game-header">
<h2 id="crossword-title" style="text-align: center;">Crossword (0%)</h2>
<div class="header-buttons">
<button class="share-game-btn" aria-label="Share game">
<span style="padding-right: 0.5rem;">Share Session</span>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.15c.52.47 1.2.77 1.96.77 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.44 9.31 6.77 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.77 0 1.44-.3 1.96-.77l7.12 4.16c-.057.21-.087.43-.087.66 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
</button>
<button class="close-game-btn" aria-label="Close game">✕</button>
</div>
</div>
<div class="game-content">
</div>
`;
const gameContent = gridContainer.querySelector('.game-content');
const closeBtn = gridContainer.querySelector('.close-game-btn');
// Create new grid element
const gridElementNew = document.createElement('crossword-grid');
gridElementNew.id = 'grid';
gridElementNew.setAttribute('rows', gridRows);
gridElementNew.setAttribute('cols', gridCols);
gridElementNew.sessionId = message.session_id; // Set session ID for message sending
gridElement = gridElementNew;
// Parse walls from grid data (walls are marked with '#')
const wallPositions = [];
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
if (message.grid[r][c] === '#') {
wallPositions.push([r, c]);
}
}
}
// Add grid to game content
gameContent.appendChild(gridElement);
// Wait for grid to be fully rendered, then set walls and letters
setTimeout(() => {
gridElement.setWalls(wallPositions);
// Set all letters from the server's grid state
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
const cell = message.grid[r][c];
// Skip walls and empty cells
if (cell !== '#' && cell !== '') {
gridElement._grid[r][c] = cell;
}
}
}
// Mark solved positions
if (message.solved_positions) {
for (const [col, row] of message.solved_positions) {
const cellKey = `${row},${col}`;
gridElement._solvedCells.add(cellKey);
}
}
// Populate clue numbers for display
gridElement.populateClueNumbers(message.clue_positions_across, message.clue_positions_down);
// Populate solution word
if (message.solution_word_positions) {
gridElement.populateSolutionIndices(message.solution_word_positions);
}
gridElement.requestUpdate();
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
console.log(`Clues: ${Object.keys(message.clues_across).length} across, ${Object.keys(message.clues_down).length} down`);
if (message.solved_positions) {
console.log(`Solved positions: ${message.solved_positions.length}`);
}
if (message.solution_word_positions) {
console.log(`Solution word positions: ${message.solution_word_positions.length}`);
}
}, 0);
// Create and add clue area
clueArea = document.createElement('clue-area');
clueArea.cluesAcross = message.clues_across;
clueArea.cluesDown = message.clues_down;
clueArea.cluePositionsAcross = message.clue_positions_across;
clueArea.cluePositionsDown = message.clue_positions_down;
clueArea.grid = message.grid; // Pass grid for dimension calculation
// Setup gridData for solved clue tracking
const walls = new Set();
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
if (message.grid[r][c] === '#') {
walls.add(`${r},${c}`);
}
}
}
const solvedCells = new Set();
if (message.solved_positions) {
for (const [col, row] of message.solved_positions) {
solvedCells.add(`${row},${col}`);
}
}
clueArea.gridData = {
rows: gridRows,
cols: gridCols,
walls: walls,
solvedCells: solvedCells
};
clueArea.selectedRow = 0;
clueArea.selectedCol = 0;
clueArea.selectedMode = 'horizontal';
// Update solved clues initially
clueArea._updateSolvedClues();
document.body.insertBefore(clueArea, document.body.firstChild);
// Listen for cell selection changes
gridElement.addEventListener('cell-selected', (e) => {
clueArea.selectedRow = e.detail.row;
clueArea.selectedCol = e.detail.col;
clueArea.selectedMode = e.detail.mode;
clueArea.requestUpdate();
});
// Listen for clue clicks to navigate grid
clueArea.addEventListener('clue-selected', (e) => {
const { row, col, mode } = e.detail;
// Call _onCellClick with preferred mode from the clue
gridElement._onCellClick(row, col, mode);
gridElement.focus();
});
// Listen for letter updates to update solved clues
gridElement.addEventListener('letter-changed', (e) => {
const { row, col, is_solved } = e.detail;
const cellKey = `${row},${col}`;
if (is_solved) {
clueArea.gridData.solvedCells.add(cellKey);
} else {
clueArea.gridData.solvedCells.delete(cellKey);
}
clueArea._updateSolvedClues();
clueArea.requestUpdate();
});
// Listen for completion ratio updates
gridElement.addEventListener('completion-ratio-changed', (e) => {
const { completionRatio } = e.detail;
updateHeaderTitle(completionRatio);
// Update session storage with completion ratio
if (window.updateSessionCompletionRatio) {
window.updateSessionCompletionRatio(currentSessionId, completionRatio);
}
});
// Function to update header title with completion percentage
function updateHeaderTitle(completionRatio) {
const titleElement = document.getElementById('crossword-title');
if (titleElement) {
titleElement.textContent = `Crossword (${completionRatio}%)`;
}
}
// Calculate initial completion ratio after grid is fully set up
setTimeout(() => {
const initialRatio = gridElement.getCompletionRatio();
updateHeaderTitle(initialRatio);
// Update session storage with initial completion ratio
if (window.updateSessionCompletionRatio) {
window.updateSessionCompletionRatio(currentSessionId, initialRatio);
}
}, 100);
// Close button handler
closeBtn.addEventListener('click', closeGame);
const shareBtn = gridContainer.querySelector('.share-game-btn');
shareBtn.addEventListener('click', shareGame);
notificationManager.success('Game loaded successfully');
});
// Function to share game
function shareGame() {
console.log('Sharing game with session ID:', currentSessionId);
// Build URL with session ID
const url = `${window.location.origin}${window.location.pathname}?session_id=${currentSessionId}`;
// Try native share API first (mobile)
if (navigator.share) {
navigator.share({
title: 'Join my Crossword!',
text: 'Play crossword with me!',
url: url
}).then(() => {
console.log('Share successful');
}).catch(err => {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
showShareDialog(url);
}
});
} else {
// Fallback: show dialog with link
showShareDialog(url);
}
}
// Function to show share dialog with copy option
function showShareDialog(url) {
console.log('Showing share dialog with URL:', url);
// Create modal dialog
const dialog = document.createElement('div');
dialog.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #2a2520;
padding: 1.5rem;
border-radius: 0.5rem;
max-width: 90%;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
`;
content.innerHTML = `
<h3 style="margin: 0 0 1rem 0; color: #f5f1ed; font-size: 1.2rem;">Share Game Link</h3>
<p style="margin: 0 0 1rem 0; color: #d4cdc5; font-size: 0.9rem;">Copy this link and send it to friends:</p>
<input type="text" value="${url}" readonly style="
width: 100%;
padding: 0.75rem;
background: #1a1511;
color: #f5f1ed;
border: 1px solid #5a4a4a;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.85rem;
box-sizing: border-box;
margin-bottom: 1rem;
" id="share-url-input" />
<div style="display: flex; gap: 0.5rem;">
<button id="copy-btn" style="
flex: 1;
padding: 0.75rem;
background: #4a7a9e;
color: #f5f1ed;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
">Copy</button>
<button id="close-share-btn" style="
flex: 1;
padding: 0.75rem;
background: #5a4a4a;
color: #f5f1ed;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
">Close</button>
</div>
`;
dialog.appendChild(content);
document.body.appendChild(dialog);
// Copy button
document.getElementById('copy-btn').addEventListener('click', () => {
const input = document.getElementById('share-url-input');
input.select();
document.execCommand('copy');
notificationManager.success('Link copied!');
dialog.remove();
});
// Close button
document.getElementById('close-share-btn').addEventListener('click', () => {
dialog.remove();
});
// Close on background click
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.remove();
}
});
}
// Function to close game and return to menu
function closeGame() {
console.log('Closing game');
// Simply reload the page without session ID to return to menu
window.location.href = window.location.pathname;
}
// Handle errors
wsManager.onMessage('error', (message) => {
console.error('Server error:', message);
// Show menu again
menu.style.display = 'block';
gridContainer.style.display = 'none';
keyboard.style.display = 'none';
gridContainer.innerHTML = '';
notificationManager.error(message.error_message || 'An error occurred');
});
// Check on page load if we have an existing session ID
window.addEventListener('load', () => {
const existingSessionId = getSessionIdFromUrl();
if (existingSessionId) {
console.log('Found existing session ID in URL:', existingSessionId);
// Wait for WebSocket to connect before subscribing
if (wsManager.isConnected()) {
subscribeToSession(existingSessionId);
} else {
// Register handler to subscribe once connected
wsManager.onMessage('open', () => {
subscribeToSession(existingSessionId);
});
}
// Hide menu immediately
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
}
});
// Handle back button to switch between sessions
window.addEventListener('popstate', (event) => {
// Skip if we just closed the game intentionally
if (isClosingGame) {
console.log('Game is being closed, skipping popstate');
return;
}
console.log('Popstate event:', event);
const sessionId = getSessionIdFromUrl();
if (sessionId && currentSessionId !== sessionId) {
console.log('Navigating to session:', sessionId);
subscribeToSession(sessionId);
} else if (!sessionId) {
console.log('No session in URL, showing menu');
closeGame();
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,179 @@
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
/**
* <mobile-keyboard>
* A compact on-screen keyboard for mobile devices. Emits 'key-press' events on window
* with detail { type: 'letter'|'navigate'|'backspace', value }
*/
export class MobileKeyboard extends LitElement {
static properties = {
// reflect so CSS attribute selectors can react to collapsed state
collapsed: { type: Boolean, reflect: true },
_isMobile: { type: Boolean, state: true },
_wideScreen: { type: Boolean, state: true },
};
// styles moved to webui/styles.css; render into light DOM so external CSS applies
constructor() {
super();
this.collapsed = true; // default collapsed; _onResize will flip for mobile
this._isMobile = false;
this._letters = 'abcdefghijklmnopqrstuvwxyz'.split('');
this._onResize = this._onResize.bind(this);
}
createRenderRoot() { return this; }
render() {
// simple QWERTY-like rows
const rows = [
'qwertyuiop'.split(''),
'asdfghjkl'.split(''),
'zxcvbnm'.split(''),
];
// compute the maximum number of columns across rows (account for backspace in second row now)
const counts = rows.map((r, idx) => r.length + (idx === 1 ? 1 : 0));
const arrowCols = 3; // reserve 3 columns on the right for [left][down][right]
const baseMax = Math.max(...counts, 10);
const maxCols = baseMax;
return html`
<div class="keyboard-container">
${html`<div class="handle" @click=${this._toggleCollapse}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle; margin-right: 0.3rem;">
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm6 7H5v-2h8v2zm0-4h-2v-2h2v2zm3 0h-2v-2h2v2zm3 0h-2v-2h2v2zm0-3h-2V8h2v2z"/>
</svg>
${this.collapsed ? '▲' : '▼'}
</div>`}
<div class="keyboard-wrapper">
<div class="keyboard">
${rows.map((r, idx) => {
// center the letter keys leaving the rightmost `arrowCols` for the arrow block
let rowClasses = 'row';
if (idx === 1) rowClasses += ' stagger'; // A row
if (idx === 2) rowClasses += ' stagger-deep'; // Z row needs a larger indent
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
<div class="keys">
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
${idx === 1 ? html`<button class="backspace" @click=${() => this._emitBackspace()}>⌫</button>` : ''}
</div>
<div class="arrows">
${Array.from({ length: arrowCols }).map((_, i) => {
if (idx === 2 && i === 1) return html`<button class="nav" @click=${() => this._emitNavigate('up')}>▲</button>`;
return html`<div class="key-spacer"></div>`;
})}
</div>
</div>`;
})}
<!-- spacebar row -->
<div class="row" style="--cols:${maxCols};">
<!-- spacebar spans all but the right arrow columns -->
<button class="space" @click=${() => this._emitSpace()}></button>
<!-- arrow columns: left, down, right (will occupy the last 3 columns) -->
<button class="nav" @click=${() => this._emitNavigate('left')}></button>
<button class="nav" @click=${() => this._emitNavigate('down')}></button>
<button class="nav" @click=${() => this._emitNavigate('right')}></button>
</div>
</div>
</div>
</div>
`;
} _emitLetter(l) {
this._vibrate();
this._emit({ type: 'letter', value: l });
}
_emitNavigate(dir) {
this._vibrate();
this._emit({ type: 'navigate', value: dir });
}
_emitBackspace() {
this._vibrate();
this._emit({ type: 'backspace' });
}
_emitSpace() {
this._vibrate();
this._emit({ type: 'letter', value: '' });
}
_emit(detail) {
window.dispatchEvent(new CustomEvent('key-press', { detail }));
}
_vibrate() {
// Use Vibration API for haptic feedback
try {
console.log('Attempting vibration... navigator.vibrate:', typeof navigator.vibrate);
if (navigator.vibrate) {
navigator.vibrate(10); // 10ms short buzz
console.log('Vibration sent!');
} else {
console.log('Vibration API not available on this device');
}
} catch (e) {
console.warn('Vibration API error:', e);
}
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('resize', this._onResize);
this._onResize();
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('resize', this._onResize);
}
_onResize() {
const mobile = window.innerWidth <= 900;
this._isMobile = mobile;
this.classList.toggle('mobile', mobile);
this.classList.toggle('desktop', !mobile);
// decide wide-screen (landscape/tablet) to change layout behavior
const wide = (window.innerWidth / window.innerHeight) > 1.6;
this._wideScreen = wide;
this.classList.toggle('wide-screen', wide);
// collapsed default: expanded on mobile, collapsed on desktop
const wasCollapsed = this.collapsed;
if (mobile) this.collapsed = false;
else this.collapsed = true;
// Set padding immediately when state changes
const main = document.querySelector('main');
if (main) {
if (this.collapsed) {
main.style.paddingBottom = 'var(--page-padding)';
} else {
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
main.style.paddingBottom = computedHeight;
}
}
} _toggleCollapse() {
this.collapsed = !this.collapsed;
if (this.collapsed) {
this.setAttribute('collapsed', '');
// Remove padding when keyboard is collapsed
const main = document.querySelector('main');
if (main) main.style.paddingBottom = 'var(--page-padding)';
} else {
this.removeAttribute('collapsed');
// Add padding when keyboard is expanded - get actual computed height
const main = document.querySelector('main');
if (main) {
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
main.style.paddingBottom = computedHeight;
}
}
}
}
customElements.define('mobile-keyboard', MobileKeyboard);

View File

@ -0,0 +1 @@
// TODO

View File

@ -0,0 +1,17 @@
{
"name": "Crosswords",
"short_name": "crosswords",
"start_url": "./",
"display": "standalone",
"background_color": "#000000",
"description": "Simple Collaborative Crossword",
"theme_color": "#000000",
"icons": [
{
"src": "./big_icon.png",
"sizes": "256x256",
"type": "image/png"
}
]
}

View File

@ -0,0 +1,454 @@
import { html, LitElement } from 'https://unpkg.com/lit-element/lit-element.js?module';
import wsManager from './websocket.js';
import notificationManager from './notification-manager.js';
export class CrosswordMenu extends LitElement {
createRenderRoot() {
return this;
}
static get properties() {
return {
_loading: { state: true },
_error: { state: true },
_sessionProperties: { state: true },
_selectedLanguage: { state: true },
_selectedBoardSize: { state: true },
_saveSessionsEnabled: { state: true },
_savedSessions: { state: true }
};
}
constructor() {
super();
this._loading = true;
this._error = null;
this._sessionProperties = null;
this._selectedLanguage = '';
this._selectedBoardSize = '';
this._saveSessionsEnabled = false;
this._savedSessions = [];
this._initializeSessionStorage();
}
connectedCallback() {
super.connectedCallback();
// Register notification manager with WebSocket
wsManager.setNotificationManager(notificationManager);
// Listen for session creation/subscription events
wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg));
wsManager.onMessage('full_session_state', (msg) => this._onSessionJoined(msg));
wsManager.onMessage('error', (msg) => this._onSessionError(msg));
this._initializeConnection();
// Make update function available globally
window.updateSessionCompletionRatio = (sessionId, completionRatio) => {
this._updateSessionCompletionRatio(sessionId, completionRatio);
};
}
disconnectedCallback() {
super.disconnectedCallback();
// Remove message handlers
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
wsManager.offMessage('error', this._handleError);
wsManager.offMessage('session_created', this._onSessionCreated);
wsManager.offMessage('full_session_state', this._onSessionJoined);
wsManager.offMessage('error', this._onSessionError);
}
_initializeConnection() {
// Register message handlers
wsManager.onMessage('available_session_properties', (msg) => this._handleSessionProperties(msg));
wsManager.onMessage('error', (msg) => this._handleError(msg));
// Also listen for open event to request properties when connection is established
wsManager.onMessage('open', (msg) => this._requestSessionProperties());
// Connect if not already connected
if (!wsManager.isConnected()) {
const wsUrl = this._getWebsocketUrl();
wsManager.connect(wsUrl);
} else {
// Already connected, request session properties
this._requestSessionProperties();
}
}
_getWebsocketUrl() {
const host = window.location.hostname;
// Special case for GitHub Pages deployment
if (host === 'antielektron.github.io') {
return 'wss://the-cake-is-a-lie.net/crosswords_backend/';
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// If host is localhost, use port 8765. Otherwise, use default port (443 for wss, 80 for ws)
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
const port = isLocalhost ? 8765 : '';
const portStr = port ? `:${port}` : '';
// If host is localhost, use it as is. Otherwise, add crosswords_backend/ to the path
const path = isLocalhost ? '' : 'crosswords_backend/';
return `${protocol}://${host}${portStr}/${path}`;
}
_requestSessionProperties() {
const message = {
type: 'get_available_session_properties'
};
wsManager.send(message);
}
_handleSessionProperties(message) {
this._sessionProperties = {
supported_languages: message.supported_languages,
min_grid_size: message.min_grid_size,
max_grid_size: message.max_grid_size,
board_size_presets: message.board_size_presets
};
// Set default selections
if (this._sessionProperties.supported_languages.length > 0) {
this._selectedLanguage = this._sessionProperties.supported_languages[0];
}
if (this._sessionProperties.board_size_presets && Object.keys(this._sessionProperties.board_size_presets).length > 0) {
this._selectedBoardSize = Object.keys(this._sessionProperties.board_size_presets)[0];
}
this._loading = false;
this._error = null;
notificationManager.success('Connected to Crossword server');
this.requestUpdate();
}
_handleError(message) {
this._error = message.error_message || 'An error occurred';
notificationManager.error(this._error);
this.requestUpdate();
}
_onLanguageChange(event) {
this._selectedLanguage = event.target.value;
}
_onBoardSizeChange(event) {
this._selectedBoardSize = event.target.value;
}
_onCreateCrossword() {
const boardDimensions = this._sessionProperties.board_size_presets[this._selectedBoardSize];
const [gridW, gridH] = boardDimensions;
console.log('Creating crossword with:', {
language: this._selectedLanguage,
grid_w: gridW,
grid_h: gridH
});
// Send session creation message to server
const message = {
type: 'new_multiplayer_session',
lang: this._selectedLanguage,
grid_w: gridW,
grid_h: gridH
};
wsManager.send(message);
notificationManager.info('Creating session...');
}
_toggleDataInfo() {
const element = this.querySelector('.data-info-details');
if (element) {
element.style.display = element.style.display === 'none' ? 'block' : 'none';
}
}
// Session storage management
_initializeSessionStorage() {
// Check if the save setting is enabled
const saveSettingEnabled = this._getCookie('saveSessionsEnabled');
if (saveSettingEnabled === 'true') {
this._saveSessionsEnabled = true;
// Load saved sessions if the setting is enabled
const savedSessionsData = this._getCookie('savedSessions');
if (savedSessionsData) {
try {
this._savedSessions = JSON.parse(savedSessionsData);
// Ensure all sessions have a completionRatio field (for backward compatibility)
this._savedSessions = this._savedSessions.map(session => ({
...session,
completionRatio: session.completionRatio || 0
}));
} catch (e) {
console.warn('Failed to parse saved sessions cookie:', e);
this._clearAllCookies();
}
}
}
}
_getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
_setCookie(name, value, days = 30) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
}
_deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}
_clearAllCookies() {
this._deleteCookie('savedSessions');
this._deleteCookie('saveSessionsEnabled');
this._savedSessions = [];
this._saveSessionsEnabled = false;
this.requestUpdate();
}
_clearSessionsOnly() {
this._deleteCookie('savedSessions');
this._savedSessions = [];
this.requestUpdate();
}
_toggleSessionSaving() {
this._saveSessionsEnabled = !this._saveSessionsEnabled;
if (this._saveSessionsEnabled) {
// Save the setting preference when enabled
this._setCookie('saveSessionsEnabled', 'true');
} else {
// Clear everything when disabled
this._clearAllCookies();
}
this.requestUpdate();
}
_saveSession(sessionId, sessionInfo = {}) {
if (!this._saveSessionsEnabled) return;
// Remove existing entry for this session
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
// Add new entry
this._savedSessions.unshift({
id: sessionId,
timestamp: Date.now(),
completionRatio: 0, // Default completion ratio
...sessionInfo
});
// Keep only last 10 sessions
this._savedSessions = this._savedSessions.slice(0, 10);
// Save to cookie
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
this.requestUpdate();
}
_updateSessionCompletionRatio(sessionId, completionRatio) {
if (!this._saveSessionsEnabled) return;
// Find and update the session
const sessionIndex = this._savedSessions.findIndex(s => s.id === sessionId);
if (sessionIndex !== -1) {
this._savedSessions[sessionIndex].completionRatio = completionRatio;
this._savedSessions[sessionIndex].timestamp = Date.now(); // Update timestamp
// Save updated sessions to cookie
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
this.requestUpdate();
}
}
_removeSession(sessionId) {
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
if (this._savedSessions.length === 0) {
this._clearSessionsOnly();
} else {
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
}
this.requestUpdate();
}
_onSessionCreated(message) {
if (message.session_id) {
this._saveSession(message.session_id, {
type: 'created',
language: this._selectedLanguage,
boardSize: this._selectedBoardSize
});
}
}
_onSessionJoined(message) {
if (message.session_id) {
this._saveSession(message.session_id, {
type: 'joined'
});
}
}
_onSessionError(message) {
// Check if it's a session not found error
if (message.error_message && message.error_message.includes('session') && message.error_message.includes('not found')) {
// Try to extract session ID from error message or use current session ID
// This is a fallback - we might not always have the exact session ID in error messages
const sessionIdMatch = message.error_message.match(/session\s+([a-f0-9-]+)/i);
if (sessionIdMatch) {
const sessionId = sessionIdMatch[1];
this._removeSession(sessionId);
notificationManager.warning(`Session ${sessionId.substring(0, 8)}... no longer exists and was removed from saved sessions`);
}
}
}
_reconnectToSession(sessionId) {
// Update the timestamp to move this session to the top
this._saveSession(sessionId, { type: 'rejoined' });
// Use the global subscribeToSession function to properly set currentSessionId
if (window.subscribeToSession) {
window.subscribeToSession(sessionId);
} else {
// Fallback if function not available
const message = {
type: 'subscribe_session',
session_id: sessionId
};
wsManager.send(message);
notificationManager.info('Reconnecting to session...');
}
}
_clearSavedSessions() {
this._clearSessionsOnly();
notificationManager.info('All saved sessions cleared');
}
_formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffHours = Math.floor((now - date) / (1000 * 60 * 60));
const diffMinutes = Math.floor((now - date) / (1000 * 60));
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
render() {
if (this._loading) {
return html`
<div class="menu-container">
<div class="loading">Loading game options...</div>
</div>
`;
}
if (!this._sessionProperties) {
return html`
<div class="menu-container">
<div class="menu">
<div class="error">Failed to load game options. Retrying...</div>
</div>
</div>
`;
}
return html`
<div class="menu-container">
<div class="menu">
<h1>Multiplayer Crossword</h1>
<p class="description">Collaborate with others to solve crosswords in real-time. Create a session and share the link with friends to play together!</p>
${this._error ? html`<div class="error">${this._error}</div>` : ''}
<div class="form-group">
<label for="language">Language:</label>
<select id="language" @change="${this._onLanguageChange}">
${this._sessionProperties.supported_languages.map(lang =>
html`<option value="${lang}" ?selected="${lang === this._selectedLanguage}">${lang.toUpperCase()}</option>`
)}
</select>
</div>
<div class="form-group">
<label for="board-size">Board Size:</label>
<select id="board-size" @change="${this._onBoardSizeChange}">
${Object.entries(this._sessionProperties.board_size_presets || {}).map(([name, dimensions]) =>
html`<option value="${name}" ?selected="${name === this._selectedBoardSize}">${name} (${dimensions[0]}×${dimensions[1]})</option>`
)}
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" ?checked="${this._saveSessionsEnabled}" @change="${this._toggleSessionSaving}">
Save list of recently joined sessions (uses cookies)
</label>
</div>
<button @click="${this._onCreateCrossword}">Create Crossword</button>
${this._savedSessions.length > 0 ? html`
<div class="saved-sessions">
<h3>Recent Sessions</h3>
<div class="session-list">
${this._savedSessions.map(session => html`
<div class="session-item">
<div class="session-info">
<span class="session-id">${session.id.substring(0, 8)}...</span>
<span class="session-time">${this._formatTimestamp(session.timestamp)}</span>
${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''}
<span class="session-completion">${session.completionRatio || 0}% solved</span>
</div>
<div class="session-actions">
<button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button>
<button class="remove-btn" @click="${() => this._removeSession(session.id)}">×</button>
</div>
</div>
`)}
</div>
<button class="clear-all-btn" @click="${this._clearSavedSessions}">Clear All Sessions</button>
</div>
` : ''}
<div class="data-info">
<span class="data-info-toggle" @click="${this._toggleDataInfo}"> 📋 Data Usage for the Multiplayer functionality</span>
<div class="data-info-details" style="display: none;">
<ul>
<li><strong>Shared Data:</strong> Only the letters you type into the grid during a session are shared with other users and the backend server in that session.</li>
<li><strong>Session Lifetime:</strong> Sessions are automatically deleted after 48 hours of inactivity.</li>
<li><strong>No Tracking:</strong> No personal data is collected or stored beyond the session duration.</li>
</ul>
</div>
</div>
<p style="margin-top: 12px; font-size: 0.9em;">
<a href="https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords" target="_blank" rel="noopener noreferrer">🔗 View source code on Gitea</a>
</p>
</div>
</div>
`;
}
}
customElements.define('crossword-menu', CrosswordMenu);

View File

@ -0,0 +1,66 @@
import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module';
import notificationManager from './notification-manager.js';
export class NotificationArea extends LitElement {
createRenderRoot() {
return this;
}
static get properties() {
return {
_message: { state: true },
_type: { state: true },
_visible: { state: true }
};
}
constructor() {
super();
this._message = '';
this._type = 'info'; // success, info, error
this._visible = false;
}
connectedCallback() {
super.connectedCallback();
// Register this element with the global notification manager
notificationManager.setNotificationElement(this);
}
/**
* Called by NotificationManager to show a notification
*/
setNotification(message, type) {
this._message = message;
this._type = type;
this._visible = true;
this.requestUpdate();
}
/**
* Called by NotificationManager to clear notification
*/
clearNotification() {
this._visible = false;
this.requestUpdate();
}
render() {
if (!this._visible) {
return html`<div class="notification-area"></div>`;
}
return html`
<div class="notification-area">
<div class="notification notification-${this._type}">
<span class="notification-message">${this._message}</span>
<button class="notification-close" @click="${() => this.clearNotification()}" aria-label="Close notification">
</button>
</div>
</div>
`;
}
}
customElements.define('notification-area', NotificationArea);

View File

@ -0,0 +1,101 @@
/**
* Global Notification Manager - Singleton Pattern
* Provides a simple notification system accessible from anywhere
* Always displays the last message with auto-dismissal after timeout
*/
class NotificationManager {
constructor() {
this.currentNotification = null;
this.dismissTimeout = null;
this.notificationElement = null;
this.defaultDuration = 5000; // 5 seconds
}
/**
* Set reference to notification element (called by notification-area component)
*/
setNotificationElement(element) {
this.notificationElement = element;
}
/**
* Show success message
*/
success(message, duration = this.defaultDuration) {
this.show(message, 'success', duration);
}
/**
* Show info message
*/
info(message, duration = this.defaultDuration) {
this.show(message, 'info', duration);
}
/**
* Show error message (longer duration)
*/
error(message, duration = this.defaultDuration + 2000) {
this.show(message, 'error', duration);
}
/**
* Show generic message
* @param {string} message - The message to display
* @param {string} type - 'success', 'info', or 'error'
* @param {number} duration - Auto-dismiss after this many ms (0 = manual dismiss)
*/
show(message, type = 'info', duration = this.defaultDuration) {
// Clear existing timeout
if (this.dismissTimeout) {
clearTimeout(this.dismissTimeout);
}
// Store current notification
this.currentNotification = {
message,
type,
timestamp: Date.now()
};
// Update UI if element exists
if (this.notificationElement) {
this.notificationElement.setNotification(message, type);
}
console.log(`[${type.toUpperCase()}] ${message}`);
// Auto-dismiss after duration (0 = no auto-dismiss)
if (duration > 0) {
this.dismissTimeout = setTimeout(() => this.dismiss(), duration);
}
}
/**
* Dismiss current notification
*/
dismiss() {
if (this.dismissTimeout) {
clearTimeout(this.dismissTimeout);
this.dismissTimeout = null;
}
this.currentNotification = null;
if (this.notificationElement) {
this.notificationElement.clearNotification();
}
}
/**
* Get current notification info
*/
getCurrent() {
return this.currentNotification;
}
}
// Create global singleton instance
const notificationManager = new NotificationManager();
export default notificationManager;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
const cacheName = 'pwa-conf-v4';
const staticAssets = [
'./',
'./index.html',
'./app.js',
'./main.js',
'./websocket.js',
'./grid.js',
'./clue_area.js',
'./keyboard.js',
'./menu.js',
'./notification-area.js',
'./notification-manager.js',
'./styles.css',
'./manifest.json',
'./favicon.png',
'./big_icon.png'
];
self.addEventListener('install', async event => {
const cache = await caches.open(cacheName);
await cache.addAll(staticAssets);
});
self.addEventListener('fetch', event => {
const req = event.request;
event.respondWith(networkFirst(req));
});
async function cacheFirst(req) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(req);
return cachedResponse || fetch(req);
}
async function networkFirst(req) {
return fetch(req).catch(function() {
return caches.match(req);
})
}

View File

@ -0,0 +1,192 @@
/**
* Global WebSocket Manager - Singleton Pattern
* Provides a single WebSocket connection accessible from anywhere
*/
class WebSocketManager {
constructor() {
this.socket = null;
this.url = null;
this.messageHandlers = new Map(); // Map<messageType, Set<handlers>>
this.isReconnecting = false;
this.reconnectDelay = 3000;
this.notificationManager = null; // Will be set when available
}
/**
* Set notification manager for displaying connection status
*/
setNotificationManager(notificationMgr) {
this.notificationManager = notificationMgr;
}
/**
* Initialize connection with URL
*/
connect(url) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
console.warn('WebSocket already connected');
return;
}
this.url = url;
console.log(`Connecting to WebSocket at ${this.url}...`);
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => this._onOpen(event);
this.socket.onclose = (event) => this._onClose(event);
this.socket.onerror = (event) => this._onError(event);
this.socket.onmessage = (event) => this._onMessage(event);
}
/**
* Send message as JSON
*/
send(message) {
if (!this.isConnected()) {
console.error('WebSocket not connected, cannot send message:', message);
return false;
}
try {
const jsonMsg = JSON.stringify(message);
this.socket.send(jsonMsg);
return true;
} catch (error) {
console.error('Error sending message:', error);
return false;
}
}
/**
* Check if socket is connected
*/
isConnected() {
return this.socket && this.socket.readyState === WebSocket.OPEN;
}
/**
* Register handler for specific message type
* @param {string} messageType - e.g., 'available_session_properties'
* @param {function} handler - callback function
*/
onMessage(messageType, handler) {
if (!this.messageHandlers.has(messageType)) {
this.messageHandlers.set(messageType, new Set());
}
this.messageHandlers.get(messageType).add(handler);
}
/**
* Unregister handler for specific message type
*/
offMessage(messageType, handler) {
if (this.messageHandlers.has(messageType)) {
this.messageHandlers.get(messageType).delete(handler);
}
}
/**
* Register global message handler (for all message types)
*/
onAnyMessage(handler) {
if (!this.messageHandlers.has('*')) {
this.messageHandlers.set('*', new Set());
}
this.messageHandlers.get('*').add(handler);
}
/**
* Internal handler - called on socket open
*/
_onOpen(event) {
console.log('WebSocket connected');
this.isReconnecting = false;
if (this.notificationManager) {
this.notificationManager.success('Connected to server', 3000);
}
this._callHandlers('open', { type: 'open' });
}
/**
* Internal handler - called on socket close
*/
_onClose(event) {
console.log('WebSocket closed - reloading page');
if (this.notificationManager) {
this.notificationManager.info('Connection lost, reloading...', 2000);
}
this._callHandlers('close', { type: 'close' });
// Simply reload the page instead of trying to reconnect
setTimeout(() => {
window.location.reload();
}, 2000);
}
/**
* Internal handler - called on socket error
*/
_onError(event) {
console.error('WebSocket error:', event);
if (this.notificationManager) {
this.notificationManager.error('Connection error', 4000);
}
this._callHandlers('error', { type: 'error', error: event });
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
}
/**
* Internal handler - called on incoming message
*/
_onMessage(event) {
try {
const message = JSON.parse(event.data);
console.log('Received message:', message);
// Call type-specific handlers
if (message.type && this.messageHandlers.has(message.type)) {
this._callHandlers(message.type, message);
}
// Call global handlers
this._callHandlers('*', message);
} catch (error) {
console.error('Error parsing message:', error);
this._callHandlers('error', { type: 'error', error: error });
}
}
/**
* Internal - call all registered handlers for a message type
*/
_callHandlers(messageType, message) {
if (this.messageHandlers.has(messageType)) {
this.messageHandlers.get(messageType).forEach(handler => {
try {
handler(message);
} catch (error) {
console.error(`Error in message handler for ${messageType}:`, error);
}
});
}
}
/**
* Close connection
*/
close() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.isReconnecting = false;
}
}
// Create global singleton instance
const wsManager = new WebSocketManager();
export default wsManager;

View File

@ -1,6 +1,6 @@
[project] [project]
name = "multiplayer-crosswords" name = "multiplayer-crosswords"
version = "0.1.0" version = "0.1.1"
description = "" description = ""
authors = [ authors = [
{name="Jonas Weinz"} {name="Jonas Weinz"}
@ -12,10 +12,12 @@ dependencies = [
"pandas (>=2.2.3,<3.0.0)", "pandas (>=2.2.3,<3.0.0)",
"numba (>=0.61.2,<1.0.0)", "numba (>=0.61.2,<1.0.0)",
"bitarray (>=3.4.2,<4.0.0)", "bitarray (>=3.4.2,<4.0.0)",
"websockets (>=15.0.1, <16.0.0)",
"pydantic (>=2.12.4,<3.0.0)"
] ]
[tool.poetry] [tool.poetry]
name = "multiplayer-crosswords" name = "multiplayer-crosswords"
version = "0.1.0" version = "0.1.1"
description = "" description = ""
authors = [ authors = [
"Jonas Weinz" "Jonas Weinz"
@ -29,6 +31,17 @@ pytest = "^7.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
jupyterlab = "^4.4.3" jupyterlab = "^4.4.3"
[[tool.poetry.packages]]
include = "multiplayer_crosswords"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "sdist"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "wheel"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"