diff --git a/bot/bot.py b/bot/bot.py index 2272cce..2161817 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -159,6 +159,16 @@ async def start_game(self, ctx: commands.Context, difficulty: str = "") -> None: await ctx.send("Word list is empty. Cannot start game.") return + scorer = self._game_state.scorer + if scorer is not None: + words = [w for w in words if scorer.is_in_vocab(w)] + if not words: + await ctx.send( + "No playable words found for this difficulty " + "(all words are out of vocabulary). Check the word list." + ) + return + target = random.choice(words) self._game_state.start_new_game(target, diff) await ctx.send( diff --git a/game/engine.py b/game/engine.py index bdfbf0b..48d22cf 100644 --- a/game/engine.py +++ b/game/engine.py @@ -58,6 +58,25 @@ def is_loaded(self) -> bool: # Public API # ------------------------------------------------------------------ + def is_in_vocab(self, word: str) -> bool: + """Return ``True`` if *word* is present in the model vocabulary. + + The word is cleaned/normalised before lookup, matching the same + pre-processing applied by :meth:`score_guess`. + + Args: + word: The word to check. + + Returns: + ``True`` if the cleaned form of *word* maps to a vocabulary key. + + Raises: + RuntimeError: If the model has not been loaded yet. + """ + if self._model is None: + raise RuntimeError("Model not loaded. Call load() first.") + return self._cleaned_key_map.get(clean_word(word)) is not None + def similarity(self, word_a: str, word_b: str) -> float | None: """Return the cosine similarity between two words. diff --git a/game/state.py b/game/state.py index aa2fcdf..b176778 100644 --- a/game/state.py +++ b/game/state.py @@ -28,6 +28,10 @@ def score_guess(self, guess: str, target: str) -> float | None: """Return a similarity score in ``[0, 1]``, or ``None`` if unknown.""" ... + def is_in_vocab(self, word: str) -> bool: + """Return ``True`` if *word* is present in the model vocabulary.""" + ... + @dataclass class GuessEntry: @@ -82,6 +86,11 @@ def __init__(self, scorer: Scorer | None = None) -> None: self._history: list[GuessEntry] = [] self._is_found: bool = False + @property + def scorer(self) -> Scorer | None: + """Return the configured scorer, or ``None`` if none was provided.""" + return self._scorer + # ------------------------------------------------------------------ # Game lifecycle # ------------------------------------------------------------------ diff --git a/tests/test_commands.py b/tests/test_commands.py index ed2bf94..fc5be3c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -748,6 +748,55 @@ async def test_start_confirmation_message_contains_prefix(self): assert "!sx" in message +# --------------------------------------------------------------------------- +# OOV filtering in start_game +# --------------------------------------------------------------------------- + +class _OovAwareScorer: + """Scorer that returns None for words not in the valid set (simulates OOV).""" + + def __init__(self, valid_words: set[str]) -> None: + self._valid = valid_words + + def score_guess(self, guess: str, target: str) -> float | None: + from game.word_utils import clean_word + if clean_word(guess) in self._valid: + return 0.5 + return None + + def is_in_vocab(self, word: str) -> bool: + from game.word_utils import clean_word + return clean_word(word) in self._valid + + +@pytest.mark.asyncio +class TestStartOovFiltering: + async def test_all_words_oov_sends_error_and_aborts(self): + bot = _make_bot() + bot._game_state = GameState(scorer=_OovAwareScorer(set())) + ctx = _make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["chat", "licorne", "dragon"]): + await _start_fn(bot, ctx) + message = ctx.send.call_args[0][0] + assert "out of vocabulary" in message.lower() + assert bot._game_state.target_word is None + + async def test_partial_oov_starts_game_with_valid_word(self): + bot = _make_bot() + bot._game_state = GameState(scorer=_OovAwareScorer({"chat", "dragon"})) + ctx = _make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["chat", "licorne", "dragon"]): + await _start_fn(bot, ctx) + assert bot._game_state.target_word in {"chat", "dragon"} + + async def test_no_scorer_skips_oov_filter(self): + bot = _make_bot() + ctx = _make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["chat", "licorne", "dragon"]): + await _start_fn(bot, ctx) + assert bot._game_state.target_word in {"chat", "licorne", "dragon"} + + # --------------------------------------------------------------------------- # hint command # --------------------------------------------------------------------------- diff --git a/tests/test_engine.py b/tests/test_engine.py index 4a972a8..f4ef3cf 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -59,6 +59,30 @@ def test_score_guess_raises_when_not_loaded(self): with pytest.raises(RuntimeError, match="not loaded"): engine.score_guess("chat", "chien") + def test_is_in_vocab_raises_when_not_loaded(self): + engine = SemanticEngine(model_path="/nonexistent/path.bin") + with pytest.raises(RuntimeError, match="not loaded"): + engine.is_in_vocab("chat") + + +# --------------------------------------------------------------------------- +# SemanticEngine – is_in_vocab +# --------------------------------------------------------------------------- + +class TestSemanticEngineIsInVocab: + def test_known_word_returns_true(self): + engine = _make_engine() + assert engine.is_in_vocab("chat") is True + + def test_unknown_word_returns_false(self): + engine = _make_engine() + assert engine.is_in_vocab("licorne") is False + + def test_all_vocabulary_words_are_in_vocab(self): + engine = _make_engine() + for word in ["chat", "chien", "maison", "voiture"]: + assert engine.is_in_vocab(word) is True + # --------------------------------------------------------------------------- # SemanticEngine – similarity