Mirroar/hivemind

View on GitHub
src/creep/combat-manager.ts

Summary

Maintainability
F
5 days
Test Coverage
import hivemind from 'hivemind';
import utilities from 'utilities';
import {getResourcesIn} from 'utils/store';
import {handleMapArea} from 'utils/map';

type AttackTarget = Creep | Structure;

interface ScoredPosition {
    pos: RoomPosition,
    score: number,
}

const rangedMassAttackDamage = {
    0: RANGED_ATTACK_POWER,
    1: RANGED_ATTACK_POWER,
    2: RANGED_ATTACK_POWER * 0.4,
    3: RANGED_ATTACK_POWER * 0.1,
}

const TILE_PLAINS = 1;
const TILE_SWAMP = 2;
const TILE_WALL = 3;

export default class CombatManager {
    tileCache: Record<number, number>;
    tileCacheTime: number;

    hasRangedAttacked: boolean;
    hasAttacked: boolean;

    public manageCombatActions(creep: Creep) {
        this.hasAttacked = false;
        this.hasRangedAttacked = false;

        this.attackNearbyTargets(creep);
        this.healNearbyTargets(creep);
    }

    private healNearbyTargets(creep: Creep) {
        const healParts = creep.getActiveBodyparts(HEAL);
        if (healParts === 0) return;

        const targets = creep.pos.findInRange(FIND_CREEPS, 3, {
            // @todo Allow full-health creeps when pre-healing makes sense.
            filter: c => c.hits < c.hitsMax && (c.my || hivemind.relations.isAlly(c.owner.username)),
        });

        const target = _.max(targets, c => Math.min(
            c.hitsMax - c.hits,
            // @todo Factor in boosts.
            healParts * (c.pos.getRangeTo(creep) <= 1 ? HEAL_POWER : RANGED_HEAL_POWER),
        ));
        // @todo Handle the fact that we can't melee attack and heal at the same time.
        if (target) {
            if (creep.pos.getRangeTo(target.pos) <=1) {
                if (!this.hasAttacked) creep.heal(target);
            } else {
                if (!this.hasAttacked && !this.hasRangedAttacked) creep.rangedHeal(target);
            }
        }
    }

    private attackNearbyTargets(creep: Creep) {
        const availableTargets = this.getNearbyTargets(creep);
        if (availableTargets.length === 0) return;

        const availableOwnedTargets = _.filter(availableTargets, target => ('owner' in target) && target?.owner?.username);

        if (creep.getActiveBodyparts(RANGED_ATTACK) > 0) {
            if (availableOwnedTargets.length >= 2 && this.determineMassAttackDamage(creep, availableOwnedTargets) > RANGED_ATTACK_POWER) {
                // @todo Ideally, even involve score from `scoreTargets()`.
                creep.rangedMassAttack();
                this.hasRangedAttacked = true;
                return;
            }

            this.hasRangedAttacked = true;
            creep.rangedAttack(this.getMostValuableTarget(creep, availableTargets));
            return;
        }

        if (creep.getActiveBodyparts(ATTACK) > 0) {
            this.hasAttacked = true;
            creep.attack(this.getMostValuableTarget(creep, availableTargets));
        }
    }

    private determineMassAttackDamage(creep: Creep, targets: AttackTarget[]): number {
        let total = 0;
        for (const target of targets) {
            total += rangedMassAttackDamage[creep.pos.getRangeTo(target.pos)];
        }

        return total;
    }

    private getNearbyTargets(creep: Creep): AttackTarget[] {
        const availableTargets = [];
        const maxRange = this.getMaxAttackRange(creep);

        for (const enemyName in creep.room.enemyCreeps) {
            if (hivemind.relations.isAlly(enemyName)) continue;

            for (const target of creep.room.enemyCreeps[enemyName]) {
                if (creep.pos.getRangeTo(target) > maxRange) continue;

                availableTargets.push(target);
            }
        }

        // @todo Add construction sites (that have build progress) to run over.
        // @todo Add structures in range only if room is not owned by an ally.
        // @todo Add power creeps
        // @todo Use same filters here and in `getAllTargetsInRoom`.
        const isMyRoom = creep.room.isMine()
            || hivemind.relations.isAlly(creep.room.controller?.owner?.username)
            || hivemind.relations.isAlly(creep.room.controller?.reservation?.username)
            || (Memory.strategy?.remoteHarvesting?.rooms || []).includes(creep.room.name);
        for (const structure of creep.pos.findInRange(FIND_STRUCTURES, maxRange)) {
            if (!structure.hits) continue;
            if ('owner' in structure && hivemind.relations.isAlly(structure.owner?.username)) continue;
            if (!('owner' in structure) && isMyRoom) continue;

            availableTargets.push(structure);
        }

        return availableTargets;
    }

    public getMaxAttackRange(creep: Creep): number {
        if (creep.getActiveBodyparts(RANGED_ATTACK) > 0) return 3;
        if (creep.getActiveBodyparts(ATTACK) > 0) return 1;

        return 0;
    }

    public performKitingMovement(creep: Creep, target: AttackTarget) {
        let targetRange = this.getMaxAttackRange(creep);
        if (target instanceof Creep) {
            const isMeleeCreep = target.getActiveBodyparts(ATTACK) > 0;
            if (!isMeleeCreep) targetRange -= 1;
        }

        const positions = this.getValidNeighboringPositions(creep.pos);
        const enemyCreeps = this.getEnemyMilitaryCreeps(creep.room);

        if (this.hasEnemyCreepsInFightingRange(creep, enemyCreeps)) {
            const scoredPositions = this.scoreKitingPositions(creep, enemyCreeps, positions);
            if (scoredPositions.length === 0) {
                // We don't know what to do. Guess we resume chasing our target.
                creep.whenInRange(targetRange, target, () => {});
            }

            const newPosition = _.max(scoredPositions, 'score');
            if (!creep.pos.isEqualTo(newPosition)) creep.move(creep.pos.getDirectionTo(newPosition.pos));

            return;
        }

        // No danger, just keep close to our target to kill it.
        creep.whenInRange(targetRange, target, () => {});
    }

    public hasEnemyCreepsInFightingRange(creep: Creep, enemyCreeps?: Creep[]): boolean {
        return _.any(enemyCreeps ?? this.getEnemyMilitaryCreeps(creep.room), c => c.pos.getRangeTo(creep) <= 5);
    }

    private getValidNeighboringPositions(position: RoomPosition) {
        const positions: RoomPosition[] = [];
        handleMapArea(position.x, position.y, (x, y) => {
            const newPosition = new RoomPosition(x, y, position.roomName);
            if (this.isTileWall(newPosition)) return;

            positions.push(newPosition);
        });

        return positions;
    }

    public getEnemyMilitaryCreeps(room: Room): Creep[] {
        const creeps = [];
        for (const userName in room.enemyCreeps) {
            if (hivemind.relations.isAlly(userName)) continue;
            for (const enemyCreep of room.enemyCreeps[userName]) {
                // Ignore creeps that are not dangerous to us.
                if (
                    enemyCreep.getActiveBodyparts(ATTACK) === 0
                    && enemyCreep.getActiveBodyparts(RANGED_ATTACK) === 0
                ) continue;

                creeps.push(enemyCreep);
            }
        }

        return creeps;
    }

    private scoreKitingPositions(creep: Creep, enemyCreeps: Creep[], positions: RoomPosition[]): ScoredPosition[] {
        const scored = _.map(positions, pos => {return {
            pos,
            score: 0,
        }});

        // @todo Prefer positions where we can do a high amount of RMA damage.
        this.addRoomCenterRangeScore(scored);
        this.addTerrainScore(creep, enemyCreeps, scored);
        this.addEnemyRangeScore(creep, enemyCreeps, scored);

        return scored;
    }

    private addRoomCenterRangeScore(positions: ScoredPosition[]) {
        for (const position of positions) {
            const range = position.pos.getRangeTo(25, 25);
            position.score -= range * (0.9 + (range / 10));
            position.score -= Math.min(25 - position.pos.x, 25 - position.pos.y);
        }
    }

    private addTerrainScore(creep: Creep, enemyCreeps: Creep[], positions: ScoredPosition[]) {
        const isFleeing = _.some(enemyCreeps, c => !this.couldWinFightAgainst(creep, c));

        for (const position of positions) {
            if (this.isTileSwamp(position.pos)) position.score -= 100;
            // @todo Check what room it at the other side of the exit to
            // determine how bad leaving in that direction would be.
            if (
                position.pos.x === 0
                || position.pos.x === 49
                || position.pos.y === 0
                || position.pos.y === 49
            ) position.score += isFleeing ? 200 : -200;

            handleMapArea(position.pos.x, position.pos.y, (x, y) => {
                const pos = new RoomPosition(x, y, position.pos.roomName);
                if (this.isTileWall(pos)) position.score -= 20;
                if (this.isTileSwamp(pos)) position.score -= 10;
                if (x === 0 || x === 49 || y === 0 || y === 49) position.score += isFleeing ? 20 : -20;
            }, 2);
        }
    }

    private isTileWall(pos: RoomPosition): boolean {
        const encodedPos = pos.roomName + ':' + (pos.x + (50 * pos.y));
        this.generateTileCache(pos, encodedPos);

        return this.tileCache[encodedPos] === TILE_WALL;
    }

    private isTileSwamp(pos: RoomPosition): boolean {
        const encodedPos = pos.roomName + ':' + (pos.x + (50 * pos.y));
        this.generateTileCache(pos, encodedPos);

        return this.tileCache[encodedPos] === TILE_SWAMP;
    }

    private isTilePlains(pos: RoomPosition): boolean {
        const encodedPos = pos.roomName + ':' + (pos.x + (50 * pos.y));
        this.generateTileCache(pos, encodedPos);

        return this.tileCache[encodedPos] === TILE_PLAINS;
    }

    private generateTileCache(pos: RoomPosition, encodedPos: string) {
        if (this.tileCacheTime !== Game.time) {
            this.tileCache = {};
            this.tileCacheTime = Game.time;
        }

        if (this.tileCache[encodedPos]) return;

        const terrain = new Room.Terrain(pos.roomName);
        const structures = pos.lookFor(LOOK_STRUCTURES);
        if (terrain.get(pos.x, pos.y) & TERRAIN_MASK_WALL) {
            if (!_.some(structures, s => s.structureType === STRUCTURE_ROAD)) {
                this.tileCache[encodedPos] = TILE_WALL;
                return;
            }
        }

        if (_.any(structures, s => !s.isWalkable())) {
            this.tileCache[encodedPos] = TILE_WALL;
            return;
        }

        if (terrain.get(pos.x, pos.y) & TERRAIN_MASK_SWAMP) {
            if (!_.some(structures, s => s.structureType === STRUCTURE_ROAD)) {
                this.tileCache[encodedPos] = TILE_SWAMP;
                return;
            }
        }

        this.tileCache[encodedPos] = TILE_PLAINS;
    }

    private addEnemyRangeScore(creep: Creep, enemyCreeps: Creep[], positions: ScoredPosition[]) {
        const maxRange = this.getMaxAttackRange(creep);
        const isRangedCreep = maxRange > 1;

        // @todo Corner positions are beneficial when we're fleeing, and
        // problematic when we're chasing.

        for (const enemy of enemyCreeps) {
            // @todo for strong enemy creeps, we might actually prefer
            // staying out of its range to getting into range (>=5).

            const willFight = this.couldWinFightAgainst(creep, enemy);
            const isEnemyMeleeCreep = enemy.getActiveBodyparts(ATTACK) > 0 && enemy.getActiveBodyparts(RANGED_ATTACK) === 0;
            const mightMoveTowardsUs = enemy.getActiveBodyparts(MOVE) > 0 && enemy.fatigue === 0;

            for (const position of positions) {
                const distance = position.pos.getRangeTo(enemy.pos);

                if (distance > maxRange) {
                    position.score -= (willFight ? (isRangedCreep ? 10 : 30) : -50) * (distance - maxRange);
                    continue;
                }

                const enemyRange = (isEnemyMeleeCreep ? 1 : 3) + (mightMoveTowardsUs ? 1 : 0);
                if (!willFight) {
                    if (distance <= enemyRange) position.score -= 2500;
                    continue;
                }

                // @todo Instead of doing these range preferences, we should calculate the possible incoming damage for
                // a tile and check whether we could heal against it.
                if (isRangedCreep) {
                    // Don't get too close, but prefer range 2 for easier chasing
                    // unless we're fighting a melee creep that might move towards us.
                    if (distance < 2) position.score -= 500;
                    if (distance === 2) position.score += isEnemyMeleeCreep ? 300 : 1000;
                    if (distance === 3) position.score += isEnemyMeleeCreep ? 1000 : 300;
                }
                else {
                    // Prefer moving onto the enemy tile, so we can keep
                    // chasing.
                    if (distance === 0) position.score += 1000;
                    if (distance === 1) position.score += 300;
                }
            }
        }
    }

    public couldWinFightAgainst(creep: Creep, otherCreep: Creep): boolean {
        if (
            (
                creep.getActiveBodyparts(RANGED_ATTACK) > 0 
                || (creep.getActiveBodyparts(ATTACK) > 0 && otherCreep.getActiveBodyparts(RANGED_ATTACK) === 0)
            )
            && otherCreep.getActiveBodyparts(HEAL) === 0
            && creep.hits === creep.hitsMax
        ) {
            // Take pot shots at creeps that can't heal.
            return true;
        }

        if (
            creep.getActiveBodyparts(RANGED_ATTACK) * RANGED_ATTACK_POWER > otherCreep.getActiveBodyparts(HEAL) * HEAL_POWER
            && otherCreep.getActiveBodyparts(RANGED_ATTACK) === 0
        ) return true;

        if (creep.getActiveBodyparts(RANGED_ATTACK) * RANGED_ATTACK_POWER + creep.getActiveBodyparts(HEAL) * HEAL_POWER > otherCreep.getActiveBodyparts(HEAL) * HEAL_POWER + otherCreep.getActiveBodyparts(RANGED_ATTACK) * RANGED_ATTACK_POWER) {
            return true;
        }

        if (
            creep.getActiveBodyparts(ATTACK) > otherCreep.getActiveBodyparts(ATTACK)
            && creep.getActiveBodyparts(ATTACK) * ATTACK_POWER > otherCreep.getActiveBodyparts(HEAL) * HEAL_POWER
            && creep.getActiveBodyparts(HEAL) * HEAL_POWER >= otherCreep.getActiveBodyparts(RANGED_ATTACK) * RANGED_ATTACK_POWER
        ) return true;

        return false;
    }

    public getMostValuableTarget(creep: Creep, targets?: AttackTarget[]): AttackTarget | null {
        if (!targets) targets = this.getAllTargetsInRoom(creep.room);

        const scoredTargets = this.scoreTargets(creep, targets);

        const bestTarget = utilities.getBestOption(scoredTargets);
        return bestTarget?.object;
    }

    private getAllTargetsInRoom(room: Room): AttackTarget[] {
        const allTargets = [];

        // Attack harvest / transport creeps, ideally those with energy in them.
        for (const enemyName in room.enemyCreeps) {
            if (hivemind.relations.isAlly(enemyName)) continue;

            for (const target of room.enemyCreeps[enemyName]) {
                // @todo Avoid military creeps that are too strong for us.
                allTargets.push(target);
            }
        }

        // @todo Also consider rooms on the path of harvesting operations
        // as my rooms.
        const isMyRoom = room.isMine()
            || hivemind.relations.isAlly(room.controller?.owner?.username)
            || hivemind.relations.isAlly(room.controller?.reservation?.username)
            || (Memory.strategy?.remoteHarvesting?.rooms || []).includes(room.name);
        // Attack containers, roads and other infrastructure.
        for (const structure of room.structures) {
            if (!structure.hits) continue;
            if ('owner' in structure && hivemind.relations.isAlly(structure.owner?.username)) continue;
            if (!('owner' in structure) && isMyRoom) continue;

            allTargets.push(structure);
        }

        // @todo Attack / stomp construction sites.
        // @todo Attack power creeps.

        return allTargets;
    }

    private scoreTargets(creep: Creep, targets: AttackTarget[]): Array<{
        weight: number;
        priority: number;
        object: AttackTarget;
    }> {
        return _.map(
            targets,
            target => {
                let priority = 0;
                let weight = 0;
                // @todo Containers and roads are only relevant targets if the room is
                // owned / harvested by another player.
                if ('structureType' in target) {
                    if (target.structureType === STRUCTURE_INVADER_CORE) priority = 4;
                    if (target.structureType === STRUCTURE_TOWER) priority = 3;
                    if (target.structureType === STRUCTURE_CONTAINER) priority = 2;
                    if (target.structureType === STRUCTURE_ROAD) priority = 1;
                    weight = 1 - (target.hits / target.hitsMax);
                }
                else {
                    // @todo Prioritize boosted creeps.
                    priority = 3 - (this.couldWinFightAgainst(creep, target) ? 0 : 2);
                    // Prioritize killing damaged creeps.
                    weight = (1 - (target.hits / target.hitsMax)) * 2;
                    // Prioritize close creeps we can actually reach.
                    weight -= target.pos.getRangeTo(creep.pos) / 20;
                    // Prioritize creeps that still have a long TTL.
                    weight += target.ticksToLive / (target.getActiveBodyparts(CLAIM) > 0 ? CREEP_CLAIM_LIFE_TIME : CREEP_LIFE_TIME);
                    // Prioritize creeps with expensive body parts.
                    weight += this.getBodyValue(target) / BODYPART_COST[CLAIM];
                    // Prioritize creeps carrying expensive goods.
                    weight += this.getStoreValue(target) / CARRY_CAPACITY;
                }

                return {
                    priority,
                    weight,
                    object: target,
                }
            }
        );
    }

    private getBodyValue(target: Creep): number {
        let total = 0;
        for (const part of target.body) {
            total += BODYPART_COST[part.type];
        }

        return total / target.body.length;
    }

    private getStoreValue(target: Creep): number {
        let total = 0;
        for (const resourceType of getResourcesIn(target.store)) {
            // @todo Weigh depending on resource type.
            total += target.store.getUsedCapacity(resourceType);
        }

        return total / target.body.length;
    }
}