Merge origin/master into loot-3 (second)

This commit is contained in:
Meta Vens 2026-10-13 23:30:23 +02:00
commit b836af4850
19 changed files with 857 additions and 82 deletions

View file

@ -36,18 +36,20 @@ public class CombatState : BaseState
// Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at
// this range, or clear the aim point when nothing qualifies -- the same predicate
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses.
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
// believes the target has, and how hard each hits, come from its book's claims, not the target's live gear
// (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the
// reveal's Item is the aim and stays the truth.
EquippedItem bestTargetItem = null;
var bestTargetDps = float.MinValue;
foreach (var targetItem in target.Equipment)
foreach (var reveal in _agent.Ship.RevealedWeapons(target))
{
if (!(targetItem.Behaviors.FirstOrDefault(b => b is Weapon) is Weapon targetWeapon)) continue;
if (!FireControl.IsRevealed(_agent.Ship, targetItem)) continue;
var dps = targetWeapon.RangeDamagePerSecond(targetDistance);
if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue;
var dps = reveal.Claimed.Behaviors.OfType<WeaponData>().First().RatedRangeDamagePerSecond(targetDistance);
if (dps > bestTargetDps)
{
bestTargetDps = dps;
bestTargetItem = targetItem;
bestTargetItem = reveal.Item;
}
}
_agent.Ship.TrySelectTargetItem(bestTargetItem);

View file

@ -54,6 +54,15 @@ public class ChargedWeaponData : InstantWeaponData
{
return new ChargedWeapon(this, item);
}
// The one formula of a charged weapon's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond).
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float chargeFiringDamageMultiplier,
float minRange, float range, float cooldown, float chargeTime, float atRange) =>
damage * chargeFiringDamageMultiplier * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / (cooldown + chargeTime);
public override float RatedRangeDamagePerSecond(float range) =>
RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), ChargeFiringDamageMultiplier, MinRange.Max, Range.Max,
Cooldown.Max, ChargeTime.Max, range);
}
public class ChargedWeapon : InstantWeapon
@ -75,13 +84,14 @@ public class ChargedWeapon : InstantWeapon
// authored charge-up speed IS its sustained rate of fire, not a second, disconnected number.
protected override float RateOverride => Evaluate(_data.ChargeEnergy);
public override float DamagePerSecond => Damage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime);
// The charge multiplier lives in the formula alone: Damage is lerped by charge for the shot itself (UpdateStats), so the
// live reads take the uncharged damage, and a nominal weapon reads its design's rated figure at every charge.
private float _unchargedDamage;
public override float DamagePerSecond => _unchargedDamage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime);
public override float RangeDamagePerSecond(float range)
{
return Damage *
_data.ChargeFiringDamageMultiplier *
_data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range))) /
(Cooldown + ChargeTime);
return ChargedWeaponData.RangeDamagePerSecond(_data.DamageCurve, _unchargedDamage, _data.ChargeFiringDamageMultiplier,
MinRange, Range, Cooldown, ChargeTime, range);
}
public event Action OnStartCharging;
@ -121,6 +131,7 @@ public class ChargedWeapon : InstantWeapon
ChargeTime = Evaluate(_data.ChargeTime);
ChargeEnergy = Evaluate(_data.ChargeEnergy);
ChargeHeat = Evaluate(_data.ChargeHeat);
_unchargedDamage = Damage;
Damage *= lerp(1, _data.ChargeFiringDamageMultiplier, saturate(_charge));
Heat *= lerp(1, _data.ChargeFiringHeatMultiplier, saturate(_charge));
Spread *= lerp(1, _data.ChargeFiringSpreadMultiplier, saturate(_charge));

View file

@ -23,6 +23,13 @@ public class ConstantWeaponData : WeaponData
{
return new ConstantWeapon(this, item);
}
// The one formula of a beam's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond).
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float atRange) =>
damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange)));
public override float RatedRangeDamagePerSecond(float range) =>
RangeDamagePerSecond(DamageCurve, Damage.Max, MinRange.Max, Range.Max, range);
}
public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer
@ -49,7 +56,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
public override float DamagePerSecond => Damage;
public override float RangeDamagePerSecond(float range)
{
return Damage * _data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range)));
return ConstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, range);
}
public event Action OnReloadBegin;

View file

@ -30,6 +30,17 @@ public class InstantWeaponData : WeaponData
{
return new InstantWeapon(this, item);
}
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
// stats, the design with its rated ones. A burst's round carries its share of the totals: PerRound, the one divisor
// UpdateStats and the rated read both use.
internal static float PerRound(float total, float burstCount) => total / (int) burstCount;
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float cooldown, float atRange) =>
damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / cooldown;
public override float RatedRangeDamagePerSecond(float range) =>
RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), MinRange.Max, Range.Max, Cooldown.Max, range);
}
public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer
@ -61,7 +72,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
public override float DamagePerSecond => Damage / Cooldown;
public override float RangeDamagePerSecond(float range)
{
return Damage * _data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range))) / Cooldown;
return InstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, Cooldown, range);
}
public override int Ammo
@ -129,9 +140,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
BurstTime = Evaluate(_data.BurstTime);
Cooldown = Evaluate(_data.Cooldown);
Damage /= (int) BurstCount;
Heat /= (int) BurstCount;
Energy /= (int) BurstCount;
Damage = InstantWeaponData.PerRound(Damage, BurstCount);
Heat = InstantWeaponData.PerRound(Heat, BurstCount);
Energy = InstantWeaponData.PerRound(Energy, BurstCount);
}
// Cut 4 (docs/stats-and-power-cut.md §7 Q4): capacity and rate, resolved fresh rather than off the cached

View file

@ -71,6 +71,13 @@ public abstract class WeaponData : BehaviorData
// weapon-tracking-authored). Unauthored is a mount that follows anything: FireControl.PMount reads +infinity as 1.
[Inspectable, JsonProperty("tracking"), Key(33), RuntimeInspectable]
public PerformanceStat Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity };
// What an observer who believes this is the design measures it by (aetheria-ew, ew-claim-weapon-measure-ruled): the
// design's rated figures, each stat's Max (what a stat with no terms resolves to), through the same formula the
// live weapon's RangeDamagePerSecond runs over its evaluated stats. Never the carrier's quality, heat, wear or power.
public abstract float RatedRangeDamagePerSecond(float range);
[IgnoreMember]
public float RatedRange => Range.Max;
}
public abstract class Weapon : Behavior, IActivatedBehavior

View file

@ -103,7 +103,27 @@ public abstract class Entity
// each equipped suppressor folds the strength in equipment order and pays for what it alone removed.
foreach (var suppressor in GetBehaviors<LaunchSuppressor>())
strength = suppressor.Suppress(channel, strength);
_visibilitySources[source] = new Signature(channel, strength, item);
_visibilitySources[source] = new Signature(channel, strength, item, ClaimOf(item));
}
// What this ship's acts claim (aetheria-ew, lie-is-own-emission, classify-from-belief): per equipped item, the
// equippable class every act of that item is stamped with (SetSignature, Emit, ReceivePaint). The hull is the
// item EquippedHull, so its claim lives in the same slot as every other: the hull class its own acts carry and
// the profile an observer derives (ClaimedHull) are one value. The truth unless claimed otherwise. Claim is the
// one write port; its production writer is the EW behaviour of cut ew-masquerade, tests only until then.
// Deactivate clears the slots. Aim, damage and physics keep the truth.
private readonly Dictionary<EquippedItem, EquippableItemData> _itemClaims = new Dictionary<EquippedItem, EquippableItemData>();
public HullData ClaimedHull => (ClaimOf(EquippedHull) as HullData) ?? HullData;
public EquippableItemData ClaimOf(EquippedItem item) =>
item == null ? null : _itemClaims.TryGetValue(item, out var claimed) ? claimed : item.Data;
internal void Claim(HullData hull) => Claim(EquippedHull, hull);
internal void Claim(EquippedItem item, EquippableItemData claimed)
{
// One hull class in the book: a hull class is claimed by the hull and only by the hull, whichever side asks.
if (claimed != null && (item == EquippedHull) != (claimed is HullData))
throw new ArgumentException("Only the hull claims a hull class, and the hull claims nothing else.", nameof(claimed));
if (claimed == null) _itemClaims.Remove(item);
else _itemClaims[item] = claimed;
}
public readonly ReactiveDictionary<Entity, float> EntityInfoGathered = new ReactiveDictionary<Entity, float>();
@ -868,6 +888,7 @@ public abstract class Entity
_pingReturns.Clear();
_emissions.Clear();
_reveals.Clear();
_itemClaims.Clear();
}
// Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published
@ -880,7 +901,7 @@ public abstract class Entity
foreach (var paint in _paintInbox)
{
_incomingPaints.Add(paint.Warning);
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission);
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission, paint.Warning.EmitterClass);
}
_paintInbox.Clear();
_pingReturns.Clear();
@ -900,16 +921,17 @@ public abstract class Entity
internal void Emit(EquippedItem item, float hearRange)
{
if (!_active) return;
_emissions.Add(new ActiveEmission(item, hearRange));
_emissions.Add(new ActiveEmission(item, hearRange, ClaimOf(item)));
}
// The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength
// the emitter's own Lock at that paint). The emitter's class is the item's data (null with no item: a
// consumable's effect, which has no item and so is never revealed).
// the emitter's own Lock at that paint). The emitter's class is the class its ship claims for the item (ClaimOf: the
// item's data unless claimed otherwise; null with no item: a consumable's effect, which has no item and so is never
// revealed).
internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength)
{
if (!_active) return;
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item));
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), emitter.ClaimOf(item), mode, strength), emitter, item));
}
// The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file):
@ -917,13 +939,14 @@ public abstract class Entity
// radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed
// subject's entries are never created or refreshed, from any caller, and neither does an item this observer
// perceives destroyed (PerceivesDestroyed). A later act of the same item replaces the
// earlier one.
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel)
// earlier one. `claimed` is the class the carrier (signature, emission or paint) stamped: what this observer's
// classifying readers will believe of the act; `item` stays the aim and book key.
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel, EquippableItemData claimed)
{
if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return;
if (!_reveals.TryGetValue(subject, out var book))
_reveals[subject] = book = new Dictionary<EquippedItem, Reveal>();
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time);
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed);
}
// This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule
@ -955,6 +978,31 @@ public abstract class Entity
public IEnumerable<Reveal> RevealsOf(Entity subject) =>
_reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty<Reveal>();
// The weapons this observer believes `subject` has: each reveal whose claimed class carries a weapon behaviour, in the
// subject's equipment order (never the book's slot order, which follows when reveals were made and ended). The one
// classifying read of the book for weapons, shared by the enemy schematic and Combat (aetheria-ew, classify-from-belief);
// a gun claimed as a non-weapon is not listed, though RevealsOf holds it for aim.
public IEnumerable<Reveal> RevealedWeapons(Entity subject) =>
RevealsOf(subject).Where(r => r.Claimed != null && r.Claimed.Behaviors.Any(b => b is WeaponData))
.OrderBy(r => FittedIndex(subject, r.Item)).ThenBy(r => r.Item.EquippableItem.Lot);
// A revealed item's place in the subject's equipment; gear the subject no longer carries follows all fitted gear
// (ties among it fall to the lot, a stable id), never ahead of it.
private static int FittedIndex(Entity subject, EquippedItem item)
{
var index = subject.Equipment.IndexOf(item);
return index < 0 ? int.MaxValue : index;
}
// The weapon rows of `subject`'s schematic as this ship reads it: its own equipment when `subject` is this ship (each
// row its own data and live range, nothing to doubt about your own ship), otherwise RevealedWeapons (each row the
// claimed class and that design's rated range). The one owner of which rows, which class and which range an
// enemy schematic shows; the Unity SchematicDisplay renders it.
public IEnumerable<SchematicRow> SchematicRows(Entity subject) =>
subject == this
? subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true))
: RevealedWeapons(subject).Select(r => new SchematicRow(r.Item, r.Claimed, false));
// Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read.
internal bool HasReveal(Entity subject, EquippedItem item) =>
_reveals.TryGetValue(subject, out var book) && book.ContainsKey(item);
@ -976,7 +1024,9 @@ public abstract class Entity
var remembered = any && Zone.Time - freshest.Time < settings.BearingMemory;
var detected = tracked || remembered;
var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default;
return new Contact(detected, tracked, detected && any, info >= settings.TargetGearInfoThreshold, bearing, info);
var identified = info >= settings.TargetGearInfoThreshold;
// Profile is the one derivation of the hull this observer believes (no stored belief until the judge, ew-masquerade).
return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null);
}
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every
@ -1000,7 +1050,7 @@ public abstract class Entity
{
if (signature.Item == null) continue;
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel, signature.Claimed);
}
// Every emission this ship made this tick that reaches this observer is revealed, bearing only (ruling
// sensor-stat-set), through RecordReveal's one gate. It feeds no track.
@ -1011,7 +1061,7 @@ public abstract class Entity
for (var j = 0; j < other._emissions.Count; j++)
{
var emission = other._emissions[j];
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission);
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission, emission.Claimed);
}
}
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and

View file

@ -2,6 +2,7 @@
* 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.Linq;
using CultMath;
// Light signatures split by channel (ruling visibility-per-capability-reveal): an observer learns what a ship
@ -20,15 +21,19 @@ public readonly struct Signature
public readonly SignatureChannel Channel;
public readonly float Strength;
public readonly EquippedItem Item;
// The class this act claims: the true item's Data unless its ship's EW claims otherwise (aetheria-ew,
// classify-from-belief). Stamped by Entity.SetSignature from the ship's claim.
public readonly EquippableItemData Claimed;
public Signature(SignatureChannel channel, float strength, EquippedItem item)
public Signature(SignatureChannel channel, float strength, EquippedItem item, EquippableItemData claimed)
{
Channel = channel;
Strength = strength;
Item = item;
Claimed = claimed;
}
public Signature WithStrength(float strength) => new Signature(Channel, strength, Item);
public Signature WithStrength(float strength) => new Signature(Channel, strength, Item, Claimed);
}
// What a painted ship learns of a beam on it (ruling sensor-stat-set item 4): a Search paint is a ping sweeping
@ -64,11 +69,14 @@ public readonly struct ActiveEmission
{
public readonly EquippedItem Item;
public readonly float HearRange;
// The class this emission claims (see Signature.Claimed); stamped by Entity.Emit.
public readonly EquippableItemData Claimed;
public ActiveEmission(EquippedItem item, float hearRange)
public ActiveEmission(EquippedItem item, float hearRange, EquippableItemData claimed)
{
Item = item;
HearRange = hearRange;
Claimed = claimed;
}
}
@ -90,23 +98,53 @@ public readonly struct PaintWarning
// What an observer has seen `Item` do (ruling visibility-per-capability-reveal): the channel it showed on, the
// bearing at that act and the zone clock then. Held per (subject, item) in the observer's Entity; the latest act
// replaces the earlier one. Position is never carried: only the track gives it.
// replaces the earlier one. Position is never carried: only the track gives it. `Item` stays the true item: it is the
// book's key and what aim reads. `Claimed` is the class the act claimed, and is what every classifying reader reads
// (aetheria-ew, classify-from-belief).
public readonly struct Reveal
{
public readonly EquippedItem Item;
public readonly EquippableItemData Claimed;
public readonly SignatureChannel Channel;
public readonly float2 Bearing;
public readonly float Time;
public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time)
public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time, EquippableItemData claimed)
{
Item = item;
Claimed = claimed;
Channel = channel;
Bearing = bearing;
Time = time;
}
}
// One weapon row of a ship's schematic as its viewer is entitled to read it (aetheria-ew, classify-from-belief; ruling
// ew-claim-weapon-measure-ruled). Made only by Entity.SchematicRows. `Item` is the true item: the selection mark's key and,
// on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on
// the viewer's own ship and the claimed design's rated range on any other; the viewer never sees a target weapon's
// quality, wear or power. A renderer shows these and decides nothing.
public readonly struct SchematicRow
{
public readonly EquippedItem Item;
public readonly EquippableItemData Claimed;
private readonly bool _own;
internal SchematicRow(EquippedItem item, EquippableItemData claimed, bool own)
{
Item = item;
Claimed = claimed;
_own = own;
}
// What a renderer compares to know the rows changed. Range is a reading, not part of the key.
public (EquippedItem, EquippableItemData) Key => (Item, Claimed);
public float Range => _own
? Item.Behaviors.OfType<Weapon>().First().Range
: Claimed.Behaviors.OfType<WeaponData>().First().RatedRange;
}
// A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how
// bright its light was (its Visibility at removal). Judged by each observer's Sense under the
// light-act gate; notification only, one tick long, it decides nothing.
@ -127,7 +165,9 @@ public readonly struct Departure
// How much an observer holds of one subject, derived on read by Entity.ContactOf from its track, its reveals and
// the zone clock. Detected: tracked, or a reveal younger than GameplaySettings.BearingMemory. Tracked: exactly
// membership of VisibleEntities. Classified: detected, with any reveal in the book. Identified: the track has reached
// TargetGearInfoThreshold. Bearing is the true bearing while tracked, else the freshest reveal's.
// TargetGearInfoThreshold, and holds a hull belief: Profile, the hull class this observer believes the subject is (null
// below Identified; aetheria-ew, classify-from-belief; seen-through arrives with the judge). Bearing is the true
// bearing while tracked, else the freshest reveal's.
public readonly struct Contact
{
public readonly bool Detected;
@ -136,9 +176,11 @@ public readonly struct Contact
public readonly bool Identified;
public readonly float2 Bearing;
public readonly float Info;
public readonly HullData Profile;
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info)
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info, HullData profile)
{
Profile = profile;
Detected = detected;
Tracked = tracked;
Classified = classified;

View file

@ -58,7 +58,8 @@ public class SchematicDisplay : MonoBehaviour
public class SchematicDisplayItem
{
public EquippedItem Item;
// The row Entity.SchematicRows derived: which item, which class and which range it shows.
public SchematicRow Row;
public SchematicListElement ListElement;
public IProgressBehavior Cooldown;
// public ItemUsage ItemUsage;
@ -93,15 +94,12 @@ public class SchematicDisplay : MonoBehaviour
CargoTemperatureLabel.text = "N/A";
}
BuildRows(_enemy ? RevealedWeapons() : entity.Equipment.Where(IsWeapon));
BuildRows(WeaponRows());
}
private static bool IsWeapon(EquippedItem item) => item.Behaviors.Any(b => b.Data is WeaponData);
// An enemy schematic lists what `_player` has seen the ship's weapons do: the weapon items of the player's book on it,
// in book order (ruling reveal-ends-on-perception), never the ship's live gear. The player's own schematic is its
// equipment; there is nothing to reveal about your own ship.
private IEnumerable<EquippedItem> RevealedWeapons() => _player.RevealsOf(_entity).Select(r => r.Item).Where(IsWeapon);
// Which rows, which class and which range label are Entity.SchematicRows' decision (aetheria-ew, classify-from-belief);
// this display only renders them.
private IEnumerable<SchematicRow> WeaponRows() => (_player ?? _entity).SchematicRows(_entity);
private void ReturnRows()
{
@ -111,21 +109,21 @@ public class SchematicDisplay : MonoBehaviour
_schematicItems = null;
}
private void BuildRows(IEnumerable<EquippedItem> items)
private void BuildRows(IEnumerable<SchematicRow> rows)
{
_schematicItems = items
_schematicItems = rows
.Select(x => new SchematicDisplayItem
{
Item = x,
Row = x,
ListElement = ListElementPrototype.Instantiate<SchematicListElement>(),
Cooldown = _enemy ? null : (IProgressBehavior) x.Behaviors.FirstOrDefault(b=> b is IProgressBehavior),
Cooldown = _enemy ? null : (IProgressBehavior) x.Item.Behaviors.FirstOrDefault(b=> b is IProgressBehavior),
// ItemUsage = _enemy ? null : (ItemUsage) x.Behaviors.FirstOrDefault(b=> b is ItemUsage),
Weapon = (Weapon) x.Behaviors.FirstOrDefault(b=>b is Weapon)
Weapon = _enemy ? null : (Weapon) x.Item.Behaviors.FirstOrDefault(b=>b is Weapon)
})
.ToArray();
foreach (var x in _schematicItems)
{
if(x.Item.Data is WeaponItemData weaponItemData)
if(x.Row.Claimed is WeaponItemData weaponItemData)
x.ListElement.ShowWeapon(weaponItemData);
//x.ListElement.Label.text = x.Item.EquippableItem.Name;
if (!_enemy)
@ -197,10 +195,10 @@ public class SchematicDisplay : MonoBehaviour
// A reveal gained or ended after selection changes the rows with no event to hook: rebuild when the
// revealed weapon set differs from the rows' items.
if (_enemy && !RevealedWeapons().SequenceEqual(_schematicItems.Select(x => x.Item)))
if (_enemy && !WeaponRows().Select(r => r.Key).SequenceEqual(_schematicItems.Select(x => x.Row.Key)))
{
ReturnRows();
BuildRows(RevealedWeapons());
BuildRows(WeaponRows());
}
// Cut 2 (docs/fire-control-cut.md): marks whichever row is the player's current aim point.
@ -208,7 +206,7 @@ public class SchematicDisplay : MonoBehaviour
// as info decays, with no event to hook.
if (_enemy && _schematicItems != null)
foreach (var x in _schematicItems)
x.ListElement.SetSelected(x.Item == _player.ResolvedTargetItem);
x.ListElement.SetSelected(x.Row.Item == _player.ResolvedTargetItem);
if(VisibilityLabel && !_enemy)
VisibilityLabel.text = ((int)_entity.Visibility).ToString();
@ -226,11 +224,11 @@ public class SchematicDisplay : MonoBehaviour
{
if (!_enemy)
{
var itemData = _entity.ItemManager.GetData(x.Item.EquippableItem);
x.ListElement.HeatFill.anchorMax = new Vector2(unlerp(itemData.MinimumTemperature, itemData.MaximumTemperature, x.Item.Temperature), 0);
var itemData = _entity.ItemManager.GetData(x.Row.Item.EquippableItem);
x.ListElement.HeatFill.anchorMax = new Vector2(unlerp(itemData.MinimumTemperature, itemData.MaximumTemperature, x.Row.Item.Temperature), 0);
if (x.Cooldown != null)
x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1);
x.ListElement.DurabilityLabel.text = $"{(int)(x.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
if (x.Weapon.WeaponData.AmmoType.IsSet())
{
if(x.Weapon.WeaponData.MagazineSize > 1)
@ -240,11 +238,8 @@ public class SchematicDisplay : MonoBehaviour
}
}
if (x.Weapon.WeaponData != null)
{
if(x.ListElement.RangeLabel)
x.ListElement.RangeLabel.text = ((int) x.Weapon.Range).ToString();
}
if (x.ListElement.RangeLabel)
x.ListElement.RangeLabel.text = ((int) x.Row.Range).ToString();
}
}
}

View file

@ -0,0 +1,464 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float3 = CultMath.float3;
// Cut ew-claims-in-the-book (aetheria-ew, classify-from-belief, lie-is-own-emission, belief-one-writer): every act a ship
// makes is stamped with the class its ship claims for the item (the true item's data unless claimed otherwise), the
// observer's book keeps that claim beside the true item, and every classifying read takes the claim while aim keeps the
// truth. The fixtures drive the production path: real ships fire, ping and lock inside Zone.Update.
public sealed partial class ContactRevealTests
{
private EquippableItemData DataOf(string name) => (EquippableItemData) _items.ItemData.GetByName<ItemData>(name);
private static PerformanceStat Span(float min, float max) => new PerformanceStat { Min = min, Max = max };
// A damage curve that falls from 1 to .2 over the range span, so every range argument lands on a different factor.
private static BezierCurve Falloff() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, .2f, 0, 0) } };
private static BezierCurve Flat() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
// ---- ClaimsDefaultToTheTruth ----
[Fact]
public void ClaimsDefaultToTheTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
Fire(e, gun);
Assert.Same(gun.Data, Assert.Single(o.RevealsOf(e)).Claimed); // a fired gun claims its own design
// The hull belief: none below the gear threshold, the true hull at it (TargetGearInfoThreshold .8).
o.EntityInfoGathered[e] = .79f;
Assert.Null(o.ContactOf(e).Profile);
o.EntityInfoGathered[e] = .8f;
Assert.Same(e.HullData, o.ContactOf(e).Profile);
}
[Fact]
public void AHeardPingDefaultsToTheTruth()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
PingAt(p);
Assert.Same(SensorItemOf(p).Data, Assert.Single(hearer.RevealsOf(p)).Claimed);
}
[Fact]
public void APaintDefaultsToTheTruth()
{
var (locker, target) = LockOn("Locker");
Tick(.5f);
Tick(.5f);
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
Assert.Same(lockItem.Data, Assert.Single(target.IncomingPaints).EmitterClass);
Assert.Same(lockItem.Data, Assert.Single(target.RevealsOf(locker)).Claimed);
}
// ---- AnItemClaimIsWhatTheBookSays ----
// The book keeps the true item as its key and the claim beside it; RevealedWeapons lists it by the claim, and aim
// still opens on the true item. Mutation: stamp item.Data in SetSignature, or have RecordReveal store the true data.
[Fact]
public void AnItemClaimIsWhatTheBookSays()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
var other = DataOf("Beam");
e.Claim(gun, other);
Fire(e, gun);
var reveal = Assert.Single(o.RevealsOf(e));
Assert.Same(gun, reveal.Item);
Assert.Same(other, reveal.Claimed);
Assert.Same(gun, Assert.Single(o.RevealedWeapons(e)).Item);
Assert.True(FireControl.IsRevealed(o, gun));
}
// ---- AGunClaimedAsNonWeaponIsNotListed ----
[Fact]
public void AGunClaimedAsNonWeaponIsNotListed()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
e.Claim(gun, DataOf("Eye"));
Fire(e, gun);
Assert.Empty(o.RevealedWeapons(e));
Assert.Single(o.RevealsOf(e)); // the book still holds the gun, for aim
Assert.True(FireControl.IsRevealed(o, gun));
}
// ---- AHullClaimIsTheProfile ----
// Mutation: ContactOf reads subject.HullData, or reads the claim below the gear threshold.
[Fact]
public void AHullClaimIsTheProfile()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var truth = e.HullData;
var hull = e.Hull;
var decoy = new HullData { Name = "Decoy" };
e.Claim(decoy);
Fire(e, Guns(e)[0]);
o.EntityInfoGathered[e] = .79f;
Assert.Null(o.ContactOf(e).Profile);
o.EntityInfoGathered[e] = .8f;
Assert.Same(decoy, o.ContactOf(e).Profile);
Assert.Same(truth, e.HullData); // the truth is untouched: damage, shape and physics keep it
Assert.Same(hull, e.Hull);
}
// ---- APaintCarriesTheClaim ----
[Fact]
public void APaintCarriesTheClaim()
{
var locker = Add(float3(0, 0, 0), new[] { "Locker" });
var target = Add(float3(0, 0, 200));
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
var claimed = DataOf("Eye");
locker.Claim(lockItem, claimed);
locker.SetIff(target, true);
locker.EntityInfoGathered[target] = 1f;
locker.SetTarget(target);
locker.Aim = normalize(target.Position - locker.Position);
Tick(.5f);
Tick(.5f);
Assert.Same(claimed, Assert.Single(target.IncomingPaints).EmitterClass);
var reveal = Assert.Single(target.RevealsOf(locker));
Assert.Same(lockItem, reveal.Item);
Assert.Same(claimed, reveal.Claimed);
}
// The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data.
[Fact]
public void AHeardPingCarriesTheClaim()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
var claimed = DataOf("Ear");
p.Claim(SensorItemOf(p), claimed);
PingAt(p);
Assert.Same(claimed, Assert.Single(hearer.RevealsOf(p)).Claimed);
}
// ---- AClaimEndsWithItsShip ----
[Fact]
public void AClaimEndsWithItsShip()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
e.Claim(new HullData { Name = "Decoy" });
e.Claim(gun, DataOf("Eye"));
e.Deactivate();
Assert.Same(e.HullData, e.ClaimedHull);
Assert.Same(gun.Data, e.ClaimOf(gun));
}
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
// A hull's claim is its own item's claim: the hull's acts (a Reflector's reflection, a launch flash, the hull as the
// item of any signature) carry the class the profile reports, so the book never holds the true hull beside a decoy.
// Mutation: ClaimedHull reads a slot of its own, or Claim(HullData) writes one.
[Fact]
public void AHullClaimReachesTheHullsOwnReveal()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var decoy = new HullData { Name = "Decoy" };
e.Claim(decoy);
Assert.Same(decoy, e.ClaimOf(e.EquippedHull));
Assert.Same(decoy, e.ClaimedHull);
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, e.EquippedHull);
Tick(.1f);
var reveal = Assert.Single(o.RevealsOf(e), r => r.Item == e.EquippedHull);
Assert.Same(decoy, reveal.Claimed);
o.EntityInfoGathered[e] = .8f;
Assert.Same(reveal.Claimed, o.ContactOf(e).Profile);
e.Claim(null);
Assert.Same(e.HullData, e.ClaimedHull);
Assert.Same(e.HullData, e.ClaimOf(e.EquippedHull));
}
[Fact]
public void TheHullCanOnlyClaimToBeAHull()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
Assert.Throws<System.ArgumentException>(() => e.Claim(e.EquippedHull, DataOf("Eye")));
Assert.Same(e.HullData, e.ClaimedHull);
}
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
// A design's rated figure is each stat's Max through the live weapon's own formula. Every stat is authored with
// Min below Max, a falling curve and a MinRange above 0, so reading a Min, scaling the range argument, the wrong
// stat or the wrong operator changes the answer. At 70 the range factor is (70 - 20) / (120 - 20) = .5.
// Mutation: any rated override reads Range.Min or MinRange.Min or a Min, drops the burst divisor, the charge
// multiplier or the charge time; the shared formula scales its range argument.
[Fact]
public void ARatedFigureIsTheDesignsMaxThroughTheSharedFormula()
{
var curve = Falloff();
var half = curve.Evaluate(.5f);
Assert.NotEqual(half, curve.Evaluate(1f)); // the Range.Min and halved-range mutants land elsewhere on the curve
Assert.NotEqual(half, curve.Evaluate(.15f));
Assert.NotEqual(half, curve.Evaluate(70f / 120f));
var instant = Spanned(new InstantWeaponData());
Assert.Equal(100f / 2 * half / 2, instant.RatedRangeDamagePerSecond(70f), 4); // 100 / 2 rounds, curve, / 2 s
Assert.Equal(120f, instant.RatedRange);
var charged = (ChargedWeaponData) Spanned(new ChargedWeaponData
{
ChargeTime = Span(1, 3), ChargeFiringDamageMultiplier = 2f
});
Assert.Equal(100f / 2 * 2 * half / (2 + 3), charged.RatedRangeDamagePerSecond(70f), 4);
var beam = Spanned(new ConstantWeaponData());
Assert.Equal(100f * half, beam.RatedRangeDamagePerSecond(70f), 4);
Assert.Equal(120f, beam.RatedRange);
}
// The rated read and the live read are one formula: a nominal weapon's live figure is its design's rated figure,
// for each kind, at ranges before, inside and past the falloff span, and equals the hand arithmetic.
// Mutation: a rated override with its own arithmetic, or a live read that drifts from the shared formula (the
// charged read's ChargeTime, the range, the curve).
[Fact]
public void ARatedFigureIsTheLiveFigureOfANominalWeapon()
{
var e = Add(float3(0, 0, 0), new[] { "SpanGun", "SpanCharged", "SpanBeam" });
Tick(.1f);
var curve = Falloff();
var expected = new Dictionary<string, Func<float, float>>
{
["SpanGun"] = at => 100f / 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / 2,
["SpanCharged"] = at => 100f / 2 * 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / (2 + 3),
["SpanBeam"] = at => 100f * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f))
};
foreach (var item in e.Equipment.Where(i => i.Behaviors.Any(b => b is Weapon)))
{
var live = item.Behaviors.OfType<Weapon>().Single();
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
foreach (var at in new[] { 10f, 70f, 110f, 500f })
{
Assert.Equal(expected[item.Data.Name](at), live.RangeDamagePerSecond(at), 3);
Assert.Equal(live.RangeDamagePerSecond(at), rated.RatedRangeDamagePerSecond(at), 3);
}
}
}
// A charged weapon at full charge reads the same rated figure: the charge multiplier is applied once, in the formula
// (UpdateStats lerps the shot's damage by charge, so the live reads start from the uncharged damage). At range 10 the
// curve factor is 1: 100 / 2 rounds x 2 / (2 + 3) = 20. Mutation: the live reads pass the charge-lerped Damage.
[Fact]
public void AFullyChargedWeaponReadsItsRatedFigure()
{
var e = Add(float3(0, 0, 0), new[] { "SpanCharged" });
Tick(.1f);
var item = e.Equipment.Single(i => i.Data.Name == "SpanCharged");
var live = (ChargedWeapon) item.Behaviors.Single(b => b is ChargedWeapon);
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
typeof(ChargedWeapon).GetField("_charge", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(live, 1f);
typeof(Weapon).GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(live, null);
foreach (var at in new[] { 10f, 70f, 110f })
Assert.Equal(rated.RatedRangeDamagePerSecond(at), live.RangeDamagePerSecond(at), 3);
Assert.Equal(20f, live.DamagePerSecond, 3);
Assert.Equal(20f, live.RangeDamagePerSecond(10f), 3);
}
// ---- The schematic's rows (ruling ew-claim-weapon-measure-ruled; finding schematic-unreached) ----
// Wears a gun's Range to 95% (a shorter one is under the fuse arming distance and the gun is refused) so that, once it ticks, its live range (38) is not its design's rated range (40).
private void WearRange(Entity ship, EquippedItem gun)
{
var data = (InstantWeaponData) gun.Data.Behaviors.Single(b => b is InstantWeaponData);
ship.Resolver.AttachModifier(gun, data.Range, "worn", StatModifierType.Multiplier, .95f);
}
// The fixture's guard, read once the gun has ticked: its live range is not its design's rated range.
private static void AssertWorn(EquippedItem gun) =>
Assert.Equal(38f, gun.Behaviors.OfType<Weapon>().Single().Range, 2);
// The unclaimed enemy row is the true gun, with the gun's own rated range (Gun: 40) though the gun is worn to a live
// 38. Mutation: the row's Item.Data or the true live range stands in for the claim (also when Claimed == Data).
[Fact]
public void AnEnemySchematicRowDefaultsToTheTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
WearRange(e, gun);
Fire(e, gun);
AssertWorn(gun);
var row = Assert.Single(o.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(gun.Data, row.Claimed);
Assert.Equal(40f, row.Range);
Assert.NotEqual(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
}
// A weapon claimed as another weapon shows the claimed design's rated range (SpanGun: Range.Max 120), not the true
// gun's 40. Mutation: Range reads the true item, or the claimed stat's Min (50), or scales the figure.
[Fact]
public void AWeaponClaimedAsAnotherWeaponShowsTheClaimedRatedRange()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
var other = DataOf("SpanGun");
e.Claim(gun, other);
Fire(e, gun);
var row = Assert.Single(o.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(other, row.Claimed);
Assert.Equal(120f, row.Range);
}
// Mutation: the rows are the book's reveals (RevealsOf) or the subject's equipment.
[Fact]
public void AWeaponClaimedAsANonWeaponHasNoSchematicRow()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
e.Claim(gun, DataOf("Eye"));
Fire(e, gun);
Assert.Empty(o.SchematicRows(e));
Assert.Single(o.RevealsOf(e));
}
// Unrevealed gear never appears on an enemy schematic: a ship that has not fired shows no row.
[Fact]
public void AnArmedShipThatNeverFiredHasNoEnemySchematicRow()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
Tick(.1f);
Assert.Empty(o.SchematicRows(e));
}
// Your own schematic is your own gear whatever you claim: the true data, the live range, weapons only.
// Mutation: own rows read ClaimOf, the rated range of the claim (SpanGun 120) or of the own design (40), or list non-weapons.
[Fact]
public void OwnSchematicRowsShowOwnTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
WearRange(e, gun);
e.Claim(gun, DataOf("SpanGun"));
Tick(.1f);
AssertWorn(gun);
var row = Assert.Single(e.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(gun.Data, row.Claimed);
Assert.Equal(38f, row.Range, 2); // the live range of the worn gun, below its rated 40
Assert.Equal(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
Assert.NotEqual(((WeaponData) gun.Data.Behaviors.Single(b => b is WeaponData)).RatedRange, row.Range);
}
// The renderer rebuilds when the key differs: a claim that changes between two acts changes the key, and an
// unchanged claim does not. Mutation: Key drops Claimed.
[Fact]
public void TheRebuildKeyChangesWhenAClaimChanges()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
Fire(e, gun);
var before = o.SchematicRows(e).Select(r => r.Key).ToArray();
Assert.Equal(before, o.SchematicRows(e).Select(r => r.Key).ToArray());
e.Claim(gun, DataOf("SpanGun"));
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, gun);
Tick(.1f);
var after = o.SchematicRows(e).Select(r => r.Key).ToArray();
Assert.Single(after);
Assert.NotEqual(before, after);
Assert.Same(DataOf("SpanGun"), after[0].Item2);
}
// Two guns of one claimed design are told apart by the key: a row set that swaps one gun for the other keeps the count and
// the claims and changes only the item, and the renderer must still rebuild (the selection mark follows the item).
// Mutation: Key drops Item.
[Fact]
public void TheRebuildKeyChangesWhenOnlyTheItemChanges()
{
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
var a = Add(float3(0, 0, 0), new[] { "Eye" });
var b = Add(float3(0, 0, 5), new[] { "Eye" });
var guns = Guns(e);
var design = DataOf("SpanGun");
e.Claim(guns[0], design);
e.Claim(guns[1], design);
a.RecordReveal(e, guns[0], SignatureChannel.LaunchFlash, e.ClaimOf(guns[0]));
b.RecordReveal(e, guns[1], SignatureChannel.LaunchFlash, e.ClaimOf(guns[1]));
var first = Assert.Single(a.SchematicRows(e));
var second = Assert.Single(b.SchematicRows(e));
Assert.Same(first.Claimed, second.Claimed);
Assert.NotEqual(first.Key, second.Key);
}
// ---- One hull class in the book ----
// The guard runs both ways: a hull class can be claimed only by the hull, so no item's reveal carries a hull class that
// disagrees with Contact.Profile. Mutation: the guard checks the hull side only.
[Fact]
public void OnlyTheHullClaimsAHullClass()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
Assert.Throws<System.ArgumentException>(() => e.Claim(gun, e.HullData));
Assert.Same(gun.Data, e.ClaimOf(gun));
}
// ---- Gear the subject no longer carries ----
// A reveal of gear the subject does not carry follows every fitted weapon, and stale gear is ordered by lot whatever
// order the observer saw it in. Mutation: the missing index sorts first (-1), or stale ties follow the book's slot order.
[Fact]
public void RevealsOfGearNoLongerCarriedFollowFittedGear()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gone = Add(float3(60, 0, 40), new[] { "Gun", "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var fitted = Guns(e)[0];
var stale = Guns(gone);
Assert.True(stale[0].EquippableItem.Lot < stale[1].EquippableItem.Lot);
o.RecordReveal(e, stale[1], SignatureChannel.LaunchFlash, stale[1].Data);
o.RecordReveal(e, stale[0], SignatureChannel.LaunchFlash, stale[0].Data);
o.RecordReveal(e, fitted, SignatureChannel.LaunchFlash, fitted.Data);
Assert.Equal(new[] { fitted, stale[0], stale[1] }, o.RevealedWeapons(e).Select(r => r.Item).ToArray());
}
}

View file

@ -89,6 +89,14 @@ public sealed partial class ContactRevealTests : IDisposable
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { Armed(new ConstantWeaponData(), 0) }
});
// Weapons authored so the rated figure is a function of every stat: a falling damage curve, a Range span, a
// MinRange above 0, burst and charge spans (a stat with no terms resolves to its Max live).
cache.Upsert(SpannedWeapon("SpanGun", Spanned(new InstantWeaponData { BurstTime = Const(0) })));
cache.Upsert(SpannedWeapon("SpanCharged", Spanned(new ChargedWeaponData
{
BurstTime = Const(0), ChargeTime = Span(1, 3), ChargeEnergy = Const(1), ChargeHeat = Const(0), ChargeFiringDamageMultiplier = 2f
})));
cache.Upsert(SpannedWeapon("SpanBeam", Spanned(new ConstantWeaponData())));
cache.Upsert(Locker("Locker", interceptFactor: null));
cache.Upsert(Locker("Hushed", interceptFactor: 4f));
cache.Upsert(new ConsumableItemData
@ -153,6 +161,29 @@ public sealed partial class ContactRevealTests : IDisposable
return weapon;
}
private static WeaponData Spanned(WeaponData weapon)
{
Armed(weapon, 5);
weapon.Damage = Span(10, 100);
weapon.MinRange = Span(0, 20);
weapon.Range = Span(50, 120);
weapon.DamageCurve = Falloff();
if (weapon is InstantWeaponData instant)
{
instant.Count = Span(1, 2);
instant.Cooldown = Span(8, 2);
}
return weapon;
}
private static WeaponItemData SpannedWeapon(string name, WeaponData weapon) => new WeaponItemData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { weapon }
};
private static GearData Suppressor(string name, float factor, float heatPerUnit)
{
var cell = new Shape(1, 1);

View file

@ -478,7 +478,7 @@ public sealed class FireAuthorityTests : IDisposable
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully tracked
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);

View file

@ -519,7 +519,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(2, 2);
var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
// The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim
@ -546,7 +546,7 @@ public sealed class FireControlCut12Tests : IDisposable
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
{
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash, e.BigMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -584,7 +584,7 @@ public sealed class FireControlCut12Tests : IDisposable
// the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral
// axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured.
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash, e.LMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -975,7 +975,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)),
markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
@ -1158,7 +1158,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var outcomes = FireMany(e, 1500);
@ -1192,7 +1192,7 @@ public sealed class FireControlCut12Tests : IDisposable
{
var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
Assert.Same(aimed, e.Shooter.ResolvedTargetItem);

View file

@ -587,8 +587,8 @@ public sealed class FireControlCut5Tests : IDisposable
target.Zone.Admit(observer, piloted: false);
observer.SetTarget(target);
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash, big.Data);
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash, medium.Data);
Assert.True(FireControl.IsRevealed(observer, big));
Assert.True(FireControl.IsRevealed(observer, medium));
Assert.True(observer.TrySelectTargetItem(big));

View file

@ -169,7 +169,7 @@ public sealed class FireControlCut6dTests : IDisposable
if (marker != null)
{
var markerItem = target.Equipment.Single(x => x.EquippableItem == marker);
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash);
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash, markerItem.Data);
Assert.True(shooter.TrySelectTargetItem(markerItem));
Assert.Same(markerItem, shooter.ResolvedTargetItem);
}

View file

@ -167,7 +167,7 @@ public sealed class FireControlCut7Tests : IDisposable
// The shooter tracks the target fully and has seen the interior item act; it selects it as the aim point.
e.Shooter.EntityInfoGathered[e.Target] = 1f;
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem);

View file

@ -68,6 +68,13 @@ public sealed class FireControlTests : IDisposable
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
// A second design, so a target can carry two guns whose true figures differ (claim tests).
buildCache.Upsert(new GearData
{
Name = "WeakGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
buildCache.FlushAsync().Wait();
}
@ -175,9 +182,10 @@ public sealed class FireControlTests : IDisposable
}
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f)
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f, bool twoTargetGuns = false)
{
var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var mounts = twoTargetGuns ? new[] { (ItemRotation.None, 0f), (ItemRotation.None, 0f) } : new[] { (ItemRotation.None, 0f) };
var (items, shooter, weapons) = BuildShipWithHardpoints(mounts);
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 };
weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
@ -194,6 +202,20 @@ public sealed class FireControlTests : IDisposable
// gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one.
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0)));
if (twoTargetGuns)
{
// Both true guns are finite, authored figures: the strong one rates 10, the weak one 1.
weaponData.Count = new PerformanceStat { Min = 1, Max = 1 };
var weakRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("WeakGun"));
var weakData = (InstantWeaponData) items.ItemData.GetByName<GearData>("WeakGun").Behaviors.Single();
weakData.Damage = new PerformanceStat { Min = 1, Max = 1 };
weakData.Count = new PerformanceStat { Min = 1, Max = 1 };
weakData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
weakData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
weakData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
weakData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
Assert.True(target.TryEquip(new EquippableItem { Data = weakRef, Durability = 1, Lot = 2 }, new int2(1, 0)));
}
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
@ -249,7 +271,7 @@ public sealed class FireControlTests : IDisposable
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data);
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
@ -262,6 +284,139 @@ public sealed class FireControlTests : IDisposable
Assert.Null(shooter.TargetItem.Value);
}
// A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data,
// not a catalog item). Rated damage per second at any range is damage / cooldown (one round, flat curve).
private static GearData ClaimedDesign(string name, float damage, float range = 1000f, BezierCurve curve = null) => new GearData
{
Name = name,
Behaviors =
{
new InstantWeaponData
{
Damage = new PerformanceStat { Min = 0, Max = damage }, Count = new PerformanceStat { Min = 1, Max = 1 },
Cooldown = new PerformanceStat { Min = 1, Max = 1 }, Range = new PerformanceStat { Min = 0, Max = range },
MinRange = new PerformanceStat { Min = 0, Max = 0 },
DamageCurve = curve ?? new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
}
}
};
// Ruling ew-aim: the AI classifies from its book, not the target's gear (aetheria-ew, classify-from-belief). A gun
// the target claims is not a weapon is not something Combat picks as a weapon, though the gun is truly one and
// the shooter has seen it act; clearing the claim and seeing it act again restores the choice. Mutation: Combat
// walks target.Equipment (or reveal.Item's true behaviours) instead of RevealedWeapons.
[Fact]
public void TheAIChoosesFromWhatItBelieves()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
target.Claim(targetGun, new GearData { Name = "NotAGun" });
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
target.Claim(targetGun, null);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
}
// Ruling ew-claim-weapon-measure-ruled (rated-design): the AI ranks revealed weapons by their claimed design's
// rated figures, so the truly weaker gun claimed as the stronger design is the one chosen, and the unclaimed pair
// picks the truly stronger. Mutation: rank by the true weapon's live figure, or by the carrier's stats.
[Fact]
public void AWeakerGunClaimedAsAStrongerDesignIsChosen()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
void See()
{
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
}
See();
Assert.Same(strong, shooter.TargetItem.Value); // unclaimed: the truly stronger gun (rated 10 against 1)
target.Claim(strong, ClaimedDesign("Pea", 2f));
target.Claim(weak, ClaimedDesign("Cannon", 90f));
See();
Assert.Same(weak, shooter.TargetItem.Value);
}
// The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one
// up close is worthless at 10 when its Range is 12, and the weaker flat design is chosen. Mutation: Combat halves
// targetDistance (the short design then rates 6.2 at 5 and wins), or the rated read ignores the range.
[Fact]
public void TheAIRanksClaimsAtTheTargetsDistance()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
var falling = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 0, 0, 0) } };
target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, curve: falling)); // ~.7 at 10, ~6.2 at 5
target.Claim(weak, ClaimedDesign("Slug", 5f)); // 5 at any range
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
Assert.Same(weak, shooter.TargetItem.Value);
}
// Ruling ew-claim-weapon-measure-ruled: no observer reads a target weapon's live state. Two unclaimed guns whose
// rated order (10 against 1) is the reverse of their live order (the strong one worn down to a hundredth) are
// ranked by the design, so the aim stays on the rated stronger gun. Mutation: rank an unclaimed reveal by the
// true weapon's live RangeDamagePerSecond.
[Fact]
public void TheAIRanksByTheDesignNotTheTargetsLiveGun()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
var strongData = (InstantWeaponData) strong.Data.Behaviors.Single(b => b is InstantWeaponData);
target.Resolver.AttachModifier(strong, strongData.Damage, "worn", StatModifierType.Multiplier, .01f);
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
var liveStrong = strong.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
var liveWeak = weak.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
Assert.True(liveStrong < liveWeak); // the fixture: live and rated disagree
Assert.Same(strong, shooter.TargetItem.Value);
}
// Ties are explicit: two revealed weapons of equal rated figure are taken in the subject's equipment order, whatever
// order the shooter saw them in. Mutation: ties go to the last (>=), or the walk follows the book's slot order.
[Fact]
public void EqualClaimsAreTakenInTheTargetsEquipmentOrder()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var first = target.Equipment.Single(x => x.Data.Name == "Gun");
var second = target.Equipment.Single(x => x.Data.Name == "WeakGun");
Assert.True(target.Equipment.IndexOf(first) < target.Equipment.IndexOf(second));
var design = ClaimedDesign("Twin", 5f);
target.Claim(first, design);
target.Claim(second, design);
shooter.RecordReveal(target, second, SignatureChannel.LaunchFlash, target.ClaimOf(second)); // seen first
shooter.RecordReveal(target, first, SignatureChannel.LaunchFlash, target.ClaimOf(first));
zone.Update(1f);
zone.Update(1f);
Assert.Equal(new[] { first, second }, shooter.RevealedWeapons(target).Select(r => r.Item).ToArray());
Assert.Same(first, shooter.TargetItem.Value);
}
// Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it
// through the one steering law (Turn); it never turns the hull by writing Aim.
[Fact]

View file

@ -43,7 +43,7 @@ public sealed partial class FireControlCut124Tests
// Fires until a shot lands; returns that outcome and the absorbs the target published for that shot alone.
private static (ShotOutcome Outcome, List<AbsorbEvent> Absorbs) FireAtTheMarker(Engagement e, List<IDisposable> subs)
{
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash); // the book must hold the item: aiming is earned by seeing it act
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash, e.Custom["Marker"].Data); // the book must hold the item: aiming is earned by seeing it act
Assert.True(e.Shooter.TrySelectTargetItem(e.Custom["Marker"]), "fixture: the marker is aimable");
var absorbs = Collect(e.Target, subs);
ShotOutcome landed = null;

View file

@ -454,7 +454,7 @@ public sealed class SignatureTests : IDisposable
var view = r.Ship.VisibilitySources;
Assert.False(view is Dictionary<object, Signature>);
Assert.True(ReferenceEquals(view, r.Ship.VisibilitySources), "one view object per entity"); // not Assert.Same: its failure message formats the entity graph and aborts the test host
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null));
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null, null));
}
// ---- APlumeKeepsTheLargerBurn ----

View file

@ -168,7 +168,7 @@ public sealed class TargetingSystemTests : IDisposable
Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet
Assert.Null(observer.TargetItem.Value);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Same(weapon, observer.ResolvedTargetItem);
@ -196,7 +196,7 @@ public sealed class TargetingSystemTests : IDisposable
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.False(FireControl.IsRevealed(observer, wideItem));
@ -230,8 +230,8 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, wideItem, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash, wideItem.Data);
Assert.True(observer.TrySelectTargetItem(weapon));
weapon.EquippableItem.Durability = 0f;
@ -250,7 +250,7 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
target.Deactivate(); // an active ship refuses to unequip
@ -269,7 +269,7 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.EntityInfoGathered[target] = .5f; // below TargetGearInfoThreshold (.9)
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
weapon.EquippableItem.Durability = 0f;