Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions Boggle/Controllers/ServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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 });
}

Expand Down Expand Up @@ -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();
}
Expand All @@ -79,27 +84,43 @@ public IActionResult Index()
return View();
}

public IActionResult newGame()
/// <summary>
/// Creates a game. Pass vsComputer=true to add the computer opponent,
/// optionally with difficulty=easy|medium|hard.
/// </summary>
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()
});
}
}

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;
}
}
Expand All @@ -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);
Expand All @@ -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++)
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Comment on lines +243 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Atomically verify the game is still empty before deletion

When the last existing player leaves while another request is joining the same game, abandoned is computed under lock (g) but deletion happens after that lock is released. The joining request can obtain the game, add its player between lines 219 and 225, and then have the now-nonempty game removed by this unconditional call; its login succeeds, but its next poll returns gameid not found. Make the empty check and removal atomic with respect to both game membership updates and the server dictionary.

Useful? React with 👍 / 👎.

}

return okMsg;
}

public IActionResult guess(int gameId, string username, string strcoords)
Expand All @@ -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))
Expand Down Expand Up @@ -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;
Expand All @@ -271,6 +315,7 @@ public IActionResult resetGame(int gameId)
if (g == null) return gameIdNotFound;
lock (g)
{
g.touch();
g.resetGame();
return okMsg;
}
Expand Down
79 changes: 79 additions & 0 deletions Boggle/Models/BoardSolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;

namespace Boggle.Models
{
/// <summary>
/// Finds every dictionary word that can legally be traced on a board.
///
/// A depth-first walk from each tile, following the same adjacency rules the
/// players get and never reusing a die within one word. The search is kept
/// cheap by pruning on prefixes: as soon as the letters collected so far
/// cannot begin any dictionary word, that whole branch is abandoned. Without
/// that check the walk explores every path on the board regardless of whether
/// it could ever spell anything.
/// </summary>
public static class BoardSolver
{
public const int MinWordLength = 3;

public static List<string> Solve(Board board)
{
return Solve(board, WordDictionary.getInstance());
}

public static List<string> Solve(Board board, WordDictionary dictionary)
{
int size = board.boardSize();
var found = new HashSet<string>(StringComparer.Ordinal);
var visited = new bool[size, size];

for (int r = 0; r < size; r++)
{
for (int c = 0; c < size; c++)
{
walk(board, dictionary, r, c, "", visited, found);
}
}

var words = new List<string>(found);
// Longest first: callers pick by length, and this makes that trivial.
words.Sort((a, b) => b.Length != a.Length
? b.Length - a.Length
: string.CompareOrdinal(a, b));
return words;
}

private static void walk(Board board, WordDictionary dictionary, int r, int c,
string prefix, bool[,] visited, HashSet<string> found)
{
int size = board.boardSize();
if (r < 0 || r >= size || c < 0 || c >= size) return;
if (visited[r, c]) return;

// A die face can be more than one character ("Qu"), so build from the
// face rather than a single char, matching how a guess is assembled.
string word = prefix + board.getDie(r, c).getUpLetter().ToLowerInvariant();

if (!dictionary.HasPrefix(word)) return;

visited[r, c] = true;

if (word.Length >= MinWordLength && dictionary.IsWord(word))
{
found.Add(word);
}

for (int dr = -1; dr <= 1; dr++)
{
for (int dc = -1; dc <= 1; dc++)
{
if (dr == 0 && dc == 0) continue;
walk(board, dictionary, r + dr, c + dc, word, visited, found);
}
}

visited[r, c] = false;
}
}
}
114 changes: 114 additions & 0 deletions Boggle/Models/ComputerPlayer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Boggle.Models
{
public enum Difficulty
{
Easy,
Medium,
Hard
}

/// <summary>
/// One word the computer intends to play, and how many seconds into the
/// round it plays it.
/// </summary>
public class BotMove
{
public string Word { get; set; }
public int AtSecond { get; set; }
}

/// <summary>
/// Turns a solved board into a plausible run of play for the computer.
///
/// The whole plan is decided up front and then released on a clock, so the
/// opponent never needs a thread of its own: the game simply asks which
/// moves are due whenever a client checks in. That keeps it deterministic
/// and testable, and means a paused or abandoned game costs nothing.
/// </summary>
public static class ComputerPlayer
{
public const string BotName = "Computer";

public static Difficulty ParseDifficulty(string value)
{
if (string.IsNullOrWhiteSpace(value)) return Difficulty.Medium;
switch (value.Trim().ToLowerInvariant())
{
case "easy": return Difficulty.Easy;
case "hard": return Difficulty.Hard;
default: return Difficulty.Medium;
}
}

/// <summary>
/// Picks the words the computer will play and spaces them across the
/// round. Harder settings play more words and reach for longer ones,
/// which score far more under the length-based scoring rules.
/// </summary>
public static List<BotMove> BuildPlan(List<string> solvedWords, Difficulty difficulty,
int roundSeconds, Random rnd)
{
var plan = new List<BotMove>();
if (solvedWords == null || solvedWords.Count == 0) return plan;

int target;
int maxLength;
switch (difficulty)
{
case Difficulty.Easy:
target = 5;
maxLength = 4;
break;
case Difficulty.Hard:
target = 24;
maxLength = int.MaxValue;
break;
default:
target = 12;
maxLength = 6;
break;
}

// solvedWords arrives longest-first. Easy and Medium are held back to
// shorter words so the computer stays beatable; Hard takes the best.
var eligible = solvedWords.Where(w => w.Length <= maxLength).ToList();
if (eligible.Count == 0) eligible = solvedWords;

List<string> chosen;
if (difficulty == Difficulty.Hard)
{
chosen = eligible.Take(target).ToList();
}
else
{
// Spread the picks through the eligible set rather than taking a
// single block, so the computer does not always play the same
// cluster of words on a given board.
chosen = eligible.OrderBy(_ => rnd.Next()).Take(target).ToList();
}

if (chosen.Count == 0) return plan;

// Leave a little quiet at each end of the round.
int first = Math.Max(2, (int)(roundSeconds * 0.10));
int last = Math.Max(first + 1, (int)(roundSeconds * 0.90));
int span = last - first;

for (int i = 0; i < chosen.Count; i++)
{
int at = chosen.Count == 1
? first
: first + (int)Math.Round((double)span * i / (chosen.Count - 1));

plan.Add(new BotMove { Word = chosen[i], AtSecond = at });
}

plan.Sort((a, b) => a.AtSecond - b.AtSecond);
return plan;
}
}
}
Loading