Merge eureka/aetheria-release-contact-reveals: reveals end on perception (cut-contact-reveals.r2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 05:18:17 +02:00
commit 31d19eae64
12 changed files with 1486 additions and 24 deletions

View file

@ -290,7 +290,7 @@ public class EntityInstance : MonoBehaviour
_subscriptions.Add(Entity.HullDamage.Subscribe(_ =>
{
if (Entity.Hull.Durability < .01f)
if (Entity.Hull.IsDestroyed)
{
if (!this) return;
_destroyed = true;

View file

@ -102,7 +102,7 @@ public class LockWeapon : InstantWeapon
{
var lerp = 1 - unlerp(0, 90, degrees);
_lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact));
target.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Track, Lock);
target.ReceivePaint(Entity, Item, PaintMode.Track, Lock);
}
else _lock = saturate(_lock - dt * Decay);
}

View file

@ -198,7 +198,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
if (sweeping && dist < _pingRadius && _reached.Add(entity) && InBeam(diff, dist))
{
heard = true;
entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0);
entity.ReceivePaint(Entity, Item, PaintMode.Search, 0);
credit += Gain(entity.Visibility, sensitivity, response, dist, dt, true, obscuration);
var pingReturn = new PingReturn(entity, dist, obscuration, Evaluate(_data.PingRange));
foreach (var ally in Entity.Zone.Entities)

View file

@ -189,7 +189,22 @@ public abstract class Entity
// sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the
// inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after
// its last paint.
private readonly List<PaintWarning> _paintInbox = new List<PaintWarning>();
// The inbox keeps who painted and with what beside the warning: the warning is all a reader of IncomingPaints
// gets, the emitter and item are for PublishInbox, which records the emitter's reveal.
private readonly struct PendingPaint
{
public readonly PaintWarning Warning;
public readonly Entity Emitter;
public readonly EquippedItem Item;
public PendingPaint(PaintWarning warning, Entity emitter, EquippedItem item)
{
Warning = warning;
Emitter = emitter;
Item = item;
}
}
private readonly List<PendingPaint> _paintInbox = new List<PendingPaint>();
private readonly List<PaintWarning> _incomingPaints = new List<PaintWarning>();
public IReadOnlyList<PaintWarning> IncomingPaints => _incomingPaints;
// An ally's sensor posts a return on the tick its ping crosses a subject; published with paints, read by this
@ -197,6 +212,12 @@ public abstract class Entity
private readonly List<PingReturn> _returnInbox = new List<PingReturn>();
private readonly List<PingReturn> _pingReturns = new List<PingReturn>();
internal IReadOnlyList<PingReturn> PingReturns => _pingReturns;
// What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject,
// item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed
// subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception).
private readonly Dictionary<Entity, Dictionary<EquippedItem, Reveal>> _reveals = new Dictionary<Entity, Dictionary<EquippedItem, Reveal>>();
private readonly List<EquippedItem> _endedItems = new List<EquippedItem>();
private readonly List<Entity> _agedSubjects = new List<Entity>();
public ReactiveProperty<SecurityLevel> CurrentSecurityLevel = new ReactiveProperty<SecurityLevel>(SecurityLevel.Open);
public ReadOnlyReactiveProperty<bool> PresencePermitted;
@ -395,22 +416,30 @@ public abstract class Entity
// Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value
// the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright,
// here, now. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the
// info settles where the gain balances the decay: sum(G_i) / (n k), capped at 1. A ping adds nothing that
// lasts, so only the passive gain enters.
// here, now (PassiveInfo at the chunk's brightness), capped at 1.
public float ChunkInfo(ChunkId chunk)
{
var visibility = Zone.ChunkVisibility(chunk);
if (visibility <= 0f) return 0f;
var position = Zone.ChunkPose(chunk.Field, chunk.Index).xy;
return saturate(PassiveInfo(Zone.ChunkPose(chunk.Field, chunk.Index).xy, visibility));
}
// The info a target of `signature` at `position` would settle at under this entity's passive sensing, before
// the cap at 1. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the
// info settles where the gain balances the decay: sum(G_i) / (n k). A ping adds nothing that lasts, so only the
// passive gain enters. ChunkInfo asks it for a chunk's brightness; Sense asks it for a unit signature once per
// ship, because it is linear in the signature below saturation and so answers for every act at a place. Zero
// with no active sensor.
public float PassiveInfo(float2 position, float signature)
{
var rate = 0f;
var sensors = 0;
foreach (var sensor in ActiveSensors())
{
rate += sensor.PassiveRate(visibility, position);
rate += sensor.PassiveRate(signature, position);
sensors++;
}
return sensors == 0 ? 0f : saturate(rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay));
return sensors == 0 ? 0f : rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay);
}
// Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity
@ -637,10 +666,25 @@ public abstract class Entity
return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f;
}
// Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables.
private IEnumerable<Sensor> ActiveSensors() =>
Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType<Sensor>()
.Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType<Sensor>());
// Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. The
// list is this entity's one buffer, refilled per call (Sense asks once per other ship per tick, allocating
// nothing), so callers use it before asking again.
private readonly List<Sensor> _activeSensors = new List<Sensor>();
private List<Sensor> ActiveSensors()
{
_activeSensors.Clear();
for (var i = 0; i < Equipment.Count; i++)
{
var item = Equipment[i];
if (!item.Active.Value) continue;
foreach (var behavior in item.Behaviors)
if (behavior is Sensor sensor) _activeSensors.Add(sensor);
}
foreach (var effect in _activeConsumables)
foreach (var behavior in effect.Behaviors)
if (behavior is Sensor sensor) _activeSensors.Add(sensor);
return _activeSensors;
}
// Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of
// what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so
@ -811,6 +855,7 @@ public abstract class Entity
_incomingPaints.Clear();
_returnInbox.Clear();
_pingReturns.Clear();
_reveals.Clear();
}
// Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published
@ -819,7 +864,11 @@ public abstract class Entity
internal void PublishInbox()
{
_incomingPaints.Clear();
_incomingPaints.AddRange(_paintInbox);
foreach (var paint in _paintInbox)
{
_incomingPaints.Add(paint.Warning);
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission);
}
_paintInbox.Clear();
_pingReturns.Clear();
_pingReturns.AddRange(_returnInbox);
@ -833,11 +882,126 @@ public abstract class Entity
}
// 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).
internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, PaintMode mode, float 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).
internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength)
{
if (!_active) return;
_paintInbox.Add(new PaintWarning(normalize((emitterPosition - Position).xz), emitterClass, mode, strength));
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item));
}
// The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file):
// this observer saw `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull
// 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)
{
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);
}
// This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule
// for a destroyed item an observer perceives, read by the book's entry (RecordReveal) and its end (Sense).
private bool PerceivesDestroyed(Entity subject, EquippedItem item) =>
item.EquippableItem.IsDestroyed
&& EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold;
// The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's.
// The subject's key goes with its last entry.
private void EndReveals(Entity subject, EquippedItem item)
{
if (!_reveals.TryGetValue(subject, out var book)) return;
if (item != null) book.Remove(item);
else book.Clear();
if (book.Count == 0) _reveals.Remove(subject);
}
// What this observer has seen `subject`'s items do: each item's latest act, and nothing the subject's present
// gear or Durability says (ruling contact-reads-book-aim-reads-live). A reveal ends only on what this observer
// perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death,
// dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the
// freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is
// FireControl.IsRevealed's question, not this one.
public IEnumerable<Reveal> RevealsOf(Entity subject) =>
_reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty<Reveal>();
// The one derivation of how much this observer holds of `subject`: see Contact.
public Contact ContactOf(Entity subject)
{
var settings = ItemManager.GameplaySettings;
var info = EntityInfoGathered.TryGetValue(subject, out var gathered) ? gathered : 0f;
var tracked = VisibleEntities.Contains(subject);
var any = false;
var freshest = default(Reveal);
foreach (var reveal in RevealsOf(subject))
if (!any || reveal.Time > freshest.Time)
{
any = true;
freshest = reveal;
}
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);
}
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every
// entity has updated): every light signature of an item that would alone settle this observer's passive info above
// TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no
// item and only feeds the track; an Emission entry is never light. One PassiveInfo per ship. Zone.Update calls it
// for every entity after all of them have updated, so what a ship shows is what every observer receives on that
// tick whatever order the zone lists them in.
internal void Sense()
{
if (!_active) return;
var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
var entities = Zone.Entities;
for (var i = 0; i < entities.Count; i++)
{
var other = entities[i];
if (other == this) continue;
var perSignature = -1f;
foreach (var signature in other._visibilitySources.Values)
{
if (signature.Item == null || signature.Channel == SignatureChannel.Emission) continue;
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
}
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and
// that reveal ends here; RecordReveal refuses the same item's lingering flash.
if (_reveals.TryGetValue(other, out var book))
{
foreach (var item in book.Keys)
if (PerceivesDestroyed(other, item)) _endedItems.Add(item);
for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]);
_endedItems.Clear();
}
}
// A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as
// bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave.
var departures = Zone.Departures;
for (var i = 0; i < departures.Count; i++)
{
var departure = departures[i];
if (!_reveals.ContainsKey(departure.Subject) || entities.Contains(departure.Subject)) continue;
if (departure.Signature * PassiveInfo(departure.Position, 1f) > threshold) EndReveals(departure.Subject, null);
}
// What stays of a departed subject is memory: pruned once its freshest entry is as old as ContactOf stops
// remembering it.
var memory = ItemManager.GameplaySettings.BearingMemory;
foreach (var pair in _reveals)
{
if (entities.Contains(pair.Key)) continue;
var freshest = float.MinValue;
foreach (var reveal in pair.Value.Values) freshest = Math.Max(freshest, reveal.Time);
if (Zone.Time - freshest >= memory) _agedSubjects.Add(pair.Key);
}
for (var i = 0; i < _agedSubjects.Count; i++) EndReveals(_agedSubjects[i], null);
_agedSubjects.Clear();
}
public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings)
@ -854,10 +1018,10 @@ public abstract class Entity
for(int i=0; i<itemManager.GameplaySettings.WeaponGroupCount; i++)
WeaponGroups[i] = (new List<Weapon>(), new List<EquippedItem>());
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.Durability < .01f).Select(x=>x.item);
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.IsDestroyed).Select(x=>x.item);
WeaponDestroyed = ItemDestroyed.Select(x => x.Behaviors.FirstOrDefault(b => b is Weapon) as Weapon).Where(x => x != null);
HullArmorDepleted = ArmorDamage.Where(x => Armor[x.pos.x, x.pos.y] < .01f).Select(x => x.pos);
Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed)
Death = HullDamage.Where(_ => Hull.IsDestroyed).Select(_ => CauseOfDeath.HullDestroyed)
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed))
@ -2035,7 +2199,7 @@ public class EquippedItem : IStatContext
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
) * Data.Durability / Data.ThermalResilience;
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
DurabilityOnline.Value = EquippableItem.Durability > .01f;
DurabilityOnline.Value = !EquippableItem.IsDestroyed;
oldTemperature = temp;
}

View file

@ -168,7 +168,7 @@ public static class FireControl
// failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this
// target" path below), and the survivors' tiers close up over the gap instead of leaving one.
var ranked = target.Equipment
.Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)
.Where(e => e.Data.HardpointType != HardpointType.Hull && !e.EquippableItem.IsDestroyed)
.OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool)
.ThenByDescending(e => e.Data.Shape.Coordinates.Length)
.ToList();

View file

@ -53,6 +53,9 @@ public class EquippableItem : CraftedItemInstance
[JsonProperty("durability"), Key(7)] public float Durability;
[JsonProperty("override"), Key(8)] public bool OverrideShutdown;
// The one rule for a destroyed item: its wear is spent.
[IgnoreMember] public bool IsDestroyed => Durability < .01f;
// Cut 5 (docs/stats-and-power-cut.md §1.3, §0b "A power tier"): a player choice, stored on the unit rather
// than the design, so two units of the same design can be prioritized differently. PowerTiers.Unassigned
// until the first equip seeds a behaviour-kind default (EquippedItem's constructor) or the player overrides

View file

@ -73,3 +73,63 @@ public readonly struct PaintWarning
Strength = strength;
}
}
// 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.
public readonly struct Reveal
{
public readonly EquippedItem Item;
public readonly SignatureChannel Channel;
public readonly float2 Bearing;
public readonly float Time;
public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time)
{
Item = item;
Channel = channel;
Bearing = bearing;
Time = time;
}
}
// 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 signature outside the Emission channel). Judged by each observer's Sense under the
// light-act gate; notification only, one tick long, it decides nothing.
public readonly struct Departure
{
public readonly Entity Subject;
public readonly float2 Position;
public readonly float Signature;
public Departure(Entity subject, float2 position, float signature)
{
Subject = subject;
Position = position;
Signature = signature;
}
}
// 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.
public readonly struct Contact
{
public readonly bool Detected;
public readonly bool Tracked;
public readonly bool Classified;
public readonly bool Identified;
public readonly float2 Bearing;
public readonly float Info;
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info)
{
Detected = detected;
Tracked = tracked;
Classified = classified;
Identified = identified;
Bearing = bearing;
Info = info;
}
}

View file

@ -213,9 +213,12 @@ public class GameplaySettings
public ExponentialLerp LockSpinSpeed;
public float TorqueMultiplier;
public float VisibilityDecay;
// Seconds a reveal's bearing keeps a ship Detected after its last act with no track (Entity.ContactOf).
public float BearingMemory = 6;
public float TargetInfoDecay;
public float TargetDetectionInfoThreshold;
public float TargetArmorInfoThreshold;
// Also the Identified threshold: the track level at which a ship's hull class, and with it its layout, is known.
public float TargetGearInfoThreshold;
public float ConvergenceMinimumDistance;
public float FiringArc = 120;

View file

@ -72,6 +72,9 @@ public class Zone
return false;
}
private readonly List<Departure> _departures = new List<Departure>();
internal IReadOnlyList<Departure> Departures => _departures;
public float Time
{
get => (float) _time;
@ -109,6 +112,9 @@ public class Zone
// every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) --
// ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone.
Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));
// The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass.
Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz,
remove.Value.Visibility - remove.Value.SignatureOn(SignatureChannel.Emission))));
Targets.Add(new EntityTargets());
@ -229,6 +235,8 @@ public class Zone
var entities = Entities.ToArray();
foreach (var entity in entities) entity.PublishInbox();
foreach (var entity in entities) entity.Update(deltaTime);
foreach (var entity in entities) entity.Sense();
_departures.Clear();
StepMines(deltaTime);
StepClouds(deltaTime);

View file

@ -386,7 +386,7 @@ public class PropertiesPanel : MonoBehaviour
private void AddEquippableItemProperties(EquippableItem item, Func<PerformanceStat, float> statValueFunction)
{
if (item.Durability < .01f)
if (item.IsDestroyed)
{
return;
}
@ -473,7 +473,7 @@ public class PropertiesPanel : MonoBehaviour
var gearData = GameManager.ItemManager.GetData(item.EquippableItem);
var statusSheet = AddStatSheet();
if (item.EquippableItem.Durability < .01f)
if (item.EquippableItem.IsDestroyed)
statusSheet.AddStat("Durability", () => "Item Destroyed!");
else statusSheet.AddStat("Durability", () => $"{(int)(item.EquippableItem.Durability / gearData.Durability * 100)}%");
statusSheet.AddStat("Temperature", () => ActionGameManager.PlayerSettings.FormatTemperature(item.Temperature));

File diff suppressed because it is too large Load diff

View file

@ -313,6 +313,32 @@ public sealed partial class MiningCut3Tests : IDisposable
$"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})");
}
// ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different
// places and bearings, one with a second sensor so the average is live, read the same chunk, and every value
// matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication
// by the chunk's brightness moves them in the last bits).
private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u };
[Fact]
public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction()
{
var kind = Kind("Asteroid", 1f);
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
var chunk = new ChunkId(s.Belts[0], 0);
var places = new[] { float2(50, -150), float2(-80, -60), float2(10, 200), float2(-300, 40), float2(120, 120), float2(0, -400) };
var values = new List<float>();
for (var i = 0; i < places.Length; i++)
{
var observer = SpawnShip(s, places[i], sensor: true, secondSensor: i == 2);
Tick((observer, places[i])); // brings the sensors online
values.Add(observer.ChunkInfo(chunk));
}
Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place
var actual = values.Select(Bits).ToArray();
Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual));
}
// A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit
// chunk of the same belt and kind can.
[Fact]