diff --git a/Boggle/Controllers/ServerController.cs b/Boggle/Controllers/ServerController.cs index 02aaf86..d8c0172 100644 --- a/Boggle/Controllers/ServerController.cs +++ b/Boggle/Controllers/ServerController.cs @@ -10,8 +10,9 @@ 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; + private static readonly Random rnd = new Random(); public ServerController() { @@ -22,6 +23,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 }); } @@ -66,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(); } @@ -79,16 +84,27 @@ 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; lock (g) { + 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() }); } } @@ -96,10 +112,15 @@ 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); + // 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; } } @@ -110,6 +131,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); @@ -120,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++) @@ -178,11 +204,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 +226,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 +254,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,9 +298,11 @@ public IActionResult endGame(int gameId) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); if (checkIsEnded(g)) return gameWasEnded; g.setState(Game.State.Ended); + g.finishBot(); calcScores(g); g.updateGameLog(); return okMsg; @@ -271,6 +315,7 @@ public IActionResult resetGame(int gameId) if (g == null) return gameIdNotFound; lock (g) { + g.touch(); g.resetGame(); 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 25ca911..ca300ab 100644 --- a/Boggle/Models/Game.cs +++ b/Boggle/Models/Game.cs @@ -19,8 +19,14 @@ public enum State private Dictionary users; private List> gameLog; private State 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) { } @@ -33,6 +39,110 @@ 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; + } + + /* ----- 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() @@ -158,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/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/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/Program.cs b/Boggle/Program.cs index 762c979..6f10547 100644 --- a/Boggle/Program.cs +++ b/Boggle/Program.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using Boggle.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +10,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 @@ -32,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(); 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/Boggle/wwwroot/css/boggle.css b/Boggle/wwwroot/css/boggle.css index 5a7ac9a..195ed19 100644 --- a/Boggle/wwwroot/css/boggle.css +++ b/Boggle/wwwroot/css/boggle.css @@ -1,66 +1,304 @@ -#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); +} + +#sc_start button, +#sc_lobby button { + height: 3rem; + min-width: 9rem; + margin: .35rem .25rem; + font-size: 1.05rem; + border-radius: 14px; +} + +/* Solo play ---------------------------------------------------------------- */ + +.solo { + margin: 1.75rem auto 0; + padding-top: 1.25rem; + border-top: 1px solid var(--line); + max-width: 420px; } -span { - margin-left: auto; - margin-right: auto; - width: 50%; +.solo label { + display: block; + margin-bottom: .6rem; + font-size: .95rem; + color: var(--muted); } -div { - margin-left: auto; - margin-right: auto; - width: 50%; +.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; } .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..97c0c9a 100644 --- a/Boggle/wwwroot/democlient.html +++ b/Boggle/wwwroot/democlient.html @@ -2,7 +2,17 @@ - Boggle + + + + + + + + + + + Boggle - online word game @@ -19,6 +29,18 @@

ENTER YOUR NAME


+ +
+ +
+ + +
+
@@ -38,11 +60,11 @@

Members:

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

+ Game + · + · +

@@ -70,7 +92,8 @@

Members:

-
+
+ 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/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) { 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 + + 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)); + } + } +} 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()); + } + } +}