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 0000000..f479724 Binary files /dev/null and b/multiplayer_crosswords/webui/big_icon.png differ 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 0000000..4af4d5f Binary files /dev/null and b/multiplayer_crosswords/webui/favicon.png differ 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"