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