diff --git a/src/AdventOfCode/Day17.cs b/src/AdventOfCode/Day17.cs
new file mode 100644
index 0000000..9a1d91f
--- /dev/null
+++ b/src/AdventOfCode/Day17.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Collections.Generic;
+using AdventOfCode.Utilities;
+
+namespace AdventOfCode
+{
+ ///
+ /// Solver for Day 17
+ ///
+ public class Day17
+ {
+ public int Part1(string[] input)
+ {
+ LavaGrid grid = LavaGrid.Parse(input);
+ return grid.ShortestPath(0, 3);
+ }
+
+ public int Part2(string[] input)
+ {
+ LavaGrid grid = LavaGrid.Parse(input);
+ return grid.ShortestPath(4, 10);
+ }
+
+ ///
+ /// Lava grid
+ ///
+ /// Grid of tile costs
+ /// Grid width
+ /// Grid height
+ private record LavaGrid(int[,] Grid, int Width, int Height)
+ {
+ ///
+ /// Parse the grid
+ ///
+ /// Input
+ /// Lava grid
+ public static LavaGrid Parse(IReadOnlyList input)
+ {
+ int[,] grid = input.ToGrid();
+
+ return new LavaGrid(grid, input[0].Length, input.Count);
+ }
+
+ ///
+ /// Calculate the shortest valid path from the top left of the grid to the bottom right
+ /// within the given constraints
+ ///
+ /// Minimum moves before we're allowed to make a turn
+ /// Maximum moves before we must make a turn
+ /// Shortest valid path
+ /// No path found
+ public int ShortestPath(int minimumMoves, int maximumMoves)
+ {
+ StepState startSouth = new((0, 1), Bearing.South, 1);
+ StepState startEast = new((1, 0), Bearing.East, 1);
+ Point2D target = (this.Width - 1, this.Height - 1);
+
+ Dictionary distances = new()
+ {
+ [startSouth] = this.Grid[1, 0],
+ [startEast] = this.Grid[0, 1],
+ };
+
+ PriorityQueue queue = new();
+ queue.Enqueue(startSouth, this.Grid[1, 0]);
+ queue.Enqueue(startEast, this.Grid[0, 1]);
+
+ HashSet visited = new();
+
+ while (queue.Count > 0)
+ {
+ StepState current = queue.Dequeue();
+ visited.Add(current);
+
+ if (current.Point == target)
+ {
+ // guaranteed to be shortest because the queue is ordered by cost
+ return distances[current];
+ }
+
+ foreach (StepState next in NextMoves(current, minimumMoves, maximumMoves))
+ {
+ if (!this.InBounds(next.Point))
+ {
+ continue;
+ }
+
+ if (visited.Contains(next))
+ {
+ continue;
+ }
+
+ int nextDistance = distances[current] + this.Grid[next.Point.Y, next.Point.X];
+
+ // only move if it's cheaper than the current best path
+ if (!distances.TryGetValue(next, out int currentBest) || nextDistance < currentBest)
+ {
+ distances[next] = nextDistance;
+ queue.Enqueue(next, nextDistance);
+ }
+ }
+ }
+
+ throw new InvalidOperationException("No path found");
+ }
+
+ ///
+ /// Enumerable the possible next moves from the current state
+ ///
+ /// Current state
+ /// Minimum moves before we're allowed to make a turn
+ /// Maximum moves before we must make a turn
+ /// Valid next moves
+ private static IEnumerable NextMoves(StepState state, int minimumMoves, int maximumMoves)
+ {
+ if (state.Consecutive < minimumMoves)
+ {
+ yield return new StepState(state.Point.Move(state.Bearing), state.Bearing, state.Consecutive + 1);
+
+ // not met minimum yet, so can't make any further moves
+ yield break;
+ }
+
+ if (state.Consecutive < maximumMoves)
+ {
+ yield return new StepState(state.Point.Move(state.Bearing), state.Bearing, state.Consecutive + 1);
+ }
+
+ switch (state.Bearing)
+ {
+ case Bearing.South or Bearing.North:
+ yield return new StepState(state.Point.Move(Bearing.West), Bearing.West, 1);
+ yield return new StepState(state.Point.Move(Bearing.East), Bearing.East, 1);
+ break;
+
+ case Bearing.East or Bearing.West:
+ yield return new StepState(state.Point.Move(Bearing.North), Bearing.North, 1);
+ yield return new StepState(state.Point.Move(Bearing.South), Bearing.South, 1);
+ break;
+ }
+ }
+
+ ///
+ /// Check if the given point is in bounds
+ ///
+ /// Point
+ /// Point is in bounds
+ private bool InBounds(Point2D point) => point.X >= 0 && point.X < this.Width
+ && point.Y >= 0 && point.Y < this.Height;
+ }
+
+ ///
+ /// Step state
+ ///
+ /// Current point
+ /// Bearing taken to enter this point (to prevent backtracking, which is not allowed)
+ /// Number of consecutive steps taken on this bearing
+ private record StepState(Point2D Point, Bearing Bearing, int Consecutive);
+ }
+}
diff --git a/src/AdventOfCode/Utilities/GridUtilities.cs b/src/AdventOfCode/Utilities/GridUtilities.cs
index a195cba..3e7e825 100644
--- a/src/AdventOfCode/Utilities/GridUtilities.cs
+++ b/src/AdventOfCode/Utilities/GridUtilities.cs
@@ -44,12 +44,12 @@ private static readonly (int x, int y)[] Deltas =
///
/// Input lines
/// Grid
- public static T[,] ToGrid(this string[] input)
+ public static T[,] ToGrid(this IReadOnlyList input)
{
// y,x remember, not x,y
- T[,] grid = new T[input.Length, input[0].Length];
+ T[,] grid = new T[input.Count, input[0].Length];
- for (int y = 0; y < input.Length; y++)
+ for (int y = 0; y < input.Count; y++)
{
for (int x = 0; x < input[y].Length; x++)
{
diff --git a/src/AdventOfCode/inputs/day17.txt b/src/AdventOfCode/inputs/day17.txt
new file mode 100644
index 0000000..20a4c86
Binary files /dev/null and b/src/AdventOfCode/inputs/day17.txt differ
diff --git a/tests/AdventOfCode.Tests/Day17Tests.cs b/tests/AdventOfCode.Tests/Day17Tests.cs
new file mode 100644
index 0000000..d010728
--- /dev/null
+++ b/tests/AdventOfCode.Tests/Day17Tests.cs
@@ -0,0 +1,86 @@
+using System.IO;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AdventOfCode.Tests
+{
+ public class Day17Tests
+ {
+ private readonly ITestOutputHelper output;
+ private readonly Day17 solver;
+
+ public Day17Tests(ITestOutputHelper output)
+ {
+ this.output = output;
+ this.solver = new Day17();
+ }
+
+ private static string[] GetRealInput()
+ {
+ string[] input = File.ReadAllLines("inputs/day17.txt");
+ return input;
+ }
+
+ private static string[] GetSampleInput()
+ {
+ return new string[]
+ {
+ "2413432311323",
+ "3215453535623",
+ "3255245654254",
+ "3446585845452",
+ "4546657867536",
+ "1438598798454",
+ "4457876987766",
+ "3637877979653",
+ "4654967986887",
+ "4564679986453",
+ "1224686865563",
+ "2546548887735",
+ "4322674655533",
+ };
+ }
+
+ [Fact]
+ public void Part1_SampleInput_ProducesCorrectResponse()
+ {
+ var expected = 102;
+
+ var result = solver.Part1(GetSampleInput());
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Part1_RealInput_ProducesCorrectResponse()
+ {
+ var expected = 928;
+
+ var result = solver.Part1(GetRealInput());
+ output.WriteLine($"Day 17 - Part 1 - {result}");
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Part2_SampleInput_ProducesCorrectResponse()
+ {
+ var expected = 94;
+
+ var result = solver.Part2(GetSampleInput());
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Part2_RealInput_ProducesCorrectResponse()
+ {
+ var expected = 1104;
+
+ var result = solver.Part2(GetRealInput());
+ output.WriteLine($"Day 17 - Part 2 - {result}");
+
+ Assert.Equal(expected, result);
+ }
+ }
+}