Mirroar/hivemind

View on GitHub
src/room/planner/placement-manager.ts

Summary

Maintainability
C
1 day
Test Coverage
import RoomPlan from 'room/planner/room-plan';
import {encodePosition, decodePosition} from 'utils/serialization';
import {handleMapArea} from 'utils/map';

export default class PlacementManager {
    public get ROAD_POSITION() {
        return 1;
    }

    public get DISCOURAGED_POSITION() {
        return 5;
    }

    public get IMPASSABLE_POSITION() {
        return 255;
    }

    public get ROAD_THROUGH_WALL_COST() {
        return 30;
    }

    protected terrain: RoomTerrain;
    protected openList: Record<string, {
        range: number;
        path: Record<string, boolean>;
    }>;

    protected closedList: Record<string, boolean>;

    protected currentBuildSpot: {
        pos: RoomPosition;
        info: {
            range: number;
            path: Record<string, boolean>;
        };
    };

    protected origin: RoomPosition;
    protected originEntrances: RoomPosition[];
    protected costMatrixBackup: Record<string, number> = {};

    constructor(
        protected roomPlan: RoomPlan,
        protected buildingMatrix: CostMatrix,
        protected wallDistanceMatrix: CostMatrix,
        protected exitDistanceMatrix: CostMatrix,
    ) {
        this.terrain = new Room.Terrain(this.roomPlan.roomName);
        this.prepareBuildingMatrix();
    }

    /**
     * Prepares building cost matrix.
     */
    prepareBuildingMatrix() {
        for (let x = 0; x < 50; x++) {
            for (let y = 0; y < 50; y++) {
                if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) {
                    this.buildingMatrix.set(x, y, this.ROAD_THROUGH_WALL_COST);
                    continue;
                }

                // Treat border as unwalkable for in-room pathfinding.
                if (x === 0 || y === 0 || x === 49 || y === 49) {
                    this.buildingMatrix.set(x, y, this.IMPASSABLE_POSITION);
                    continue;
                }

                const wallDistance = this.wallDistanceMatrix.get(x, y);
                const exitDistance = this.exitDistanceMatrix.get(x, y);

                if (exitDistance <= 2) {
                    // Avoid tiles we can't build ramparts on.
                    this.buildingMatrix.set(x, y, this.DISCOURAGED_POSITION * 2);
                }
            }
        }
    }

    /**
     * Plans a room planner location of a certain type.
     *
     * @param {RoomPosition} pos
     *   Position to plan the structure at.
     * @param {string} locationType
     *   Type of location to plan.
     * @param {number} pathFindingCost
     *   Value to set in the pathfinding costmatrix at this position (Default 255).
     */
    planLocation(pos: RoomPosition, locationType: string, pathFindingCost?: number) {
        this.roomPlan.addPosition(locationType, pos);

        if (typeof pathFindingCost === 'undefined') {
            pathFindingCost = this.IMPASSABLE_POSITION;
        }

        if (pathFindingCost && this.buildingMatrix.get(pos.x, pos.y) < 100) {
            this.buildingMatrix.set(pos.x, pos.y, pathFindingCost);
        }
    }

    discouragePosition(x: number, y: number) {
        if (this.buildingMatrix.get(x, y) >= this.DISCOURAGED_POSITION || this.buildingMatrix.get(x, y) === this.ROAD_POSITION) return;

        this.buildingMatrix.set(x, y, this.DISCOURAGED_POSITION);
    }

    blockPosition(x: number, y: number) {
        this.buildingMatrix.set(x, y, this.IMPASSABLE_POSITION);
    }

    unblockPosition(x: number, y: number) {
        this.buildingMatrix.set(x, y, 0);
    }

    getWallDistance(x: number, y: number): number {
        return this.wallDistanceMatrix.get(x, y);
    }

    getExitDistance(x: number, y: number): number {
        return this.exitDistanceMatrix.get(x, y);
    }

    isBlockedTile(x: number, y: number): boolean {
        return this.buildingMatrix.get(x, y) >= 100;
    }

    /**
     * Checks if a structure can be placed on the given tile.
     *
     * @param {number} x
     *   x coordinate of the position to check.
     * @param {number} y
     *   y coordinate of the position to check.
     * @param {boolean} allowRoads
     *   Whether to allow building placement on a road.
     *
     * @return {boolean}
     *   True if building on the given coordinates is allowed.
     */
    isBuildableTile(x: number, y: number, allowRoads?: boolean, allowNearExit?: boolean): boolean {
        // Only build on valid terrain.
        if (this.terrain.get(x, y) === TERRAIN_MASK_WALL) return false;

        // Don't build too close to exits.
        if (this.exitDistanceMatrix.get(x, y) <= (allowNearExit ? 2 : 5)) return false;

        const matrixValue = this.buildingMatrix.get(x, y);
        // Can't build on other buildings.
        if (matrixValue > 100) return false;

        // Tiles next to walls are fine for building, just not so much for pathing.
        if (matrixValue === this.DISCOURAGED_POSITION && this.wallDistanceMatrix.get(x, y) <= 2) return true;

        // @todo Find out why this check was initially introduced.
        // Probably to not build close to exits.
        if (matrixValue > 1) return false;

        // Don't build on roads if not allowed.
        if (matrixValue === this.ROAD_POSITION && !allowRoads) return false;

        return true;
    }

    /**
     * Initializes pathfinding for finding building placement spots.
     */
    startBuildingPlacement(origin?: RoomPosition, originEntrances?: RoomPosition[]) {
        if (origin) this.origin = origin;
        if (originEntrances) this.originEntrances = originEntrances;

        // Flood fill from the center to place buildings that need to be accessible.
        this.openList = {};
        this.closedList = {};
        const startPath = {};
        startPath[encodePosition(this.origin)] = true;
        this.openList[encodePosition(this.origin)] = {
            range: 0,
            path: startPath,
        };
    }

    /**
     * Gets the next reasonable building placement location.
     *
     * @return {RoomPosition}
     *   A buildable spot.
     */
    getNextAvailableBuildSpot(): RoomPosition {
        while (_.size(this.openList) > 0) {
            let minDist = null;
            let nextPos = null;
            let nextInfo = null;
            _.each(this.openList, (info, posName) => {
                const pos = decodePosition(posName);
                if (!minDist || info.range < minDist) {
                    minDist = info.range;
                    nextPos = pos;
                    nextInfo = info;
                }
            });

            if (!nextPos) break;

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

            // Add unhandled adjacent tiles to open list.
            handleMapArea(nextPos.x, nextPos.y, (x, y) => {
                if (x === nextPos.x && y === nextPos.y) return;
                if (!this.isBuildableTile(x, y, true)) return;

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

                const newPath = {};
                for (const oldPos of _.keys(nextInfo.path)) {
                    newPath[oldPos] = true;
                }

                newPath[location] = true;
                this.openList[location] = {
                    range: minDist + 1,
                    path: newPath,
                };
            });

            // Don't build to close to room center.
            if (nextPos.getRangeTo(this.origin) < 3) continue;

            // Don't build on roads.
            if (!this.isBuildableTile(nextPos.x, nextPos.y)) continue;

            this.currentBuildSpot = {
                pos: nextPos,
                info: nextInfo,
            };
            return nextPos;
        }

        return null;
    }

    /**
     * Removes all pathfinding options that use the given position.
     *
     * @param {string} targetPos
     *   An encoded room position that should not be used in paths anymore.
     */
    filterOpenList(targetPos: string) {
        for (const posName in this.openList) {
            if (this.openList[posName].path[targetPos]) {
                delete this.openList[posName];
            }
        }
    }

    /**
     * Gets information about the most recently requested build spot.
     *
     * @return {object}
     *   Info about the build spot, containing:
     *   - range: Distance from room center.
     *   - path: An object keyed by room positions that have been traversed.
     */
    getCurrentBuildSpotInfo() {
        return this.currentBuildSpot.info;
    }

    /**
     * Places all remaining structures of a given type.
     *
     * @param {string} structureType
     *   The type of structure to plan.
     * @param {boolean} addRoad
     *   Whether an access road should be added for these structures.
     */
    placeAll(structureType: StructureConstant, addRoad: boolean) {
        while (this.roomPlan.canPlaceMore(structureType)) {
            const nextPos = this.getNextAvailableBuildSpot();
            if (!nextPos) break;

            this.planLocation(new RoomPosition(nextPos.x, nextPos.y, this.roomPlan.roomName), structureType);
            this.filterOpenList(encodePosition(nextPos));

            if (addRoad) this.placeAccessRoad(nextPos);
        }
    }

    /**
     * Plans a road from the given position to the room's center.
     *
     * @param {RoomPosition} to
     *   Source position from which to start the road.
     */
    placeAccessRoad(to: RoomPosition) {
        // Plan road out of labs.
        const accessRoads = this.findAccessRoad(to, this.originEntrances);
        for (const pos of accessRoads) {
            this.planLocation(pos, 'road', 1);
        }
    }

    /**
     * Tries to create a road from a target point.
     *
     * @param {RoomPosition} from
     *   Position from where to start road creation. The position itself will not
     *   have a road built on it.
     * @param {RoomPosition|RoomPosition[]} to
     *   Position or positions to lead the road to.
     *
     * @return {RoomPosition[]}
     *   Positions that make up the newly created road.
     */
    findAccessRoad(from: RoomPosition, to: RoomPosition | RoomPosition[], breakExtensions?: boolean): RoomPosition[] {
        const result = PathFinder.search(from, to, {
            roomCallback: () => {
                if (!breakExtensions) return this.buildingMatrix;

                const matrix = this.buildingMatrix.clone();
                for (const pos of this.roomPlan.getPositions('extension')) {
                    matrix.set(pos.x, pos.y, 25);
                }

                return matrix;
            },
            maxRooms: 1,
            plainCost: 2,
            swampCost: 2, // Swamps are more expensive to build roads on, but once a road is on them, creeps travel at the same speed.
            heuristicWeight: 0.9,
        });

        if (!result.path) return [];

        const newRoads = [];
        for (const pos of result.path) {
            newRoads.push(pos);
        }

        return newRoads;
    }

    isPositionAccessible(pos: RoomPosition, noTunnels?: boolean) {
        // We don't care about cost, just about possibility.
        const result = PathFinder.search(pos, this.originEntrances, {
            roomCallback: () => this.buildingMatrix,
            maxRooms: 1,
            plainCost: 1,
            swampCost: 1,
        });

        if (noTunnels) {
            for (const position of result.path) {
                if (this.terrain.get(position.x, position.y) === TERRAIN_MASK_WALL && !this.roomPlan.hasPosition('road', position)) return false;
            }
        }

        return !result.incomplete;
    }

    /**
     * Plans a room planner location of a certain type without fully committing.
     *
     * @param {RoomPosition} pos
     *   Position to plan the structure at.
     * @param {string} locationType
     *   Type of location to plan.
     * @param {number} pathFindingCost
     *   Value to set in the pathfinding costmatrix at this position (Default 255).
     */
    planTemporaryLocation(pos: RoomPosition, locationType: string, pathFindingCost?: number) {
        if (!this.costMatrixBackup[encodePosition(pos)]) {
            this.costMatrixBackup[encodePosition(pos)] = this.buildingMatrix.get(pos.x, pos.y);
        }

        this.planLocation(pos, locationType + '_placeholder', pathFindingCost);
    }

    commitTemporaryLocation(pos: RoomPosition, locationType: string) {
        delete this.costMatrixBackup[encodePosition(pos)];
        this.planLocation(pos, locationType, null);
        this.roomPlan.removePosition(locationType + '_placeholder', pos);
    }

    discardTemporaryLocations(locationType: string) {
        for (const position of this.roomPlan.getPositions(locationType + '_placeholder')) {
            delete this.costMatrixBackup[encodePosition(position)];
        }

        this.roomPlan.removeAllPositions(locationType + '_placeholder');
    }
}