diff --git a/Codel-Cloud-Native.ApiService/DTOs/DtoMappingExtensions.cs b/Codel-Cloud-Native.ApiService/DTOs/DtoMappingExtensions.cs index 4cda122..9c53afd 100644 --- a/Codel-Cloud-Native.ApiService/DTOs/DtoMappingExtensions.cs +++ b/Codel-Cloud-Native.ApiService/DTOs/DtoMappingExtensions.cs @@ -19,7 +19,10 @@ public static GameSessionDto ToDto(this GameSession gameSession) IsWin = gameSession.IsWin, CreatedAt = gameSession.CreatedAt, RemainingAttempts = gameSession.RemainingAttempts, - GuessHistory = gameSession.Attempts.Select(a => a.ToDto()).ToList() + GuessHistory = gameSession.Attempts.Select(a => a.ToDto()).ToList(), + GuessedLetters = gameSession.GuessedLetters.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToString()) }; } diff --git a/Codel-Cloud-Native.ApiService/DTOs/GameDTOs.cs b/Codel-Cloud-Native.ApiService/DTOs/GameDTOs.cs index cfabae0..0684684 100644 --- a/Codel-Cloud-Native.ApiService/DTOs/GameDTOs.cs +++ b/Codel-Cloud-Native.ApiService/DTOs/GameDTOs.cs @@ -13,6 +13,7 @@ public record GameSessionDto public DateTime CreatedAt { get; init; } public int RemainingAttempts { get; init; } public List GuessHistory { get; init; } = new(); + public Dictionary GuessedLetters { get; init; } = new(); } /// diff --git a/Codel-Cloud-Native.ApiService/Program.cs b/Codel-Cloud-Native.ApiService/Program.cs index 6f0ef4d..ff9a6b4 100644 --- a/Codel-Cloud-Native.ApiService/Program.cs +++ b/Codel-Cloud-Native.ApiService/Program.cs @@ -111,6 +111,7 @@ app.Run(); +public partial class Program { } diff --git a/Codel-Cloud-Native.ServiceDefaults/Codel-Cloud-Native.ServiceDefaults.csproj b/Codel-Cloud-Native.ServiceDefaults/Codel-Cloud-Native.ServiceDefaults.csproj index 1ddb50d..90fffe7 100644 --- a/Codel-Cloud-Native.ServiceDefaults/Codel-Cloud-Native.ServiceDefaults.csproj +++ b/Codel-Cloud-Native.ServiceDefaults/Codel-Cloud-Native.ServiceDefaults.csproj @@ -13,7 +13,7 @@ - + diff --git a/Codel-Cloud-Native.Tests/DomainServicesTests.cs b/Codel-Cloud-Native.Tests/DomainServicesTests.cs index 48c5204..05d34aa 100644 --- a/Codel-Cloud-Native.Tests/DomainServicesTests.cs +++ b/Codel-Cloud-Native.Tests/DomainServicesTests.cs @@ -40,6 +40,26 @@ public void GuessEvaluator_AllIncorrect_ReturnsAllIncorrectStatuses() Assert.All(resultList, r => Assert.Equal(LetterStatus.Incorrect, r.Status)); } + [Fact] + public void GuessEvaluator_DuplicateLetter_WithOneExactMatchAndOneExtraGuessLetter_ScoresCorrectly() + { + // Arrange + var evaluator = new GuessEvaluator(); + var guess = "floor"; + var target = "razor"; + + // Act + var result = evaluator.EvaluateGuess(guess, target).ToList(); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(LetterStatus.Incorrect, result[0].Status); // f + Assert.Equal(LetterStatus.Incorrect, result[1].Status); // l + Assert.Equal(LetterStatus.Incorrect, result[2].Status); // o (extra) + Assert.Equal(LetterStatus.Correct, result[3].Status); // o (exact) + Assert.Equal(LetterStatus.Correct, result[4].Status); // r (exact) + } + [Fact] public void GuessEvaluator_IsWinningGuess_CorrectAnswer_ReturnsTrue() { diff --git a/Codel-Cloud-Native.Tests/UnitTests.cs b/Codel-Cloud-Native.Tests/UnitTests.cs index 4dfe14d..8b5f5ac 100644 --- a/Codel-Cloud-Native.Tests/UnitTests.cs +++ b/Codel-Cloud-Native.Tests/UnitTests.cs @@ -1,6 +1,8 @@ using System.Net; using System; using CodeleLogic; +using CodeleLogic.Models; +using CodeleLogic.Services; namespace Codel_Cloud_Native.Tests; @@ -108,6 +110,26 @@ public void TestGetGuessNotDuplicateLetters() Assert.Equal(LetterStatus.Correct, guess.GuessStatus[4].Item2); } + [Fact] + public void TestGetGuessStatuses_DuplicateLetter_WithOneExactMatchAndOneExtraGuessLetter_ScoresCorrectly() + { + // Arrange + var guess = new Guess("floor"); + string answer = "razor"; + + // Act + guess.GetGuessStatuses(answer); + + // Assert + Assert.NotNull(guess.GuessStatus); + Assert.Equal(5, guess.GuessStatus.Count); + Assert.Equal(LetterStatus.Incorrect, guess.GuessStatus[0].Item2); // f + Assert.Equal(LetterStatus.Incorrect, guess.GuessStatus[1].Item2); // l + Assert.Equal(LetterStatus.Incorrect, guess.GuessStatus[2].Item2); // o (extra) + Assert.Equal(LetterStatus.Correct, guess.GuessStatus[3].Item2); // o (exact) + Assert.Equal(LetterStatus.Correct, guess.GuessStatus[4].Item2); // r (exact) + } + [Fact] public void TestIsWinningGuess() { @@ -163,4 +185,80 @@ public void TestIsGuessWrongLength() // Assert Assert.False(guess.IsWinningGuess(answer)); } + + [Fact] + public void TestGameSession_TrackGuessedLetters_SingleGuess() + { + // Arrange + var gameSession = new GameSession(Guid.NewGuid().ToString(), "APPLE", 5); + var guessEvaluator = new GuessEvaluator(); + + // Act + var statuses = guessEvaluator.EvaluateGuess("HELLO", "APPLE"); + var letters = statuses.Select((ls, index) => new LetterResult(ls.Letter, ls.Status, index)).ToList(); + var guessResult = new GuessResult("HELLO", letters, guessEvaluator.IsWinningGuess("HELLO", "APPLE")); + gameSession.AddAttempt(guessResult); + + // Assert - HELLO vs APPLE: + // H=Incorrect, E=IncorrectPosition (E is in position 4 in APPLE), L=IncorrectPosition (L is in position 2), L=Incorrect, O=Incorrect + // Since we track the best status per letter, L should be IncorrectPosition (not Incorrect) + Assert.Equal(4, gameSession.GuessedLetters.Count); // H, E, L, O (duplicates consolidated) + Assert.Equal(LetterStatus.Incorrect, gameSession.GuessedLetters['H']); + Assert.Equal(LetterStatus.IncorrectPosition, gameSession.GuessedLetters['E']); // E is in APPLE but wrong position + Assert.Equal(LetterStatus.IncorrectPosition, gameSession.GuessedLetters['L']); // L is in APPLE but wrong position (best status wins) + Assert.Equal(LetterStatus.Incorrect, gameSession.GuessedLetters['O']); + } + + [Fact] + public void TestGameSession_TrackGuessedLetters_LetterStatusUpgrade() + { + // Arrange + var gameSession = new GameSession(Guid.NewGuid().ToString(), "APPLE", 5); + var guessEvaluator = new GuessEvaluator(); + + // Act - first guess: HELLO vs APPLE (L will be IncorrectPosition) + var statuses1 = guessEvaluator.EvaluateGuess("HELLO", "APPLE"); + var letters1 = statuses1.Select((ls, index) => new LetterResult(ls.Letter, ls.Status, index)).ToList(); + var guessResult1 = new GuessResult("HELLO", letters1, guessEvaluator.IsWinningGuess("HELLO", "APPLE")); + gameSession.AddAttempt(guessResult1); + + // Act - second guess: APPLE vs APPLE (all letters correct, including L upgraded to Correct) + var statuses2 = guessEvaluator.EvaluateGuess("APPLE", "APPLE"); + var letters2 = statuses2.Select((ls, index) => new LetterResult(ls.Letter, ls.Status, index)).ToList(); + var guessResult2 = new GuessResult("APPLE", letters2, guessEvaluator.IsWinningGuess("APPLE", "APPLE")); + gameSession.AddAttempt(guessResult2); + + // Assert - L should be upgraded from IncorrectPosition to Correct + Assert.Equal(LetterStatus.Correct, gameSession.GuessedLetters['A']); + Assert.Equal(LetterStatus.Correct, gameSession.GuessedLetters['P']); + Assert.Equal(LetterStatus.Correct, gameSession.GuessedLetters['L']); // Upgraded from IncorrectPosition to Correct + Assert.Equal(LetterStatus.Correct, gameSession.GuessedLetters['E']); // Upgraded from IncorrectPosition to Correct + } + + [Fact] + public void TestGameSession_TrackGuessedLetters_NoDowngrade() + { + // Arrange + var gameSession = new GameSession(Guid.NewGuid().ToString(), "APPLE", 5); + var guessEvaluator = new GuessEvaluator(); + + // Act - first guess: A is correct in APPLE + var statuses1 = guessEvaluator.EvaluateGuess("ALOFT", "APPLE"); + var letters1 = statuses1.Select((ls, index) => new LetterResult(ls.Letter, ls.Status, index)).ToList(); + var guessResult1 = new GuessResult("ALOFT", letters1, guessEvaluator.IsWinningGuess("ALOFT", "APPLE")); + gameSession.AddAttempt(guessResult1); + + // Act - second guess: A would be incorrect position if it appeared elsewhere, but shouldn't downgrade + var statuses2 = guessEvaluator.EvaluateGuess("BEAUT", "APPLE"); // A is not in APPLE at position 2 + var letters2 = statuses2.Select((ls, index) => new LetterResult(ls.Letter, ls.Status, index)).ToList(); + var guessResult2 = new GuessResult("BEAUT", letters2, guessEvaluator.IsWinningGuess("BEAUT", "APPLE")); + gameSession.AddAttempt(guessResult2); + + // Assert - A should remain Correct from first guess + Assert.Equal(LetterStatus.Correct, gameSession.GuessedLetters['A']); + Assert.Contains('B', gameSession.GuessedLetters.Keys); + Assert.Contains('E', gameSession.GuessedLetters.Keys); + Assert.Contains('U', gameSession.GuessedLetters.Keys); + Assert.Contains('T', gameSession.GuessedLetters.Keys); + } } diff --git a/Codel-Cloud-Native.Web/CodeleApiClient.cs b/Codel-Cloud-Native.Web/CodeleApiClient.cs index 8ff266d..d4160c0 100644 --- a/Codel-Cloud-Native.Web/CodeleApiClient.cs +++ b/Codel-Cloud-Native.Web/CodeleApiClient.cs @@ -72,6 +72,7 @@ public record GameSessionDto public DateTime CreatedAt { get; init; } public int RemainingAttempts { get; init; } public List GuessHistory { get; init; } = new(); + public Dictionary GuessedLetters { get; init; } = new(); } public record GuessResultDto diff --git a/Codel-Cloud-Native.Web/Components/Pages/PlayCodele.razor b/Codel-Cloud-Native.Web/Components/Pages/PlayCodele.razor index 05944f2..35b1505 100644 --- a/Codel-Cloud-Native.Web/Components/Pages/PlayCodele.razor +++ b/Codel-Cloud-Native.Web/Components/Pages/PlayCodele.razor @@ -10,6 +10,34 @@ @if (gameSession != null) { + @if (gameSession.GuessedLetters != null && gameSession.GuessedLetters.Count > 0) + { +
+
Letters Guessed:
+
+ @foreach (char letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + { + @if (gameSession.GuessedLetters.ContainsKey(letter)) + { + var status = gameSession.GuessedLetters[letter]; + var cssClass = status switch + { + "Correct" => "btn btn-success", + "IncorrectPosition" => "btn btn-warning", + "Incorrect" => "btn btn-secondary", + _ => "btn btn-outline-dark" + }; + + } + else + { + + } + } +
+
+ } +

Attempt #: @(gameSession.Attempts + 1)

} diff --git a/CodeleLogic/CodeleLogic.csproj b/CodeleLogic/CodeleLogic.csproj index 935837c..4516454 100644 --- a/CodeleLogic/CodeleLogic.csproj +++ b/CodeleLogic/CodeleLogic.csproj @@ -6,7 +6,7 @@ - + \ No newline at end of file diff --git a/CodeleLogic/Guess.cs b/CodeleLogic/Guess.cs index 233a838..db6cd7d 100644 --- a/CodeleLogic/Guess.cs +++ b/CodeleLogic/Guess.cs @@ -20,29 +20,54 @@ public void GetGuessStatuses(string answer) { if (!string.IsNullOrEmpty(Word)) { - GuessStatus = new(); - // iterate over the overlapping length of the guess and answer to avoid index exceptions + // Iterate over the overlapping length of the guess and answer to avoid index exceptions. var length = Math.Min(Word.Length, answer.Length); + var statuses = new LetterStatus[length]; + var isExactMatch = new bool[length]; + var unmatchedAnswerLetterCounts = new Dictionary(); + + // First pass: mark exact matches and count remaining answer letters. + for (int i = 0; i < length; i++) + { + if (Word[i] == answer[i]) + { + isExactMatch[i] = true; + statuses[i] = LetterStatus.Correct; + } + else + { + char answerLetter = answer[i]; + unmatchedAnswerLetterCounts[answerLetter] = unmatchedAnswerLetterCounts.GetValueOrDefault(answerLetter) + 1; + } + } + + // Second pass: consume unmatched answer letters for incorrect-position matches. for (int i = 0; i < length; i++) { - char letter = Word[i]; - bool isDuplicateInAnswer = answer.Count(x => x == letter) > 1; + if (isExactMatch[i]) + { + continue; + } - // Check for duplicate letters - if ((GuessStatus.Contains((letter, LetterStatus.Correct)) || GuessStatus.Contains((letter, LetterStatus.IncorrectPosition))) && !isDuplicateInAnswer) + char guessLetter = Word[i]; + if (unmatchedAnswerLetterCounts.TryGetValue(guessLetter, out var count) && count > 0) { - GuessStatus.Add((letter, LetterStatus.Incorrect)); + statuses[i] = LetterStatus.IncorrectPosition; + unmatchedAnswerLetterCounts[guessLetter] = count - 1; } - else // regular Wordle logic + else { - if (Word[i] == answer[i]) GuessStatus.Add((letter, LetterStatus.Correct)); - else if (answer.Contains(letter)) GuessStatus.Add((letter, LetterStatus.IncorrectPosition)); - else GuessStatus.Add((letter, LetterStatus.Incorrect)); + statuses[i] = LetterStatus.Incorrect; } } + for (int i = 0; i < length; i++) + { + GuessStatus.Add((Word[i], statuses[i])); + } + // If guess is longer than answer, mark remaining letters as incorrect if (Word.Length > answer.Length) { diff --git a/CodeleLogic/Models/GameSession.cs b/CodeleLogic/Models/GameSession.cs index f9b1c2e..c29decd 100644 --- a/CodeleLogic/Models/GameSession.cs +++ b/CodeleLogic/Models/GameSession.cs @@ -1,3 +1,4 @@ +using CodeleLogic; using CodeleLogic.Services; namespace CodeleLogic.Models; @@ -14,6 +15,12 @@ public class GameSession public bool IsComplete { get; private set; } public bool IsWin { get; private set; } public DateTime CreatedAt { get; private set; } + + /// + /// Dictionary tracking the status of letters that have been guessed + /// Key: letter character (uppercase), Value: best status achieved for that letter + /// + public Dictionary GuessedLetters { get; private set; } public GameSession(string gameId, string targetWord, int maxAttempts = 5) { @@ -21,6 +28,7 @@ public GameSession(string gameId, string targetWord, int maxAttempts = 5) TargetWord = targetWord ?? throw new ArgumentNullException(nameof(targetWord)); MaxAttempts = maxAttempts; Attempts = new List(); + GuessedLetters = new Dictionary(); IsComplete = false; IsWin = false; CreatedAt = DateTime.UtcNow; @@ -41,6 +49,19 @@ public void AddAttempt(GuessResult guessResult) Attempts.Add(guessResult); + // Update guessed letters tracking + foreach (var letterResult in guessResult.Letters) + { + char upperLetter = char.ToUpperInvariant(letterResult.Letter); + + // Only update if we don't have this letter or if the new status is better + if (!GuessedLetters.ContainsKey(upperLetter) || + GetStatusPriority(letterResult.Status) > GetStatusPriority(GuessedLetters[upperLetter])) + { + GuessedLetters[upperLetter] = letterResult.Status; + } + } + // Check if this attempt wins the game if (guessResult.IsWin) { @@ -54,6 +75,21 @@ public void AddAttempt(GuessResult guessResult) } } + /// + /// Gets the priority of a letter status for tracking purposes + /// Higher priority statuses take precedence over lower ones + /// + private static int GetStatusPriority(LetterStatus status) + { + return status switch + { + LetterStatus.Correct => 3, + LetterStatus.IncorrectPosition => 2, + LetterStatus.Incorrect => 1, + _ => 0 + }; + } + /// /// Gets the current attempt number (1-based). /// diff --git a/CodeleLogic/Services/GuessEvaluator.cs b/CodeleLogic/Services/GuessEvaluator.cs index 71de268..fd8f696 100644 --- a/CodeleLogic/Services/GuessEvaluator.cs +++ b/CodeleLogic/Services/GuessEvaluator.cs @@ -10,32 +10,52 @@ public class GuessEvaluator : IGuessEvaluator if (string.IsNullOrEmpty(guess) || string.IsNullOrEmpty(targetWord)) return Enumerable.Empty<(char, LetterStatus)>(); - var results = new List<(char, LetterStatus)>(); - - // Use the existing logic from Guess.cs with improvements var length = Math.Min(guess.Length, targetWord.Length); - + var statuses = new LetterStatus[length]; + var isExactMatch = new bool[length]; + var unmatchedTargetLetterCounts = new Dictionary(); + + // First pass: mark exact-position matches and count the remaining target letters. for (int i = 0; i < length; i++) { - char letter = guess[i]; - bool isDuplicateInAnswer = targetWord.Count(x => x == letter) > 1; + if (guess[i] == targetWord[i]) + { + isExactMatch[i] = true; + statuses[i] = LetterStatus.Correct; + } + else + { + var targetLetter = targetWord[i]; + unmatchedTargetLetterCounts[targetLetter] = unmatchedTargetLetterCounts.GetValueOrDefault(targetLetter) + 1; + } + } - // Check for duplicate letters - use existing logic - if ((results.Contains((letter, LetterStatus.Correct)) || results.Contains((letter, LetterStatus.IncorrectPosition))) && !isDuplicateInAnswer) + // Second pass: only assign IncorrectPosition if an unmatched target letter is still available. + for (int i = 0; i < length; i++) + { + if (isExactMatch[i]) { - results.Add((letter, LetterStatus.Incorrect)); + continue; } - else // regular Wordle logic + + var guessLetter = guess[i]; + if (unmatchedTargetLetterCounts.TryGetValue(guessLetter, out var count) && count > 0) + { + statuses[i] = LetterStatus.IncorrectPosition; + unmatchedTargetLetterCounts[guessLetter] = count - 1; + } + else { - if (guess[i] == targetWord[i]) - results.Add((letter, LetterStatus.Correct)); - else if (targetWord.Contains(letter)) - results.Add((letter, LetterStatus.IncorrectPosition)); - else - results.Add((letter, LetterStatus.Incorrect)); + statuses[i] = LetterStatus.Incorrect; } } + var results = new List<(char, LetterStatus)>(length); + for (int i = 0; i < length; i++) + { + results.Add((guess[i], statuses[i])); + } + // If guess is longer than answer, mark remaining letters as incorrect if (guess.Length > targetWord.Length) {