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
@@ -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));
}