diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index f766522..500b893 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -467b7b67ea1a5b2deab9646070fd3096cb487f9f +c7dfbffcc7eb9824c5f54fccb5ea0e0a2daa4fbf diff --git a/starters/csharp/Dockerfile b/starters/csharp/Dockerfile index fe3d0a3..820acef 100644 --- a/starters/csharp/Dockerfile +++ b/starters/csharp/Dockerfile @@ -1,3 +1,4 @@ +# Build stage FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder WORKDIR /app @@ -6,7 +7,17 @@ RUN dotnet restore COPY Program.cs Grid.cs . RUN dotnet publish -c Release -o /out -FROM mcr.microsoft.com/dotnet/runtime:9.0-alpine +# Test stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS test +WORKDIR /app/tests/GridTests +COPY tests/GridTests/GridTests.csproj . +RUN dotnet restore +COPY tests/GridTests/GridTests.cs . +COPY tests/GridTests/Grid.cs . +RUN dotnet test + +# Runtime stage - use ASP.NET Core runtime for web apps +FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine WORKDIR /app COPY --from=builder /out . diff --git a/starters/csharp/Program.cs b/starters/csharp/Program.cs index af5239f..316892e 100644 --- a/starters/csharp/Program.cs +++ b/starters/csharp/Program.cs @@ -25,6 +25,9 @@ var app = builder.Build(); app.MapGet("/health", () => Results.Ok("OK")); +// --- Constants --- +string[] Directions = ["N", "E", "S", "W"]; + app.MapPost("/turn", (HttpContext ctx) => { var signature = ctx.Request.Headers["X-ACB-Signature"].FirstOrDefault() ?? ""; @@ -73,8 +76,6 @@ app.Run(); // --- Strategy --- // Replace this with your own logic! -string[] Directions = ["N", "E", "S", "W"]; - List ComputeMoves(GameState state) { var rows = state.Config.Rows; diff --git a/starters/csharp/README.md b/starters/csharp/README.md index 0b329d8..2a20278 100644 --- a/starters/csharp/README.md +++ b/starters/csharp/README.md @@ -53,6 +53,27 @@ Dockerfile # Container build - `Grid.Neighbors(p, rows, cols)` — 8-directional neighbors with wrap - `Grid.Bfs(start, goal, passable, rows, cols)` — BFS pathfinding, returns path or `null` +## Testing + +Unit tests for the Grid helper methods are provided in the `tests/GridTests/` directory. + +```bash +# Run tests locally +cd tests/GridTests +dotnet restore +dotnet test + +# Run tests with Docker +docker build --target test -t my-bot-tests . +docker run --rm my-bot-tests +``` + +The test suite covers: +- Toroidal distance calculations (Manhattan and Chebyshev) +- Wrap-around behavior at grid boundaries +- 8-directional neighbor enumeration +- BFS pathfinding with obstacles + ## Customization Edit `ComputeMoves()` in `Program.cs` to implement your strategy. The `GameState` record provides: diff --git a/starters/csharp/tests/GridTests/Grid.cs b/starters/csharp/tests/GridTests/Grid.cs new file mode 100644 index 0000000..3879fba --- /dev/null +++ b/starters/csharp/tests/GridTests/Grid.cs @@ -0,0 +1,79 @@ +// Grid utility functions for AI Code Battle. +// +// Provides toroidal distance calculations, neighbor enumeration, +// and BFS pathfinding on a wrapping grid. + +using System.Collections.Generic; + +static class Grid +{ + private static readonly (int dr, int dc)[] Offsets = + { + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1), + }; + + /// Manhattan distance with wrap-around on a toroidal grid. + public static int ToroidalManhattan(int r1, int c1, int r2, int c2, int rows, int cols) + { + int dr = Math.Min(Math.Abs(r1 - r2), rows - Math.Abs(r1 - r2)); + int dc = Math.Min(Math.Abs(c1 - c2), cols - Math.Abs(c1 - c2)); + return dr + dc; + } + + /// Chebyshev distance with wrap-around on a toroidal grid. + public static int ToroidalChebyshev(int r1, int c1, int r2, int c2, int rows, int cols) + { + int dr = Math.Min(Math.Abs(r1 - r2), rows - Math.Abs(r1 - r2)); + int dc = Math.Min(Math.Abs(c1 - c2), cols - Math.Abs(c1 - c2)); + return Math.Max(dr, dc); + } + + /// 8-directional neighbors with wrap-around. + public static Position[] Neighbors(Position p, int rows, int cols) + { + var result = new Position[8]; + for (int i = 0; i < Offsets.Length; i++) + { + result[i] = new Position + { + Row = (p.Row + Offsets[i].dr + rows) % rows, + Col = (p.Col + Offsets[i].dc + cols) % cols, + }; + } + return result; + } + + /// BFS pathfinding on a toroidal grid. + /// Returns path (excluding start) or null if unreachable. + public static List? Bfs(Position start, Position goal, + Func passable, int rows, int cols) + { + if (start.Row == goal.Row && start.Col == goal.Col) + return []; + + var visited = new HashSet<(int, int)> { (start.Row, start.Col) }; + var queue = new Queue<(Position pos, List path)>(); + queue.Enqueue((start, [])); + + while (queue.Count > 0) + { + var (cur, path) = queue.Dequeue(); + foreach (var nb in Neighbors(cur, rows, cols)) + { + var newPath = new List(path) { nb }; + if (nb.Row == goal.Row && nb.Col == goal.Col) + return newPath; + + var key = (nb.Row, nb.Col); + if (!visited.Contains(key) && passable(nb)) + { + visited.Add(key); + queue.Enqueue((nb, newPath)); + } + } + } + return null; + } +} diff --git a/starters/csharp/tests/GridTests/GridTests.cs b/starters/csharp/tests/GridTests/GridTests.cs new file mode 100644 index 0000000..2b997cf --- /dev/null +++ b/starters/csharp/tests/GridTests/GridTests.cs @@ -0,0 +1,268 @@ +using Xunit; + +// Minimal types needed for Grid tests +record Position +{ + public int Row { get; init; } + public int Col { get; init; } +} + +public class GridTests +{ + [Fact] + public void ToroidalManhattan_SamePosition_ReturnsZero() + { + int result = Grid.ToroidalManhattan(5, 5, 5, 5, 10, 10); + Assert.Equal(0, result); + } + + [Fact] + public void ToroidalManhattan_AdacentNoWrap_ReturnsOne() + { + int result = Grid.ToroidalManhattan(5, 5, 5, 6, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalManhattan_WrapAroundRows_ShortestPath() + { + // On a 10x10 grid, distance from row 0 to row 9 should be 1 (wrap) not 9 + int result = Grid.ToroidalManhattan(0, 5, 9, 5, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalManhattan_WrapAroundCols_ShortestPath() + { + // On a 10x10 grid, distance from col 0 to col 9 should be 1 (wrap) not 9 + int result = Grid.ToroidalManhattan(5, 0, 5, 9, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalManhattan_DiagonalNoWrap_ReturnsTwo() + { + int result = Grid.ToroidalManhattan(5, 5, 6, 6, 10, 10); + Assert.Equal(2, result); + } + + [Fact] + public void ToroidalManhattan_OppositeCorners_WrapPath() + { + // On a 10x10 grid, (0,0) to (9,9): best is wrap both ways = 1 + 1 = 2 + int result = Grid.ToroidalManhattan(0, 0, 9, 9, 10, 10); + Assert.Equal(2, result); + } + + [Fact] + public void ToroidalChebyshev_SamePosition_ReturnsZero() + { + int result = Grid.ToroidalChebyshev(5, 5, 5, 5, 10, 10); + Assert.Equal(0, result); + } + + [Fact] + public void ToroidalChebyshev_AdacentNoWrap_ReturnsOne() + { + int result = Grid.ToroidalChebyshev(5, 5, 5, 6, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalChebyshev_DiagonalNoWrap_ReturnsOne() + { + int result = Grid.ToroidalChebyshev(5, 5, 6, 6, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalChebyshev_WrapAroundRows_ShortestPath() + { + int result = Grid.ToroidalChebyshev(0, 5, 9, 5, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalChebyshev_WrapAroundCols_ShortestPath() + { + int result = Grid.ToroidalChebyshev(5, 0, 5, 9, 10, 10); + Assert.Equal(1, result); + } + + [Fact] + public void ToroidalChebyshev_KnightMoveNoWrap_ReturnsTwo() + { + int result = Grid.ToroidalChebyshev(5, 5, 7, 6, 10, 10); + Assert.Equal(2, result); + } + + [Fact] + public void Neighbors_ReturnsEightPositions() + { + var pos = new Position { Row = 5, Col = 5 }; + var neighbors = Grid.Neighbors(pos, 10, 10); + Assert.Equal(8, neighbors.Length); + } + + [Fact] + public void Neighbors_CornerPosition_WrapsCorrectly() + { + var pos = new Position { Row = 0, Col = 0 }; + var neighbors = Grid.Neighbors(pos, 10, 10); + + // Should include wrap-around positions + Assert.Contains(neighbors, n => n.Row == 9 && n.Col == 9); // NW wrap + Assert.Contains(neighbors, n => n.Row == 9 && n.Col == 0); // N wrap + Assert.Contains(neighbors, n => n.Row == 9 && n.Col == 1); // NE wrap + Assert.Contains(neighbors, n => n.Row == 0 && n.Col == 9); // W wrap + Assert.Contains(neighbors, n => n.Row == 0 && n.Col == 1); // E + Assert.Contains(neighbors, n => n.Row == 1 && n.Col == 9); // SW wrap + Assert.Contains(neighbors, n => n.Row == 1 && n.Col == 0); // S + Assert.Contains(neighbors, n => n.Row == 1 && n.Col == 1); // SE + } + + [Fact] + public void Neighbors_EdgePosition_WrapsCorrectly() + { + var pos = new Position { Row = 0, Col = 5 }; + var neighbors = Grid.Neighbors(pos, 10, 10); + + // Should wrap on row axis only + Assert.Contains(neighbors, n => n.Row == 9); + Assert.Contains(neighbors, n => n.Row == 1); + } + + [Fact] + public void Neighbors_AllNeighborsAreDistinct() + { + var pos = new Position { Row = 5, Col = 5 }; + var neighbors = Grid.Neighbors(pos, 10, 10); + var distinct = neighbors.DistinctBy(n => (n.Row, n.Col)).ToArray(); + Assert.Equal(8, distinct.Length); + } + + [Fact] + public void Bfs_StartEqualsGoal_ReturnsEmptyPath() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 5 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + Assert.Empty(path); + } + + [Fact] + public void Bfs_AdacentPosition_ReturnsSingleStep() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 6 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + Assert.Single(path); + Assert.Equal(5, path[0].Row); + Assert.Equal(6, path[0].Col); + } + + [Fact] + public void Bfs_UnobstructedPath_ReturnsShortestPath() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 8 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + Assert.Equal(3, path.Count); + } + + [Fact] + public void Bfs_WrappedPath_FindsShortestRoute() + { + var start = new Position { Row = 0, Col = 0 }; + var goal = new Position { Row = 9, Col = 9 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + // With 8-directional movement, NW from (0,0) wraps to (9,9) in 1 step + Assert.Single(path); + Assert.Equal(9, path[0].Row); + Assert.Equal(9, path[0].Col); + } + + [Fact] + public void Bfs_AllBlocked_ReturnsNull() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 8 }; + bool neverPassable(Position p) => false; + + var path = Grid.Bfs(start, goal, neverPassable, 10, 10); + + Assert.Null(path); + } + + [Fact] + public void Bfs_GoalBlocked_ReturnsNull() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 8 }; + bool goalBlocked(Position p) => p.Row == 5 && p.Col == 8; + + var path = Grid.Bfs(start, goal, goalBlocked, 10, 10); + + Assert.Null(path); + } + + [Fact] + public void Bfs_NavigatesAroundObstacle() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 7 }; + + // Block direct path + bool hasWall(Position p) => !(p.Row == 5 && p.Col == 6); + + var path = Grid.Bfs(start, goal, hasWall, 10, 10); + + Assert.NotNull(path); + Assert.NotEmpty(path); + Assert.Equal(5, path[^1].Row); + Assert.Equal(7, path[^1].Col); + } + + [Fact] + public void Bfs_PathDoesNotIncludeStart() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 5, Col = 7 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + Assert.DoesNotContain(path, p => p.Row == 5 && p.Col == 5); + } + + [Fact] + public void Bfs_PathEndsAtGoal() + { + var start = new Position { Row = 5, Col = 5 }; + var goal = new Position { Row = 7, Col = 8 }; + bool alwaysPassable(Position p) => true; + + var path = Grid.Bfs(start, goal, alwaysPassable, 10, 10); + + Assert.NotNull(path); + Assert.Equal(7, path[^1].Row); + Assert.Equal(8, path[^1].Col); + } +} diff --git a/starters/csharp/tests/GridTests/GridTests.csproj b/starters/csharp/tests/GridTests/GridTests.csproj new file mode 100644 index 0000000..f22af12 --- /dev/null +++ b/starters/csharp/tests/GridTests/GridTests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + +