From e60491984b4ea891d15b46523e2923fb81b140ad Mon Sep 17 00:00:00 2001 From: Jonas Weinz Date: Thu, 13 Nov 2025 12:25:07 +0100 Subject: [PATCH] syncing --- multiplayer_crosswords/crossword.py | 73 ++- .../server/client_messages.py | 42 ++ multiplayer_crosswords/server/main.py | 40 ++ .../server/server_config.py | 50 ++ .../server/server_messages.py | 41 ++ multiplayer_crosswords/server/server_utils.py | 43 ++ .../server/websocket_connection_handler.py | 88 ++++ .../server/websocket_crossword_server.py | 292 ++++++++++++ multiplayer_crosswords/utils.py | 6 + multiplayer_crosswords/webui/app.js | 15 + multiplayer_crosswords/webui/big_icon.png | Bin 0 -> 9713 bytes .../clue_area.js} | 0 multiplayer_crosswords/webui/favicon.png | Bin 0 -> 3367 bytes multiplayer_crosswords/webui/grid.js | 358 +++++++++++++++ multiplayer_crosswords/webui/index.html | 56 +++ multiplayer_crosswords/webui/keyboard.js | 126 +++++ multiplayer_crosswords/webui/main.js | 1 + multiplayer_crosswords/webui/manifest.json | 17 + multiplayer_crosswords/webui/menu.js | 0 multiplayer_crosswords/webui/notifications.js | 0 multiplayer_crosswords/webui/styles.css | 431 ++++++++++++++++++ multiplayer_crosswords/webui/sw.js | 39 ++ multiplayer_crosswords/webui/websocket.js | 0 pyproject.toml | 2 + 24 files changed, 1708 insertions(+), 12 deletions(-) create mode 100644 multiplayer_crosswords/server/client_messages.py create mode 100644 multiplayer_crosswords/server/main.py create mode 100644 multiplayer_crosswords/server/server_config.py create mode 100644 multiplayer_crosswords/server/server_messages.py create mode 100644 multiplayer_crosswords/server/server_utils.py create mode 100644 multiplayer_crosswords/server/websocket_connection_handler.py create mode 100644 multiplayer_crosswords/server/websocket_crossword_server.py create mode 100644 multiplayer_crosswords/webui/app.js create mode 100644 multiplayer_crosswords/webui/big_icon.png rename multiplayer_crosswords/{websocket_server.py => webui/clue_area.js} (100%) create mode 100644 multiplayer_crosswords/webui/favicon.png create mode 100644 multiplayer_crosswords/webui/grid.js create mode 100644 multiplayer_crosswords/webui/index.html create mode 100644 multiplayer_crosswords/webui/keyboard.js create mode 100644 multiplayer_crosswords/webui/main.js create mode 100644 multiplayer_crosswords/webui/manifest.json create mode 100644 multiplayer_crosswords/webui/menu.js create mode 100644 multiplayer_crosswords/webui/notifications.js create mode 100644 multiplayer_crosswords/webui/styles.css create mode 100644 multiplayer_crosswords/webui/sw.js create mode 100644 multiplayer_crosswords/webui/websocket.js diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py index 19691ab..f0a4a2d 100644 --- a/multiplayer_crosswords/crossword.py +++ b/multiplayer_crosswords/crossword.py @@ -1,8 +1,11 @@ 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.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots import random +import logging + +logger = logging.getLogger(__name__) from multiplayer_crosswords.utils import load_de_dictionary @@ -14,7 +17,7 @@ class CrosswordWord: start_x: int, start_y: int, orientation: Orientation, - hist: str, + hint: str, index: Optional[int], solved: bool = False, @@ -23,7 +26,7 @@ class CrosswordWord: self.start_x = start_x self.start_y = start_y self.orientation = orientation - self.hist = hist + self.hint = hint self.length = len(word) self.index: Optional[int] = index self.solved = solved @@ -42,6 +45,10 @@ class Crossword: @property def words(self) -> List[CrosswordWord]: return self._words + + @property + def current_grid(self) -> List[List[Optional[str]]]: + return self._current_grid def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]: @@ -67,7 +74,7 @@ class Crossword: start_x=wdata.get("start_x"), start_y=wdata.get("start_y"), orientation=Orientation[wdata.get("orientation")], - hist=wdata.get("hint", ""), + hint=wdata.get("hint", ""), index=wdata.get("index", None), solved=wdata.get("solved", False) ) @@ -111,12 +118,19 @@ class Crossword: ) 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 Crossword( + logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio) + + cw = Crossword( dictionary=dictionary, grid=final_step.grid, ) + + logger.debug("Generated Crossword: \n\n%s", cw) + + return cw def __init__( @@ -164,7 +178,41 @@ class Crossword: for i in range(cw.length): 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): """Extract all fully placed words from the grid and store them in self._words. @@ -172,7 +220,7 @@ class Crossword: 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 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 = [] @@ -221,7 +269,7 @@ class Crossword: start_x=slot.col, start_y=slot.row, orientation=slot.orientation, - hist=hint_str, + hint=hint_str, index=word_index, ) word_index += 1 @@ -240,7 +288,7 @@ class Crossword: "start_x": w.start_x, "start_y": w.start_y, "orientation": w.orientation.name, - "hint": w.hist, + "hint": w.hint, "length": w.length, "solved": w.solved, "index": w.index, @@ -259,6 +307,7 @@ class Crossword: Returns: bool: True if the letter was placed successfully, False otherwise. """ + letter = letter.lower() if y < 0 or y >= len(self._current_grid): return False if x < 0 or x >= len(self._current_grid[0]): @@ -323,15 +372,15 @@ if __name__ == "__main__": crossword = Crossword.generate( dictionary=dictionary, seed=None, - grid_width=20, - grid_height=20, + grid_width=25, + grid_height=25, grid_block_ratio=0.38 ) crossword.extract_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) \ No newline at end of file diff --git a/multiplayer_crosswords/server/client_messages.py b/multiplayer_crosswords/server/client_messages.py new file mode 100644 index 0000000..9a0df13 --- /dev/null +++ b/multiplayer_crosswords/server/client_messages.py @@ -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 \ No newline at end of file diff --git a/multiplayer_crosswords/server/main.py b/multiplayer_crosswords/server/main.py new file mode 100644 index 0000000..51d3f01 --- /dev/null +++ b/multiplayer_crosswords/server/main.py @@ -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() diff --git a/multiplayer_crosswords/server/server_config.py b/multiplayer_crosswords/server/server_config.py new file mode 100644 index 0000000..f0b5daa --- /dev/null +++ b/multiplayer_crosswords/server/server_config.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel + +DEFAULT_WEBSOCKET_HOST = "0.0.0.0" +DEFAULT_WEBSOCKET_PORT = 8765 + +DEFAULT_MIN_GRID_SIZE = 10 +DEFAULT_MAX_GRID_SIZE = 30 + +DEFAULT_GRID_BLOCK_RATIO = 0.38 + +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 + + diff --git a/multiplayer_crosswords/server/server_messages.py b/multiplayer_crosswords/server/server_messages.py new file mode 100644 index 0000000..62923df --- /dev/null +++ b/multiplayer_crosswords/server/server_messages.py @@ -0,0 +1,41 @@ +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 + +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 diff --git a/multiplayer_crosswords/server/server_utils.py b/multiplayer_crosswords/server/server_utils.py new file mode 100644 index 0000000..131c112 --- /dev/null +++ b/multiplayer_crosswords/server/server_utils.py @@ -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 (max_size // 2, max_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}") diff --git a/multiplayer_crosswords/server/websocket_connection_handler.py b/multiplayer_crosswords/server/websocket_connection_handler.py new file mode 100644 index 0000000..5c97288 --- /dev/null +++ b/multiplayer_crosswords/server/websocket_connection_handler.py @@ -0,0 +1,88 @@ +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 + + 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 diff --git a/multiplayer_crosswords/server/websocket_crossword_server.py b/multiplayer_crosswords/server/websocket_crossword_server.py new file mode 100644 index 0000000..992fce3 --- /dev/null +++ b/multiplayer_crosswords/server/websocket_crossword_server.py @@ -0,0 +1,292 @@ +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.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.websocket.closed} + + async def send_message_to_all_clients(self, message: dict): + for client in self._clients.copy(): + if client.websocket.closed: + self._clients.discard(client) + continue + try: + await client.send(message=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 create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession: + async with self._sessions_lock: + if isinstance(lang, str): + lang = Languages(lang) + dictionary = lang.load_dictionary() + 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 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=[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 + clues_across, clues_down = session.crossword.get_clues() + clue_positions_across, clue_positions_down = session.crossword.get_clue_positions() + + 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, + ) + 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 + crossword.place_letter( + x=message.col, + y=message.row, + letter=msg_letter.lower(), + ) + # TODO: Validate letter against solution? + broadcast_message = server_messages.LetterUpdateBroadcastServerMessage( + session_id=session.session_id, + row=message.row, + col=message.col, + letter=msg_letter.upper(), + ) + + # 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 + 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()) \ No newline at end of file diff --git a/multiplayer_crosswords/utils.py b/multiplayer_crosswords/utils.py index 6087bf4..c0bea7f 100644 --- a/multiplayer_crosswords/utils.py +++ b/multiplayer_crosswords/utils.py @@ -3,6 +3,11 @@ import json from pathlib import Path 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) if not p.exists(): raise FileNotFoundError(f"Dictionary file not found: {p}") @@ -17,6 +22,7 @@ def load_dictionary(p: str | Path) -> Dictionary: continue word = word.lower() dict_obj.add_word(Word(word=word, hints=[], difficulty=1)) + load_dictionary._cache[cache_key] = dict_obj return dict_obj def load_en_dictionary() -> Dictionary: diff --git a/multiplayer_crosswords/webui/app.js b/multiplayer_crosswords/webui/app.js new file mode 100644 index 0000000..1128b1b --- /dev/null +++ b/multiplayer_crosswords/webui/app.js @@ -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'); + } +} \ No newline at end of file diff --git a/multiplayer_crosswords/webui/big_icon.png b/multiplayer_crosswords/webui/big_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f479724e684c93224dd658b8949e4010df326493 GIT binary patch literal 9713 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Ü+10uyI(sxv9Xy&L${JLD=r6_GJoR;T zc?Wpyo>MsfwzV7`CW`5KlUrnR*9j~mE=$e+yxugDb0xL~b^^r1c*$};RwKNShFJwq OAvGoRy-Ed3%zpvDdihTP literal 0 HcmV?d00001 diff --git a/multiplayer_crosswords/websocket_server.py b/multiplayer_crosswords/webui/clue_area.js similarity index 100% rename from multiplayer_crosswords/websocket_server.py rename to multiplayer_crosswords/webui/clue_area.js diff --git a/multiplayer_crosswords/webui/favicon.png b/multiplayer_crosswords/webui/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4af4d5fdc00b033d51a26a0dfae7d8e621dcf1d4 GIT binary patch literal 3367 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 F{wETpDDnUR literal 0 HcmV?d00001 diff --git a/multiplayer_crosswords/webui/grid.js b/multiplayer_crosswords/webui/grid.js new file mode 100644 index 0000000..3e3cc89 --- /dev/null +++ b/multiplayer_crosswords/webui/grid.js @@ -0,0 +1,358 @@ +import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module'; + +/** + * + * 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' + }; + + // 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 + } + + 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); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('key-press', this._keyHandler); + this.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('resize', this._resizeHandler); + } + + _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` +
+ ${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()} +
+ `; + } + + + _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']; + if (value === '#') classes.push('wall'); + 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'); + } + } + + return html`
this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${value}
`; + } + + /** + * 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 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) { + // if same cell is clicked again, toggle the input mode + if (this._selected.r === r && this._selected.c === c) { + this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal'; + } else { + // select a new cell + this._selected = { r, c }; + + // 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 }, bubbles: true, composed: true })); + // focus the element so keyboard input goes to the grid + this.focus(); + } + + _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; + // ignore walls + if (this._grid[r][c] === '#') return; + + // delete the letter at current cell + this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? '' : cell))); + + // 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; + // ignore walls + if (this._grid[r][c] === '#') return; + this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? letter : cell))); + + // move to next cell based on input mode + if (letter !== '') { + // only navigate if a letter was placed (not on backspace) + 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(); + return; + } + + if (this._inputMode === 'vertical' && isHorizontalArrow) { + // User pressed left/right arrow but mode is vertical, switch to horizontal + this._inputMode = 'horizontal'; + this.requestUpdate(); + 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); + this._selected = { r: nr, c: nc }; + this.requestUpdate(); + } + + // 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(); + } +} + +customElements.define('crossword-grid', CrosswordGrid); \ No newline at end of file diff --git a/multiplayer_crosswords/webui/index.html b/multiplayer_crosswords/webui/index.html new file mode 100644 index 0000000..35f3dd4 --- /dev/null +++ b/multiplayer_crosswords/webui/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+

Crossword Grid (Demo)

+ +
+
+ + + + + + \ No newline at end of file diff --git a/multiplayer_crosswords/webui/keyboard.js b/multiplayer_crosswords/webui/keyboard.js new file mode 100644 index 0000000..a8d69d0 --- /dev/null +++ b/multiplayer_crosswords/webui/keyboard.js @@ -0,0 +1,126 @@ +import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module'; + +/** + * + * 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 first row) + const counts = rows.map((r, idx) => r.length + (idx === 0 ? 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` +
+ ${html`
${this.collapsed ? '▲' : '▼'}
`} +
+
+ ${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`
+
+ ${r.map(l => html``) } + ${idx === 0 ? html`` : ''} +
+
+ ${Array.from({ length: arrowCols }).map((_, i) => { + if (idx === 2 && i === 1) return html``; + return html`
`; + })} +
+
`; + })} + + +
+ + + + + + +
+
+
+
+ `; + } + + _emitLetter(l) { + this._emit({ type: 'letter', value: l }); + } + + _emitNavigate(dir) { + this._emit({ type: 'navigate', value: dir }); + } + + _emit(detail) { + window.dispatchEvent(new CustomEvent('key-press', { detail })); + } + + 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 + if (mobile) this.collapsed = false; + else this.collapsed = true; + } + + _toggleCollapse() { + this.collapsed = !this.collapsed; + if (this.collapsed) this.setAttribute('collapsed', ''); + else this.removeAttribute('collapsed'); + } +} + +customElements.define('mobile-keyboard', MobileKeyboard); \ No newline at end of file diff --git a/multiplayer_crosswords/webui/main.js b/multiplayer_crosswords/webui/main.js new file mode 100644 index 0000000..0ffdd02 --- /dev/null +++ b/multiplayer_crosswords/webui/main.js @@ -0,0 +1 @@ +// TODO \ No newline at end of file diff --git a/multiplayer_crosswords/webui/manifest.json b/multiplayer_crosswords/webui/manifest.json new file mode 100644 index 0000000..e71d0e0 --- /dev/null +++ b/multiplayer_crosswords/webui/manifest.json @@ -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" + } + ] + } \ No newline at end of file diff --git a/multiplayer_crosswords/webui/menu.js b/multiplayer_crosswords/webui/menu.js new file mode 100644 index 0000000..e69de29 diff --git a/multiplayer_crosswords/webui/notifications.js b/multiplayer_crosswords/webui/notifications.js new file mode 100644 index 0000000..e69de29 diff --git a/multiplayer_crosswords/webui/styles.css b/multiplayer_crosswords/webui/styles.css new file mode 100644 index 0000000..1ec4503 --- /dev/null +++ b/multiplayer_crosswords/webui/styles.css @@ -0,0 +1,431 @@ +:root { + --keyboard-space: 30vh; /* reserve ~30% of viewport height for keyboard on small screens */ + --page-padding: clamp(0.75rem, 3.5vw, 2rem); + --max-container-width: 720px; + + /* Crossword color palette - lighter, more paper-like */ + --paper-bg: #f9f7f3; + --paper-light: #fcfaf8; + --paper-dark: #e6e2db; + --ink-dark: #1a1815; + --ink-light: #3a3530; + --wall-dark: #2a2520; + --wall-light: #1a1815; + --highlight-current: #fffbee; + --highlight-mode: #ecf8fb; + --accent-wall: #d4696b; +} + +html, body { -webkit-text-size-adjust: 100%; } +body { + font-family: 'Segoe UI', 'Helvetica Neue', system-ui, Roboto, Arial; + margin: 0; + background: #0a0805; + background-image: + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px); + color: var(--ink-dark); + -webkit-font-smoothing: antialiased; + font-size: 100%; + min-height: 100vh; +} + +main { + display: flex; + justify-content: center; + align-items: flex-start; + padding: var(--page-padding); + padding-bottom: var(--keyboard-space); + min-height: calc(100vh - 0px); + box-sizing: border-box; +} +.container { width: 100%; max-width: var(--max-container-width); } +crossword-grid { display: block; margin: 0 auto; } +mobile-keyboard { display: block; } +h2, h3 { margin: 0.5rem 0; color: #f5f1ed; text-align: center; text-shadow: 0 2px 4px rgba(0,0,0,0.7); font-weight: 600; } + +crossword-grid { + display: block; + max-width: 100%; + margin: 0 auto; + + border-radius: 0px; + background: var(--ink-dark); + position: relative; +} + +.grid { + display: grid; + gap: 0; + background: var(--ink-dark); + background-image: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.025) 1px, rgba(0,0,0,.025) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.025) 1px, rgba(0,0,0,.025) 2px); + padding: 0.8rem; + grid-template-columns: repeat(var(--cols), var(--cell-size)); + border: 2px solid var(--ink-dark); + +} + +.cell { + width: var(--cell-size, 4rem); + height: var(--cell-size, 4rem); + display: flex; + align-items: center; + justify-content: center; + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + radial-gradient(ellipse 600px 500px at 70% 60%, rgba(0,0,0,.02) 0%, transparent 50%), + linear-gradient(135deg, #fefdfb 0%, #f9f7f3 100%); + color: var(--ink-dark); + font-weight: 700; + font-size: 1.05rem; + user-select: none; + touch-action: manipulation; + border: 1px solid #c0bbb5; + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.9), + inset 0 -0.5px 1px rgba(0,0,0,0.03), + 0 0.5px 1px rgba(0,0,0,0.05), + inset -1px -1px 2px rgba(0,0,0,0.015), + inset 1px 1px 2px rgba(255,255,255,0.5); + border-radius: 0; + box-sizing: border-box; + position: relative; + transition: background-color 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.cell:active { + box-shadow: inset 0 1px 2px rgba(0,0,0,0.1), inset 0 0 4px rgba(0,0,0,0.03); +} + +.cell.wall { + background: + repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.05) 2px, rgba(0,0,0,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.05) 2px, rgba(0,0,0,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(0,0,0,.15) 0%, transparent 50%), + linear-gradient(135deg, #3a3530 0%, #2a2520 100%); + color: transparent; + border-color: var(--wall-dark); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.1), + 0 0.5px 1px rgba(0,0,0,0.2), + inset -1px -1px 2px rgba(0,0,0,0.3), + inset 1px 1px 2px rgba(255,255,255,0.05); +} + +.cell.wall.selected { + border-color: var(--accent-wall); + box-shadow: inset 0 1px 2px rgba(255,255,255,0.1), inset 0 0 0 1px var(--accent-wall), 0 0 6px rgba(212,105,107,0.3); + background: + repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + linear-gradient(135deg, #3a3530 0%, #2a2520 100%); +} + +.cell.wall.mode-highlighted { + background: + repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px), + linear-gradient(135deg, #3a3530 0%, #2a2520 100%); +} + +.cell.mode-highlighted { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(100,180,200,.2) 1px, rgba(100,180,200,.2) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(100,180,200,.2) 1px, rgba(100,180,200,.2) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(100,180,200,.1) 1px, rgba(100,180,200,.1) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(100,180,200,.1) 1px, rgba(100,180,200,.1) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(100,180,200,.05) 2px, rgba(100,180,200,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(100,180,200,.05) 2px, rgba(100,180,200,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.3) 0%, transparent 40%), + linear-gradient(135deg, #f0fafb 0%, #e8f4f8 100%); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.9), + inset 0 0 0 1px #c8e6f0, + 0 0.5px 1px rgba(0,0,0,0.05), + inset -1px -1px 2px rgba(100,180,200,0.08), + inset 1px 1px 2px rgba(255,255,255,0.4); + border-color: #a8d4e8; +} + +.cell.selected { + outline: none; + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(135deg, #fffef9 0%, #fff8e6 100%); + border-color: var(--ink-dark); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.9), + inset 0 0 0 1.5px #ffc107, + 0 0 8px rgba(255,193,7,0.25), + inset -1px -1px 2px rgba(255,200,0,0.1), + inset 1px 1px 2px rgba(255,255,255,0.5); +} + +.cell.selected.mode-highlighted { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(200,150,0,.2) 1px, rgba(200,150,0,.2) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(200,150,0,.2) 1px, rgba(200,150,0,.2) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(200,150,0,.1) 1px, rgba(200,150,0,.1) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(200,150,0,.1) 1px, rgba(200,150,0,.1) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(200,150,0,.05) 2px, rgba(200,150,0,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(200,150,0,.05) 2px, rgba(200,150,0,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(135deg, #fffaf0 0%, #fff5d6 100%); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.9), + inset 0 0 0 1.5px #ffc107, + 0 0 8px rgba(255,193,7,0.25), + inset -1px -1px 2px rgba(200,150,0,0.1), + inset 1px 1px 2px rgba(255,255,255,0.5); +} + +@media (max-width: 900px) { + .cell { font-size: 1.05rem; } +} + +/* ========= mobile-keyboard (light DOM) ========= */ +mobile-keyboard { + display: block; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 2000; + font-size: clamp(0.95rem, 2.4vw, 1.15rem); + --key-width: clamp(1.7rem, 5.5vw, 2.1rem); + --key-height: calc(var(--key-width) * 1.2); + --stagger-factor: 0.4; + --stagger-factor-deep: 0.8; + --up-arrow-offset: calc(0 - var(--key-width) * var(--stagger-factor-deep)); +} + +mobile-keyboard .keyboard-container { + position: relative; + transition: transform 0.3s ease, opacity 0.2s ease; +} + +mobile-keyboard .keyboard { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.75em; + background: #0a0805; + background-image: + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px); + border-top: 2px solid #3a3530; + box-shadow: 0 -0.6rem 1.4rem rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05); + margin: 0 auto; + max-width: calc(var(--key-width) * 10 + 5.25rem); + align-items: left; +} + +mobile-keyboard .row { + display: flex; + gap: 0.35rem; + align-items: left; +} + +mobile-keyboard .row .keys { transform: none; } +mobile-keyboard .row.stagger .keys { + transform: translateX(calc(var(--key-width) * var(--stagger-factor))); +} +mobile-keyboard .row.stagger-deep .keys { + transform: translateX(calc(var(--key-width) * var(--stagger-factor-deep))); +} + +mobile-keyboard .row .keys { + display: flex; + gap: 0.35rem; + justify-content: center; + align-items: center; + flex: 1 1 auto; +} + +mobile-keyboard .row .arrows { + display: grid; + grid-auto-flow: column; + grid-auto-columns: var(--key-width); + gap: 0.35rem; + align-items: left; +} + +mobile-keyboard button { + width: var(--key-width); + height: var(--key-height); + padding: 0; + border-radius: 0.5rem; + border: none; + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%); + font-family: inherit; + font-weight: 600; + font-size: clamp(1rem, 2.4vw, 1.25rem); + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + color: var(--ink-dark); + box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.8), 0 1px 3px rgba(0,0,0,0.2); + cursor: pointer; + transition: all 0.1s ease; + user-select: none; + transform-origin: center; +} + +mobile-keyboard button:active { + box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.2); + transform: translateY(2px) rotateZ(-1deg); +} + +mobile-keyboard button:hover { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.45) 0%, transparent 40%), + linear-gradient(180deg, #fffbf5 0%, #f8f4ef 100%); + transform: scale(1.05); +} + +mobile-keyboard button.backspace { + width: calc(var(--key-width) * 0.8); +} + +mobile-keyboard button[aria-pressed="true"] { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(180deg, #fffef5 0%, #fffbf0 100%); + box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 0 0 2px #ffc107, inset 0 1px 0 rgba(255,255,255,0.8); +} + +mobile-keyboard .nav { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + linear-gradient(180deg, #5a8ca4 0%, #4a7c94 100%); + color: #f0f8ff; + width: var(--key-width); + height: var(--key-height); + box-shadow: 0 3px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3); + border: none; + border-radius: 0.5rem; +} + +mobile-keyboard .nav:hover { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + linear-gradient(180deg, #6a9cb4 0%, #5a8ca4 100%); +} + +mobile-keyboard .space { + width: calc(var(--key-width) * 8.4); + min-width: calc(var(--key-width) * 8.4); + height: var(--key-height); + padding: 0; + border-radius: 0.5rem; + border: none; + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%); + font-family: inherit; + font-weight: 600; + font-size: clamp(1rem, 2.4vw, 1.25rem); + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + color: var(--ink-dark); + box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.8), 0 1px 3px rgba(0,0,0,0.2); +} + +mobile-keyboard .key-spacer { + width: var(--key-width); + height: var(--key-height); +} + +mobile-keyboard .handle { + position: absolute; + top: -2.4rem; + left: 50%; + transform: translateX(-50%); + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.15) 1px, rgba(0,0,0,.15) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.15) 1px, rgba(0,0,0,.15) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.08) 1px, rgba(0,0,0,.08) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.08) 1px, rgba(0,0,0,.08) 2px), + #0a0805; + color: #a89f99; + padding: 0.55rem 0.9rem; + border-radius: 0.25rem 0.25rem 0 0; + border: 1px solid #2a2520; + cursor: pointer; + box-shadow: 0 -2px 8px rgba(0,0,0,0.5); + font-size: 0.9rem; + font-weight: 600; +} + +mobile-keyboard[collapsed] .keyboard-container { transform: translateY(calc(100% - 2.6rem)); } +mobile-keyboard:not([collapsed]) .keyboard-container { transform: translateY(0); } + +@media (max-width: 980px) and (orientation: portrait) { + mobile-keyboard .keyboard { padding-bottom: 1.6rem; } + mobile-keyboard { + width: 100%; + --key-width: 8vw; + } +} + +mobile-keyboard.wide-screen .keyboard-wrapper { display: flex; align-items: flex-end; justify-content: center; gap: 0.75rem; } +mobile-keyboard.wide-screen .keyboard-controls { display: none; } diff --git a/multiplayer_crosswords/webui/sw.js b/multiplayer_crosswords/webui/sw.js new file mode 100644 index 0000000..46f10f3 --- /dev/null +++ b/multiplayer_crosswords/webui/sw.js @@ -0,0 +1,39 @@ +const cacheName = 'pwa-conf-v4'; +const staticAssets = [ + './app.js ', + './websocket.js' //TODO: add all necessary files + +]; + + +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); + }) +} \ No newline at end of file diff --git a/multiplayer_crosswords/webui/websocket.js b/multiplayer_crosswords/webui/websocket.js new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 4c30703..1fbad06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "pandas (>=2.2.3,<3.0.0)", "numba (>=0.61.2,<1.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] name = "multiplayer-crosswords"