Day 19 has us figuring out the right order to build resource collection devices to get the optimal number of end-of-chain resources. I have approached this as a Depth First Search. My solution works, but completed in a length of time measured in minutes, and would benefit from better pruning.
I am using some structs for the core concepts. The naming is a little tricky, with some overlap between Blueprints the unreal concept (solution space), and Blueprints the puzzle input concept (problem space).
constexpr int D19_None = -1; constexpr int D19_Ore = 0; constexpr int D19_Clay = 1; constexpr int D19_Obs = 2; constexpr int D19_Geode = 3; // material costs for a thing USTRUCT(BlueprintType) struct FD19Costs { GENERATED_BODY() int Mats[4]; }; // material costs for each thing USTRUCT(BlueprintType) struct FD19Blueprint { GENERATED_BODY() UPROPERTY(BlueprintReadWrite) int Id; FD19Costs Bots[4]; int Caps[4]; }; USTRUCT(BlueprintType) struct FD19State { GENERATED_BODY() int Mats[4]={0}; int Bots[4]={0}; };
I will also use Test Automation, and structure the Actor around making that easy:
bool AoC2022D19_Test::RunTest(const FString& Parameters) { FString BP1Line = TEXT("Blueprint 1: Each ore robot costs 4 ore. Each clay robot costs 2 ore. Each obsidian robot costs 3 ore and 14 clay. Each geode robot costs 2 ore and 7 obsidian."); FD19Blueprint BP1 = AD19Actor::ReadBlueprint(BP1Line); TestEqual(TEXT("BP1 Id = 1"), BP1.Id,1); FString BP2Line = TEXT("Blueprint 2: Each ore robot costs 2 ore. Each clay robot costs 3 ore. Each obsidian robot costs 3 ore and 8 clay. Each geode robot costs 3 ore and 12 obsidian."); FD19Blueprint BP2 = AD19Actor::ReadBlueprint(BP2Line); TestEqual(TEXT("BP2 Id = 2"), BP2.Id,2); TestEqual( TEXT("Using blueprint 1 ... the largest number of geodes you could open in 24 minutes is 9."), AD19Actor::Simulate(BP1, 24), 9); TestEqual( TEXT("by using blueprint 2 ... the largest number of geodes you could open in 24 minutes is 12."), AD19Actor::Simulate(BP2, 24), 12); return true; }
ReadBlueprint is a static function on the actor. It reads the integers out of the string and puts them in an array. I use the array to fill out my robot “blueprint” spec. My first attempt at reducing the search space was, noting you can only build 1 robot at a time, to put a cap (maximum) on the harvesting robots, so I never build bots that would result in more resources gathered than can be used:
//static FD19Blueprint AD19Actor::ReadBlueprint(FString& Line) { TArray<int> Ints; Ints.Reserve(7); int Accu=0; for(int i=0;i<Line.Len();++i) { // scan to first number while (i<Line.Len() && !FChar::IsDigit(Line[i])) ++i; // parse the number if (i<Line.Len()) { do { Accu = Accu*10 + Line[i]-'0'; i++; }while (i<Line.Len() && FChar::IsDigit(Line[i])); Ints.Add(Accu); Accu = 0; } } ensure (Ints.Num()==7); FD19Blueprint Result; // zero the costs for (int i=0;i!=4;++i) for (int j=0;j!=4;++j) Result.Bots[i].Mats[j] = 0; Result.Id = Ints[0]; // Ore bots are made of Ore Result.Bots[0].Mats[0] = Ints[1]; // Clay bots are made of Ore Result.Bots[1].Mats[0] = Ints[2]; // Obsidian bots are made of Ore and Clay Result.Bots[2].Mats[0] = Ints[3]; Result.Bots[2].Mats[1] = Ints[4]; // Geode bots are made of Ore and Obsidian Result.Bots[3].Mats[0] = Ints[5]; Result.Bots[3].Mats[2] = Ints[6]; // given we can only buy one bot per turn, // work out the maximum usable number of each bot type // if the most ore a bot costs is 1 Ore the, having more than 1 Ore bot is useless for (int i=0;i!=4;++i) { Result.Caps[i]=0; for (int b=0;b!=4;++b) if (Result.Caps[i] < Result.Bots[b].Mats[i]) Result.Caps[i] = Result.Bots[b].Mats[i]; } // geode bots are uncapped Result.Caps[D19_Geode] = INT_MAX; return Result; }
With the branch in the search being what bot to build, the order of operations: select what to build, collect resources, build that bot, made structuring the recursion feel awkward. This is what ended up with:
int BuyAndContinue(const FD19Blueprint& Blueprint, FD19State State, int Bot, int Minutes) { int Result = State.Mats[D19_Geode]; // pay if (Bot>-1) { for (int i=0;i!=4;++i) State.Mats[i] -= Blueprint.Bots[Bot].Mats[i]; } while (Minutes) { // collect resources for (int i=0;i!=4;++i) State.Mats[i] += State.Bots[i]; // acquire the bot if (Bot>-1) State.Bots[Bot]++; Bot=-1; // work out what we can afford, high end first for (int b=3;b>=0;--b) { if (State.Bots[b]<Blueprint.Caps[b]) { bool CanAfford = true; for (int i=0;i!=4 && CanAfford;++i) CanAfford = State.Mats[i] >= Blueprint.Bots[b].Mats[i]; if (CanAfford) { int R = BuyAndContinue(Blueprint, State, b, Minutes-1); if (R>Result) Result=R; // if i can buy a higher end robot, do not consider any alternatives // break; [fast but fails tests] if (b==D19_Obs || b==D19_Geode) break; } } } // loop instead of recurse for the no-purchase path int R = State.Mats[D19_Geode]; if (R>Result) Result=R; Minutes = Minutes-1; } return Result; } int AD19Actor::Simulate(FD19Blueprint Blueprint, int Minutes) { FD19State State; State.Bots[D19_Ore]=1; return BuyAndContinue(Blueprint, State, -1, Minutes); }
Note the “if (b==D19_Obs || b==D19_Geode) break;” this prune was the game-changer that made the solution viable. As you can see, I tried break unconditionally first, but the Blueprint 2 example test fails with that change.
With that C++ in place, the Unreal Blueprint to solve Part 1 looks like this (wired from Event Tick):
Each tick takes about a second. Fast enough to watch but slow enough to be harmful to interactivity. I unwired Part 1 to run the BP for Part 2:
Part 2 takes minute(s) per line but with just 3 lines to process and the correct answer for my second star, I’m not motivated to optimize it further.
The pattern that is forming as these get harder is: Problem state in USTRUCT(s), C++ static functions on an Actor that can be unit tested in isolation, and an Actor to hook puzzle input up and tick the problem solver in BP.