Source: danubius_triggers.js

// Ships respawn every few minutes, attack the closest warships, then patrol the sea.
// To prevent unlimited spawning of ships, no more than the amount of ships intended at a given time are spawned.

// Ships are filled or refilled with new units.
// The number of ships, number of units per ship, as well as ratio of siege engines, champion and heroes
// increases with time, while keeping an individual and randomized composition for each ship.
// Each hero exists at most once per map.

// Every few minutes, equal amount of ships unload units at the sides of the river unless
// one side of the river was wiped from players.
// Siege engines attack defensive structures, units attack units then patrol that side of the river.

const showDebugLog = false;

const danubiusAttackerTemplates = deepfreeze({
	"ships": TriggerHelper.GetTemplateNamesByClasses("Warship", "gaul", undefined, undefined, true),
	"siege": TriggerHelper.GetTemplateNamesByClasses("Siege", "gaul", undefined, undefined, true),
	"females": TriggerHelper.GetTemplateNamesByClasses("FemaleCitizen", "gaul", undefined, undefined, true),
	"healers": TriggerHelper.GetTemplateNamesByClasses("Healer", "gaul", undefined, undefined, true),
	"champions": TriggerHelper.GetTemplateNamesByClasses("Champion", "gaul", undefined, undefined, true),
	"champion_infantry": TriggerHelper.GetTemplateNamesByClasses("Champion+Infantry", "gaul", undefined, undefined, true),
	"citizen_soldiers": TriggerHelper.GetTemplateNamesByClasses("CitizenSoldier", "gaul", undefined, "Basic", true),
	"heroes": [
		// Excludes the Vercingetorix variant
		"units/gaul/hero_viridomarus",
		"units/gaul/hero_vercingetorix",
		"units/gaul/hero_brennus"
	]
});

var ccDefenders = [
	{ "count": 8, "templates": danubiusAttackerTemplates.citizen_soldiers },
	{ "count": 13, "templates": danubiusAttackerTemplates.champions },
	{ "count": 4, "templates": danubiusAttackerTemplates.healers },
	{ "count": 5, "templates": danubiusAttackerTemplates.females },
	{ "count": 10, "templates": ["gaia/fauna_sheep"] }
];

var gallicBuildingGarrison = [
	{
		"buildingClasses": ["House"],
		"unitTemplates": danubiusAttackerTemplates.females.concat(danubiusAttackerTemplates.healers)
	},
	{
		"buildingClasses": ["CivCentre", "Temple"],
		"unitTemplates": danubiusAttackerTemplates.champions,
	},
	{
		"buildingClasses": ["Tower", "Outpost"],
		"unitTemplates": danubiusAttackerTemplates.champion_infantry
	}
];

/**
 * Notice if gaia becomes too strong, players will just turtle and try to outlast the players on the other side.
 * However we want interaction and fights between the teams.
 * This can be accomplished by not wiping out players buildings entirely.
 */

/**
 * Time in minutes between two consecutive waves spawned from the gaia civic centers, if they still exist.
 */
var ccAttackerInterval = t => randFloat(6, 8);

/**
 * Number of attackers spawned at a civic center at t minutes ingame time.
 */
var ccAttackerCount = t => Math.min(20, Math.max(0, Math.round(t * 1.5)));

/**
 * Time between two consecutive waves.
 */
var shipRespawnTime = () => randFloat(8, 10);

/**
 * Limit of ships on the map when spawning them.
 * Have at least two ships, so that both sides will be visited.
 */
var shipCount = (t, numPlayers) => Math.max(2, Math.round(Math.min(1.5, t / 10) * numPlayers));

/**
 * Order all ships to ungarrison at the shoreline.
 */
var shipUngarrisonInterval = () => randFloat(5, 7);

/**
 * Time between refillings of all ships with new soldiers.
 */
var shipFillInterval = () => randFloat(4, 5);

/**
 * Total count of gaia attackers per shipload.
 */
var attackersPerShip = t => Math.min(30, Math.round(t * 2));

/**
 * Likelihood of adding a non-existing hero at t minutes.
 */
var heroProbability = t => Math.max(0, Math.min(1, (t - 25) / 60));

/**
 * Percent of healers to add per shipload after potentially adding a hero and siege engines.
 */
var healerRatio = t => randFloat(0, 0.1);

/**
 * Number of siege engines to add per shipload.
 */
var siegeCount = t => 1 + Math.min(2, Math.floor(t / 30));

/**
 * Percent of champions to be added after spawning heroes, healers and siege engines.
 * Rest will be citizen soldiers.
 */
var championRatio = t => Math.min(1, Math.max(0, (t - 25) / 75));

/**
 * Number of trigger points to patrol when not having enemies to attack.
 */
var patrolCount = 5;

/**
 * Which units ships should focus when attacking and patrolling.
 */
var shipTargetClass = "Warship";

/**
 * Which entities siege engines should focus when attacking and patrolling.
 */
var siegeTargetClass = "Defensive";

/**
 * Which entities units should focus when attacking and patrolling.
 */
var unitTargetClass = "Unit+!Ship";

/**
 * Ungarrison ships when being in this range of the target.
 */
var shipUngarrisonDistance = 50;

/**
 * Currently formations are not working properly and enemies in vision range are often ignored.
 * So only have a small chance of using formations.
 */
var formationProbability = 0.2;

var unitFormations = [
	"special/formations/box",
	"special/formations/battle_line",
	"special/formations/line_closed",
	"special/formations/column_closed"
];

/**
 * Chance for the units at the meeting place to participate in the ritual.
 */
var ritualProbability = 0.75;

/**
 * Units celebrating at the meeting place will perform one of these animations
 * if idle and switch back when becoming idle again.
 */
var ritualAnimations = {
	"female": ["attack_slaughter"],
	"male": ["attack_capture", "promotion", "attack_slaughter"],
	"healer": ["attack_capture", "promotion", "heal"]
};

var triggerPointShipSpawn = "A";
var triggerPointShipPatrol = "B";
var triggerPointUngarrisonLeft = "C";
var triggerPointUngarrisonRight = "D";
var triggerPointLandPatrolLeft = "E";
var triggerPointLandPatrolRight = "F";
var triggerPointCCAttackerPatrolLeft = "G";
var triggerPointCCAttackerPatrolRight = "H";
var triggerPointRiverDirection = "I";

/**
 * Which playerID to use for the opposing gallic reinforcements.
 */
var gaulPlayer = 0;

Trigger.prototype.debugLog = function(txt)
{
	if (showDebugLog)
		print("DEBUG [" + Math.round(TriggerHelper.GetMinutes()) + "] " + txt + "\n");
};

Trigger.prototype.GarrisonAllGallicBuildings = function()
{
	this.debugLog("Garrisoning all gallic buildings");

	for (let buildingGarrison of gallicBuildingGarrison)
		for (let buildingClass of buildingGarrison.buildingClasses)
		{
			let unitCounts = TriggerHelper.SpawnAndGarrisonAtClasses(gaulPlayer, buildingClass, buildingGarrison.unitTemplates, 1);
			this.debugLog("Garrisoning at " + buildingClass + ": " + uneval(unitCounts));
		}
};

/**
 * Spawn units of the template at each gaia Civic Center and set them to defensive.
 */
Trigger.prototype.SpawnInitialCCDefenders = function()
{
	this.debugLog("To defend CCs, spawning " + uneval(ccDefenders));

	for (let ent of this.civicCenters)
		for (let ccDefender of ccDefenders)
			for (let spawnedEnt of TriggerHelper.SpawnUnits(ent, pickRandom(ccDefender.templates), ccDefender.count, gaulPlayer))
				TriggerHelper.SetUnitStance(spawnedEnt, "defensive");
};

Trigger.prototype.SpawnCCAttackers = function()
{
	let time = TriggerHelper.GetMinutes();

	let [spawnLeft, spawnRight] = this.GetActiveRiversides();

	for (let gaiaCC of this.civicCenters)
	{
		if (!TriggerHelper.IsInWorld(gaiaCC))
			continue;

		let isLeft = this.IsLeftRiverside(gaiaCC);
		if (isLeft && !spawnLeft || !isLeft && !spawnRight)
			continue;

		let templateCounts = TriggerHelper.BalancedTemplateComposition(this.GetAttackerComposition(time, false), ccAttackerCount(time));
		this.debugLog("Spawning civic center attackers at " + gaiaCC + ": " + uneval(templateCounts));

		let ccAttackers = [];

		for (let templateName in templateCounts)
		{
			let ents = TriggerHelper.SpawnUnits(gaiaCC, templateName, templateCounts[templateName], gaulPlayer);

			if (danubiusAttackerTemplates.heroes.indexOf(templateName) != -1 && ents[0])
				this.heroes.add(ents[0]);

			ccAttackers = ccAttackers.concat(ents);
		}

		let patrolPointRef = isLeft ?
			triggerPointCCAttackerPatrolLeft :
			triggerPointCCAttackerPatrolRight;

		this.AttackAndPatrol(ccAttackers, unitTargetClass, patrolPointRef, "CCAttackers", false);
	}

	if (this.civicCenters.size)
		this.DoAfterDelay(ccAttackerInterval() * 60 * 1000, "SpawnCCAttackers", {});
};

/**
 * Remember most Humans present at the beginning of the match (before spawning any unit) and
 * make them defensive.
 */
Trigger.prototype.StartCelticRitual = function()
{
	for (let ent of TriggerHelper.GetPlayerEntitiesByClass(gaulPlayer, "Human"))
	{
		if (randBool(ritualProbability))
			this.ritualEnts.add(ent);

		TriggerHelper.SetUnitStance(ent, "defensive");
	}

	this.DoRepeatedly(5 * 1000, "UpdateCelticRitual", {});
};

/**
 * Play one of the given animations for most participants if and only if they are idle.
 */
Trigger.prototype.UpdateCelticRitual = function()
{
	for (let ent of this.ritualEnts)
	{
		let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
		if (!cmpUnitAI || cmpUnitAI.GetCurrentState() != "INDIVIDUAL.IDLE")
			continue;

		let cmpIdentity = Engine.QueryInterface(ent, IID_Identity);
		if (!cmpIdentity)
			continue;

		let animations = ritualAnimations[
			cmpIdentity.HasClass("Healer") ? "healer" :
			cmpIdentity.HasClass("Female") ? "female" : "male"];

		let cmpVisual = Engine.QueryInterface(ent, IID_Visual);
		if (!cmpVisual)
			continue;

		if (animations.indexOf(cmpVisual.GetAnimationName()) == -1)
			cmpVisual.SelectAnimation(pickRandom(animations), false, 1, "");
	}
};

/**
 * Spawn ships with a unique attacker composition each until
 * the number of ships is reached that is supposed to exist at the given time.
 */
Trigger.prototype.SpawnShips = function()
{
	let time = TriggerHelper.GetMinutes();
	let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetActivePlayers().length;
	let shipSpawnCount = shipCount(time, numPlayers) - this.ships.size;

	this.debugLog("Spawning " + shipSpawnCount + " ships");

	while (this.ships.size < shipSpawnCount)
		this.ships.add(
			TriggerHelper.SpawnUnits(
				pickRandom(this.GetTriggerPoints(triggerPointShipSpawn)),
				pickRandom(danubiusAttackerTemplates.ships),
				1,
				gaulPlayer)[0]);

	for (let ship of this.ships)
		this.AttackAndPatrol([ship], shipTargetClass, triggerPointShipPatrol, "Ship", true);

	this.DoAfterDelay(shipRespawnTime(time) * 60 * 1000, "SpawnShips", {});

	let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
	cmpTimer.CancelTimer(this.fillShipsTimer);

	this.FillShips();
};

Trigger.prototype.GetAttackerComposition = function(time, siegeEngines)
{
	let champRatio = championRatio(time);
	return [
		{
			"templates": danubiusAttackerTemplates.heroes,
			"count": randBool(heroProbability(time)) ? 1 : 0,
			"unique_entities": Array.from(this.heroes)
		},
		{
			"templates": danubiusAttackerTemplates.siege,
			"count": siegeEngines ? siegeCount(time) : 0
		},
		{
			"templates": danubiusAttackerTemplates.healers,
			"frequency": healerRatio(time)
		},
		{
			"templates": danubiusAttackerTemplates.champions,
			"frequency": champRatio
		},
		{
			"templates": danubiusAttackerTemplates.citizen_soldiers,
			"frequency": 1 - champRatio
		}
	];
};

Trigger.prototype.FillShips = function()
{
	let time = TriggerHelper.GetMinutes();
	for (let ship of this.ships)
	{
		let cmpGarrisonHolder = Engine.QueryInterface(ship, IID_GarrisonHolder);
		if (!cmpGarrisonHolder)
			continue;

		let templateCounts = TriggerHelper.BalancedTemplateComposition(
			this.GetAttackerComposition(time, true),
			Math.max(0, attackersPerShip(time) - cmpGarrisonHolder.GetEntities().length));

		this.debugLog("Filling ship " + ship + " with " + uneval(templateCounts));

		for (let templateName in templateCounts)
		{
			let ents = TriggerHelper.SpawnGarrisonedUnits(ship, templateName, templateCounts[templateName], gaulPlayer);
			if (danubiusAttackerTemplates.heroes.indexOf(templateName) != -1 && ents[0])
				this.heroes.add(ents[0]);
		}
	}

	this.fillShipsTimer = this.DoAfterDelay(shipFillInterval() * 60 * 1000, "FillShips", {});
};

/**
 * Attack the closest enemy target around, then patrol the map.
 */
Trigger.prototype.AttackAndPatrol = function(entities, targetClass, triggerPointRef, debugName, attack)
{
	if (!entities.length)
		return;

	let healers = TriggerHelper.MatchEntitiesByClass(entities, "Healer").filter(TriggerHelper.IsInWorld);
	if (healers.length)
	{
		let healerTargets = TriggerHelper.MatchEntitiesByClass(entities, "Hero Champion");
		if (!healerTargets.length)
			healerTargets = TriggerHelper.MatchEntitiesByClass(entities, "Soldier");

		ProcessCommand(gaulPlayer, {
			"type": "guard",
			"entities": healers,
			"target": pickRandom(healerTargets),
			"queued": false
		});
	}

	let attackers = TriggerHelper.MatchEntitiesByClass(entities, "!Healer").filter(TriggerHelper.IsInWorld);
	if (!attackers.length)
		return;

	let isLeft = this.IsLeftRiverside(attackers[0]);
	let targets = TriggerHelper.MatchEntitiesByClass(TriggerHelper.GetAllPlayersEntities(), targetClass);
	let closestTarget;
	let minDistance = Infinity;

	for (let target of targets)
	{
		if (!TriggerHelper.IsInWorld(target) || this.IsLeftRiverside(target) != isLeft)
			continue;

		let targetDistance = PositionHelper.DistanceBetweenEntities(attackers[0], target);
		if (targetDistance < minDistance)
		{
			closestTarget = target;
			minDistance = targetDistance;
		}
	}

	this.debugLog(debugName + " " + uneval(attackers) + " attack " + uneval(closestTarget));

	if (attack && closestTarget)
		ProcessCommand(gaulPlayer, {
			"type": "attack",
			"entities": attackers,
			"target": closestTarget,
			"queued": true,
			"allowCapture": false
		});

	let patrolTargets = shuffleArray(this.GetTriggerPoints(triggerPointRef)).slice(0, patrolCount);
	this.debugLog(debugName + " " + uneval(attackers) + " patrol to " + uneval(patrolTargets));

	for (let patrolTarget of patrolTargets)
	{
		let targetPos = TriggerHelper.GetEntityPosition2D(patrolTarget);
		ProcessCommand(gaulPlayer, {
			"type": "patrol",
			"entities": attackers,
			"x": targetPos.x,
			"z": targetPos.y,
			"targetClasses": {
				"attack": targetClass
			},
			"queued": true,
			"allowCapture": false
		});
	}
};

/**
 * To avoid unloading unlimited amounts of units on empty riversides,
 * only add attackers to riversides where player buildings exist that are
 * actually targeted.
 */
Trigger.prototype.GetActiveRiversides = function()
{
	let left = false;
	let right = false;

	for (let ent of TriggerHelper.GetAllPlayersEntitiesByClass(siegeTargetClass))
	{
		if (this.IsLeftRiverside(ent))
			left = true;
		else
			right = true;

		if (left && right)
			break;
	}

	return [left, right];
};

Trigger.prototype.IsLeftRiverside = function(ent)
{
	return Vector2D.sub(TriggerHelper.GetEntityPosition2D(ent), this.mapCenter).cross(this.riverDirection) < 0;
};

/**
 * Order all ships to abort naval warfare and move to the shoreline all few minutes.
 */
Trigger.prototype.UngarrisonShipsOrder = function()
{
	let [ungarrisonLeft, ungarrisonRight] = this.GetActiveRiversides();
	if (!ungarrisonLeft && !ungarrisonRight)
		return;

	// Determine which ships should ungarrison on which side of the river
	let ships = Array.from(this.ships);
	let shipsLeft = [];
	let shipsRight = [];

	if (ungarrisonLeft && ungarrisonRight)
	{
		shipsLeft = shuffleArray(ships).slice(0, Math.round(ships.length / 2));
		shipsRight = ships.filter(ship => shipsLeft.indexOf(ship) == -1);
	}
	else if (ungarrisonLeft)
		shipsLeft = ships;
	else if (ungarrisonRight)
		shipsRight = ships;

	// Determine which ships should ungarrison and patrol at which trigger point names
	let sides = [];
	if (shipsLeft.length)
		sides.push({
			"ships": shipsLeft,
			"ungarrisonPointRef": triggerPointUngarrisonLeft,
			"landPointRef": triggerPointLandPatrolLeft
		});

	if (shipsRight.length)
		sides.push({
			"ships": shipsRight,
			"ungarrisonPointRef": triggerPointUngarrisonRight,
			"landPointRef": triggerPointLandPatrolRight
		});

	// Order those ships to move to a randomly chosen trigger point on the determined
	// side of the river. Remember that chosen ungarrison point and the name of the
	// trigger points where the ungarrisoned units should patrol afterwards.
	for (let side of sides)
		for (let ship of side.ships)
		{
			let ungarrisonPoint = pickRandom(this.GetTriggerPoints(side.ungarrisonPointRef));
			let ungarrisonPos = TriggerHelper.GetEntityPosition2D(ungarrisonPoint);

			this.debugLog("Ship " + ship + " will ungarrison at " + side.ungarrisonPointRef +
				" (" + ungarrisonPos.x + "," + ungarrisonPos.y + ")");

			Engine.QueryInterface(ship, IID_UnitAI).Walk(ungarrisonPos.x, ungarrisonPos.y, false);
			this.shipTarget[ship] = { "landPointRef": side.landPointRef, "ungarrisonPoint": ungarrisonPoint };
		}

	this.DoAfterDelay(shipUngarrisonInterval() * 60 * 1000, "UngarrisonShipsOrder", {});
};

/**
 * Check frequently whether the ships are close enough to unload at the shoreline.
 */
Trigger.prototype.CheckShipRange = function()
{
	for (let ship of this.ships)
	{
		if (!this.shipTarget[ship] || PositionHelper.DistanceBetweenEntities(ship, this.shipTarget[ship].ungarrisonPoint) > shipUngarrisonDistance)
			continue;

		let cmpGarrisonHolder = Engine.QueryInterface(ship, IID_GarrisonHolder);
		if (!cmpGarrisonHolder)
			continue;

		let humans = TriggerHelper.MatchEntitiesByClass(cmpGarrisonHolder.GetEntities(), "Human");
		let siegeEngines = TriggerHelper.MatchEntitiesByClass(cmpGarrisonHolder.GetEntities(), "Siege");

		this.debugLog("Ungarrisoning ship " + ship + " at " + uneval(this.shipTarget[ship]));
		cmpGarrisonHolder.UnloadAll();
		this.AttackAndPatrol([ship], shipTargetClass, triggerPointShipPatrol, "Ships", true);

		if (randBool(formationProbability))
			TriggerHelper.SetUnitFormation(gaulPlayer, humans, pickRandom(unitFormations));

		this.AttackAndPatrol(siegeEngines, siegeTargetClass, this.shipTarget[ship].landPointRef, "Siege Engines", true);

		// Order soldiers at last, so the follow-player observer feature focuses the soldiers
		this.AttackAndPatrol(humans, unitTargetClass, this.shipTarget[ship].landPointRef, "Units", true);

		delete this.shipTarget[ship];
	}
};

Trigger.prototype.DanubiusOwnershipChange = function(data)
{
	if (data.from != 0)
		return;

	if (this.heroes.delete(data.entity))
		this.debugLog("Hero " + data.entity + " died");

	if (this.ships.delete(data.entity))
		this.debugLog("Ship " + data.entity + " sunk");

	if (this.civicCenters.delete(data.entity))
		this.debugLog("Gaia civic center " + data.entity + " destroyed or captured");

	this.ritualEnts.delete(data.entity);
};

Trigger.prototype.InitDanubius = function()
{
	// Set a custom animation of idle ritual units frequently
	this.ritualEnts = new Set();

	// To prevent spawning more than the limits, track IDs of current entities
	this.ships = new Set();
	this.heroes = new Set();

	// Remember gaia CCs to spawn attackers from
	this.civicCenters = new Set(TriggerHelper.GetPlayerEntitiesByClass(gaulPlayer, "CivCentre"));

	// Depends on this.heroes
	Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger).RegisterTrigger("OnOwnershipChanged", "DanubiusOwnershipChange", { "enabled": true });

	// Maps from gaia ship entity ID to ungarrison trigger point entity ID and land patrol triggerpoint name
	this.shipTarget = {};
	this.fillShipsTimer = undefined;

	// Be able to distinguish between the left and right riverside
	let mapSize = TriggerHelper.GetMapSizeTerrain();
	this.mapCenter = new Vector2D(mapSize / 2, mapSize / 2);

	this.riverDirection = Vector2D.sub(
		TriggerHelper.GetEntityPosition2D(this.GetTriggerPoints(triggerPointRiverDirection)[0]),
		this.mapCenter);

	this.StartCelticRitual();
	this.GarrisonAllGallicBuildings();
	this.SpawnInitialCCDefenders();
	this.SpawnCCAttackers();

	this.SpawnShips();
	this.DoAfterDelay(shipUngarrisonInterval() * 60 * 1000, "UngarrisonShipsOrder", {});
	this.DoRepeatedly(5 * 1000, "CheckShipRange", {});
};

{
	Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger).RegisterTrigger("OnInitGame", "InitDanubius", { "enabled": true });
}