2 Commits

Author SHA1 Message Date
460dfd608e checkpoint 2024-11-16 12:37:56 -06:00
13e79eb490 Saving snapshots 2024-11-09 13:35:39 -06:00
22 changed files with 314 additions and 64 deletions

View File

@@ -92,6 +92,7 @@ public class ShogiApplication(
if (moveResult.IsSuccess)
{
await sessionRepository.CreateMove(sessionId, command);
await sessionRepository.CreateState(session);
await gameHubContext.Emit_PieceMoved(sessionId);
return new NoContentResult();
}
@@ -128,4 +129,38 @@ public class ShogiApplication(
return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName!;
}
public async Task<IActionResult> ReadSessionSnapshots(string sessionId)
{
var session = this.ReadSession(sessionId);
if (session == null)
{
return new NotFoundResult();
}
var snapshots = await queryRepository.ReadSessionSnapshots(sessionId);
var boardStates = snapshots.Select(snap => new Contracts.Types.BoardState
{
Board = snap.Board.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value == null
? null
: new Contracts.Types.Piece
{
IsPromoted = kvp.Value.IsPromoted,
Owner = (Contracts.Types.WhichPlayer)kvp.Value.Owner,
WhichPiece = (Contracts.Types.WhichPiece)kvp.Value.WhichPiece,
}),
Player1Hand = snap.Player1Hand.Cast<Contracts.Types.WhichPiece>().ToArray(),
Player2Hand = snap.Player2Hand.Cast<Contracts.Types.WhichPiece>().ToArray(),
PlayerInCheck = snap.PlayerInCheck == null ? null : (Contracts.Types.WhichPlayer)snap.PlayerInCheck,
Victor = snap.IsGameOver
? snap.PlayerInCheck == Repositories.Dto.SessionState.WhichPlayer.Player1 ? Contracts.Types.WhichPlayer.Player2 : Contracts.Types.WhichPlayer.Player1
: null,
WhoseTurn = (Contracts.Types.WhichPlayer)snap.WhoseTurn,
});
return new OkObjectResult(boardStates.ToArray());
}
}

View File

@@ -65,8 +65,8 @@ public class SessionsController(
BoardState = new BoardState
{
Board = session.Board.BoardState.State.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.ToContract(),
Player2Hand = session.Board.BoardState.Player2Hand.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.Select(p => p.WhichPiece.ToContract()).ToArray(),
Player2Hand = session.Board.BoardState.Player2Hand.Select(p => p.WhichPiece.ToContract()).ToArray(),
PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract(),
Victor = session.Board.BoardState.IsCheckmate
@@ -118,4 +118,14 @@ public class SessionsController(
return await application.MovePiece(id, sessionId, command);
}
/// <summary>
/// Returns an array of board states, one per player move of the given session, in the same order that player moves occurred.
/// </summary>
[HttpGet("{sessionId}/History")]
[AllowAnonymous]
public async Task<IActionResult> GetHistory([FromRoute] string sessionId)
{
return await application.ReadSessionSnapshots(sessionId);
}
}

View File

@@ -38,14 +38,6 @@ public static class ContractsExtensions
WhichPiece = piece.WhichPiece.ToContract()
};
public static IReadOnlyList<Piece> ToContract(this List<Domain.ValueObjects.Piece> pieces)
{
return pieces
.Select(ToContract)
.ToList()
.AsReadOnly();
}
public static Dictionary<string, Piece?> ToContract(this ReadOnlyDictionary<string, Domain.ValueObjects.Piece?> boardState) =>
boardState.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToContract());

View File

@@ -0,0 +1,34 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public class Piece
{
public bool IsPromoted { get; set; }
public WhichPiece WhichPiece { get; set; }
public WhichPlayer Owner { get; set; }
public Piece() { }
public Piece(Domain.ValueObjects.Piece piece)
{
IsPromoted = piece.IsPromoted;
WhichPiece = piece.WhichPiece switch
{
Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
Domain.ValueObjects.WhichPiece.King => WhichPiece.King,
Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook,
Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight,
Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance,
Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException()
};
Owner = piece.Owner switch
{
Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException()
};
}
}

View File

@@ -0,0 +1,69 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public class SessionStateDocument
{
public long Id { get; set; }
public Dictionary<string, Piece?> Board { get; set; }
public WhichPiece[] Player1Hand { get; set; }
public WhichPiece[] Player2Hand { get; set; }
public WhichPlayer? PlayerInCheck { get; set; }
public WhichPlayer WhoseTurn { get; set; }
public bool IsGameOver { get; set; }
public string DocumentVersion { get; set; } = "1";
public SessionStateDocument() { }
public SessionStateDocument(Domain.ValueObjects.BoardState boardState)
{
this.Board = boardState.State.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value == null ? null : new Piece(kvp.Value));
this.Player1Hand = boardState.Player1Hand
.Select(piece => Map(piece.WhichPiece))
.ToArray();
this.Player2Hand = boardState.Player2Hand
.Select(piece => Map(piece.WhichPiece))
.ToArray();
this.PlayerInCheck = boardState.InCheck.HasValue
? Map(boardState.InCheck.Value)
: null;
this.IsGameOver = boardState.IsCheckmate;
}
static WhichPiece Map(Domain.ValueObjects.WhichPiece whichPiece)
{
return whichPiece switch
{
Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
Domain.ValueObjects.WhichPiece.King => WhichPiece.King,
Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook,
Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight,
Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance,
Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException()
};
}
static WhichPlayer Map(Domain.ValueObjects.WhichPlayer whichPlayer)
{
return whichPlayer switch
{
Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException()
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public enum WhichPiece
{
King,
GoldGeneral,
SilverGeneral,
Bishop,
Rook,
Knight,
Lance,
Pawn
}

View File

@@ -0,0 +1,7 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public enum WhichPlayer
{
Player1,
Player2
}

View File

@@ -1,7 +1,9 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Api.Repositories.Dto.SessionState;
using System.Data;
using System.Data.SqlClient;
using System.Text.Json;
namespace Shogi.Api.Repositories;
@@ -21,4 +23,27 @@ public class QueryRepository(IConfiguration configuration)
return await results.ReadAsync<SessionDto>();
}
public async Task<List<SessionStateDocument>> ReadSessionSnapshots(string sessionId)
{
using var connection = new SqlConnection(this.connectionString);
var command = connection.CreateCommand();
command.CommandText = "session.ReadStatesBySession";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("SessionId", sessionId);
await using var reader = await command.ExecuteReaderAsync();
var documents = new List<SessionStateDocument>(20);
while (await reader.ReadAsync())
{
var json = reader.GetString("Document");
var document = JsonSerializer.Deserialize<SessionStateDocument>(json);
if (document != null)
{
documents.Add(document);
}
}
return documents;
}
}

View File

@@ -1,9 +1,11 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Api.Repositories.Dto.SessionState;
using Shogi.Contracts.Api.Commands;
using Shogi.Domain.Aggregates;
using System.Data;
using System.Data.SqlClient;
using System.Text.Json;
namespace Shogi.Api.Repositories;
@@ -81,4 +83,19 @@ public class SessionRepository(IConfiguration configuration)
},
commandType: CommandType.StoredProcedure);
}
public async Task CreateState(Session session)
{
var document = new SessionStateDocument(session.Board.BoardState);
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.CreateState",
new
{
SessionId = session.Id.ToString(),
Document = JsonSerializer.Serialize(document)
},
commandType: CommandType.StoredProcedure);
}
}

View File

@@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
namespace Shogi.Contracts.Types;
public class BoardState
{
public Dictionary<string, Piece?> Board { get; set; } = new Dictionary<string, Piece?>();
public IReadOnlyCollection<Piece> Player1Hand { get; set; } = Array.Empty<Piece>();
public IReadOnlyCollection<Piece> Player2Hand { get; set; } = Array.Empty<Piece>();
public Dictionary<string, Piece?> Board { get; set; } = [];
public IReadOnlyCollection<WhichPiece> Player1Hand { get; set; } = [];
public IReadOnlyCollection<WhichPiece> Player2Hand { get; set; } = [];
public WhichPlayer? PlayerInCheck { get; set; }
public WhichPlayer WhoseTurn { get; set; }
public WhichPlayer? Victor { get; set; }

View File

@@ -1,9 +1,8 @@
namespace Shogi.Contracts.Types
{
namespace Shogi.Contracts.Types;
public class Piece
{
public bool IsPromoted { get; set; }
public WhichPiece WhichPiece { get; set; }
public WhichPlayer Owner { get; set; }
}
}

View File

@@ -0,0 +1,6 @@
CREATE PROCEDURE [session].[CreateState]
@SessionId [session].[SessionSurrogateKey],
@Document NVARCHAR(MAX)
AS
INSERT INTO [session].[State] (SessionId, Document) VALUES (@SessionId, @Document);

View File

@@ -0,0 +1,8 @@
CREATE PROCEDURE [session].[ReadStatesBySession]
@SessionId [session].[SessionSurrogateKey]
AS
SELECT Id, SessionId, Document
FROM [session].[State]
WHERE Id = @SessionId
ORDER BY Id ASC;

View File

@@ -0,0 +1,9 @@
CREATE TABLE [session].[State]
(
[Id] BIGINT NOT NULL PRIMARY KEY IDENTITY,
[SessionId] [session].[SessionSurrogateKey] NOT NULL,
[Document] NVARCHAR(MAX) NOT NULL,
CONSTRAINT [FK_State_ToSession] FOREIGN KEY (SessionId) REFERENCES [session].[Session](Id),
CONSTRAINT [StateDocument must be JSON] CHECK(ISJSON(Document)=1)
)

View File

@@ -1,2 +1,3 @@
CREATE TYPE [session].[SessionSurrogateKey]
-- C# Guid
CREATE TYPE [session].[SessionSurrogateKey]
FROM CHAR(36) NOT NULL

View File

@@ -79,6 +79,9 @@
<Build Include="Session\Stored Procedures\ReadSessionsMetadata.sql" />
<Build Include="AspNetUsersId.sql" />
<Build Include="Session\Functions\MaxNewSessionsPerUser.sql" />
<Build Include="Session\Tables\State.sql" />
<Build Include="Session\Stored Procedures\CreateState.sql" />
<Build Include="Session\Stored Procedures\ReadStatesBySession.sql" />
</ItemGroup>
<ItemGroup>
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />

View File

@@ -1,8 +1,7 @@
namespace Shogi.Domain.ValueObjects
{
namespace Shogi.Domain.ValueObjects;
public enum WhichPlayer
{
Player1,
Player2
}
}

View File

@@ -2,6 +2,16 @@
@using System.Text.Json;
<article class="game-board">
<!-- Controls -->
<header class="controls">
<form @onsubmit:preventDefault>
<fieldset class="history" disabled=@Yep>
<legend>Replay</legend>
<button disabled=@Yep>&lt;</button>
<button>&gt;</button>
</fieldset>
</form>
</header>
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
@@ -17,7 +27,7 @@
style="grid-area: @position">
@if (piece != null)
{
<GamePiece Piece="piece" Perspective="Perspective" />
<GamePiece Piece="piece.WhichPiece" RenderUpsideDown="@(piece.Owner != Perspective)" IsPromoted="piece.IsPromoted" />
}
</div>
}
@@ -57,7 +67,7 @@
@foreach (var piece in opponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
<GamePiece Piece="piece" RenderUpsideDown="true" />
</div>
}
}
@@ -86,12 +96,12 @@
<div class="hand">
@if (userHand.Any())
{
@foreach (var piece in userHand)
@foreach (var whichPiece in userHand)
{
<div @onclick="OnClickHandInternal(piece)"
<div @onclick="OnClickHandInternal(whichPiece)"
class="tile"
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
<GamePiece Piece="piece" Perspective="Perspective" />
data-selected="@(whichPiece == SelectedPieceFromHand)">
<GamePiece Piece="whichPiece" RenderUpsideDown="false" />
</div>
}
}
@@ -104,6 +114,7 @@
</article>
@code {
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
/// <summary>
@@ -116,21 +127,26 @@
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public EventCallback<string> OnClickTile { get; set; }
[Parameter] public EventCallback<Piece> OnClickHand { get; set; }
[Parameter] public EventCallback<WhichPiece> OnClickHand { get; set; }
[Parameter] public EventCallback OnClickJoinGame { get; set; }
[Parameter] public bool UseSideboard { get; set; } = true;
[Parameter] public IList<BoardState> History { get; set; }
private IReadOnlyCollection<Piece> opponentHand;
private IReadOnlyCollection<Piece> userHand;
private bool Yep => History.Count == 0;
private IReadOnlyCollection<WhichPiece> opponentHand;
private IReadOnlyCollection<WhichPiece> userHand;
private string? userName;
private string? opponentName;
private int historyIndex;
public GameBoardPresentation()
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
opponentHand = [];
userHand = [];
userName = string.Empty;
opponentName = string.Empty;
History = [];
}
protected override void OnParametersSet()
@@ -138,8 +154,8 @@
base.OnParametersSet();
if (Session == null)
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
opponentHand = [];
userHand = [];
userName = string.Empty;
opponentName = string.Empty;
}
@@ -158,17 +174,16 @@
? this.Session.Player2 ?? "Empty Seat"
: this.Session.Player1;
}
Console.WriteLine("Count: {0}", History.Count);
}
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private bool IsPlayerInCheck => Session?.BoardState.PlayerInCheck == Perspective;
private bool IsOpponentInCheck => Session?.BoardState.PlayerInCheck != null && Session.BoardState.PlayerInCheck != Perspective;
private bool IsPlayerVictor => Session?.BoardState.Victor == Perspective;
private bool IsOpponentVictor => Session?.BoardState.Victor != null && Session.BoardState.Victor != Perspective;
private Func<Task> OnClickTileInternal(string position) => () =>
@@ -180,7 +195,7 @@
return Task.CompletedTask;
};
private Func<Task> OnClickHandInternal(Piece piece) => () =>
private Func<Task> OnClickHandInternal(WhichPiece piece) => () =>
{
if (IsMyTurn)
{

View File

@@ -2,7 +2,7 @@
--ratio: 0.9;
display: grid;
grid-template-columns: min-content repeat(9, minmax(2rem, 4rem)) max-content;
grid-template-rows: repeat(9, 1fr) auto;
grid-template-rows: auto repeat(9, 1fr) auto;
background-color: #444;
gap: 3px;
place-self: center;
@@ -98,6 +98,15 @@
padding: 0.5rem;
}
.controls {
grid-column: span 11;
display: flex;
}
.controls .history {
}
@media all and (max-width: 1000px) {
.game-board {

View File

@@ -136,17 +136,17 @@
}
}
void OnClickHand(Piece piece)
void OnClickHand(WhichPiece piece)
{
if (showPromotePrompt) return;
// Prevent selecting from both the hand and the board.
selectedBoardPosition = null;
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
selectedPieceFromHand = piece== selectedPieceFromHand
// Deselecting the already-selected piece
? selectedPieceFromHand = null
: selectedPieceFromHand = piece.WhichPiece;
: selectedPieceFromHand = piece;
StateHasChanged();
}

View File

@@ -1,32 +1,41 @@
@using Shogi.Contracts.Types
<div class="game-piece" title="@HtmlTitle" data-upsidedown="@(Piece?.Owner != Perspective)" data-owner="@Piece?.Owner.ToString()">
@switch (Piece?.WhichPiece)
<div class="game-piece" title="@HtmlTitle" data-upsidedown="@RenderUpsideDown">
@switch (Piece)
{
case WhichPiece.Bishop:
<Bishop IsPromoted="@IsPromoted" />
break;
case WhichPiece.GoldGeneral:
<GoldGeneral />
break;
case WhichPiece.King:
<ChallengingKing />
break;
case WhichPiece.Knight:
<Knight IsPromoted="@IsPromoted" />
break;
case WhichPiece.Lance:
<Lance IsPromoted="@IsPromoted" />
break;
case WhichPiece.Pawn:
<Pawn IsPromoted="@IsPromoted" />
break;
case WhichPiece.Rook:
<Rook IsPromoted="@IsPromoted" />
break;
case WhichPiece.SilverGeneral:
<SilverGeneral IsPromoted="@IsPromoted" />
break;
default:
@*render nothing*@
break;
@@ -34,15 +43,11 @@
</div>
@code {
[Parameter]
public Contracts.Types.Piece? Piece { get; set; }
[Parameter][EditorRequired] public WhichPiece? Piece { get; set; }
[Parameter] public bool IsPromoted { get; set; } = false;
[Parameter] public bool RenderUpsideDown { get; set; }
[Parameter]
public WhichPlayer Perspective { get; set; }
private bool IsPromoted => Piece != null && Piece.IsPromoted;
private string HtmlTitle => Piece?.WhichPiece switch
private string HtmlTitle => this.Piece switch
{
WhichPiece.Bishop => "Bishop",
WhichPiece.GoldGeneral => "Gold General",

View File

@@ -4,13 +4,8 @@ using System.Linq;
namespace UnitTests
{
public class ShogiShould
public class ShogiShould(ITestOutputHelper console)
{
private readonly ITestOutputHelper console;
public ShogiShould(ITestOutputHelper console)
{
this.console = console;
}
[Fact]
public void MoveAPieceToAnEmptyPosition()