-
Notifications
You must be signed in to change notification settings - Fork 5
Instructions 01 MinimalAPI
The game will be accessible via a REST API implemented with ASP.NET Core.
This step can be skipped by using the CNinnovation.Codebreaker.BackendModels NuGet package.
Check the code of the Codebreaker.GameAPIs.Models project. The classes Game and Move represent a stored game.
public class Game(
Guid gameId,
string gameType,
string playerName,
DateTime startTime,
int numberCodes,
int maxMoves) : IGame
{
public Guid GameId { get; } = gameId;
public string GameType { get; } = gameType;
public string PlayerName { get; } = playerName;
public DateTime StartTime { get; } = startTime;
public DateTime? EndTime { get; set; }
public TimeSpan? Duration { get; set; }
public int LastMoveNumber { get; set; } = 0;
public int NumberCodes { get; private set; } = numberCodes;
public int MaxMoves { get; private set; } = maxMoves;
public bool IsVictory { get; set; } = false;
public required IDictionary<string, IEnumerable<string>> FieldValues { get; init; }
public required string[] Codes { get; init; }
public ICollection<Move> Moves { get; } = new List<Move>();
public override string ToString() => $"{GameId}:{GameType} - {StartTime}";
}public class Move(Guid moveId, int moveNumber)
{
public Guid MoveId { get; private set; } = moveId;
public int MoveNumber { get; } = moveNumber;
public required string[] GuessPegs { get; init; }
public required string[] KeyPegs { get; init; }
public override string ToString() => $"{MoveNumber}. {string.Join(':', GuessPegs)}";
}The Game class implements the IGame interface which is used by game analyers zu analyze a game move.
Create an ASP.NET Core 8 project hosting the Minimal API
Create an in-memory games repository class GamesMemoryRepository implementing the interface IGamesRepository. The most important methods are AddGameAsync, AddMoveAsync, and GetGameAsync. All the other methods can throw a NotSupportedException with a simple game flow.
public class GamesMemoryRepository(ILogger<GamesMemoryRepository> logger) : IGamesRepository
{
private readonly ConcurrentDictionary<Guid, Game> _games = new();
private readonly ILogger _logger = logger;
public Task AddGameAsync(Game game, CancellationToken cancellationToken = default)
{
if (!_games.TryAdd(game.GameId, game))
{
_logger.LogWarning("gameid {gameId} already exists", game.GameId);
}
return Task.CompletedTask;
}
public Task AddMoveAsync(Game game, Move move, CancellationToken cancellationToken = default)
{
_games[game.GameId] = game;
return Task.CompletedTask;
}
public Task<Game?> GetGameAsync(Guid gameId, CancellationToken cancellationToken = default)
{
_games.TryGetValue(gameId, out Game? game);
return Task.FromResult(game);
} Create a GamesFactory class. The class from the repo supports different game types. Today we only need the Game6x4 game type. This factory uses a game analyzer from the analyzer library to calculate the move result.
public static class GamesFactory
{
private static readonly string[] s_colors6 = [Colors.Red, Colors.Green, Colors.Blue, Colors.Yellow, Colors.Purple, Colors.Orange];
/// <summary>
/// Creates a game object with specified gameType and playerName.
/// </summary>
/// <param name="gameType">The type of game to be created.</param>
/// <param name="playerName">The name of the player.</param>
/// <returns>The created game object.</returns>
public static Game CreateGame(string gameType, string playerName)
{
Game Create6x4Game() =>
new(Guid.NewGuid(), gameType, playerName, DateTime.Now, 4, 12)
{
FieldValues = new Dictionary<string, IEnumerable<string>>()
{
{ FieldCategories.Colors, s_colors6 }
},
Codes = Random.Shared.GetItems(s_colors6, 4)
};
return gameType switch
{
GameTypes.Game6x4 => Create6x4Game(),
_ => throw new CodebreakerException("Invalid game type") { Code = CodebreakerExceptionCodes.InvalidGameType }
};
}
/// <summary>
/// Applies a player's move to a game and returns a <see cref="Move"/> object that encapsulates the player's guess and the result of the guess.
/// </summary>
/// <param name="game">The game to apply the move to.</param>
/// <param name="guesses">The player's guesses.</param>
/// <param name="moveNumber">The move number.</param>
/// <returns>A <see cref="Move"/> object that encapsulates the player's guess and the result of the guess.</returns>
public static Move ApplyMove(this Game game, string[] guesses, int moveNumber)
{
static TField[] GetGuesses<TField>(IEnumerable<string> guesses)
where TField : IParsable<TField> =>
guesses
.Select(g => TField.Parse(g, default))
.ToArray();
Move GetColorGameGuessAnalyzerResult()
{
ColorGameGuessAnalyzer analyzer = new (game, GetGuesses<ColorField>(guesses), moveNumber);
ColorResult result = analyzer.GetResult();
return new(Guid.NewGuid(), moveNumber)
{
GuessPegs = guesses,
KeyPegs = result.ToStringResults()
};
}
Move move = game.GameType switch
{
GameTypes.Game6x4 => GetColorGameGuessAnalyzerResult(),
_ => throw new CodebreakerException("Invalid game type") { Code = CodebreakerExceptionCodes.InvalidGameType }
};
game.Moves.Add(move);
return move;
}
}Define the IGamesService contract for a game run:
public interface IGamesService
{
Task<Game> StartGameAsync(string gameType, string playerName, CancellationToken cancellationToken = default);
Task<(Game Game, Move Move)> SetMoveAsync(Guid gameId, string[] guesses, int moveNumber, CancellationToken cancellationToken = default);
ValueTask<Game> GetGameAsync(Guid id, CancellationToken cancellationToken = default);
}Implement the GamesService class which injects the IGamesRepository:
public class GamesService(IGamesRepository dataRepository) : IGamesService
{
private readonly IGamesRepository _dataRepository = dataRepository;
public async Task<Game> StartGameAsync(string gameType, string playerName, CancellationToken cancellationToken = default)
{
Game game = GamesFactory.CreateGame(gameType, playerName);
await _dataRepository.AddGameAsync(game, cancellationToken);
return game;
}
public async Task<(Game Game, Move Move)> SetMoveAsync(Guid gameId, string[] guesses, int moveNumber, CancellationToken cancellationToken = default)
{
Game? game = await _dataRepository.GetGameAsync(gameId, cancellationToken);
CodebreakerException.ThrowIfNull(game);
CodebreakerException.ThrowIfEnded(game);
Move move = game.ApplyMove(guesses, moveNumber);
// Update the game in the game-service database
await _dataRepository.AddMoveAsync(game, move, cancellationToken);
return (game, move);
}
// get the game from the cache or the data repository
public async ValueTask<Game> GetGameAsync(Guid gameId, CancellationToken cancellationToken = default)
{
var game = await _dataRepository.GetGameAsync(gameId, cancellationToken);
CodebreakerException.ThrowIfNull(game);
return game;
}
}Implement the DTOs for the API data transfer:
[JsonConverter(typeof(JsonStringEnumConverter<GameType>))]
public enum GameType
{
Game6x4,
TBD
}
public record class CreateGameRequest(GameType GameType, string PlayerName);
public record class CreateGameResponse(Guid GameId, GameType GameType, string PlayerName, int NumberCodes, int MaxMoves)
{
public required IDictionary<string, IEnumerable<string>> FieldValues { get; init; }
}
public record class UpdateGameRequest(Guid GameId, GameType GameType, string PlayerName, int MoveNumber, bool End = false)
{
public string[]? GuessPegs { get; set; }
}
public record class UpdateGameResponse(
Guid GameId,
GameType GameType,
int MoveNumber,
bool Ended,
bool IsVictory,
string[]? Results);Define a game endpoint and define a group:
public static void MapGameEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/games")
.WithTags("Games API");Using the group, specify MapPost to start a game:
group.MapPost("/", async Task<Results<Created<CreateGameResponse>, BadRequest<GameError>>> (
CreateGameRequest request,
IGamesService gameService,
HttpContext context,
CancellationToken cancellationToken) =>
{
Game game;
try
{
game = await gameService.StartGameAsync(request.GameType.ToString(), request.PlayerName, cancellationToken);
}
catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.InvalidGameType)
{
GameError error = new(ErrorCodes.InvalidGameType, $"Game type {request.GameType} does not exist", context.Request.GetDisplayUrl(), Enum.GetNames<GameType>());
return TypedResults.BadRequest(error);
}
return TypedResults.Created($"/games/{game.GameId}", game.AsCreateGameResponse());
})
.WithName("CreateGame")
.WithSummary("Creates and starts a game")
.WithOpenApi(op =>
{
op.RequestBody.Description = "The game type and the player name of the game to create";
return op;
});Using the group, specify MapPatch to update the game with a move:
// Update the game resource with a move
group.MapPatch("/{gameId:guid}", async Task<Results<Ok<UpdateGameResponse>, NotFound, BadRequest<GameError>>> (
Guid gameId,
UpdateGameRequest request,
IGamesService gameService,
HttpContext context,
CancellationToken cancellationToken) =>
{
if (!request.End && request.GuessPegs == null)
{
return TypedResults.BadRequest(new GameError(ErrorCodes.InvalidMove, "End the game or set guesses", context.Request.GetDisplayUrl()));
}
try
{
if (request.End)
{
Game? game = await gameService.EndGameAsync(gameId, cancellationToken);
if (game is null)
return TypedResults.NotFound();
return TypedResults.Ok(game.AsUpdateGameResponse());
}
else
{
(Game game, Move move) = await gameService.SetMoveAsync(gameId, request.GuessPegs!, request.MoveNumber, cancellationToken);
return TypedResults.Ok(game.AsUpdateGameResponse(move.KeyPegs));
}
}
catch (ArgumentException ex) when (ex.HResult is >= 4200 and <= 4500)
{
string url = context.Request.GetDisplayUrl();
return ex.HResult switch
{
4200 => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidGuessNumber, "Invalid number of guesses received", url)),
4300 => TypedResults.BadRequest(new GameError(ErrorCodes.UnexpectedMoveNumber, "Unexpected move number received", url)),
4400 => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidGuess, "Invalid guess values received!", url)),
_ => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidMove,"Invalid move received!", url))
};
}
catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.GameNotFound)
{
return TypedResults.NotFound();
}
catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.GameNotActive)
{
string url = context.Request.GetDisplayUrl();
return TypedResults.BadRequest(new GameError(ErrorCodes.GameNotActive, "The game already ended", url));
}
})
.WithName("SetMove")
.WithSummary("End the game or set a move")
.WithOpenApi(op =>
{
op.Parameters[0].Description = "The id of the game to set a move";
op.RequestBody.Description = "The data for creating the move";
return op;
});Define the MapGet method to return a single game:
// Get game by id
group.MapGet("/{gameId:guid}", async Task<Results<Ok<Game>, NotFound>> (
Guid gameId,
IGamesService gameService,
CancellationToken cancellationToken
) =>
{
Game? game = await gameService.GetGameAsync(gameId, cancellationToken);
if (game is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(game);
})
.WithName("GetGame")
.WithSummary("Gets a game by the given id")
.WithOpenApi(op =>
{
op.Parameters[0].Description = "The id of the game to get";
return op;
});builder.Services.AddSingleton<IGamesRepository, GamesMemoryRepository>();
builder.Services.AddScoped<IGamesService, GamesService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
// options.InjectStylesheet("/swagger-ui/swaggerstyle.css");
options.SwaggerEndpoint("/swagger/v3/swagger.json", "v3");
});
}
app.MapGameEndpoints();Create an HTTP file to test the API:
@Codebreaker.GameAPIs_HostAddress = http://localhost:9400
@ContentType = application/json
### Create a game
POST {{Codebreaker.GameAPIs_HostAddress}}/games/
Content-Type: {{ContentType}}
{
"gameType": "Game6x4",
"playerName": "test"
}
### Set a move
@gameid = 9421c1cc-4cbf-4485-83c9-da02d47b383e
PATCH {{Codebreaker.GameAPIs_HostAddress}}/games/{{gameid}}
Content-Type: {{ContentType}}
{
"gameType": "Game6x4",
"playerName": "test",
"moveNumber": 4,
"guessPegs": [
"Green",
"Red",
"Green",
"Red"
]
}
### Get game information
GET {{Codebreaker.GameAPIs_HostAddress}}/games/{{gameid}}
Run the service locally and test it with the HTTP file.