archive by month
Skip to content

sigh, another “impossible” bug

Since yesterday I’ve been fighting a mystery bug, one of those “should never happen” bugs that happen in unsafe languages like C++. Under some narrow set of circumstances, fetching the top item from the production queue fails with symptoms which indicate that the queue is both empty (so you can’t get the top item) and not empty (so that you try to). In other words, something stepped on the data structure and broke it. I haven’t touched the queue lately, so it might be anything.

Luckily it’s reproducible, at least usually, so I can narrow it down step by step. It’s not due to the new ops boss, it’s not due to changes in the combat commander, etc. The narrowing down process eats time as if time were the world’s most luscious burrito. I’ve now determined that it has something to do with the hydralisk den in the queue, so I may be getting close.

See bizarre bug for my May experience in being called stupid by the language. Coding in a safe language is 10 times nicer. :-/

discipline

I’m not disciplined enough! I have too many ideas!

Each time I add a piece of basic infrastructure, which I do because I foresee its valuable uses, I feel overwhelmed by the valuable uses and start thinking of new ones. Then I need to write the ideas down and give them an initial evaluation. If there’s a better plan, shouldn’t I switch to it? I keep changing my plans, and it is slowing me down. Well, I always change my plans a lot, but now I’m doing different stuff that (at least psychologically) allows more room for changes.

When I implemented unit clustering, I had a plan f0r how to use it at the operational level (so I put it in a new class OpsBoss) and a plan for how to use it one level down for squad control tactics. Having seen my code in action and thought about it more, I find a lot more uses, including everything that touches on pathing. I also thought of a more complicated but clearly better plan to use it for squad control.

I’m still thinking through the new plan. While that is percolating, yesterday I added another bit of basic infrastructure, target tracking so I can keep tabs on who wants to shoot at me. And I wrote its first use, reacting to spider mines. Obviously it has many more uses; it should factor into combat micro throughout... along with other information that Steamhammer doesn’t collect yet. The possibilities are slowing me down just by being so numerous. It’s easy to get distracted, and it takes time to set priorities.

I’m not disciplined enough to keep my to-do list short and manageable!

Steamhammer is crashing

Argh, crashes!

I ran a ton of test games, against both stronger and weaker opponents of all races on rotating maps, and saw no sign of any crashing bug. I eliminated nearly all crashes from Steamhammer by about April last year, and since then there have been only a handful of crashes total on SSCAIT. Every release has been reliable. But on SSCAIT, the new Steamhammer is crashing frequently, in up to 1/3 of games. If that happens in CIG, Steamhammer will finish in the bottom half if it is not disqualified altogether. I have contacted the CIG organizers to warn them of the possible disaster.

The symptoms indicate a severe bug which happens in many games and causes a crash or not depending on details of the environment. It’s likely a pointer or iterator thing. Crash records point to squad update routines for different squads. Those were all changed to assign or unassign detectors depending on the situation, so I know where to start looking.

Please stand by while I employ my fine-tooth comb. It’s too late for CIG, but the rest of the world can be saved. I’ll issue a new version when it’s fixed, and I hope it won’t be long.

I’m eager to get on with the real work, but crashes come first. Argh!

Update: I found bugs of the general kind I expected in the places I expected. As usual, the bugs were obvious once I read my own code carefully. Lesson: I was in too much hurry. Code review works even with only 1 coder. I’ll upload version 1.4.7 after reading through again and retesting.

a latent bug in squad updating

CombatCommander::updateIdleSquad() does this:

    for (const auto unit : _combatUnits)
    {
        // if it hasn't been assigned to a squad yet, put it in the low priority idle squad
        if (_squadData.canAssignUnitToSquad(unit, idleSquad))
        {
            idleSquad.addUnit(unit);
        }
    }

The code is correct as it stands. It cannot assign 1 unit to 2 squads. But I consider it a latent bug: Only the lowest-priority squad can be updated this way. It would be easy and natural to make changes to squad priorities and cause an error, or to copy and paste this code under the assumption that it would be correct in a different case. You would get an irritating bug with no obvious cause. In the spirit of defensive programming, I recommend changing it to this:

    for (const auto unit : _combatUnits)
    {
        // if it hasn't been assigned to a squad yet, put it in the low priority idle squad
        if (_squadData.canAssignUnitToSquad(unit, idleSquad))
        {
            _squadData.assignUnitToSquad(unit, idleSquad);
        }
    }

assignUnitToSquad() ensures that the unit is removed from any previous squad before it is added to the new squad. Any squad other than the lowest-priority squad might steal a unit from another squad, and you can’t steal it without taking it away. Assigning 1 unit to 2 squads would cause problems, and Steamhammer checks later and will throw.

At a minimum, I recommend commenting the code so you don’t copy-paste it heedlessly when adding a new squad type.

Steamhammer bug fixes

Yesterday was a productive day for fixing bugs, thanks to info from Bruce Nielsen, author of Locutus. See comments to Steamhammer 1.4.3 change list. Because tournaments are coming up, it may be a while before I release another public version of Steamhammer, but bugfixes are important for everyone with a fork, short of the devil.

After fixing these bugs, I can no longer reproduce the “wrong reserves” error messages that the building manager used to kick out several times per game. Other mysterious misbehavior is probably fixed too, though I’m not sure what.

BuildingManager shouldn’t copy buildings

The methods BuildingManager::validateWorkersAndBuildings() and BuildingManager::checkForCompletedBuildings() loop through the vector of Building data structures and delete those that are no longer needed. To avoid deleting from the vector of Building objects while in the midst of looping through it, it stashes the buildings to delete in another vector toRemove and passes toRemove to a deletion method.

    std::vector<Building> toRemove;

Well, it’s copying the building data. I haven’t uncovered any bug that this causes, but it is wasteful at best and at worst an invitation to errors. If code updates the copies, or updates the real buildings before the copies are sent in for deletion, no real building or the wrong real building might be deleted because of how equality is defined in the Building class. undoBuildings() has to undo data structure dependencies and could easily make such a mistake.

There are several steps to the fix, but the idea is to keep a vector of references instead of a vector of copies. To keep references in a Standard Template Library container, we have to uglify the type signature with a wrapper.

    std::vector< std::reference_wrapper<Building> > toRemove;

The rest of the code doesn’t change, only the declaration. Then we also have to update the declarations of the deletion routines that undo data structure dependencies and perform the deletion.

    void undoBuildings(const std::vector< std::reference_wrapper<Building> > & toRemove);
    void removeBuildings(const std::vector< std::reference_wrapper<Building> > & toRemove);

canceling buildings is incorrect

BuildingManager::cancelQueuedBuildings() and BuildingManager::cancelBuildingType() also delete unwanted buildings, and do it while looping through the vector of buildings. It is unsafe and can cause errors. It’s my fault, I wrote these. I rewrote them like this:

// It's an emergency. Cancel all buildings which are not yet started.
void BuildingManager::cancelQueuedBuildings()
{
	std::vector< std::reference_wrapper<Building> > toCancel;

	for (Building & b : _buildings)
	{
		if (b.status == BuildingStatus::Unassigned || b.status == BuildingStatus::Assigned)
		{
			toCancel.push_back(b);
		}
	}

	for (Building & b : toCancel)
	{
		cancelBuilding(b);
	}
}

and

// It's an emergency. Cancel all buildings of a given type.
void BuildingManager::cancelBuildingType(BWAPI::UnitType t)
{
	std::vector< std::reference_wrapper<Building> > toCancel;

	for (Building & b : _buildings)
	{
		if (b.type == t)
		{
			toCancel.push_back(b);
		}
	}

	for (Building & b : toCancel)
	{
		cancelBuilding(b);
	}
}

releasing workers from a squad

I realized I was not sensitive enough to this category of bug, and surveyed the codebase to see whether there were any more. Almost all risky loops were correct, whether written by Dave Churchill or by me, but I found 2 more. One was inconsequential because it was in the BuildingData class, which is unused (so I deleted it). The other was in Squad::releaseWorkers() and should be fixed.

// Remove all workers from the squad, releasing them back to WorkerManager.
void Squad::releaseWorkers()
{
	for (auto it = _units.begin(); it != _units.end(); )
	{
		if (_combatSquad && (*it)->getType().isWorker())
		{
			WorkerManager::Instance().finishedWithWorker(*it);
			it = _units.erase(it);
		}
		else
		{
			++it;
		}
	}
}

finding the next expansion to take

Bruce also pointed out an unrelated typo in MapTools::nextExpansion(). It's a serious bug. Here is the Locutus commit fixing it.

Change this:

        for (int x = 0; x < player->getRace().getCenter().tileWidth(); ++x)
        {
			for (int y = 0; y < player->getRace().getCenter().tileHeight(); ++y)
            {
				if (BuildingPlacer::Instance().isReserved(x,y))
				{
					// This happens if we were already planning to expand here. Try somewhere else.
					buildingInTheWay = true;
					break;
				}

To this:

        for (int x = 0; x < player->getRace().getCenter().tileWidth(); ++x)
        {
			for (int y = 0; y < player->getRace().getCenter().tileHeight(); ++y)
            {
				if (BuildingPlacer::Instance().isReserved(tile.x + x, tile.y + y))
				{
					// This happens if we were already planning to expand here. Try somewhere else.
					buildingInTheWay = true;
					break;
				}

x and y were treated as absolute map coordinates instead of relative to the building location, so the check gave completely wrong results. The next check, immediately below, is correct; the bug is a typo. But the social status of the bug does not matter. What matters is that it broke stuff.

bizarre bug

I was making changes to squad membership updating and tactical targeting, which happens once every 8 frames. And I hit a bug which had these symptoms: Steamhammer appeared to run normally, but extremely slowly. Each frame took a second or more. Not each eighth frame, each single frame. Also Steamhammer was unable to recognize its slowness; its timer said that the mean frame took 0.1 milliseconds, normal for the very early game which was as far as I let it run. What could cause such bizarre behavior?

It took a long time to debug. I finally traced it to a misplaced } character which included too much in an if, with the result that an important value was never initialized. It was not easy to see in the code. How this caused a slowdown and how the slowdown stayed outside the timed code (or otherwise broke the timer's behavior) I do not know, and don't expect to find out....

It is worth it to use a prickly language like C++? One wrong touch and you get glochids in your skin for who knows how long. Some days I think about recoding Steamhammer in a pleasant language like Haskell, where new code more often than not works the first time. But that also comes with big disadvantages.

a cosmetic code improvement

Steamhammer inherits UAlbertaBot’s architecture: It is a collection of singletons which communicate by directly calling each other. Each provides a method Instance() which defines its instance as a static and returns a reference. It’s a standard C++ trick. The cross-calls look like this:

    return BuildingManager::Instance().isBeingBuilt(unitType);

It works, but I find it wordy and ugly. It repeats implementation details that are irrelevant to the caller. I plan to change the syntax to this:

    return the.building.isBeingBuilt(unitType);

With a local instance variable the, a reference to the The singleton which will centralize access to the other singletons. So far I have done this only for the newly-written MapPartition class which does connectivity analysis. It was a test to make sure I could see in the dark, because of the C++ Needlessly Obscure Initialization Rules (NOIR).

The code changes are not too much, and when I feel less time pressure I intend to implement the idea throughout. I think it’s an improvement. More concise and readable code will make the codebase more fun to work with.

waypoints

Steamhammer inherits from UAlbertaBot the use of waypoint navigation in 2 narrow cases, for moving the scouting worker around the edge of the enemy base, and for moving the transport around the edge of the map when dropping. The 2 implementations are completely separate.

Today I am fixing yesterday’s bug related to drop. Following my minimum energy trajectory, I naturally clean up any code that I work on. The drop navigation turns out to be extravagantly overcomplex; I imagine it was copy-pasted from the scout navigation with minimal changes, even though following the map edge is a far easier problem. In cleaning it up, while keeping the same general plan I am eliminating 1 persistent data structure (dropping _waypoints and keeping _mapEdgeVertices which I will rename to _waypoints) and 2 temporary data structures used unnecessarily to find the edge of the map. You seriously don’t need complex calculations to lay waypoints around the edge of the map. I decided to keep the overall waypoint plan because it provides flexibility for future uses, even though the flexibility isn’t needed now.

Back when I first worked on drop, I treated this code as a black box and touched it as little as possible while marveling at its opacity. Now it is becoming much shorter (and incidentally faster), and getting commented so it will be understandable. I also intend to decide accurately which direction to take around the edge. Steamhammer too often takes the long way around.

Waypoints seem like a good navigation tool whenever you want to plan movement several steps in advance. If you figure out a safe path to get an overlord to a good observation post, or to route vultures around the sunken to reach the mineral line from the far side, then you can represent the path as a sequence of waypoints. Executing the plan seems easy. I guess if you’ve moving a substantial ground army, you probably don’t want to send all units to a single point, but I can see ways to work with that (either plan ahead with “way zones” or react on the fly to keep to safe locations within some tolerance). Of course the plan may run into difficulties so that you have to replan, but that is always true.

If you sometimes plan only one step ahead, then you don’t need waypoints—but you can still use them. Waypoints seem like a general purpose representation of paths from A to B.

I’ve noticed the duplicated waypoint code before, between scouting and dropping, and thought of factoring it out into a waypoint class for wider use. I may do that. It would be part of the solution to the other and bigger bug, getting stuck on map obstacles.

Do people have opinions?

rare bugs

I thought Steamhammer would always locate the enemy. If the early game scout gets killed, the ground forces have orders to visit any unscouted bases. If the ground forces are held at bay, the recon squad will check the rest of the map, locating the enemy by eliminating other possibilities. Only if Steamhammer loses quickly should it be unable to find the enemy base, and then it doesn’t matter.

Today it happened, and it caused a bug. I was testing map analysis with a particularly difficult map. Steamhammer was terran, so scouting was on the ground only, and everything that left Steamhammer’s base got caught behind map obstacles. The strategy was vulture drop, and when it came time to drop, Steamhammer loaded up the dropship and... had nowhere to send it. It started throwing an exception over and over. It’s a basic bug, but it had never happened before.

The question is, how do I fix it? The simplest plan would be to disband the drop squad if there is no target. Maybe I should do that, since it’s a rare case. It would be more effective to use the dropship itself to scout for targets, since everything else failed. It’s a skill that will be needed anyway when drops start to happen later in the game, but on the other hand it would take time. Hmm....

Another fix needed, of course, is pathing so units don’t get stuck. It’s easy enough to implement, but I keep putting it off because it seems to add architectural complexity and invite bugs. I need to think of a clean way to do it.

Map analysis, by the way, is working great. I have eradicated all the bugs I could find, and now in the rare cases where it does something unexpected, it always turns out to be good enough. The one base on Match Point is still on the far side of the minerals; I think it’s reasonable. The upper left island on Lost Temple gets 2 bases because the resources are so far apart; it seems reasonable. On almost all maps, even large and irregular old Blizzard maps, every base is placed as expected.

Now I have to start the work of actually making use of the map analysis. BWTA is explicitly written into the code in a ton of places. I don’t think it’s smart to rewrite them all at once, big bang style, so I’ll do them a little at a time.

Update: I decided to follow a plan similar to the PurpleWave idea mentioned in a comment. Steamhammer now calculates a drop target instead of simply aiming for the enemy main base, and if there are no enemy bases or buildings known, the target will be an unexplored place. I’m still updating MicroTransports so it can recognize when the target changes, which could happen at any time. It turns out that the map edge following code is far more complicated than it needs to be. I think Dave Churchill must have originally written it for another purpose, and dropped it in with little simplification.

Steamhammer occasionally takes a barren base

Steamhammer occasionally likes to expand to mined-out bases late in the game. I have made a whole series of changes over different versions to fix it: I had it score bases by available resources, I had it avoid bases altogether which don’t have enough minerals to be worth it, then I extended it to avoid bases where the geyser is mined out. And still there was one recent game where Steamhammer took an empty base.

Finally it dawned on me that Starcraft is a game of imperfect information. If you choose your next base when you can’t see it, then of course you’ll sometimes choose the wrong base. Oh... I think I should have learned that by now. And yet just today I was fixing visibility mistakes in the base code, where it called getTilePosition() instead of getInitialTilePosition(). Somehow it’s easy to forget.

Does that account for all the cases? I’m not sure. It’s a somewhat rare bug, but I have a feeling that the most common empty base to take is the enemy’s natural after destroying it, when it should be visible. Maybe there’s yet another bug, which would be depressing.

How many more bugs are there because I am sometimes blind to the basic workings of BWAPI? I bet there are more than a few. Software is hard.

SSCAIT 2017 round of 8 remarks

There’s a striking pattern in the SSCAIT round of 8 that I want to point out. First, the video and the Liquipedia page. I’ll discuss the results. The round was made up of 6 newer and frequently updated bots, which were all paired against each other, and 2 old hands which were paired together. Each pair played a best of 5 match.

The old hands were Killerbot and XIMP, not updated for this tournament. They played a balanced match which was decided by strategy and tactics and fine details of play which the bots didn’t take into account. There weren’t any obvious or decisive bugs, it was about good everyday play.

Taking the rest of the matches from the top, Iron-Microwave went to Microwave because the zerg bot was able to break the terran wall. Iron knew how to repair its wall and made marines to defend it. But the marines were afraid of the zerglings which could not attack them through the wall, and did not shoot. I suppose that the combat simulator doesn’t understand the wall and says “uh oh, we’ll lose if the melee units get close, keep a safe distance!” Microwave won by exploiting a bug in Iron.

Steamhammer swept Arrakhammer 3-0 with 3 zergling builds in a row. Nepeta didn’t point it out in the video, but in each game Arrakhammer’s own zerglings fought piecemeal, inefficiently engaging with a partial force and then retreating, suffering more damage than they dealt. Steamhammer won by exploiting a bug in Arrakhammer.

CherryPi beat McRave 3-2 in a close match. McRave showed strategy weaknesses which deserve some of the blame. Nepeta’s points in the video are valid: Expand earlier, get +1 attack for the zealots to counter zerglings (since with +1 a zealot kills an unupgraded zergling in 2 hits instead of 3, a giant difference), and don’t cower in your own base when you play an aggressive 2 gate opening (you should never be behind in units for long). But McRave’s biggest weakness was that its high templar usually did not cast psionic storm, and seemed happy to suicide themselves. CherryPi won by exploiting the bug. In one game that CherryPi lost, zerg did not build the macro hatcheries it needed to keep up its zergling-heavy unit mix, and CherryPi’s mineral bank grew into the thousands. McRave won that game by exploiting a bug in CherryPi.

Two conclusions are loud and clear. 1. 6 of the 8 participants that made it this far are frequently updated and fast evolving. Only 2 old timers could keep up. The hard work to make many improvements pays off. 2. The same frequent updates leave bots vulnerable to bugs. The old hands were solid (at least they looked solid this time), and the fast movers had fragile spots.

I’m not sure there are any lessons for bot authors, other than “hard work pays off” and “fix the worst problems first,” both of which we already knew. The pattern in the results was so striking that I couldn’t ignore it.

Next: Steamhammer 1.4 change list.

Randomhammer crash!

Randomhammer crashed in a game! It’s the first crash since May, except for the untraceable crash. Now I know exactly what to do next.

The crash was in an interesting TvP game against cannon-bot Jakub Trancik. It’s a shame not to see how it continued. Randomhammer went with marines and Jakub Trancik decided to build its proxy cannons below the ramp, so Randomhammer (seeing the pylon with its scout) didn’t pull SCVs but waited for combat units. The first marines were not quite able to stop the cannons, and retreated out of sight up the ramp. Terran and protoss have very few strategic reactions (unlike zerg), but there is one when the bot is making infantry and meets dragoons, tanks, lurkers, or static defense: It adds tanks. It was the correct move; the unit control is clumsy and loses stuff unnecessarily, but the tanks were sieging down cannons and I was starting to think that the play was good enough.

Then CRASH! The crash is in CombatCommander::updateReconSquad() which decides what units are in the Recon squad. It is complicated for terran because there may be medics in the squad, depending on size and composition, so it’s no big surprise that there’s a bug.

To work!

untraceable Steamhammer crash

Steamhammer has had its first crash loss on SSCAIT since May. Or it may have overstepped the time limit. Randomhammer protoss has sometimes broken the time limit, but this is the first for zerg in a long time.

The game is Steamhammer-UPStarcraftAI 2016 (a zerg rushbot), and it looked like a routine Steamhammer win until 5:19 in, when suddenly bam, the 0/0 supply that means crash. Steamhammer has won dozens of these rushbot games without incident.

  • SSCAIT did not record an exception log.
  • If it was a time limit loss, why did it happen in a short game with few units?
  • It didn’t seem like server maintenance either. The games before and after were closely spaced.

It’s a mystery, and there doesn’t seem to be any information to start from.

Steamhammer does still have crashing bugs despite my rigorous eradication campaign. AIIDE 2017 reported 4 crashes out of 2964 games, a rate of about one per 740 games. The current SSCAIT version now has 1 crash in 385 games, which is not statistically different. I found a crash yesterday with a stress test where I forced Steamhammer to play an opening that caused it to lose bases and struggle to recover. After many games, I got one crash. I think I fixed the cause, but the crash is rare and difficult to reproduce, so....

impressive gas steal bug

Stealing gas is irritatingly complicated in Steamhammer. Today I discovered another bug: If the scout worker has been ordered to circle the enemy base forever, and while it’s doing that you decide (this late in the day) to steal gas, then the building manager goes haywire and moves the worker into a corner or some other useless location. Now what??? It worked when I told to it to scout once around the enemy base and interrupted the circle in the middle by deciding to steal gas, but circling more than once somehow breaks it... in a different module.

Of course everything works perfectly if you decide up front to steal gas, so the worker arrives at the enemy base knowing what to do. That’s the usual case. It sure would be nice to support late decisions, but there are interactions with the scouting command.

I think I should write a general purpose plan sequencer and reduce the gas steal to a plan represented as data, not code. It will be more complicated, but I only have to debug it once and it will have tons of uses.

the importance of bugs

Here’s a little story for anyone who doubts the importance of fixing bugs: Randomhammer’s last 4 games were all decided by bugs.

4. Versus IceBot, Randomhammer rolled terran and went vultures. The vulture attack almost broke through IceBot’s wall, but IceBot got its bunker up and managed to keep 1 marine alive long enough to get in. Randomhammer’s hard-coded switch to tanks was late and its tactics were weak, and IceBot was soon winning. The hostile tank push started, then—IceBot crashed, game to Randomhammer.

3. Oleg Ostroumov crashed at 5 SCVs, before the game was properly underway.

2. Hardcoded crashed on start.

1. Versus zerg ZurZurZur there was at least a game. Randomhammer again rolled terran and this time went for a vulture drop. ZurZurZur sent 4 hydralisks across for a poke, where they met Randomhammer’s small force of marines and vultures, whose purpose is to keep the enemy occupied out front while the drop happens in back. It worked; ZurZurZur’s forces were far out of position, and the vultures killed all but 1 drone before being cleaned up. Then Randomhammer fell into a production freeze and did not produce another unit for the rest of the game, allowing ZurZurZur to slowly recover with its 1 drone and win.

There is a sub-theme here: Crash bugs are the worst. You crash, you lose. Randomhammer could have defeated ZurZurZur on points if it had better vulture micro, even with the production freeze. (It couldn’t have won outright, though, because there were sunkens that existing forces could not have brought down.)

Steamhammer has no known crashing bugs. I went on campaign and exterminated them. Its last crashes due to fixable bugs were in April, and its last recorded crashes on SSCAIT were in May and appear to have been due to bugs in the infrastructure, not in Steamhammer. Some of the bugs I fixed are obscure and their triggers have never occurred in real games. For example, Steamhammer has never had a unit mind controlled, as far as I have seen. The normal unit validity check done on every frame should catch mind controlled squad units and workers before Steamhammer tries to send them orders—though it hasn’t been tested, so I don’t promise it’s correct. But I noticed a loophole: The scouting worker is not in a squad and is not treated like a regular worker. It is controlled directly by ScoutManager, which does not run the validity check. I left a comment in the code where I fixed it. How many years would it have been, do you suppose, before somebody mind controlled the scouting worker and brought about a crash?

opponent modeling, scouting, and software development

Opponent modeling is coming along, though never as fast as I would like. Steamhammer now records a pretty informative model. Making best use of the model is not as easy—it has a ton of uses. If it works as well as I hope, Steamhammer will gain scary predictive abilities, even against opponents with multiple strategies.

Opponent modeling depends on good scouting. The more Steamhammer finds out about what the opponent does, the better the model. So today I added a new scouting command, "go scout once around" which sends the worker scout on a single circuit of the enemy base and then returns it home. (Usually. There are some funny cases because the waypoint numbering is not quite clean.) In a lot of openings, I used it to replace "go scout location" which only finds the enemy base and doesn’t look to see what’s there. I’m thinking of also adding "go scout while safe".

The command is a minor addition, but while hooking up the wiring I saw awkwardness in the communication among ProductionManager which executes the commands, GameCommander which initiates scouting, and ScoutManager which does the work. I ended up spending the whole afternoon refactoring it for simplicity and testing to make sure I hadn’t broken anything.

Is that what I should be spending my time on? And yet it makes Steamhammer better.