-- Copyright (C) 2022  veden

-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.

-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.

-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <https://www.gnu.org/licenses/>.

-- imports

local ChunkPropertyUtils = require("libs/ChunkPropertyUtils")
local UnitUtils = require("libs/UnitUtils")
local BaseUtils = require("libs/BaseUtils")
local MathUtils = require("libs/MathUtils")
local Processor = require("libs/Processor")
local Constants = require("libs/Constants")
local MapUtils = require("libs/MapUtils")
local Squad = require("libs/Squad")
local tests = require("tests")
local ChunkUtils = require("libs/ChunkUtils")
local Upgrade = require("libs/Upgrade")
local Utils = require("libs/Utils")

-- Constants

local FACTION_SET = Constants.FACTION_SET

local ENEMY_ALIGNMENT_LOOKUP = Constants.ENEMY_ALIGNMENT_LOOKUP
local VANILLA_ENTITY_TYPE_LOOKUP = Constants.VANILLA_ENTITY_TYPE_LOOKUP
local ENTITY_SKIP_COUNT_LOOKUP = Constants.ENTITY_SKIP_COUNT_LOOKUP
local BUILDING_HIVE_TYPE_LOOKUP = Constants.BUILDING_HIVE_TYPE_LOOKUP

local TICKS_A_MINUTE = Constants.TICKS_A_MINUTE
local COMMAND_TIMEOUT = Constants.COMMAND_TIMEOUT

local RECOVER_NEST_COST = Constants.RECOVER_NEST_COST
local RECOVER_WORM_COST = Constants.RECOVER_WORM_COST

local RETREAT_GRAB_RADIUS = Constants.RETREAT_GRAB_RADIUS

local RETREAT_SPAWNER_GRAB_RADIUS = Constants.RETREAT_SPAWNER_GRAB_RADIUS
local BASE_PHEROMONE = Constants.BASE_PHEROMONE
local PLAYER_PHEROMONE = Constants.PLAYER_PHEROMONE

local UNIT_DEATH_POINT_COST = Constants.UNIT_DEATH_POINT_COST

local PENDING_UPGRADE_CREATION_THESHOLD = Constants.PENDING_UPGRADE_CREATION_THESHOLD

local MAX_HIVE_TTL = Constants.MAX_HIVE_TTL
local MIN_HIVE_TTL = Constants.MIN_HIVE_TTL
local DEV_HIVE_TTL = Constants.DEV_HIVE_TTL

local SETTLE_CLOUD_WARMUP = Constants.SETTLE_CLOUD_WARMUP

-- imported functions

local isMember = Utils.isMember
local split = Utils.split

local planning = BaseUtils.planning

local addExcludeSurface = MapUtils.addExcludedSurface
local removeExcludeSurface = MapUtils.removeExcludedSurface

local setPointAreaInQuery = Utils.setPointAreaInQuery

local nextMap = MapUtils.nextMap

local processClouds = Processor.processClouds

local distortPosition = MathUtils.distortPosition
local linearInterpolation = MathUtils.linearInterpolation
local gaussianRandomRangeRG = MathUtils.gaussianRandomRangeRG
local prepMap = MapUtils.prepMap
local activateMap = MapUtils.activateMap

local findBaseInitialAlignment = BaseUtils.findBaseInitialAlignment

local processBaseAIs = BaseUtils.processBaseAIs

local registerEnemyBaseStructure = ChunkUtils.registerEnemyBaseStructure

local queueGeneratedChunk = MapUtils.queueGeneratedChunk
local isRampantSetting = Utils.isRampantSetting

local processPendingUpgrades = Processor.processPendingUpgrades
local canMigrate = BaseUtils.canMigrate

local squadDispatch = Squad.squadDispatch

local cleanUpMapTables = Processor.cleanUpMapTables

local positionToChunkXY = MapUtils.positionToChunkXY

local processVengence = Processor.processVengence
local processAttackWaves = Processor.processAttackWaves

local disperseVictoryScent = MapUtils.disperseVictoryScent

local getChunkByPosition = MapUtils.getChunkByPosition

local removeChunkFromMap = MapUtils.removeChunkFromMap
local getChunkByXY = MapUtils.getChunkByXY

local entityForPassScan = ChunkUtils.entityForPassScan

local processPendingChunks = Processor.processPendingChunks
local processScanChunks = Processor.processScanChunks

local processMap = Processor.processMap
local processPlayers = Processor.processPlayers
local scanEnemyMap = Processor.scanEnemyMap
local scanPlayerMap = Processor.scanPlayerMap
local scanResourceMap = Processor.scanResourceMap

local processNests = Processor.processNests
local processHives = Processor.processHives

local rallyUnits = Squad.rallyUnits

local recycleBases = BaseUtils.recycleBases

local deathScent = MapUtils.deathScent
local victoryScent = MapUtils.victoryScent

local createSquad = Squad.createSquad

local createBase = BaseUtils.createBase
local findNearbyBaseByPosition = ChunkPropertyUtils.findNearbyBaseByPosition
local findNearbyBase = ChunkPropertyUtils.findNearbyBase

local removeDrainPylons = ChunkPropertyUtils.removeDrainPylons
local getDrainPylonPair = ChunkPropertyUtils.getDrainPylonPair

local createDrainPylon = UnitUtils.createDrainPylon

local isDrained = ChunkPropertyUtils.isDrained
local setDrainedTick = ChunkPropertyUtils.setDrainedTick
local getCombinedDeathGeneratorRating = ChunkPropertyUtils.getCombinedDeathGeneratorRating

local retreatUnits = Squad.retreatUnits

local accountPlayerEntity = ChunkUtils.accountPlayerEntity
local unregisterEnemyBaseStructure = ChunkUtils.unregisterEnemyBaseStructure
local makeImmortalEntity = ChunkUtils.makeImmortalEntity

local registerResource = ChunkUtils.registerResource
local unregisterResource = ChunkUtils.unregisterResource

local cleanSquads = Squad.cleanSquads

local queueUpgrade = BaseUtils.queueUpgrade

local modifyBaseUnitPoints = BaseUtils.modifyBaseUnitPoints

local tRemove = table.remove

local sFind = string.find

local mRandom = math.random

-- local references to global

local Universe -- manages the chunks that make up the game Universe

-- hook functions

local function onIonCannonFired(event)
    --[[
        event.force, event.surface, event.player_index, event.position, event.radius
    --]]
    local map = Universe.maps[event.surface.index]
    if not map then
        return
    end

    local chunk = getChunkByPosition(map, event.position)
    if (chunk ~= -1) then
        local base = findNearbyBase(chunk)
        if base then
            base.ionCannonBlasts = base.ionCannonBlasts + 1
            rallyUnits(chunk, event.tick)
            modifyBaseUnitPoints(base, 4000, "Ion Cannon")
        end
    end
end

local function onAbandonedRuins(event)
    local entity = event.entity
    if entity.valid and (entity.force.name ~= "enemy") then
        local map = Universe.maps[entity.surface.index]
        if not map then
            return
        end
        accountPlayerEntity(entity, map, true)
    end
end

local function hookEvents()
    if settings.startup["ion-cannon-radius"] ~= nil then
        script.on_event(remote.call("orbital_ion_cannon", "on_ion_cannon_fired"),
                        onIonCannonFired)
    end
    if settings.global["AbandonedRuins-set"] ~= nil then
        script.on_event(remote.call("AbandonedRuins", "get_on_entity_force_changed_event"),
                        onAbandonedRuins)
    end
end

local function initializeLibraries()
    BaseUtils.init(Universe)
    Squad.init(Universe)
    BaseUtils.init(Universe)
    Processor.init(Universe)
    MapUtils.init(Universe)
    ChunkPropertyUtils.init(Universe, MapUtils)
    ChunkUtils.init(Universe)
end

local function onLoad()
    Universe = global.universe

    hookEvents()
end

local function onChunkGenerated(event)
    -- queue generated chunk for delayed processing, queuing is required because
    -- some mods (RSO) mess with chunk as they are generated, which messes up the
    -- scoring.
    queueGeneratedChunk(event)
end

local function onModSettingsChange(event)

    if not isRampantSetting(event.setting) then
        return
    end

    Universe.safeEntities["curved-rail"] = settings.global["rampant--safeBuildings-curvedRail"].value
    Universe.safeEntities["straight-rail"] = settings.global["rampant--safeBuildings-straightRail"].value
    Universe.safeEntities["rail-signal"] = settings.global["rampant--safeBuildings-railSignals"].value
    Universe.safeEntities["rail-chain-signal"] = settings.global["rampant--safeBuildings-railChainSignals"].value
    Universe.safeEntities["train-stop"] = settings.global["rampant--safeBuildings-trainStops"].value
    Universe.safeEntities["lamp"] = settings.global["rampant--safeBuildings-lamps"].value

    Universe.safeEntities["big-electric-pole"] = settings.global["rampant--safeBuildings-bigElectricPole"].value

    Universe.safeEntities["big-electric-pole"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["big-electric-pole-2"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["big-electric-pole-3"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["big-electric-pole-4"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["lighted-big-electric-pole-4"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["lighted-big-electric-pole-3"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["lighted-big-electric-pole-2"] = Universe.safeEntities["big-electric-pole"]
    Universe.safeEntities["lighted-big-electric-pole"] = Universe.safeEntities["big-electric-pole"]

    Universe["temperamentRateModifier"] = settings.global["rampant--temperamentRateModifier"].value
    Universe["baseDistanceModifier"] = settings.global["rampant--baseDistanceModifier"].value
    Universe["printBaseAdaptation"] = settings.global["rampant--printBaseAdaptation"].value
    Universe["adaptationModifier"] = settings.global["rampant--adaptationModifier"].value

    Universe["raidAIToggle"] = settings.global["rampant--raidAIToggle"].value
    Universe["siegeAIToggle"] = settings.global["rampant--siegeAIToggle"].value
    Universe["attackPlayerThreshold"] = settings.global["rampant--attackPlayerThreshold"].value
    Universe["attackUsePlayer"] = settings.global["rampant--attackWaveGenerationUsePlayerProximity"].value

    Universe["attackWaveMaxSize"] = settings.global["rampant--attackWaveMaxSize"].value
    Universe["aiNocturnalMode"] = settings.global["rampant--permanentNocturnal"].value
    Universe["aiPointsScaler"] = settings.global["rampant--aiPointsScaler"].value

    Universe["aiPointsPrintGainsToChat"] = settings.global["rampant--aiPointsPrintGainsToChat"].value
    Universe["aiPointsPrintSpendingToChat"] = settings.global["rampant--aiPointsPrintSpendingToChat"].value
    Universe["printBaseUpgrades"] = settings.global["rampant--printBaseUpgrades"].value
    Universe["PRINT_BASE_SETTLING"] = settings.global["rampant--printBaseSettling"].value

    Universe["enabledMigration"] = Universe.expansion and settings.global["rampant--enableMigration"].value
    Universe["peacefulAIToggle"] = settings.global["rampant--peacefulAIToggle"].value
    Universe["printAIStateChanges"] = settings.global["rampant--printAIStateChanges"].value
    Universe["debugTemperament"] = settings.global["rampant--debugTemperament"].value
    Universe["legacyChunkScanning"] = settings.global["rampant--legacyChunkScanning"].value

    Universe["enabledPurpleSettlerCloud"] = settings.global["rampant--enabledPurpleSettlerCloud"].value

    Universe["AI_MAX_SQUAD_COUNT"] = settings.global["rampant--maxNumberOfSquads"].value
    Universe["AI_MAX_BUILDER_COUNT"] = settings.global["rampant--maxNumberOfBuilders"].value
    Universe["AI_MAX_VANILLA_SQUAD_COUNT"] = Universe["AI_MAX_SQUAD_COUNT"] * 0.65
    Universe["AI_MAX_VANILLA_BUILDER_COUNT"] = Universe["AI_MAX_BUILDER_COUNT"] * 0.65
    Universe["MAX_BASE_MUTATIONS"] = settings.global["rampant--max-base-mutations"].value

    Universe["MAX_BASE_ALIGNMENT_HISTORY"] = settings.global["rampant--maxBaseAlignmentHistory"].value

    Universe["initialPeaceTime"] =
        (settings.global["rampant--initialPeaceTime"].value * TICKS_A_MINUTE) + Universe.modAddedTick

    Universe["printAwakenMessage"] = settings.global["rampant--printAwakenMessage"].value

    Universe["minimumAdaptationEvolution"] = settings.global["rampant--minimumAdaptationEvolution"].value

    return true
end

local function onConfigChanged()
    Upgrade.init(Universe)
    game.print("Rampant - Version 3.2.0")
    Upgrade.addUniverseProperties()
    initializeLibraries()
    Upgrade.attempt()

    onModSettingsChange({setting="rampant--"})

    Universe["NEW_ENEMIES"] = settings.startup["rampant--newEnemies"].value

    -- not a completed implementation needs if checks to use all forces
    -- both in the data stage, commands, and if then logic
    local enemyForces = {
        ["enemy"]=true
    }

    local npcForces = {
        ["neutral"]=true
    }

    if game.active_mods["AbandonedRuins"] then
        npcForces["AbandonedRuins:enemy"] = true
    end

    Upgrade.setCommandForces(npcForces, enemyForces)

    for _,surface in pairs(game.surfaces) do
        if not Universe.maps[surface.index] then
            prepMap(surface)
        end
    end

    if not Universe.ranIncompatibleMessage and Universe.newEnemies and
        (game.active_mods["bobenemies"] or game.active_mods["Natural_Evolution_Enemies"]) then
        Universe.ranIncompatibleMessage = true
        game.print({"description.rampant-bobs-nee-newEnemies"})
    end
end

local function onEnemyBaseBuild(entity, tick)
    local map = Universe.maps[entity.surface.index]
    if not map then
        return
    end
    local chunk = getChunkByPosition(map, entity.position)
    if (chunk ~= -1) then
        local base = findNearbyBase(chunk)
        if not base then
            base = createBase(map,
                              chunk,
                              tick)
        end

        if Universe.NEW_ENEMIES then
            if VANILLA_ENTITY_TYPE_LOOKUP[entity.name] then
                queueUpgrade(entity,
                             base,
                             nil,
                             true)
            else
                registerEnemyBaseStructure(entity, base, tick, true)
            end
        else
            registerEnemyBaseStructure(entity, base, tick)
        end
    else
        local x,y = positionToChunkXY(entity.position)
        onChunkGenerated({
                surface = entity.surface,
                tick = tick,
                area = {
                    left_top = {
                        x = x,
                        y = y
                    }
                }
        })
    end
end

local function onBuild(event)
    local entity = event.created_entity or event.entity
    if entity.valid then
        local entityForceName = entity.force.name
        if entityForceName == "enemy" then
            if BUILDING_HIVE_TYPE_LOOKUP[entity.name] then
                onEnemyBaseBuild(entity, event.tick)
            end
        else
            local map = Universe.maps[entity.surface.index]
            if not map then
                return
            end
            if entity.type == "resource" then
                registerResource(entity, map)
            else
                accountPlayerEntity(entity, map, true)
                if Universe.safeEntities[entity.type] or Universe.safeEntities[entity.name] then
                    entity.destructible = false
                end
            end
        end
    end
end

local function onMine(event)
    local entity = event.entity
    if entity.valid then
        local map = Universe.maps[entity.surface.index]
        if not map then
            return
        end
        accountPlayerEntity(entity, map, false)
    end
end

local function onDeath(event)
    local entity = event.entity
    if not entity.valid then
        return
    end
    local surface = entity.surface
    local map = Universe.maps[surface.index]
    if not map then
        return
    end
    local entityForceName = entity.force.name
    if (entityForceName == "neutral") then
        if (entity.name == "cliff") then
            entityForPassScan(map, entity)
        end
        return
    end
    local cause = event.cause
    local causedByEnemyForce = event.force and (event.force.name == "enemy")
    local tick = event.tick
    local entityType = entity.type
    local entityPosition = entity.position
    local damageTypeName = event.damage_type and event.damage_type.name
    local chunk = getChunkByPosition(map, entityPosition)
    local base

    if entityForceName == "enemy" then
        if entityType ~= "unit" then
            if getDrainPylonPair(map, entity.unit_number) then
                removeDrainPylons(map, entity.unit_number)
            else
                unregisterEnemyBaseStructure(map, entity, damageTypeName)
            end
        else
            local group = entity.unit_group
            if group then
                local squad = Universe.groupNumberToSquad[group.group_number]
                if damageTypeName and squad then
                    base = squad.base
                end
            end
        end

        if (chunk ~= -1) then
            if not base then
                base = findNearbyBase(chunk)
            end

            local artilleryBlast = (cause and ((cause.type == "artillery-wagon") or (cause.type == "artillery-turret")))
            if (entityType == "unit") and not ENTITY_SKIP_COUNT_LOOKUP[entity.name] then
                deathScent(chunk)
                if base then
                    base.lostEnemyUnits = base.lostEnemyUnits + 1
                    if damageTypeName then
                        base.damagedBy[damageTypeName] = (base.damagedBy[damageTypeName] or 0) + 0.01
                        base.deathEvents = base.deathEvents + 1
                    end
                    if base.lostEnemyUnits % 20 == 0 then
                        modifyBaseUnitPoints(base, -(20*UNIT_DEATH_POINT_COST), "20 Units Lost")
                    end
                    if (Universe.random() < Universe.rallyThreshold) and not surface.peaceful_mode then
                        rallyUnits(chunk, tick)
                    end
                    if artilleryBlast then
                        base.artilleryBlasts = base.artilleryBlasts + 1
                    end
                end

                if (getCombinedDeathGeneratorRating(chunk) < Universe.retreatThreshold) and cause and cause.valid then
                    retreatUnits(chunk,
                                 cause,
                                 map,
                                 tick,
                                 (artilleryBlast and RETREAT_SPAWNER_GRAB_RADIUS) or RETREAT_GRAB_RADIUS)
                end
            elseif BUILDING_HIVE_TYPE_LOOKUP[entity.name] or
                (entityType == "unit-spawner") or
                (entityType == "turret")
            then
                deathScent(chunk, true)
                if base then
                    if (entityType == "unit-spawner") then
                        modifyBaseUnitPoints(base, RECOVER_NEST_COST, "Nest Lost")
                    elseif (entityType == "turret") then
                        modifyBaseUnitPoints(base, RECOVER_WORM_COST, "Worm Lost")
                    end
                    rallyUnits(chunk, tick)
                    if artilleryBlast then
                        base.artilleryBlasts = base.artilleryBlasts + 1
                    end
                end

                if cause and cause.valid then
                    retreatUnits(chunk,
                                 cause,
                                 map,
                                 tick,
                                 RETREAT_SPAWNER_GRAB_RADIUS)
                end
            end
        end
    else
        local creditNatives = false
        if causedByEnemyForce then
            creditNatives = true
            local drained = false
            if chunk ~= -1 then
                victoryScent(chunk, entityType)
                drained = (entityType == "electric-turret") and isDrained(chunk, tick)
                if cause and cause.type == "unit" then
                    local group = cause.unit_group
                    if group and group.valid then
                        local squad = Universe.groupNumberToSquad[group.group_number]
                        if squad then
                            base = squad.base
                        end
                    end
                end
                if not base then
                    base = findNearbyBase(chunk)
                end
            end

            if cause or drained then
                createDrainPylon(map, cause, entity, entityType)
            end
        end
        if creditNatives and (Universe.safeEntities[entityType] or Universe.safeEntities[entity.name])
        then
            makeImmortalEntity(surface, entity)
        else
            accountPlayerEntity(entity, map, false, base)
        end
    end
end

local function processSurfaceTile(map, position, chunks, tick)
    local chunk = getChunkByPosition(map, position)

    if (chunk ~= -1) then
        Universe.chunkToPassScan[chunk.id] = chunk
    else
        local x,y = positionToChunkXY(position)
        local addMe = true
        if chunks then
            for ci=1,#chunks do
                local c = chunks[ci]
                if (c.x == x) and (c.y == y) then
                    addMe = false
                    break
                end
            end
        end
        if addMe then
            local chunkXY = {x=x,y=y}
            if chunks then
                chunks[#chunks+1] = chunkXY
            end
            onChunkGenerated({area = { left_top = chunkXY },
                              tick = tick,
                              surface = map.surface})
        end
    end
end

local function onSurfaceTileChange(event)
    local surfaceIndex = event.surface_index or (event.robot and event.robot.surface and event.robot.surface.index)
    local map = Universe.maps[surfaceIndex]
    if not map then
        return
    end
    local chunks = {}
    local tiles = event.tiles
    if event.tile then
        if ((event.tile.name == "landfill") or sFind(event.tile.name, "water")) then
            for i=1,#tiles do
                processSurfaceTile(map, tiles[i].position, chunks, event.tick)
            end
        end
    else
        for i=1,#tiles do
            local tile = tiles[i]
            if (tile.name == "landfill") or sFind(tile.name, "water") then
                processSurfaceTile(map, tiles[i].position, chunks, event.tick)
            end
        end
    end
end

local function onResourceDepleted(event)
    local entity = event.entity
    if entity.valid then
        local map = Universe.maps[entity.surface.index]
        if not map then
            return
        end
        unregisterResource(entity, map)
    end
end

local function onRobotCliff(event)
    local entity = event.robot
    if entity.valid then
        local map = Universe.maps[entity.surface.index]
        if not map then
            return
        end
        if (event.item.name == "cliff-explosives") then
            entityForPassScan(map, event.cliff)
        end
    end
end

local function onUsedCapsule(event)
    local surface = game.players[event.player_index].surface
    local map = Universe.maps[surface.index]
    if not map then
        return
    end
    if (event.item.name == "cliff-explosives") then
        setPointAreaInQuery(Universe.oucCliffQuery, event.position, 0.75)
        local cliffs = surface.find_entities_filtered(Universe.oucCliffQuery)
        for i=1,#cliffs do
            entityForPassScan(map, cliffs[i])
        end
    end
end

local function onRocketLaunch(event)
    local entity = event.rocket_silo or event.rocket
    if entity.valid then
        local map = Universe.maps[entity.surface.index]
        if not map then
            return
        end
        local chunk = getChunkByPosition(map, entity.position)
        if (chunk ~= -1) then
            local base = findNearbyBase(chunk)
            if base then
                base.rocketLaunched = base.rocketLaunched + 1
                modifyBaseUnitPoints(base, 5000, "Rocket Launch")
            end
        end
    end
end

local function onTriggerEntityCreated(event)
    if (event.effect_id == "rampant-drain-trigger") then
        local entity = event.target_entity
        if (entity and entity.valid) then
            local map = Universe.maps[event.surface_index]
            if not map then
                return
            end
            local chunk = getChunkByPosition(map, entity.position)
            if (chunk ~= -1) then
                setDrainedTick(chunk, event.tick)
            end
        elseif (event.target_position) then
            local map = Universe.maps[event.surface_index]
            if not map then
                return
            end
            local chunk = getChunkByPosition(map, event.target_position)
            if (chunk ~= -1) then
                setDrainedTick(chunk, event.tick)
            end
        end
    elseif (event.effect_id == "deathLandfillParticle--rampant") then
        local map = Universe.maps[event.surface_index]
        if not map then
            return
        end
        processSurfaceTile(
            map,
            event.target_position,
            nil,
            event.tick
        )
    end
end

local function onInit()
    global.universe = {}

    Universe = global.universe

    hookEvents()
    onConfigChanged()
end

local function onUnitGroupCreated(event)
    local group = event.group
    if (group.force.name ~= "enemy") then
        return
    end
    local surface = group.surface
    local squad
    if group.is_script_driven then
        return
    end
    if not Universe.awake then
        group.destroy()
        return
    end
    local map = Universe.maps[surface.index]
    if not map then
        return
    end
    activateMap(map)
    local position = group.position
    local chunk = getChunkByPosition(map, position)
    local base
    if (chunk == -1) then
        base = findNearbyBaseByPosition(map, position.x, position.y)
    else
        base = findNearbyBase(chunk)
    end
    if not base then
        group.destroy()
        return
    end
    if not Universe.aiNocturnalMode then
        local settler = canMigrate(base) and
            (Universe.builderCount < Universe.AI_MAX_VANILLA_BUILDER_COUNT) and
            (Universe.random() < 0.25)

        if not settler and ( (Universe.squadCount >= Universe.AI_MAX_VANILLA_SQUAD_COUNT) or (chunk == -1) ) then
            group.destroy()
            return
        end

        if not settler and (chunk[BASE_PHEROMONE] < 0.0001) and (chunk[PLAYER_PHEROMONE] < 0.0001) then
            group.destroy()
            return
        end

        squad = createSquad(nil, map, group, settler, base)
        Universe.groupNumberToSquad[group.group_number] = squad

        if settler then
            Universe.builderCount = Universe.builderCount + 1
        else
            Universe.squadCount = Universe.squadCount + 1
        end
    else
        if not (surface.darkness > 0.65) then
            group.destroy()
            return
        end

        local settler = canMigrate(base) and
            (Universe.builderCount < Universe.AI_MAX_VANILLA_BUILDER_COUNT) and
            (Universe.random() < 0.25)

        if not settler and ( (Universe.squadCount >= Universe.AI_MAX_VANILLA_SQUAD_COUNT) or (chunk == -1) ) then
            group.destroy()
            return
        end

        if not settler and chunk[BASE_PHEROMONE] < 0.0001 and chunk[PLAYER_PHEROMONE] < 0.0001 then
            group.destroy()
            return
        end

        squad = createSquad(nil, map, group, settler, base)
        Universe.groupNumberToSquad[group.group_number] = squad

        if settler then
            Universe.builderCount = Universe.builderCount + 1
        else
            Universe.squadCount = Universe.squadCount + 1
        end
    end
end

local function onGroupFinishedGathering(event)
    local group = event.group
    if not group.valid or (group.force.name ~= "enemy") then
        return
    end
    if not Universe.awake then
        group.destroy()
    end
    local map = Universe.maps[group.surface.index]
    if not map then
        return
    end
    activateMap(map)
    local squad = Universe.groupNumberToSquad[group.group_number]
    if squad then
        if squad.settler then
            if (Universe.builderCount <= Universe.AI_MAX_BUILDER_COUNT) then
                squadDispatch(map, squad, event.tick)
            else
                group.destroy()
            end
        else
            local chunk = getChunkByPosition(map, squad.group.position)
            if (chunk ~= -1) and (chunk[BASE_PHEROMONE] < 0.0001) and (chunk[PLAYER_PHEROMONE] < 0.0001) then
                group.destroy()
                return
            end

            if (Universe.squadCount <= Universe.AI_MAX_SQUAD_COUNT) then
                squadDispatch(map, squad, event.tick)
            else
                group.destroy()
            end
        end
    else
        local position = group.position
        local chunk = getChunkByPosition(map, position)
        local base
        if chunk == -1 then
            base = findNearbyBaseByPosition(map, position.x, position.y)
        else
            base = findNearbyBase(chunk)
        end
        if not base then
            group.destroy()
            return
        end
        local settler = canMigrate(base) and
            (Universe.builderCount < Universe.AI_MAX_VANILLA_BUILDER_COUNT) and
            (Universe.random() < 0.25)

        if not settler and (Universe.squadCount >= Universe.AI_MAX_VANILLA_SQUAD_COUNT) then
            group.destroy()
            return
        end

        if not settler and (chunk[BASE_PHEROMONE] < 0.0001) and (chunk[PLAYER_PHEROMONE] < 0.0001) then
            group.destroy()
            return
        end

        squad = createSquad(nil, map, group, settler, base)
        Universe.groupNumberToSquad[group.group_number] = squad
        if settler then
            Universe.builderCount = Universe.builderCount + 1
        else
            Universe.squadCount = Universe.squadCount + 1
        end
        squadDispatch(map, squad, event.tick)
    end
end

local function onForceCreated(event)
    if Universe.playerForces then
        Universe.playerForces[#Universe.playerForces+1] = event.force.name
    end
end

local function onForceMerged(event)
    for i=#Universe.playerForces,1,-1 do
        if (Universe.playerForces[i] == event.source_name) then
            tRemove(Universe.playerForces, i)
            break
        end
    end
end

local function onSurfaceCreated(event)
    prepMap(game.surfaces[event.surface_index])
end

local function onSurfaceDeleted(event)
    local surfaceIndex = event.surface_index
    if (Universe.mapIterator == surfaceIndex) then
        Universe.mapIterator, Universe.currentMap = next(
            Universe.activeMaps,
            Universe.mapIterator
        )
    end
    if (Universe.processMapAIIterator == surfaceIndex) then
        Universe.processMapAIIterator = nil
    end
    Universe.maps[surfaceIndex] = nil
end

local function onSurfaceCleared(event)
    onSurfaceDeleted(event)
    onSurfaceCreated(event)
end

local function onChunkDeleted(event)
    local surfaceIndex = event.surface_index
    local map = Universe.maps[surfaceIndex]
    if map then
        local positions = event.positions
        for i=1,#positions do
            local position = positions[i]
            local x = position.x * 32
            local y = position.y * 32
            local chunk = getChunkByXY(map, x, y)
            if chunk ~= -1 then
                removeChunkFromMap(map, chunk)
            end
        end
    end
end

local function onBuilderArrived(event)
    local builder = event.group
    local usingUnit = false
    if not (builder and builder.valid) then
        builder = event.unit
        if not (builder and builder.valid and builder.force.name == "enemy") then
            return
        end
        usingUnit = true
    elseif (builder.force.name ~= "enemy") then
        return
    end

    local map = Universe.maps[builder.surface.index]
    if not map then
        return
    end
    activateMap(map)
    if not usingUnit then
        local squad = Universe.groupNumberToSquad[builder.group_number]
        squad.commandTick = event.tick + COMMAND_TIMEOUT * 10
    end
    if Universe.PRINT_BASE_SETTLING then
        game.print(map.surface.name.." Settled: [gps=" .. builder.position.x .. "," .. builder.position.y .."]")
    end
    if Universe.enabledPurpleSettlerCloud then
        Universe.eventId = Universe.eventId + 1
        Universe.settlePurpleCloud[Universe.eventId] = {
            map = map,
            group = builder,
            tick = event.tick + SETTLE_CLOUD_WARMUP
        }
    end
end

-- hooks

script.on_event(defines.events.on_tick,
                function ()
                    local gameRef = game
                    local tick = gameRef.tick
                    local range = (Universe.legacyChunkScanning and 4) or 3
                    local pick = tick % range
                    -- local profiler = game.create_profiler()

                    local map = nextMap()

                    if (pick == 0) then
                        processPendingChunks(tick)
                        if map then
                            recycleBases()
                        end
                        cleanUpMapTables(tick)
                        planning(gameRef.forces.enemy.evolution_factor)
                        processHives(tick)
                    elseif (pick == 1) then
                        processPlayers(gameRef.connected_players, tick)
                    elseif (pick == 2) then
                        processPendingUpgrades(tick)
                        processVengence()
                        disperseVictoryScent()
                        processAttackWaves()
                        processClouds(tick)
                        processScanChunks()
                    elseif (pick == 3) then
                        if map then
                            scanPlayerMap(map)
                            scanResourceMap(map)
                            scanEnemyMap(map, tick)
                        end
                    end

                    if map then
                        processMap(map, tick)
                    end

                    -- game.print({"", "--dispatch3 ", profiler, " , ", pick," , ",math.random()})

                    processBaseAIs(tick)
                    processNests(tick)
                    cleanSquads(tick)

                    -- game.print({"", "--dispatch4 ", profiler, " , ", pick," , ",math.random()})
end)

script.on_event(defines.events.on_chunk_deleted, onChunkDeleted)
script.on_event(defines.events.on_surface_deleted, onSurfaceDeleted)
script.on_event(defines.events.on_surface_cleared, onSurfaceCleared)
script.on_event(defines.events.on_surface_created, onSurfaceCreated)

script.on_init(onInit)
script.on_load(onLoad)
script.on_event(defines.events.on_runtime_mod_setting_changed, onModSettingsChange)
script.on_configuration_changed(onConfigChanged)

script.on_event(defines.events.on_resource_depleted, onResourceDepleted)
script.on_event({defines.events.on_player_built_tile,
                 defines.events.on_robot_built_tile,
                 defines.events.script_raised_set_tiles}, onSurfaceTileChange)

script.on_event(defines.events.on_player_used_capsule, onUsedCapsule)

script.on_event(defines.events.on_script_trigger_effect, onTriggerEntityCreated)

script.on_event(defines.events.on_pre_robot_exploded_cliff, onRobotCliff)

script.on_event({defines.events.on_player_mined_entity,
                 defines.events.on_robot_mined_entity}, onMine)

script.on_event(
    {
        defines.events.on_built_entity,
        defines.events.on_robot_built_entity,
        defines.events.script_raised_built,
        defines.events.script_raised_revive,
        defines.events.on_biter_base_built
    },
    onBuild)

script.on_event(defines.events.on_rocket_launched, onRocketLaunch)
script.on_event({defines.events.on_entity_died,
                 defines.events.script_raised_destroy}, onDeath)
script.on_event(defines.events.on_chunk_generated, onChunkGenerated)
script.on_event(defines.events.on_unit_group_created, onUnitGroupCreated)
script.on_event(defines.events.on_force_created, onForceCreated)
script.on_event(defines.events.on_forces_merged, onForceMerged)
script.on_event(defines.events.on_unit_group_finished_gathering, onGroupFinishedGathering)

script.on_event(defines.events.on_build_base_arrived, onBuilderArrived)

-- testing

remote.add_interface("rampantTests",
                     {
                         dumpEnvironment = tests.dumpEnvironment,
                         -- fillableDirtTest = tests.fillableDirtTest,
                         -- tunnelTest = tests.tunnelTest,
                         reveal = tests.reveal,
                         -- showMovementGrid = tests.showMovementGrid,
                         -- showBaseGrid = tests.showBaseGrid,
                         -- colorResourcePoints = tests.colorResourcePoints,
                         exportAiState = tests.exportAiState(nil),
                         scanEnemy = tests.scanEnemy,
                         chunkCount = tests.chunkCount
                     }
)

local function removeNewEnemies()
    game.print({"description.rampant--removeNewEnemies"})
    Universe.NEW_ENEMIES = false
    for _,map in pairs(Universe.maps) do
        local surface = map.surface
        if surface.valid then
            local entities = surface.find_entities_filtered({
                    force=Universe.enemyForces,
                    type={
                        "turret",
                        "unit-spawner"
                    }
            })
            for entityIndex = 1,#entities do
                local entity = entities[entityIndex]
                if entity.valid and not VANILLA_ENTITY_TYPE_LOOKUP[entity.name] then
                    local position = entity.position
                    local newEntityName
                    local entityType = entity.type
                    unregisterEnemyBaseStructure(map, entity, nil, true)
                    entity.destroy()
                    if entityType == "unit-spawner" then
                        if mRandom() < 0.5 then
                            newEntityName = "biter-spawner"
                        else
                            newEntityName = "spitter-spawner"
                        end
                    elseif entityType == "turret" then
                        if Universe.evolutionLevel >= 0.9 then
                            newEntityName = "behemoth-worm-turret"
                        elseif Universe.evolutionLevel >= 0.5 then
                            newEntityName = "big-worm-turret"
                        elseif Universe.evolutionLevel >= 0.3 then
                            newEntityName = "medium-worm-turret"
                        else
                            newEntityName = "small-worm-turret"
                        end
                    end
                    local newEntity = surface.create_entity({
                            name=newEntityName,
                            position=position
                    })
                    local chunk = getChunkByPosition(map, position)
                    if chunk ~= -1 then
                        local tick = game.tick
                        local base = findNearbyBase(chunk)
                        if not base then
                            base = createBase(map,
                                              chunk,
                                              tick)
                        end
                        registerEnemyBaseStructure(newEntity, base, tick)
                    end
                end
            end
        end
    end
end

local function removeFaction(cmd)
    local factionNames = cmd.parameter
    if not factionNames then
        game.print({"description.rampant--removeFactionNames"})
        return
    end
    factionNames = split(factionNames)
    for _,factionName in pairs(factionNames) do
        local found = false
        if (factionName ~= "neutral") then
            for _,faction in pairs(FACTION_SET) do
                if (faction.type == factionName) then
                    found = true
                    break
                end
            end
        end
        if not found then
            game.print({"description.rampant--removeFactionNames"})
            return
        end
    end
    local localizedFactionNames = ""
    for _,name in pairs(factionNames) do
        if localizedFactionNames == "" then
            localizedFactionNames = name
        else
            localizedFactionNames = localizedFactionNames .. "," .. name
        end
    end
    game.print({"description.rampant--removeFaction", localizedFactionNames})

    for _,base in pairs(Universe.bases) do
        if isMember(base.alignment[1], factionNames) then
            base.alignment = findBaseInitialAlignment(Universe.evolutionLevel, factionNames)
        elseif isMember(base.alignment[2], factionNames) then
            base.alignment = findBaseInitialAlignment(Universe.evolutionLevel, factionNames)
        end
    end

    for _,map in pairs(Universe.maps) do
        local surface = map.surface
        if surface.valid then
            local entities = surface.find_entities_filtered({
                    force=Universe.enemyForces,
                    type={
                        "turret",
                        "unit-spawner"
                    }
            })
            for entityIndex = 1,#entities do
                local entity = entities[entityIndex]
                if entity.valid and isMember(ENEMY_ALIGNMENT_LOOKUP[entity.name], factionNames) then
                    local position = entity.position
                    local newEntityName
                    local entityType = entity.type
                    unregisterEnemyBaseStructure(map, entity, nil, true)
                    entity.destroy()
                    if entityType == "unit-spawner" then
                        if mRandom() < 0.5 then
                            newEntityName = "biter-spawner"
                        else
                            newEntityName = "spitter-spawner"
                        end
                    elseif entityType == "turret" then
                        if Universe.evolutionLevel >= 0.9 then
                            newEntityName = "behemoth-worm-turret"
                        elseif Universe.evolutionLevel >= 0.5 then
                            newEntityName = "big-worm-turret"
                        elseif Universe.evolutionLevel >= 0.3 then
                            newEntityName = "medium-worm-turret"
                        else
                            newEntityName = "small-worm-turret"
                        end
                    end
                    local newEntity = surface.create_entity({
                            name=newEntityName,
                            position=position
                    })
                    local chunk = getChunkByPosition(map, position)
                    if chunk ~= -1 then
                        local tick = game.tick
                        local base = findNearbyBase(chunk)
                        if not base then
                            base = createBase(map,
                                              chunk,
                                              tick)
                        end
                        registerEnemyBaseStructure(newEntity, base, tick)
                    end
                end
            end
        end
    end
end

remote.add_interface("Rampant", {
                         addExcludeSurface = addExcludeSurface,
                         removeExcludeSurface = removeExcludeSurface
})

commands.add_command('rampantRemoveNewEnemies', "", removeNewEnemies)
commands.add_command('rampantRemoveFaction', "", removeFaction)

local function rampantSetAIState(event)
    if event.parameter then
        local target
        local baseId
        local i = 0

        for m in string.gmatch(event.parameter, "%d+") do
            if i == 0 then
                i = i + 1
                target = tonumber(m)
            else
                baseId = tonumber(m)
            end
        end

        if not target or not Constants.STATE_ENGLISH[target] then
            game.print(target .. " is not a valid state. /rampantSetAIState <stateId> <baseId>")
        else
            if not baseId then
                game.print("Invalid baseId. /rampantSetAIState <stateId> <baseId>")
                return
            end
            local base = Universe.bases[baseId]
            if not base then
                game.print(baseId .. " is not a valid base. /rampantSetAIState <stateId> <baseId>")
                return
            end
            local previousState = base.stateAI
            base.stateAI = target
            local surface = base.map.surface
            if not surface.valid then
                game.print("Base is invalid because surface is invalid")
                return
            end
            game.print("id:" .. baseId .. " on surface:" .. surface.name
                       .. " was in " .. Constants.STATE_ENGLISH[previousState]
                       .. " is now in " .. Constants.STATE_ENGLISH[base.stateAI])
        end
    else
        game.print("Missing parameters: /rampantSetAIState <stateId> <baseId>")
    end
end

commands.add_command('rampantSetAIState', "", rampantSetAIState)