Mirroar/hivemind

View on GitHub
src/room/planner/variation-builder.ts

Summary

Maintainability
F
1 wk
Test Coverage
import hivemind from 'hivemind';
import minCut from 'utils/mincut';
import PlaceTowersStep from 'room/planner/step/place-towers';
import RoomVariationBuilderBase from 'room/planner/variation-builder-base';
import settings from 'settings-manager';
import {encodePosition, decodePosition} from 'utils/serialization';
import {getExitCenters} from 'utils/room-info';
import {getRoomIntel} from 'room-intel';
import {handleMapArea} from 'utils/map';

const TILE_IS_ENDANGERED = 0;
const TILE_IS_SAFE = 1;
const TILE_IS_UNSAFE = 2;
const TILE_IS_UNSAFE_NEAR_WALL = 3;

export default class RoomVariationBuilder extends RoomVariationBuilderBase {
    exitCenters: ExitCoords;
    roomCenter: RoomPosition;
    roomCenterEntrances: RoomPosition[];
    protected sourceInfo: Record<string, {
        harvestPosition: RoomPosition;
    }>;

    protected steps: Array<() => StepResult>;

    safetyMatrix: CostMatrix;

    constructor(roomName: string, variation: string, protected variationInfo: VariationInfo, wallMatrix: CostMatrix, exitMatrix: CostMatrix) {
        super(roomName, variation, wallMatrix, exitMatrix);
        hivemind.log('rooms', this.roomName).info('Started generating room plan for variation', variation);

        this.steps = [
            this.gatherExitCoords,
            this.determineCorePosition,
            this.determineHarvesterPositions,
            this.determineUpgraderPosition,
            this.placeRoadNetwork,
            this.placeRoomCore,
            this.placeHarvestBayStructures,
            this.placeHelperParkingLot,
            this.placeBays,
            this.placeLabs,
            this.placeHighLevelStructures,
            this.placeRamparts,
            this.placeQuadBreaker,
            this.sealRoom,
            this.placeTowers,
            this.placeRoadsToRamps,
            this.placeOnRamps,
        ];
    }

    buildStep(step: number): StepResult {
        if (step < this.steps.length) {
            return this.steps[step].call(this);
        }

        return 'done';
    }

    gatherExitCoords(): StepResult {
        // Prepare exit points.
        this.exitCenters = getExitCenters(this.roomName);

        for (const dir in this.exitCenters) {
            for (const pos of this.exitCenters[dir]) {
                this.placementManager.planLocation(pos, 'exit', null);
            }
        }

        return 'ok';
    }

    determineCorePosition(): StepResult {
        if (!this.variationInfo.roomCenter) return 'failed';

        this.roomCenter = this.variationInfo.roomCenter;

        // Center is accessible via the 4 cardinal directions.
        this.roomCenterEntrances = [
            new RoomPosition(this.roomCenter.x + 2, this.roomCenter.y, this.roomName),
            new RoomPosition(this.roomCenter.x - 2, this.roomCenter.y, this.roomName),
            new RoomPosition(this.roomCenter.x, this.roomCenter.y + 2, this.roomName),
            new RoomPosition(this.roomCenter.x, this.roomCenter.y - 2, this.roomName),
        ];

        this.placementManager.planLocation(this.roomCenter, 'center', null);

        return 'ok';
    }

    determineHarvesterPositions(): StepResult {
        this.sourceInfo = {};
        const roomIntel = getRoomIntel(this.roomName);
        for (const source of roomIntel.getSourcePositions()) {
            const harvestPosition = this.determineHarvestPositionForSource(source);
            this.placementManager.planLocation(harvestPosition, 'harvester', null);
            this.placementManager.planLocation(harvestPosition, 'harvester.' + source.id, null);
            this.placementManager.planLocation(harvestPosition, 'bay_center', null);

            // Discourage roads through spots around harvest position.
            handleMapArea(harvestPosition.x, harvestPosition.y, (x, y) => {
                if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return;

                this.placementManager.discouragePosition(x, y);
            });

            this.storeHarvestPosition(source, harvestPosition);
        }

        for (const mineral of roomIntel.getMineralPositions()) {
            const mineralPosition = new RoomPosition(mineral.x, mineral.y, this.roomName);
            this.placementManager.planLocation(mineralPosition, 'extractor');
            const mineralRoads = this.placementManager.findAccessRoad(mineralPosition, this.roomCenterEntrances);
            for (const pos of mineralRoads) {
                this.placementManager.planLocation(pos, 'road', 1);
                this.placementManager.planLocation(pos, 'road.mineral', null);
            }

            this.placeContainer(mineralRoads, 'mineral');

            this.storeHarvestPosition(mineral, mineralRoads[0]);
        }

        return 'ok';
    }

    determineHarvestPositionForSource(source: {x: number; y: number}): RoomPosition {
        // Find adjacent space that will provide most building space.
        // @todo Reasonably handle sources that can be accessed from multiple
        // sides. For example by checking if theres more than 1 group of
        // unconnected free tiles.
        let bestPos;
        handleMapArea(source.x, source.y, (x, y) => {
            if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return;

            let numberFreeTiles = 0;
            handleMapArea(x, y, (x2, y2) => {
                if (this.terrain.get(x2, y2) === TERRAIN_MASK_WALL) return;
                if (!this.placementManager.isBuildableTile(x2, y2)) return;

                numberFreeTiles++;
            });

            if (!bestPos || bestPos.numFreeTiles < numberFreeTiles) {
                bestPos = {x, y, numFreeTiles: numberFreeTiles};
            }
        });

        return new RoomPosition(bestPos.x, bestPos.y, this.roomName);
    }

    storeHarvestPosition(source: {id: string}, harvestPosition: RoomPosition) {
        // Make sure no other paths get led through harvester position.
        this.placementManager.blockPosition(harvestPosition.x, harvestPosition.y);

        // Setup memory for quick access to harvest spots.
        this.sourceInfo[source.id] = {
            harvestPosition,
        };
    }

    determineUpgraderPosition(): StepResult {
        const roomIntel = getRoomIntel(this.roomName);
        const controllerPosition = roomIntel.getControllerPosition();
        this.protectPosition(controllerPosition, 1);

        const controllerRoads = this.findBestControllerRoad(controllerPosition);
        for (const pos of controllerRoads) {
            this.placementManager.planLocation(pos, 'road', 1);
            this.placementManager.planLocation(pos, 'road.controller', null);
            this.protectPosition(pos, 0);
        }

        // Store position where main upgrader can stay and upgrade.
        this.placementManager.planLocation(controllerRoads[0], 'upgrader.0', 1);
        this.protectPosition(controllerRoads[0], 1);

        this.placeContainer(controllerRoads, 'controller');

        // Place a link near controller, but off the calculated path.
        this.placeLink(controllerRoads, 'controller');

        // Make sure no other paths get led through upgrader position.
        this.placementManager.blockPosition(controllerRoads[0].x, controllerRoads[0].y);

        return 'ok';
    }

    findBestControllerRoad(controllerPosition: RoomPosition): RoomPosition[] {
        let best: RoomPosition[];
        handleMapArea(controllerPosition.x, controllerPosition.y, (x, y) => {
            if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return;
            if (!this.isAcceptableUpgraderPosition(x, y, controllerPosition)) return;

            const startPosition = new RoomPosition(x, y, this.roomName);
            const path = this.placementManager.findAccessRoad(startPosition, this.roomCenterEntrances);
            path.splice(0, 0, startPosition);

            if (!best || best.length > path.length) best = path;
        }, 3);

        return best;
    }

    isAcceptableUpgraderPosition(x: number, y: number, controllerPosition: RoomPosition): boolean {
        // We want at least 3 spots for upgraders that can reach the controller.
        let validSpots = 0;
        handleMapArea(x, y, (x2, y2) => {
            if (this.terrain.get(x2, y2) === TERRAIN_MASK_WALL) return;
            if (controllerPosition.getRangeTo(x2, y2) > 3) return;

            validSpots++;
        });

        return validSpots >= 3;
    }

    placeRoadNetwork(): StepResult {
        // Find paths from each exit towards the room center for making roads.
        for (const dir of _.keys(this.exitCenters)) {
            for (const pos of this.exitCenters[dir]) {
                const exitRoads = this.placementManager.findAccessRoad(pos, this.roomCenterEntrances);
                for (const pos of exitRoads) {
                    // Mark exit road locations as roads without actually placing any.
                    // This ensures there is always an open path for reaching any exit.
                    this.placementManager.planLocation(pos, 'road.exit', 1);
                }
            }
        }

        // Remove stored locations to save memory, they are not needed.
        this.roomPlan.removeAllPositions('road.exit');

        return 'ok';
    }

    placeHarvestBayStructures(): StepResult {
        const roomIntel = getRoomIntel(this.roomName);
        for (const source of roomIntel.getSourcePositions()) {
            const shouldAddSpawn = this.variationInfo.sourcesWithSpawn.includes(source.id);
            const harvestPosition = this.sourceInfo[source.id].harvestPosition;
            const sourceRoads = this.placementManager.findAccessRoad(harvestPosition, this.roomCenterEntrances);
            for (const pos of sourceRoads) {
                this.placementManager.planLocation(pos, 'road', 1);
                this.placementManager.planLocation(pos, 'road.source', null);
                if (shouldAddSpawn) this.protectPosition(pos, 0);
            }

            this.placementManager.planLocation(harvestPosition, 'container.source', null);
            this.placementManager.planLocation(harvestPosition, 'container', null);

            this.placeBayStructures(harvestPosition, {spawn: shouldAddSpawn, source: true});
        }

        return 'ok';
    }

    /**
     * Places structures that are fixed to the room's center.
     */
    placeRoomCore(): StepResult {
        // Fill center cross with roads.
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x - 2, this.roomCenter.y, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x + 2, this.roomCenter.y, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x, this.roomCenter.y - 2, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x, this.roomCenter.y + 2, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x - 1, this.roomCenter.y - 1, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x - 1, this.roomCenter.y + 1, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x + 1, this.roomCenter.y - 1, this.roomName), 'road', 1);
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x + 1, this.roomCenter.y + 1, this.roomName), 'road', 1);

        // Mark center buildings for construction.
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x, this.roomCenter.y + 1, this.roomName), 'storage');
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x, this.roomCenter.y - 1, this.roomName), 'terminal');
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x + 1, this.roomCenter.y, this.roomName), 'factory');
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x - 1, this.roomCenter.y, this.roomName), 'link');
        this.placementManager.planLocation(new RoomPosition(this.roomCenter.x - 1, this.roomCenter.y, this.roomName), 'link.storage');

        return 'ok';
    }

    /**
     * Places parking spot for helper creep.
     */
    placeHelperParkingLot(): StepResult {
        this.placementManager.startBuildingPlacement(this.roomCenter, this.roomCenterEntrances);
        const nextPos = this.placementManager.getNextAvailableBuildSpot();
        if (!nextPos) return 'failed';

        this.placementManager.planLocation(nextPos, 'road', 255);
        this.placementManager.planLocation(nextPos, 'helper_parking');

        this.placementManager.placeAccessRoad(nextPos);

        this.placementManager.filterOpenList(encodePosition(nextPos));

        return 'ok';
    }

    /**
     * Places extension bays.
     */
    placeBays(): StepResult {
        let count = 0;
        while (this.roomPlan.canPlaceMore('extension')) {
            const pos = this.findBayPosition();
            if (!pos) return 'failed';

            this.placementManager.placeAccessRoad(pos);

            // Make sure there is a road in the center of the bay.
            this.placementManager.planLocation(pos, 'road', 1);
            this.placementManager.planLocation(pos, 'bay_center', 1);

            this.placeBayStructures(pos, {spawn: true, id: count++});

            this.protectPosition(pos);

            // Reinitialize pathfinding.
            this.placementManager.startBuildingPlacement();
        }

        return 'ok';
    }

    /**
     * Finds best position to place a new bay at.
     *
     * @return {RoomPosition}
     *   The calculated position.
     */
    findBayPosition(): RoomPosition {
        let maxExtensions = 0;
        let bestPos = null;
        let bestScore = 0;

        this.placementManager.startBuildingPlacement(this.roomCenter, this.roomCenterEntrances);

        while (maxExtensions < 8) {
            const nextPos = this.placementManager.getNextAvailableBuildSpot();
            if (!nextPos) break;

            // Don't build too close to exits.
            if (this.placementManager.getExitDistance(nextPos.x, nextPos.y) < 8) continue;

            if (!this.placementManager.isBuildableTile(nextPos.x, nextPos.y)) continue;

            // @todo One tile is allowed to be a road.
            // @todo Use a lenient stamper.
            let tileCount = 0;
            if (this.placementManager.isBuildableTile(nextPos.x - 1, nextPos.y)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x + 1, nextPos.y)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x, nextPos.y - 1)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x, nextPos.y + 1)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x - 1, nextPos.y - 1)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x + 1, nextPos.y - 1)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x - 1, nextPos.y + 1)) tileCount++;
            if (this.placementManager.isBuildableTile(nextPos.x + 1, nextPos.y + 1)) tileCount++;

            if (tileCount <= maxExtensions) continue;

            maxExtensions = tileCount;
            const score = tileCount / (this.placementManager.getCurrentBuildSpotInfo().range + 10);
            if (score > bestScore && tileCount >= 4) {
                bestPos = nextPos;
                bestScore = score;
            }
        }

        if (maxExtensions < 4) return null;

        return bestPos;
    }

    /**
     * Places labs in big compounds.
     */
    placeLabs() {
        this.placementManager.startBuildingPlacement();
        while (this.roomPlan.canPlaceMore('lab')) {
            const nextPos = this.placementManager.getNextAvailableBuildSpot();
            if (!nextPos) return 'failed';

            // Don't build too close to exits.
            if (this.placementManager.getExitDistance(nextPos.x, nextPos.y) < 8) continue;

            const {x, y, roomName} = nextPos;

            // @todo Use a stamper.
            for (const [dx, dy] of [[1, 1], [-1, 1], [1, -1], [-1, -1]]) {
                const availableTiles = [
                    [x - dx, y + dy],
                    [x - dx, y],
                    [x - dx, y - dy],
                    [x, y - dy],
                    [x + dx, y - dy],
                    [x, y + 2 * dy],
                    [x + dx, y + 2 * dy],
                    [x + 2 * dx, y + 2 * dy],
                    [x + 2 * dx, y + dy],
                    [x + 2 * dx, y],
                ];
                if (!this.canFitLabStamp(nextPos, dx, dy, availableTiles)) continue;

                // Place center area.
                this.placementManager.planLocation(new RoomPosition(x, y, roomName), 'road', 1);
                this.placementManager.planLocation(new RoomPosition(x + dx, y, roomName), 'lab');
                this.placementManager.planLocation(new RoomPosition(x + dx, y, roomName), 'lab.reaction');
                this.placementManager.planLocation(new RoomPosition(x, y + dy, roomName), 'lab');
                this.placementManager.planLocation(new RoomPosition(x, y + dy, roomName), 'lab.reaction');
                this.placementManager.planLocation(new RoomPosition(x + dx, y + dy, roomName), 'road', 1);

                this.placementManager.placeAccessRoad(nextPos);

                // Place succounding labs where there is space.
                for (const [lx, ly] of availableTiles) {
                    if (!this.roomPlan.canPlaceMore('lab')) break;
                    if (!this.placementManager.isBuildableTile(lx, ly)) continue;

                    this.placementManager.planLocation(new RoomPosition(lx, ly, roomName), 'lab');
                    this.placementManager.planLocation(new RoomPosition(lx, ly, roomName), 'lab.reaction');
                }

                break;
            }
        }

        // Reinitialize pathfinding.
        this.placementManager.startBuildingPlacement();

        return 'ok';
    }

    canFitLabStamp(pos: RoomPosition, dx: number, dy: number, availableTiles: number[][]): boolean {
        // This stamp can fit 1 more lab than necessary.
        //  ooo
        // oo.o
        // o.oo
        // .oo

        // Center 4 tiles need to always be free, for 2 labs and 2 roads.
        if (!this.placementManager.isBuildableTile(pos.x, pos.y)) return false;
        if (!this.placementManager.isBuildableTile(pos.x + dx, pos.y)) return false;
        if (!this.placementManager.isBuildableTile(pos.x, pos.y + dy)) return false;
        if (!this.placementManager.isBuildableTile(pos.x + dx, pos.y + dy)) return false;

        // We need at least 9 surrounding spots to be available (8 labs + 1 road).
        let freeTiles = 0;
        for (const [x, y] of availableTiles) {
            if (this.placementManager.isBuildableTile(x, y)) freeTiles++;
        }

        return freeTiles > 8;
    }

    placeHighLevelStructures(): StepResult {
        this.placementManager.placeAll('powerSpawn', true);
        this.placementManager.placeAll('nuker', true);
        this.placementManager.placeAll('observer', false);

        return 'ok';
    }

    placeRamparts(): StepResult {
        // Make sure the controller can't directly be reached by enemies.
        const roomIntel = getRoomIntel(this.roomName);
        const safety = roomIntel.calculateAdjacentRoomSafety();

        this.protectPosition(roomIntel.getControllerPosition(), 1);

        for (const locationType of this.roomPlan.getPositionTypes()) {
            const baseType = locationType.split('.')[0];
            if (!CONTROLLER_STRUCTURES[baseType] || ['extension', 'road', 'container', 'extractor', 'link'].includes(baseType)) continue;

            // Protect area around essential structures.
            for (const pos of this.roomPlan.getPositions(locationType)) {
                this.protectPosition(pos);
            }
        }

        // Protect exits to safe rooms.
        const bounds: MinCutRect = {x1: 0, x2: 49, y1: 0, y2: 49};
        for (const exitDir of _.keys(safety.directions)) {
            if (!safety.directions[exitDir]) continue;

            if (exitDir === 'N') bounds.protectTopExits = true;
            if (exitDir === 'S') bounds.protectBottomExits = true;
            if (exitDir === 'W') bounds.protectLeftExits = true;
            if (exitDir === 'E') bounds.protectRightExits = true;
        }

        const potentialWallPositions: RoomPosition[] = [];
        const rampartCoords = minCut.getCutTiles(this.roomName, this.minCutBounds, bounds);
        for (const coord of rampartCoords) {
            potentialWallPositions.push(new RoomPosition(coord.x, coord.y, this.roomName));
        }

        this.pruneWalls(potentialWallPositions);

        // Actually place ramparts.
        for (const wallPosition of potentialWallPositions) {
            if (!wallPosition.isRelevant) continue;
            if (this.terrain.get(wallPosition.x, wallPosition.y) === TERRAIN_MASK_WALL) continue;

            this.placementManager.planLocation(wallPosition, 'rampart', null);
            if (settings.get('constructRoadsUnderRamparts') || this.terrain.get(wallPosition.x, wallPosition.y) === TERRAIN_MASK_SWAMP) {
                this.placementManager.planLocation(wallPosition, 'road', null);
                this.placementManager.planLocation(wallPosition, 'road.rampart', null);
            }
        }

        return 'ok';
    }

    /**
     * Marks all walls which are adjacent to the "inner area" of the room.
     *
     * @param {RoomPosition[]} walls
     *   Positions where walls are currently planned.
     */
    pruneWalls(walls: RoomPosition[]) {
        const roomIntel = getRoomIntel(this.roomName);
        const safety = roomIntel.calculateAdjacentRoomSafety();
        const roomCenter = _.first(this.roomPlan.getPositions('center'));
        this.safetyMatrix = new PathFinder.CostMatrix();

        const openList = [];
        openList.push(encodePosition(roomCenter));
        openList.push(encodePosition(roomIntel.getControllerPosition()));
        for (const source of roomIntel.getSourcePositions()) {
            openList.push(encodePosition(new RoomPosition(source.x, source.y, this.roomName)));
        }

        for (const mineral of roomIntel.getMineralPositions()) {
            openList.push(encodePosition(new RoomPosition(mineral.x, mineral.y, this.roomName)));
        }

        this.pruneWallFromTiles(walls, openList);

        // Do a second pass, checking which walls get touched by unsafe exits.

        // Prepare CostMatrix and exit points.
        const exits = [];

        for (let i = 0; i < 50; i++) {
            if (this.terrain.get(0, i) !== TERRAIN_MASK_WALL && !safety.directions.W) {
                exits.push(encodePosition(new RoomPosition(0, i, this.roomName)));
            }

            if (this.terrain.get(49, i) !== TERRAIN_MASK_WALL && !safety.directions.E) {
                exits.push(encodePosition(new RoomPosition(49, i, this.roomName)));
            }

            if (this.terrain.get(i, 0) !== TERRAIN_MASK_WALL && !safety.directions.N) {
                exits.push(encodePosition(new RoomPosition(i, 0, this.roomName)));
            }

            if (this.terrain.get(i, 49) !== TERRAIN_MASK_WALL && !safety.directions.S) {
                exits.push(encodePosition(new RoomPosition(i, 49, this.roomName)));
            }
        }

        this.pruneWallFromTiles(walls, exits, true);

        // Safety matrix has been filled, now mark any tiles unsafe that can be reached by a ranged attacker.
        for (let x = 0; x < 50; x++) {
            for (let y = 0; y < 50; y++) {
                // Only check around unsafe tiles.
                if (this.safetyMatrix.get(x, y) !== TILE_IS_UNSAFE) continue;

                this.markTilesInRangeOfUnsafeTile(x, y);
            }
        }
    }

    /**
     * Removes any walls that can not be reached from the given list of coordinates.
     *
     * @param {RoomPosition[]} walls
     *   Positions where walls are currently planned.
     * @param {string[]} startLocations
     *   Encoded positions from where to start flood filling.
     * @param {boolean} onlyRelevant
     *   Only check walls that have been declared as relevant in a previous pass.
     */
    pruneWallFromTiles(walls: RoomPosition[], startLocations: string[], onlyRelevant?: boolean) {
        const openList = {};
        const closedList = {};
        let safetyValue = TILE_IS_SAFE;

        for (const location of startLocations) {
            openList[location] = true;
        }

        // If we're doing an additionall pass, unmark walls first.
        if (onlyRelevant) {
            safetyValue = TILE_IS_UNSAFE;
            for (const wall of walls) {
                wall.isIrrelevant = true;
                if (wall.isRelevant) {
                    wall.isIrrelevant = false;
                    wall.isRelevant = false;
                }
            }
        }

        // Flood fill, marking all walls we touch as relevant.
        while (_.size(openList) > 0) {
            const nextPos = decodePosition(_.first(_.keys(openList)));

            // Record which tiles are safe or unsafe.
            this.safetyMatrix.set(nextPos.x, nextPos.y, safetyValue);

            delete openList[encodePosition(nextPos)];
            closedList[encodePosition(nextPos)] = true;

            this.checkForAdjacentWallsToPrune(nextPos, walls, openList, closedList);
        }
    }

    /**
     * Checks tiles adjacent to this one.
     * Marks ramparts as relevant and adds open positions to open list.
     *
     * @param {RoomPosition} targetPos
     *   The position to check around.
     * @param {RoomPosition[]} walls
     *   Positions where walls are currently planned.
     * @param {object} openList
     *   List of tiles to check, keyed by encoded tile position.
     * @param {object} closedList
     *   List of tiles that have been checked, keyed by encoded tile position.
     */
    checkForAdjacentWallsToPrune(targetPos: RoomPosition, walls: RoomPosition[], openList, closedList) {
        // Add unhandled adjacent tiles to open list.
        handleMapArea(targetPos.x, targetPos.y, (x, y) => {
            if (x === targetPos.x && y === targetPos.y) return;
            if (x < 1 || x > 48 || y < 1 || y > 48) return;

            // Ignore walls.
            if (this.terrain.get(x, y) === TERRAIN_MASK_WALL && !this.roomPlan.hasPosition('road', new RoomPosition(targetPos.x, targetPos.y, this.roomName))) return;

            const posName = encodePosition(new RoomPosition(x, y, this.roomName));
            if (openList[posName] || closedList[posName]) return;

            // If there's a rampart to be built there, mark it and move on.
            let wallFound = false;
            for (const wall of walls) {
                if (wall.x !== x || wall.y !== y) continue;

                // Skip walls that might have been discarded in a previous pass.
                if (wall.isIrrelevant) continue;

                wall.isRelevant = true;
                wallFound = true;
                closedList[posName] = true;
                break;
            }

            if (!wallFound) {
                openList[posName] = true;
            }
        });
    }

    /**
     * Mark tiles that can be reached by ranged creeps outside our walls as unsafe.
     *
     * @param {number} x
     *   x position of the a tile that is unsafe.
     * @param {number} y
     *   y position of the a tile that is unsafe.
     */
    markTilesInRangeOfUnsafeTile(x: number, y: number) {
        handleMapArea(x, y, (ax, ay) => {
            if (this.safetyMatrix.get(ax, ay) === TILE_IS_SAFE) {
                // Safe tile in range of an unsafe tile, mark as neutral.
                this.safetyMatrix.set(ax, ay, TILE_IS_ENDANGERED);
            }
        }, 3);
    }

    /**
     * Mark all tiles outside safe area as unbuildable.
     */
    sealRoom(): StepResult {
        for (let x = 1; x < 49; x++) {
            for (let y = 1; y < 49; y++) {
                if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) {
                    this.placementManager.blockPosition(x, y);
                    continue;
                }

                if (this.safetyMatrix.get(x, y) === TILE_IS_SAFE) {
                    // Record safe status in room plan.
                    this.placementManager.planLocation(new RoomPosition(x, y, this.roomName), 'safe', null);
                    continue;
                }
                if (this.safetyMatrix.get(x, y) === TILE_IS_ENDANGERED) continue;

                this.placementManager.blockPosition(x, y);
            }
        }

        return 'ok';
    }

    placeTowers(): StepResult {
        const step = new PlaceTowersStep(this.roomPlan, this.placementManager, this.safetyMatrix);
        return step.run();
    }

    placeRoadsToRamps(): StepResult {
        for (const rampartGroup of this.getRampartGroups()) {
            const roads = this.placementManager.findAccessRoad(this.roomCenterEntrances[0], rampartGroup, true);

            for (const road of roads) {
                if (this.roomPlan.hasPosition('extension', road)) {
                    this.roomPlan.removePosition('extension', road);
                    this.roomPlan.removePosition('extension.bay', road);
                    for (let i = 0; i < 10; i++) {
                        this.roomPlan.removePosition('extension.bay.' + i, road);
                    }

                    this.placementManager.unblockPosition(road.x, road.y);
                }

                this.placementManager.planLocation(road, 'road', 1);
            }
        }

        return 'ok';
    }

    getRampartGroups(): RoomPosition[][] {
        const allRamparts = _.map(this.roomPlan.getPositions('rampart'), pos => ({
            pos,
            isUsed: false,
        }));

        const rampartGroups: RoomPosition[][] = [];
        for (const rampart of allRamparts) {
            if (rampart.isUsed) continue;

            rampart.isUsed = true;
            const currentGroup = [rampart.pos];
            let rampartAdded = false;
            do {
                rampartAdded = false;
                for (const otherRampart of allRamparts) {
                    if (otherRampart.isUsed) continue;

                    for (const currentRampart of currentGroup) {
                        if (currentRampart.getRangeTo(otherRampart) !== 1) continue;

                        otherRampart.isUsed = true;
                        currentGroup.push(otherRampart.pos);
                        rampartAdded = true;
                        break;
                    }
                }
            } while (rampartAdded);

            rampartGroups.push(currentGroup);
        }

        return rampartGroups;
    }

    placeOnRamps(): StepResult {
        for (const rampart of this.roomPlan.getPositions('rampart')) {
            handleMapArea(rampart.x, rampart.y, (x, y) => {
                if (this.safetyMatrix.get(x, y) !== TILE_IS_ENDANGERED) return;
                if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return;

                const pos = new RoomPosition(x, y, this.roomName);
                if (!this.roomPlan.hasPosition('road', pos)) return;
                if (this.roomPlan.hasPosition('rampart', pos)) return;

                this.placementManager.planLocation(pos, 'rampart', null);
                this.placementManager.planLocation(pos, 'rampart.ramp', null);
            }, 3);
        }

        return 'ok';
    }

    placeQuadBreaker(): StepResult {
        for (const rampart of this.roomPlan.getPositions('rampart')) {
            handleMapArea(rampart.x, rampart.y, (x, y) => {
                if (this.safetyMatrix.get(x, y) !== TILE_IS_UNSAFE) return;
                if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return;

                this.safetyMatrix.set(x, y, TILE_IS_UNSAFE_NEAR_WALL);
                if (this.placementManager.getExitDistance(x, y) < 3) return;
                if (this.placementManager.isBlockedTile(x, y)) return;

                const pos = new RoomPosition(x, y, this.roomName);
                if (this.roomPlan.hasPosition('road', pos)) return;

                let nearRoad = false;
                if (
                    this.roomPlan.hasPosition('road', new RoomPosition(x - 1, y, this.roomName))
                    && !this.roomPlan.hasPosition('road.rampart', new RoomPosition(x - 1, y, this.roomName))
                ) nearRoad = true;
                if (
                    this.roomPlan.hasPosition('road', new RoomPosition(x + 1, y, this.roomName))
                    && !this.roomPlan.hasPosition('road.rampart', new RoomPosition(x + 1, y, this.roomName))
                ) nearRoad = true;
                if (
                    this.roomPlan.hasPosition('road', new RoomPosition(x, y - 1, this.roomName))
                    && !this.roomPlan.hasPosition('road.rampart', new RoomPosition(x, y - 1, this.roomName))
                ) nearRoad = true;
                if (
                    this.roomPlan.hasPosition('road', new RoomPosition(x, y + 1, this.roomName))
                    && !this.roomPlan.hasPosition('road.rampart', new RoomPosition(x, y + 1, this.roomName))
                ) nearRoad = true;

                if (!nearRoad && (x + y) % 2 === 0) return;

                this.placementManager.planLocation(pos, 'wall', null);
                this.placementManager.planLocation(pos, 'wall.quad', null);
            }, 3);
        }

        return 'ok';
    }

    isFinished(): boolean {
        return this.finished;
    }
}