This commit is contained in:
2026-10-23 19:23:03 +02:00
parent 8c8240de63
commit 19428550ca
90 changed files with 6912 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
**/bin/
**/obj/
**/.vs/
**/*.user
Dockerfile
.dockerignore
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
*.user
.vs/
@@ -0,0 +1,71 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game", "src\ActionBlocks.Game\ActionBlocks.Game.csproj", "{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Server", "src\ActionBlocks.Server\ActionBlocks.Server.csproj", "{864B69BE-238A-4816-A3FF-95F0649FD99A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game.Tests", "tests\ActionBlocks.Game.Tests\ActionBlocks.Game.Tests.csproj", "{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.Build.0 = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.Build.0 = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{864B69BE-238A-4816-A3FF-95F0649FD99A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+21
View File
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ActionBlocks.Server.sln ./
COPY src/ActionBlocks.Game/ActionBlocks.Game.csproj src/ActionBlocks.Game/
COPY src/ActionBlocks.Server/ActionBlocks.Server.csproj src/ActionBlocks.Server/
COPY tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj tests/ActionBlocks.Game.Tests/
RUN dotnet restore
COPY . .
RUN dotnet test --no-restore -c Release \
&& dotnet publish src/ActionBlocks.Server/ActionBlocks.Server.csproj --no-restore -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app ./
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "ActionBlocks.Server.dll"]
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ActionBlocks.Game</RootNamespace>
</PropertyGroup>
</Project>
@@ -0,0 +1,114 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// The grid of locked cells. Row 0 is the top of the hidden buffer zone; the visible playfield
/// starts at <see cref="BufferRows"/>.
/// </summary>
public sealed class Board
{
private List<Cell[]> _rows;
public Board(int width, int visibleRows, int bufferRows)
{
Width = width;
VisibleRows = visibleRows;
BufferRows = bufferRows;
_rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList();
}
public int Width { get; }
public int VisibleRows { get; }
public int BufferRows { get; }
public int Height => VisibleRows + BufferRows;
public Cell this[int x, int y]
{
get => _rows[y][x];
set => _rows[y][x] = value;
}
public bool IsInside(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
/// <summary>True if the position is outside the board or occupied.</summary>
public bool IsBlocked(int x, int y) => !IsInside(x, y) || !_rows[y][x].IsEmpty;
public bool Fits(ActivePiece piece) => piece.Cells().All(c => !IsBlocked(c.X, c.Y));
public bool IsEmpty => _rows.All(r => r.All(c => c.IsEmpty));
public void Place(ActivePiece piece)
{
var i = 0;
foreach (var c in piece.Cells())
{
var item = i == piece.Spec.ItemMinoIndex ? piece.Spec.ItemId : null;
_rows[c.Y][c.X] = new Cell((byte)piece.Type, item);
i++;
}
}
public IReadOnlyList<int> FullRows() =>
Enumerable.Range(0, Height).Where(y => _rows[y].All(c => !c.IsEmpty)).ToList();
/// <summary>Removes the given rows, drops everything above, and returns the item ids the rows contained.</summary>
public IReadOnlyList<string> ClearRows(IReadOnlyCollection<int> rows)
{
var items = new List<string>();
var remaining = new List<Cell[]>(Height);
for (var y = 0; y < Height; y++)
{
if (rows.Contains(y))
{
items.AddRange(_rows[y].Where(c => c.ItemId is not null).Select(c => c.ItemId!));
}
else
{
remaining.Add(_rows[y]);
}
}
while (remaining.Count < Height)
{
remaining.Insert(0, NewRow());
}
_rows = remaining;
return items;
}
/// <summary>
/// Pushes garbage rows in from the bottom. Returns true if a filled cell was pushed out of the top
/// of the buffer zone (a top out).
/// </summary>
public bool AddGarbage(int count, int holeColumn)
{
var overflow = false;
for (var i = 0; i < count; i++)
{
overflow |= _rows[0].Any(c => !c.IsEmpty);
_rows.RemoveAt(0);
var row = Enumerable.Repeat(new Cell(CellColor.Garbage), Width).ToArray();
row[Math.Clamp(holeColumn, 0, Width - 1)] = Cell.Empty;
_rows.Add(row);
}
return overflow;
}
public void RemoveBottomRow()
{
_rows.RemoveAt(Height - 1);
_rows.Insert(0, NewRow());
}
public void Clear() => _rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList();
/// <summary>Exchanges all locked cells with another board of the same size.</summary>
public void SwapContents(Board other)
{
if (other.Width != Width || other.Height != Height)
{
throw new InvalidOperationException("Boards must have equal dimensions to swap.");
}
(_rows, other._rows) = (other._rows, _rows);
}
private Cell[] NewRow() => Enumerable.Repeat(Cell.Empty, Width).ToArray();
}
@@ -0,0 +1,14 @@
namespace ActionBlocks.Game.Core;
/// <summary>One board cell. <see cref="ItemId"/> is set when the cell carries an item.</summary>
public readonly record struct Cell(byte Color, string? ItemId = null)
{
public static readonly Cell Empty = new(CellColor.Empty);
public bool IsEmpty => Color == CellColor.Empty;
}
public readonly record struct Point(int X, int Y)
{
public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
}
@@ -0,0 +1,26 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// A piece definition as it travels through the queue: its shape plus an optional item
/// hidden inside one of its minos.
/// </summary>
public sealed record PieceSpec(PieceType Type, string? ItemId = null, int ItemMinoIndex = -1)
{
public bool HasItem => ItemId is not null;
}
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
public sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation)
{
public PieceType Type => Spec.Type;
public IEnumerable<Point> Cells()
{
foreach (var m in PieceDefinitions.Minos(Spec.Type, Rotation))
{
yield return new Point(X + m.X, Y + m.Y);
}
}
public ActivePiece Moved(int dx, int dy) => this with { X = X + dx, Y = Y + dy };
}
@@ -0,0 +1,50 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// Shape data for all pieces. Coordinates are relative to the piece's bounding box, y grows downwards.
/// Rotation states are derived by rotating the spawn shape inside its box, which keeps the mino
/// order stable across rotations (needed to track which mino carries an item).
/// </summary>
public static class PieceDefinitions
{
public const int RotationCount = 4;
private static readonly Dictionary<PieceType, (int BoxSize, Point[] Spawn)> SpawnShapes = new()
{
[PieceType.I] = (4, [new(0, 1), new(1, 1), new(2, 1), new(3, 1)]),
[PieceType.J] = (3, [new(0, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.L] = (3, [new(2, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.O] = (2, [new(0, 0), new(1, 0), new(0, 1), new(1, 1)]),
[PieceType.S] = (3, [new(1, 0), new(2, 0), new(0, 1), new(1, 1)]),
[PieceType.Z] = (3, [new(0, 0), new(1, 0), new(1, 1), new(2, 1)]),
[PieceType.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]),
};
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize;
/// <summary>Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L).</summary>
public static IReadOnlyList<Point> Minos(PieceType type, int rotation) => Rotations[type][Mod(rotation)];
public static int Mod(int rotation) => ((rotation % RotationCount) + RotationCount) % RotationCount;
private static Dictionary<PieceType, Point[][]> BuildRotations()
{
var result = new Dictionary<PieceType, Point[][]>();
foreach (var (type, (size, spawn)) in SpawnShapes)
{
var states = new Point[RotationCount][];
states[0] = spawn;
for (var r = 1; r < RotationCount; r++)
{
// Clockwise rotation inside an n x n box: (x, y) -> (n - 1 - y, x)
states[r] = states[r - 1].Select(p => new Point(size - 1 - p.Y, p.X)).ToArray();
}
result[type] = states;
}
return result;
}
}
@@ -0,0 +1,19 @@
namespace ActionBlocks.Game.Core;
/// <summary>The seven four-cell piece shapes. Numeric values double as cell colors (1-7).</summary>
public enum PieceType : byte
{
I = 1,
J = 2,
L = 3,
O = 4,
S = 5,
Z = 6,
T = 7,
}
public static class CellColor
{
public const byte Empty = 0;
public const byte Garbage = 8;
}
@@ -0,0 +1,26 @@
namespace ActionBlocks.Game.Core;
public interface IRandomizer
{
PieceType Next();
}
/// <summary>"Bag" randomizer: every piece shape once per shuffled bag.</summary>
public sealed class SevenBagRandomizer(Random random) : IRandomizer
{
private readonly Queue<PieceType> _bag = new();
public PieceType Next()
{
if (_bag.Count == 0)
{
var pieces = PieceDefinitions.All.ToArray();
random.Shuffle(pieces);
foreach (var p in pieces)
{
_bag.Enqueue(p);
}
}
return _bag.Dequeue();
}
}
@@ -0,0 +1,63 @@
namespace ActionBlocks.Game.Core;
public interface IRotationSystem
{
/// <summary>
/// Tries to rotate <paramref name="piece"/> by <paramref name="direction"/> (+1 = clockwise, -1 = counter-clockwise).
/// Returns the rotated piece and the index of the kick that was used, or null if no kick fits.
/// </summary>
(ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction);
}
/// <summary>Standard rotation system with wall and floor kicks.</summary>
public sealed class SuperRotationSystem : IRotationSystem
{
// Kick tables use the conventional y-up notation and are flipped when applied.
private static readonly Dictionary<(int From, int To), Point[]> JlstzKicks = new()
{
[(0, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)],
[(1, 0)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)],
[(1, 2)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)],
[(2, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)],
[(2, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)],
[(3, 2)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)],
[(3, 0)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)],
[(0, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)],
};
private static readonly Dictionary<(int From, int To), Point[]> IKicks = new()
{
[(0, 1)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)],
[(1, 0)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)],
[(1, 2)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)],
[(2, 1)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)],
[(2, 3)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)],
[(3, 2)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)],
[(3, 0)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)],
[(0, 3)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)],
};
private static readonly Point[] NoKicks = [new(0, 0)];
public (ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction)
{
var from = PieceDefinitions.Mod(piece.Rotation);
var to = PieceDefinitions.Mod(piece.Rotation + direction);
var kicks = piece.Type switch
{
PieceType.O => NoKicks,
PieceType.I => IKicks[(from, to)],
_ => JlstzKicks[(from, to)],
};
for (var i = 0; i < kicks.Length; i++)
{
var candidate = piece with { Rotation = to, X = piece.X + kicks[i].X, Y = piece.Y - kicks[i].Y };
if (board.Fits(candidate))
{
return (candidate, i);
}
}
return null;
}
}
@@ -0,0 +1,33 @@
namespace ActionBlocks.Game;
/// <summary>Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay.</summary>
public sealed record GameSettings
{
public int BoardWidth { get; init; } = 10;
public int VisibleRows { get; init; } = 20;
public int BufferRows { get; init; } = 20;
public int PreviewCount { get; init; } = 5;
public double LockDelaySeconds { get; init; } = 0.5;
public int MaxLockResets { get; init; } = 15;
/// <summary>Soft drop moves this many times faster than normal gravity.</summary>
public double SoftDropFactor { get; init; } = 20;
public double MaxSoftDropRowsPerSecond { get; init; } = 60;
public int LinesPerLevel { get; init; } = 10;
public int MaxLevel { get; init; } = 15;
/// <summary>Chance (0..1) that a newly generated piece carries an item.</summary>
public double ItemSpawnChance { get; init; } = 0.1;
public int MaxInventory { get; init; } = 10;
public double CountdownSeconds { get; init; } = 3;
/// <summary>Seconds per row for a level (marathon speed curve).</summary>
public double GravitySecondsPerRow(int level)
{
var l = Math.Clamp(level, 1, MaxLevel);
return Math.Pow(0.8 - (l - 1) * 0.007, l - 1);
}
}
@@ -0,0 +1,89 @@
namespace ActionBlocks.Game.Garbage;
public enum SpinKind
{
None,
Mini,
Full,
}
/// <summary>Everything that is known about a lock that cleared (or did not clear) lines.</summary>
public sealed record ClearInfo(int Lines, SpinKind Spin, bool BackToBack, int Combo, bool PerfectClear)
{
/// <summary>Quads and spins that clear lines keep a back-to-back chain going.</summary>
public bool IsDifficult => Lines == 4 || (Spin != SpinKind.None && Lines > 0);
}
public interface IGarbageCalculator
{
int LinesToSend(ClearInfo clear);
}
/// <summary>Line attack table used by common multiplayer rule sets.</summary>
public sealed class StandardGarbageCalculator : IGarbageCalculator
{
private static readonly int[] ComboTable = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5];
public int LinesToSend(ClearInfo clear)
{
if (clear.Lines == 0)
{
return 0;
}
var lines = clear.Spin switch
{
SpinKind.Full => clear.Lines * 2,
SpinKind.Mini => clear.Lines - 1,
_ => clear.Lines == 4 ? 4 : clear.Lines - 1,
};
if (clear.BackToBack)
{
lines += 1;
}
lines += ComboTable[Math.Clamp(clear.Combo, 0, ComboTable.Length - 1)];
if (clear.PerfectClear)
{
lines += 10;
}
return lines;
}
}
public interface IScoreCalculator
{
int Score(ClearInfo clear, int level);
}
public sealed class StandardScoreCalculator : IScoreCalculator
{
public int Score(ClearInfo clear, int level)
{
var basePoints = (clear.Spin, clear.Lines) switch
{
(SpinKind.Full, 0) => 400,
(SpinKind.Full, 1) => 800,
(SpinKind.Full, 2) => 1200,
(SpinKind.Full, _) => 1600,
(SpinKind.Mini, 0) => 100,
(SpinKind.Mini, 1) => 200,
(SpinKind.Mini, _) => 400,
(_, 1) => 100,
(_, 2) => 300,
(_, 3) => 500,
(_, 4) => 800,
_ => 0,
};
var points = clear.BackToBack ? basePoints * 3 / 2 : basePoints;
if (clear.Lines > 0 && clear.Combo > 0)
{
points += 50 * clear.Combo;
}
return points * level;
}
}
@@ -0,0 +1,25 @@
namespace ActionBlocks.Game.Items;
/// <summary>
/// An item that can be found inside pieces and used on a player.
/// To add a new item: implement this interface and register it in <see cref="ItemRegistry"/>.
/// The frontend needs a matching icon entry for <see cref="Id"/>.
/// </summary>
public interface IItem
{
/// <summary>Stable identifier shared with the frontend (icons, colors).</summary>
string Id { get; }
/// <summary>Name shown when the item is used.</summary>
string DisplayName { get; }
/// <summary>Relative chance of this item being picked when an item spawns.</summary>
int SpawnWeight => 1;
void Apply(ItemContext context);
}
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now)
{
public bool IsSelfTarget => User == Target;
}
@@ -0,0 +1,54 @@
namespace ActionBlocks.Game.Items;
/// <summary>All items that exist in the game. Register new items here.</summary>
public sealed class ItemRegistry
{
private readonly Dictionary<string, IItem> _items;
public ItemRegistry(IEnumerable<IItem> items)
{
_items = items.ToDictionary(i => i.Id);
}
public static ItemRegistry Default { get; } = new(
[
new AddGarbageLineItem(),
new ClearBottomLineItem(),
new ClearBoardItem(),
new SwapBoardItem(),
new InvisibilityItem(),
]);
public IReadOnlyCollection<IItem> All => _items.Values;
public IItem? Get(string id) => _items.GetValueOrDefault(id);
public IItem PickRandom(Random random)
{
var total = _items.Values.Sum(i => i.SpawnWeight);
var roll = random.Next(total);
foreach (var item in _items.Values)
{
roll -= item.SpawnWeight;
if (roll < 0)
{
return item;
}
}
return _items.Values.First();
}
}
/// <summary>Decides whether a new piece carries an item and in which of its minos.</summary>
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
{
public Core.PieceSpec Decorate(Core.PieceType type)
{
if (registry.All.Count == 0 || random.NextDouble() >= chance)
{
return new Core.PieceSpec(type);
}
var item = registry.PickRandom(random);
return new Core.PieceSpec(type, item.Id, random.Next(4));
}
}
@@ -0,0 +1,51 @@
namespace ActionBlocks.Game.Items;
public sealed class AddGarbageLineItem : IItem
{
public string Id => "add_line";
public string DisplayName => "Add Line";
public void Apply(ItemContext context) =>
context.Target.InsertGarbageNow(1, context.Random.Next(context.Target.Board.Width));
}
public sealed class ClearBottomLineItem : IItem
{
public string Id => "clear_line";
public string DisplayName => "Clear Line";
public void Apply(ItemContext context) => context.Target.RemoveBottomLine();
}
public sealed class ClearBoardItem : IItem
{
public string Id => "nuke";
public string DisplayName => "Wipe Out";
public void Apply(ItemContext context) => context.Target.ClearBoard();
}
public sealed class SwapBoardItem : IItem
{
public string Id => "swap";
public string DisplayName => "Switch";
public void Apply(ItemContext context)
{
if (!context.IsSelfTarget)
{
PlayerGame.SwapBoards(context.User, context.Target);
}
}
}
public sealed class InvisibilityItem : IItem
{
public const double DurationSeconds = 4;
public string Id => "blind";
public string DisplayName => "Blackout";
public void Apply(ItemContext context) =>
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now + DurationSeconds);
}
@@ -0,0 +1,222 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
using ActionBlocks.Game.Targeting;
namespace ActionBlocks.Game;
public enum MatchPhase
{
Countdown,
Running,
Finished,
}
public abstract record MatchEvent;
/// <summary>An item hit a player. Deliberately does not say who used it.</summary>
public sealed record ItemUsedEvent(string TargetId, string ItemId, string ItemName) : MatchEvent;
public sealed record PlayerEliminatedEvent(string PlayerId, int Place) : MatchEvent;
public sealed record MatchPlayer(string Id, string Name);
/// <summary>
/// One round between a set of players. Owns every <see cref="PlayerGame"/>, routes garbage and items
/// between them and decides the winner. Not thread-safe; callers serialize access.
/// </summary>
public sealed class Match
{
private readonly List<PlayerGame> _players;
private readonly ItemRegistry _items;
private readonly IGarbageTargeter _garbageTargeter;
private readonly Random _random;
private readonly List<MatchEvent> _events = [];
private readonly Dictionary<string, int> _places = new();
public Match(
IEnumerable<MatchPlayer> players,
GameSettings? settings = null,
ItemRegistry? items = null,
IGarbageTargeter? garbageTargeter = null,
int? seed = null)
{
Settings = settings ?? new GameSettings();
_items = items ?? ItemRegistry.Default;
_garbageTargeter = garbageTargeter ?? new CyclingGarbageTargeter();
_random = seed is null ? new Random() : new Random(seed.Value);
// Every player receives the same piece sequence, but items are rolled independently.
var pieceSeed = _random.Next();
var rotation = new SuperRotationSystem();
var garbage = new StandardGarbageCalculator();
var score = new StandardScoreCalculator();
_players = players.Select(p =>
{
var playerRandom = new Random(_random.Next());
return new PlayerGame(
p.Id,
p.Name,
Settings,
new SevenBagRandomizer(new Random(pieceSeed)),
new ItemSpawner(_items, Settings.ItemSpawnChance, playerRandom),
rotation,
garbage,
score,
playerRandom);
}).ToList();
CountdownRemaining = Settings.CountdownSeconds;
Phase = CountdownRemaining > 0 ? MatchPhase.Countdown : MatchPhase.Running;
if (Phase == MatchPhase.Running)
{
StartPlayers();
}
}
public GameSettings Settings { get; }
public IReadOnlyList<PlayerGame> Players => _players;
public MatchPhase Phase { get; private set; }
public double CountdownRemaining { get; private set; }
public double Now { get; private set; }
public string? WinnerId { get; private set; }
public IReadOnlyDictionary<string, int> Places => _places;
public ItemRegistry Items => _items;
public PlayerGame? Find(string playerId) => _players.FirstOrDefault(p => p.PlayerId == playerId);
public IReadOnlyList<MatchEvent> DrainEvents()
{
var result = _events.ToList();
_events.Clear();
return result;
}
public void Update(double deltaSeconds)
{
switch (Phase)
{
case MatchPhase.Countdown:
CountdownRemaining -= deltaSeconds;
if (CountdownRemaining <= 0)
{
CountdownRemaining = 0;
Phase = MatchPhase.Running;
StartPlayers();
}
return;
case MatchPhase.Finished:
return;
}
Now += deltaSeconds;
foreach (var player in _players)
{
player.Update(deltaSeconds, Now);
}
AfterStateChange();
}
public void HandleInput(string playerId, InputAction action)
{
if (Phase != MatchPhase.Running || Find(playerId) is not { IsAlive: true } player)
{
return;
}
if (action == InputAction.UseItem)
{
UseItem(player);
}
else
{
player.HandleInput(action);
}
AfterStateChange();
}
/// <summary>Selects who receives the player's items. Unknown or eliminated targets fall back to self.</summary>
public void SelectTarget(string playerId, string targetId)
{
if (Find(playerId) is not { } player)
{
return;
}
player.TargetId = Find(targetId) is { IsAlive: true } ? targetId : playerId;
}
public void Forfeit(string playerId)
{
if (Find(playerId) is { IsAlive: true } player)
{
player.Forfeit();
AfterStateChange();
}
}
private void StartPlayers()
{
foreach (var player in _players)
{
player.Start();
}
AfterStateChange();
}
private void UseItem(PlayerGame user)
{
var target = Find(user.TargetId) is { IsAlive: true } t ? t : user;
if (user.Inventory.Count == 0)
{
return;
}
var itemId = user.TakeFirstItem()!;
if (_items.Get(itemId) is not { } item)
{
return;
}
item.Apply(new ItemContext(user, target, _random, Now));
_events.Add(new ItemUsedEvent(target.PlayerId, item.Id, item.DisplayName));
}
private void AfterStateChange()
{
foreach (var player in _players)
{
foreach (var attack in player.DrainOutgoingAttacks())
{
_garbageTargeter.NextTarget(player, _players)?.QueueGarbage(attack);
}
}
var alive = _players.Count(p => p.IsAlive);
// Players eliminated in the same step share the best place still available.
var newlyEliminated = _players.Where(p => !p.IsAlive && !_places.ContainsKey(p.PlayerId)).ToList();
foreach (var player in newlyEliminated)
{
_places[player.PlayerId] = alive + 1;
_events.Add(new PlayerEliminatedEvent(player.PlayerId, alive + 1));
}
foreach (var player in _players.Where(p => Find(p.TargetId) is { IsAlive: false }))
{
player.TargetId = player.PlayerId;
}
var finished = _players.Count > 1 ? alive <= 1 : alive == 0;
if (Phase == MatchPhase.Running && finished)
{
Phase = MatchPhase.Finished;
var winner = _players.FirstOrDefault(p => p.IsAlive);
if (winner is not null)
{
_places[winner.PlayerId] = 1;
WinnerId = winner.PlayerId;
}
else if (_players.Count > 0)
{
WinnerId = _players.MinBy(p => _places[p.PlayerId])!.PlayerId;
}
}
}
}
@@ -0,0 +1,559 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
namespace ActionBlocks.Game;
public enum InputAction
{
MoveLeft,
MoveRight,
RotateCw,
RotateCcw,
SoftDropStart,
SoftDropStop,
HardDrop,
Hold,
UseItem,
}
/// <summary>
/// Simulation of a single player's field: falling piece, hold, queue, line clears, garbage and items.
/// The object is not thread-safe; the owning <see cref="Match"/> serializes access.
/// </summary>
public sealed class PlayerGame
{
private readonly GameSettings _settings;
private readonly IRandomizer _randomizer;
private readonly ItemSpawner _itemSpawner;
private readonly IRotationSystem _rotation;
private readonly IGarbageCalculator _garbageCalculator;
private readonly IScoreCalculator _scoreCalculator;
private readonly Random _random;
private readonly List<PieceSpec> _next = [];
private readonly List<string> _inventory = [];
private readonly List<int> _pendingGarbage = [];
private readonly List<int> _outgoingAttacks = [];
private double _gravityProgress;
private double _lockTimer;
private int _lockResets;
private int _lowestY;
private bool _lastActionWasRotation;
private int _lastKickIndex;
private bool _backToBackActive;
public PlayerGame(
string playerId,
string name,
GameSettings settings,
IRandomizer randomizer,
ItemSpawner itemSpawner,
IRotationSystem rotation,
IGarbageCalculator garbageCalculator,
IScoreCalculator scoreCalculator,
Random random)
{
PlayerId = playerId;
Name = name;
TargetId = playerId;
_settings = settings;
_randomizer = randomizer;
_itemSpawner = itemSpawner;
_rotation = rotation;
_garbageCalculator = garbageCalculator;
_scoreCalculator = scoreCalculator;
_random = random;
Board = new Board(settings.BoardWidth, settings.VisibleRows, settings.BufferRows);
}
public string PlayerId { get; }
public string Name { get; }
public Board Board { get; }
public ActivePiece? Active { get; private set; }
public PieceSpec? Hold { get; private set; }
public bool HoldUsed { get; private set; }
public IReadOnlyList<PieceSpec> Next => _next;
public IReadOnlyList<string> Inventory => _inventory;
public int PendingGarbage => _pendingGarbage.Sum();
public bool IsSoftDropping { get; private set; }
public int Score { get; private set; }
public int Lines { get; private set; }
public int Level { get; private set; } = 1;
public int Combo { get; private set; } = -1;
public ClearInfo? LastClear { get; private set; }
public bool IsAlive { get; private set; } = true;
public double Now { get; private set; }
/// <summary>Match time until which locked cells and the ghost piece are hidden from this player.</summary>
public double InvisibleUntil { get; set; }
public bool IsInvisible => Now < InvisibleUntil;
/// <summary>The player selected as item target (own id = self).</summary>
public string TargetId { get; set; }
public void Start()
{
FillQueue();
SpawnNext();
}
public void Update(double deltaSeconds, double now)
{
Now = now;
if (!IsAlive || Active is null)
{
return;
}
var secondsPerRow = _settings.GravitySecondsPerRow(Level);
var rowsPerSecond = 1 / secondsPerRow;
if (IsSoftDropping)
{
rowsPerSecond = Math.Min(rowsPerSecond * _settings.SoftDropFactor, _settings.MaxSoftDropRowsPerSecond);
}
_gravityProgress += deltaSeconds * rowsPerSecond;
while (_gravityProgress >= 1)
{
_gravityProgress -= 1;
if (!TryMove(0, 1))
{
_gravityProgress = 0;
break;
}
if (IsSoftDropping)
{
Score += 1;
}
}
if (IsGrounded())
{
_lockTimer += deltaSeconds;
if (_lockTimer >= _settings.LockDelaySeconds || _lockResets >= _settings.MaxLockResets)
{
Lock();
}
}
}
public void HandleInput(InputAction action)
{
if (!IsAlive || Active is null)
{
if (action == InputAction.SoftDropStop)
{
IsSoftDropping = false;
}
return;
}
switch (action)
{
case InputAction.MoveLeft:
TryShift(-1);
break;
case InputAction.MoveRight:
TryShift(1);
break;
case InputAction.RotateCw:
TryRotate(1);
break;
case InputAction.RotateCcw:
TryRotate(-1);
break;
case InputAction.SoftDropStart:
IsSoftDropping = true;
break;
case InputAction.SoftDropStop:
IsSoftDropping = false;
break;
case InputAction.HardDrop:
HardDrop();
break;
case InputAction.Hold:
HoldPiece();
break;
case InputAction.UseItem:
// Items need a target and are resolved by the match.
break;
}
}
/// <summary>Where the active piece would land.</summary>
public ActivePiece? Ghost()
{
if (Active is null)
{
return null;
}
var ghost = Active;
while (Board.Fits(ghost.Moved(0, 1)))
{
ghost = ghost.Moved(0, 1);
}
return ghost;
}
/// <summary>Queues incoming garbage; it rises when the player next locks a piece without clearing lines.</summary>
public void QueueGarbage(int lines)
{
if (lines > 0 && IsAlive)
{
_pendingGarbage.Add(lines);
}
}
/// <summary>Returns and clears the attacks produced since the last call.</summary>
public IReadOnlyList<int> DrainOutgoingAttacks()
{
var result = _outgoingAttacks.ToList();
_outgoingAttacks.Clear();
return result;
}
public string? TakeFirstItem()
{
if (_inventory.Count == 0)
{
return null;
}
var item = _inventory[0];
_inventory.RemoveAt(0);
return item;
}
public void AddItem(string itemId)
{
if (_inventory.Count < _settings.MaxInventory)
{
_inventory.Add(itemId);
}
}
public void InsertGarbageNow(int lines, int holeColumn)
{
if (!IsAlive)
{
return;
}
if (Board.AddGarbage(lines, holeColumn))
{
TopOut();
return;
}
ResolveActiveOverlap();
}
public void RemoveBottomLine()
{
if (!IsAlive)
{
return;
}
Board.RemoveBottomRow();
ResolveActiveOverlap();
}
public void ClearBoard()
{
if (IsAlive)
{
Board.Clear();
}
}
public static void SwapBoards(PlayerGame a, PlayerGame b)
{
if (!a.IsAlive || !b.IsAlive)
{
return;
}
a.Board.SwapContents(b.Board);
a.ResolveActiveOverlap();
b.ResolveActiveOverlap();
}
public void Forfeit() => TopOut();
private void FillQueue()
{
while (_next.Count < Math.Max(1, _settings.PreviewCount))
{
_next.Add(_itemSpawner.Decorate(_randomizer.Next()));
}
}
private void SpawnNext()
{
var spec = _next[0];
_next.RemoveAt(0);
FillQueue();
Spawn(spec);
}
private void Spawn(PieceSpec spec)
{
var box = PieceDefinitions.BoxSize(spec.Type);
// Spawn in the two rows above the visible field, centered and rounded to the left.
var piece = new ActivePiece(spec, (Board.Width - box) / 2, Board.BufferRows - 2, 0);
if (!Board.Fits(piece))
{
Active = piece;
TopOut();
return;
}
Active = piece;
_gravityProgress = 0;
_lockTimer = 0;
_lockResets = 0;
_lastActionWasRotation = false;
_lowestY = piece.Y;
// Pieces move down one row immediately after appearing.
TryMove(0, 1);
}
private bool IsGrounded() => Active is not null && !Board.Fits(Active.Moved(0, 1));
private bool TryMove(int dx, int dy)
{
if (Active is null)
{
return false;
}
var moved = Active.Moved(dx, dy);
if (!Board.Fits(moved))
{
return false;
}
Active = moved;
_lastActionWasRotation = false;
if (moved.Y > _lowestY)
{
_lowestY = moved.Y;
_lockResets = 0;
_lockTimer = 0;
}
return true;
}
private void TryShift(int dx)
{
var wasGrounded = IsGrounded();
if (TryMove(dx, 0))
{
RegisterLockReset(wasGrounded);
}
}
private void TryRotate(int direction)
{
if (Active is null)
{
return;
}
var wasGrounded = IsGrounded();
var result = _rotation.TryRotate(Board, Active, direction);
if (result is null)
{
return;
}
Active = result.Value.Piece;
_lastActionWasRotation = true;
_lastKickIndex = result.Value.KickIndex;
if (Active.Y > _lowestY)
{
_lowestY = Active.Y;
_lockResets = 0;
_lockTimer = 0;
}
RegisterLockReset(wasGrounded);
}
private void RegisterLockReset(bool wasGrounded)
{
if (wasGrounded || IsGrounded())
{
_lockResets++;
_lockTimer = 0;
}
}
private void HardDrop()
{
var distance = 0;
while (Active is not null && Board.Fits(Active.Moved(0, 1)))
{
Active = Active.Moved(0, 1);
_lastActionWasRotation = false;
distance++;
}
Score += distance * 2;
Lock();
}
private void HoldPiece()
{
if (HoldUsed || Active is null)
{
return;
}
var current = Active.Spec;
HoldUsed = true;
if (Hold is null)
{
Hold = current;
SpawnNext();
}
else
{
var held = Hold;
Hold = current;
Spawn(held);
}
}
private SpinKind DetectSpin(ActivePiece piece)
{
if (piece.Type != PieceType.T || !_lastActionWasRotation)
{
return SpinKind.None;
}
// Corners of the 3x3 box: top-left, top-right, bottom-right, bottom-left.
Point[] corners = [new(0, 0), new(2, 0), new(2, 2), new(0, 2)];
var occupied = corners.Select(c => Board.IsBlocked(piece.X + c.X, piece.Y + c.Y)).ToArray();
if (occupied.Count(o => o) < 3)
{
return SpinKind.None;
}
// The two corners on the side the T points to.
var rotation = PieceDefinitions.Mod(piece.Rotation);
var front = (occupied[rotation], occupied[(rotation + 1) % 4]);
var fullFront = front is (true, true);
return fullFront || _lastKickIndex == 4 ? SpinKind.Full : SpinKind.Mini;
}
private void Lock()
{
if (Active is null)
{
return;
}
var piece = Active;
var spin = DetectSpin(piece);
var lockOut = piece.Cells().All(c => c.Y < Board.BufferRows);
Board.Place(piece);
Active = null;
if (lockOut)
{
TopOut();
return;
}
var rows = Board.FullRows();
foreach (var item in Board.ClearRows(rows))
{
AddItem(item);
}
var lines = rows.Count;
Combo = lines > 0 ? Combo + 1 : -1;
var difficult = lines == 4 || (spin != SpinKind.None && lines > 0);
var backToBack = lines > 0 && difficult && _backToBackActive;
if (lines > 0)
{
_backToBackActive = difficult;
}
var clear = new ClearInfo(lines, spin, backToBack, Math.Max(Combo, 0), lines > 0 && Board.IsEmpty);
if (lines > 0 || spin != SpinKind.None)
{
LastClear = clear;
}
Score += _scoreCalculator.Score(clear, Level);
Lines += lines;
Level = Math.Min(_settings.MaxLevel, 1 + Lines / _settings.LinesPerLevel);
var attack = CancelPendingGarbage(_garbageCalculator.LinesToSend(clear));
if (attack > 0)
{
_outgoingAttacks.Add(attack);
}
if (lines == 0 && !ApplyPendingGarbage())
{
return;
}
HoldUsed = false;
SpawnNext();
}
/// <summary>Uses outgoing lines to cancel incoming garbage first; returns what is left to send.</summary>
private int CancelPendingGarbage(int attack)
{
while (attack > 0 && _pendingGarbage.Count > 0)
{
var cancel = Math.Min(attack, _pendingGarbage[0]);
attack -= cancel;
_pendingGarbage[0] -= cancel;
if (_pendingGarbage[0] == 0)
{
_pendingGarbage.RemoveAt(0);
}
}
return attack;
}
/// <summary>Raises all pending garbage. Returns false if the player topped out.</summary>
private bool ApplyPendingGarbage()
{
foreach (var chunk in _pendingGarbage)
{
if (Board.AddGarbage(chunk, _random.Next(Board.Width)))
{
_pendingGarbage.Clear();
TopOut();
return false;
}
}
_pendingGarbage.Clear();
return true;
}
/// <summary>After the board changed under the active piece, push the piece up until it fits again.</summary>
private void ResolveActiveOverlap()
{
if (Active is null || Board.Fits(Active))
{
return;
}
for (var dy = 1; dy <= Board.Height; dy++)
{
var candidate = Active.Moved(0, -dy);
if (Board.Fits(candidate))
{
Active = candidate;
_lowestY = candidate.Y;
return;
}
}
TopOut();
}
private void TopOut()
{
IsAlive = false;
IsSoftDropping = false;
_pendingGarbage.Clear();
}
}
@@ -0,0 +1,34 @@
namespace ActionBlocks.Game.Targeting;
public interface IGarbageTargeter
{
/// <summary>Picks who receives the next garbage attack from <paramref name="sender"/>, or null if nobody can.</summary>
PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList<PlayerGame> players);
}
/// <summary>Cycles through the living opponents of each sender in turn.</summary>
public sealed class CyclingGarbageTargeter : IGarbageTargeter
{
private readonly Dictionary<string, int> _cursor = new();
public PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList<PlayerGame> players)
{
var opponents = players.Where(p => p != sender).ToList();
if (opponents.Count == 0)
{
return null;
}
var start = _cursor.GetValueOrDefault(sender.PlayerId);
for (var i = 0; i < opponents.Count; i++)
{
var index = (start + i) % opponents.Count;
if (opponents[index].IsAlive)
{
_cursor[sender.PlayerId] = index + 1;
return opponents[index];
}
}
return null;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ActionBlocks.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ActionBlocks.Game\ActionBlocks.Game.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
namespace ActionBlocks.Server.Dtos;
public sealed record LobbyPlayerDto(string Id, string Name, bool IsHost);
public sealed record ResultDto(string Id, string Name, int Place, int Score, int Lines);
public sealed record LobbyDto(
string Code,
string HostId,
string Phase,
int MaxPlayers,
IReadOnlyList<LobbyPlayerDto> Players,
IReadOnlyList<ResultDto>? LastResults);
public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby);
public sealed record PieceDto(int Type, string? ItemId, int ItemIndex);
public sealed record ItemCellDto(int X, int Y, string ItemId);
public sealed record ActivePieceDto(int Type, int[][] Cells, int ItemIndex, string? ItemId);
public sealed record PlayerStateDto(
string Id,
string Name,
bool Alive,
int? Place,
string Board,
IReadOnlyList<ItemCellDto> Items,
ActivePieceDto? Active,
int[][]? Ghost,
PieceDto? Hold,
bool HoldUsed,
IReadOnlyList<PieceDto> Next,
IReadOnlyList<string> Inventory,
int Score,
int Lines,
int Level,
int PendingGarbage,
double InvisibleRemaining);
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
public sealed record MatchEventDto(
string Type,
string? TargetId = null,
string? ItemId = null,
string? ItemName = null,
string? PlayerId = null,
int? Place = null);
public sealed record MatchStateDto(
string Phase,
double Countdown,
string? WinnerId,
int Width,
int Height,
IReadOnlyList<PlayerStateDto> Players,
IReadOnlyList<MatchEventDto> Events);
public sealed record ItemInfoDto(string Id, string Name);
@@ -0,0 +1,71 @@
using System.Text;
using ActionBlocks.Game;
using ActionBlocks.Game.Core;
namespace ActionBlocks.Server.Dtos;
/// <summary>Converts game objects into compact network snapshots. Only the visible rows are sent.</summary>
public static class SnapshotMapper
{
public static MatchStateDto ToDto(Match match, IReadOnlyList<MatchEvent> events) => new(
match.Phase.ToString().ToLowerInvariant(),
Math.Round(match.CountdownRemaining, 2),
match.WinnerId,
match.Settings.BoardWidth,
match.Settings.VisibleRows,
match.Players.Select(p => ToDto(p, match)).ToList(),
events.Select(ToDto).ToList());
private static PlayerStateDto ToDto(PlayerGame player, Match match)
{
var board = player.Board;
var cells = new StringBuilder(board.Width * board.VisibleRows);
var items = new List<ItemCellDto>();
for (var y = board.BufferRows; y < board.Height; y++)
{
for (var x = 0; x < board.Width; x++)
{
var cell = board[x, y];
cells.Append((char)('0' + cell.Color));
if (cell.ItemId is not null)
{
items.Add(new ItemCellDto(x, y - board.BufferRows, cell.ItemId));
}
}
}
return new PlayerStateDto(
player.PlayerId,
player.Name,
player.IsAlive,
match.Places.TryGetValue(player.PlayerId, out var place) ? place : null,
cells.ToString(),
items,
player.Active is { } active && player.IsAlive ? ToDto(active, board) : null,
player.IsAlive ? player.Ghost() is { } ghost ? ToCells(ghost, board) : null : null,
player.Hold is { } hold ? ToDto(hold) : null,
player.HoldUsed,
player.Next.Select(ToDto).ToList(),
player.Inventory.ToList(),
player.Score,
player.Lines,
player.Level,
player.PendingGarbage,
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2));
}
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
new((int)piece.Type, ToCells(piece, board), piece.Spec.ItemMinoIndex, piece.Spec.ItemId);
private static int[][] ToCells(ActivePiece piece, Board board) =>
piece.Cells().Select(c => new[] { c.X, c.Y - board.BufferRows }).ToArray();
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, spec.ItemId, spec.ItemMinoIndex);
private static MatchEventDto ToDto(MatchEvent e) => e switch
{
ItemUsedEvent used => new MatchEventDto("itemUsed", TargetId: used.TargetId, ItemId: used.ItemId, ItemName: used.ItemName),
PlayerEliminatedEvent eliminated => new MatchEventDto("eliminated", PlayerId: eliminated.PlayerId, Place: eliminated.Place),
_ => new MatchEventDto("unknown"),
};
}
@@ -0,0 +1,134 @@
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
using ActionBlocks.Server.Lobbies;
using Microsoft.AspNetCore.SignalR;
namespace ActionBlocks.Server.Hubs;
/// <summary>
/// Client-facing API. Server-to-client messages: "LobbyUpdated" (LobbyDto) and "MatchState" (MatchStateDto).
/// </summary>
public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub
{
public const string LobbyUpdated = "LobbyUpdated";
public const string MatchState = "MatchState";
public async Task<JoinResultDto> CreateLobby(string name)
{
var (lobby, player) = Run(() => lobbies.Create(Context.ConnectionId, name));
return await Joined(lobby, player);
}
public async Task<JoinResultDto> JoinLobby(string code, string name)
{
var (lobby, player) = Run(() => lobbies.Join(Context.ConnectionId, code, name));
return await Joined(lobby, player);
}
public async Task LeaveLobby()
{
var lobby = lobbies.FindByConnection(Context.ConnectionId);
if (lobby is null)
{
return;
}
await Groups.RemoveFromGroupAsync(Context.ConnectionId, lobby.Code);
await LeaveAndNotify();
}
public async Task StartGame()
{
var lobby = lobbies.FindByConnection(Context.ConnectionId) ?? throw new HubException("You are not in a lobby.");
LobbyDto dto;
lock (lobby.Sync)
{
var player = lobby.FindByConnection(Context.ConnectionId);
if (player is null || player.Id != lobby.HostId)
{
throw new HubException("Only the host can start the game.");
}
if (lobby.IsPlaying)
{
throw new HubException("A game is already running.");
}
lobby.StartMatch(settings);
dto = lobby.ToDto();
}
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
}
public void Input(InputAction action) =>
WithPlayer((lobby, player) =>
{
lobby.Match?.HandleInput(player.Id, action);
lobby.Dirty = true;
});
public void SelectTarget(string targetId) =>
WithPlayer((lobby, player) => lobby.Match?.SelectTarget(player.Id, targetId));
public IReadOnlyList<ItemInfoDto> GetItems() =>
Game.Items.ItemRegistry.Default.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName)).ToList();
public override async Task OnDisconnectedAsync(Exception? exception)
{
await LeaveAndNotify();
await base.OnDisconnectedAsync(exception);
}
private async Task<JoinResultDto> Joined(Lobby lobby, LobbyPlayer player)
{
await Groups.AddToGroupAsync(Context.ConnectionId, lobby.Code);
LobbyDto dto;
lock (lobby.Sync)
{
dto = lobby.ToDto();
}
await Clients.OthersInGroup(lobby.Code).SendAsync(LobbyUpdated, dto);
return new JoinResultDto(player.Id, dto);
}
private async Task LeaveAndNotify()
{
var lobby = lobbies.Leave(Context.ConnectionId);
if (lobby is null)
{
return;
}
LobbyDto dto;
lock (lobby.Sync)
{
lobby.Dirty = true;
dto = lobby.ToDto();
}
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
}
private void WithPlayer(Action<Lobby, LobbyPlayer> action)
{
var lobby = lobbies.FindByConnection(Context.ConnectionId);
if (lobby is null)
{
return;
}
lock (lobby.Sync)
{
if (lobby.FindByConnection(Context.ConnectionId) is { } player)
{
action(lobby, player);
}
}
}
private static T Run<T>(Func<T> action)
{
try
{
return action();
}
catch (LobbyException e)
{
throw new HubException(e.Message);
}
}
}
@@ -0,0 +1,79 @@
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
namespace ActionBlocks.Server.Lobbies;
public sealed record LobbyPlayer(string Id, string ConnectionId, string Name);
/// <summary>A group of players sharing a lobby code. All access goes through <see cref="Sync"/>.</summary>
public sealed class Lobby(string code)
{
public const int MaxPlayers = 8;
private readonly List<LobbyPlayer> _players = [];
public string Code { get; } = code;
public object Sync { get; } = new();
public IReadOnlyList<LobbyPlayer> Players => _players;
public string HostId { get; private set; } = "";
public Match? Match { get; private set; }
public DateTimeOffset? MatchFinishedAt { get; set; }
public IReadOnlyList<ResultDto>? LastResults { get; private set; }
/// <summary>Set when a snapshot should be sent on the next tick regardless of the broadcast rate.</summary>
public bool Dirty { get; set; }
public bool IsPlaying => Match is not null;
public LobbyPlayer? FindByConnection(string connectionId) =>
_players.FirstOrDefault(p => p.ConnectionId == connectionId);
public void Add(LobbyPlayer player)
{
_players.Add(player);
if (_players.Count == 1)
{
HostId = player.Id;
}
}
public void Remove(LobbyPlayer player)
{
_players.Remove(player);
Match?.Forfeit(player.Id);
if (HostId == player.Id)
{
HostId = _players.FirstOrDefault()?.Id ?? "";
}
}
public void StartMatch(GameSettings settings)
{
Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings);
MatchFinishedAt = null;
Dirty = true;
}
public void EndMatch()
{
if (Match is null)
{
return;
}
LastResults = Match.Players
.Select(p => new ResultDto(p.PlayerId, p.Name, Match.Places.GetValueOrDefault(p.PlayerId, 1), p.Score, p.Lines))
.OrderBy(r => r.Place)
.ToList();
Match = null;
MatchFinishedAt = null;
}
public LobbyDto ToDto() => new(
Code,
HostId,
IsPlaying ? "playing" : "waiting",
MaxPlayers,
_players.Select(p => new LobbyPlayerDto(p.Id, p.Name, p.Id == HostId)).ToList(),
LastResults);
}
@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace ActionBlocks.Server.Lobbies;
public sealed class LobbyException(string message) : Exception(message);
/// <summary>Keeps all lobbies in memory and tracks which connection belongs to which lobby.</summary>
public sealed class LobbyManager
{
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 6;
private const int MaxNameLength = 16;
private readonly ConcurrentDictionary<string, Lobby> _lobbies = new();
private readonly ConcurrentDictionary<string, string> _connectionLobby = new();
public IEnumerable<Lobby> All => _lobbies.Values;
public (Lobby Lobby, LobbyPlayer Player) Create(string connectionId, string name)
{
while (true)
{
var lobby = new Lobby(GenerateCode());
if (_lobbies.TryAdd(lobby.Code, lobby))
{
return Join(connectionId, lobby.Code, name);
}
}
}
public (Lobby Lobby, LobbyPlayer Player) Join(string connectionId, string code, string name)
{
if (_connectionLobby.ContainsKey(connectionId))
{
throw new LobbyException("You are already in a lobby.");
}
var normalized = (code ?? "").Trim().ToUpperInvariant();
if (!_lobbies.TryGetValue(normalized, out var lobby))
{
throw new LobbyException("Lobby not found.");
}
lock (lobby.Sync)
{
if (!_lobbies.ContainsKey(normalized))
{
throw new LobbyException("Lobby not found.");
}
if (lobby.Players.Count >= Lobby.MaxPlayers)
{
throw new LobbyException("Lobby is full.");
}
var player = new LobbyPlayer(Guid.NewGuid().ToString("N")[..12], connectionId, CleanName(name, lobby.Players.Count + 1));
lobby.Add(player);
_connectionLobby[connectionId] = lobby.Code;
return (lobby, player);
}
}
public Lobby? FindByConnection(string connectionId) =>
_connectionLobby.TryGetValue(connectionId, out var code) ? _lobbies.GetValueOrDefault(code) : null;
/// <summary>Removes the connection from its lobby. Returns the lobby if it still has players.</summary>
public Lobby? Leave(string connectionId)
{
if (!_connectionLobby.TryRemove(connectionId, out var code) || !_lobbies.TryGetValue(code, out var lobby))
{
return null;
}
lock (lobby.Sync)
{
if (lobby.FindByConnection(connectionId) is { } player)
{
lobby.Remove(player);
}
if (lobby.Players.Count == 0)
{
_lobbies.TryRemove(code, out _);
return null;
}
return lobby;
}
}
private static string GenerateCode() =>
string.Create(CodeLength, 0, (span, _) =>
{
for (var i = 0; i < span.Length; i++)
{
span[i] = CodeAlphabet[RandomNumberGenerator.GetInt32(CodeAlphabet.Length)];
}
});
private static string CleanName(string? name, int fallbackNumber)
{
var cleaned = new string((name ?? "").Where(c => !char.IsControl(c)).ToArray()).Trim();
if (cleaned.Length > MaxNameLength)
{
cleaned = cleaned[..MaxNameLength];
}
return cleaned.Length == 0 ? $"Player {fallbackNumber}" : cleaned;
}
}
@@ -0,0 +1,89 @@
using System.Diagnostics;
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
using ActionBlocks.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace ActionBlocks.Server.Lobbies;
/// <summary>Advances every running match at a fixed rate and pushes snapshots to the lobby's clients.</summary>
public sealed class MatchRunner(LobbyManager lobbies, IHubContext<GameHub> hub, ILogger<MatchRunner> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(1000.0 / 60);
private static readonly TimeSpan ResultsDelay = TimeSpan.FromSeconds(4);
/// <summary>Snapshots are sent every n-th tick unless input made a lobby dirty.</summary>
private const int BroadcastEveryTicks = 2;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
var clock = Stopwatch.StartNew();
var last = clock.Elapsed;
long tick = 0;
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var now = clock.Elapsed;
// Clamp to avoid huge jumps after a stall.
var delta = Math.Min((now - last).TotalSeconds, 0.1);
last = now;
tick++;
foreach (var lobby in lobbies.All)
{
try
{
Step(lobby, delta, tick % BroadcastEveryTicks == 0);
}
catch (Exception e)
{
logger.LogError(e, "Failed to update lobby {Code}", lobby.Code);
}
}
}
}
private void Step(Lobby lobby, double delta, bool broadcastTick)
{
MatchStateDto? snapshot = null;
LobbyDto? lobbyUpdate = null;
lock (lobby.Sync)
{
var match = lobby.Match;
if (match is null)
{
return;
}
match.Update(delta);
var events = match.DrainEvents();
if (broadcastTick || lobby.Dirty || events.Count > 0)
{
snapshot = SnapshotMapper.ToDto(match, events);
lobby.Dirty = false;
}
if (match.Phase == MatchPhase.Finished)
{
lobby.MatchFinishedAt ??= DateTimeOffset.UtcNow;
if (DateTimeOffset.UtcNow - lobby.MatchFinishedAt >= ResultsDelay)
{
lobby.EndMatch();
lobbyUpdate = lobby.ToDto();
}
}
}
if (snapshot is not null)
{
_ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.MatchState, snapshot);
}
if (lobbyUpdate is not null)
{
_ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.LobbyUpdated, lobbyUpdate);
}
}
}
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
using ActionBlocks.Game;
using ActionBlocks.Server.Hubs;
using ActionBlocks.Server.Lobbies;
var builder = WebApplication.CreateBuilder(args);
// Comma separated list of origins allowed to connect, e.g. "https://blocks.example.com".
// Empty = allow any origin (useful for local development).
var allowedOrigins = (builder.Configuration["ALLOWED_ORIGINS"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var hubPath = builder.Configuration["HUB_PATH"] ?? "/hub";
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
{
if (allowedOrigins.Length > 0)
{
policy.WithOrigins(allowedOrigins);
}
else
{
policy.SetIsOriginAllowed(_ => true);
}
policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}));
builder.Services
.AddSignalR()
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Rules can be tuned via configuration, e.g. env Game__ItemSpawnChance=0.2
builder.Services.AddSingleton(builder.Configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings());
builder.Services.AddSingleton<LobbyManager>();
builder.Services.AddHostedService<MatchRunner>();
var app = builder.Build();
app.UseCors();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapHub<GameHub>(hubPath);
app.Run();
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ActionBlocks.Game\ActionBlocks.Game.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
using ActionBlocks.Game.Core;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class CoreTests
{
[Fact]
public void T_piece_rotates_clockwise_to_point_right()
{
var cells = PieceDefinitions.Minos(PieceType.T, 1).ToHashSet();
Assert.Equal(new HashSet<Point> { new(1, 0), new(1, 1), new(2, 1), new(1, 2) }, cells);
}
[Fact]
public void I_piece_vertical_state_uses_third_column()
{
var cells = PieceDefinitions.Minos(PieceType.I, 1);
Assert.All(cells, c => Assert.Equal(2, c.X));
}
[Fact]
public void Four_rotations_return_to_spawn_shape()
{
foreach (var type in PieceDefinitions.All)
{
Assert.Equal(PieceDefinitions.Minos(type, 0), PieceDefinitions.Minos(type, 4));
}
}
[Fact]
public void Seven_bag_deals_every_shape_once_per_bag()
{
var randomizer = new SevenBagRandomizer(new Random(42));
for (var bag = 0; bag < 3; bag++)
{
var pieces = Enumerable.Range(0, 7).Select(_ => randomizer.Next()).ToHashSet();
Assert.Equal(7, pieces.Count);
}
}
[Fact]
public void Rotation_against_wall_uses_kick()
{
var board = new Board(10, 20, 20);
var srs = new SuperRotationSystem();
// Vertical I hugging the left wall (column 0 in state L uses box column 1).
var piece = new ActivePiece(new PieceSpec(PieceType.I), -1, 30, 3);
Assert.True(board.Fits(piece));
var result = srs.TryRotate(board, piece, 1);
Assert.NotNull(result);
Assert.True(result.Value.KickIndex > 0);
Assert.True(board.Fits(result.Value.Piece));
}
[Fact]
public void Clearing_rows_returns_contained_items_and_drops_rows_above()
{
var board = new Board(10, 20, 20);
var bottom = Bottom(board);
FillRow(board, bottom);
board[3, bottom] = new Cell(CellColor.Garbage, "nuke");
board[0, bottom - 1] = new Cell(1);
var items = board.ClearRows(board.FullRows().ToList());
Assert.Equal(["nuke"], items);
Assert.False(board[0, bottom].IsEmpty);
Assert.Equal(1, FilledCount(board));
}
[Fact]
public void Garbage_pushes_rows_up_with_a_hole()
{
var board = new Board(10, 20, 20);
board[5, Bottom(board)] = new Cell(1);
var overflow = board.AddGarbage(2, 4);
Assert.False(overflow);
Assert.True(board[4, Bottom(board)].IsEmpty);
Assert.False(board[5, Bottom(board) - 2].IsEmpty);
Assert.Equal(19, FilledCount(board));
}
}
@@ -0,0 +1,172 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Items;
using ActionBlocks.Game.Targeting;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class ItemTests
{
private static ItemContext Context(PlayerGame user, PlayerGame target, double now = 0) =>
new(user, target, new Random(3), now);
[Fact]
public void Add_line_inserts_one_garbage_row()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
new AddGarbageLineItem().Apply(Context(user, target));
Assert.Equal(9, FilledCount(target.Board));
Assert.Equal(0, FilledCount(user.Board));
}
[Fact]
public void Clear_line_removes_bottom_row()
{
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
target.Board[0, Bottom(target.Board) - 1] = new Cell(1);
new ClearBottomLineItem().Apply(Context(target, target));
Assert.Equal(1, FilledCount(target.Board));
Assert.False(target.Board[0, Bottom(target.Board)].IsEmpty);
}
[Fact]
public void Wipe_out_clears_the_board()
{
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
new ClearBoardItem().Apply(Context(target, target));
Assert.True(target.Board.IsEmpty);
}
[Fact]
public void Switch_exchanges_boards()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
new SwapBoardItem().Apply(Context(user, target));
Assert.Equal(9, FilledCount(user.Board));
Assert.True(target.Board.IsEmpty);
}
[Fact]
public void Switch_pushes_active_piece_up_when_it_would_overlap()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
user.Start();
for (var y = 20; y < target.Board.Height; y++)
{
FillRow(target.Board, y, 0);
}
new SwapBoardItem().Apply(Context(user, target));
Assert.True(user.IsAlive);
Assert.True(user.Board.Fits(user.Active!));
}
[Fact]
public void Blackout_hides_board_for_four_seconds()
{
var target = CreatePlayer("b");
target.Start();
new InvisibilityItem().Apply(Context(target, target, now: 10));
target.Update(0.01, 13.9);
Assert.True(target.IsInvisible);
target.Update(0.01, 14.1);
Assert.False(target.IsInvisible);
}
[Fact]
public void Registry_contains_five_unique_items()
{
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
}
}
public class MatchTests
{
private static Match CreateMatch(params string[] ids) =>
new(ids.Select(id => new MatchPlayer(id, id)), new GameSettings { CountdownSeconds = 0 }, seed: 1);
[Fact]
public void Using_an_item_hits_selected_target_and_emits_event()
{
var match = CreateMatch("a", "b");
var a = match.Find("a")!;
var b = match.Find("b")!;
a.AddItem("add_line");
match.SelectTarget("a", "b");
match.HandleInput("a", InputAction.UseItem);
Assert.Empty(a.Inventory);
Assert.Equal(9, FilledCount(b.Board));
var used = Assert.Single(match.DrainEvents().OfType<ItemUsedEvent>());
Assert.Equal("b", used.TargetId);
}
[Fact]
public void Last_player_alive_wins()
{
var match = CreateMatch("a", "b", "c");
match.Forfeit("b");
Assert.Equal(MatchPhase.Running, match.Phase);
match.Forfeit("c");
Assert.Equal(MatchPhase.Finished, match.Phase);
Assert.Equal("a", match.WinnerId);
Assert.Equal(3, match.Places["b"]);
Assert.Equal(2, match.Places["c"]);
}
[Fact]
public void Target_falls_back_to_self_when_target_is_eliminated()
{
var match = CreateMatch("a", "b", "c");
match.SelectTarget("a", "b");
match.Forfeit("b");
Assert.Equal("a", match.Find("a")!.TargetId);
}
[Fact]
public void Garbage_targets_cycle_through_living_opponents()
{
var match = CreateMatch("a", "b", "c");
var targeter = new CyclingGarbageTargeter();
var a = match.Find("a")!;
var targets = Enumerable.Range(0, 4).Select(_ => targeter.NextTarget(a, match.Players)!.PlayerId).ToList();
Assert.Equal(["b", "c", "b", "c"], targets);
}
[Fact]
public void Countdown_delays_the_start()
{
var match = new Match([new MatchPlayer("a", "a")], new GameSettings { CountdownSeconds = 1 });
Assert.Equal(MatchPhase.Countdown, match.Phase);
Assert.Null(match.Find("a")!.Active);
match.Update(1.01);
Assert.Equal(MatchPhase.Running, match.Phase);
Assert.NotNull(match.Find("a")!.Active);
}
}
@@ -0,0 +1,129 @@
using ActionBlocks.Game.Core;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class PlayerGameTests
{
[Fact]
public void Piece_spawns_centered_and_falls_with_gravity()
{
var player = CreatePlayer(pieces: PieceType.T);
player.Start();
var startY = player.Active!.Y;
Assert.Equal(3, player.Active.X);
player.Update(1.1, 1.1);
Assert.Equal(startY + 1, player.Active!.Y);
}
[Fact]
public void Hard_drop_clears_line_and_collects_item_in_row()
{
var player = CreatePlayer(pieces: PieceType.I);
var bottom = Bottom(player.Board);
FillRow(player.Board, bottom, 3, 4, 5, 6);
player.Board[0, bottom] = new Cell(CellColor.Garbage, "swap");
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Equal(1, player.Lines);
Assert.Equal(["swap"], player.Inventory);
Assert.Equal(0, FilledCount(player.Board));
}
[Fact]
public void Item_inside_piece_is_collected_when_its_row_clears()
{
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
player.Start();
var itemId = player.Active!.Spec.ItemId;
Assert.NotNull(itemId);
player.HandleInput(InputAction.HardDrop);
Assert.Equal([itemId!], player.Inventory);
}
[Fact]
public void Item_stays_on_board_when_row_is_not_cleared()
{
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Empty(player.Inventory);
var bottom = Bottom(player.Board);
Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null);
}
[Fact]
public void Hold_swaps_piece_once_per_lock()
{
var player = CreatePlayer(pieces: [PieceType.T, PieceType.O, PieceType.S]);
player.Start();
player.HandleInput(InputAction.Hold);
Assert.Equal(PieceType.T, player.Hold!.Type);
Assert.Equal(PieceType.O, player.Active!.Type);
player.HandleInput(InputAction.Hold);
Assert.Equal(PieceType.O, player.Active!.Type);
}
[Fact]
public void Pending_garbage_rises_after_a_lock_without_clear()
{
var player = CreatePlayer(pieces: PieceType.O);
player.Start();
player.QueueGarbage(2);
player.HandleInput(InputAction.HardDrop);
var bottom = Bottom(player.Board);
Assert.Equal(9, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].Color == CellColor.Garbage));
Assert.Equal(0, player.PendingGarbage);
}
[Fact]
public void Double_clear_sends_one_line()
{
var player = CreatePlayer(pieces: PieceType.O);
var bottom = Bottom(player.Board);
FillRow(player.Board, bottom, 4, 5);
FillRow(player.Board, bottom - 1, 4, 5);
player.Board[0, bottom - 2] = new Cell(1); // avoid a perfect clear
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Equal([1], player.DrainOutgoingAttacks());
}
[Fact]
public void Stacking_to_the_top_ends_the_game()
{
var player = CreatePlayer(pieces: PieceType.O);
player.Start();
for (var i = 0; i < 30 && player.IsAlive; i++)
{
player.HandleInput(InputAction.HardDrop);
}
Assert.False(player.IsAlive);
}
[Fact]
public void Inventory_is_capped()
{
var player = CreatePlayer();
for (var i = 0; i < 20; i++)
{
player.AddItem("nuke");
}
Assert.Equal(new GameSettings().MaxInventory, player.Inventory.Count);
}
}
@@ -0,0 +1,41 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
namespace ActionBlocks.Game.Tests;
internal sealed class FixedRandomizer(params PieceType[] sequence) : IRandomizer
{
private int _index;
public PieceType Next() => sequence[_index++ % sequence.Length];
}
internal static class TestHelpers
{
public static PlayerGame CreatePlayer(string id = "p1", double itemChance = 0, params PieceType[] pieces) =>
new(
id,
id,
new GameSettings { ItemSpawnChance = itemChance },
new FixedRandomizer(pieces.Length == 0 ? [PieceType.I] : pieces),
new ItemSpawner(ItemRegistry.Default, itemChance, new Random(1)),
new SuperRotationSystem(),
new StandardGarbageCalculator(),
new StandardScoreCalculator(),
new Random(1));
public static int Bottom(Board board) => board.Height - 1;
/// <summary>Fills a row except the given columns.</summary>
public static void FillRow(Board board, int y, params int[] holes)
{
for (var x = 0; x < board.Width; x++)
{
board[x, y] = holes.Contains(x) ? Cell.Empty : new Cell(CellColor.Garbage);
}
}
public static int FilledCount(Board board) =>
Enumerable.Range(0, board.Height).Sum(y => Enumerable.Range(0, board.Width).Count(x => !board[x, y].IsEmpty));
}