Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
|
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Reflection;
|
|
|
|
|
using CultMath;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using UniRx;
|
|
|
|
|
using Xunit;
|
|
|
|
|
using static CultMath.math;
|
|
|
|
|
using float2 = CultMath.float2;
|
|
|
|
|
using float3 = CultMath.float3;
|
|
|
|
|
using int2 = CultMath.int2;
|
|
|
|
|
|
|
|
|
|
// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a
|
|
|
|
|
// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a
|
|
|
|
|
// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every
|
|
|
|
|
// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon.
|
|
|
|
|
public sealed class FireControlPerWeaponTests : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N"));
|
|
|
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
|
|
|
|
|
|
public FireControlPerWeaponTests() => Directory.CreateDirectory(_root);
|
|
|
|
|
|
|
|
|
|
private readonly List<CultCache> _openCaches = new List<CultCache>();
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
foreach (var c in _openCaches) c.Dispose();
|
|
|
|
|
Directory.Delete(_root, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static GameplaySettings TestSettings(float minHit) => new GameplaySettings
|
|
|
|
|
{
|
|
|
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
|
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
|
|
|
|
QualityPriceModifier = new ExponentialLerp(),
|
|
|
|
|
TargetDetectionInfoThreshold = .1f,
|
|
|
|
|
TargetArmorInfoThreshold = .2f,
|
|
|
|
|
TargetGearInfoThreshold = .8f,
|
|
|
|
|
FiringArc = 170,
|
|
|
|
|
CommitHorizon = .5f,
|
|
|
|
|
SchematicCellSize = 2f,
|
|
|
|
|
UnaidedAccuracy = .05f,
|
|
|
|
|
UnaidedTracking = 10f,
|
|
|
|
|
UnaidedPrecision = 1000f,
|
|
|
|
|
AgentMinHitProbability = minHit,
|
|
|
|
|
BeamResolveInterval = .1f
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
|
|
|
|
|
|
|
|
|
private static Shape SolidShape(int w, int h)
|
|
|
|
|
{
|
|
|
|
|
var shape = new Shape(w, h);
|
|
|
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
|
|
|
return shape;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class Gun
|
|
|
|
|
{
|
|
|
|
|
public EquippedItem Item;
|
|
|
|
|
public Weapon Weapon;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class Rig
|
|
|
|
|
{
|
|
|
|
|
public Zone Zone;
|
|
|
|
|
public Ship Shooter;
|
|
|
|
|
public Ship Target;
|
|
|
|
|
public List<Gun> Guns = new List<Gun>();
|
|
|
|
|
public Gun Gun => Guns[0];
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
public Gun Decoy;
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170
|
|
|
|
|
// degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30
|
|
|
|
|
// arms at 30, so a Range of 29.5 refuses and 40 fires.
|
|
|
|
|
private Rig Build(
|
|
|
|
|
(ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f,
|
|
|
|
|
float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false,
|
2026-09-29 21:16:52 +02:00
|
|
|
bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false)
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
{
|
|
|
|
|
mounts ??= new[] { (ItemRotation.None, 0f) };
|
|
|
|
|
var hullData = new HullData
|
|
|
|
|
{
|
|
|
|
|
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0,
|
|
|
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
|
|
|
|
|
};
|
|
|
|
|
var shooterHullData = new HullData
|
|
|
|
|
{
|
|
|
|
|
Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000
|
|
|
|
|
};
|
|
|
|
|
for (var i = 0; i < mounts.Length; i++)
|
|
|
|
|
shooterHullData.Hardpoints.Add(new HardpointData
|
|
|
|
|
{
|
|
|
|
|
Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(),
|
|
|
|
|
Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc
|
|
|
|
|
});
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
// A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead
|
|
|
|
|
// and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused.
|
|
|
|
|
if (decoy)
|
|
|
|
|
shooterHullData.Hardpoints.Add(new HardpointData
|
|
|
|
|
{
|
|
|
|
|
Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(),
|
|
|
|
|
Rotation = ItemRotation.None, FiringArc = 0f
|
|
|
|
|
});
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
|
|
|
|
|
WeaponData behavior;
|
|
|
|
|
if (beam)
|
|
|
|
|
behavior = new ConstantWeaponData();
|
|
|
|
|
else if (lockWeapon)
|
|
|
|
|
behavior = new LockWeaponData
|
|
|
|
|
{
|
|
|
|
|
LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1),
|
|
|
|
|
Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1)
|
|
|
|
|
};
|
2026-09-29 21:16:52 +02:00
|
|
|
else if (charged)
|
|
|
|
|
behavior = new ChargedWeaponData
|
|
|
|
|
{
|
|
|
|
|
Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst,
|
|
|
|
|
ChargeTime = Constant(1), ChargeEnergy = Constant(0), ChargeHeat = Constant(5000), CanFireEarly = true
|
|
|
|
|
};
|
|
|
|
|
else if (auto)
|
|
|
|
|
behavior = new AutoWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst };
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
else
|
|
|
|
|
behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst };
|
|
|
|
|
behavior.Damage = Constant(100);
|
|
|
|
|
behavior.Range = Constant(range);
|
|
|
|
|
behavior.MinRange = Constant(0);
|
|
|
|
|
behavior.Velocity = Constant(0);
|
|
|
|
|
behavior.Spread = Constant(0);
|
|
|
|
|
behavior.DamageSpread = Constant(0);
|
|
|
|
|
behavior.Penetration = Constant(0);
|
|
|
|
|
behavior.Energy = Constant(energy);
|
|
|
|
|
behavior.Heat = Constant(heat);
|
|
|
|
|
behavior.Visibility = Constant(visibility);
|
|
|
|
|
behavior.MagazineSize = magazine;
|
|
|
|
|
behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
|
|
|
|
|
|
|
|
|
|
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
|
|
|
|
|
_openCaches.Add(cache);
|
2026-10-10 07:18:00 +02:00
|
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
cache.Upsert(hullData);
|
|
|
|
|
cache.Upsert(shooterHullData);
|
|
|
|
|
cache.Upsert(new WeaponItemData
|
|
|
|
|
{
|
|
|
|
|
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
|
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
|
|
|
Fuse = fuse, BlastRadius = blast,
|
|
|
|
|
Behaviors = { behavior }
|
|
|
|
|
});
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
cache.Upsert(new WeaponItemData
|
|
|
|
|
{
|
|
|
|
|
Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
|
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
|
|
|
Behaviors =
|
|
|
|
|
{
|
|
|
|
|
new InstantWeaponData
|
|
|
|
|
{
|
|
|
|
|
Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100),
|
|
|
|
|
MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0),
|
|
|
|
|
Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0),
|
|
|
|
|
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
cache.Upsert(new GearData
|
|
|
|
|
{
|
|
|
|
|
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
|
|
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
|
|
|
Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } }
|
|
|
|
|
});
|
|
|
|
|
cache.Upsert(new GearData
|
|
|
|
|
{
|
|
|
|
|
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
|
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
|
|
|
Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
|
|
|
|
|
});
|
|
|
|
|
cache.Upsert(new GearData
|
|
|
|
|
{
|
|
|
|
|
Name = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
|
|
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
|
|
|
Behaviors = { new TurretControllerData() }
|
|
|
|
|
});
|
|
|
|
|
cache.FlushAsync().Wait();
|
|
|
|
|
|
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
|
|
|
for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
|
|
|
var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { });
|
|
|
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null);
|
|
|
|
|
|
|
|
|
|
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
|
|
|
|
|
{
|
|
|
|
|
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<ItemData>(name)), Durability = durability, Lot = lot
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var lot = 1;
|
|
|
|
|
var shooterHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("ShooterHull"));
|
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
|
|
|
var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings());
|
|
|
|
|
var rig = new Rig();
|
|
|
|
|
for (var i = 0; i < mounts.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
var gun = Make("Gun", lot++, 1);
|
|
|
|
|
Assert.True(shooter.TryEquip(gun, new int2(i, 0)));
|
|
|
|
|
var item = shooter.Equipment.Single(x => x.EquippableItem == gun);
|
|
|
|
|
rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) });
|
|
|
|
|
}
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
if (decoy)
|
|
|
|
|
{
|
|
|
|
|
var d = Make("Decoy", lot++, 1);
|
|
|
|
|
Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0)));
|
|
|
|
|
var item = shooter.Equipment.Single(x => x.EquippableItem == d);
|
|
|
|
|
rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) };
|
|
|
|
|
}
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
var targeting = Make("Targeting", lot++, 1);
|
|
|
|
|
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
|
|
|
|
Assert.True(shooter.TryEquip(targeting, tpos));
|
|
|
|
|
if (true)
|
|
|
|
|
{
|
|
|
|
|
var reactor = Make("Reactor", lot++, 10);
|
|
|
|
|
Assert.True(shooter.TryFindSpace(reactor, out var rpos));
|
|
|
|
|
Assert.True(shooter.TryEquip(reactor, rpos));
|
|
|
|
|
}
|
|
|
|
|
if (turret)
|
|
|
|
|
{
|
|
|
|
|
var t = Make("Turret", lot++, 1);
|
|
|
|
|
Assert.True(shooter.TryFindSpace(t, out var upos));
|
|
|
|
|
Assert.True(shooter.TryEquip(t, upos));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings());
|
|
|
|
|
Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0)));
|
|
|
|
|
|
|
|
|
|
zone.Entities.Add(shooter);
|
|
|
|
|
zone.Entities.Add(target);
|
|
|
|
|
shooter.Activate();
|
|
|
|
|
target.Activate();
|
|
|
|
|
|
|
|
|
|
shooter.Position = float3(-53, 0, -101);
|
|
|
|
|
target.Position = shooter.Position + float3(0, 0, targetRange);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
shooter.SetTarget(target);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
shooter.EntityInfoGathered[target] = 1f;
|
|
|
|
|
shooter.SetIff(target, true);
|
|
|
|
|
|
|
|
|
|
zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
rig.Zone = zone;
|
|
|
|
|
rig.Shooter = shooter;
|
|
|
|
|
rig.Target = target;
|
|
|
|
|
return rig;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 12:33:20 +02:00
|
|
|
private static void Aim(Rig r, float2 direction) => r.Shooter.Aim = float3(direction.x, 0, direction.y);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
|
|
|
|
|
private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) };
|
|
|
|
|
private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) };
|
|
|
|
|
|
|
|
|
|
// ==== Ruling 1: per weapon, and a fused weapon needs a designated target ====
|
|
|
|
|
|
|
|
|
|
// A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot
|
|
|
|
|
// bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its
|
|
|
|
|
// first weapon (Combat's old `testWeapon`), which silences the group.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CombatDecidesEachWeaponOfAGroupForItself()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(SideThenForward);
|
|
|
|
|
var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons;
|
|
|
|
|
Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group");
|
|
|
|
|
r.Zone.Agents.Add(new Minion(r.Shooter));
|
|
|
|
|
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear");
|
|
|
|
|
Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires
|
|
|
|
|
// both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f);
|
|
|
|
|
r.Zone.Agents.Add(new Minion(r.Shooter));
|
|
|
|
|
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no");
|
|
|
|
|
Assert.True(r.Guns[1].Weapon.Firing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own
|
|
|
|
|
// range test, for a fused weapon.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true);
|
|
|
|
|
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked }
|
|
|
|
|
|
|
|
|
|
// The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one
|
|
|
|
|
// an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on
|
|
|
|
|
// the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included).
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(Withheld.NoTarget)]
|
|
|
|
|
[InlineData(Withheld.OutOfRange)]
|
|
|
|
|
[InlineData(Withheld.NotVisible)]
|
|
|
|
|
[InlineData(Withheld.Unlocked)]
|
|
|
|
|
public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f,
|
|
|
|
|
targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked);
|
|
|
|
|
var target = r.Target;
|
|
|
|
|
if (reason == Withheld.NoTarget) target = null;
|
|
|
|
|
if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target);
|
|
|
|
|
|
|
|
|
|
Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance()
|
|
|
|
|
{
|
|
|
|
|
// Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved.
|
|
|
|
|
var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f);
|
|
|
|
|
|
|
|
|
|
Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon.
|
|
|
|
|
// Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one.
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(.5f, .2f, true)]
|
|
|
|
|
[InlineData(.5f, .9f, false)]
|
|
|
|
|
public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(accuracy: accuracy, minHit: minHit);
|
|
|
|
|
var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target);
|
|
|
|
|
Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==== Ruling 2: the HUD forecast is Fire's own decision ====
|
|
|
|
|
|
|
|
|
|
// Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0,
|
|
|
|
|
// and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming
|
|
|
|
|
// distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a
|
|
|
|
|
// Range that cannot reach the arming distance, and a weapon without a fuse.
|
|
|
|
|
// Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp).
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)]
|
|
|
|
|
[InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)]
|
|
|
|
|
[InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)]
|
|
|
|
|
[InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)]
|
|
|
|
|
[InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)]
|
|
|
|
|
[InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)]
|
|
|
|
|
public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
if (noTarget) r.Shooter.SetTarget(TargetRef.None);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value.Entity);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(outcome, d.Outcome);
|
|
|
|
|
Assert.Equal(outcome != FireOutcome.Refused, shotId != 0);
|
|
|
|
|
if (outcome == FireOutcome.Refused) return;
|
|
|
|
|
Assert.Equal(reach, d.BurstReach, 2);
|
|
|
|
|
var shot = SafeAssert.OnlyShot(r.Zone);
|
|
|
|
|
Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AWeaponWithoutAFuseForecastsADirectHit()
|
|
|
|
|
{
|
|
|
|
|
var r = Build();
|
|
|
|
|
|
|
|
|
|
var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(FireOutcome.Direct, d.Outcome);
|
|
|
|
|
Assert.True(d.PBase > 0f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==== Ruling 3: a refused round is free ====
|
|
|
|
|
|
|
|
|
|
private sealed class Costs
|
|
|
|
|
{
|
|
|
|
|
public int Ammo;
|
|
|
|
|
public float ChargeSpent;
|
|
|
|
|
public int Sounds;
|
|
|
|
|
public int Wears;
|
|
|
|
|
public float Heat;
|
|
|
|
|
public bool Visible;
|
|
|
|
|
public int Announced;
|
|
|
|
|
public int Shots;
|
2026-09-29 21:16:52 +02:00
|
|
|
public bool CanFire;
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge,
|
|
|
|
|
// a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires.
|
2026-09-29 21:16:52 +02:00
|
|
|
private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false)
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
{
|
2026-09-29 21:16:52 +02:00
|
|
|
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (InstantWeapon) r.Gun.Weapon;
|
|
|
|
|
var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon);
|
|
|
|
|
r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } };
|
|
|
|
|
var costs = new Costs();
|
|
|
|
|
r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++);
|
|
|
|
|
r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick
|
|
|
|
|
r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; });
|
|
|
|
|
weapon.OnFire += _ => costs.Announced++;
|
|
|
|
|
capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for
|
|
|
|
|
var chargeBefore = capacitor.Charge;
|
|
|
|
|
var heatBefore = r.Gun.Item.Temperature;
|
|
|
|
|
var ammoBefore = weapon.Ammo;
|
|
|
|
|
Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine");
|
|
|
|
|
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
// The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that
|
|
|
|
|
// has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not.
|
|
|
|
|
if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f });
|
2026-09-29 21:16:52 +02:00
|
|
|
if (auto)
|
|
|
|
|
{
|
|
|
|
|
// AutoWeapon's own trigger: a held weapon whose cooldown has run out retriggers inside Execute.
|
|
|
|
|
typeof(Weapon).GetField("_firing", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, true);
|
|
|
|
|
typeof(InstantWeapon).GetField("_cooldown", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, -1f);
|
|
|
|
|
}
|
|
|
|
|
else if (viaTrigger) weapon.Activate();
|
2026-09-29 18:51:57 +02:00
|
|
|
else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
r.Zone.Update(.01f);
|
|
|
|
|
|
|
|
|
|
costs.Ammo = ammoBefore - weapon.Ammo;
|
|
|
|
|
costs.ChargeSpent = chargeBefore - capacitor.Charge;
|
|
|
|
|
costs.Heat = r.Gun.Item.Temperature - heatBefore;
|
2026-10-11 09:13:24 +02:00
|
|
|
costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v.Strength > 1f;
|
2026-09-29 21:16:52 +02:00
|
|
|
costs.CanFire = weapon.CanFire;
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
return costs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that
|
|
|
|
|
// never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
// visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each;
|
2026-09-29 18:51:57 +02:00
|
|
|
// `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose
|
|
|
|
|
// Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself.
|
|
|
|
|
// Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an
|
|
|
|
|
// announcement, a sound, wear, heat or visibility left ahead of the `continue`.
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
[Theory]
|
2026-09-29 18:51:57 +02:00
|
|
|
[InlineData(false, true)]
|
|
|
|
|
[InlineData(true, true)]
|
|
|
|
|
[InlineData(false, false)]
|
|
|
|
|
public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger)
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
{
|
2026-09-29 18:51:57 +02:00
|
|
|
var fired = Pull(40f, single, viaTrigger);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
Assert.True(fired.Shots == 1, $"shots {fired.Shots}");
|
|
|
|
|
Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}");
|
|
|
|
|
Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy");
|
|
|
|
|
Assert.Equal(1, fired.Sounds);
|
|
|
|
|
Assert.True(fired.Wears > 0);
|
|
|
|
|
Assert.True(fired.Heat > 0f);
|
|
|
|
|
Assert.True(fired.Visible);
|
|
|
|
|
Assert.Equal(1, fired.Announced);
|
|
|
|
|
|
2026-09-29 18:51:57 +02:00
|
|
|
var refused = Pull(29.5f, single, viaTrigger);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
Assert.Equal(0, refused.Shots);
|
|
|
|
|
Assert.Equal(0, refused.Ammo);
|
|
|
|
|
Assert.Equal(0f, refused.ChargeSpent);
|
|
|
|
|
Assert.Equal(0, refused.Sounds);
|
|
|
|
|
Assert.Equal(0, refused.Wears);
|
|
|
|
|
Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})");
|
|
|
|
|
Assert.False(refused.Visible);
|
|
|
|
|
Assert.Equal(0, refused.Announced);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws
|
|
|
|
|
// nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
// that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts).
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
// Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not
|
|
|
|
|
// consulting FireControl at all.
|
|
|
|
|
private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (ConstantWeapon) r.Gun.Weapon;
|
|
|
|
|
var wears = 0;
|
|
|
|
|
var stops = 0;
|
|
|
|
|
var shots = 0;
|
|
|
|
|
r.Zone.ShotResolved.Subscribe(_ => shots++);
|
|
|
|
|
r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; });
|
|
|
|
|
weapon.OnStopFiring += () => stops++;
|
|
|
|
|
var heatBefore = r.Gun.Item.Temperature;
|
|
|
|
|
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
var request = weapon.PowerRequest(.1f);
|
|
|
|
|
for (var i = 0; i < 5; i++) r.Zone.Update(.1f);
|
|
|
|
|
|
2026-10-11 09:13:24 +02:00
|
|
|
var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v.Strength > 1f;
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ARefusedBeamCostsNothing()
|
|
|
|
|
{
|
|
|
|
|
var fired = Burn(40f);
|
|
|
|
|
Assert.True(fired.Shots > 0, "control: the beam rolls shots");
|
|
|
|
|
Assert.True(fired.Wears > 0);
|
|
|
|
|
Assert.True(fired.Request > 0f);
|
|
|
|
|
Assert.True(fired.Visible);
|
|
|
|
|
Assert.True(fired.Heat > 0f);
|
|
|
|
|
|
|
|
|
|
var refused = Burn(29.5f);
|
|
|
|
|
Assert.Equal(0, refused.Shots);
|
|
|
|
|
Assert.Equal(0, refused.Wears);
|
|
|
|
|
Assert.Equal(0f, refused.Request);
|
|
|
|
|
Assert.False(refused.Visible);
|
|
|
|
|
Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})");
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and
|
|
|
|
|
// audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control
|
|
|
|
|
// (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl.
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(40f, 1)]
|
|
|
|
|
[InlineData(29.5f, 0)]
|
|
|
|
|
public void ARefusedBeamNeverStarts(float range, int starts)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (ConstantWeapon) r.Gun.Weapon;
|
|
|
|
|
var started = 0;
|
|
|
|
|
weapon.OnStartFiring += () => started++;
|
|
|
|
|
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
for (var i = 0; i < 5; i++) r.Zone.Update(.1f);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(starts, started);
|
|
|
|
|
Assert.Equal(starts == 1, weapon.Firing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range
|
|
|
|
|
// (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is.
|
|
|
|
|
// Kills: AgentFires answering "designated" without asking whether Solve refuses.
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(29.5f, false)]
|
|
|
|
|
[InlineData(40f, true)]
|
|
|
|
|
public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a
|
|
|
|
|
// pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent.
|
|
|
|
|
// Kills: Combat counting a refused weapon's damage toward its group.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CombatDoesNotPickAGroupWhoseWeaponIsRefused()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true);
|
|
|
|
|
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group");
|
|
|
|
|
r.Zone.Agents.Add(new Minion(r.Shooter));
|
|
|
|
|
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated");
|
|
|
|
|
Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:54:36 +02:00
|
|
|
// Combat's group pick counts only weapons whose target is designated: the stronger gun's Range (10) does not reach the
|
|
|
|
|
// target (20), so its group is not picked and the decoy's, which does reach, is. Kills: Combat asking
|
|
|
|
|
// Designated nothing (a group of weapons that cannot reach the target selected).
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CombatDoesNotPickAGroupWhoseWeaponCannotReachTheTarget()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(range: 10f, targetRange: 20f, decoy: true);
|
|
|
|
|
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group");
|
|
|
|
|
r.Zone.Agents.Add(new Minion(r.Shooter));
|
|
|
|
|
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
r.Zone.Update(1f);
|
|
|
|
|
|
|
|
|
|
Assert.False(r.Gun.Weapon.Firing, "a weapon that cannot reach the target is never activated");
|
|
|
|
|
Assert.True(r.Decoy.Weapon.Firing, "the group that reaches is the one picked");
|
|
|
|
|
}
|
|
|
|
|
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
// Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old
|
|
|
|
|
// private test was strict). Kills: a range comparison of Combat's own.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CombatCountsATargetExactlyAtMaxRangeAsInReach()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(range: 60f, targetRange: 60f);
|
|
|
|
|
// The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first
|
|
|
|
|
// update leaves the root state for combat, the second is combat deciding.
|
|
|
|
|
var agent = new Minion(r.Shooter);
|
|
|
|
|
agent.Update(.01f);
|
|
|
|
|
agent.Update(.01f);
|
|
|
|
|
|
|
|
|
|
Assert.True(r.Gun.Weapon.Firing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and
|
|
|
|
|
// one priced a hair under it is not. Kills: `>` for `>=`.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AnAgentFiresAtExactlyTheHitProbabilityThreshold()
|
|
|
|
|
{
|
|
|
|
|
var probe = Build(accuracy: .5f);
|
|
|
|
|
var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target);
|
|
|
|
|
Assert.True(price > 0f);
|
|
|
|
|
|
|
|
|
|
var atThreshold = Build(accuracy: .5f, minHit: price);
|
|
|
|
|
var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price));
|
|
|
|
|
|
|
|
|
|
Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target));
|
|
|
|
|
Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A round refused on a Range that fell between the trigger and the round is still free: the burst is paid
|
|
|
|
|
// (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with.
|
2026-09-29 21:16:52 +02:00
|
|
|
// The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal
|
|
|
|
|
// starts no cooldown either (operator ruling 2026-09-30): the weapon can fire again at once, while the
|
|
|
|
|
// control (Range 40) fired and is cooling down. Covers the player's trigger and AutoWeapon's own.
|
|
|
|
|
// Kills: paying for the burst at the trigger; starting the cooldown at the trigger (Trigger or AutoWeapon).
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(true, false)]
|
|
|
|
|
[InlineData(false, false)]
|
|
|
|
|
[InlineData(true, true)]
|
|
|
|
|
[InlineData(false, true)]
|
|
|
|
|
public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing(bool single, bool auto)
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
{
|
2026-09-29 21:16:52 +02:00
|
|
|
var fired = Pull(40f, single, viaTrigger: true, staleRange: true, auto: auto);
|
|
|
|
|
Assert.Equal(1, fired.Shots);
|
|
|
|
|
Assert.False(fired.CanFire, "control: a fired burst starts the cooldown");
|
|
|
|
|
|
|
|
|
|
var refused = Pull(29.5f, single, viaTrigger: true, staleRange: true, auto: auto);
|
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
|
|
|
|
|
|
|
|
Assert.Equal(0, refused.Shots);
|
|
|
|
|
Assert.Equal(0, refused.Ammo);
|
|
|
|
|
Assert.Equal(0f, refused.ChargeSpent);
|
|
|
|
|
Assert.Equal(0, refused.Announced);
|
2026-09-29 21:16:52 +02:00
|
|
|
Assert.True(refused.CanFire, "a burst that fired nothing leaves no cooldown");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A refused charged weapon never starts charging: no charge heat, no audio, no start event, and it can be
|
|
|
|
|
// triggered again at once. The control (Range 40) charges, sounds and heats.
|
|
|
|
|
// Kills: ChargedWeapon.Activate that does not ask FireControl.
|
|
|
|
|
private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
2026-09-29 21:16:52 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (ChargedWeapon) r.Gun.Weapon;
|
2026-09-29 21:23:04 +02:00
|
|
|
r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } };
|
2026-09-29 21:16:52 +02:00
|
|
|
var starts = 0;
|
|
|
|
|
var sounds = 0;
|
|
|
|
|
weapon.OnStartCharging += () => starts++;
|
|
|
|
|
r.Gun.Item.AudioEvents.Subscribe(_ => sounds++);
|
|
|
|
|
var heatBefore = r.Gun.Item.Temperature;
|
|
|
|
|
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
for (var i = 0; i < 3; i++) r.Zone.Update(.1f);
|
|
|
|
|
|
|
|
|
|
return (starts, sounds, r.Gun.Item.Temperature - heatBefore, weapon.Progress > 0f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ARefusedChargedWeaponNeverStartsCharging()
|
|
|
|
|
{
|
|
|
|
|
var fired = Charge(40f);
|
|
|
|
|
Assert.Equal(1, fired.Starts);
|
|
|
|
|
Assert.True(fired.Sounds > 0, "control: charging sounds");
|
|
|
|
|
Assert.True(fired.Heat > 0f, "control: charging heats");
|
|
|
|
|
|
|
|
|
|
var refused = Charge(29.5f);
|
|
|
|
|
Assert.Equal(0, refused.Starts);
|
|
|
|
|
Assert.Equal(0, refused.Sounds);
|
|
|
|
|
Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})");
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
}
|
2026-09-29 21:39:18 +02:00
|
|
|
// A triggered burst has not started its cooldown, but it is not ready either: pulled again before its first round
|
|
|
|
|
// flies it would restart the burst. Kills: CanFire that reads the cooldown alone.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ATriggeredBurstIsNotReadyBeforeItsFirstRound()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(range: 100f);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
2026-09-29 21:39:18 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (InstantWeapon) r.Gun.Weapon;
|
|
|
|
|
r.Zone.Update(.01f); // resolves the gun's stats, which size the burst
|
|
|
|
|
Assert.True(weapon.CanFire);
|
|
|
|
|
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
|
|
|
|
|
Assert.False(weapon.CanFire);
|
|
|
|
|
}
|
2026-09-29 22:15:51 +02:00
|
|
|
|
|
|
|
|
// A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed
|
|
|
|
|
// again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate
|
|
|
|
|
// that reads the cooldown alone instead of CanFire.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(charged: true, range: 100f);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
2026-09-29 22:15:51 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (ChargedWeapon) r.Gun.Weapon;
|
|
|
|
|
var starts = 0;
|
|
|
|
|
weapon.OnStartCharging += () => starts++;
|
|
|
|
|
r.Zone.Update(.01f); // resolves the stats
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
for (var i = 0; i < 15; i++) r.Zone.Update(.1f);
|
|
|
|
|
Assert.Equal(1, starts);
|
|
|
|
|
|
|
|
|
|
weapon.Deactivate();
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
r.Zone.Update(.1f);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, starts);
|
|
|
|
|
Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in.
|
|
|
|
|
// Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused
|
|
|
|
|
// leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown
|
|
|
|
|
// at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst.
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData(100f, 12)]
|
|
|
|
|
[InlineData(29.5f, 0)]
|
|
|
|
|
public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots)
|
|
|
|
|
{
|
|
|
|
|
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
2026-09-29 22:15:51 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (InstantWeapon) r.Gun.Weapon;
|
|
|
|
|
var fired = 0;
|
|
|
|
|
weapon.OnFire += _ => fired++;
|
|
|
|
|
r.Zone.Update(.25f); // resolves the stats
|
|
|
|
|
var readyTicks = 0;
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < 60; i++)
|
|
|
|
|
{
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
r.Zone.Update(.25f);
|
|
|
|
|
if (weapon.CanFire) readyTicks++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.Equal(shots, fired);
|
|
|
|
|
if (shots == 0) Assert.Equal(60, readyTicks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started
|
|
|
|
|
// (and ready) before its first round has flown.
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound()
|
|
|
|
|
{
|
|
|
|
|
var r = Build(range: 100f);
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
r.Shooter.SetTarget(TargetRef.None);
|
2026-09-29 22:15:51 +02:00
|
|
|
Aim(r, float2(0, 1));
|
|
|
|
|
var weapon = (InstantWeapon) r.Gun.Weapon;
|
|
|
|
|
r.Zone.Update(.25f);
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
r.Zone.Update(.25f);
|
|
|
|
|
Assert.False(weapon.CanFire, "control: the first burst fired and is cooling");
|
|
|
|
|
for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f);
|
|
|
|
|
Assert.True(weapon.CanFire, "fixture: the cooldown expired");
|
|
|
|
|
|
|
|
|
|
weapon.Activate();
|
|
|
|
|
|
|
|
|
|
Assert.False(weapon.CanFire);
|
|
|
|
|
}
|
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
|
|
|
}
|