This commit is contained in:
2025-11-13 12:25:07 +01:00
parent efc0781434
commit e60491984b
24 changed files with 1708 additions and 12 deletions

View File

@ -1,8 +1,11 @@
from enum import Enum from enum import Enum
from typing import List, Tuple, Optional from typing import Dict, List, Tuple, Optional
from multiplayer_crosswords.dictionary import Dictionary, Word from multiplayer_crosswords.dictionary import Dictionary, Word
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
import random import random
import logging
logger = logging.getLogger(__name__)
from multiplayer_crosswords.utils import load_de_dictionary from multiplayer_crosswords.utils import load_de_dictionary
@ -14,7 +17,7 @@ class CrosswordWord:
start_x: int, start_x: int,
start_y: int, start_y: int,
orientation: Orientation, orientation: Orientation,
hist: str, hint: str,
index: Optional[int], index: Optional[int],
solved: bool = False, solved: bool = False,
@ -23,7 +26,7 @@ class CrosswordWord:
self.start_x = start_x self.start_x = start_x
self.start_y = start_y self.start_y = start_y
self.orientation = orientation self.orientation = orientation
self.hist = hist self.hint = hint
self.length = len(word) self.length = len(word)
self.index: Optional[int] = index self.index: Optional[int] = index
self.solved = solved self.solved = solved
@ -43,6 +46,10 @@ class Crossword:
def words(self) -> List[CrosswordWord]: def words(self) -> List[CrosswordWord]:
return self._words return self._words
@property
def current_grid(self) -> List[List[Optional[str]]]:
return self._current_grid
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]: def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
"""Get the list of CrosswordWord objects that start at position (x, y).""" """Get the list of CrosswordWord objects that start at position (x, y)."""
@ -67,7 +74,7 @@ class Crossword:
start_x=wdata.get("start_x"), start_x=wdata.get("start_x"),
start_y=wdata.get("start_y"), start_y=wdata.get("start_y"),
orientation=Orientation[wdata.get("orientation")], orientation=Orientation[wdata.get("orientation")],
hist=wdata.get("hint", ""), hint=wdata.get("hint", ""),
index=wdata.get("index", None), index=wdata.get("index", None),
solved=wdata.get("solved", False) solved=wdata.get("solved", False)
) )
@ -111,13 +118,20 @@ class Crossword:
) )
if final_step is None: if final_step is None:
logger.warning("Crossword generation failed for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
return None return None
return Crossword( logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
cw = Crossword(
dictionary=dictionary, dictionary=dictionary,
grid=final_step.grid, grid=final_step.grid,
) )
logger.debug("Generated Crossword: \n\n%s", cw)
return cw
def __init__( def __init__(
self, self,
@ -164,7 +178,41 @@ class Crossword:
for i in range(cw.length): for i in range(cw.length):
self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw
def get_clues(self) -> Tuple[Dict[int, str], Dict[int, str]]:
"""Get the clues for horizontal and vertical words.
Returns:
Tuple[Dict[int, str], Dict[int, str]]: Two dictionaries,
each containing (index, hint) for horizontal and vertical words respectively.
"""
clues_across = {}
clues_down = {}
for word in self._words:
if word.orientation == Orientation.HORIZONTAL:
clues_across[str(word.index)] = word.hint
else:
clues_down[str(word.index)] = word.hint
return clues_across, clues_down
def get_clue_positions(self) -> Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]:
"""Get the starting positions for horizontal and vertical clues.
Returns:
Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]: Two dictionaries,
each containing (index, (x, y)) for horizontal and vertical words respectively.
"""
positions_across = {}
positions_down = {}
for word in self._words:
if word.orientation == Orientation.HORIZONTAL:
positions_across[str(word.index)] = (word.start_x, word.start_y)
else:
positions_down[str(word.index)] = (word.start_x, word.start_y)
return positions_across, positions_down
def extract_words(self): def extract_words(self):
"""Extract all fully placed words from the grid and store them in self._words. """Extract all fully placed words from the grid and store them in self._words.
@ -172,7 +220,7 @@ class Crossword:
A "placed" word is a slot (horizontal or vertical, length >= 2) where every A "placed" word is a slot (horizontal or vertical, length >= 2) where every
cell contains a letter (not '' and not '#'). For each found word we try to cell contains a letter (not '' and not '#'). For each found word we try to
retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint) retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint)
and store it on the CrosswordWord.hist field (empty string if no hint found). and store it on the CrosswordWord.hint field (empty string if no hint found).
""" """
self._words = [] self._words = []
@ -221,7 +269,7 @@ class Crossword:
start_x=slot.col, start_x=slot.col,
start_y=slot.row, start_y=slot.row,
orientation=slot.orientation, orientation=slot.orientation,
hist=hint_str, hint=hint_str,
index=word_index, index=word_index,
) )
word_index += 1 word_index += 1
@ -240,7 +288,7 @@ class Crossword:
"start_x": w.start_x, "start_x": w.start_x,
"start_y": w.start_y, "start_y": w.start_y,
"orientation": w.orientation.name, "orientation": w.orientation.name,
"hint": w.hist, "hint": w.hint,
"length": w.length, "length": w.length,
"solved": w.solved, "solved": w.solved,
"index": w.index, "index": w.index,
@ -259,6 +307,7 @@ class Crossword:
Returns: Returns:
bool: True if the letter was placed successfully, False otherwise. bool: True if the letter was placed successfully, False otherwise.
""" """
letter = letter.lower()
if y < 0 or y >= len(self._current_grid): if y < 0 or y >= len(self._current_grid):
return False return False
if x < 0 or x >= len(self._current_grid[0]): if x < 0 or x >= len(self._current_grid[0]):
@ -323,15 +372,15 @@ if __name__ == "__main__":
crossword = Crossword.generate( crossword = Crossword.generate(
dictionary=dictionary, dictionary=dictionary,
seed=None, seed=None,
grid_width=20, grid_width=25,
grid_height=20, grid_height=25,
grid_block_ratio=0.38 grid_block_ratio=0.38
) )
crossword.extract_words() crossword.extract_words()
for word in crossword.words: for word in crossword.words:
print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hist}") print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hint}")
print(crossword) print(crossword)

View File

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

View File

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

View File

@ -0,0 +1,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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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())

View File

@ -3,6 +3,11 @@ import json
from pathlib import Path from pathlib import Path
def load_dictionary(p: str | Path) -> Dictionary: def load_dictionary(p: str | Path) -> Dictionary:
if not hasattr(load_dictionary, "_cache"):
load_dictionary._cache = {}
cache_key = str(p)
if cache_key in load_dictionary._cache:
return load_dictionary._cache[cache_key]
p = Path(p) p = Path(p)
if not p.exists(): if not p.exists():
raise FileNotFoundError(f"Dictionary file not found: {p}") raise FileNotFoundError(f"Dictionary file not found: {p}")
@ -17,6 +22,7 @@ def load_dictionary(p: str | Path) -> Dictionary:
continue continue
word = word.lower() word = word.lower()
dict_obj.add_word(Word(word=word, hints=[], difficulty=1)) dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
load_dictionary._cache[cache_key] = dict_obj
return dict_obj return dict_obj
def load_en_dictionary() -> Dictionary: def load_en_dictionary() -> Dictionary:

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,358 @@
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
/**
* <crossword-grid>
* Simple Lit-based grid component for testing the crossword UI.
* - Attributes: rows, cols (numbers)
* - Public behavior: listens for 'key-press' CustomEvents on window and
* places letters / navigates accordingly.
* - Dispatches 'cell-selected' when user taps a cell.
*/
export class CrosswordGrid extends LitElement {
static properties = {
rows: { type: Number },
cols: { type: Number },
_grid: { state: true },
_selected: { state: true },
_inputMode: { state: true }, // 'horizontal' or 'vertical'
};
// 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`
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
</div>
`;
}
_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`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${value}</div>`;
}
/**
* Get the length of the horizontal line at the selected cell
*/
_getHorizontalLineLength(r = this._selected.r, c = this._selected.c) {
let start = c, end = c;
// Expand left
while (start > 0 && this._grid[r][start - 1] !== '#') {
start--;
}
// Expand right
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
end++;
}
return end - start + 1;
}
/**
* Check if cell (r, c) is part of the horizontal line from the selected cell
* (i.e., same row and not blocked by walls before/after this cell)
*/
_isInHorizontalLine(r, c) {
const selectedRow = this._selected.r;
if (r !== selectedRow) return false;
const selectedCol = this._selected.c;
// Find the start and end of the continuous line in this row
let start = selectedCol, end = selectedCol;
// Expand left
while (start > 0 && this._grid[r][start - 1] !== '#') {
start--;
}
// Expand right
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
end++;
}
return c >= start && c <= end;
}
/**
* Get the length of the vertical line at the selected cell
*/
_getVerticalLineLength(r = this._selected.r, c = this._selected.c) {
let start = r, end = r;
// Expand up
while (start > 0 && this._grid[start - 1][c] !== '#') {
start--;
}
// Expand down
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
end++;
}
return end - start + 1;
}
/**
* Check if 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);

View File

@ -0,0 +1,56 @@
<!doctype html>
<html>
<head>
<link rel="icon" type="image/png" href="./favicon.png" />
<link rel="manifest" href="./manifest.json">
<script type="module" src="./app.js"></script>
<script type="module" src="./sw.js"></script>
<script type="module" src="./main.js"></script>
<script type="module" src="./websocket.js"></script>
<!-- Polyfills only needed for Firefox and Edge. -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@latest/webcomponents-loader.js"></script>
<!-- Works only on browsers that support Javascript modules like
Chrome, Safari, Firefox 60, Edge 17 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="./styles.css">
</head>
</head>
<body>
<main>
<body>
<main>
<div class="container">
<h2>Crossword Grid (Demo)</h2>
<crossword-grid id="grid" rows="10" cols="10"></crossword-grid>
</div>
</main>
<mobile-keyboard></mobile-keyboard>
<script type="module">
import './grid.js';
import './keyboard.js';
const grid = document.getElementById('grid');
// set some demo walls
grid.setWalls([[0,3],[1,3],[2,3],[3,3],[4,3],[4,4],[2,4]]);
// log cell-selected events
grid.addEventListener('cell-selected', (e) => {
console.log('cell-selected', e.detail);
});
// example: listen for key-press events globally (keyboard.js dispatches them)
window.addEventListener('key-press', (e) => {
console.debug('key-press', e.detail);
});
</script>
</body>
</html>

View File

@ -0,0 +1,126 @@
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
/**
* <mobile-keyboard>
* A compact on-screen keyboard for mobile devices. Emits 'key-press' events on window
* with detail { type: 'letter'|'navigate'|'backspace', value }
*/
export class MobileKeyboard extends LitElement {
static properties = {
// reflect so CSS attribute selectors can react to collapsed state
collapsed: { type: Boolean, reflect: true },
_isMobile: { type: Boolean, state: true },
_wideScreen: { type: Boolean, state: true },
};
// styles moved to webui/styles.css; render into light DOM so external CSS applies
constructor() {
super();
this.collapsed = true; // default collapsed; _onResize will flip for mobile
this._isMobile = false;
this._letters = 'abcdefghijklmnopqrstuvwxyz'.split('');
this._onResize = this._onResize.bind(this);
}
createRenderRoot() { return this; }
render() {
// simple QWERTY-like rows
const rows = [
'qwertyuiop'.split(''),
'asdfghjkl'.split(''),
'zxcvbnm'.split(''),
];
// compute the maximum number of columns across rows (account for backspace in 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`
<div class="keyboard-container">
${html`<div class="handle" @click=${this._toggleCollapse}>${this.collapsed ? '▲' : '▼'}</div>`}
<div class="keyboard-wrapper">
<div class="keyboard">
${rows.map((r, idx) => {
// center the letter keys leaving the rightmost `arrowCols` for the arrow block
let rowClasses = 'row';
if (idx === 1) rowClasses += ' stagger'; // A row
if (idx === 2) rowClasses += ' stagger-deep'; // Z row needs a larger indent
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
<div class="keys">
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
${idx === 0 ? html`<button class="backspace" @click=${() => this._emit({ type: 'backspace' })}>⌫</button>` : ''}
</div>
<div class="arrows">
${Array.from({ length: arrowCols }).map((_, i) => {
if (idx === 2 && i === 1) return html`<button class="nav" @click=${() => this._emitNavigate('up')}>▲</button>`;
return html`<div class="key-spacer"></div>`;
})}
</div>
</div>`;
})}
<!-- spacebar row -->
<div class="row" style="--cols:${maxCols};">
<!-- spacebar spans all but the right arrow columns -->
<button class="space" @click=${() => this._emit({ type: 'letter', value: '' })}></button>
<!-- arrow columns: left, down, right (will occupy the last 3 columns) -->
<button class="nav" @click=${() => this._emitNavigate('left')}></button>
<button class="nav" @click=${() => this._emitNavigate('down')}></button>
<button class="nav" @click=${() => this._emitNavigate('right')}></button>
</div>
</div>
</div>
</div>
`;
}
_emitLetter(l) {
this._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);

View File

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

View File

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

View File

View File

@ -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; }

View File

@ -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);
})
}

View File

@ -12,6 +12,8 @@ dependencies = [
"pandas (>=2.2.3,<3.0.0)", "pandas (>=2.2.3,<3.0.0)",
"numba (>=0.61.2,<1.0.0)", "numba (>=0.61.2,<1.0.0)",
"bitarray (>=3.4.2,<4.0.0)", "bitarray (>=3.4.2,<4.0.0)",
"websockets (>=15.0.1, <16.0.0)",
"pydantic (>=2.12.4,<3.0.0)"
] ]
[tool.poetry] [tool.poetry]
name = "multiplayer-crosswords" name = "multiplayer-crosswords"