-
Notifications
You must be signed in to change notification settings - Fork 0
Fix the room leak, redesign the UI, and add a computer opponent #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
557f1e3
Reclaim finished game rooms instead of leaking them forever
Ssavan99 b6cab41
Redesign the game UI and show the word being traced
Ssavan99 adb71f0
Add a single-player mode against the computer
Ssavan99 cc0504c
Stop browsers serving stale client code after a deploy
Ssavan99 0a1f996
Make the site indexable by search engines
Ssavan99 ab5fd31
Add the Google Search Console verification tag
Ssavan99 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
|
|
||
| namespace Boggle.Models | ||
| { | ||
| /// <summary> | ||
| /// Finds every dictionary word that can legally be traced on a board. | ||
| /// | ||
| /// A depth-first walk from each tile, following the same adjacency rules the | ||
| /// players get and never reusing a die within one word. The search is kept | ||
| /// cheap by pruning on prefixes: as soon as the letters collected so far | ||
| /// cannot begin any dictionary word, that whole branch is abandoned. Without | ||
| /// that check the walk explores every path on the board regardless of whether | ||
| /// it could ever spell anything. | ||
| /// </summary> | ||
| public static class BoardSolver | ||
| { | ||
| public const int MinWordLength = 3; | ||
|
|
||
| public static List<string> Solve(Board board) | ||
| { | ||
| return Solve(board, WordDictionary.getInstance()); | ||
| } | ||
|
|
||
| public static List<string> Solve(Board board, WordDictionary dictionary) | ||
| { | ||
| int size = board.boardSize(); | ||
| var found = new HashSet<string>(StringComparer.Ordinal); | ||
| var visited = new bool[size, size]; | ||
|
|
||
| for (int r = 0; r < size; r++) | ||
| { | ||
| for (int c = 0; c < size; c++) | ||
| { | ||
| walk(board, dictionary, r, c, "", visited, found); | ||
| } | ||
| } | ||
|
|
||
| var words = new List<string>(found); | ||
| // Longest first: callers pick by length, and this makes that trivial. | ||
| words.Sort((a, b) => b.Length != a.Length | ||
| ? b.Length - a.Length | ||
| : string.CompareOrdinal(a, b)); | ||
| return words; | ||
| } | ||
|
|
||
| private static void walk(Board board, WordDictionary dictionary, int r, int c, | ||
| string prefix, bool[,] visited, HashSet<string> found) | ||
| { | ||
| int size = board.boardSize(); | ||
| if (r < 0 || r >= size || c < 0 || c >= size) return; | ||
| if (visited[r, c]) return; | ||
|
|
||
| // A die face can be more than one character ("Qu"), so build from the | ||
| // face rather than a single char, matching how a guess is assembled. | ||
| string word = prefix + board.getDie(r, c).getUpLetter().ToLowerInvariant(); | ||
|
|
||
| if (!dictionary.HasPrefix(word)) return; | ||
|
|
||
| visited[r, c] = true; | ||
|
|
||
| if (word.Length >= MinWordLength && dictionary.IsWord(word)) | ||
| { | ||
| found.Add(word); | ||
| } | ||
|
|
||
| for (int dr = -1; dr <= 1; dr++) | ||
| { | ||
| for (int dc = -1; dc <= 1; dc++) | ||
| { | ||
| if (dr == 0 && dc == 0) continue; | ||
| walk(board, dictionary, r + dr, c + dc, word, visited, found); | ||
| } | ||
| } | ||
|
|
||
| visited[r, c] = false; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
|
|
||
| namespace Boggle.Models | ||
| { | ||
| public enum Difficulty | ||
| { | ||
| Easy, | ||
| Medium, | ||
| Hard | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// One word the computer intends to play, and how many seconds into the | ||
| /// round it plays it. | ||
| /// </summary> | ||
| public class BotMove | ||
| { | ||
| public string Word { get; set; } | ||
| public int AtSecond { get; set; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Turns a solved board into a plausible run of play for the computer. | ||
| /// | ||
| /// The whole plan is decided up front and then released on a clock, so the | ||
| /// opponent never needs a thread of its own: the game simply asks which | ||
| /// moves are due whenever a client checks in. That keeps it deterministic | ||
| /// and testable, and means a paused or abandoned game costs nothing. | ||
| /// </summary> | ||
| public static class ComputerPlayer | ||
| { | ||
| public const string BotName = "Computer"; | ||
|
|
||
| public static Difficulty ParseDifficulty(string value) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(value)) return Difficulty.Medium; | ||
| switch (value.Trim().ToLowerInvariant()) | ||
| { | ||
| case "easy": return Difficulty.Easy; | ||
| case "hard": return Difficulty.Hard; | ||
| default: return Difficulty.Medium; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Picks the words the computer will play and spaces them across the | ||
| /// round. Harder settings play more words and reach for longer ones, | ||
| /// which score far more under the length-based scoring rules. | ||
| /// </summary> | ||
| public static List<BotMove> BuildPlan(List<string> solvedWords, Difficulty difficulty, | ||
| int roundSeconds, Random rnd) | ||
| { | ||
| var plan = new List<BotMove>(); | ||
| if (solvedWords == null || solvedWords.Count == 0) return plan; | ||
|
|
||
| int target; | ||
| int maxLength; | ||
| switch (difficulty) | ||
| { | ||
| case Difficulty.Easy: | ||
| target = 5; | ||
| maxLength = 4; | ||
| break; | ||
| case Difficulty.Hard: | ||
| target = 24; | ||
| maxLength = int.MaxValue; | ||
| break; | ||
| default: | ||
| target = 12; | ||
| maxLength = 6; | ||
| break; | ||
| } | ||
|
|
||
| // solvedWords arrives longest-first. Easy and Medium are held back to | ||
| // shorter words so the computer stays beatable; Hard takes the best. | ||
| var eligible = solvedWords.Where(w => w.Length <= maxLength).ToList(); | ||
| if (eligible.Count == 0) eligible = solvedWords; | ||
|
|
||
| List<string> chosen; | ||
| if (difficulty == Difficulty.Hard) | ||
| { | ||
| chosen = eligible.Take(target).ToList(); | ||
| } | ||
| else | ||
| { | ||
| // Spread the picks through the eligible set rather than taking a | ||
| // single block, so the computer does not always play the same | ||
| // cluster of words on a given board. | ||
| chosen = eligible.OrderBy(_ => rnd.Next()).Take(target).ToList(); | ||
| } | ||
|
|
||
| if (chosen.Count == 0) return plan; | ||
|
|
||
| // Leave a little quiet at each end of the round. | ||
| int first = Math.Max(2, (int)(roundSeconds * 0.10)); | ||
| int last = Math.Max(first + 1, (int)(roundSeconds * 0.90)); | ||
| int span = last - first; | ||
|
|
||
| for (int i = 0; i < chosen.Count; i++) | ||
| { | ||
| int at = chosen.Count == 1 | ||
| ? first | ||
| : first + (int)Math.Round((double)span * i / (chosen.Count - 1)); | ||
|
|
||
| plan.Add(new BotMove { Word = chosen[i], AtSecond = at }); | ||
| } | ||
|
|
||
| plan.Sort((a, b) => a.AtSecond - b.AtSecond); | ||
| return plan; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the last existing player leaves while another request is joining the same game,
abandonedis computed underlock (g)but deletion happens after that lock is released. The joining request can obtain the game, add its player between lines 219 and 225, and then have the now-nonempty game removed by this unconditional call; its login succeeds, but its next poll returnsgameid not found. Make the empty check and removal atomic with respect to both game membership updates and the server dictionary.Useful? React with 👍 / 👎.