Compare commits

...

4 commits

Author SHA1 Message Date
Atrik
2abb7337fd Add test for queries with visibility checks 2026-06-15 01:34:53 +02:00
Atrik
5f63f9e497 Enable attacks on foes visible through shared LOS
Adds a baseRange parameter to parabolic queries providing simple
2D detection alongside parabolic detection.
StandGround and Chase stances now use full attack parabolic range
with vision range as baseRange, allowing units to attack enemies
visible through friendly vision.
2026-06-15 01:20:19 +02:00
Atrik
402de88f25 Filter out hidden targets in CanAttack
Units should not be able to attack entities with "hidden" visibility.
Visible and fogged (mirage/retainInFog) targets remain attackable.
2026-06-15 01:20:19 +02:00
Atrik
626c91e02b Add tests for CanEverReachTarget and its helpers 2026-06-15 01:20:19 +02:00
11 changed files with 401 additions and 53 deletions

View file

@ -271,6 +271,15 @@ Attack.prototype.CanAttack = function(target, wantedTypes)
if (!cmpTargetPlayer || !cmpEntityPlayer)
return false;
// Must be visible or miraged / with retainInFog flag, not completely hidden
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (cmpRangeManager)
{
const visibility = cmpRangeManager.GetLosVisibility(target, cmpEntityPlayer.GetPlayerID());
if (visibility == "hidden")
return false;
}
const types = this.GetAttackTypes(wantedTypes);
const entityOwner = cmpEntityPlayer.GetPlayerID();
const targetOwner = cmpTargetPlayer.GetPlayerID();

View file

@ -127,9 +127,17 @@ BuildingAI.prototype.SetupRangeQuery = function()
const range = cmpAttack.GetRange(attackType);
const yOrigin = cmpAttack.GetAttackYOrigin(attackType);
// Get building's vision range
const cmpVision = Engine.QueryInterface(this.entity, IID_Vision);
const visionRange = cmpVision ? cmpVision.GetRange() : 0;
// Base range
const baseRange = Math.min(visionRange, range.max);
// This takes entity sizes into accounts, so no need to compensate for structure size.
this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery(
this.entity, range.min, range.max, yOrigin,
this.entity, range.min, range.max, baseRange, yOrigin,
enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal"));
cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery);
@ -156,10 +164,17 @@ BuildingAI.prototype.SetupGaiaRangeQuery = function()
const range = cmpAttack.GetRange(attackType);
const yOrigin = cmpAttack.GetAttackYOrigin(attackType);
// Get building's vision range
const cmpVision = Engine.QueryInterface(this.entity, IID_Vision);
const visionRange = cmpVision ? cmpVision.GetRange() : 0;
// Base range
const baseRange = Math.min(visionRange, range.max);
// This query is only interested in Gaia entities that can attack.
// This takes entity sizes into accounts, so no need to compensate for structure size.
this.gaiaUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery(
this.entity, range.min, range.max, yOrigin,
this.entity, range.min, range.max, baseRange, yOrigin,
[0], IID_Attack, cmpRangeManager.GetEntityFlagMask("normal"));
cmpRangeManager.EnableActiveQuery(this.gaiaUnitsQuery);
@ -170,7 +185,6 @@ BuildingAI.prototype.SetupGaiaRangeQuery = function()
*/
BuildingAI.prototype.OnRangeUpdate = function(msg)
{
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (!cmpAttack)
return;
@ -189,10 +203,10 @@ BuildingAI.prototype.OnRangeUpdate = function(msg)
// Add new targets.
for (const entity of msg.added)
if (cmpAttack.CanAttack(entity))
if (!this.targetUnits.includes(entity))
this.targetUnits.push(entity);
// Remove targets outside of vision-range.
// Remove targets out of range.
for (const entity of msg.removed)
{
const index = this.targetUnits.indexOf(entity);
@ -375,13 +389,7 @@ BuildingAI.prototype.FireArrows = function()
{
const selectedTarget = targets[targetIndex].entityId;
if (this.CheckTargetVisible(selectedTarget) && cmpObstructionManager.IsInTargetParabolicRange(
this.entity,
selectedTarget,
range.min,
range.max,
yOrigin,
false))
if (cmpAttack.CanAttack(selectedTarget, [attackType]))
{
cmpAttack.PerformAttack(attackType, selectedTarget);
PlaySound("attack_" + attackType.toLowerCase(), this.entity);

View file

@ -4053,10 +4053,17 @@ UnitAI.prototype.SetupAttackRangeQuery = function(enable = true)
{
const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
const yOrigin = cmpAttack ? cmpAttack.GetAttackYOrigin("Ranged") : 0;
const baseRange = range.baseRange;
// Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that.
this.losAttackRangeQuery = cmpRangeManager.CreateActiveParabolicQuery(this.entity,
range.min, range.max, yOrigin,
players, IID_Resistance,
this.losAttackRangeQuery = cmpRangeManager.CreateActiveParabolicQuery(
this.entity,
range.min,
range.max,
baseRange,
yOrigin,
players,
IID_Resistance,
cmpRangeManager.GetEntityFlagMask("normal"));
}
else
@ -5383,8 +5390,16 @@ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force)
if (!this.AbleToMove())
return false;
// Check if we should chase based on stance
if (this.GetStance().respondChase)
return true;
{
// If we're allowed to chase beyond vision, always chase
if (this.GetStance().respondChaseBeyondVision)
return true;
// Otherwise, only chase if the target is within our personal vision
return this.CheckTargetIsInVisionRange(target);
}
// If we are guarding/escorting, chase at least as long as the guarded unit is in target range of the attacker
if (this.isGuardOf)
@ -6357,14 +6372,15 @@ UnitAI.prototype.FindWalkAndFightTargets = function()
* the unit should "notice" an enemy and potentially start moving toward it.
*
* @param {number} iid - IID_Vision, IID_Heal, or IID_Attack
* @returns {{min: number, max: number, parabolic: boolean}}
* @returns {{min: number, max: number, baseRange: number, parabolic: boolean}}
* 'parabolic' indicates that the caller
* should use a parabolic range query (accounting for elevation) instead of a
* flat 2D one. Generally used for projectile attacks.
* 'baseRange' is a non-parabolic 2D detection range that always counts as in-range.
*/
UnitAI.prototype.GetQueryRange = function(iid)
{
const ret = { "min": 0, "max": 0, "parabolic": false };
const ret = { "min": 0, "max": 0, "baseRange": 0, "parabolic": false };
const cmpVision = Engine.QueryInterface(this.entity, IID_Vision);
if (!cmpVision)
@ -6386,7 +6402,8 @@ UnitAI.prototype.GetQueryRange = function(iid)
if (!range)
return ret;
ret.min = range.min;
ret.max = Math.min(range.max, visionRange);
ret.max = range.max;
ret.baseRange = Math.min(visionRange, range.max);
// For StandGround, the 'parabolic' flag is set so that the caller can create a
// parabolic query instead of a flat one. This ensures elevation bonuses are
// properly accounted for when detecting enemies.
@ -6397,9 +6414,27 @@ UnitAI.prototype.GetQueryRange = function(iid)
// before actually attacking.
ret.parabolic = range.parabolic;
}
// In other stances, detect enemies within vision or within parabolic attack range.
// This allows attacking enemies visible through allies but outside personal vision.
// Base range capped by attack range to avoid querying beyond what we can actually hit.
else if (this.GetStance().respondChase)
// Chase stances: use full vision range (unit will chase anything it sees)
ret.max = visionRange;
{
const range = this.GetRange(iid);
if (range && range.parabolic)
{
// Detect enemies within vision and
// enemies within parabolic attack range
ret.parabolic = true;
ret.max = range.max; // Parabolic attack range
ret.baseRange = Math.min(visionRange, range.max); // Always detect within vision
ret.min = range.min;
}
else
{
// Non-parabolic attacks: simple vision range
ret.max = visionRange;
}
}
else if (this.GetStance().respondHoldGround)
{
// HoldGround: vision range + half attack range (willing to move a bit)

View file

@ -429,3 +429,100 @@ function testAttackPreference()
}
testAttackPreference();
function testCanEverReachTarget()
{
const attacker = ++entityID;
AddMock(attacker, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0,
"GetPosition2D": () => new Vector2D(1, 2)
});
const cmpAttack = ConstructComponent(attacker, "Attack", {
"Melee": {
"Damage": { "Hack": 10, "Pierce": 0, "Crush": 0 },
"MaxRange": 5
},
"Ranged": {
"Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 },
"MaxRange": 30,
"Projectile": { "Speed": 50, "Spread": 1, "Gravity": 1, "FriendlyFire": "false" }
}
});
// Melee target within 3D range
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true);
}
// Melee target too high
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 10
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), false);
}
// Melee target at same height, within range (close distance)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 4
});
// sqrt(0² + 4²) = 4 <= 5
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true);
}
// Ranged: target at same height — reachable from current position (check 1)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0,
"GetPosition": () => new Vector3D(1, 0, 2)
});
// Need RangeManager mock for IsTargetInRange (check 1) to work
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"GetEffectiveParabolicRange": () => 25,
"GetMaxReachableParabolicHeight": () => 15
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => true
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), true);
}
// Ranged: target too high for parabolic arc even at closest approach (check 2)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 20,
"GetPosition": () => new Vector3D(1, 20, 2)
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"GetEffectiveParabolicRange": () => -1, // out of range
"GetMaxReachableParabolicHeight": () => 10
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => false
});
// heightDiff = 20 - 0 = 20, maxReachableHeightDiff = 10 → unreachable
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), false);
}
}
testCanEverReachTarget();

View file

@ -15,6 +15,7 @@ const enemyPlayer = 2;
const alliedPlayer = 3;
const turretHolderID = 9;
const entitiesToTest = [10, 11, 12, 13];
let entityID = 100;
AddMock(turretHolderID, IID_Ownership, {
"GetOwner": () => player
@ -244,3 +245,80 @@ cmpTurretHolder.OnOwnershipChanged({
"from": INVALID_PLAYER
});
TS_ASSERT(cmpTurretHolder.OccupiesTurretPoint(spawned));
// Test GetClosestApproachDistanceToTurretPoint
{
const holder = ++entityID;
// Mock the holder's obstruction
AddMock(holder, IID_Obstruction, {
"GetBlockMovementFlag": () => true,
"GetObstructionHalfSizes": () => ({ "x": 10, "y": 15 })
});
const cmpHolder = ConstructComponent(holder, "TurretHolder", {
"TurretPoints": {
"center": {
"X": "0",
"Y": "5.0",
"Z": "0"
},
"edge": {
"X": "8.0",
"Y": "5.0",
"Z": "0"
},
"corner": {
"X": "10.0",
"Y": "5.0",
"Z": "15.0"
},
"outside": {
"X": "15.0",
"Y": "5.0",
"Z": "0"
}
}
});
// Center point (0,0) in 20x30 building → min(10, 15) = 10
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("center"), 10);
// Edge point (8,0) in 20x30 building → min(10-8, 15-0) = 2
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("edge"), 2);
// Corner point (10,15) in 20x30 building → min(10-10, 15-15) = 0
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("corner"), 0);
// Outside point (15,0) in 20x30 building → min(10-15, 15-0) = -5 → clamped to 0
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("outside"), 0);
// Nonexistent turret point
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("nonexistent"), 0);
// Pass object directly
const turretPoint = cmpHolder.TurretPointByName("center");
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint(turretPoint), 10);
// Passable building (no obstruction or doesn't block movement)
const passableHolder = ++entityID;
AddMock(passableHolder, IID_Obstruction, {
"GetBlockMovementFlag": () => false
});
const cmpHolderPassable = ConstructComponent(passableHolder, "TurretHolder", {
"TurretPoints": {
"center": { "X": "0", "Y": "5.0", "Z": "0" }
}
});
TS_ASSERT_EQUALS(cmpHolderPassable.GetClosestApproachDistanceToTurretPoint("center"), 0);
// No obstruction component at all
++entityID;
const cmpHolderNoObst = ConstructComponent(entityID, "TurretHolder", {
"TurretPoints": {
"center": { "X": "0", "Y": "5.0", "Z": "0" }
}
});
TS_ASSERT_EQUALS(cmpHolderNoObst.GetClosestApproachDistanceToTurretPoint("center"), 0);
}

View file

@ -67,7 +67,7 @@
<HeightOffset>18.0</HeightOffset>
</StatusBars>
<Vision>
<Range>80</Range>
<Range>10</Range>
</Vision>
<VisualActor>
<FoundationActor>structures/fndn_3x3.xml</FoundationActor>

View file

@ -110,6 +110,6 @@
</SoundGroups>
</Sound>
<Vision>
<Range>80</Range>
<Range>10</Range>
</Vision>
</Entity>

View file

@ -74,6 +74,6 @@
<Acceleration op="mul">0.75</Acceleration>
</UnitMotion>
<Vision>
<Range>100</Range>
<Range>10</Range>
</Vision>
</Entity>

View file

@ -186,8 +186,10 @@ struct Query
{
std::vector<entity_id_t> lastMatch;
CEntityHandle source; // TODO: this could crash if an entity is destroyed while a Query is still referencing it
player_id_t sourcePlayer; // Player who owns the source entity
entity_pos_t minRange;
entity_pos_t maxRange;
entity_pos_t baseRange; // Non-parabolic detection range
entity_pos_t yOrigin; // Used for parabolas only.
u32 ownersMask;
i32 interface;
@ -318,8 +320,10 @@ struct SerializeHelper<Query>
template<typename S>
void Common(S& serialize, const char* /*name*/, Serialize::qualify<S, Query> value)
{
serialize.NumberI32_Unbounded("sourcePlayer", value.sourcePlayer);
serialize.NumberFixed_Unbounded("min range", value.minRange);
serialize.NumberFixed_Unbounded("max range", value.maxRange);
serialize.NumberFixed_Unbounded("baseRange", value.baseRange);
serialize.NumberFixed_Unbounded("yOrigin", value.yOrigin);
serialize.NumberU32_Unbounded("owners mask", value.ownersMask);
serialize.NumberI32_Unbounded("interface", value.interface);
@ -972,12 +976,11 @@ public:
}
tag_t CreateActiveParabolicQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flags) override
{
tag_t id = m_QueryNext++;
m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, yOrigin, owners, requiredInterface, flags, true);
m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, baseRange, yOrigin, owners, requiredInterface, flags, true);
return id;
}
@ -1268,9 +1271,33 @@ public:
if (id == q.source.GetId())
return false;
// Ignore if it's missing the required interface
// Check if this is a mirage entity
CmpPtr<ICmpMirage> cmpMirage(GetSimContext(), id);
bool isMirage = !!cmpMirage;
// Ignore if it's missing the required interface but allow mirages to bypass
if (q.interface && !GetSimContext().GetComponentManager().QueryInterface(id, q.interface))
return false;
{
if (!isMirage)
return false;
}
// Skip hidden entities (not visible to source player)
if (q.source.GetId() != INVALID_ENTITY)
{
// Look up the source's current owner
EntityMap<EntityData>::const_iterator itSource = m_EntityData.find(q.source.GetId());
if (itSource != m_EntityData.end())
{
player_id_t sourceOwner = itSource->second.owner;
if (sourceOwner != INVALID_PLAYER)
{
LosVisibility vis = GetPlayerVisibility(entity.visibilities, sourceOwner);
if (vis == LosVisibility::HIDDEN)
return false;
}
}
}
return true;
}
@ -1295,13 +1322,18 @@ public:
// Not the entire world, so check a parabolic range, or a regular range.
else if (q.parabolic)
{
// The yOrigin is part of the 3D position, as the source is really that much heigher.
// The yOrigin is part of the 3D position, as the source is really that much higher.
CmpPtr<ICmpPosition> cmpSourcePosition(q.source);
CFixedVector3D pos3d = cmpSourcePosition->GetPosition()+
CFixedVector3D(entity_pos_t::Zero(), q.yOrigin, entity_pos_t::Zero()) ;
// Get a quick list of entities that are potentially in range, with a cutoff of 2*maxRange.
CFixedVector3D pos3d = cmpSourcePosition->GetPosition() +
CFixedVector3D(entity_pos_t::Zero(), q.yOrigin, entity_pos_t::Zero());
// Get a quick list of entities that are potentially in range.
// For parabolic queries, the search radius must cover:
// 1. The baseRange circle (non-parabolic detection)
// 2. The maximum possible horizontal extent of the parabolic range
// Multiplying maxRange by 2 provides a safe upper bound for all possible height differences.
entity_pos_t subdivisionRange = std::max(q.baseRange, q.maxRange * 2);
subdivisionResultsBuffer.clear();
m_Subdivision.GetNear(subdivisionResultsBuffer, pos, q.maxRange * 2);
m_Subdivision.GetNear(subdivisionResultsBuffer, pos, subdivisionRange);
for (size_t i = 0; i < subdivisionResultsBuffer.size(); ++i)
{
@ -1311,6 +1343,20 @@ public:
if (!TestEntityQuery(q, it->first, it->second))
continue;
CFixedVector2D delta2D = CFixedVector2D(it->second.x, it->second.z) - pos;
// Check base range first
bool inBaseRange = !q.baseRange.IsZero() && delta2D.CompareLength(q.baseRange) <= 0;
if (inBaseRange)
{
// In base range - no need for parabolic check
if (q.minRange.IsZero() || delta2D.CompareLength(q.minRange) >= 0)
r.push_back(it->first);
continue;
}
// Parabolic check for entities outside base range
CmpPtr<ICmpPosition> cmpSecondPosition(GetSimContext(), subdivisionResultsBuffer[i]);
if (!cmpSecondPosition || !cmpSecondPosition->IsInWorld())
continue;
@ -1328,7 +1374,7 @@ public:
continue;
if (!q.minRange.IsZero())
if ((CFixedVector2D(it->second.x, it->second.z) - pos).CompareLength(q.minRange) < 0)
if (delta2D.CompareLength(q.minRange) < 0)
continue;
r.push_back(it->first);
@ -1536,10 +1582,13 @@ public:
if (maxRange < entity_pos_t::Zero() && maxRange != ALWAYS_IN_RANGE)
LOGWARNING("CCmpRangeManager: Invalid max range %f in query for entity %u", maxRange.ToDouble(), source);
CmpPtr<ICmpOwnership> cmpOwnership(GetSimContext(), source);
Query q;
q.enabled = false;
q.parabolic = false;
q.source = GetSimContext().GetComponentManager().LookupEntityHandle(source);
q.sourcePlayer = INVALID_PLAYER;
q.minRange = minRange;
q.maxRange = maxRange;
q.yOrigin = entity_pos_t::Zero();
@ -1580,12 +1629,13 @@ public:
}
Query ConstructParabolicQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const
{
Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flagsMask, accountForSize);
q.parabolic = true;
q.yOrigin = yOrigin;
q.baseRange = baseRange;
return q;
}

View file

@ -174,6 +174,9 @@ public:
* @param maxRange non-negative maximum distance in metres (inclusive) for units on the same elevation;
* or -1.0 to ignore distance.
* For units on a different height positions, a physical correct paraboloid with height=maxRange/2 above the unit is used to query them
* @param baseRange non-negative base detection range in metres (inclusive) for simple 2D circle checks.
* Units within this horizontal distance are always considered in range regardless of height.
* Set to 0 to disable (original parabolic-only behavior).
* @param yOrigin extra bonus so the source can be placed higher and shoot further
* @param owners list of player IDs that matching entities may have; -1 matches entities with no owner.
* @param requiredInterface if non-zero, an interface ID that matching entities must implement.
@ -181,7 +184,8 @@ public:
* NB: this one has no accountForSize parameter (assumed true), because we currently can only have 7 arguments for JS functions.
* @return unique non-zero identifier of query.
*/
virtual tag_t CreateActiveParabolicQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin,
virtual tag_t CreateActiveParabolicQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flags) = 0;

View file

@ -220,7 +220,7 @@ public:
}
}
void test_queries()
void test_range_queries_distance_only()
{
ComponentTestHelper test(*g_ScriptContext);
@ -242,8 +242,9 @@ public:
{ CMessageCreate msg(100); rangeManager->HandleMessage(msg, false); }
{ CMessageCreate msg(101); rangeManager->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(100, -1, 1); rangeManager->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 1); rangeManager->HandleMessage(msg, false); }
// Don't set ownership for either entity - leave both as INVALID_PLAYER.
// This bypasses the visibility check in TestEntityQuery, allowing us to test
// the core distance calculation logic independently of the LOS system.
auto move = [&rangeManager](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z);
@ -253,45 +254,111 @@ public:
move(100, position, fixed::FromInt(10), fixed::FromInt(10));
move(101, position2, fixed::FromInt(10), fixed::FromInt(20));
std::vector<entity_id_t> nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
// Query for owner -1 (INVALID_PLAYER) since both entities have no owner
std::vector<entity_id_t> nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
move(101, position2, fixed::FromInt(10), fixed::FromInt(10));
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(13));
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(15));
// In range thanks to self obstruction size.
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
// In range thanks to target obstruction size.
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
// Trickier: min-range is closest-to-closest, but rotation may change the real distance.
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
}
void test_range_queries_visibility_filtering()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision, vision2;
MockPositionRgm position, position2;
MockObstructionRgm obs(fixed::FromInt(2)), obs2(fixed::Zero());
test.AddMock(100, IID_Vision, vision);
test.AddMock(100, IID_Position, position);
test.AddMock(100, IID_Obstruction, obs);
test.AddMock(101, IID_Vision, vision2);
test.AddMock(101, IID_Position, position2);
test.AddMock(101, IID_Obstruction, obs2);
rangeManager->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
rangeManager->Verify();
{ CMessageCreate msg(100); rangeManager->HandleMessage(msg, false); }
{ CMessageCreate msg(101); rangeManager->HandleMessage(msg, false); }
// Set ownership for both entities so they have proper owners
{ CMessageOwnershipChanged msg(100, -1, 1); rangeManager->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 1); rangeManager->HandleMessage(msg, false); }
auto move = [&rangeManager](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z);
{ CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
};
move(100, position, fixed::FromInt(10), fixed::FromInt(10));
move(101, position2, fixed::FromInt(10), fixed::FromInt(15));
// Note: Full LOS testing (vision range, terrain, fog) requires a full game world
// with terrain, water, and proper pathfinding. That's beyond the scope of this
// unit test. The visibility test here verifies that ExecuteQuery respects the
// reveal whole map flag, which exercises the visibility check path in TestEntityQuery.
// Enable "reveal whole map" to force all entities to be visible
rangeManager->SetLosRevealWholeMap(1, true);
// Process an update
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
// Entity 101 should be visible (due to reveal map) and in range
std::vector<entity_id_t> nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
// Disable "reveal whole map" to test hidden entities
rangeManager->SetLosRevealWholeMap(1, false);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
// Entity 101 should now be hidden because LOS isn't properly set up
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
// Re-enable reveal map to show it works again
rangeManager->SetLosRevealWholeMap(1, true);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
}
void test_ParabolicRangeBasic()
{
ComponentTestHelper test(*g_ScriptContext);