keep session id history

This commit is contained in:
2025-11-14 16:47:01 +01:00
parent 8d194c0dff
commit 8939c6ffb5
13 changed files with 195 additions and 38 deletions

View File

@ -140,9 +140,9 @@ class Crossword:
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
solution_word_positions: Optional[List[Tuple[int, int]]] = None
max_solution_word_attempts = 10
for _ in range(max_solution_word_attempts):
random_length = random.randint(10, 20)
max_solution_word_attempts = 20
for i in range(max_solution_word_attempts):
random_length = random.randint(10, 27 - (i // 2))
possible_words = dictionary.find_by_pattern('*' * random_length)
if not possible_words:
continue

View File

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

View File

@ -3,8 +3,8 @@ 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_MIN_GRID_SIZE = 12
DEFAULT_MAX_GRID_SIZE = 25
DEFAULT_GRID_BLOCK_RATIO = 0.39

View File

@ -34,7 +34,7 @@ class BoardSizePreset(str, Enum):
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)
return (min_size + (max_size - min_size) // 2, min_size + (max_size - min_size) // 2)
elif self == BoardSizePreset.LARGE:
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
elif self == BoardSizePreset.VERY_LARGE:

View File

@ -109,12 +109,29 @@ class MultiplayerSessionManager(object):
self._sessions_lock = asyncio.Lock()
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
async def remove_idle_sessions(self):
"""Remove all idle sessions that have exceeded their max idle time."""
async with self._sessions_lock:
idle_session_ids = [
session_id for session_id, session in self._sessions.items()
if session.is_idle
]
for session_id in idle_session_ids:
logger.info("Removing idle session %s", session_id)
del self._sessions[session_id]
if idle_session_ids:
logger.info("Removed %d idle sessions", len(idle_session_ids))
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
async with self._sessions_lock:
# Remove idle sessions before creating a new one
await self.remove_idle_sessions()
if isinstance(lang, str):
lang = Languages(lang)
dictionary = lang.load_dictionary()
max_tries = 4
for i in range(max_tries):
crossword = Crossword.generate(
dictionary=dictionary,
seed=None,
@ -122,6 +139,8 @@ class MultiplayerSessionManager(object):
grid_height=grid_h,
grid_block_ratio=self._grid_block_ratio,
)
if crossword is not None:
break
if crossword is None:
raise RuntimeError("Failed to generate crossword for the given parameters.")
session_id = str(uuid.uuid4())
@ -146,7 +165,7 @@ class WebsocketCrosswordServer(object):
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],
supported_languages=list(reversed([lang.value for lang in Languages])),
min_grid_size=server_config.MIN_GRID_SIZE,
max_grid_size=server_config.MAX_GRID_SIZE,
board_size_presets={
@ -227,7 +246,9 @@ class WebsocketCrosswordServer(object):
solution_word_positions = []
positions = session.crossword.solution_word_positions
for pos in positions:
solution_word_positions.append((pos[1], pos[0])) # convert (row, col) to (col, row)
# Convert from (row, col) to (col, row) for client
row, col = pos
solution_word_positions.append((col, row))
response = server_messages.SendFullSessionStateServerMessage(
session_id=session.session_id,

View File

@ -21,7 +21,48 @@ def load_dictionary(p: str | Path) -> Dictionary:
if not word.isalpha():
continue
word = word.lower()
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
hints = []
try:
if "senses" in obj:
for sense in obj["senses"]:
text = sense
if word in text.lower():
continue
hints.append(text)
except Exception:
pass
try:
if "synonyms" in obj:
for synonym in obj["synonyms"]:
text = synonym
if word in text.lower():
continue
hints.append( text)
except Exception:
pass
try:
if "antonyms" in obj:
for antonym in obj["antonyms"]:
text = antonym
if word in text.lower():
continue
if "de" in p.name:
text = "Gegenteil von " + text
else:
text = "Opposite of " + text
hints.append( text)
except Exception:
pass
if len(hints) > 0:
w = Word(word=word,
hints=hints,
difficulty=1)
dict_obj.add_word(w)
load_dictionary._cache[cache_key] = dict_obj
return dict_obj

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -41,7 +41,12 @@ export class MobileKeyboard extends LitElement {
return html`
<div class="keyboard-container">
${html`<div class="handle" @click=${this._toggleCollapse}>${this.collapsed ? '▲' : '▼'}</div>`}
${html`<div class="handle" @click=${this._toggleCollapse}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle; margin-right: 0.3rem;">
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm6 7H5v-2h8v2zm0-4h-2v-2h2v2zm3 0h-2v-2h2v2zm3 0h-2v-2h2v2zm0-3h-2V8h2v2z"/>
</svg>
${this.collapsed ? '▲' : '▼'}
</div>`}
<div class="keyboard-wrapper">
<div class="keyboard">
${rows.map((r, idx) => {

View File

@ -130,6 +130,13 @@ export class CrosswordMenu extends LitElement {
notificationManager.info('Creating session...');
}
_toggleDataInfo() {
const element = this.querySelector('.data-info-details');
if (element) {
element.style.display = element.style.display === 'none' ? 'block' : 'none';
}
}
render() {
if (this._loading) {
return html`
@ -152,7 +159,9 @@ export class CrosswordMenu extends LitElement {
return html`
<div class="menu-container">
<div class="menu">
<h1>Crossword</h1>
<h1>Multiplayer Crossword</h1>
<p class="description">Collaborate with others to solve crosswords in real-time. Create a session and share the link with friends to play together!</p>
${this._error ? html`<div class="error">${this._error}</div>` : ''}
@ -175,6 +184,17 @@ export class CrosswordMenu extends LitElement {
</div>
<button @click="${this._onCreateCrossword}">Create Crossword</button>
<div class="data-info">
<span class="data-info-toggle" @click="${this._toggleDataInfo}">📋 Data & Privacy</span>
<div class="data-info-details" style="display: none;">
<ul>
<li><strong>Shared Data:</strong> Only the letters you type into the grid during a session are shared with other users and the backend server in that session.</li>
<li><strong>Session Lifetime:</strong> Sessions are automatically deleted after 48 hours of inactivity.</li>
<li><strong>No Tracking:</strong> No personal data is collected or stored beyond the session duration.</li>
</ul>
</div>
</div>
</div>
</div>
`;

View File

@ -657,26 +657,38 @@ mobile-keyboard .key-spacer {
mobile-keyboard .handle {
position: absolute;
top: -2.4rem;
left: 50%;
transform: translateX(-50%);
top: -1.6rem;
left: 0.75rem;
transform: none;
background:
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 5px),
repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(255,255,255,.015) 4px, rgba(255,255,255,.015) 6px),
repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(0,0,0,.02) 3px, rgba(0,0,0,.02) 5px),
repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(0,0,0,.015) 4px, rgba(0,0,0,.015) 6px),
repeating-radial-gradient(circle at 12% 18%, rgba(255,255,255,.02) 1px, transparent 1px),
repeating-radial-gradient(circle at 67% 77%, rgba(0,0,0,.03) 1px, transparent 1px),
repeating-radial-gradient(circle at 34% 51%, rgba(255,255,255,.015) 1.5px, transparent 1.5px),
linear-gradient(135deg, #0a0805 0%, #0f0c09 100%);
color: #a89f99;
padding: 0.55rem 0.9rem;
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(255,255,255,.01) 3px, rgba(255,255,255,.01) 5px),
repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(255,255,255,.008) 4px, rgba(255,255,255,.008) 6px),
repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(0,0,0,.012) 3px, rgba(0,0,0,.012) 5px),
repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(0,0,0,.008) 4px, rgba(0,0,0,.008) 6px),
repeating-radial-gradient(circle at 12% 18%, rgba(255,255,255,.01) 1px, transparent 1px),
repeating-radial-gradient(circle at 67% 77%, rgba(0,0,0,.015) 1px, transparent 1px),
repeating-radial-gradient(circle at 34% 51%, rgba(255,255,255,.008) 1.5px, transparent 1.5px),
linear-gradient(135deg, #1a1512 0%, #0f0c09 100%);
color: #7a7268;
padding: 0.35rem 0.6rem;
border-radius: 0.25rem 0.25rem 0 0;
border: 1px solid #2a2520;
border: 1px solid rgba(58, 53, 48, 0.4);
cursor: pointer;
box-shadow: 0 -2px 8px rgba(0,0,0,0.5);
font-size: 0.9rem;
font-weight: 600;
box-shadow: 0 -1px 3px rgba(0,0,0,0.3);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.75;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.3rem;
}
mobile-keyboard .handle:hover {
opacity: 0.95;
}
mobile-keyboard[collapsed] .keyboard-container { transform: translateY(calc(100% - 2.6rem)); }

View File

@ -1,8 +1,20 @@
const cacheName = 'pwa-conf-v4';
const staticAssets = [
'./',
'./index.html',
'./app.js',
'./websocket.js' //TODO: add all necessary files
'./main.js',
'./websocket.js',
'./grid.js',
'./clue_area.js',
'./keyboard.js',
'./menu.js',
'./notification-area.js',
'./notification-manager.js',
'./styles.css',
'./manifest.json',
'./favicon.png',
'./big_icon.png'
];

View File

@ -31,6 +31,17 @@ pytest = "^7.0"
[tool.poetry.group.dev.dependencies]
jupyterlab = "^4.4.3"
[[tool.poetry.packages]]
include = "multiplayer_crosswords"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "sdist"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "wheel"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"