I hope you like edge cases, because Day 22 is all about the edge cases. Lets wrap around…
Starting with Part 1 I follow a now familiar pattern: Create a new Actor class, and a new Unreal Test Class, both in C++, and write some tests using IMPLEMENT_SIMPLE_AUTOMATION_TEST that closely follow the puzzle example:
bool AoC2022D22_Test::RunTest(const FString& Parameters) { TArray<FString> Example={ TEXT(" ...#"), TEXT(" .#.."), TEXT(" #..."), TEXT(" ...."), TEXT("...#.......#"), TEXT("........#..."), TEXT("..#....#...."), TEXT("..........#."), TEXT(" ...#...."), TEXT(" .....#.."), TEXT(" .#......"), TEXT(" ......#."), TEXT(""), TEXT("10R5L5R10L4R5L5") }; auto grid = AD22Actor::CreateGrid(Example); FD22Cursor current = AD22Actor::GetInitial(grid); TestEqual(TEXT("Initial position is on 1st Row"), current.Pos.Y, 0.0); TestEqual(TEXT("Initial position is on 9th Column"), current.Pos.X, 8.0); TestEqual(TEXT("Initial direction is Right"), current.GetDirChar(), TCHAR('>')); grid.Log(); FD22Cursor result = AD22Actor::Follow(grid,current,Example.Last()); grid.Log(); int Row = result.Pos.Y+1; int Column = result.Pos.X+1; TestEqual(TEXT("In the above example, the final row is 6"), Row, 6); TestEqual(TEXT("... the final column is 8"), Column, 8); TestEqual(TEXT("... the final facing is 0"), result.GetFacing(), 0); // The final password is the sum of 1000 times the row, 4 times the column, and the facing. TestEqual(TEXT("So, the final password is 1000 * 6 + 4 * 8 + 0: 6032."), result.Password(), 6032); return true; }
In writing my Tests I discover I want Cursor and Grid types, to hold the current state of the head of the path. I define them as structs with the Actor:
// in the header file USTRUCT(BlueprintType) struct FD22Cursor { GENERATED_BODY() FVector2d Pos; FVector2d Dir; TCHAR GetDirChar()const; int GetFacing() const; int Password() const; }; // in the .cpp TCHAR FD22Cursor::GetDirChar() const { if (Dir.X>0) return TCHAR('>'); if (Dir.X<0) return TCHAR('<'); if (Dir.Y>0) return TCHAR('v'); if (Dir.Y<0) return TCHAR('^'); return TCHAR('o'); } int FD22Cursor::GetFacing() const { // Facing is 0 for right (>), 1 for down (v), 2 for left (<), and 3 for up (^). if (Dir.X>0) return 0; if (Dir.X<0) return 2; if (Dir.Y>0) return 1; if (Dir.Y<0) return 3; return -1; } int FD22Cursor::Password() const { int Row = Pos.Y+1; int Column = Pos.X+1; return 1000 * Row + 4 * Column + GetFacing(); }
My Grid uses a single buffer and provides some very basic off-edge behaviors to help keep my path tracing loop light on bounds checking:
USTRUCT(BlueprintType) struct FD22Grid { GENERATED_BODY() int Width=0; int Height=0; TArray<TCHAR> Data; void Set(int x, int y, TCHAR c) { Data[x+y*Width]=c; } bool Set(FVector2d Pos, TCHAR c) { if (Pos.X>=Width || Pos.Y>=Height || Pos.X<0 || Pos.Y<0) return false; Set(Pos.X,Pos.Y,c); return true; } TCHAR Get(int x, int y) const { return Data[x+y*Width]; } TCHAR Get(FVector2d Pos) const { // infinite blank in all directions if (Pos.X>=Width || Pos.Y>=Height || Pos.X<0 || Pos.Y<0) return ' '; return Get(Pos.X,Pos.Y); } void Log(); };
Creating the Grid and Getting the Initial Cursor position are straightforward input handling:
FD22Grid AD22Actor::CreateGrid(const TArray<FString>& Lines) { int i,j=0; for (i=0;i!=Lines.Num();++i) { if (Lines[i].IsEmpty()) break; if (Lines[i].Len()>j) j=Lines[i].Len(); } FD22Grid Result; Result.Width=j; Result.Height=i; Result.Data.Init(TCHAR(' '), i*j); for (i=0;i!=Result.Height;++i) { for (j=0;j!=Lines[i].Len();++j) { Result.Set(j,i,Lines[i][j]); } } return Result; } FD22Cursor AD22Actor::GetInitial(const FD22Grid& Grid) { // You begin the path in the leftmost open tile of the top row of tiles. int Row=0; int Column=0; while (TCHAR(' ')==Grid.Get(Column, Row )) Column++; FD22Cursor Initial; Initial.Pos.X=Column; Initial.Pos.Y=Row; // Initially, you are facing to the right Initial.Dir=FVector2D(1,0); return Initial; }
Turning a 90 rotation for a 2D Vector can be implemented with a X Y swap and negate one, depending on direction:
FVector2d AD22Actor::Turn(const FVector2d& V, TCHAR D) { if (D==TCHAR('R')) { return FVector2d(-V.Y, V.X); } else { ensure(D==TCHAR('L')); return FVector2d(V.Y, -V.X); } }
Taking a single move the key detail is to work the Next after wrapping before updating the position, so wall-blocking works around edges:
FD22Cursor AD22Actor::Move(FD22Grid& Grid, const FD22Cursor& Start, int Steps) { FD22Cursor Result = Start; while(Steps--) { FVector2d Next = Result.Pos+Start.Dir; // continue to step through the blank spaces with wrap around: while(Grid.Get(Next)==TCHAR(' ')) { if (Next.X<0) Next.X+=Grid.Width; else if (Next.X>=Grid.Width) Next.X-=Grid.Width; else if (Next.Y<0) Next.Y+=Grid.Height; else if (Next.Y>=Grid.Height) Next.Y-=Grid.Height; else Next+=Start.Dir; } if (Grid.Get(Next)==TCHAR('#')) break; Grid.Set(Result.Pos, Result.GetDirChar()); Result.Pos=Next; }; return Result; }
Also note, writing the path back into the grid is for debugging / validation.
The Move method is then used to write the path follow. The ReadNum you may recognize from Day 21. It is modified here to take the i parameter by reference, so we can use it as an iterator for parsing number/turn-letter pairs and advance through the path:
FD22Cursor AD22Actor::Follow(FD22Grid& Grid, const FD22Cursor& Start, const FString& Path) { int i=0; FD22Cursor Current = Start; while(i<Path.Len()) { int Steps = ReadNum(Path,i); Current = Move(Grid,Current,Steps); if (i<Path.Len()) Current.Dir = Turn(Current.Dir, Path[i++]); } return Current; }
And that is Part 1 complete. Part 2 explains the map is a Cube that has been unwrapped, and the wrap around should be implemented to move the cursor from face to face.
In a move that might be described as sadistic, the example problem is unfolded with a different face positioning to my problem data. I decide to hard-code my solution to the layout I am given:
7 face-links. Each one bi-directional, each one with 2 axis, each one providing different opportunities for off-by-one errors. Working through each one carefully took some time. I made several mistakes, and a mixture of unit tests, assertions, and visually inspecting the paths from the logging helped me identify.
A modified “Move” handles the different ways you can move between faces. This is not elegant code:
FD22Cursor AD22Actor::CubeMove(FD22Grid& Grid, const FD22Cursor& Start, int Steps) { FD22Cursor Result = Start; while(Steps--) { FVector2d NextDir = Result.Dir; FVector2d Next = Result.Pos+Result.Dir; if (Grid.Get(Next)==TCHAR(' ')) { if (Next.X==-1) { ensure (Next.Y>=100); if (Next.Y<150) { // 0,2 -> 1.0 Next.X = 50; Next.Y = 49 - (Next.Y-100); NextDir = FVector2d(1,0); } else { //0,3 -> 1,0 Next.X = 50 + (Next.Y-150); Next.Y = 0; NextDir = FVector2d(0,1); } } else if (Next.X==49) { ensure (Next.Y<100); if (Next.Y<50) { // 1,0 -> 0,2 Next.X = 0; Next.Y = 149 - Next.Y; NextDir = FVector2d(1,0); } else { //1,1 -> 0,2 Next.X = Next.Y-50; Next.Y = 100; NextDir = FVector2d(0,1); } } else if (Next.X==50 && Next.Y>149) { // 0,3 -> 1,2 Next.X = 50 + (Next.Y - 150); Next.Y = 149; NextDir = FVector2d(0,-1); } else if (Next.X==100 && Result.Dir.X==1) { if (Next.Y<100) { // 1,1 -> 2,0 Next.X = 100 + (Next.Y-50); Next.Y = 49; NextDir = FVector2d(0,-1); } else { // 1,2 -> 2,0 Next.X = 149; Next.Y = 49 - (Next.Y-100); NextDir = FVector2d(-1,0); } } else if (Next.X==150) { // 2,0 -> 1,2 Next.X = 99; Next.Y = 149 - Next.Y; NextDir = FVector2d(-1,0); } else if (Next.Y==-1) { if (Next.X<100) { // 1,0 -> 0,2 Next.Y = 150 + Next.X-50; Next.X = 0; NextDir = FVector2d(1,0); } else { // 2,0 -> 0,3 Next.Y = 199; Next.X = Next.X - 100; NextDir = FVector2d(0,-1); } } else if (Next.Y==50) { // 2,0 -> 1,1 Next.Y = 50 + Next.X-100; Next.X = 99; NextDir = FVector2d(-1,0); } else if (Next.Y==99) { // 0,2 -> 1,1 Next.Y = 50 + Next.X; Next.X = 50; NextDir = FVector2d(1,0); } else if (Next.Y==150) { // 1,2 -> 0,3 Next.Y = 150 + Next.X-50; Next.X = 49; NextDir = FVector2d(-1,0); } else if (Next.Y==200) { // 0,3 -> 2,0 Next.Y = 0; Next.X = Next.X + 100; } else { UE_LOG(LogTemp, Error, TEXT("%i,%i Missing Edge Case Heading %c"), Next.X, Next.Y, Result.GetDirChar()); } } if (Grid.Get(Next)==TCHAR('#')) break; ensure(Grid.Get(Next)!=' '); if (Grid.Set(Result.Pos, Result.GetDirChar())==false) { Grid.Log(); UE_LOG(LogTemp, Error, TEXT("%i,%i Out of Bounds heading %c"), Result.Pos.X, Result.Pos.Y, Result.GetDirChar()); break; } Result.Pos=Next; Result.Dir=NextDir; }; return Result; }
The line “ensure(Grid.Get(Next)!=’ ‘);” was critical to me finding all the bugs, as was testing the travel in a semi procedural test:
FVector2D a[]={{1,0},{1,0},{2,0},{2,0},{2,0},{1,1},{0,3}}; FVector2D b[]={{0,3},{0,2},{0,3},{1,1},{1,2},{0,2},{1,2}}; FVector2D d[]={{0,-1},{-1,0},{0,-1},{0,1},{1,0},{-1,0},{1,0}}; for (int i=0;i!=sizeof(a)/sizeof(FVector);++i) { auto test = MakePart2Grid(); FD22Cursor c; c.Dir = d[i]; c.Pos = a[i] * 50; c = AD22Actor::CubeMove(test, c,50); TestEqual(*FString::FromInt(i), (int)c.Pos.X/50,(int)b[i].X); TestEqual(*FString::FromInt(i), (int)c.Pos.Y/50,(int)b[i].Y); test.Log(); }
Can you think of a better way? Let me know here or on Mastodon.
1 thought on “Advent of Code in Unreal Engine Day 22”