Finish Tic-Tac-Toe Game Backend

This commit is contained in:
Alan Moon 2025-07-05 13:08:16 -07:00
parent 0508db6742
commit 7fb10cc728
6 changed files with 199 additions and 104 deletions

View File

@ -3,6 +3,7 @@
public enum GameStatus public enum GameStatus
{ {
WaitingForPlayer, WaitingForPlayer,
SelectingSymbol,
Ongoing, Ongoing,
P1Win, P1Win,
P2Win, P2Win,

View File

@ -1,6 +1,7 @@
using qtc_api.Enums; using qtc_api.Enums;
using qtc_api.Models; using qtc_api.Models;
using qtc_api.Schema; using qtc_api.Schema;
using qtc_api.Services.GameRoomService;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Timers; using System.Timers;
@ -11,115 +12,95 @@ namespace qtc_api.Hubs
{ {
private IUserService _userService; private IUserService _userService;
private ILogger<TicTacToeHub> _logger; private ILogger<TicTacToeHub> _logger;
private GameRoomService _gameRoomService;
private Dictionary<Guid, GameRoom> GameRooms = []; public TicTacToeHub(ILogger<TicTacToeHub> logger, IUserService userService, GameRoomService gameRoomService)
public TicTacToeHub(ILogger<TicTacToeHub> logger, IUserService userService)
{ {
_userService = userService; _userService = userService;
_gameRoomService = gameRoomService;
_logger = logger; _logger = logger;
} }
public async override Task OnConnectedAsync() public override async Task OnDisconnectedAsync(Exception? exception)
{ {
// tell the client that the server is finding a room // find any existing room user was in
await Clients.Client(Context.ConnectionId).SendAsync("FindingRoom"); var room = _gameRoomService.GameRooms.FirstOrDefault(e => (e.Player1?.Id == Context.UserIdentifier) || (e.Player2?.Id == Context.UserIdentifier));
if (room != null)
// check game rooms dictionary for any open rooms (any rooms that are waiting on players)
if(GameRooms.Count > 0 && GameRooms.Any(e => e.Value.Status == GameStatus.WaitingForPlayer))
{ {
// get first available room waiting for a player // just end the game
var roomKVP = GameRooms.FirstOrDefault(e => (e.Value.Status == GameStatus.WaitingForPlayer) || (e.Value.Status == GameStatus.PlayerDisconnected)); room.Status = GameStatus.PlayerDisconnected;
var room = roomKVP.Value; string usernameDisconnected = string.Empty;
var roomId = roomKVP.Key.ToString();
if (room != null && room.Status == GameStatus.WaitingForPlayer) // determine which player to null out
{ if (room.Player1?.Id == Context.UserIdentifier) { usernameDisconnected = room.Player1!.Username; room.Player1 = null; }
// get user from user identifier else { usernameDisconnected = room.Player2!.Username; room.Player2 = null; }
var user = await _userService.GetUserById(Context.UserIdentifier ?? "");
if (user != null && user.Success && user.Data != null) bool isWin = room.Status == GameStatus.P1Win || room.Status == GameStatus.P2Win || room.Status == GameStatus.NoWin;
{ if(!isWin)
// set player 2 await Clients.Group(room.Id).SendAsync("GameEnd", room, usernameDisconnected);
room.Player2 = user.Data;
// add player to room // remove the room
await Groups.AddToGroupAsync(Context.ConnectionId, roomId); _gameRoomService.RemoveRoom(room);
// start symbol selection (on player one)
await Clients.User(room.Player1.Id).SendAsync("SelectSymbol");
// set room status to ongoing
room.Status = GameStatus.Ongoing;
}
} else if (room != null && room.Status == GameStatus.PlayerDisconnected)
{
// add player back into group
await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
if (room.Player1?.Id == Context.UserIdentifier) await Clients.Client(roomId).SendAsync("HostReconnect");
else await Clients.Client(roomId).SendAsync("PlayerReconnect");
}
} else
{
// get user from user identifier
var user = await _userService.GetUserById(Context.UserIdentifier ?? "");
if(user != null && user.Success && user.Data != null)
{
// create a room
var guid = new Guid();
GameRooms.Add(guid, new GameRoom { Status = GameStatus.WaitingForPlayer, Player1 = user.Data });
var guidString = guid.ToString();
// add player to new room
await Groups.AddToGroupAsync(Context.ConnectionId, guidString);
// send waiting for player message
await Clients.Group(guidString).SendAsync("WaitingForPlayer");
}
} }
} }
public async override Task OnDisconnectedAsync(Exception? exception) [HubMethodName("FindRoom")]
public async Task FindRoom()
{ {
// find the game room the user is in // find user from useridentifier
var roomKVP = GameRooms.FirstOrDefault(e => (e.Value.Player1?.Id == Context.UserIdentifier) || (e.Value.Player2?.Id == Context.UserIdentifier)); var user = await _userService.GetUserById(Context.UserIdentifier!);
if(roomKVP.Value != null) if (user != null && user.Success && user.Data != null)
{ {
var room = roomKVP.Value; // find any room waiting on a player
var roomId = roomKVP.Key.ToString(); var roomWOP = _gameRoomService.GameRooms.FirstOrDefault(e => e.Status == GameStatus.WaitingForPlayer && e.Player2 == null && e.Player1?.Id != Context.UserIdentifier);
if (roomWOP != null)
{
// add player to said group
await Groups.AddToGroupAsync(Context.ConnectionId, roomWOP.Id);
// inform the room that one of the players has disconnected // set player two
if (room.Player1?.Id == Context.UserIdentifier) await Clients.Group(roomId).SendAsync("HostDisconnected", exception); roomWOP.Player2 = user.Data;
else await Clients.Group(roomId).SendAsync("PlayerDisconnected", exception);
// set room status to discconnect // this game can now start symbol selection
room.Status = GameStatus.PlayerDisconnected; roomWOP.Status = GameStatus.SelectingSymbol;
await Clients.User(roomWOP.Player1!.Id).SendAsync("SelectSymbol", roomWOP);
await Clients.Group(roomWOP.Id).SendAsync("SelectingSymbol", roomWOP);
// schedule the game to be ended if player does not return return;
await ScheduleGameEnd(roomKVP, Context.UserIdentifier); }
// find any room who's player one matches the user and is stuck in 'WaitingForPlayer' status
var roomWFPP1 = _gameRoomService.GameRooms.FirstOrDefault(e => e.Player1?.Id == user.Data.Id && e.Status == GameStatus.WaitingForPlayer);
if (roomWFPP1 != null)
// just remove it
_gameRoomService.RemoveRoom(roomWFPP1);
// if all if statements above don't match, make a new room
var gameRoom = new GameRoom { Id = Guid.NewGuid().ToString(), Player1 = user.Data, Status = GameStatus.WaitingForPlayer };
_gameRoomService.AddRoom(gameRoom);
// add user to group
await Groups.AddToGroupAsync(Context.ConnectionId, gameRoom.Id);
// waiting for player
await Clients.Group(gameRoom.Id).SendAsync("WaitingForPlayer", gameRoom);
} }
} }
[HubMethodName("SetSymbol")] [HubMethodName("SetSymbol")]
public async Task SetPlayerSymbol(User user, TicTacToeSymbol symbol) public async Task SetPlayerSymbol(string roomId, TicTacToeSymbol symbol)
{ {
// find the room // find the room
var roomKVP = GameRooms.FirstOrDefault(e => (e.Value.Player1?.Id == user.Id) || (e.Value.Player2?.Id == user.Id)); var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId);
var room = roomKVP.Value;
if(room != null) if (room != null)
{ {
var roomId = roomKVP.Key.ToString();
// set player one symbol // set player one symbol
room.P1Symbol = symbol; room.P1Symbol = symbol;
// set player two symbol based on what player one picked // set player two symbol based on what player one picked
switch(room.P1Symbol) switch (room.P1Symbol)
{ {
case TicTacToeSymbol.X: case TicTacToeSymbol.X:
room.P2Symbol = TicTacToeSymbol.O; room.P2Symbol = TicTacToeSymbol.O;
@ -130,25 +111,27 @@ namespace qtc_api.Hubs
} }
// the game can now start // the game can now start
await Clients.Group(roomId).SendAsync("GameStart"); room.Status = GameStatus.Ongoing;
await Clients.Group(room.Id).SendAsync("GameStart", room);
// start player ones turn
await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room);
await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room);
} }
} }
[HubMethodName("MakeMove")] [HubMethodName("MakeMove")]
public async Task MakeMoveAsync(TicTacToeMove move) public async Task MakeMove(string roomId, TicTacToeMove move)
{ {
// find the room // find the room
var roomKVP = GameRooms.FirstOrDefault(e => (e.Value.Player1?.Id == move.User.Id) || (e.Value.Player2?.Id == move.User.Id)); var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId);
var room = roomKVP.Value;
if (room != null) if (room != null)
{ {
var roomId = roomKVP.Key.ToString();
// update board based on move (TOOD - figure out a better way to do this) // update board based on move (TOOD - figure out a better way to do this)
if(room.Player1?.Id == move.User.Id) if (room.Player1?.Id == move.User.Id)
{ {
switch(move.Point) switch (move.Point)
{ {
case 1: case 1:
room.Board.Square1 = room.P1Symbol; room.Board.Square1 = room.P1Symbol;
@ -178,7 +161,8 @@ namespace qtc_api.Hubs
room.Board.Square9 = room.P1Symbol; room.Board.Square9 = room.P1Symbol;
break; break;
} }
} else }
else
{ {
switch (move.Point) switch (move.Point)
{ {
@ -213,31 +197,78 @@ namespace qtc_api.Hubs
} }
// send board update // send board update
await Clients.Group(roomId).SendAsync("UpdateBoard", room.Board); await Clients.Group(roomId).SendAsync("UpdateBoard", room, move);
// TODO - win logic // check for draw
if(room.Board.IsDraw())
{
room.Status = GameStatus.NoWin;
await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty);
return;
}
// check for winner
var winningSymbol = room.Board.GetWinner();
if(winningSymbol != TicTacToeSymbol.Blank)
{
if (winningSymbol == room.P1Symbol)
{
// player one has won the game
room.Status = GameStatus.P1Win;
await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty);
} else
{
// player two has won the game
room.Status = GameStatus.P2Win;
await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty);
}
return;
}
// switch turns
if (move.User.Id == room.Player1?.Id)
{
await Clients.User(room.Player1!.Id).SendAsync("EndTurn", room);
await Clients.User(room.Player2!.Id).SendAsync("StartTurn", room);
}
else
{
await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room);
await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room);
}
} }
} }
private Task ScheduleGameEnd(KeyValuePair<Guid, GameRoom> roomKVP, string? userId) [HubMethodName("RestartGame")]
public async Task RestartGame(string roomId)
{ {
System.Timers.Timer tmr = new System.Timers.Timer(TimeSpan.FromSeconds(60)); // find the room
tmr.Start(); var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId);
if(room != null)
tmr.Elapsed += async (sender, args) =>
{ {
var client = Clients.User(userId!); // room still exists and the game can be restarted
if (client == null) room.Status = GameStatus.Ongoing;
{ room.Board = new TicTacToeBoard(); // clears the board
// user never reconnected, end game
roomKVP.Value.Status = GameStatus.NoWin;
await Clients.Group(roomKVP.Key.ToString()).SendAsync("GameEnd", roomKVP.Value.Status);
GameRooms.Remove(roomKVP.Key); await Clients.Group(room.Id).SendAsync("RestartGame", room);
}
};
return Task.CompletedTask; // have player one start their turn
await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room);
await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room);
} else
{
// the user trying to restart the game needs to be informed that the other user has left
await Clients.Client(Context.ConnectionId).SendAsync("CannotRestart");
}
}
[HubMethodName("SendRoomMessage")]
public async Task SendRoomMessage(string roomId, string message)
{
// find the room
var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId);
if (room != null) await Clients.Group(room.Id).SendAsync("ReceiveMessage", message);
} }
} }
} }

View File

@ -14,6 +14,7 @@ global using qtc_api.Hubs;
using qtc_api.Services.RoomService; using qtc_api.Services.RoomService;
using qtc_api.Services.ContactService; using qtc_api.Services.ContactService;
using qtc_api.Services.CurrencyGamesService; using qtc_api.Services.CurrencyGamesService;
using qtc_api.Services.GameRoomService;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -58,6 +59,8 @@ builder.Services.AddScoped<IRoomService, RoomService>();
builder.Services.AddScoped<IContactService, ContactService>(); builder.Services.AddScoped<IContactService, ContactService>();
builder.Services.AddSingleton<CurrencyGamesService>(); builder.Services.AddSingleton<CurrencyGamesService>();
builder.Services.AddSingleton<GameRoomService>();
builder.Services.AddHostedService(provider => provider.GetService<CurrencyGamesService>()!); builder.Services.AddHostedService(provider => provider.GetService<CurrencyGamesService>()!);
var app = builder.Build(); var app = builder.Build();

View File

@ -4,6 +4,7 @@ namespace qtc_api.Schema
{ {
public class GameRoom public class GameRoom
{ {
public string Id { get; set; } = string.Empty;
public GameStatus Status { get; set; } public GameStatus Status { get; set; }
public TicTacToeBoard Board { get; set; } = new(); public TicTacToeBoard Board { get; set; } = new();
public User? Player1 { get; set; } public User? Player1 { get; set; }

View File

@ -13,5 +13,52 @@ namespace qtc_api.Schema
public TicTacToeSymbol Square7 { get; set; } = TicTacToeSymbol.Blank; public TicTacToeSymbol Square7 { get; set; } = TicTacToeSymbol.Blank;
public TicTacToeSymbol Square8 { get; set; } = TicTacToeSymbol.Blank; public TicTacToeSymbol Square8 { get; set; } = TicTacToeSymbol.Blank;
public TicTacToeSymbol Square9 { get; set; } = TicTacToeSymbol.Blank; public TicTacToeSymbol Square9 { get; set; } = TicTacToeSymbol.Blank;
public TicTacToeSymbol GetWinner()
{
// winning possibilities
var lines = new List<(TicTacToeSymbol, TicTacToeSymbol, TicTacToeSymbol)>
{
(Square1, Square2, Square3),
(Square4, Square5, Square6),
(Square7, Square8, Square9),
(Square1, Square4, Square7),
(Square2, Square5, Square8),
(Square3, Square6, Square9),
(Square1, Square5, Square9),
(Square3, Square5, Square7)
};
foreach(var (a, b, c) in lines)
{
if (a != TicTacToeSymbol.Blank && a == b && b == c)
{
return a; // winner
}
}
return TicTacToeSymbol.Blank;
}
public bool IsDraw()
{
// If there is a winner, it's not a draw
if (GetWinner() != TicTacToeSymbol.Blank)
return false;
// Check if all squares are filled
bool allFilled =
Square1 != TicTacToeSymbol.Blank &&
Square2 != TicTacToeSymbol.Blank &&
Square3 != TicTacToeSymbol.Blank &&
Square4 != TicTacToeSymbol.Blank &&
Square5 != TicTacToeSymbol.Blank &&
Square6 != TicTacToeSymbol.Blank &&
Square7 != TicTacToeSymbol.Blank &&
Square8 != TicTacToeSymbol.Blank &&
Square9 != TicTacToeSymbol.Blank;
return allFilled;
}
} }
} }

View File

@ -0,0 +1,12 @@
using qtc_api.Schema;
namespace qtc_api.Services.GameRoomService
{
public class GameRoomService
{
public List<GameRoom> GameRooms { get; set; } = [];
public void AddRoom(GameRoom room) => GameRooms.Add(room);
public void RemoveRoom(GameRoom room) => GameRooms.Remove(room);
}
}