| | """
|
| | Semantic guessing game - backend server.
|
| |
|
| | Loads pre-computed word embeddings and serves similarity scores.
|
| |
|
| | Usage:
|
| | pip install fastapi uvicorn numpy
|
| | python generate_embeddings.py # create mock embeddings first
|
| | uvicorn server:app --reload --port 8000
|
| | """
|
| |
|
| | import hashlib |
| | import base64 |
| | import json |
| | import math |
| | import random
|
| | import re
|
| | import secrets
|
| | import time
|
| | from pathlib import Path
|
| |
|
| | import numpy as np
|
| | from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
| | from fastapi.middleware.cors import CORSMiddleware
|
| | from fastapi.staticfiles import StaticFiles
|
| | from pydantic import BaseModel
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | EMBEDDINGS_PATH = Path(__file__).parent / "embeddings.npz"
|
| |
|
| | if not EMBEDDINGS_PATH.exists():
|
| | raise FileNotFoundError(
|
| | f"Embeddings not found at {EMBEDDINGS_PATH}. "
|
| | f"Run: python generate_embeddings.py"
|
| | )
|
| |
|
| | print(f"Loading embeddings from {EMBEDDINGS_PATH}...")
|
| | _data = np.load(EMBEDDINGS_PATH, allow_pickle=True)
|
| | WORDS: list[str] = _data["words"].tolist()
|
| | VECTORS: np.ndarray = _data["vectors"].astype(np.float32)
|
| | WORD_TO_IDX: dict[str, int] = {w: i for i, w in enumerate(WORDS)}
|
| | print(f"Loaded {len(WORDS)} words, {VECTORS.shape[1]}-dim embeddings")
|
| |
|
| |
|
| | SECRET_WORDS_PATH = Path(__file__).parent / "secret_words.txt"
|
| | if SECRET_WORDS_PATH.exists():
|
| | _secret_raw = [w.strip().lower() for w in SECRET_WORDS_PATH.read_text(encoding="utf-8").splitlines() if w.strip()]
|
| |
|
| | SECRET_CANDIDATES = [w for w in _secret_raw if w in WORD_TO_IDX]
|
| | print(f"Loaded {len(SECRET_CANDIDATES)} secret word candidates from {SECRET_WORDS_PATH}")
|
| | else:
|
| |
|
| | SECRET_CANDIDATES = [w for w in WORDS if 3 <= len(w) <= 16 and w.isalpha()]
|
| | print(f"No secret_words.txt found, using {len(SECRET_CANDIDATES)} filtered candidates from vocabulary")
|
| |
|
| |
|
| | SECRET_META_PATH = Path(__file__).parent / "secret_words_meta.json"
|
| | SECRET_META: dict = {}
|
| | if SECRET_META_PATH.exists():
|
| | SECRET_META = json.loads(SECRET_META_PATH.read_text(encoding="utf-8"))
|
| | print(f"Loaded metadata for {len(SECRET_META)} secret words from {SECRET_META_PATH}")
|
| | else:
|
| | print(f"No secret_words_meta.json found, hint metadata unavailable")
|
| |
|
| | PHASE3_MIN_COVERAGE = {"vad": 0.25, "sensorimotor": 0.2, "glasgow": 0.15}
|
| | if SECRET_META:
|
| | PHASE3_COVERAGE = {
|
| | k: (sum(1 for _e in SECRET_META.values() if _e.get(k)) / len(SECRET_META))
|
| | for k in PHASE3_MIN_COVERAGE
|
| | }
|
| | else:
|
| | PHASE3_COVERAGE = {k: 0.0 for k in PHASE3_MIN_COVERAGE}
|
| | PHASE3_HINT_ENABLED = {
|
| | k: PHASE3_COVERAGE.get(k, 0.0) >= threshold for k, threshold in PHASE3_MIN_COVERAGE.items()
|
| | }
|
| | print(
|
| | "Phase 3 hint coverage: "
|
| | + ", ".join(
|
| | f"{k}={PHASE3_COVERAGE[k]*100:.1f}% ({'enabled' if PHASE3_HINT_ENABLED[k] else 'disabled'})"
|
| | for k in ["vad", "sensorimotor", "glasgow"]
|
| | )
|
| | )
|
| |
|
| | |
| | CANDIDATES_BY_DIFFICULTY: dict[str, list[str]] = {} |
| | for _w in SECRET_CANDIDATES: |
| | _diff = SECRET_META.get(_w, {}).get("difficulty", "medium") |
| | CANDIDATES_BY_DIFFICULTY.setdefault(_diff, []).append(_w) |
| | CANDIDATE_SET_BY_DIFFICULTY: dict[str, set[str]] = { |
| | diff: set(words) for diff, words in CANDIDATES_BY_DIFFICULTY.items() |
| | } |
| |
|
| | SECRET_WORD_SET = set(SECRET_CANDIDATES) |
| | SECRET_WORD_SEEDS_PATH = Path(__file__).parent / "secret_word_seeds.json" |
| | DEFAULT_CANONICAL_SEED_PREFIX = "" |
| | DEFAULT_CANONICAL_SEED_NAMESPACE = "semantick:secret-seed:v2" |
| | DEFAULT_CANONICAL_SEED_TOKEN_LEN = 6 |
| |
|
| |
|
| | def _seed_for_word_with_nonce(word: str, seed_prefix: str, nonce: int) -> str: |
| | payload = f"{DEFAULT_CANONICAL_SEED_NAMESPACE}|{word}|{nonce}".encode("utf-8") |
| | digest = hashlib.blake2b(payload, digest_size=16).digest() |
| | token = base64.b32encode(digest).decode("ascii").rstrip("=").lower() |
| | return f"{seed_prefix}{token[:DEFAULT_CANONICAL_SEED_TOKEN_LEN]}" |
| |
|
| |
|
| | def _build_default_secret_seed_map(words: list[str], seed_prefix: str) -> dict[str, str]: |
| | unique_words = sorted(set(words)) |
| | result: dict[str, str] = {} |
| | used: set[str] = set() |
| | for word in unique_words: |
| | nonce = 0 |
| | while True: |
| | seed = _seed_for_word_with_nonce(word, seed_prefix, nonce) |
| | if len(word) >= 4 and word in seed: |
| | nonce += 1 |
| | continue |
| | if seed not in used: |
| | used.add(seed) |
| | result[word] = seed |
| | break |
| | nonce += 1 |
| | return result |
| |
|
| |
|
| | def _load_secret_seed_mapping() -> tuple[str, dict[str, str]]: |
| | """ |
| | Load precomputed canonical seed mapping if available and valid. |
| | Falls back to deterministic opaque hash mapping. |
| | """ |
| | fallback = _build_default_secret_seed_map(SECRET_CANDIDATES, DEFAULT_CANONICAL_SEED_PREFIX) |
| | if not SECRET_WORD_SEEDS_PATH.exists(): |
| | print("No secret_word_seeds.json found, using default canonical mapping") |
| | return DEFAULT_CANONICAL_SEED_PREFIX, fallback |
| |
|
| | try: |
| | payload = json.loads(SECRET_WORD_SEEDS_PATH.read_text(encoding="utf-8")) |
| | prefix = str(payload.get("seed_prefix") or DEFAULT_CANONICAL_SEED_PREFIX) |
| | raw_map = payload.get("all") or {} |
| | loaded: dict[str, str] = {} |
| | if isinstance(raw_map, dict): |
| | for raw_word, raw_seed in raw_map.items(): |
| | word = str(raw_word).strip().lower() |
| | seed = str(raw_seed).strip() |
| | if word in SECRET_WORD_SET and seed: |
| | loaded[word] = seed |
| | |
| | complete = {word: loaded.get(word, fallback[word]) for word in sorted(SECRET_WORD_SET)} |
| | |
| | if len(set(complete.values())) != len(complete): |
| | print("secret_word_seeds.json has duplicate seeds; using default canonical mapping") |
| | return DEFAULT_CANONICAL_SEED_PREFIX, fallback |
| | print(f"Loaded canonical secret seeds for {len(complete)} words from {SECRET_WORD_SEEDS_PATH}") |
| | return prefix, complete |
| | except Exception as exc: |
| | print(f"Failed to parse {SECRET_WORD_SEEDS_PATH}: {exc}; using default canonical mapping") |
| | return DEFAULT_CANONICAL_SEED_PREFIX, fallback |
| |
|
| |
|
| | CANONICAL_SEED_PREFIX, SECRET_WORD_TO_CANONICAL_SEED = _load_secret_seed_mapping() |
| | CANONICAL_SEED_TO_WORD = { |
| | seed: word for word, seed in SECRET_WORD_TO_CANONICAL_SEED.items() |
| | } |
| |
|
| |
|
| |
|
| |
|
| |
|
| | GAMES: dict[str, dict] = {}
|
| | MAX_GAMES = 1000
|
| | GAME_TTL_SECONDS = 86400
|
| |
|
| | HINT_RANKS = [1000, 100, 10, 9, 8, 7, 6, 5, 4, 3, 2]
|
| |
|
| |
|
| | def cleanup_games():
|
| | """Remove expired games and enforce max games limit."""
|
| | now = time.time()
|
| | expired = [gid for gid, g in GAMES.items() if now - g["created_at"] > GAME_TTL_SECONDS]
|
| | for gid in expired:
|
| | del GAMES[gid]
|
| |
|
| | if len(GAMES) > MAX_GAMES:
|
| | by_age = sorted(GAMES.items(), key=lambda x: x[1]["created_at"])
|
| | for gid, _ in by_age[:len(GAMES) - MAX_GAMES]:
|
| | del GAMES[gid]
|
| |
|
| |
|
| | def _ensure_guess_counters(game: dict) -> None:
|
| | """Backfill guess counters for older in-memory game entries."""
|
| | if "guess_count" in game:
|
| | return
|
| | guess_count = 0
|
| | for g in game.get("guesses", []):
|
| | if not g.get("isHint"):
|
| | guess_count += 1
|
| | g.setdefault("guess_num", guess_count)
|
| | game["guess_count"] = guess_count
|
| |
|
| |
|
| | def _get_game_or_404(game_id: str, *, require_active: bool = False) -> dict: |
| | cleanup_games() |
| | game = GAMES.get(game_id) |
| | if not game: |
| | raise HTTPException(404, "Game not found. It may have expired — start a new game.") |
| | _ensure_guess_counters(game) |
| | if require_active and (game.get("solved") or game.get("gave_up")): |
| | raise HTTPException(400, "Game is over. Start a new game.") |
| | return game |
| |
|
| |
|
| | def rank_to_score(rank: int, vocab_size: int) -> float:
|
| | """
|
| | Map rank to a 0-1 score using a curved logarithmic scale.
|
| | Rank 1 = 1.0, rank N = 0.0. Power of 1.5 on the log ratio
|
| | gives more room at the top and compresses the bottom:
|
| | rank 10 ≈ 0.91, rank 100 ≈ 0.75, rank 1000 ≈ 0.54, rank 10000 ≈ 0.28
|
| | """
|
| | if rank <= 1:
|
| | return 1.0
|
| | log_ratio = math.log(rank) / math.log(vocab_size)
|
| | return round(max(0.0, 1 - log_ratio ** 1.5), 4)
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | app = FastAPI(title="Semantick")
|
| | app.add_middleware(
|
| | CORSMiddleware,
|
| | allow_origins=["*"],
|
| | allow_methods=["*"],
|
| | allow_headers=["*"],
|
| | )
|
| |
|
| |
|
| | class NewGameResponse(BaseModel): |
| | game_id: str |
| | vocab_size: int |
| | seed: str |
| |
|
| |
|
| | class SeedLookupResponse(BaseModel): |
| | word: str |
| | found: bool |
| | seed: str | None = None |
| | message: str |
| | difficulty: str | None = None |
| |
|
| |
|
| | class GameStateResponse(BaseModel): |
| | game_id: str
|
| | vocab_size: int
|
| | seed: str
|
| | guesses: list[dict]
|
| | hints_used: int
|
| | total_hints: int = 0
|
| | solved: bool
|
| | gave_up: bool
|
| | secret_word: str | None = None
|
| | pos_revealed: bool = False
|
| | pos: str | None = None
|
| | category_hints: list[str] = []
|
| | difficulty: str | None = None
|
| | definition: str | None = None
|
| | definition_done: bool = False
|
| | concreteness_label: str | None = None
|
| | vad_label: str | None = None
|
| | sensorimotor_label: str | None = None
|
| | glasgow_label: str | None = None
|
| | conceptnet_hints: list[dict] = []
|
| | has_definition: bool = False
|
| | has_concreteness: bool = False
|
| | has_vad: bool = False
|
| | has_sensorimotor: bool = False
|
| | has_glasgow: bool = False
|
| | has_conceptnet: bool = False
|
| | has_categories: bool = False
|
| |
|
| |
|
| | class GuessRequest(BaseModel):
|
| | word: str
|
| |
|
| |
|
| | class GuessResponse(BaseModel):
|
| | word: str
|
| | score: float
|
| | rank: int | None = None
|
| | guess_number: int
|
| | total_guesses: int
|
| | solved: bool
|
| |
|
| |
|
| | class HintResponse(BaseModel):
|
| | word: str
|
| | rank: int
|
| | score: float
|
| | hints_used: int
|
| | already_guessed: bool = False
|
| |
|
| |
|
| | class PosHintResponse(BaseModel):
|
| | pos: str
|
| | total_hints: int
|
| |
|
| |
|
| | class CategoryHintResponse(BaseModel):
|
| | category: str
|
| | category_hints_used: int
|
| | total_hints: int
|
| | has_more: bool = True
|
| |
|
| |
|
| | class DefinitionHintResponse(BaseModel):
|
| | definition: str
|
| | total_hints: int
|
| | done: bool
|
| |
|
| |
|
| | class ConcretenessHintResponse(BaseModel):
|
| | concreteness: str
|
| | total_hints: int
|
| |
|
| |
|
| | class LabelHintResponse(BaseModel):
|
| | label: str
|
| | total_hints: int
|
| |
|
| |
|
| | class ConceptNetHintResponse(BaseModel):
|
| | relation: str
|
| | values: list[str]
|
| | total_hints: int
|
| | has_more: bool = True
|
| |
|
| |
|
| | class GiveUpResponse(BaseModel):
|
| | secret_word: str
|
| | total_guesses: int
|
| |
|
| |
|
| | def _build_progressive_definition(definition: str, secret_word: str, words_revealed: int) -> tuple[str, int]:
|
| | """
|
| | Build a progressively revealed definition.
|
| | Returns (display_string, total_content_words).
|
| | Words are revealed from the end toward the start (short function words first tends
|
| | to be less spoilery). The secret word itself is always redacted as "___".
|
| | """
|
| |
|
| | redacted = re.sub(re.escape(secret_word), "___", definition, flags=re.IGNORECASE)
|
| |
|
| | tokens = re.findall(r"[A-Za-z']+|[^A-Za-z']+", redacted)
|
| |
|
| | content_indices = [i for i, t in enumerate(tokens) if re.match(r"[A-Za-z']", t) and t != "___"]
|
| | total_content = len(content_indices)
|
| | if words_revealed <= 0:
|
| | return redacted, total_content
|
| |
|
| | reveal_set = set(content_indices[-words_revealed:]) if words_revealed < total_content else set(content_indices)
|
| |
|
| | parts = []
|
| | for i, token in enumerate(tokens):
|
| | if i in set(content_indices) - reveal_set:
|
| | parts.append("_" * len(token))
|
| | else:
|
| | parts.append(token)
|
| | return "".join(parts), total_content
|
| |
|
| |
|
| | def _concreteness_label(rating: float) -> str:
|
| | if rating >= 4.5:
|
| | return "very concrete"
|
| | elif rating >= 3.5:
|
| | return "somewhat concrete"
|
| | elif rating >= 2.5:
|
| | return "somewhat abstract"
|
| | else:
|
| | return "very abstract"
|
| |
|
| |
|
| | def _normalize_score(value: float, *, low: float, high: float) -> str:
|
| | if value <= low:
|
| | return "low"
|
| | if value >= high:
|
| | return "high"
|
| | return "mid"
|
| |
|
| |
|
| | def _score_to_1_9(value: float) -> float:
|
| | if value <= 1.0:
|
| | return 1.0 + (8.0 * value)
|
| | if value <= 9.0:
|
| | return value
|
| | if value <= 100.0:
|
| | return 1.0 + (8.0 * (value / 100.0))
|
| | return 9.0
|
| |
|
| |
|
| | def _score_to_1_7(value: float) -> float:
|
| | if value <= 1.0:
|
| | return 1.0 + (6.0 * value)
|
| | if value <= 7.0:
|
| | return value
|
| | if value <= 100.0:
|
| | return 1.0 + (6.0 * (value / 100.0))
|
| | return 7.0
|
| |
|
| |
|
| | def _vad_label(vad: dict) -> str:
|
| | valence = _score_to_1_9(float(vad.get("valence", 5.0)))
|
| | arousal = _score_to_1_9(float(vad.get("arousal", 5.0)))
|
| | dominance = _score_to_1_9(float(vad.get("dominance", 5.0)))
|
| |
|
| | valence_map = {"low": "unpleasant", "mid": "neutral tone", "high": "pleasant"}
|
| | arousal_map = {"low": "calm", "mid": "moderate arousal", "high": "intense"}
|
| | dominance_map = {"low": "submissive", "mid": "balanced control", "high": "in control"}
|
| |
|
| | return ", ".join(
|
| | [
|
| | valence_map[_normalize_score(valence, low=4.0, high=6.0)],
|
| | arousal_map[_normalize_score(arousal, low=4.0, high=6.0)],
|
| | dominance_map[_normalize_score(dominance, low=4.0, high=6.0)],
|
| | ]
|
| | )
|
| |
|
| |
|
| | def _sensorimotor_label(sensorimotor: dict) -> str:
|
| | modality = str(sensorimotor.get("dominant_modality") or "").replace("_", " ").strip()
|
| | if not modality:
|
| | return "unclear sensory grounding"
|
| | strength = float(sensorimotor.get("strength") or 0.0)
|
| | if strength <= 2.5:
|
| | strength_label = "light"
|
| | elif strength <= 4.0:
|
| | strength_label = "moderate"
|
| | else:
|
| | strength_label = "strong"
|
| | secondary = str(sensorimotor.get("secondary_modality") or "").replace("_", " ").strip()
|
| | if secondary and secondary != modality:
|
| | return f"{strength_label} {modality} / {secondary} grounding"
|
| | return f"{strength_label} {modality} grounding"
|
| |
|
| |
|
| | def _glasgow_label(glasgow: dict) -> str:
|
| | parts: list[str] = []
|
| | familiarity = glasgow.get("familiarity")
|
| | imageability = glasgow.get("imageability")
|
| | valence = glasgow.get("valence")
|
| | if familiarity is not None:
|
| | fam = _score_to_1_7(float(familiarity))
|
| | fam_label = _normalize_score(fam, low=3.0, high=5.0)
|
| | parts.append({"low": "less familiar", "mid": "moderately familiar", "high": "very familiar"}[fam_label])
|
| | if imageability is not None:
|
| | img = _score_to_1_7(float(imageability))
|
| | img_label = _normalize_score(img, low=3.0, high=5.0)
|
| | parts.append({"low": "low imageability", "mid": "medium imageability", "high": "high imageability"}[img_label])
|
| | if not parts and valence is not None:
|
| | val = _score_to_1_7(float(valence))
|
| | val_label = _normalize_score(val, low=3.0, high=5.0)
|
| | parts.append({"low": "negative tone", "mid": "neutral tone", "high": "positive tone"}[val_label])
|
| | return ", ".join(parts) if parts else "compact psycholinguistic profile available"
|
| |
|
| |
|
| | def create_game(secret_word: str, seed: str) -> dict: |
| | """Create a new game state for a given secret word."""
|
| | secret_idx = WORD_TO_IDX[secret_word]
|
| |
|
| |
|
| | sims = VECTORS @ VECTORS[secret_idx]
|
| |
|
| |
|
| | hint_positions = [r - 1 for r in HINT_RANKS]
|
| | partitioned = np.argpartition(-sims, hint_positions)
|
| | hint_words = [WORDS[int(partitioned[pos])] for pos in hint_positions]
|
| |
|
| | meta = SECRET_META.get(secret_word, {})
|
| | return {
|
| | "secret_word": secret_word,
|
| | "secret_idx": secret_idx,
|
| | "sims": sims,
|
| | "hint_words": hint_words,
|
| | "seed": seed,
|
| | "guesses": [],
|
| | "guess_count": 0,
|
| | "hints_used": 0,
|
| | "total_hints": 0,
|
| | "pos_revealed": False,
|
| | "category_hints_used": 0,
|
| | "definition_words_revealed": 0,
|
| | "concreteness_revealed": False,
|
| | "vad_revealed": False,
|
| | "sensorimotor_revealed": False,
|
| | "glasgow_revealed": False,
|
| | "conceptnet_hints_used": 0,
|
| | "meta": meta,
|
| | "difficulty": meta.get("difficulty"),
|
| | "solved": False,
|
| | "winner": None,
|
| | "gave_up": False,
|
| | "gave_up_by": None,
|
| | "created_at": time.time(), |
| | } |
| |
|
| |
|
| | def canonical_seed_for_word(word: str) -> str | None: |
| | return SECRET_WORD_TO_CANONICAL_SEED.get(word.strip().lower()) |
| |
|
| |
|
| | def _word_from_canonical_seed(seed: str) -> str | None: |
| | raw = seed.strip() |
| | if not raw: |
| | return None |
| | |
| | mapped = CANONICAL_SEED_TO_WORD.get(raw) |
| | if mapped: |
| | return mapped |
| | return None |
| |
|
| |
|
| | def _canonical_seed_fallback(word: str) -> str: |
| | normalized = word.strip().lower() |
| | if not normalized: |
| | return "" |
| | nonce = 0 |
| | while True: |
| | seed = _seed_for_word_with_nonce(normalized, DEFAULT_CANONICAL_SEED_PREFIX, nonce) |
| | if len(normalized) >= 4 and normalized in seed: |
| | nonce += 1 |
| | continue |
| | owner = CANONICAL_SEED_TO_WORD.get(seed) |
| | if owner is None or owner == normalized: |
| | return seed |
| | nonce += 1 |
| |
|
| |
|
| | def seed_to_word_filtered(seed: str, difficulty: str | None = None) -> str: |
| | """Deterministically pick a secret word, optionally filtered by difficulty.""" |
| | if difficulty: |
| | if difficulty not in CANDIDATES_BY_DIFFICULTY: |
| | raise HTTPException(422, f"Unknown difficulty '{difficulty}'") |
| | pool = CANDIDATES_BY_DIFFICULTY[difficulty] |
| | pool_set = CANDIDATE_SET_BY_DIFFICULTY[difficulty] |
| | else: |
| | pool = SECRET_CANDIDATES |
| | pool_set = SECRET_WORD_SET |
| | if not pool: |
| | raise HTTPException(500, "Secret word pool is empty") |
| |
|
| | canonical_word = _word_from_canonical_seed(seed) |
| | if canonical_word and canonical_word in pool_set: |
| | return canonical_word |
| |
|
| | h = int(hashlib.sha256(seed.encode()).hexdigest(), 16) |
| | return pool[h % len(pool)] |
| |
|
| |
|
| | @app.get("/api/seed-for-word", response_model=SeedLookupResponse) |
| | def seed_for_word(word: str = Query(...), difficulty: str | None = Query(None)): |
| | normalized = word.strip().lower() |
| | if not normalized: |
| | raise HTTPException(400, "Empty word") |
| |
|
| | if difficulty: |
| | if difficulty not in CANDIDATES_BY_DIFFICULTY: |
| | raise HTTPException(422, f"Unknown difficulty '{difficulty}'") |
| | pool_set = CANDIDATE_SET_BY_DIFFICULTY[difficulty] |
| | else: |
| | pool_set = SECRET_WORD_SET |
| |
|
| | if normalized not in pool_set: |
| | if difficulty and normalized in SECRET_WORD_SET: |
| | actual_difficulty = SECRET_META.get(normalized, {}).get("difficulty") |
| | if actual_difficulty: |
| | msg = f"word is in secret words list but not in '{difficulty}' difficulty (it's '{actual_difficulty}')" |
| | else: |
| | msg = f"word is in secret words list but not in '{difficulty}' difficulty" |
| | else: |
| | msg = "word not in secret words list" |
| | return SeedLookupResponse( |
| | word=normalized, |
| | found=False, |
| | seed=None, |
| | message=msg, |
| | difficulty=difficulty, |
| | ) |
| |
|
| | seed_value = canonical_seed_for_word(normalized) or _canonical_seed_fallback(normalized) |
| | return SeedLookupResponse( |
| | word=normalized, |
| | found=True, |
| | seed=seed_value, |
| | message="ok", |
| | difficulty=difficulty, |
| | ) |
| |
|
| |
|
| | @app.post("/api/new-game", response_model=NewGameResponse)
|
| | def new_game(seed: str | None = Query(None), difficulty: str | None = Query(None)):
|
| | """Start a new game. Optionally provide a seed and/or difficulty filter."""
|
| | cleanup_games()
|
| |
|
| | if seed:
|
| | secret_word = seed_to_word_filtered(seed, difficulty)
|
| | else:
|
| | seed = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6))
|
| | secret_word = seed_to_word_filtered(seed, difficulty)
|
| |
|
| | game_id = secrets.token_urlsafe(12)
|
| | GAMES[game_id] = create_game(secret_word, seed)
|
| | return NewGameResponse(game_id=game_id, vocab_size=len(WORDS), seed=seed)
|
| |
|
| |
|
| | @app.get("/api/game/{game_id}", response_model=GameStateResponse)
|
| | def get_game_state(game_id: str):
|
| | """Retrieve current game state (for resuming after refresh)."""
|
| | game = _get_game_or_404(game_id) |
| |
|
| | meta = game.get("meta", {})
|
| | hypernyms = meta.get("hypernyms", [])
|
| | revealed_categories = hypernyms[:game.get("category_hints_used", 0)]
|
| |
|
| |
|
| | conceptnet_relations = meta.get("conceptnet", {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | cn_available = [r for r in cn_order if r in conceptnet_relations and conceptnet_relations[r]]
|
| | cn_used = game.get("conceptnet_hints_used", 0)
|
| | revealed_cn = [{"relation": cn_available[i], "values": conceptnet_relations[cn_available[i]]} for i in range(min(cn_used, len(cn_available)))]
|
| |
|
| |
|
| | definition = None
|
| | definition_done = False
|
| | def_words_revealed = game.get("definition_words_revealed", 0)
|
| | raw_defs = meta.get("definitions", [])
|
| | if def_words_revealed > 0 and raw_defs:
|
| | definition, total_content = _build_progressive_definition(raw_defs[0], game["secret_word"], def_words_revealed)
|
| | definition_done = def_words_revealed >= total_content
|
| |
|
| |
|
| | concreteness_label = None
|
| | if game.get("concreteness_revealed"):
|
| | rating = meta.get("concreteness")
|
| | if rating is not None:
|
| | concreteness_label = _concreteness_label(rating)
|
| |
|
| | vad_label = _vad_label(meta.get("vad", {})) if game.get("vad_revealed") and meta.get("vad") else None
|
| | sensorimotor_label = (
|
| | _sensorimotor_label(meta.get("sensorimotor", {}))
|
| | if game.get("sensorimotor_revealed") and meta.get("sensorimotor")
|
| | else None
|
| | )
|
| | glasgow_label = _glasgow_label(meta.get("glasgow", {})) if game.get("glasgow_revealed") and meta.get("glasgow") else None
|
| |
|
| |
|
| | conceptnet_relations = meta.get("conceptnet", {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | cn_available = [r for r in cn_order if r in conceptnet_relations and conceptnet_relations[r]]
|
| | has_conceptnet = len(cn_available) > game.get("conceptnet_hints_used", 0)
|
| | has_categories = len(hypernyms) > game.get("category_hints_used", 0)
|
| | has_vad = PHASE3_HINT_ENABLED["vad"] and bool(meta.get("vad")) and not game.get("vad_revealed")
|
| | has_sensorimotor = PHASE3_HINT_ENABLED["sensorimotor"] and bool(meta.get("sensorimotor")) and not game.get("sensorimotor_revealed")
|
| | has_glasgow = PHASE3_HINT_ENABLED["glasgow"] and bool(meta.get("glasgow")) and not game.get("glasgow_revealed")
|
| |
|
| | return GameStateResponse(
|
| | game_id=game_id,
|
| | vocab_size=len(WORDS),
|
| | seed=game["seed"],
|
| | guesses=game["guesses"],
|
| | hints_used=game["hints_used"],
|
| | total_hints=game.get("total_hints", 0),
|
| | solved=game["solved"],
|
| | gave_up=game["gave_up"],
|
| | secret_word=game["secret_word"] if game["solved"] or game["gave_up"] else None,
|
| | pos_revealed=game.get("pos_revealed", False),
|
| | pos=meta.get("pos") if game.get("pos_revealed") else None,
|
| | category_hints=revealed_categories,
|
| | difficulty=game.get("difficulty"),
|
| | definition=definition,
|
| | definition_done=definition_done,
|
| | concreteness_label=concreteness_label,
|
| | vad_label=vad_label,
|
| | sensorimotor_label=sensorimotor_label,
|
| | glasgow_label=glasgow_label,
|
| | conceptnet_hints=revealed_cn,
|
| | has_definition=bool(raw_defs),
|
| | has_concreteness=meta.get("concreteness") is not None,
|
| | has_vad=has_vad,
|
| | has_sensorimotor=has_sensorimotor,
|
| | has_glasgow=has_glasgow,
|
| | has_conceptnet=has_conceptnet,
|
| | has_categories=has_categories,
|
| | )
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/guess", response_model=GuessResponse)
|
| | def make_guess(game_id: str, req: GuessRequest):
|
| | """Submit a guess and get the similarity score."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| |
|
| | word = req.word.strip().lower()
|
| | if not word:
|
| | raise HTTPException(400, "Empty guess")
|
| |
|
| |
|
| | guess_idx = WORD_TO_IDX.get(word)
|
| | if guess_idx is None:
|
| | raise HTTPException(422, "not in our dictionary — try a synonym")
|
| |
|
| | existing = next((g for g in game["guesses"] if not g.get("isHint") and g["word"] == word), None)
|
| | if existing:
|
| | return GuessResponse(
|
| | word=word,
|
| | score=existing["score"],
|
| | rank=existing["rank"],
|
| | guess_number=existing.get("guess_num", 0),
|
| | total_guesses=game.get("guess_count", 0),
|
| | solved=game.get("solved", False),
|
| | )
|
| |
|
| |
|
| | solved = word == game["secret_word"]
|
| | sims = game["sims"]
|
| | guess_sim = float(sims[guess_idx])
|
| | rank = int((sims > guess_sim).sum()) + 1
|
| | score = rank_to_score(rank, len(WORDS))
|
| |
|
| |
|
| | game["guess_count"] = game.get("guess_count", 0) + 1
|
| | game["guesses"].append(
|
| | {
|
| | "word": word,
|
| | "score": score,
|
| | "rank": rank,
|
| | "isHint": False,
|
| | "guess_num": game["guess_count"],
|
| | }
|
| | )
|
| | if solved:
|
| | game["solved"] = True
|
| | game["winner"] = "solo"
|
| |
|
| | return GuessResponse(
|
| | word=word,
|
| | score=score,
|
| | rank=rank,
|
| | guess_number=game["guess_count"],
|
| | total_guesses=game["guess_count"],
|
| | solved=solved,
|
| | )
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint", response_model=HintResponse)
|
| | def get_hint(game_id: str):
|
| | """Reveal a hint word at a predetermined rank."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | hints_used = game["hints_used"]
|
| | if hints_used >= len(HINT_RANKS):
|
| | raise HTTPException(400, "No more hints available")
|
| | rank = HINT_RANKS[hints_used]
|
| | hint_word = game["hint_words"][hints_used]
|
| | score = rank_to_score(rank, len(WORDS))
|
| | game["hints_used"] = hints_used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | already_guessed = any(g["word"] == hint_word for g in game["guesses"])
|
| | if not already_guessed:
|
| | game["guesses"].append({"word": hint_word, "score": score, "rank": rank, "isHint": True})
|
| | return HintResponse(word=hint_word, rank=rank, score=score, hints_used=game["hints_used"], already_guessed=already_guessed)
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/pos", response_model=PosHintResponse)
|
| | def get_pos_hint(game_id: str):
|
| | """Reveal the part of speech of the secret word."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | if game.get("pos_revealed"):
|
| |
|
| | return PosHintResponse(pos=game["meta"].get("pos", "unknown"), total_hints=game.get("total_hints", 0))
|
| | meta = game.get("meta", {})
|
| | pos = meta.get("pos", "unknown")
|
| | game["pos_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return PosHintResponse(pos=pos, total_hints=game["total_hints"])
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/category", response_model=CategoryHintResponse)
|
| | def get_category_hint(game_id: str):
|
| | """Reveal the next category (hypernym) level for the secret word."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | meta = game.get("meta", {})
|
| | hypernyms = meta.get("hypernyms", [])
|
| | used = game.get("category_hints_used", 0)
|
| | if used >= len(hypernyms):
|
| | raise HTTPException(400, "No more category hints available")
|
| | category = hypernyms[used]
|
| | game["category_hints_used"] = used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return CategoryHintResponse(category=category, category_hints_used=game["category_hints_used"], total_hints=game["total_hints"], has_more=game["category_hints_used"] < len(hypernyms))
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/definition", response_model=DefinitionHintResponse)
|
| | def get_definition_hint(game_id: str):
|
| | """Progressively reveal words in the definition. Each call reveals one more word."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | meta = game.get("meta", {})
|
| | definitions = meta.get("definitions", [])
|
| | if not definitions:
|
| | raise HTTPException(400, "No definition available for this word")
|
| | current = game.get("definition_words_revealed", 0)
|
| |
|
| | _, total_content = _build_progressive_definition(definitions[0], game["secret_word"], 0)
|
| | if current >= total_content:
|
| | raise HTTPException(400, "Definition fully revealed")
|
| | game["definition_words_revealed"] = current + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | display, _ = _build_progressive_definition(definitions[0], game["secret_word"], current + 1)
|
| | done = (current + 1) >= total_content
|
| | return DefinitionHintResponse(definition=display, total_hints=game["total_hints"], done=done)
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/concreteness", response_model=ConcretenessHintResponse)
|
| | def get_concreteness_hint(game_id: str):
|
| | """Reveal how concrete or abstract the secret word is."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | if game.get("concreteness_revealed"):
|
| | raise HTTPException(400, "Concreteness already revealed")
|
| | meta = game.get("meta", {})
|
| | rating = meta.get("concreteness")
|
| | if rating is None:
|
| | raise HTTPException(400, "No concreteness data available for this word")
|
| | label = _concreteness_label(rating)
|
| | game["concreteness_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return ConcretenessHintResponse(concreteness=label, total_hints=game["total_hints"])
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/vad", response_model=LabelHintResponse)
|
| | def get_vad_hint(game_id: str):
|
| | """Reveal affective profile (valence/arousal/dominance) when enabled."""
|
| | if not PHASE3_HINT_ENABLED["vad"]:
|
| | raise HTTPException(400, "VAD hints disabled due low dataset coverage")
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | if game.get("vad_revealed"):
|
| | raise HTTPException(400, "VAD hint already revealed")
|
| | meta = game.get("meta", {})
|
| | vad = meta.get("vad")
|
| | if not vad:
|
| | raise HTTPException(400, "No VAD data available for this word")
|
| | label = _vad_label(vad)
|
| | game["vad_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return LabelHintResponse(label=label, total_hints=game["total_hints"])
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/sensorimotor", response_model=LabelHintResponse)
|
| | def get_sensorimotor_hint(game_id: str):
|
| | """Reveal dominant sensorimotor modality when enabled."""
|
| | if not PHASE3_HINT_ENABLED["sensorimotor"]:
|
| | raise HTTPException(400, "Sensorimotor hints disabled due low dataset coverage")
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | if game.get("sensorimotor_revealed"):
|
| | raise HTTPException(400, "Sensorimotor hint already revealed")
|
| | meta = game.get("meta", {})
|
| | sensorimotor = meta.get("sensorimotor")
|
| | if not sensorimotor:
|
| | raise HTTPException(400, "No sensorimotor data available for this word")
|
| | label = _sensorimotor_label(sensorimotor)
|
| | game["sensorimotor_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return LabelHintResponse(label=label, total_hints=game["total_hints"])
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/glasgow", response_model=LabelHintResponse)
|
| | def get_glasgow_hint(game_id: str):
|
| | """Reveal compact psycholinguistic profile from Glasgow norms when enabled."""
|
| | if not PHASE3_HINT_ENABLED["glasgow"]:
|
| | raise HTTPException(400, "Glasgow hints disabled due low dataset coverage")
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | if game.get("glasgow_revealed"):
|
| | raise HTTPException(400, "Glasgow hint already revealed")
|
| | meta = game.get("meta", {})
|
| | glasgow = meta.get("glasgow")
|
| | if not glasgow:
|
| | raise HTTPException(400, "No Glasgow data available for this word")
|
| | label = _glasgow_label(glasgow)
|
| | game["glasgow_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return LabelHintResponse(label=label, total_hints=game["total_hints"])
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/hint/conceptnet", response_model=ConceptNetHintResponse)
|
| | def get_conceptnet_hint(game_id: str):
|
| | """Reveal the next ConceptNet relation for the secret word."""
|
| | game = _get_game_or_404(game_id, require_active=True)
|
| | meta = game.get("meta", {})
|
| | conceptnet = meta.get("conceptnet", {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | available = [r for r in cn_order if r in conceptnet and conceptnet[r]]
|
| | used = game.get("conceptnet_hints_used", 0)
|
| | if used >= len(available):
|
| | raise HTTPException(400, "No more semantic clues available")
|
| | relation = available[used]
|
| | values = conceptnet[relation]
|
| | game["conceptnet_hints_used"] = used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | return ConceptNetHintResponse(relation=relation, values=values, total_hints=game["total_hints"], has_more=game["conceptnet_hints_used"] < len(available))
|
| |
|
| |
|
| | @app.post("/api/game/{game_id}/give-up", response_model=GiveUpResponse)
|
| | def give_up(game_id: str):
|
| | """Reveal the secret word."""
|
| | game = _get_game_or_404(game_id)
|
| | if not game.get("gave_up"):
|
| | game["gave_up"] = True
|
| | if not game.get("gave_up_by"):
|
| | game["gave_up_by"] = "solo"
|
| | return GiveUpResponse(
|
| | secret_word=game["secret_word"],
|
| | total_guesses=game.get("guess_count", 0),
|
| | )
|
| |
|
| |
|
| | @app.get("/api/health") |
| | def health(): |
| | cleanup_games() |
| | cleanup_lobbies() |
| | return { |
| | "status": "ok", |
| | "vocab_size": len(WORDS), |
| | "active_games": len(GAMES),
|
| | "phase3_hint_coverage": PHASE3_COVERAGE,
|
| | "phase3_hint_enabled": PHASE3_HINT_ENABLED,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | LOBBIES: dict[str, dict] = {}
|
| | MAX_LOBBIES = 200
|
| | LOBBY_TTL_SECONDS = 7200
|
| |
|
| |
|
| | def cleanup_lobbies():
|
| | now = time.time()
|
| | expired = [lid for lid, lb in LOBBIES.items() if now - lb.get("updated_at", lb["created_at"]) > LOBBY_TTL_SECONDS]
|
| | for lid in expired:
|
| | del LOBBIES[lid]
|
| | if len(LOBBIES) > MAX_LOBBIES:
|
| | by_age = sorted(LOBBIES.items(), key=lambda x: x[1].get("updated_at", x[1]["created_at"]))
|
| | for lid, _ in by_age[:len(LOBBIES) - MAX_LOBBIES]:
|
| | del LOBBIES[lid]
|
| |
|
| |
|
| | def generate_lobby_code() -> str:
|
| | """Generate a 6-char uppercase alphanumeric lobby code."""
|
| | chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
| | return "".join(random.choices(chars, k=6))
|
| |
|
| |
|
| | def _touch_lobby(lobby: dict) -> None:
|
| | lobby["updated_at"] = time.time()
|
| |
|
| |
|
| | def _lobby_member_items(lobby: dict) -> list[tuple[str, dict]]:
|
| | return sorted(lobby["members"].items(), key=lambda kv: kv[1].get("joined_at", 0.0))
|
| |
|
| |
|
| | def _lobby_player_names(lobby: dict) -> list[str]:
|
| | return [member["name"] for _, member in _lobby_member_items(lobby)]
|
| |
|
| |
|
| | def _lobby_host_name(lobby: dict) -> str:
|
| | host_id = lobby.get("host_player_id")
|
| | if host_id and host_id in lobby["members"]:
|
| | return lobby["members"][host_id]["name"]
|
| | return "Host"
|
| |
|
| |
|
| | def _remove_lobby_member(lobby: dict, player_id: str) -> str | None:
|
| | member = lobby["members"].pop(player_id, None)
|
| | lobby["connections"].pop(player_id, None)
|
| | if not member:
|
| | return None
|
| | if lobby.get("host_player_id") == player_id:
|
| | next_host = _lobby_member_items(lobby)[0][0] if lobby["members"] else None
|
| | lobby["host_player_id"] = next_host
|
| | _touch_lobby(lobby)
|
| | return member["name"]
|
| |
|
| |
|
| | async def broadcast(lobby: dict, message: dict, exclude: str | None = None):
|
| | """Send a JSON message to all connected players in a lobby."""
|
| | dead: list[str] = []
|
| | for pid, ws in lobby["connections"].items():
|
| | if pid == exclude:
|
| | continue
|
| | try:
|
| | await ws.send_json(message)
|
| | except Exception:
|
| | dead.append(pid)
|
| | for pid in dead:
|
| | lobby["connections"].pop(pid, None)
|
| | member = lobby["members"].get(pid)
|
| | if member:
|
| | member["last_seen"] = time.time()
|
| |
|
| |
|
| | class CreateLobbyRequest(BaseModel):
|
| | host_name: str
|
| | difficulty: str | None = None
|
| |
|
| |
|
| | class CreateLobbyResponse(BaseModel):
|
| | code: str
|
| | host_name: str
|
| | player_id: str
|
| |
|
| |
|
| | class LobbyStateResponse(BaseModel):
|
| | code: str
|
| | host: str
|
| | players: list[str]
|
| | difficulty: str | None
|
| | game_active: bool
|
| | game_id: str | None
|
| |
|
| |
|
| | @app.post("/api/lobby/create", response_model=CreateLobbyResponse)
|
| | def create_lobby(req: CreateLobbyRequest):
|
| | cleanup_lobbies()
|
| | code = generate_lobby_code()
|
| | while code in LOBBIES:
|
| | code = generate_lobby_code()
|
| | name = req.host_name.strip()[:20] or "Host"
|
| | now = time.time()
|
| | player_id = secrets.token_urlsafe(8)
|
| | LOBBIES[code] = {
|
| | "id": code,
|
| | "host_player_id": player_id,
|
| | "members": {
|
| | player_id: {
|
| | "name": name,
|
| | "joined_at": now,
|
| | "last_seen": now,
|
| | }
|
| | },
|
| | "connections": {},
|
| | "game_id": None,
|
| | "difficulty": req.difficulty,
|
| | "created_at": now,
|
| | "updated_at": now,
|
| | }
|
| | return CreateLobbyResponse(code=code, host_name=name, player_id=player_id)
|
| |
|
| |
|
| | @app.get("/api/lobby/{code}", response_model=LobbyStateResponse)
|
| | def get_lobby(code: str): |
| | cleanup_lobbies() |
| | cleanup_games() |
| | code = code.upper() |
| | lobby = LOBBIES.get(code) |
| | if not lobby: |
| | raise HTTPException(404, "Lobby not found") |
| | if lobby.get("game_id") and lobby["game_id"] not in GAMES: |
| | lobby["game_id"] = None |
| | game = GAMES.get(lobby["game_id"]) if lobby.get("game_id") else None |
| | return LobbyStateResponse(
|
| | code=code,
|
| | host=_lobby_host_name(lobby),
|
| | players=_lobby_player_names(lobby),
|
| | difficulty=lobby["difficulty"],
|
| | game_active=bool(game and not game.get("solved") and not game.get("gave_up")),
|
| | game_id=lobby["game_id"],
|
| | )
|
| |
|
| |
|
| | async def _send_lobby_game_snapshot(ws: WebSocket, lobby: dict, game_id: str, game: dict):
|
| | _ensure_guess_counters(game)
|
| | meta = game.get("meta", {})
|
| | hypernyms = meta.get("hypernyms", [])
|
| | await ws.send_json({
|
| | "type": "game_started",
|
| | "seed": game["seed"],
|
| | "difficulty": lobby.get("difficulty"),
|
| | "vocab_size": len(WORDS),
|
| | "game_id": game_id,
|
| | "has_definition": bool(meta.get("definitions")),
|
| | "has_concreteness": meta.get("concreteness") is not None,
|
| | "has_vad": PHASE3_HINT_ENABLED["vad"] and bool(meta.get("vad")),
|
| | "has_sensorimotor": PHASE3_HINT_ENABLED["sensorimotor"] and bool(meta.get("sensorimotor")),
|
| | "has_glasgow": PHASE3_HINT_ENABLED["glasgow"] and bool(meta.get("glasgow")),
|
| | "has_conceptnet": bool([r for r in ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"] if meta.get("conceptnet", {}).get(r)]),
|
| | "has_categories": bool(meta.get("hypernyms")),
|
| | })
|
| |
|
| | for g in game["guesses"]:
|
| | if g.get("isHint"):
|
| | await ws.send_json({
|
| | "type": "hint_result",
|
| | "player": g.get("player", "?"),
|
| | "hint_type": "word",
|
| | "word": g["word"],
|
| | "score": g["score"],
|
| | "rank": g["rank"],
|
| | "hints_used": game["hints_used"],
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| | else:
|
| | await ws.send_json({
|
| | "type": "guess_result",
|
| | "player": g.get("player", "?"),
|
| | "word": g["word"],
|
| | "score": g["score"],
|
| | "rank": g["rank"],
|
| | "guess_num": g.get("guess_num", 0),
|
| | "duplicate": False,
|
| | })
|
| |
|
| | if game.get("pos_revealed"):
|
| | await ws.send_json({"type": "hint_pos", "player": "?", "pos": meta.get("pos", "unknown"), "total_hints": game.get("total_hints", 0)})
|
| | if game.get("concreteness_revealed") and meta.get("concreteness") is not None:
|
| | await ws.send_json({"type": "hint_concreteness", "player": "?", "label": _concreteness_label(meta["concreteness"]), "total_hints": game.get("total_hints", 0)})
|
| | if game.get("vad_revealed") and meta.get("vad"):
|
| | await ws.send_json({"type": "hint_vad", "player": "?", "label": _vad_label(meta["vad"]), "total_hints": game.get("total_hints", 0)})
|
| | if game.get("sensorimotor_revealed") and meta.get("sensorimotor"):
|
| | await ws.send_json({"type": "hint_sensorimotor", "player": "?", "label": _sensorimotor_label(meta["sensorimotor"]), "total_hints": game.get("total_hints", 0)})
|
| | if game.get("glasgow_revealed") and meta.get("glasgow"):
|
| | await ws.send_json({"type": "hint_glasgow", "player": "?", "label": _glasgow_label(meta["glasgow"]), "total_hints": game.get("total_hints", 0)})
|
| |
|
| | cat_used = game.get("category_hints_used", 0)
|
| | if cat_used > 0:
|
| | await ws.send_json({
|
| | "type": "hint_category",
|
| | "player": "?",
|
| | "category": hypernyms[cat_used - 1],
|
| | "categories": hypernyms[:cat_used],
|
| | "has_categories": cat_used < len(hypernyms),
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| |
|
| | def_revealed = game.get("definition_words_revealed", 0)
|
| | raw_defs = meta.get("definitions", [])
|
| | if def_revealed > 0 and raw_defs:
|
| | display, total = _build_progressive_definition(raw_defs[0], game["secret_word"], def_revealed)
|
| | await ws.send_json({"type": "hint_definition", "player": "?", "definition": display, "done": def_revealed >= total, "total_hints": game.get("total_hints", 0)})
|
| |
|
| | cn_used = game.get("conceptnet_hints_used", 0)
|
| | conceptnet = meta.get("conceptnet", {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | cn_available = [r for r in cn_order if r in conceptnet and conceptnet[r]]
|
| | for i in range(min(cn_used, len(cn_available))):
|
| | await ws.send_json({
|
| | "type": "hint_conceptnet",
|
| | "player": "?",
|
| | "relation": cn_available[i],
|
| | "values": conceptnet[cn_available[i]],
|
| | "has_more": i + 1 < len(cn_available),
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| |
|
| | if game.get("solved"):
|
| | await ws.send_json({
|
| | "type": "game_over",
|
| | "winner": game.get("winner") or "?",
|
| | "word": game["secret_word"],
|
| | "guesses": game.get("guess_count", 0),
|
| | })
|
| | if game.get("gave_up"):
|
| | await ws.send_json({
|
| | "type": "gave_up",
|
| | "player": game.get("gave_up_by") or "?",
|
| | "word": game["secret_word"],
|
| | })
|
| |
|
| |
|
| | async def _handle_lobby_ws(lobby: dict, player_id: str, ws: WebSocket):
|
| | """Main WebSocket handler for a lobby participant."""
|
| | code = lobby["id"]
|
| |
|
| | while True:
|
| | try:
|
| | data = await ws.receive_json()
|
| | except WebSocketDisconnect:
|
| | break
|
| | except Exception:
|
| | break
|
| |
|
| | member = lobby["members"].get(player_id)
|
| | if not member:
|
| | break
|
| | name = member["name"]
|
| | is_host = player_id == lobby.get("host_player_id")
|
| | _touch_lobby(lobby)
|
| |
|
| | msg_type = data.get("type")
|
| | cleanup_games()
|
| | game_id = lobby.get("game_id")
|
| | game = GAMES.get(game_id) if game_id else None
|
| | if game:
|
| | _ensure_guess_counters(game)
|
| | elif game_id and game is None:
|
| | lobby["game_id"] = None
|
| |
|
| | if msg_type == "leave_lobby":
|
| | removed_name = _remove_lobby_member(lobby, player_id)
|
| | if removed_name and lobby["members"]:
|
| | await broadcast(lobby, {
|
| | "type": "player_left",
|
| | "name": removed_name,
|
| | "players": _lobby_player_names(lobby),
|
| | "host": _lobby_host_name(lobby),
|
| | })
|
| | if not lobby["members"]:
|
| | LOBBIES.pop(code, None)
|
| | await ws.close()
|
| | break
|
| |
|
| | if msg_type == "start_game" or msg_type == "new_round":
|
| | if not is_host:
|
| | await ws.send_json({"type": "error", "message": "Only the host can start a game"})
|
| | continue
|
| | cleanup_games()
|
| | diff = data.get("difficulty") or lobby.get("difficulty")
|
| | lobby["difficulty"] = diff
|
| | seed = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=6))
|
| | secret_word = seed_to_word_filtered(seed, diff)
|
| | new_game_id = secrets.token_urlsafe(12)
|
| | GAMES[new_game_id] = create_game(secret_word, seed)
|
| | lobby["game_id"] = new_game_id
|
| | _touch_lobby(lobby)
|
| | meta = SECRET_META.get(secret_word, {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | cn_available = [r for r in cn_order if meta.get("conceptnet", {}).get(r)]
|
| | hypernyms = meta.get("hypernyms", [])
|
| | await broadcast(lobby, {
|
| | "type": "game_started",
|
| | "seed": seed,
|
| | "difficulty": diff,
|
| | "vocab_size": len(WORDS),
|
| | "game_id": new_game_id,
|
| | "has_definition": bool(meta.get("definitions")),
|
| | "has_concreteness": meta.get("concreteness") is not None,
|
| | "has_vad": PHASE3_HINT_ENABLED["vad"] and bool(meta.get("vad")),
|
| | "has_sensorimotor": PHASE3_HINT_ENABLED["sensorimotor"] and bool(meta.get("sensorimotor")),
|
| | "has_glasgow": PHASE3_HINT_ENABLED["glasgow"] and bool(meta.get("glasgow")),
|
| | "has_conceptnet": bool(cn_available),
|
| | "has_categories": bool(hypernyms),
|
| | })
|
| |
|
| | auto_hints = max(0, int(data.get("start_hints", 0)))
|
| | new_game = GAMES[new_game_id]
|
| | for _ in range(auto_hints):
|
| | h_used = new_game["hints_used"]
|
| | if h_used >= len(HINT_RANKS):
|
| | break
|
| | rank = HINT_RANKS[h_used]
|
| | hint_word = new_game["hint_words"][h_used]
|
| | score = rank_to_score(rank, len(WORDS))
|
| | new_game["hints_used"] = h_used + 1
|
| | new_game["total_hints"] = new_game.get("total_hints", 0) + 1
|
| | already_guessed = any(g["word"] == hint_word for g in new_game["guesses"])
|
| | if not already_guessed:
|
| | new_game["guesses"].append({"word": hint_word, "score": score, "rank": rank, "isHint": True, "player": "(auto)"})
|
| | await broadcast(lobby, {
|
| | "type": "hint_result",
|
| | "player": "(auto)",
|
| | "hint_type": "word",
|
| | "word": hint_word,
|
| | "score": score,
|
| | "rank": rank,
|
| | "hints_used": new_game["hints_used"],
|
| | "total_hints": new_game.get("total_hints", 0),
|
| | "already_guessed": already_guessed,
|
| | })
|
| |
|
| | elif msg_type == "guess":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | await ws.send_json({"type": "error", "message": "No active game"})
|
| | continue
|
| | word = data.get("word", "").strip().lower()
|
| | if not word:
|
| | continue
|
| | guess_idx = WORD_TO_IDX.get(word)
|
| | if guess_idx is None:
|
| | await ws.send_json({"type": "error", "message": f'"{word}" not in dictionary'})
|
| | continue
|
| | if any(g["word"] == word for g in game["guesses"]):
|
| | existing = next(g for g in game["guesses"] if g["word"] == word)
|
| | await ws.send_json({
|
| | "type": "guess_result",
|
| | "player": name,
|
| | "word": word,
|
| | "score": existing["score"],
|
| | "rank": existing["rank"],
|
| | "guess_num": existing.get("guess_num", 0),
|
| | "duplicate": True,
|
| | })
|
| | continue
|
| | solved = word == game["secret_word"]
|
| | sims = game["sims"]
|
| | guess_sim = float(sims[guess_idx])
|
| | rank = int((sims > guess_sim).sum()) + 1
|
| | score = rank_to_score(rank, len(WORDS))
|
| | game["guess_count"] = game.get("guess_count", 0) + 1
|
| | game["guesses"].append({"word": word, "score": score, "rank": rank, "isHint": False, "player": name, "guess_num": game["guess_count"]})
|
| | if solved:
|
| | game["solved"] = True
|
| | game["winner"] = name
|
| | result = {
|
| | "type": "guess_result",
|
| | "player": name,
|
| | "word": word,
|
| | "score": score,
|
| | "rank": rank,
|
| | "guess_num": game["guess_count"],
|
| | "duplicate": False,
|
| | }
|
| | await broadcast(lobby, result)
|
| | if solved:
|
| | await broadcast(lobby, {"type": "game_over", "winner": name, "word": word, "guesses": game.get("guess_count", 0)})
|
| |
|
| | elif msg_type == "hint":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | await ws.send_json({"type": "error", "message": "No active game"})
|
| | continue
|
| | hints_used = game["hints_used"]
|
| | if hints_used >= len(HINT_RANKS):
|
| | await ws.send_json({"type": "error", "message": "No more hints"})
|
| | continue
|
| | rank = HINT_RANKS[hints_used]
|
| | hint_word = game["hint_words"][hints_used]
|
| | score = rank_to_score(rank, len(WORDS))
|
| | game["hints_used"] = hints_used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | already_guessed = any(g["word"] == hint_word for g in game["guesses"])
|
| | if not already_guessed:
|
| | game["guesses"].append({"word": hint_word, "score": score, "rank": rank, "isHint": True, "player": name})
|
| | await broadcast(lobby, {
|
| | "type": "hint_result",
|
| | "player": name,
|
| | "hint_type": "word",
|
| | "word": hint_word,
|
| | "score": score,
|
| | "rank": rank,
|
| | "hints_used": game["hints_used"],
|
| | "total_hints": game.get("total_hints", 0),
|
| | "already_guessed": already_guessed,
|
| | })
|
| |
|
| | elif msg_type == "hint_pos":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | meta = game.get("meta", {})
|
| | pos = meta.get("pos", "unknown")
|
| | if not game.get("pos_revealed"):
|
| | game["pos_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {"type": "hint_pos", "player": name, "pos": pos, "total_hints": game.get("total_hints", 0)})
|
| |
|
| | elif msg_type == "hint_category":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | meta = game.get("meta", {})
|
| | hypernyms = meta.get("hypernyms", [])
|
| | used = game.get("category_hints_used", 0)
|
| | if used >= len(hypernyms):
|
| | await ws.send_json({"type": "error", "message": "No more category hints"})
|
| | continue
|
| | category = hypernyms[used]
|
| | game["category_hints_used"] = used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {
|
| | "type": "hint_category",
|
| | "player": name,
|
| | "category": category,
|
| | "categories": hypernyms[:game["category_hints_used"]],
|
| | "has_categories": used + 1 < len(hypernyms),
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| |
|
| | elif msg_type == "hint_definition":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | meta = game.get("meta", {})
|
| | definitions = meta.get("definitions", [])
|
| | if not definitions:
|
| | await ws.send_json({"type": "error", "message": "No definition available"})
|
| | continue
|
| | current = game.get("definition_words_revealed", 0)
|
| | _, total_content = _build_progressive_definition(definitions[0], game["secret_word"], 0)
|
| | if current >= total_content:
|
| | await ws.send_json({"type": "error", "message": "Definition fully revealed"})
|
| | continue
|
| | game["definition_words_revealed"] = current + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | display, _ = _build_progressive_definition(definitions[0], game["secret_word"], current + 1)
|
| | done = (current + 1) >= total_content
|
| | await broadcast(lobby, {
|
| | "type": "hint_definition",
|
| | "player": name,
|
| | "definition": display,
|
| | "done": done,
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| |
|
| | elif msg_type == "hint_concreteness":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | meta = game.get("meta", {})
|
| | rating = meta.get("concreteness")
|
| | if rating is None:
|
| | await ws.send_json({"type": "error", "message": "No concreteness data"})
|
| | continue
|
| | if not game.get("concreteness_revealed"):
|
| | game["concreteness_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | label = _concreteness_label(rating)
|
| | await broadcast(lobby, {"type": "hint_concreteness", "player": name, "label": label, "total_hints": game.get("total_hints", 0)})
|
| |
|
| | elif msg_type == "hint_vad":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | if not PHASE3_HINT_ENABLED["vad"]:
|
| | await ws.send_json({"type": "error", "message": "VAD hints disabled"})
|
| | continue
|
| | meta = game.get("meta", {})
|
| | vad = meta.get("vad")
|
| | if not vad:
|
| | await ws.send_json({"type": "error", "message": "No VAD data"})
|
| | continue
|
| | if not game.get("vad_revealed"):
|
| | game["vad_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {"type": "hint_vad", "player": name, "label": _vad_label(vad), "total_hints": game.get("total_hints", 0)})
|
| |
|
| | elif msg_type == "hint_sensorimotor":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | if not PHASE3_HINT_ENABLED["sensorimotor"]:
|
| | await ws.send_json({"type": "error", "message": "Sensorimotor hints disabled"})
|
| | continue
|
| | meta = game.get("meta", {})
|
| | sensorimotor = meta.get("sensorimotor")
|
| | if not sensorimotor:
|
| | await ws.send_json({"type": "error", "message": "No sensorimotor data"})
|
| | continue
|
| | if not game.get("sensorimotor_revealed"):
|
| | game["sensorimotor_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {"type": "hint_sensorimotor", "player": name, "label": _sensorimotor_label(sensorimotor), "total_hints": game.get("total_hints", 0)})
|
| |
|
| | elif msg_type == "hint_glasgow":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | if not PHASE3_HINT_ENABLED["glasgow"]:
|
| | await ws.send_json({"type": "error", "message": "Glasgow hints disabled"})
|
| | continue
|
| | meta = game.get("meta", {})
|
| | glasgow = meta.get("glasgow")
|
| | if not glasgow:
|
| | await ws.send_json({"type": "error", "message": "No Glasgow data"})
|
| | continue
|
| | if not game.get("glasgow_revealed"):
|
| | game["glasgow_revealed"] = True
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {"type": "hint_glasgow", "player": name, "label": _glasgow_label(glasgow), "total_hints": game.get("total_hints", 0)})
|
| |
|
| | elif msg_type == "hint_conceptnet":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | meta = game.get("meta", {})
|
| | conceptnet = meta.get("conceptnet", {})
|
| | cn_order = ["UsedFor", "HasProperty", "AtLocation", "IsA", "HasA"]
|
| | available = [r for r in cn_order if r in conceptnet and conceptnet[r]]
|
| | used = game.get("conceptnet_hints_used", 0)
|
| | if used >= len(available):
|
| | await ws.send_json({"type": "error", "message": "No more semantic clues"})
|
| | continue
|
| | relation = available[used]
|
| | values = conceptnet[relation]
|
| | game["conceptnet_hints_used"] = used + 1
|
| | game["total_hints"] = game.get("total_hints", 0) + 1
|
| | await broadcast(lobby, {
|
| | "type": "hint_conceptnet",
|
| | "player": name,
|
| | "relation": relation,
|
| | "values": values,
|
| | "has_more": game["conceptnet_hints_used"] < len(available),
|
| | "total_hints": game.get("total_hints", 0),
|
| | })
|
| |
|
| | elif msg_type == "give_up":
|
| | if not game or game["solved"] or game["gave_up"]:
|
| | continue
|
| | game["gave_up"] = True
|
| | game["gave_up_by"] = name
|
| | await broadcast(lobby, {"type": "gave_up", "player": name, "word": game["secret_word"]})
|
| |
|
| | else:
|
| | await ws.send_json({"type": "error", "message": f"Unknown message type: {msg_type}"})
|
| |
|
| |
|
| | @app.websocket("/api/lobby/{code}/ws")
|
| | async def lobby_websocket(ws: WebSocket, code: str, name: str = Query(...), player_id: str | None = Query(None)):
|
| | cleanup_lobbies()
|
| | code = code.upper()
|
| | lobby = LOBBIES.get(code)
|
| | if not lobby:
|
| | await ws.close(code=4004, reason="Lobby not found")
|
| | return
|
| |
|
| | name = name.strip()[:20] or "Player"
|
| | reconnecting = bool(player_id and player_id in lobby["members"])
|
| | if reconnecting and player_id:
|
| | name = lobby["members"][player_id]["name"]
|
| | lobby["members"][player_id]["last_seen"] = time.time()
|
| | else:
|
| | existing_names = {m["name"] for m in lobby["members"].values()}
|
| | base_name = name
|
| | counter = 2
|
| | while name in existing_names:
|
| | name = f"{base_name}{counter}"
|
| | counter += 1
|
| | player_id = secrets.token_urlsafe(8)
|
| | lobby["members"][player_id] = {
|
| | "name": name,
|
| | "joined_at": time.time(),
|
| | "last_seen": time.time(),
|
| | }
|
| | if lobby.get("host_player_id") is None:
|
| | lobby["host_player_id"] = player_id
|
| |
|
| | await ws.accept() |
| | existing_ws = lobby["connections"].get(player_id) |
| | if existing_ws is not None and existing_ws is not ws: |
| | try: |
| | await existing_ws.close() |
| | except Exception: |
| | pass |
| | lobby["connections"][player_id] = ws |
| | _touch_lobby(lobby) |
| |
|
| | await broadcast(
|
| | lobby,
|
| | {"type": "player_joined", "name": name, "players": _lobby_player_names(lobby), "host": _lobby_host_name(lobby)},
|
| | )
|
| |
|
| | cleanup_games()
|
| | game_id = lobby.get("game_id")
|
| | game = GAMES.get(game_id) if game_id else None
|
| | if game:
|
| | await _send_lobby_game_snapshot(ws, lobby, game_id, game)
|
| |
|
| | await ws.send_json({
|
| | "type": "welcome",
|
| | "name": name,
|
| | "host": _lobby_host_name(lobby),
|
| | "players": _lobby_player_names(lobby),
|
| | "code": code,
|
| | "player_id": player_id,
|
| | "reconnected": reconnecting,
|
| | })
|
| |
|
| | try: |
| | await _handle_lobby_ws(lobby, player_id, ws) |
| | finally: |
| | if lobby["connections"].get(player_id) is ws: |
| | lobby["connections"].pop(player_id, None) |
| | member = lobby["members"].get(player_id) |
| | if member: |
| | member["last_seen"] = time.time() |
| | _touch_lobby(lobby)
|
| | if not lobby["members"]:
|
| | LOBBIES.pop(code, None)
|
| |
|
| |
|
| |
|
| | _static = Path(__file__).parent / "static"
|
| | if _static.exists():
|
| | app.mount("/", StaticFiles(directory=str(_static), html=True), name="static")
|
| |
|