diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py index f0a4a2d..4d6fb70 100644 --- a/multiplayer_crosswords/crossword.py +++ b/multiplayer_crosswords/crossword.py @@ -339,10 +339,22 @@ class Crossword: for i in range(crossword_word.length): r = crossword_word.start_y + dr * i c = crossword_word.start_x + dc * i - if self._current_grid[r][c] != crossword_word.word[i]: + if self._current_grid[r][c].lower() != crossword_word.word[i].lower(): return False return True + def get_words_at_position(self, x: int, y: int) -> List[CrosswordWord]: + """ + get horizontal and vertical words at position (x, y) + """ + result = [] + horizontal_cw = self._horizontal_words_by_y_x_position.get((y, x)) + if horizontal_cw is not None: + result.append(horizontal_cw) + vertical_cw = self._vertical_words_by_y_x_position.get((y, x)) + if vertical_cw is not None: + result.append(vertical_cw) + return result def __str__(self): # Simple string representation for debugging diff --git a/multiplayer_crosswords/server/server_messages.py b/multiplayer_crosswords/server/server_messages.py index 62923df..0ef4f6e 100644 --- a/multiplayer_crosswords/server/server_messages.py +++ b/multiplayer_crosswords/server/server_messages.py @@ -30,8 +30,9 @@ class SendFullSessionStateServerMessage(ServerMessageBase): 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 + clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position + clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position + solved_positions: list[tuple[int, int]] # list of (col, row) positions that are solved class LetterUpdateBroadcastServerMessage(ServerMessageBase): type: str = "letter_update" @@ -39,3 +40,4 @@ class LetterUpdateBroadcastServerMessage(ServerMessageBase): row: int col: int letter: str # single character string, uppercase A-Z or empty string for clearing the cell + is_solved: bool diff --git a/multiplayer_crosswords/server/websocket_connection_handler.py b/multiplayer_crosswords/server/websocket_connection_handler.py index 5c97288..a220eab 100644 --- a/multiplayer_crosswords/server/websocket_connection_handler.py +++ b/multiplayer_crosswords/server/websocket_connection_handler.py @@ -35,6 +35,10 @@ class WebsocketConnectionHandler: self._websocket = websocket self._is_closed = False + def is_closed(self) -> bool: + """Check if the connection is closed""" + return self._is_closed + async def handle_message(self, message: dict): try: message_type = message.get("type") diff --git a/multiplayer_crosswords/server/websocket_crossword_server.py b/multiplayer_crosswords/server/websocket_crossword_server.py index 992fce3..103da6d 100644 --- a/multiplayer_crosswords/server/websocket_crossword_server.py +++ b/multiplayer_crosswords/server/websocket_crossword_server.py @@ -9,6 +9,7 @@ import json import traceback from multiplayer_crosswords.crossword import Crossword +from multiplayer_crosswords.crossword_algorithm import Orientation from multiplayer_crosswords.dictionary import Dictionary, Word from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary @@ -77,15 +78,18 @@ class MultiplayerSession: def remove_unconnected_clients(self): self._assure_is_locked() - self._clients = {c for c in self._clients if not c.websocket.closed} + self._clients = {c for c in self._clients if not c.is_closed()} async def send_message_to_all_clients(self, message: dict): + logger.debug("Broadcasting message to all clients in session %s: %s. #clients: %s", self._session_id, message, len(self._clients)) for client in self._clients.copy(): - if client.websocket.closed: + if client.is_closed(): + logger.debug("Removing closed client %s from session %s", client, self._session_id) self._clients.discard(client) continue try: await client.send(message=message) + logger.debug("Message sent to client %s: %s", client, message) except Exception as e: logger.warning("error sending message to client %s: %s", client, str(e)) logger.warning("stack trace: %s", traceback.format_exc()) @@ -198,9 +202,28 @@ class WebsocketCrosswordServer(object): async with session.lock: grid_state = session.crossword.current_grid + # make all letters uppercase for the client + for row in range(len(grid_state)): + for col in range(len(grid_state[0])): + grid_state[row][col] = grid_state[row][col].upper() clues_across, clues_down = session.crossword.get_clues() clue_positions_across, clue_positions_down = session.crossword.get_clue_positions() + # get all sovled positions by looping over all words + solved_positions = set() + for cw in session.crossword.words: + if cw.solved: + for i in range(len(cw.word)): + if cw.orientation == Orientation.HORIZONTAL: + row = cw.start_y + col = cw.start_x + i + else: + row = cw.start_y + i + col = cw.start_x + solved_positions.add((col, row)) + + solved_positions = list(solved_positions) + response = server_messages.SendFullSessionStateServerMessage( session_id=session.session_id, grid=grid_state, @@ -208,7 +231,10 @@ class WebsocketCrosswordServer(object): clues_down=clues_down, clue_positions_across=clue_positions_across, clue_positions_down=clue_positions_down, + solved_positions=solved_positions, ) + # register the client to the session + session.add_client(handler) await handler.send(message=response.model_dump()) @WebsocketConnectionHandler.register_message_handler( @@ -245,18 +271,48 @@ class WebsocketCrosswordServer(object): 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(), - ) + # now check if the word is solved + words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col) + is_solved = any(cw.solved for cw in words_at_position) + if is_solved: + logger.info("Word solved at position (%d, %d) in session %s", message.col, message.row, session.session_id) + messages = [] + for cw in words_at_position: + if cw.solved: + logger.info("Solved word: %s", cw.word) + # go through each letter in the word and create a message + for i in range(len(cw.word)): + if cw.orientation == Orientation.HORIZONTAL: + row = cw.start_y + col = cw.start_x + i + else: + row = cw.start_y + i + col = cw.start_x + letter = cw.word[i].upper() + msg = server_messages.LetterUpdateBroadcastServerMessage( + session_id=session.session_id, + row=row, + col=col, + letter=letter, + is_solved=True + ) + messages.append(msg) + + else: + msg = server_messages.LetterUpdateBroadcastServerMessage( + session_id=session.session_id, + row=message.row, + col=message.col, + letter=msg_letter.upper(), + is_solved=is_solved + ) + messages = [msg] # NOTE: we do this purposefully outside of the session lock to avoid # potential deadlocks if sending messages takes time. # this could cause clients to receive messages slightly out of order - await session.send_message_to_all_clients(message=broadcast_message.model_dump()) + for broadcast_message in messages: + await session.send_message_to_all_clients(message=broadcast_message.model_dump()) diff --git a/multiplayer_crosswords/webui/clue_area.js b/multiplayer_crosswords/webui/clue_area.js index e69de29..d61e6c7 100644 --- a/multiplayer_crosswords/webui/clue_area.js +++ b/multiplayer_crosswords/webui/clue_area.js @@ -0,0 +1,269 @@ +import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module'; + +export class ClueArea extends LitElement { + createRenderRoot() { + return this; + } + + static get properties() { + return { + cluesAcross: { type: Object }, + cluesDown: { type: Object }, + cluePositionsAcross: { type: Object }, + cluePositionsDown: { type: Object }, + selectedRow: { type: Number }, + selectedCol: { type: Number }, + selectedMode: { type: String }, // 'horizontal' or 'vertical' + grid: { type: Array }, // 2D grid from server (needed to find walls) + _showAllCluesAcross: { state: true }, + _showAllCluesDown: { state: true } + }; + } + + constructor() { + super(); + this.cluesAcross = {}; + this.cluesDown = {}; + this.cluePositionsAcross = {}; + this.cluePositionsDown = {}; + this.selectedRow = 0; + this.selectedCol = 0; + this.selectedMode = 'horizontal'; + this.grid = []; + this._showAllCluesAcross = false; + this._showAllCluesDown = false; + } + + /** + * Find a horizontal clue that contains the given cell + * NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)! + */ + _getCellAcrossClue(row, col) { + console.log(`[ACROSS] Looking for clue at [row=${row}, col=${col}]`); + + if (!this.grid || this.grid.length === 0) { + console.log('[ACROSS] No grid data'); + return null; + } + + const gridHeight = this.grid.length; + const gridWidth = this.grid[0]?.length || 0; + console.log(`[ACROSS] Grid: ${gridHeight}x${gridWidth}`); + + // Find all across clues on this row + const cluesOnRow = []; + for (const [clueNum, position] of Object.entries(this.cluePositionsAcross)) { + // Server sends (x, y) = (col, row)! + const clueCol = position[0]; + const clueRow = position[1]; + + console.log(`[ACROSS] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`); + + if (clueRow === row) { + cluesOnRow.push({ clueNum, clueCol }); + console.log(`[ACROSS] → On same row!`); + } + } + + if (cluesOnRow.length === 0) { + console.log('[ACROSS] No clues on this row'); + return null; + } + + // Sort by column + cluesOnRow.sort((a, b) => a.clueCol - b.clueCol); + console.log(`[ACROSS] Sorted clues on row ${row}:`, cluesOnRow); + + // Find which clue this cell belongs to + for (let i = 0; i < cluesOnRow.length; i++) { + const startCol = cluesOnRow[i].clueCol; + const endCol = i + 1 < cluesOnRow.length + ? cluesOnRow[i + 1].clueCol - 1 + : gridWidth - 1; + + console.log(`[ACROSS] Clue ${cluesOnRow[i].clueNum}: cols ${startCol}-${endCol}`); + + // Check if cell is within this clue range + if (col >= startCol && col <= endCol) { + console.log(`[ACROSS] ✓ FOUND: Clue ${cluesOnRow[i].clueNum}`); + return { + number: cluesOnRow[i].clueNum, + direction: 'across', + text: this.cluesAcross[cluesOnRow[i].clueNum] || '' + }; + } + } + + console.log('[ACROSS] Cell not in any clue range'); + return null; + } + + /** + * Find a vertical clue that contains the given cell + * NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)! + */ + _getCellDownClue(row, col) { + console.log(`[DOWN] Looking for clue at [row=${row}, col=${col}]`); + + if (!this.grid || this.grid.length === 0) { + console.log('[DOWN] No grid data'); + return null; + } + + const gridHeight = this.grid.length; + const gridWidth = this.grid[0]?.length || 0; + console.log(`[DOWN] Grid: ${gridHeight}x${gridWidth}`); + + // Find all down clues in this column + const cluesInCol = []; + for (const [clueNum, position] of Object.entries(this.cluePositionsDown)) { + // Server sends (x, y) = (col, row)! + const clueCol = position[0]; + const clueRow = position[1]; + + console.log(`[DOWN] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`); + + if (clueCol === col) { + cluesInCol.push({ clueNum, clueRow }); + console.log(`[DOWN] → On same column!`); + } + } + + if (cluesInCol.length === 0) { + console.log('[DOWN] No clues in this column'); + return null; + } + + // Sort by row + cluesInCol.sort((a, b) => a.clueRow - b.clueRow); + console.log(`[DOWN] Sorted clues in column ${col}:`, cluesInCol); + + // Find which clue this cell belongs to + for (let i = 0; i < cluesInCol.length; i++) { + const startRow = cluesInCol[i].clueRow; + const endRow = i + 1 < cluesInCol.length + ? cluesInCol[i + 1].clueRow - 1 + : gridHeight - 1; + + console.log(`[DOWN] Clue ${cluesInCol[i].clueNum}: rows ${startRow}-${endRow}`); + + // Check if cell is within this clue range + if (row >= startRow && row <= endRow) { + console.log(`[DOWN] ✓ FOUND: Clue ${cluesInCol[i].clueNum}`); + return { + number: cluesInCol[i].clueNum, + direction: 'down', + text: this.cluesDown[cluesInCol[i].clueNum] || '' + }; + } + } + + console.log('[DOWN] Cell not in any clue range'); + return null; + } + + /** + * Get current clue based on selected cell and mode + */ + _getCurrentClue() { + // Check if we're clicking on a wall - if so, no clue + if (this.grid && this.grid[this.selectedRow] && this.grid[this.selectedRow][this.selectedCol] === '#') { + return null; + } + + if (this.selectedMode === 'horizontal') { + return this._getCellAcrossClue(this.selectedRow, this.selectedCol); + } else { + return this._getCellDownClue(this.selectedRow, this.selectedCol); + } + } + + _toggleShowAllClues() { + this._showAllCluesAcross = !this._showAllCluesAcross; + } + + _toggleShowAllCluesDown() { + this._showAllCluesDown = !this._showAllCluesDown; + } + + render() { + const currentClue = this._getCurrentClue(); + + // Show across clues + if (this._showAllCluesAcross) { + return html` +