Combat: FIX missile splash over-application -- once per salvo, not per round (task #62)

Live regression: a clustered bystander died in ~2 missile salvos ("suddenly
lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per
trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst =
missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying
BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1,
each floored at 1 -- so the distance floor was applied N times = ~missileCount x
too much splash.

Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher::
FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round;
the contact + world-impact splash hooks fire only when splashBurst>0, using it as
baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the
single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so
the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher;
splash_burst defaults 0).

Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6
splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to
direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not
splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander
within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash].
KB: combat-damage.md documents the N-round cluster trap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 19:43:50 -05:00
co-authored by Claude Opus 4.8
parent 12fbc023a8
commit 145a69f865
5 changed files with 139 additions and 70 deletions
+82 -50
View File
@@ -937,79 +937,111 @@ void
if (enemy_res)
{
enemy_res->Lock();
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
// Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
// sits within a missile's SplashRadius (30) of the primary -- the rig for
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
// collateral. Clamped to a sane range.
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
if (enemyCount > 8) enemyCount = 8;
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
// faces local -Z; rotate that axis into world by the spawn orientation).
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
// the real-controls drive keeps the authentic spawn orientation, so the
// dummy must follow the actual facing or the auto-walk leaves it behind.
Origin enemy_origin = mech_location;
Origin base_origin = mech_location;
UnitVector spawnZ;
{
AffineMatrix facing;
facing.BuildIdentity();
facing = mech_location.angularPosition; // rotation from the spawn pose
UnitVector spawnZ;
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
enemy_origin.linearPosition.y -= spawnZ.y * 120.0f;
enemy_origin.linearPosition.z -= spawnZ.z * 120.0f;
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
// the same way (the player stared at the enemy's back). Flip the
// enemy's yaw 180 deg about Y so the two face each other. Engine
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
enemy_origin.angularPosition =
base_origin.angularPosition =
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
}
Mech::MakeMessage
create_enemy(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
host_manager->MakeUniqueEntityID(),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
enemy_res->resourceID,
Mech::DefaultFlags,
enemy_origin,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(),
playerMission->GetColorName(),
((BTMission *)playerMission)->GetPatchName());
enemy_res->Unlock();
Mech *enemy = Mech::Make(&create_enemy);
if (enemy)
// Right vector in the ground plane (perp to the forward XZ): cluster the
// extra dummies sideways so a hit on the primary catches the bystander.
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
{
Register_Object(enemy);
// Mark it a VALID master so Entity::Dispatch delivers messages
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
// "entity invalid" events that never fire -- otherwise TakeDamage never
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
// handshake; a manually-spawned entity must set it itself. Its resources
// are already loaded -- it renders + its zones are built.) Also force
// pre-run + interest so it ticks like a live entity.
enemy->SetValidFlag();
enemy->SetPreRunFlag();
if (enemy->interestCount == 0) enemy->interestCount = 1;
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
// for GetCurrentCollisions (the engine iterates it unchecked,
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
// (btl4app.cpp:591); this dummy is a master too, so start its own
// -- without it the authentic ground block skips its collision half.
enemy->StartCollisionAssistant();
gEnemyMech = enemy; // the player's targeting step locks onto this
DEBUG_STREAM << "[enemy] spawned target mech at ("
<< enemy_origin.linearPosition.x << ", "
<< enemy_origin.linearPosition.y << ", "
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
else { rightv.x /= rl; rightv.z /= rl; }
}
else
for (int ei = 0; ei < enemyCount; ++ei)
{
DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush;
Origin enemy_origin = base_origin;
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
Scalar off = (ei == 0) ? 0.0f
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
enemy_origin.linearPosition.x += rightv.x * off;
enemy_origin.linearPosition.z += rightv.z * off;
Mech::MakeMessage
create_enemy(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
host_manager->MakeUniqueEntityID(),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
enemyResID,
Mech::DefaultFlags,
enemy_origin,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(),
playerMission->GetColorName(),
((BTMission *)playerMission)->GetPatchName());
Mech *enemy = Mech::Make(&create_enemy);
if (enemy)
{
Register_Object(enemy);
// Mark it a VALID master so Entity::Dispatch delivers messages
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
// "entity invalid" events that never fire -- otherwise TakeDamage never
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
// handshake; a manually-spawned entity must set it itself. Its resources
// are already loaded -- it renders + its zones are built.) Also force
// pre-run + interest so it ticks like a live entity.
enemy->SetValidFlag();
enemy->SetPreRunFlag();
if (enemy->interestCount == 0) enemy->interestCount = 1;
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
// for GetCurrentCollisions (the engine iterates it unchecked,
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
// (btl4app.cpp:591); this dummy is a master too, so start its own
// -- without it the authentic ground block skips its collision half.
enemy->StartCollisionAssistant();
if (ei == 0)
gEnemyMech = enemy; // the player's targeting step locks onto the primary
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
<< " id=" << enemy->GetEntityID()
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
<< " at ("
<< enemy_origin.linearPosition.x << ", "
<< enemy_origin.linearPosition.y << ", "
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
}
else
{
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
}
}
}
else