diff --git a/bot/bot.py b/bot/bot.py index 5855499..290c9e5 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -195,6 +195,12 @@ async def guess(self, ctx: commands.Context, word: str = "") -> None: if result.is_found: await ctx.send(f"🎉 {ctx.author.name} found the word '{word}'!") + elif result.already_cited: + if result.entry.score is not None: + pct = int(result.entry.score * 100) + await ctx.send(f"'{word}' has already been suggested ({pct}% similarity).") + else: + await ctx.send(f"'{word}' has already been suggested.") elif result.entry.score is not None: pct = int(result.entry.score * 100) await ctx.send(f"'{word}': {pct}% similarity") diff --git a/game/state.py b/game/state.py index 976ca70..aa2fcdf 100644 --- a/game/state.py +++ b/game/state.py @@ -55,10 +55,13 @@ class GuessResult: Attributes: entry: The :class:`GuessEntry` that was recorded. is_found: ``True`` if this guess matched the target word. + already_cited: ``True`` if the same word had already been submitted + earlier in this round (by any player). """ entry: GuessEntry is_found: bool + already_cited: bool class GameState: @@ -118,6 +121,7 @@ def submit_guess(self, user: str, word: str) -> GuessResult: normalized = clean_word(word) target_normalized = clean_word(self._target_word) + already_cited = any(e.normalized_word == normalized for e in self._history) found = normalized == target_normalized score: float | None @@ -134,12 +138,13 @@ def submit_guess(self, user: str, word: str) -> GuessResult: normalized_word=normalized, score=score, ) - self._history.append(entry) + if not already_cited: + self._history.append(entry) if found: self._is_found = True - return GuessResult(entry=entry, is_found=found) + return GuessResult(entry=entry, is_found=found, already_cited=already_cited) # ------------------------------------------------------------------ # State inspection diff --git a/tests/test_commands.py b/tests/test_commands.py index 5e65d20..1e08d50 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -374,6 +374,53 @@ async def test_guess_unknown_word_reports_vocabulary_miss(self): message = ctx.send.call_args[0][0] assert "vocabulary" in message.lower() + async def test_guess_already_cited_word_with_score_sends_distinct_message(self): + bot = _make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = _make_ctx() + ctx.author.name = "alice" + # First submission of "chien" + await _guess_fn(bot, ctx, "chien") + # Second submission of the same word + ctx2 = _make_ctx() + ctx2.author.name = "bob" + await _guess_fn(bot, ctx2, "chien") + message = ctx2.send.call_args[0][0] + assert "already" in message.lower() + assert "%" in message + + async def test_guess_already_cited_word_without_score_sends_distinct_message(self): + bot = _make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = _make_ctx() + ctx.author.name = "alice" + # No scorer, so non-exact guess produces score=None + await _guess_fn(bot, ctx, "unknownword") + ctx2 = _make_ctx() + ctx2.author.name = "bob" + await _guess_fn(bot, ctx2, "unknownword") + message = ctx2.send.call_args[0][0] + assert "already" in message.lower() + + async def test_guess_exact_match_not_reported_as_already_cited(self): + """A winning guess should show the win message even if the word was cited before.""" + bot = _make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = _make_ctx() + ctx.author.name = "alice" + # Someone submits the winning word first (game won) + await _guess_fn(bot, ctx, "chat") + # Another user submits the same winning word again + ctx2 = _make_ctx() + ctx2.author.name = "bob" + await _guess_fn(bot, ctx2, "chat") + message = ctx2.send.call_args[0][0] + # Should show winner message, not "already cited" + assert "bob" in message.lower() + assert "chat" in message.lower() + # --------------------------------------------------------------------------- # event_error diff --git a/tests/test_game_state.py b/tests/test_game_state.py index ba68e74..6070d30 100644 --- a/tests/test_game_state.py +++ b/tests/test_game_state.py @@ -157,6 +157,69 @@ def test_guess_result_contains_entry(self): assert result.entry.user == "alice" +# --------------------------------------------------------------------------- +# TestAlreadyCited +# --------------------------------------------------------------------------- + + +class TestAlreadyCited: + def test_first_guess_is_not_already_cited(self): + """The first submission of a word should not be marked as already cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + result = gs.submit_guess("alice", "chien") + assert not result.already_cited + + def test_repeated_word_is_already_cited(self): + """A word submitted a second time by any player should be already cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + result = gs.submit_guess("bob", "chien") + assert result.already_cited + + def test_same_user_repeated_word_is_already_cited(self): + """A word submitted twice by the same user should be already cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + result = gs.submit_guess("alice", "chien") + assert result.already_cited + + def test_different_word_is_not_already_cited(self): + """A word not yet in history should not be marked as already cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + result = gs.submit_guess("bob", "maison") + assert not result.already_cited + + def test_already_cited_normalised_match(self): + """Words that normalise to the same form should be detected as already cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "Chien") + result = gs.submit_guess("bob", "CHIEN") + assert result.already_cited + + def test_already_cited_word_not_appended_to_history(self): + """Already-cited guesses should not be recorded again in history.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + gs.submit_guess("bob", "chien") + assert gs.attempt_count == 1 + + def test_already_cited_resets_on_new_game(self): + """After starting a new game, previously cited words are no longer cited.""" + gs = _make_state() + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + gs.start_new_game("maison", Difficulty.EASY) + result = gs.submit_guess("bob", "chien") + assert not result.already_cited + + # --------------------------------------------------------------------------- # TestGameStateLeaderboard # --------------------------------------------------------------------------- @@ -184,10 +247,20 @@ def test_top_guesses_respects_n(self): """top_guesses(n) should return at most n entries.""" gs = _make_state(with_scorer=True) gs.start_new_game("chat", Difficulty.EASY) - for i in range(5): - gs.submit_guess(f"user{i}", "ch") + for word in ["a", "ab", "abc", "abcd", "abcde"]: + gs.submit_guess("alice", word) assert len(gs.top_guesses(n=3)) == 3 + def test_already_cited_word_excluded_from_top_guesses(self): + """A word submitted a second time should not appear twice in the leaderboard.""" + gs = _make_state(with_scorer=True) + gs.start_new_game("chat", Difficulty.EASY) + gs.submit_guess("alice", "chien") + gs.submit_guess("bob", "chien") + top = gs.top_guesses() + normalized_words = [e.normalized_word for e in top] + assert normalized_words.count("chien") == 1 + def test_score_stored_in_entry_with_scorer(self): """When a scorer is provided, GuessEntry.score should be set.""" gs = _make_state(with_scorer=True)