From 557f1e39d015533a711e75ebdfe667a0e25ed7d6 Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:52 -0500 Subject: [PATCH 1/6] Reclaim finished game rooms instead of leaking them forever Server.deleteGame existed but was never called from anywhere, so every "New Game" click leaked a room for the lifetime of the process. Games live only in memory, so nothing else cleaned them up either. Left alone this is not just a slow memory leak. Game ids are drawn from rnd.Next(1, 1000000) inside a do/while that retries on collision, so as the dictionary fills, collisions rise and the loop degenerates; once the id space is exhausted it spins forever and hangs the request thread. Two further latent crashes turned up next to it: startGame and removePlayer both locked on the game without the null check every other endpoint performs, so any stale game id threw a NullReferenceException instead of returning "gameid not found". Changes: - Track last-activity time on Game, refreshed by every endpoint that touches it, so a table someone is still polling is never considered idle - Delete the game as soon as its last player leaves - Sweep games idle for more than 30 minutes from a hosted background service running every 5 minutes - Bound id allocation and cap concurrent games, returning a clear "too many games in progress" response rather than looping - Add the missing null checks to startGame and removePlayer, and reject an unknown username instead of removing null Nine tests cover the reclamation paths, including the two calls that used to throw. Full suite: 40 tests, all passing. Co-Authored-By: Claude Opus 5 --- Boggle/Controllers/ServerController.cs | 30 ++++- Boggle/Models/Game.cs | 22 +++ Boggle/Models/Server.cs | 76 ++++++++++- Boggle/Program.cs | 4 + Boggle/Services/StaleGameCleanupService.cs | 65 +++++++++ UnitTests/TestGameLifecycle.cs | 148 +++++++++++++++++++++ 6 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 Boggle/Services/StaleGameCleanupService.cs create mode 100644 UnitTests/TestGameLifecycle.cs diff --git a/Boggle/Controllers/ServerController.cs b/Boggle/Controllers/ServerController.cs index 02aaf86..f614e7b 100644 --- a/Boggle/Controllers/ServerController.cs +++ b/Boggle/Controllers/ServerController.cs @@ -10,7 +10,7 @@ namespace Boggle.Controllers public class ServerController : Controller { private Server srv; - private IActionResult gameIdNotFound, invalidUsername, usernameNotFound, gameWasEnded, emptyGuess, duplicateUsername; + private IActionResult gameIdNotFound, invalidUsername, usernameNotFound, gameWasEnded, emptyGuess, duplicateUsername, serverAtCapacity; private IActionResult okMsg; public ServerController() @@ -22,6 +22,7 @@ public ServerController() gameWasEnded = failedMsg("game was ended"); emptyGuess = failedMsg("no word selected"); duplicateUsername = failedMsg("Username already used"); + serverAtCapacity = failedMsg("too many games in progress, try again shortly"); okMsg = Json(new { ok = true }); } @@ -82,8 +83,10 @@ public IActionResult Index() public IActionResult newGame() { Game g = srv.newGame(); + if (g == null) return serverAtCapacity; lock (g) { + g.touch(); g.getBoard().shakeForNewBoard(); return Json(new { @@ -96,8 +99,10 @@ public IActionResult newGame() public IActionResult startGame(int gameId) { Game g = srv.getGame(gameId); + if (g == null) return gameIdNotFound; lock (g) { + g.touch(); g.resetTimer(); g.setState(Game.State.Playing); return okMsg; @@ -110,6 +115,7 @@ public IActionResult getGameState(int gameId, string username) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); if (string.IsNullOrWhiteSpace(username)) return invalidUsername; User u = g.getUser(username); @@ -178,11 +184,12 @@ public IActionResult login(int gameId, string username) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); if (checkIsEnded(g)) return gameWasEnded; if (string.IsNullOrWhiteSpace(username)) return invalidUsername; - + if (g.isUsernameUsed(username)) { return duplicateUsername; @@ -199,12 +206,26 @@ public IActionResult login(int gameId, string username) public IActionResult removePlayer(int gameId, string username) { Game g = srv.getGame(gameId); + if (g == null) return gameIdNotFound; + + bool abandoned; lock (g) { + g.touch(); User u = g.getUser(username); + if (u == null) return usernameNotFound; g.removePlayer(u); - return okMsg; + abandoned = g.getPlayerCount() == 0; } + + // The last player left, so nobody can reach this game again. Drop it + // now rather than waiting for the idle sweep. + if (abandoned) + { + srv.deleteGame(gameId); + } + + return okMsg; } public IActionResult guess(int gameId, string username, string strcoords) @@ -213,6 +234,7 @@ public IActionResult guess(int gameId, string username, string strcoords) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); if (checkIsEnded(g)) return gameWasEnded; if (string.IsNullOrWhiteSpace(username)) @@ -256,6 +278,7 @@ public IActionResult endGame(int gameId) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); if (checkIsEnded(g)) return gameWasEnded; g.setState(Game.State.Ended); @@ -271,6 +294,7 @@ public IActionResult resetGame(int gameId) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); g.resetGame(); return okMsg; } diff --git a/Boggle/Models/Game.cs b/Boggle/Models/Game.cs index 25ca911..f7ec294 100644 --- a/Boggle/Models/Game.cs +++ b/Boggle/Models/Game.cs @@ -19,6 +19,7 @@ public enum State private Dictionary users; private List> gameLog; private State state; + private DateTime lastActivityUtc; private const int gameDurationSec = 3 * 60; public Game() : this(0, DateTime.Now) @@ -33,6 +34,27 @@ public Game(int id, DateTime startTime) users = new Dictionary(); gameLog = new List>(); state = State.Lobby; + lastActivityUtc = DateTime.UtcNow; + } + + /// + /// Marks the game as recently used. The cleanup sweep removes games that + /// have not been touched for a while, so every request that concerns a + /// game should call this. + /// + public void touch() + { + lastActivityUtc = DateTime.UtcNow; + } + + public DateTime getLastActivityUtc() + { + return lastActivityUtc; + } + + public int getPlayerCount() + { + return users.Count; } public int getId() diff --git a/Boggle/Models/Server.cs b/Boggle/Models/Server.cs index bd92a8f..7d6c02a 100644 --- a/Boggle/Models/Server.cs +++ b/Boggle/Models/Server.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; namespace Boggle.Models { @@ -11,6 +10,16 @@ public class Server private Dictionary games; private Random rnd; + /// + /// Upper bound on concurrent games. Game ids are drawn from a space of + /// roughly one million, so allowing the dictionary to approach that size + /// would make id allocation degenerate into a very long retry loop. + /// Refusing new games well before that point keeps allocation cheap. + /// + public const int MaxConcurrentGames = 20000; + + private const int MaxIdAttempts = 200; + private static Server inst = new Server(); public static Server getInstance() @@ -24,15 +33,36 @@ public Server() rnd = new Random(); } + /// + /// Creates a game, or returns null when the server is at capacity or a + /// free id could not be found. Callers must handle null. + /// public Game newGame() { lock (mtx) { - int id; - do id = rnd.Next(1, 1000000); - while (games.ContainsKey(id)); + if (games.Count >= MaxConcurrentGames) + { + return null; + } + + int id = 0; + bool allocated = false; + for (int attempt = 0; attempt < MaxIdAttempts; attempt++) + { + id = rnd.Next(1, 1000000); + if (!games.ContainsKey(id)) + { + allocated = true; + break; + } + } + + if (!allocated) + { + return null; + } - //id = 123; Game g = new Game(id, DateTime.Now); games[id] = g; return g; @@ -69,5 +99,39 @@ public Game deleteGame(int id) } } } + + public int getGameCount() + { + lock (mtx) + { + return games.Count; + } + } + + /// + /// Drops games that nobody has interacted with for longer than + /// . Games live only in memory, so without + /// this they accumulate for the lifetime of the process. A client that is + /// still polling keeps its game alive, so an active table is never culled. + /// + /// How many games were removed. + public int removeStaleGames(TimeSpan idleTimeout) + { + lock (mtx) + { + DateTime cutoff = DateTime.UtcNow - idleTimeout; + List stale = games + .Where(kv => kv.Value.getLastActivityUtc() < cutoff) + .Select(kv => kv.Key) + .ToList(); + + foreach (int id in stale) + { + games.Remove(id); + } + + return stale.Count; + } + } } } diff --git a/Boggle/Program.cs b/Boggle/Program.cs index 762c979..4c0ca31 100644 --- a/Boggle/Program.cs +++ b/Boggle/Program.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Boggle.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,9 @@ builder.Services.AddControllersWithViews(); +// Games are held in memory and were never reclaimed; this sweeps idle ones. +builder.Services.AddHostedService(); + var app = builder.Build(); // Honour X-Forwarded-* so the app sees the original scheme when it runs behind diff --git a/Boggle/Services/StaleGameCleanupService.cs b/Boggle/Services/StaleGameCleanupService.cs new file mode 100644 index 0000000..0df572e --- /dev/null +++ b/Boggle/Services/StaleGameCleanupService.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Boggle.Models; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Boggle.Services +{ + /// + /// Periodically drops games nobody has touched for a while. + /// + /// Games live only in the in-memory singleton, and + /// nothing removed them before: every "New Game" click leaked a room for the + /// lifetime of the process. Left alone that grows without bound and, because + /// game ids are drawn from a fixed space, eventually makes id allocation fail. + /// + /// A client that is still polling getGameState keeps its game alive, so an + /// active table is never collected. + /// + public class StaleGameCleanupService : BackgroundService + { + private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(5); + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(30); + + private readonly ILogger logger; + + public StaleGameCleanupService(ILogger logger) + { + this.logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(SweepInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + try + { + Server srv = Server.getInstance(); + int removed = srv.removeStaleGames(IdleTimeout); + if (removed > 0) + { + logger.LogInformation( + "Removed {Removed} idle game(s); {Remaining} still active.", + removed, srv.getGameCount()); + } + } + catch (Exception ex) + { + // A failed sweep must never take the host down; try again next tick. + logger.LogError(ex, "Stale game sweep failed."); + } + } + } + } +} diff --git a/UnitTests/TestGameLifecycle.cs b/UnitTests/TestGameLifecycle.cs new file mode 100644 index 0000000..c7809d0 --- /dev/null +++ b/UnitTests/TestGameLifecycle.cs @@ -0,0 +1,148 @@ +using System; +using System.Reflection; +using Boggle.Controllers; +using Boggle.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace UnitTests +{ + /// + /// Games are held in memory and used to accumulate forever: deleteGame was + /// never called from anywhere. These cover the reclamation paths. + /// + [TestClass] + public class TestGameLifecycle + { + /// + /// Backdates a game's last-activity stamp so idle behaviour can be tested + /// without waiting. The field is private because nothing outside the game + /// should be moving this clock in production code. + /// + private static void backdate(Game g, TimeSpan age) + { + FieldInfo f = typeof(Game).GetField("lastActivityUtc", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(f, "lastActivityUtc field is missing from Game"); + f.SetValue(g, DateTime.UtcNow - age); + } + + [TestMethod] + public void newGameIsTracked() + { + Server srv = new Server(); + int before = srv.getGameCount(); + srv.newGame(); + Assert.AreEqual(before + 1, srv.getGameCount()); + } + + [TestMethod] + public void deleteGameRemovesIt() + { + Server srv = new Server(); + Game g = srv.newGame(); + + srv.deleteGame(g.getId()); + + Assert.AreEqual(0, srv.getGameCount()); + Assert.IsNull(srv.getGame(g.getId())); + } + + [TestMethod] + public void idleGamesAreSweptAway() + { + Server srv = new Server(); + Game stale = srv.newGame(); + Game active = srv.newGame(); + + backdate(stale, TimeSpan.FromHours(2)); + + int removed = srv.removeStaleGames(TimeSpan.FromMinutes(30)); + + Assert.AreEqual(1, removed); + Assert.IsNull(srv.getGame(stale.getId())); + Assert.IsNotNull(srv.getGame(active.getId())); + } + + [TestMethod] + public void activeGamesSurviveTheSweep() + { + Server srv = new Server(); + Game g = srv.newGame(); + + backdate(g, TimeSpan.FromHours(2)); + g.touch(); + + Assert.AreEqual(0, srv.removeStaleGames(TimeSpan.FromMinutes(30))); + Assert.IsNotNull(srv.getGame(g.getId())); + } + + [TestMethod] + public void lastPlayerLeavingDropsTheGame() + { + ServerController c = new ServerController(); + Server srv = c.getServer(); + int id = srv.newGame().getId(); + + c.login(id, "solo"); + Assert.IsNotNull(srv.getGame(id)); + + c.removePlayer(id, "solo"); + + Assert.IsNull(srv.getGame(id), + "the game should be dropped once the last player leaves"); + } + + [TestMethod] + public void gameSurvivesWhileOtherPlayersRemain() + { + ServerController c = new ServerController(); + Server srv = c.getServer(); + int id = srv.newGame().getId(); + + c.login(id, "alice"); + c.login(id, "bob"); + + c.removePlayer(id, "alice"); + + Assert.IsNotNull(srv.getGame(id), + "bob is still playing, so the game must stay"); + } + + [TestMethod] + public void removePlayerOnUnknownGameDoesNotThrow() + { + ServerController c = new ServerController(); + + // Previously this dereferenced a null game and threw. + var result = c.removePlayer(999999999, "nobody"); + + Assert.IsNotNull(result); + } + + [TestMethod] + public void startGameOnUnknownGameDoesNotThrow() + { + ServerController c = new ServerController(); + + var result = c.startGame(999999999); + + Assert.IsNotNull(result); + } + + [TestMethod] + public void gameIdsStayUniqueAcrossManyGames() + { + Server srv = new Server(); + var seen = new System.Collections.Generic.HashSet(); + + for (int i = 0; i < 500; i++) + { + Game g = srv.newGame(); + Assert.IsNotNull(g, "allocation should succeed well below capacity"); + Assert.IsTrue(seen.Add(g.getId()), "duplicate game id issued"); + } + + Assert.AreEqual(500, srv.getGameCount()); + } + } +} From b6cab4178d8943551a730ef81aef04cd4f87e54c Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:53 -0500 Subject: [PATCH 2/6] Redesign the game UI and show the word being traced The stylesheet was 67 lines built around a global "div { width: 50% }" rule, and the page had no viewport meta tag, so the game was unusable on a phone. The leaderboard was absolutely positioned and overlapped the board on narrow screens. Presentation: - Rebuild the stylesheet on CSS custom properties, keeping the cream and orange identity but adding depth, spacing and a consistent type scale - Size the board and type with clamp() so it adapts from phone to desktop - Add the viewport meta tag, without which none of the above applies on mobile - Give buttons hover, active, focus-visible and disabled states - Keep the leaderboard out of the flow only on wide screens, so it can no longer cover the board - Add a meta description Gameplay feedback, all additive: - Show the word as it is traced. Previously the only feedback was highlighted tiles, so players could not read back what they were about to submit - Disable Guess and Reset while nothing is selected, initialised when the board is drawn rather than on first click - Select tiles via a CSS class instead of an inline background colour, so the selected state can carry more than one property Rules, scoring, the API and the request flow are untouched. Verified through the UI: traced GEM on a real board, submitted with the Guess button, validated against the dictionary at round end, scored 1 point. A non-word was correctly rejected. No console errors. 40 tests passing. Co-Authored-By: Claude Opus 5 --- Boggle/wwwroot/css/boggle.css | 272 ++++++++++++++++++++++++++++----- Boggle/wwwroot/democlient.html | 15 +- Boggle/wwwroot/js/utils.js | 20 ++- 3 files changed, 261 insertions(+), 46 deletions(-) diff --git a/Boggle/wwwroot/css/boggle.css b/Boggle/wwwroot/css/boggle.css index 5a7ac9a..c05c02e 100644 --- a/Boggle/wwwroot/css/boggle.css +++ b/Boggle/wwwroot/css/boggle.css @@ -1,66 +1,264 @@ -#tbl_game button { - width: 2.5em; +/* --------------------------------------------------------------------------- + Boggle + + The markup is driven by boggle.js, which looks elements up by id, so ids and + structure are load-bearing. Everything here is presentation only. + --------------------------------------------------------------------------- */ + +:root { + --cream: #f2ecbf; + --surface: #fffdf6; + --accent: #faa964; + --accent-dark: #e08340; + --ink: #2f2a1f; + --muted: #7d7563; + --line: #e0d9ae; + --shadow: 0 2px 0 rgba(0, 0, 0, .12); } -#tbl_gamelog { - background-color: white; - position: absolute; - top: 30px; - left: 30px; +* { + box-sizing: border-box; } body { - background-color: #f2ecbf; + background-color: var(--cream); + color: var(--ink); text-align: center; + margin: 0; + padding: 1.5rem 1rem 3rem; + font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, + Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +h1, h2 { + font-family: inherit; + letter-spacing: .02em; +} + +h1 { + font-size: clamp(1.5rem, 4vw, 2.1rem); + margin: .4em 0; +} + +h2 { + font-size: clamp(1rem, 3vw, 1.3rem); + font-weight: 600; + color: var(--muted); + margin: .4em 0 1.2em; +} + +#logo { + width: min(260px, 60vw); + height: auto; + margin: 0 auto .5rem; + display: block; +} + +/* Screens ------------------------------------------------------------------ */ + +.sc { + width: min(760px, 100%); + margin: 0 auto; +} + +/* Buttons ------------------------------------------------------------------ */ + +button { + font-family: inherit; + font-size: 1rem; + font-weight: 600; + color: var(--ink); + background: var(--surface); + border: 2px solid var(--accent); + border-radius: 12px; + padding: .55em 1.1em; + cursor: pointer; + box-shadow: var(--shadow); + transition: transform .08s ease, background-color .15s ease, + box-shadow .15s ease; +} + +button:hover:not(:disabled) { + background: #fff4e6; +} + +button:active:not(:disabled) { + transform: translateY(2px); + box-shadow: none; +} + +button:focus-visible { + outline: 3px solid var(--accent-dark); + outline-offset: 2px; +} + +button:disabled { + opacity: .45; + cursor: not-allowed; + box-shadow: none; +} + +#sc_start input { + display: block; + width: min(320px, 100%); + margin: .5rem auto; + height: 3rem; + padding: 0 .9rem; + font-family: inherit; + font-size: 1.1rem; + color: var(--ink); + background: var(--surface); + border: 2px solid var(--line); + border-radius: 12px; +} + +#sc_start input:focus { + outline: none; + border-color: var(--accent); } -span { - margin-left: auto; - margin-right: auto; - width: 50%; +#sc_start button, +#sc_lobby button { + height: 3rem; + min-width: 9rem; + margin: .35rem .25rem; + font-size: 1.05rem; + border-radius: 14px; } -div { - margin-left: auto; - margin-right: auto; - width: 50%; +.rules-btn { + position: static; + margin-top: 1.25rem; } .btn-rules { - position: absolute; - top: 10px; - right: 10px; + position: static; + background: transparent; + border-color: transparent; + color: var(--muted); + box-shadow: none; + text-decoration: underline; +} + +.btn-rules:hover:not(:disabled) { + background: transparent; + color: var(--ink); +} + +/* Status line -------------------------------------------------------------- */ + +#sc_game { + font-size: 1rem; + color: var(--muted); +} + +#lbl_time { + font-variant-numeric: tabular-nums; + font-weight: 700; + color: var(--ink); +} + +/* The word currently being traced ------------------------------------------ */ + +#current_word { + min-height: 2.4rem; + margin: .9rem auto .2rem; + font-size: 1.6rem; + font-weight: 700; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--accent-dark); +} + +#current_word:empty::before { + content: "\00a0"; +} + +/* Board -------------------------------------------------------------------- */ + +#tbl_game { + margin: .4rem auto 1rem; + border-spacing: .5rem; +} + +#tbl_game button { + width: clamp(56px, 17vw, 84px); + height: clamp(56px, 17vw, 84px); + padding: 0; + font-size: clamp(1.5rem, 6vw, 2.4rem); + font-weight: 700; + background: var(--surface); + border: 2px solid var(--line); + border-radius: 16px; +} + +#tbl_game button:hover:not(:disabled) { + border-color: var(--accent); + background: #fff4e6; +} + +/* renderSelected() toggles this class as letters are chained. */ +#tbl_game button.selected { + background: var(--accent); + border-color: var(--accent-dark); + color: #fff; + transform: translateY(1px); + box-shadow: none; } +/* Tables ------------------------------------------------------------------- */ + table.in_line { - background-color: white; + background: var(--surface); display: inline-block; - margin: 0 1.5%; + margin: .5rem .75rem; vertical-align: top; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow); + min-width: 8.5rem; } -#logo { - margin-left: auto; - margin-right: auto; - width: 25%; +table.in_line td { + border: 1px solid var(--line); + padding: .4rem .8rem; + font-size: .95rem; } +table.in_line thead td { + background: var(--accent); + color: #fff; + font-weight: 700; +} -#sc_start input { - height: 50px; - font-size: 18pt; +#gamelog { + margin: 0 auto 1rem; } -#sc_start button, #sc_lobby button { - border-radius: 15px; - height: 50px; - font-size: 18pt; +#tbl_gamelog { + background: var(--surface); + margin: 0 auto; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow); } -#tbl_game button { - font-size: 40px; - height: 80px; - width: 80px; - border-radius: 15px; +#tbl_gamelog td { + border: 1px solid var(--line); + padding: .35rem .7rem; + font-size: .9rem; +} +/* On wide screens the leaderboard sits out of the way; on narrow ones it + stays in the flow so it cannot cover the board. */ +@media (min-width: 1100px) { + #gamelog { + position: absolute; + top: 24px; + left: 24px; + margin: 0; + } } diff --git a/Boggle/wwwroot/democlient.html b/Boggle/wwwroot/democlient.html index a7003c5..b05ade6 100644 --- a/Boggle/wwwroot/democlient.html +++ b/Boggle/wwwroot/democlient.html @@ -2,6 +2,8 @@ + + Boggle @@ -38,11 +40,11 @@

Members:

- GameID: | - Username: | - Time: - -
+

+ Game + · + · +

@@ -70,7 +72,8 @@

Members:

-
+
+ diff --git a/Boggle/wwwroot/js/utils.js b/Boggle/wwwroot/js/utils.js index 65f924e..aaf37d6 100644 --- a/Boggle/wwwroot/js/utils.js +++ b/Boggle/wwwroot/js/utils.js @@ -72,8 +72,8 @@ function cellClick(i, j) { if (sel.length > 0) { if (sel[sel.length - 1].i === i && sel[sel.length - 1].j === j) { //deselecting the last letter - cell(i, j).css("background-color", ""); sel.pop(); + renderSelected(); } else { sel.push({ i: i, j: j }); renderSelected(); @@ -95,18 +95,32 @@ function fillBoard(board) { })(i, j); } } + + // Redraw highlights and the guess buttons for the new board, so Guess starts + // disabled rather than waiting for the first tile click. + renderSelected(); } function renderSelected() { for (var i = 0; i < boggle.size; i++) { for (var j = 0; j < boggle.size; j++) { - cell(i, j).css("background-color", ""); + cell(i, j).removeClass("selected"); } } + + // Build the word as it is traced so the player can see what they are about + // to submit; the board only ever showed highlighted tiles before. var s = boggle.selected; + var word = ""; for (var i = 0; i < s.length; i++) { - cell(s[i].i, s[i].j).css("background-color", "#faa964"); + var c = cell(s[i].i, s[i].j); + c.addClass("selected"); + word += c.text(); } + + $("#current_word").text(word); + $("#btn_guess").prop("disabled", s.length === 0); + $("#btn_resetguess").prop("disabled", s.length === 0); } function refreshState(gameid, auto) { From adb71f02a3c59bd9b21da5a432ee4878452ec8d7 Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:10:07 -0500 Subject: [PATCH 3/6] Add a single-player mode against the computer There was no way to play alone: a solo game was just a room with one person in it and nothing to play against. The opponent is built on a board solver. A depth-first walk from every tile follows the same adjacency rules players get and never reuses a die, pruning a branch as soon as the letters collected cannot begin any dictionary word. That prefix check is what makes it cheap; without it the walk explores every path on the grid regardless of whether it could spell anything. Prefix lookup reuses the existing sorted word list via binary search, so no extra structure is held in memory. Die faces can be two characters ("Qu"), so the solver builds words from faces rather than single characters, matching how a guess is assembled. The opponent itself is a difficulty policy over the solution set. Easy plays about five short words, Medium about twelve, Hard takes the best two dozen. Easy and Medium are capped by word length so the computer stays beatable, and picks are drawn randomly through the eligible set so the same board does not always produce the same game. Moves are planned up front and released on a clock rather than by a timer thread: the game hands over whatever is due whenever a client checks in. That keeps it deterministic and testable, costs nothing for an idle game, and needs no extra concurrency around the existing lock. It also composes with what was already there. The computer is an ordinary player, so duplicate cancellation applies between it and the human, and the existing masking hides its words until the round ends. Fourteen tests cover prefix lookup, solver correctness, die reuse, difficulty scaling, plan ordering and release timing. Suite: 54 tests, all passing. Co-Authored-By: Claude Opus 5 --- Boggle/Controllers/ServerController.cs | 25 ++- Boggle/Models/BoardSolver.cs | 79 +++++++++ Boggle/Models/ComputerPlayer.cs | 114 ++++++++++++ Boggle/Models/Game.cs | 93 ++++++++++ Boggle/Models/WordDictionary.cs | 21 +++ Boggle/wwwroot/css/boggle.css | 40 +++++ Boggle/wwwroot/democlient.html | 12 ++ Boggle/wwwroot/js/apis.js | 7 + Boggle/wwwroot/js/boggle.js | 33 ++++ UnitTests/TestComputerOpponent.cs | 232 +++++++++++++++++++++++++ 10 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 Boggle/Models/BoardSolver.cs create mode 100644 Boggle/Models/ComputerPlayer.cs create mode 100644 UnitTests/TestComputerOpponent.cs diff --git a/Boggle/Controllers/ServerController.cs b/Boggle/Controllers/ServerController.cs index f614e7b..d8c0172 100644 --- a/Boggle/Controllers/ServerController.cs +++ b/Boggle/Controllers/ServerController.cs @@ -12,6 +12,7 @@ public class ServerController : Controller private Server srv; private IActionResult gameIdNotFound, invalidUsername, usernameNotFound, gameWasEnded, emptyGuess, duplicateUsername, serverAtCapacity; private IActionResult okMsg; + private static readonly Random rnd = new Random(); public ServerController() { @@ -67,6 +68,9 @@ private bool checkIsEnded(Game g) { if(g.getState() != Game.State.Ended) { + // Let the computer play out anything still owed before the + // round is scored, or its late words would be dropped. + g.finishBot(); calcScores(g); g.updateGameLog(); } @@ -80,7 +84,11 @@ public IActionResult Index() return View(); } - public IActionResult newGame() + /// + /// Creates a game. Pass vsComputer=true to add the computer opponent, + /// optionally with difficulty=easy|medium|hard. + /// + public IActionResult newGame(bool vsComputer = false, string difficulty = null) { Game g = srv.newGame(); if (g == null) return serverAtCapacity; @@ -88,10 +96,15 @@ public IActionResult newGame() { g.touch(); g.getBoard().shakeForNewBoard(); + if (vsComputer) + { + g.enableBot(ComputerPlayer.ParseDifficulty(difficulty)); + } return Json(new { ok = true, - gameId = g.getId() + gameId = g.getId(), + vsComputer = g.isBotEnabled() }); } } @@ -105,6 +118,9 @@ public IActionResult startGame(int gameId) g.touch(); g.resetTimer(); g.setState(Game.State.Playing); + // Solve the board now that it is final, so the computer plays + // words that are actually on the grid in front of the player. + g.planBotMoves(rnd); return okMsg; } } @@ -126,6 +142,10 @@ public IActionResult getGameState(int gameId, string username) int remainingTime = (int)g.getEndTime().Subtract(DateTime.Now).TotalSeconds; bool ended = g.getState() == Game.State.Ended; + // The computer plays on a clock rather than a thread: whatever it + // owes by now is released whenever a client checks in. + g.advanceBot(g.getDurationSeconds() - remainingTime); + int sz = g.getBoard().boardSize(); string[][] board = new string[sz][]; for (int i = 0; i < sz; i++) @@ -282,6 +302,7 @@ public IActionResult endGame(int gameId) if (checkIsEnded(g)) return gameWasEnded; g.setState(Game.State.Ended); + g.finishBot(); calcScores(g); g.updateGameLog(); return okMsg; diff --git a/Boggle/Models/BoardSolver.cs b/Boggle/Models/BoardSolver.cs new file mode 100644 index 0000000..694ae14 --- /dev/null +++ b/Boggle/Models/BoardSolver.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace Boggle.Models +{ + /// + /// 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. + /// + public static class BoardSolver + { + public const int MinWordLength = 3; + + public static List Solve(Board board) + { + return Solve(board, WordDictionary.getInstance()); + } + + public static List Solve(Board board, WordDictionary dictionary) + { + int size = board.boardSize(); + var found = new HashSet(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(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 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; + } + } +} diff --git a/Boggle/Models/ComputerPlayer.cs b/Boggle/Models/ComputerPlayer.cs new file mode 100644 index 0000000..68aa5e2 --- /dev/null +++ b/Boggle/Models/ComputerPlayer.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Boggle.Models +{ + public enum Difficulty + { + Easy, + Medium, + Hard + } + + /// + /// One word the computer intends to play, and how many seconds into the + /// round it plays it. + /// + public class BotMove + { + public string Word { get; set; } + public int AtSecond { get; set; } + } + + /// + /// 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. + /// + 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; + } + } + + /// + /// 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. + /// + public static List BuildPlan(List solvedWords, Difficulty difficulty, + int roundSeconds, Random rnd) + { + var plan = new List(); + 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 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; + } + } +} diff --git a/Boggle/Models/Game.cs b/Boggle/Models/Game.cs index f7ec294..ca300ab 100644 --- a/Boggle/Models/Game.cs +++ b/Boggle/Models/Game.cs @@ -22,6 +22,11 @@ public enum State private DateTime lastActivityUtc; private const int gameDurationSec = 3 * 60; + private bool botEnabled; + private Difficulty botDifficulty; + private List botPlan; + private int botCursor; + public Game() : this(0, DateTime.Now) { } @@ -57,6 +62,89 @@ public int getPlayerCount() return users.Count; } + /* ----- computer opponent ------------------------------------------- */ + + public bool isBotEnabled() + { + return botEnabled; + } + + public Difficulty getBotDifficulty() + { + return botDifficulty; + } + + /// + /// Adds the computer as a player. Its moves are planned later, when the + /// round starts and the final board is known. + /// + public void enableBot(Difficulty difficulty) + { + botEnabled = true; + botDifficulty = difficulty; + botPlan = null; + botCursor = 0; + + if (!users.ContainsKey(ComputerPlayer.BotName)) + { + users.Add(ComputerPlayer.BotName, new User(ComputerPlayer.BotName)); + } + } + + /// + /// Solves the current board and decides what the computer will play. + /// Called when the round starts, so the plan matches the board in front + /// of the human. + /// + public void planBotMoves(Random rnd) + { + if (!botEnabled) return; + + List solved = BoardSolver.Solve(board); + botPlan = ComputerPlayer.BuildPlan(solved, botDifficulty, gameDurationSec, rnd); + botCursor = 0; + } + + public int getBotPlanCount() + { + return botPlan == null ? 0 : botPlan.Count; + } + + /// + /// Releases any computer moves that are due by . + /// Driven by client activity rather than a timer, so an idle game does no + /// work at all. + /// + public void advanceBot(int elapsedSeconds) + { + if (!botEnabled || botPlan == null) return; + if (state != State.Playing && state != State.Ended) return; + + User bot = getUser(ComputerPlayer.BotName); + if (bot == null) return; + + while (botCursor < botPlan.Count && botPlan[botCursor].AtSecond <= elapsedSeconds) + { + string w = botPlan[botCursor].Word; + if (!bot.isUsed(w)) + { + bot.addWord(w); + } + botCursor++; + } + } + + /// Releases every remaining computer move, for end of round. + public void finishBot() + { + advanceBot(int.MaxValue); + } + + public int getDurationSeconds() + { + return gameDurationSec; + } + public int getId() { return id; @@ -180,6 +268,11 @@ public void resetGame() board.shakeForNewBoard(); state = State.Lobby; startTime = DateTime.Now; + + // The board changed, so the old plan no longer applies; it is rebuilt + // when the next round starts. + botPlan = null; + botCursor = 0; } public List getAllUsersInGameLog() diff --git a/Boggle/Models/WordDictionary.cs b/Boggle/Models/WordDictionary.cs index f9f8669..eccf731 100644 --- a/Boggle/Models/WordDictionary.cs +++ b/Boggle/Models/WordDictionary.cs @@ -54,6 +54,27 @@ public bool IsWord(string word) return this.DictionaryWords.BinarySearch(word) >= 0; } + /// + /// True when at least one dictionary word starts with the given text. + /// + /// This is what makes solving a board tractable: a depth-first walk can + /// abandon a path the moment no word could still be reached from it, + /// instead of exploring every route to full length. Because the list is + /// sorted, the first entry at or after the prefix is the only candidate + /// that needs checking. + /// + public bool HasPrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) return true; + + int idx = this.DictionaryWords.BinarySearch(prefix); + if (idx >= 0) return true; + + idx = ~idx; + return idx < this.DictionaryWords.Count + && this.DictionaryWords[idx].StartsWith(prefix, StringComparison.Ordinal); + } + public static WordDictionary getInstance() { return inst; diff --git a/Boggle/wwwroot/css/boggle.css b/Boggle/wwwroot/css/boggle.css index c05c02e..195ed19 100644 --- a/Boggle/wwwroot/css/boggle.css +++ b/Boggle/wwwroot/css/boggle.css @@ -127,6 +127,46 @@ button:disabled { border-radius: 14px; } +/* Solo play ---------------------------------------------------------------- */ + +.solo { + margin: 1.75rem auto 0; + padding-top: 1.25rem; + border-top: 1px solid var(--line); + max-width: 420px; +} + +.solo label { + display: block; + margin-bottom: .6rem; + font-size: .95rem; + color: var(--muted); +} + +.solo-row { + display: flex; + gap: .5rem; + justify-content: center; + flex-wrap: wrap; +} + +#sel_difficulty { + height: 3rem; + padding: 0 .8rem; + font-family: inherit; + font-size: 1rem; + color: var(--ink); + background: var(--surface); + border: 2px solid var(--line); + border-radius: 12px; + cursor: pointer; +} + +#sel_difficulty:focus { + outline: none; + border-color: var(--accent); +} + .rules-btn { position: static; margin-top: 1.25rem; diff --git a/Boggle/wwwroot/democlient.html b/Boggle/wwwroot/democlient.html index b05ade6..d98d63c 100644 --- a/Boggle/wwwroot/democlient.html +++ b/Boggle/wwwroot/democlient.html @@ -21,6 +21,18 @@

ENTER YOUR NAME


+ +
+ +
+ + +
+
diff --git a/Boggle/wwwroot/js/apis.js b/Boggle/wwwroot/js/apis.js index dafc72f..abfe957 100644 --- a/Boggle/wwwroot/js/apis.js +++ b/Boggle/wwwroot/js/apis.js @@ -11,6 +11,13 @@ function newGame() { return ajaxReq("/Server/newGame"); } +function newGameVsComputer(difficulty) { + return ajaxReq("/Server/newGame", { + vsComputer: true, + difficulty: difficulty + }); +} + function startGame() { return ajaxReq("/Server/startGame", { gameId: boggle.gameId diff --git a/Boggle/wwwroot/js/boggle.js b/Boggle/wwwroot/js/boggle.js index 9912345..d91b0cf 100644 --- a/Boggle/wwwroot/js/boggle.js +++ b/Boggle/wwwroot/js/boggle.js @@ -28,6 +28,39 @@ }); }); + // Solo play skips the lobby: there is nobody to wait for, so the round + // starts as soon as the board exists. + $("#btn_vscomputer").click(function () { + boggle.username = $("#txt_username").val() || "You"; + var difficulty = $("#sel_difficulty").val(); + + newGameVsComputer(difficulty) + .then(function (ngResp) { + if (!ngResp.ok) { + return $.Deferred().reject(ngResp.msg); + } + boggle.gameId = ngResp.gameId; + return login(); + }) + .then(function (loginResp) { + if (!loginResp.ok) { + return $.Deferred().reject(loginResp.msg); + } + }) + .then(getGameState) + .then(function (stt) { + initGame(stt); + return startGame(); + }) + .then(function () { + refreshState(boggle.gameId); + changePlayAgainAvailability(true); + }) + .fail(function (err) { + alert("Fail: " + err); + }); + }); + $("#btn_startgame").click(function () { startGame().then(function () { refreshState(boggle.gameId); diff --git a/UnitTests/TestComputerOpponent.cs b/UnitTests/TestComputerOpponent.cs new file mode 100644 index 0000000..aaa3077 --- /dev/null +++ b/UnitTests/TestComputerOpponent.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Boggle.Controllers; +using Boggle.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace UnitTests +{ + [TestClass] + public class TestComputerOpponent + { + /// + /// Forces a known board so solver results are predictable. Die faces are + /// set by rolling until the wanted letter comes up, which is the only way + /// to drive a Die from outside. + /// + private static Board boardFrom(string[] rows) + { + var dice = new Die[4, 4]; + for (int r = 0; r < 4; r++) + { + string[] letters = rows[r].Split(' '); + for (int c = 0; c < 4; c++) + { + dice[r, c] = new Die(new string[] + { + letters[c], letters[c], letters[c], + letters[c], letters[c], letters[c] + }); + dice[r, c].roll(); + } + } + + Board b = new Board(); + b.setDice(dice); + return b; + } + + [TestMethod] + public void hasPrefixFindsRealPrefixes() + { + WordDictionary d = WordDictionary.getInstance(); + + Assert.IsTrue(d.HasPrefix("ca")); + Assert.IsTrue(d.HasPrefix("boggl")); + Assert.IsFalse(d.HasPrefix("zzzq")); + } + + [TestMethod] + public void hasPrefixAcceptsCompleteWords() + { + WordDictionary d = WordDictionary.getInstance(); + Assert.IsTrue(d.HasPrefix("cat")); + } + + [TestMethod] + public void solverFindsWordsThatAreOnTheBoard() + { + Board b = boardFrom(new[] + { + "C A T S", + "X X X X", + "X X X X", + "X X X X" + }); + + List words = BoardSolver.Solve(b); + + Assert.IsTrue(words.Contains("cat"), "cat is traceable across the top row"); + Assert.IsTrue(words.Contains("cats"), "cats is traceable across the top row"); + } + + [TestMethod] + public void solverRejectsWordsThatAreNotTraceable() + { + Board b = boardFrom(new[] + { + "C X X T", + "X X X X", + "X X X A", + "X X X X" + }); + + List words = BoardSolver.Solve(b); + + Assert.IsFalse(words.Contains("cat"), + "the letters are not adjacent, so cat must not be found"); + } + + [TestMethod] + public void solverNeverReusesADie() + { + // Only one 'o': "oo" words must not appear. + Board b = boardFrom(new[] + { + "N O X X", + "X X X X", + "X X X X", + "X X X X" + }); + + List words = BoardSolver.Solve(b); + + Assert.IsFalse(words.Any(w => w.Contains("oo")), + "a die cannot be used twice in one word"); + } + + [TestMethod] + public void solverHonoursMinimumWordLength() + { + Board b = boardFrom(new[] + { + "C A T S", + "X X X X", + "X X X X", + "X X X X" + }); + + Assert.IsTrue(BoardSolver.Solve(b).All(w => w.Length >= BoardSolver.MinWordLength)); + } + + [TestMethod] + public void harderSettingsPlayMoreWords() + { + var solved = new List(); + for (int i = 0; i < 60; i++) solved.Add(new string('a', 3 + (i % 5))); + // distinct entries + solved = solved.Select((w, i) => w + i).ToList(); + + var rnd = new Random(1); + int easy = ComputerPlayer.BuildPlan(solved, Difficulty.Easy, 180, rnd).Count; + int medium = ComputerPlayer.BuildPlan(solved, Difficulty.Medium, 180, rnd).Count; + int hard = ComputerPlayer.BuildPlan(solved, Difficulty.Hard, 180, rnd).Count; + + Assert.IsTrue(easy < medium, "medium should play more words than easy"); + Assert.IsTrue(medium < hard, "hard should play more words than medium"); + } + + [TestMethod] + public void planIsSpreadAcrossTheRound() + { + var solved = Enumerable.Range(0, 40).Select(i => "word" + i).ToList(); + var plan = ComputerPlayer.BuildPlan(solved, Difficulty.Hard, 180, new Random(7)); + + Assert.IsTrue(plan.Count > 1); + Assert.IsTrue(plan.First().AtSecond >= 0); + Assert.IsTrue(plan.Last().AtSecond <= 180, "no move may land after the round ends"); + + for (int i = 1; i < plan.Count; i++) + { + Assert.IsTrue(plan[i].AtSecond >= plan[i - 1].AtSecond, "plan must be ordered"); + } + } + + [TestMethod] + public void emptyBoardPlanIsEmpty() + { + var plan = ComputerPlayer.BuildPlan(new List(), Difficulty.Hard, 180, new Random(1)); + Assert.AreEqual(0, plan.Count); + } + + [TestMethod] + public void botJoinsAsAPlayerWhenRequested() + { + ServerController c = new ServerController(); + Game g = c.getServer().newGame(); + g.enableBot(Difficulty.Medium); + + Assert.IsTrue(g.isBotEnabled()); + Assert.IsNotNull(g.getUser(ComputerPlayer.BotName)); + } + + [TestMethod] + public void botPlaysOnlyWhatIsDue() + { + Game g = new Game(1, DateTime.Now); + g.enableBot(Difficulty.Hard); + g.setState(Game.State.Playing); + g.planBotMoves(new Random(3)); + + User bot = g.getUser(ComputerPlayer.BotName); + + g.advanceBot(0); + int atStart = bot.getWordsUsed().Count; + + g.advanceBot(180); + int atEnd = bot.getWordsUsed().Count; + + Assert.IsTrue(atEnd >= atStart, "word count must not go backwards"); + Assert.IsTrue(atEnd > 0, "the computer should have played by the end of the round"); + } + + [TestMethod] + public void finishBotReleasesEverything() + { + Game g = new Game(2, DateTime.Now); + g.enableBot(Difficulty.Hard); + g.setState(Game.State.Playing); + g.planBotMoves(new Random(11)); + + g.finishBot(); + + User bot = g.getUser(ComputerPlayer.BotName); + Assert.AreEqual(g.getBotPlanCount(), bot.getWordsUsed().Count); + } + + [TestMethod] + public void gamesWithoutABotAreUnaffected() + { + Game g = new Game(3, DateTime.Now); + g.setState(Game.State.Playing); + + g.planBotMoves(new Random(1)); + g.advanceBot(180); + g.finishBot(); + + Assert.IsFalse(g.isBotEnabled()); + Assert.IsNull(g.getUser(ComputerPlayer.BotName)); + Assert.AreEqual(0, g.getPlayerCount()); + } + + [TestMethod] + public void difficultyParsingFallsBackToMedium() + { + Assert.AreEqual(Difficulty.Easy, ComputerPlayer.ParseDifficulty("easy")); + Assert.AreEqual(Difficulty.Hard, ComputerPlayer.ParseDifficulty("HARD")); + Assert.AreEqual(Difficulty.Medium, ComputerPlayer.ParseDifficulty("nonsense")); + Assert.AreEqual(Difficulty.Medium, ComputerPlayer.ParseDifficulty(null)); + } + } +} From cc0504cde2336a157fcdb0cac5a4f4a2a6caac0a Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:10:07 -0500 Subject: [PATCH 4/6] Stop browsers serving stale client code after a deploy The client is plain files with unversioned names, so a browser that had already loaded them kept running the old JavaScript and CSS after a deploy. This was not theoretical: during testing the browser held a cached apis.js and could not see a newly added function even though the server was serving it correctly. A returning player would have hit exactly that. Markup, script and styles are now sent with no-cache so the browser revalidates; static files already carry ETags, so the check is a cheap 304 rather than a re-download. Images keep a long cache since their contents do not change. Co-Authored-By: Claude Opus 5 --- Boggle/Program.cs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Boggle/Program.cs b/Boggle/Program.cs index 4c0ca31..6f10547 100644 --- a/Boggle/Program.cs +++ b/Boggle/Program.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Boggle.Services; using Microsoft.AspNetCore.Builder; @@ -36,7 +37,25 @@ { DefaultFileNames = new List { "democlient.html" } }); -app.UseStaticFiles(); + +// The client is plain files with unversioned names, so a browser that cached +// them keeps running old code after a deploy. Asking it to revalidate markup, +// script and styles avoids that; ETags make the check a cheap 304. Images are +// stable, so they keep a long cache. +app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = ctx => + { + string path = ctx.File.Name; + bool revalidate = path.EndsWith(".html", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".css", StringComparison.OrdinalIgnoreCase); + + ctx.Context.Response.Headers["Cache-Control"] = revalidate + ? "no-cache, must-revalidate" + : "public, max-age=604800"; + } +}); app.UseRouting(); From 0a1f996e7504ac2d269d8703262c8bcda6745b73 Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:13:43 -0500 Subject: [PATCH 5/6] Make the site indexable by search engines Nothing told a crawler what this page is, and crawling it had a side effect: a GET to /Server/newGame creates a real game room, so an indexer walking the API would have generated rooms on every pass. - robots.txt allowing the site but disallowing /Server/ - sitemap.xml, referenced from robots.txt - A descriptive title and description, a canonical link, and Open Graph and Twitter card tags so a shared link previews properly - A marked spot for the Search Console verification tag Co-Authored-By: Claude Opus 5 --- Boggle/wwwroot/democlient.html | 12 ++++++++++-- Boggle/wwwroot/robots.txt | 8 ++++++++ Boggle/wwwroot/sitemap.xml | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 Boggle/wwwroot/robots.txt create mode 100644 Boggle/wwwroot/sitemap.xml diff --git a/Boggle/wwwroot/democlient.html b/Boggle/wwwroot/democlient.html index d98d63c..5978ce9 100644 --- a/Boggle/wwwroot/democlient.html +++ b/Boggle/wwwroot/democlient.html @@ -3,8 +3,16 @@ - - Boggle + + + + + + + + + + Boggle - online word game diff --git a/Boggle/wwwroot/robots.txt b/Boggle/wwwroot/robots.txt new file mode 100644 index 0000000..7c56b20 --- /dev/null +++ b/Boggle/wwwroot/robots.txt @@ -0,0 +1,8 @@ +User-agent: * +Allow: / + +# The JSON API is not content, and crawling it has side effects: a GET to +# /Server/newGame creates a real game room. +Disallow: /Server/ + +Sitemap: https://boggle-fxe8hwa0duhvc8hc.centralus-01.azurewebsites.net/sitemap.xml diff --git a/Boggle/wwwroot/sitemap.xml b/Boggle/wwwroot/sitemap.xml new file mode 100644 index 0000000..7155ae7 --- /dev/null +++ b/Boggle/wwwroot/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://boggle-fxe8hwa0duhvc8hc.centralus-01.azurewebsites.net/ + monthly + 1.0 + + From ab5fd31a504faae8a28a1c130993e1fb411e1313 Mon Sep 17 00:00:00 2001 From: Ssavan99 <58150774+Ssavan99@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:20:06 -0500 Subject: [PATCH 6/6] Add the Google Search Console verification tag Fills the placeholder left in the head so the property can be verified via the HTML tag method. This token is a public site identifier, not a secret. Co-Authored-By: Claude Opus 5 --- Boggle/wwwroot/democlient.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Boggle/wwwroot/democlient.html b/Boggle/wwwroot/democlient.html index 5978ce9..97c0c9a 100644 --- a/Boggle/wwwroot/democlient.html +++ b/Boggle/wwwroot/democlient.html @@ -11,7 +11,7 @@ - + Boggle - online word game