Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions game/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions game/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
49 changes: 49 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
24 changes: 24 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading