0ad/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js
Dunedan 93ce94655d
Use @stylistic/brace-style for eslint
Up to now `eslint-plugin-brace-rules` was used to enforce a common brace
style for JavaScript code. This plugin was however updated the last time
over 9 years ago and will be incompatible with ESLint v10, as that
[removes `context.getSourceCode()`][1], the plugin relies on.

To keep the eslint config working with ESLint v10, this replaces
`eslint-plugin-brace-rules` with the [`@stylistic/brace-style`][2] rule
from `@stylistic/eslint-plugin`, a package we already use.

While `@stylistic/brace-style` doesn't offer an option to format braces
in exactly the same way as before, the "allman" style seems to be the
one closest to the existing code.

[1]: https://eslint.org/blog/2025/11/eslint-v10.0.0-alpha.0-released/#removed-deprecated-rule-context-members
[2]: https://eslint.style/rules/brace-style
2026-01-12 21:33:52 +01:00

892 lines
No EOL
24 KiB
JavaScript

Engine.LoadHelperScript("Player.js");
Engine.LoadHelperScript("Position.js");
Engine.LoadHelperScript("Sound.js");
Engine.LoadComponentScript("interfaces/Auras.js");
Engine.LoadComponentScript("interfaces/Builder.js");
Engine.LoadComponentScript("interfaces/BuildingAI.js");
Engine.LoadComponentScript("interfaces/Capturable.js");
Engine.LoadComponentScript("interfaces/Diplomacy.js");
Engine.LoadComponentScript("interfaces/Garrisonable.js");
Engine.LoadComponentScript("interfaces/Resistance.js");
Engine.LoadComponentScript("interfaces/Formation.js");
Engine.LoadComponentScript("interfaces/Heal.js");
Engine.LoadComponentScript("interfaces/Health.js");
Engine.LoadComponentScript("interfaces/Pack.js");
Engine.LoadComponentScript("interfaces/ResourceSupply.js");
Engine.LoadComponentScript("interfaces/ResourceGatherer.js");
Engine.LoadComponentScript("interfaces/Timer.js");
Engine.LoadComponentScript("interfaces/Turretable.js");
Engine.LoadComponentScript("interfaces/UnitAI.js");
Engine.LoadComponentScript("Formation.js");
Engine.LoadComponentScript("UnitAI.js");
/**
* Fairly straightforward test that entity renaming is handled
* by unitAI states. These ought to be augmented with integration tests, ideally.
*/
function TestTargetEntityRenaming(init_state, post_state, setup)
{
ResetState();
const player_ent = 5;
const target_ent = 6;
AddMock(SYSTEM_ENTITY, IID_Timer, {
"SetInterval": () => {},
"SetTimeout": () => {}
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => false
});
const unitAI = ConstructComponent(player_ent, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive",
"FleeDistance": 10
});
unitAI.OnCreate();
setup(unitAI, player_ent, target_ent);
TS_ASSERT_EQUALS(unitAI.GetCurrentState(), init_state);
unitAI.OnGlobalEntityRenamed({
"entity": target_ent,
"newentity": target_ent + 1
});
TS_ASSERT_EQUALS(unitAI.GetCurrentState(), post_state);
}
TestTargetEntityRenaming(
"INDIVIDUAL.GARRISON.APPROACHING", "INDIVIDUAL.IDLE",
(unitAI, player_ent, target_ent) =>
{
unitAI.CanGarrison = (target) => target == target_ent;
unitAI.MoveToTargetRange = (target) => target == target_ent;
unitAI.AbleToMove = () => true;
unitAI.Garrison(target_ent, false);
}
);
TestTargetEntityRenaming(
"INDIVIDUAL.REPAIR.REPAIRING", "INDIVIDUAL.REPAIR.REPAIRING",
(unitAI, player_ent, target_ent) =>
{
AddMock(player_ent, IID_Builder, {
"StartRepairing": () => true,
"StopRepairing": () => {}
});
QueryBuilderListInterface = () => {};
unitAI.CheckTargetRange = () => true;
unitAI.CanRepair = (target) => target == target_ent;
unitAI.Repair(target_ent, false, false);
}
);
TestTargetEntityRenaming(
"INDIVIDUAL.FLEEING", "INDIVIDUAL.FLEEING",
(unitAI, player_ent, target_ent) =>
{
PositionHelper.DistanceBetweenEntities = () => 10;
unitAI.CheckTargetRangeExplicit = () => false;
AddMock(player_ent, IID_UnitMotion, {
"MoveToTargetRange": () => true,
"GetRunMultiplier": () => 1,
"SetSpeedMultiplier": () => {},
"GetAcceleration": () => 1,
"StopMoving": () => {}
});
unitAI.Flee(target_ent, false);
}
);
/* Regression test.
* Tests the FSM behaviour of a unit when walking as part of a formation,
* then exiting the formation.
* mode == 0: There is no enemy unit nearby.
* mode == 1: There is a live enemy unit nearby.
* mode == 2: There is a dead enemy unit nearby.
*/
function TestFormationExiting(mode)
{
ResetState();
var playerEntity = 5;
var unit = 10;
var enemy = 20;
var controller = 30;
AddMock(SYSTEM_ENTITY, IID_Timer, {
"SetInterval": function() { },
"SetTimeout": function() { },
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"CreateActiveQuery": function(ent, minRange, maxRange, players, iid, flags, accountForSize)
{
return 1;
},
"EnableActiveQuery": function(id) { },
"ResetActiveQuery": function(id) { if (mode == 0) return []; return [enemy]; },
"DisableActiveQuery": function(id) { },
"GetEntityFlagMask": function(identifier) { },
});
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"GetCurrentTemplateName": function(ent) { return "special/formations/line_closed"; },
});
AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
"GetPlayerByID": function(id) { return playerEntity; },
"GetNumPlayers": function() { return 2; },
});
AddMock(playerEntity, IID_Diplomacy, {
"IsAlly": function() { return false; },
"IsEnemy": function() { return true; },
"GetEnemies": function() { return [2]; },
});
var unitAI = ConstructComponent(unit, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" });
AddMock(unit, IID_Identity, {
"GetClassesList": function() { return []; },
});
AddMock(unit, IID_Ownership, {
"GetOwner": function() { return 1; },
});
AddMock(unit, IID_Position, {
"GetTurretParent": function() { return INVALID_ENTITY; },
"GetPosition": function() { return new Vector3D(); },
"GetPosition2D": function() { return new Vector2D(); },
"GetRotation": function() { return { "y": 0 }; },
"IsInWorld": function() { return true; },
});
AddMock(unit, IID_UnitMotion, {
"GetWalkSpeed": () => 1,
"GetAcceleration": () => 1,
"SetSpeedMultiplier": () => {},
"MoveToFormationOffset": (target, x, z) => {},
"MoveToTargetRange": (target, min, max) => true,
"PossiblyAtDestination": () => false,
"SetMemberOfFormation": () => {},
"StopMoving": () => {},
"SetFacePointAfterMove": () => {},
"GetFacePointAfterMove": () => true,
"GetPassabilityClassName": () => "default"
});
AddMock(unit, IID_Vision, {
"GetRange": function() { return 10; },
});
AddMock(unit, IID_Attack, {
"GetRange": function() { return { "max": 10, "min": 0 }; },
"GetFullAttackRange": function() { return { "max": 40, "min": 0 }; },
"GetBestAttackAgainst": function(t) { return "melee"; },
"GetPreference": function(t) { return 0; },
"GetTimers": function() { return { "prepare": 500, "repeat": 1000 }; },
"CanAttack": function(v) { return true; },
"CompareEntitiesByPreference": function(a, b) { return 0; },
"IsTargetInRange": () => true,
"StartAttacking": () => true
});
unitAI.OnCreate();
unitAI.SetupAttackRangeQuery(1);
if (mode == 1)
{
AddMock(enemy, IID_Health, {
"GetHitpoints": function() { return 10; },
});
AddMock(enemy, IID_UnitAI, {
"IsAnimal": () => "false",
"IsDangerousAnimal": () => "false"
});
}
else if (mode == 2)
AddMock(enemy, IID_Health, {
"GetHitpoints": function() { return 0; },
});
const controllerFormation = ConstructComponent(controller, "Formation", {
"FormationShape": "square",
"ShiftRows": "false",
"SortingClasses": "",
"WidthDepthRatio": 1,
"UnitSeparationWidthMultiplier": 1,
"UnitSeparationDepthMultiplier": 1,
"SpeedMultiplier": 1,
"Sloppiness": 0
});
const controllerAI = ConstructComponent(controller, "UnitAI", {
"FormationController": "true",
"DefaultStance": "aggressive"
});
AddMock(controller, IID_Position, {
"JumpTo": function(x, z) { this.x = x; this.z = z; },
"TurnTo": function() {},
"GetTurretParent": function() { return INVALID_ENTITY; },
"GetPosition": function() { return new Vector3D(this.x, 0, this.z); },
"GetPosition2D": function() { return new Vector2D(this.x, this.z); },
"GetRotation": function() { return { "y": 0 }; },
"IsInWorld": function() { return true; },
"MoveOutOfWorld": () => {}
});
AddMock(controller, IID_UnitMotion, {
"GetWalkSpeed": () => 1,
"StopMoving": () => {},
"SetSpeedMultiplier": () => {},
"SetAcceleration": (accel) => {},
"SetPassabilityClassName": (name) => {},
"MoveToPointRange": () => true,
"SetFacePointAfterMove": () => {},
"GetFacePointAfterMove": () => true,
"GetPassabilityClassName": () => "default"
});
AddMock(SYSTEM_ENTITY, IID_Pathfinder, {
"GetClearance": () => 1,
"GetPassabilityClass": () => 16
});
controllerAI.OnCreate();
TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.IDLE");
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE");
controllerFormation.SetMembers([unit]);
controllerAI.Walk(100, 100, false);
TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.WALKING");
TS_ASSERT_EQUALS(unitAI.fsmStateName, "FORMATIONMEMBER.WALKING");
controllerFormation.Disband();
unitAI.UnitFsm.ProcessMessage(unitAI, { "type": "Timer" });
if (mode == 0)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE");
else if (mode == 1)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING");
else if (mode == 2)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE");
else
TS_FAIL("invalid mode");
}
function TestMoveIntoFormationWhileAttacking()
{
ResetState();
var playerEntity = 5;
var controller = 10;
var enemy = 20;
var unit = 30;
var units = [];
var unitCount = 8;
var unitAIs = [];
AddMock(SYSTEM_ENTITY, IID_Timer, {
"SetInterval": function() { },
"SetTimeout": function() { },
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"CreateActiveQuery": function(ent, minRange, maxRange, players, iid, flags, accountForSize)
{
return 1;
},
"EnableActiveQuery": function(id) { },
"ResetActiveQuery": function(id) { return [enemy]; },
"DisableActiveQuery": function(id) { },
"GetEntityFlagMask": function(identifier) { },
});
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"GetCurrentTemplateName": function(ent) { return "special/formations/line_closed"; },
});
AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
"GetPlayerByID": function(id) { return playerEntity; },
"GetNumPlayers": function() { return 2; },
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": (ent, target, min, max) => true
});
AddMock(playerEntity, IID_Diplomacy, {
"IsAlly": function() { return false; },
"IsEnemy": function() { return true; },
"GetEnemies": function() { return [2]; },
});
// create units
for (var i = 0; i < unitCount; i++)
{
units.push(unit + i);
var unitAI = ConstructComponent(unit + i, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" });
AddMock(unit + i, IID_Identity, {
"GetClassesList": function() { return []; },
});
AddMock(unit + i, IID_Ownership, {
"GetOwner": function() { return 1; },
});
AddMock(unit + i, IID_Position, {
"GetTurretParent": function() { return INVALID_ENTITY; },
"GetPosition": function() { return new Vector3D(); },
"GetPosition2D": function() { return new Vector2D(); },
"GetRotation": function() { return { "y": 0 }; },
"IsInWorld": function() { return true; },
});
AddMock(unit + i, IID_UnitMotion, {
"GetWalkSpeed": () => 1,
"GetAcceleration": () => 1,
"SetSpeedMultiplier": () => {},
"MoveToFormationOffset": (target, x, z) => {},
"MoveToTargetRange": (target, min, max) => true,
"PossiblyAtDestination": () => false,
"SetMemberOfFormation": () => {},
"StopMoving": () => {},
"SetFacePointAfterMove": () => {},
"GetFacePointAfterMove": () => true,
"GetPassabilityClassName": () => "default"
});
AddMock(unit + i, IID_Vision, {
"GetRange": function() { return 10; },
});
AddMock(unit + i, IID_Attack, {
"GetRange": function() { return { "max": 10, "min": 0 }; },
"GetFullAttackRange": function() { return { "max": 40, "min": 0 }; },
"GetBestAttackAgainst": function(t) { return "melee"; },
"GetTimers": function() { return { "prepare": 500, "repeat": 1000 }; },
"CanAttack": function(v) { return true; },
"CompareEntitiesByPreference": function(a, b) { return 0; },
"IsTargetInRange": () => true,
"StartAttacking": () => true,
"StopAttacking": () => {}
});
unitAI.OnCreate();
unitAI.SetupAttackRangeQuery(1);
unitAIs.push(unitAI);
}
// create enemy
AddMock(enemy, IID_Health, {
"GetHitpoints": function() { return 40; },
});
const controllerFormation = ConstructComponent(controller, "Formation", {
"FormationShape": "square",
"ShiftRows": "false",
"SortingClasses": "",
"WidthDepthRatio": 1,
"UnitSeparationWidthMultiplier": 1,
"UnitSeparationDepthMultiplier": 1,
"SpeedMultiplier": 1,
"Sloppiness": 0
});
const controllerAI = ConstructComponent(controller, "UnitAI", {
"FormationController": "true",
"DefaultStance": "aggressive"
});
AddMock(controller, IID_Position, {
"GetTurretParent": () => INVALID_ENTITY,
"JumpTo": function(x, z) { this.x = x; this.z = z; },
"TurnTo": function() {},
"GetPosition": function(){ return new Vector3D(this.x, 0, this.z); },
"GetPosition2D": function(){ return new Vector2D(this.x, this.z); },
"GetRotation": () => ({ "y": 0 }),
"IsInWorld": () => true,
"MoveOutOfWorld": () => {},
});
AddMock(controller, IID_UnitMotion, {
"GetWalkSpeed": () => 1,
"SetSpeedMultiplier": (speed) => {},
"SetAcceleration": (accel) => {},
"SetPassabilityClassName": (name) => {},
"MoveToPointRange": (x, z, minRange, maxRange) => {},
"StopMoving": () => {},
"SetFacePointAfterMove": () => {},
"GetFacePointAfterMove": () => true,
"GetPassabilityClassName": () => "default"
});
AddMock(SYSTEM_ENTITY, IID_Pathfinder, {
"GetClearance": () => 1,
"GetPassabilityClass": () => 16
});
AddMock(controller, IID_Attack, {
"GetRange": function() { return { "max": 10, "min": 0 }; },
"CanAttackAsFormation": function() { return false; },
});
controllerAI.OnCreate();
controllerFormation.SetMembers(units);
controllerAI.Attack(enemy, []);
for (const ent of unitAIs)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING");
controllerAI.MoveIntoFormation({ "name": "Circle" });
// let all units be in position
for (const ent of unitAIs)
controllerFormation.SetFinishedEntity(ent);
for (const ent of unitAIs)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING");
controllerFormation.Disband();
}
TestFormationExiting(0);
TestFormationExiting(1);
TestFormationExiting(2);
TestMoveIntoFormationWhileAttacking();
function TestWalkAndFightTargets()
{
const ent = 10;
const unitAI = ConstructComponent(ent, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive",
"FleeDistance": 10
});
unitAI.OnCreate();
unitAI.losAttackRangeQuery = true;
// The result is stored here
let result;
unitAI.PushOrderFront = function(type, order)
{
if (type === "Attack" && order?.target)
result = order.target;
};
// Create some targets.
AddMock(ent+1, IID_UnitAI, { "IsAnimal": () => true, "IsDangerousAnimal": () => false });
AddMock(ent+2, IID_Ownership, { "GetOwner": () => 2 });
AddMock(ent+3, IID_Ownership, { "GetOwner": () => 2 });
AddMock(ent+4, IID_Ownership, { "GetOwner": () => 2 });
AddMock(ent+5, IID_Ownership, { "GetOwner": () => 2 });
AddMock(ent+6, IID_Ownership, { "GetOwner": () => 2 });
AddMock(ent+7, IID_Ownership, { "GetOwner": () => 2 });
unitAI.CanAttack = function(target)
{
return target !== ent+2 && target !== ent+7;
};
AddMock(ent, IID_Attack, {
"GetPreference": (target) => ({
[ent+4]: 0,
[ent+5]: 1,
[ent+6]: 2,
[ent+7]: 0
}?.[target])
});
const runTest = function(ents, res)
{
result = undefined;
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"ResetActiveQuery": () => ents
});
TS_ASSERT_EQUALS(unitAI.FindWalkAndFightTargets(), !!res);
TS_ASSERT_EQUALS(result, res);
};
// No entities.
runTest([]);
// Entities that cannot be attacked.
runTest([ent+1, ent+2, ent+7]);
// No preference, one attackable entity.
runTest([ent+1, ent+2, ent+3], ent+3);
// Check preferences.
runTest([ent+1, ent+2, ent+3, ent+4], ent+4);
runTest([ent+1, ent+2, ent+3, ent+4, ent+5], ent+4);
runTest([ent+1, ent+2, ent+6, ent+3, ent+4, ent+5], ent+4);
runTest([ent+1, ent+2, ent+7, ent+6, ent+3, ent+4, ent+5], ent+4);
runTest([ent+1, ent+2, ent+7, ent+6, ent+3, ent+5], ent+5);
runTest([ent+1, ent+2, ent+7, ent+6, ent+3], ent+6);
runTest([ent+1, ent+2, ent+7, ent+3], ent+3);
}
TestWalkAndFightTargets();
function TestAttemptObstructionMitigation()
{
ResetState();
const controllerID = 100;
const member1ID = 201;
const member2ID = 202;
const member3ID = 203;
const playerID = 1;
const playerEntity = 5;
// Helper function to create timer mocks
function createTimerMock()
{
let timerId = null;
let canceledTimer = null;
return {
"SetTimeout": function(entity, iid, functionName, time, data)
{
timerId = 123;
return timerId;
},
"CancelTimer": function(id)
{
canceledTimer = id;
},
"SetInterval": function() { return null; }
};
}
AddMock(SYSTEM_ENTITY, IID_Timer, createTimerMock(true));
AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
"GetPlayerByID": id => playerEntity,
"GetNumPlayers": () => 2
});
AddMock(SYSTEM_ENTITY, IID_Pathfinder, {
"GetClearance": () => 1,
"GetPassabilityClass": () => 16
});
AddMock(playerEntity, IID_Player, {
"GetPlayerID": () => playerID
});
// Create controller UnitAI
const controllerAI = ConstructComponent(controllerID, "UnitAI", {
"FormationController": "true",
"DefaultStance": "aggressive"
});
// Mock controller position
let controllerX = 0;
let controllerZ = 0;
AddMock(controllerID, IID_Position, {
"IsInWorld": () => true,
"GetPosition": () => new Vector3D(controllerX, 0, controllerZ),
"GetPosition2D": () => new Vector2D(controllerX, controllerZ),
"GetRotation": () => ({ "y": 0 }),
"GetTurretParent": () => INVALID_ENTITY,
"TurnTo": () => {},
"JumpTo": function(x, z)
{
controllerX = x;
controllerZ = z;
},
"MoveOutOfWorld": () => {}
});
AddMock(controllerID, IID_Ownership, {
"GetOwner": () => playerID,
});
AddMock(controllerID, IID_UnitMotion, {
"GetWalkSpeed": () => 1,
"GetAcceleration": () => 1,
"StopMoving": () => {},
"MoveToTargetRange": () => true,
"MoveToPointRange": () => true,
"SetSpeedMultiplier": () => {},
"SetAcceleration": () => {},
"SetPassabilityClassName": () => {},
"SetFacePointAfterMove": () => {},
"GetFacePointAfterMove": () => true,
"GetPassabilityClassName": () => "default",
"SetMemberOfFormation": () => {},
"FaceTowardsPoint": () => {}
});
controllerAI.OnCreate();
// Helper function to reset controller position
function resetControllerPosition(x = 0, z = 0)
{
controllerX = x;
controllerZ = z;
}
// Helper function to test obstruction mitigation
function testObstructionMitigation(formationMock, orderData, expectedX, expectedZ, shouldJump)
{
resetControllerPosition();
if (formationMock)
{
AddMock(controllerID, IID_Formation, formationMock);
}
controllerAI.order = { "data": orderData };
// Clear any previous flags
delete controllerAI.obstructionMitigationAttempted;
delete controllerAI.obstructionMitigationTimer;
controllerAI.AttemptObstructionMitigation();
TS_ASSERT_EQUALS(controllerX, expectedX);
TS_ASSERT_EQUALS(controllerZ, expectedZ);
if (shouldJump)
{
TS_ASSERT(controllerAI.obstructionMitigationAttempted);
TS_ASSERT_EQUALS(controllerAI.obstructionMitigationTimer, 123);
}
}
// Should not execute if already attempted
(function()
{
controllerAI.obstructionMitigationAttempted = true;
const originalX = controllerX;
const originalZ = controllerZ;
controllerAI.order = { "data": { "x": 100, "z": 100 } };
controllerAI.AttemptObstructionMitigation();
TS_ASSERT_EQUALS(controllerX, originalX);
TS_ASSERT_EQUALS(controllerZ, originalZ);
delete controllerAI.obstructionMitigationAttempted;
})();
// Should not execute without formation component
(function()
{
const originalX = controllerX;
const originalZ = controllerZ;
controllerAI.order = { "data": { "x": 100, "z": 100 } };
controllerAI.AttemptObstructionMitigation();
TS_ASSERT_EQUALS(controllerX, originalX);
TS_ASSERT_EQUALS(controllerZ, originalZ);
})();
// Should not execute without valid destination
(function()
{
const formationMock = { "GetClosestMemberToPosition": () => member1ID };
// Test with missing destination
testObstructionMitigation(formationMock, {}, 0, 0, false);
// Test with undefined x
testObstructionMitigation(formationMock, { "z": 100 }, 0, 0, false);
// Test with undefined z
testObstructionMitigation(formationMock, { "x": 100 }, 0, 0, false);
})();
// Should not execute if no closest member found
(function()
{
const formationMock = { "GetClosestMemberToPosition": () => INVALID_ENTITY };
testObstructionMitigation(formationMock, { "x": 100, "z": 100 }, 0, 0, false);
})();
// Should not execute if member or controller missing position component
(function()
{
const formationMock = { "GetClosestMemberToPosition": () => member1ID };
testObstructionMitigation(formationMock, { "x": 100, "z": 100 }, 0, 0, false);
})();
// Should jump when member is more than 2 meters closer to destination
(function()
{
AddMock(member1ID, IID_Position, {
"GetPosition2D": () => new Vector2D(90, 90)
});
const formationMock = {
"GetClosestMemberToPosition": () => member1ID
};
testObstructionMitigation(formationMock, { "x": 100, "z": 100 }, 90, 90, true);
})();
// Should NOT jump when member is NOT more than 2 meters closer
(function()
{
AddMock(member1ID, IID_Position, {
"GetPosition2D": () => new Vector2D(95, 96)
});
const formationMock = {
"GetClosestMemberToPosition": () => member1ID
};
controllerX = 95;
controllerZ = 95;
controllerAI.order = { "data": { "x": 100, "z": 100 } };
delete controllerAI.obstructionMitigationAttempted;
controllerAI.AttemptObstructionMitigation();
TS_ASSERT_EQUALS(controllerX, 95);
TS_ASSERT_EQUALS(controllerZ, 95);
TS_ASSERT(controllerAI.obstructionMitigationAttempted);
})();
// Should NOT jump when member is actually farther away
(function()
{
AddMock(member1ID, IID_Position, {
"GetPosition2D": () => new Vector2D(0, 0)
});
const formationMock = {
"GetClosestMemberToPosition": () => member1ID
};
controllerX = 95;
controllerZ = 95;
controllerAI.order = { "data": { "x": 100, "z": 100 } };
delete controllerAI.obstructionMitigationAttempted;
controllerAI.AttemptObstructionMitigation();
TS_ASSERT_EQUALS(controllerX, 95);
TS_ASSERT_EQUALS(controllerZ, 95);
TS_ASSERT(controllerAI.obstructionMitigationAttempted);
})();
// Should jump when member is exactly 2.1 meters closer (edge case)
(function()
{
AddMock(member1ID, IID_Position, {
"GetPosition2D": () => new Vector2D(2, 1)
});
const formationMock = {
"GetClosestMemberToPosition": () => member1ID
};
testObstructionMitigation(formationMock, { "x": 100, "z": 100 }, 2, 1, true);
})();
// Test SetObstructionMitigationFlag and ResetObstructionMitigationFlag
(function()
{
// Use SetTimeout version for this test
AddMock(SYSTEM_ENTITY, IID_Timer, createTimerMock(true));
controllerAI.SetObstructionMitigationFlag();
TS_ASSERT(controllerAI.obstructionMitigationAttempted);
TS_ASSERT_EQUALS(controllerAI.obstructionMitigationTimer, 123);
controllerAI.ResetObstructionMitigationFlag();
TS_ASSERT(!controllerAI.obstructionMitigationAttempted);
})();
// Multiple members, should pick closest one
(function()
{
const members = [member1ID, member2ID, member3ID];
AddMock(member1ID, IID_Position, {
"GetPosition2D": () => ({ "x": 80, "y": 80 }),
"IsInWorld": () => true
});
AddMock(member2ID, IID_Position, {
"GetPosition2D": () => ({ "x": 90, "y": 90 }),
"IsInWorld": () => true
});
AddMock(member3ID, IID_Position, {
"GetPosition2D": () => ({ "x": 50, "y": 50 }),
"IsInWorld": () => true
});
const formationMock = {
"GetClosestMemberToPosition": function(targetPosition, filter)
{
const memberPositions = {
[member1ID]: { "x": 80, "y": 80 },
[member2ID]: { "x": 90, "y": 90 },
[member3ID]: { "x": 50, "y": 50 }
};
let closestMember = INVALID_ENTITY;
let closestDistance = Infinity;
for (const member of members)
{
if (filter && !filter(member))
continue;
const memberPos = memberPositions[member];
if (!memberPos)
continue;
const dist = (targetPosition.x - memberPos.x) ** 2 + (targetPosition.y - memberPos.y) ** 2;
if (dist < closestDistance)
{
closestMember = member;
closestDistance = dist;
}
}
return closestMember;
}
};
testObstructionMitigation(formationMock, { "x": 100, "z": 100 }, 90, 90, true);
})();
}
TestAttemptObstructionMitigation();