Zone.Entities read-only outside Zone: Admit and Remove are the only membership writers

Entities is a MemberView with no mutating member; Admit installs the death watch with the
membership and refuses a member already present, Remove takes it away with the membership.
The Reset and duplicate-Add bypass paths of the death watch cannot be spelled any more.
Tests move to Admit/Remove; Admit also activates, so the explicit Activate calls that followed
a raw Add are dropped. Unity call sites route through Zone.Remove.

Builds: unverified at this commit (verified in the job at the head).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 02:13:43 +02:00
commit fa911f2d85
50 changed files with 206 additions and 320 deletions

View file

@ -64,10 +64,8 @@ public static class ImpactPresenterSmoke
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings());
zone.Entities.Add(ship);
zone.Entities.Add(other);
ship.Activate();
other.Activate();
zone.Admit(ship, piloted: false);
zone.Admit(other, piloted: false);
// The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship.
var feedbackObject = new GameObject("Smoke Impact Feedback");

View file

@ -739,7 +739,7 @@ public class ActionGameManager : MonoBehaviour
if (CurrentEntity != null)
{
CurrentEntity.Deactivate();
CurrentEntity.Zone.Entities.Remove(CurrentEntity);
CurrentEntity.Zone.Remove(CurrentEntity);
CurrentEntity.Zone = Zone;
Zone.Admit(CurrentEntity, piloted: false);
}

View file

@ -1,51 +0,0 @@
using System;
using CultMath;
using CultMath.UnityBridge;
using UnityEngine;
using static CultMath.math;
using Random = UnityEngine.Random;
public class GridObject : MonoBehaviour
{
public float RotationSpeed;
public float GridOffset;
public float GridAttraction;
public float Gravity;
public float Drag = .1f;
public float LaunchDrag;
private float2 _selfVelocity;
private float _timeOffset;
public Zone Zone { get; set; }
public Vector3 Velocity { get; set; }
private void Start()
{
_timeOffset = Random.value * 100;
}
void Update()
{
var t = transform;
t.localRotation = Quaternion.Euler(sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90);
if (Zone == null) return;
var position = t.position;
var gridHeight = Zone.GetHeight(position.Flatland().ToCultMath()) + GridOffset;
Velocity += Vector3.up * (sign(gridHeight - position.y) * GridAttraction * Time.deltaTime);
Velocity *= max(0, 1 - LaunchDrag * Time.deltaTime);
var normal = Zone.GetNormal(position.Flatland().ToCultMath());
var force = new float2(normal.x, normal.z);
var forceMagnitude = lengthsq(force);
if (forceMagnitude > .001f)
{
var fa = 1 / (1 - forceMagnitude) - 1;
_selfVelocity += normalize(force) * Zone.Settings.GravityStrength * fa * Gravity;
}
_selfVelocity *= max(0, 1 - Drag * Time.deltaTime);
t.position = position + (Velocity + new Vector3(_selfVelocity.x, 0, _selfVelocity.y)) * Time.deltaTime;
}
}

View file

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 737f47c158214f0995ba2835f040ed96
timeCreated: 1618441439

View file

@ -1,19 +0,0 @@
using System;
using UnityEngine;
public class TimedDestroy : MonoBehaviour
{
public float Duration;
private float _startTime;
private void Start()
{
_startTime = Time.time;
}
public void Update()
{
if(Time.time - _startTime > Duration) Destroy(gameObject);
}
}

View file

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 5d5539a23b814254b038a16f32f1d8a9
timeCreated: 1618453298

View file

@ -1507,7 +1507,7 @@ public abstract class Entity
{
bay.DockedShip = ship;
ship.SetParent(this);
Zone.Entities.Remove(ship);
Zone.Remove(ship);
ship.Deactivate();
ship.Docked.OnNext(this);
}

View file

@ -15,7 +15,29 @@ using float2 = CultMath.float2;
public class Zone
{
public Action<string> Log;
public ReactiveCollection<Entity> Entities = new ReactiveCollection<Entity>();
// Membership, in admission order. Admit and Remove are the only writers; everyone else reads this view, which has
// no mutating member, so the death watch below follows membership by construction.
private readonly ReactiveCollection<Entity> _entities = new ReactiveCollection<Entity>();
public MemberView Entities { get; }
public sealed class MemberView : IReadOnlyReactiveCollection<Entity>
{
private readonly ReactiveCollection<Entity> _members;
internal MemberView(ReactiveCollection<Entity> members) => _members = members;
public int Count => _members.Count;
public Entity this[int index] => _members[index];
public int IndexOf(Entity entity) => _members.IndexOf(entity);
public bool Contains(Entity entity) => _members.Contains(entity);
public IEnumerator<Entity> GetEnumerator() => _members.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public UniRx.IObservable<CollectionAddEvent<Entity>> ObserveAdd() => _members.ObserveAdd();
public UniRx.IObservable<int> ObserveCountChanged(bool notifyCurrentCount = false) => _members.ObserveCountChanged(notifyCurrentCount);
public UniRx.IObservable<CollectionMoveEvent<Entity>> ObserveMove() => _members.ObserveMove();
public UniRx.IObservable<CollectionRemoveEvent<Entity>> ObserveRemove() => _members.ObserveRemove();
public UniRx.IObservable<CollectionReplaceEvent<Entity>> ObserveReplace() => _members.ObserveReplace();
public UniRx.IObservable<Unit> ObserveReset() => _members.ObserveReset();
}
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
// and every removal is a blast. Runtime state, never saved.
@ -105,30 +127,13 @@ public class Zone
// The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n).
private uint _deaths;
// One death subscription per member entity, owned here: a membership record and its subscription are created
// together, and only when the entity is not already a member. Leaving Entities (Remove) or resetting it (Clear)
// disposes it; nothing else touches it.
// One death subscription per member entity: Admit creates it together with the membership, Remove disposes it
// together with the membership, and nothing else touches either.
private readonly Dictionary<Entity, IDisposable> _deathWatches = new Dictionary<Entity, IDisposable>();
private void WatchDeath(Entity entity)
{
if (_deathWatches.ContainsKey(entity)) return;
_deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Entities.Remove(entity); entity.Deactivate(); }));
}
private void UnwatchDeath(Entity entity)
{
if (_deathWatches.Remove(entity, out var watch)) watch.Dispose();
}
private void UnwatchAll()
{
foreach (var watch in _deathWatches.Values) watch.Dispose();
_deathWatches.Clear();
}
public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy)
{
Entities = new MemberView(_entities);
_time = pack.Time;
GalaxyZone = galaxyZone;
Galaxy = galaxy;
@ -144,11 +149,8 @@ public class Zone
// ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone.
// The death watch lives exactly as long as the entity's membership in this zone: installed on admission,
// disposed on removal (death, dock, jump), so an entity admitted again never holds a second one.
Entities.ObserveAdd().Subscribe(add => WatchDeath(add.Value));
Entities.ObserveRemove().Subscribe(remove => UnwatchDeath(remove.Value));
Entities.ObserveReset().Subscribe(_ => UnwatchAll());
// 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,
_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());
@ -205,17 +207,30 @@ public class Zone
// TODO: Associate planets with stored entities for planetary colonies
}
// The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of Entities.Add and the only
// The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of membership and the only
// creator of agents. Construction, new-run staging (RunStart), warp arrival, undock and the console's spawned
// turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all.
// Only a ship can be piloted. The Death subscription above covers every join, whichever caller admits it.
// Only a ship can be piloted. The death watch is installed here, for every join, whichever caller admits it.
// An entity that is already a member is refused: it would hold a second watch and a second agent.
public void Admit(Entity entity, bool piloted)
{
Entities.Add(entity);
if (_entities.Contains(entity)) throw new InvalidOperationException("entity: already a member of this zone");
_entities.Add(entity);
_deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Remove(entity); entity.Deactivate(); }));
entity.Activate();
if (piloted) Agents.Add(CreateAgent((Ship) entity));
}
// The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and
// nothing changes, when the entity is not a member. The caller deactivates; Remove does not.
public bool Remove(Entity entity)
{
if (!_entities.Remove(entity)) return false;
_deathWatches.Remove(entity, out var watch);
watch.Dispose();
return true;
}
private Agent CreateAgent(Ship ship)
{
var agent = new Minion(ship);
@ -231,7 +246,7 @@ public class Zone
{
Radius = Pack.Radius,
Mass = Pack.Mass,
Entities = Entities.Select(EntitySerializer.Pack).ToList(),
Entities = _entities.Select(EntitySerializer.Pack).ToList(),
Orbits = Orbits.Keys.Select(key => new CultRecordRef<OrbitData>(key)).ToList(),
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(key)).ToList(),
Time = _time,

View file

@ -113,11 +113,10 @@ public sealed class BrownoutTests : IDisposable
// not flight, and a lone off-centre thruster could not fire without turning.
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Consumer")), new int2(2, 1)));
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
// Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the
// ship trying to turn. Aim steers nothing, so this one is only a plausible aim.
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}

View file

@ -96,9 +96,8 @@ public sealed class ConditionRatioTests : IDisposable
// not flight, and a lone off-centre thruster could not fire without turning.
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Consumer")), new int2(2, 1)));
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests
ship.Activate();
consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer");
Assert.True(ship.GetBehavior<Thruster>().Torque == 0f, "the lone thruster turns nothing");

View file

@ -83,8 +83,7 @@ public sealed class ConsumableTests : IDisposable
var ship = new Ship(items, zone, Gear(cache.Get(hullRef)), new EntitySettings());
Assert.True(ship.TryEquip(Gear(cache.Get(engineRef)), EngineCell));
Assert.True(ship.TryEquip(Gear(cache.Get(holdRef)), HoldCell));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
var data = cache.Get(consumableRef);
return new Rig
{

View file

@ -187,8 +187,7 @@ public sealed class ContactRevealTests : IDisposable
Assert.True(ship.TryFindSpace(item, out var at));
Assert.True(ship.TryEquip(item, at));
}
_zone.Entities.Add(ship);
ship.Activate();
_zone.Admit(ship, piloted: false);
ship.Position = position;
ship.Aim = float3(0, 0, 1);
_zone.Update(0f); // brings the gear online
@ -514,8 +513,8 @@ public sealed class ContactRevealTests : IDisposable
{
switch (how)
{
case "remove": zone.Entities.Remove(e); break;
case "warp": e.Deactivate(); zone.Entities.Remove(e); break;
case "remove": zone.Remove(e); break;
case "warp": e.Deactivate(); zone.Remove(e); break;
case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break;
default: throw new Exception("fixture: " + how);
}
@ -554,7 +553,7 @@ public sealed class ContactRevealTests : IDisposable
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate");
Assert.Equal(2, times.Count);
_zone.Entities.Remove(e);
_zone.Remove(e);
Assert.Equal(2, o.RevealsOf(e).Count());
var lapsed = false;
@ -640,7 +639,7 @@ public sealed class ContactRevealTests : IDisposable
var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f);
ShowOnly(e, gate * against, Guns(e)[0]);
_zone.Entities.Remove(e);
_zone.Remove(e);
Tick(.1f);
Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count());
@ -662,7 +661,7 @@ public sealed class ContactRevealTests : IDisposable
ShowOnly(e, threshold * 8f, Guns(e)[0]);
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate");
_zone.Entities.Remove(e);
_zone.Remove(e);
Tick(.1f);
Assert.Single(o.RevealsOf(e));
@ -679,7 +678,7 @@ public sealed class ContactRevealTests : IDisposable
ShowOnly(e, 0f, Guns(e)[0]);
e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]);
_zone.Entities.Remove(e);
_zone.Remove(e);
Tick(.1f);
Assert.Single(o.RevealsOf(e));
@ -696,8 +695,9 @@ public sealed class ContactRevealTests : IDisposable
Tick(1f); // the gun cools down
ShowOnly(e, 1000f, gun); // bright enough for any gate
_zone.Entities.Remove(e);
_zone.Entities.Add(e);
_zone.Remove(e);
e.Deactivate();
_zone.Admit(e, piloted: false);
Tick(.1f);
Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time);
@ -813,7 +813,7 @@ public sealed class ContactRevealTests : IDisposable
var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded
Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate");
_zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox
_zone.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox
var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count;
Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight");
Tick(.1f);
@ -865,7 +865,7 @@ public sealed class ContactRevealTests : IDisposable
for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f);
Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published
_zone.Entities.Remove(p);
_zone.Remove(p);
Tick(.1f);
Assert.Single(o.IncomingPaints);
@ -1039,7 +1039,7 @@ public sealed class ContactRevealTests : IDisposable
ShowOnly(e, 1f, gun);
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate");
_zone.Entities.Remove(e);
_zone.Remove(e);
Tick(.1f);
Assert.Single(o.RevealsOf(e));
@ -1069,8 +1069,8 @@ public sealed class ContactRevealTests : IDisposable
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate");
_zone.Entities.Remove(firstThenSecond ? a : b);
_zone.Entities.Remove(firstThenSecond ? b : a);
_zone.Remove(firstThenSecond ? a : b);
_zone.Remove(firstThenSecond ? b : a);
Tick(.1f);
Assert.Empty(o.RevealsOf(a));
@ -1096,7 +1096,7 @@ public sealed class ContactRevealTests : IDisposable
Tick(.01f);
Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting
e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it
_zone.Entities.Remove(e);
_zone.Remove(e);
for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s)
Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned
@ -1179,7 +1179,7 @@ public sealed class ContactRevealTests : IDisposable
Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f);
var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18]));
foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]);
_zone.Entities.Remove(departed);
_zone.Remove(departed);
Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone));
sense();
Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune

View file

@ -64,8 +64,7 @@ public sealed class ContactTests : IDisposable
{
var hullRef = Items.ItemData.RefOf<ItemData>(Items.ItemData.GetByName<HullData>(hull));
var ship = new Ship(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, new EntitySettings());
Zone.Entities.Add(ship);
ship.Activate();
Zone.Admit(ship, piloted: false);
ship.Position = at;
ship.Velocity = velocity;
return ship;

View file

@ -176,10 +176,8 @@ public sealed class FireAuthorityTests : IDisposable
// extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate().
beforeActivate?.Invoke(items, shooter, target);
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
@ -327,7 +325,7 @@ public sealed class FireAuthorityTests : IDisposable
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
e.Zone.Entities.Remove(e.Target);
e.Zone.Remove(e.Target);
e.Zone.Update(10f); // well past arrival
Assert.Equal(before, e.Target.Hull.Durability);

View file

@ -177,10 +177,8 @@ public sealed class FireControlCut11Tests : IDisposable
Assert.True(target.TryEquip(shield, spos));
}
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3(37, 0, -11);
target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter
@ -280,7 +278,7 @@ public sealed class FireControlCut11Tests : IDisposable
// What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s.
Assert.Equal(.3f, committed.ArrivalIn, 3);
e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives
e.Zone.Remove(e.Target); // the target is gone before the shot arrives
e.Zone.Update(1f);
Assert.NotNull(resolved);
@ -307,7 +305,7 @@ public sealed class FireControlCut11Tests : IDisposable
e.Zone.Update(1f);
SafeAssert.Empty(committed);
e.Zone.Entities.Remove(e.Target);
e.Zone.Remove(e.Target);
e.Zone.Update(1f);
foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) })

View file

@ -224,10 +224,8 @@ public sealed class FireControlCut123Tests : IDisposable
cockpit = target.Equipment.Single(x => x.EquippableItem == ci);
}
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
// Cut 11.3: the shooter sits off the origin.
shooter.Position = float3(37, 0, -11);

View file

@ -242,10 +242,8 @@ public sealed partial class FireControlCut124Tests : IDisposable
Assert.True(target.TryEquip(shield, spos));
}
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
// Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits
// `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is
@ -515,8 +513,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
Data = e.Items.ItemData.RefOf<ItemData>(e.Items.ItemData.GetByName<GearData>("Marker")), Durability = 1000000, Lot = lot + 1
};
Assert.True(ship.TryEquip(marker, new int2(1, 1)));
e.Zone.Entities.Add(ship);
ship.Activate();
e.Zone.Admit(ship, piloted: false);
ship.Position = position;
return ship;
}
@ -1465,7 +1462,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target");
e.Zone.Update(.01f);
e.Zone.Entities.Remove(e.Target);
e.Zone.Remove(e.Target);
e.Zone.Update(3f);
SafeAssert.NoShots(e.Zone);
@ -1510,7 +1507,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer"));
e.Zone.Entities.Remove(e.Target);
e.Zone.Remove(e.Target);
Assert.ThrowsAny<Exception>(() => e.Zone.Update(.01f));
var second = Record.Exception(() => e.Zone.Update(.01f));
@ -2004,7 +2001,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f);
e.Zone.Entities.Remove(e.Shooter);
e.Zone.Remove(e.Shooter);
e.Zone.Update(4f);
SafeAssert.NoShots(e.Zone);
@ -2022,7 +2019,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
e.Zone.Entities.Remove(e.Shooter);
e.Zone.Remove(e.Shooter);
e.Zone.Update(6f);
SafeAssert.NoShots(e.Zone);
@ -2044,7 +2041,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
e.Zone.Entities.Remove(e.Shooter);
e.Zone.Remove(e.Shooter);
e.Zone.Update(6f);
SafeAssert.NoShots(e.Zone);
@ -2274,7 +2271,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(committedFirst ? 4.6f : .01f);
Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed);
e.Zone.Entities.Remove(e.Target);
e.Zone.Remove(e.Target);
e.Zone.Update(6f);
foreach (var s in subscriptions) s.Dispose();

View file

@ -269,10 +269,8 @@ public sealed class FireControlCut12Tests : IDisposable
Assert.True(target.TryEquip(shield, spos));
}
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
// Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and
// `target + source` the same vector, hiding a whole class of sign mutants.

View file

@ -154,10 +154,8 @@ public sealed class FireControlCut4Tests : IDisposable
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
@ -233,8 +231,7 @@ public sealed class FireControlCut4Tests : IDisposable
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon);
zone.Entities.Add(shooter);
shooter.Activate();
zone.Admit(shooter, piloted: false);
zone.Update(0f); // warm up resolved stats
// No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation.
@ -276,7 +273,7 @@ public sealed class FireControlCut4Tests : IDisposable
var ship = new Ship(items, zone, hull, new EntitySettings());
ship.Position = position;
ship.Direction = direction;
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
return ship;
}

View file

@ -184,10 +184,8 @@ public sealed class FireControlCut5Tests : IDisposable
beforeActivate?.Invoke(items, shooter, target);
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
@ -339,10 +337,8 @@ public sealed class FireControlCut5Tests : IDisposable
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = targetPosition;
@ -427,10 +423,8 @@ public sealed class FireControlCut5Tests : IDisposable
var targetGun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 4 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
return (items, shooter, target, weaponItem);
}
@ -576,8 +570,7 @@ public sealed class FireControlCut5Tests : IDisposable
Assert.True(target.TryEquip(medium, mediumPos));
var mediumItem = target.Equipment.Single(e => e.EquippableItem == medium);
zone.Entities.Add(target);
target.Activate();
zone.Admit(target, piloted: false);
return (items, target, bigItem, mediumItem);
}
@ -592,8 +585,7 @@ public sealed class FireControlCut5Tests : IDisposable
var (items, target, big, medium) = BuildRevealTarget(settings);
var observerHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
target.Zone.Entities.Add(observer);
observer.Activate();
target.Zone.Admit(observer, piloted: false);
observer.SetTarget(target);
// With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8.

View file

@ -156,10 +156,8 @@ public sealed class FireControlCut6Tests : IDisposable
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);

View file

@ -138,10 +138,8 @@ public sealed class FireControlCut6cTests : IDisposable
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
@ -267,8 +265,7 @@ public sealed class FireControlCut6cTests : IDisposable
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
var entity = EntitySerializer.Unpack(items, zone, pack);
zone.Entities.Add(entity);
entity.Activate();
zone.Admit(entity, piloted: false);
zone.Update(0f);
Assert.Null(entity.GetBehavior<TargetingSystem>());

View file

@ -156,10 +156,8 @@ public sealed class FireControlCut6dTests : IDisposable
Assert.True(target.TryEquip(marker, markerCell.Value));
}
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);

View file

@ -133,10 +133,8 @@ public sealed class FireControlCut7Tests : IDisposable
beforeActivate?.Invoke(items, shooter, target);
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, 100);

View file

@ -129,10 +129,8 @@ public sealed class FireControlCut9Tests : IDisposable
Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos));
Assert.True(target.TryEquip(targetMarker, targetMarkerPos));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, 100);
@ -340,10 +338,8 @@ public sealed class FireControlCut9Tests : IDisposable
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = float3(0, 0, 100);
@ -567,10 +563,8 @@ public sealed class FireControlCut9Tests : IDisposable
var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 };
Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run
zone.Entities.Add(locker);
zone.Entities.Add(bystander);
locker.Activate();
bystander.Activate();
zone.Admit(locker, piloted: false);
zone.Admit(bystander, piloted: false);
locker.Position = float3.zero;
bystander.Position = float3(0, 0, 100);
@ -603,7 +597,7 @@ public sealed class FireControlCut9Tests : IDisposable
// the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove
// subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so
// nothing reacts to this removal yet.
s.Zone.Entities.Remove(s.Bystander);
s.Zone.Remove(s.Bystander);
s.Locker.Activate(); // undock
Assert.True(s.Locker.Active);

View file

@ -238,10 +238,8 @@ public sealed class FireControlPerWeaponTests : IDisposable
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();
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3(-53, 0, -101);
target.Position = shooter.Position + float3(0, 0, targetRange);

View file

@ -95,8 +95,7 @@ public sealed class FireControlTests : IDisposable
weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun);
}
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return (items, ship, weapons);
}
@ -195,8 +194,7 @@ 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)));
zone.Entities.Add(target);
target.Activate();
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = targetPosition;

View file

@ -239,9 +239,8 @@ public sealed class FloatingBodyTests : IDisposable
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
var ship = new Ship(items, zone, hull, new EntitySettings());
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Aim = float3(0, 0, 1);
ship.Activate();
ship.Position = float3(x, 0, 0);
return ship;
}

View file

@ -82,8 +82,7 @@ public sealed class IffAndCombatTests : IDisposable
Assert.True(ship.TryEquip(gun, HardpointCell));
}
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return ship;
}
@ -132,8 +131,9 @@ public sealed class IffAndCombatTests : IDisposable
a.SetIff(b, true);
Assert.True(a.EntityHostility[b]);
zone.Entities.Remove(b); // b leaves the zone: a's override on b must be cleared
zone.Entities.Add(b); // b returns as a "new" contact from a's perspective
zone.Remove(b); // b leaves the zone: a's override on b must be cleared
b.Deactivate();
zone.Admit(b, piloted: false); // b returns as a "new" contact from a's perspective
Assert.False(a.EntityHostility[b]); // recomputed fresh from the derived (non-hostile) rule
Assert.False(a.IsHostileTo(b));

View file

@ -33,8 +33,7 @@ public sealed partial class FireControlCut124Tests
{
var hullRef = e.Items.ItemData.RefOf<ItemData>(e.Items.ItemData.GetByName<HullData>("Hull"));
var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = lot }, new EntitySettings());
e.Zone.Entities.Add(ship);
ship.Activate();
e.Zone.Admit(ship, piloted: false);
ship.Position = position;
return ship;
}
@ -135,7 +134,7 @@ public sealed partial class FireControlCut124Tests
var gone = Build(TestSettings(), SolidShape(5, 4), velocity: 50f, damage: 100f);
var goneAbsorbs = new[] { gone.Target, gone.Shooter }.SelectMany(x => Collect(x, subs)).ToList();
FireControl.Fire(gone.Weapon, gone.WeaponItem, gone.Shooter);
gone.Zone.Entities.Remove(gone.Target);
gone.Zone.Remove(gone.Target);
for (var i = 0; i < 40; i++) gone.Zone.Update(.1f);
foreach (var s in subs) s.Dispose();

View file

@ -97,8 +97,7 @@ public sealed class InputCapacitorTests : IDisposable
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return (ship, items, ship.GetBehavior<Reactor>());
}

View file

@ -105,8 +105,7 @@ public sealed class LockWarningTests : IDisposable
var gearRef = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<GearData>(launcher));
Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell));
}
_zone.Entities.Add(ship);
ship.Activate();
_zone.Admit(ship, piloted: false);
ship.Position = position;
_zone.Update(0f); // brings the gear online
return ship;

View file

@ -133,8 +133,7 @@ public sealed class LootTests : IDisposable
Assert.True(ship.CargoBays.Single().TryStore(lab.Ore(7)));
Assert.True(ship.CargoBays.Single().TryStore(lab.Make("Spare")));
}
lab.Zone.Entities.Add(ship);
ship.Activate();
lab.Zone.Admit(ship, piloted: false);
ship.Position = at;
return ship;
}
@ -153,8 +152,7 @@ public sealed class LootTests : IDisposable
Assert.True(station.TryEquip(spare, pos));
Assert.True(station.CargoBays.Single().TryStore(lab.Ore(3)));
}
lab.Zone.Entities.Add(station);
station.Activate();
lab.Zone.Admit(station, piloted: false);
station.Position = at;
return station;
}
@ -393,10 +391,10 @@ public sealed class LootTests : IDisposable
var lab = Build(probability: 1f);
var ship = Spawn(lab, float3(3, 0, 4));
// What Entity.TryDock then Entity.TryUndock do to the zone: Entities.Remove + Deactivate, then Zone.Admit.
lab.Zone.Entities.Remove(ship);
lab.Zone.Remove(ship);
ship.Deactivate();
lab.Zone.Admit(ship, piloted: false);
lab.Zone.Entities.Remove(ship);
lab.Zone.Remove(ship);
lab.Zone.Admit(ship, piloted: false);
Kill(ship);
var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList();
@ -412,7 +410,7 @@ public sealed class LootTests : IDisposable
var ship = Spawn(lab, float3(3, 0, 4));
// What ActionGameManager does on a jump.
ship.Deactivate();
lab.Zone.Entities.Remove(ship);
lab.Zone.Remove(ship);
ship.Zone = other;
other.Admit(ship, piloted: false);
Kill(ship);
@ -509,9 +507,10 @@ public sealed class LootTests : IDisposable
Assert.True(lab.Zone.FloatingItems.Select(f => Math.Round(Math.Atan2(f.Body.Velocity.y, f.Body.Velocity.x), 3)).Distinct().Count() > 1);
}
// A member added twice holds one death subscription, so it drops each item once, as a single death would.
// Membership has one owner (aetheria-release zone-entities-encapsulation): a second admission of a member is
// refused, so it never holds a second death watch and the zone never lists it twice.
[Fact]
public void AMemberAddedTwiceStillDropsEachItemOnce()
public void AMemberAdmittedTwiceIsRefusedAndStillDropsEachItemOnce()
{
var once = Build("Once", probability: 1f);
Kill(Spawn(once, float3(3, 0, 4)));
@ -520,20 +519,45 @@ public sealed class LootTests : IDisposable
var lab = Build("Twice", probability: 1f);
var ship = Spawn(lab, float3(3, 0, 4));
lab.Zone.Entities.Add(ship);
Assert.Throws<InvalidOperationException>(() => lab.Zone.Admit(ship, piloted: false));
Assert.Equal(1, lab.Zone.Entities.Count(e => e == ship));
Kill(ship);
Assert.Equal(baseline, lab.Zone.FloatingItems.Count);
}
// Clearing Entities removes every member, so a death afterwards drops nothing in this zone.
// Remove is the one removal: a non-member is refused with nothing changed, and a member's removal takes its death
// watch with it, so a death signal afterwards drops nothing and a re-admission watches exactly once.
[Fact]
public void AClearedZoneDropsNothingWhenAFormerMemberDies()
public void RemovingANonMemberChangesNothingAndARemovedMembersWatchGoesWithIt()
{
var lab = Build("Cleared", probability: 1f);
var lab = Build(probability: 1f);
var ship = Spawn(lab, float3(3, 0, 4));
lab.Zone.Entities.Clear();
var stranger = Spawn(Build("Elsewhere", probability: 1f), float3(1, 0, 1));
var members = lab.Zone.Entities.Count;
Assert.False(lab.Zone.Remove(stranger));
Assert.Equal(members, lab.Zone.Entities.Count);
Assert.True(lab.Zone.Remove(ship));
Assert.False(lab.Zone.Remove(ship));
Kill(ship);
Assert.Empty(lab.Zone.FloatingItems);
ship.Deactivate();
lab.Zone.Admit(ship, piloted: false);
Kill(ship);
var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList();
Assert.NotEmpty(dropped);
Assert.Equal(dropped.Count, dropped.Distinct().Count());
}
// The membership view has no mutating member: a caller can read it and subscribe to it, and nothing else.
[Fact]
public void TheMembershipViewOffersNoWayToMutateMembership()
{
var lab = Build();
Spawn(lab, float3(3, 0, 4));
Assert.IsNotAssignableFrom<System.Collections.Generic.ICollection<Entity>>(lab.Zone.Entities);
Assert.IsNotAssignableFrom<System.Collections.IList>(lab.Zone.Entities);
}
// The pickup is whole or nothing at the arithmetic boundary: two stacks of 90 hold 20 more in all and no free

View file

@ -130,8 +130,7 @@ public sealed class MineTests : IDisposable
var targeting = lab.Make("Targeting", 1);
Assert.True(layer.TryFindSpace(targeting, out var tpos));
Assert.True(layer.TryEquip(targeting, tpos));
zone.Entities.Add(layer);
layer.Activate();
zone.Admit(layer, piloted: false);
layer.Position = layerAt ?? float3(-53, 0, -101);
lab.Layer = layer;
zone.Update(1f);
@ -152,8 +151,7 @@ public sealed class MineTests : IDisposable
Assert.True(ship.TryFindSpace(gear, out var spos));
Assert.True(ship.TryEquip(gear, spos));
}
lab.Zone.Entities.Add(ship);
ship.Activate();
lab.Zone.Admit(ship, piloted: false);
ship.Position = at;
if (shield != null) ship.Shield.Item.Enabled.Value = true;
return ship;

View file

@ -222,10 +222,9 @@ public sealed partial class MiningCut3Tests : IDisposable
s.Cache.Upsert(gun);
Assert.True(ship.TryEquip(Mint(s, gun)));
}
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, at);
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}
@ -867,10 +866,9 @@ public sealed partial class MiningCut3Tests : IDisposable
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
Assert.True(ship.TryEquip(Mint(s, s.Eye)));
foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon)));
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, eyeAt);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, eyeAt)); // weapons read their ranges when they run
return ship;
}

View file

@ -54,10 +54,9 @@ public sealed partial class MiningCut3Tests
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
Assert.True(ship.TryEquip(Mint(s, eye)));
Assert.True(ship.TryEquip(Mint(s, gun)));
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, at);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick
return ship;
}
@ -177,7 +176,7 @@ public sealed partial class MiningCut3Tests
Assert.True(expected.ToHashSet().SetEquals(offered), $"sensitivity {sensitivity}: {expected.Count} visible, {offered.Count} offered");
visibleSeen += expected.Count;
if (examined < inReach.Count) pruned++;
s.Zone.Entities.Remove(observer);
s.Zone.Remove(observer);
}
Assert.True(visibleSeen > 50 && pruned > 4, $"degenerate: {visibleSeen} visible, {pruned} queries pruned");
}
@ -422,7 +421,7 @@ public sealed partial class MiningCut3Tests
var brightest = all.Max(c => s.Zone.ChunkVisibility(c));
var unit = Observer(s, float2(5000, 0), 350f, 1f, Falloff());
var sensitivity = 120f / (brightest * unit.DetectionReachPerVisibility());
s.Zone.Entities.Remove(unit);
s.Zone.Remove(unit);
var offered = new List<ChunkId>();
long examined = 0, inReach = 0;
@ -437,7 +436,7 @@ public sealed partial class MiningCut3Tests
observer.VisibleChunksInReach(offered);
examined += s.Zone.Targets.Examined - before;
inReach += all.Count(c => length(At(s, c) - at) <= 350f);
s.Zone.Entities.Remove(observer);
s.Zone.Remove(observer);
}
Assert.True(examined < .265 * inReach, $"examined {examined} of {inReach} rocks in reach: the visibility bound has loosened");
}

View file

@ -33,7 +33,7 @@ public sealed partial class MiningCut3Tests
{
var observer = Observer(s, at, reach, sensitivity, Falloff());
if (inReach.Count(observer.ChunkVisible) >= share * inReach.Count) return (s, observer);
s.Zone.Entities.Remove(observer);
s.Zone.Remove(observer);
}
throw new InvalidOperationException("no sensitivity sees the rocks");
}
@ -42,7 +42,7 @@ public sealed partial class MiningCut3Tests
private static Ship Sight(Scene s, Ship observer, float2 at, bool enemy = false)
{
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, at);
observer.VisibleEntities.Add(ship);
if (enemy) observer.VisibleEnemies.Add(ship);
@ -265,7 +265,7 @@ public sealed partial class MiningCut3Tests
observer.Aim = float3(cos(heading), 0, sin(heading));
Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle);
}
s.Zone.Entities.Remove(observer);
s.Zone.Remove(observer);
}
}
Assert.True(failures.Count == 0, string.Join("; ", failures));
@ -336,10 +336,9 @@ public sealed partial class MiningCut3Tests
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
Assert.True(ship.TryEquip(Mint(s, s.Eye)));
Assert.True(ship.TryEquip(Mint(s, launcher)));
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, at);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, at));
Assert.Contains(ship.Weapons, w => w.Range == 400f);
Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range");
@ -375,7 +374,7 @@ public sealed partial class MiningCut3Tests
private static Ship Unseen(Scene s, float2 at)
{
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
s.Zone.Entities.Add(ship);
s.Zone.Admit(ship, piloted: false);
Hold(ship, at);
return ship;
}

View file

@ -266,8 +266,7 @@ public sealed class MunitionPartsTests : IDisposable
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name);
}
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
// Settle at the item's optimal temperature, as ConditionRatioTests does, so heat and wear read their identity and only
// the role's quality moves a stat.
var item = ship.Equipment.Single(e => e.Data.Name == "GT 3K");

View file

@ -125,8 +125,7 @@ public sealed class PaintModeTests : IDisposable
var gearRef2 = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<GearData>(sensor2));
Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef2, Durability = 1, Lot = _nextLot++ }, new int2(1, 0)));
}
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
ship.Position = position;
ship.SetSignature(new object(), SignatureChannel.Reflection, 5f, null);
zone.Update(0f); // brings the gear online
@ -334,7 +333,7 @@ public sealed class PaintModeTests : IDisposable
if (tick == 2 && illuminatorDies)
{
Assert.True(SensorOf(a).PingRadius < 100f, "fixture: A dies before its wavefront reaches T");
_zone.Entities.Remove(a);
_zone.Remove(a);
}
track.Add(Track(c, t));
}
@ -425,7 +424,7 @@ public sealed class PaintModeTests : IDisposable
for (var tick = 1; tick <= 24; tick++)
{
Tick(.1f);
if (tick == removePingerAfterTick) _zone.Entities.Remove(a);
if (tick == removePingerAfterTick) _zone.Remove(a);
Near(tick <= 3 ? 0f : .21213f, Track(c, t));
Assert.Equal(tick == 4 ? 1 : 0, t.IncomingPaints.Count);
}
@ -686,7 +685,7 @@ public sealed class PaintModeTests : IDisposable
SensorOf(a).Ping();
for (var tick = 1; tick <= 3; tick++) Tick(.1f);
Assert.True(SensorOf(a).PingRadius > 50f); // the front had passed 50 m by the move
a.Deactivate(); _zone.Entities.Remove(a); a.Zone = z2; z2.Admit(a, false);
a.Deactivate(); _zone.Remove(a); a.Zone = z2; z2.Admit(a, false);
var paints = 0;
for (var tick = 1; tick <= 24; tick++) { z2.Update(.1f); paints += near.IncomingPaints.Count + far.IncomingPaints.Count; }
Assert.Equal(0, paints);
@ -706,8 +705,8 @@ public sealed class PaintModeTests : IDisposable
var paints = 0;
for (var tick = 1; tick <= 30; tick++)
{
if (tick == 5) { _zone.Entities.Remove(t); t.Deactivate(); }
if (tick == 8) { _zone.Entities.Add(t); t.Activate(); }
if (tick == 5) { _zone.Remove(t); t.Deactivate(); }
if (tick == 8) { _zone.Admit(t, piloted: false); }
Tick(.1f);
paints += t.IncomingPaints.Count;
}

View file

@ -101,8 +101,7 @@ public sealed class PowerBusTests : IDisposable
if (reactorFirst) { EquipReactor(); EquipCapacitor(); } else { EquipCapacitor(); EquipReactor(); }
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
var reactor = ship.GetBehavior<Reactor>();
var capacitor = ship.GetBehavior<Capacitor>();

View file

@ -103,8 +103,7 @@ public sealed class PowerCurveTests : IDisposable
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Engine"))));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return ship;
}
@ -408,9 +407,8 @@ public sealed class PowerCurveTests : IDisposable
var ship = new Ship(items, zone, hullItem, new EntitySettings());
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Drain")), HardpointCell));
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Booster")), SecondHardpointCell));
zone.Entities.Add(ship);
var error = Assert.Throws<InvalidOperationException>(() => ship.Activate());
var error = Assert.Throws<InvalidOperationException>(() => zone.Admit(ship, piloted: false));
Assert.Contains("Drain", error.Message); // the item whose request would be corrupted
Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it
}

View file

@ -72,8 +72,7 @@ public sealed class PowerTierTests : IDisposable
((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge);
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
var reactor = ship.GetBehavior<Reactor>();
ship.Update(0f);

View file

@ -314,9 +314,8 @@ public sealed class RestoredHullsTests
Assert.True(ship.TryEquip(fillerItem, filled.Position));
}
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Position = float3.zero;
ship.Activate();
zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them
return ship;
}
@ -618,9 +617,8 @@ public sealed class RestoredHullsTests
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
var ship = (Ship) EntitySerializer.Unpack(items, zone, pack);
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Position = float3.zero;
ship.Activate();
zone.Update(0f);
ship.MovementDirection = float2(0, 1); // full forward

View file

@ -95,8 +95,7 @@ public sealed class ShieldReserveTests : IDisposable
var shieldData = cache.GetByName<GearData>("Shield");
Assert.True(ship.TryEquip(Mint(cache, items, shieldData)));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return (ship, ship.GetBehavior<Shield>());
}

View file

@ -159,8 +159,7 @@ public sealed class SignatureTests : IDisposable
Assert.True(ship.TryEquip(item, at));
}
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
ship.Aim = float3(0, 0, 1);
zone.Update(1f);
return rig;

View file

@ -189,8 +189,7 @@ public sealed partial class FireControlCut124Tests
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
var hull = e.Items.ItemData.RefOf<ItemData>(e.HullData);
var decoy = new Ship(e.Items, e.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = 400 }, new EntitySettings());
e.Zone.Entities.Add(decoy);
decoy.Activate();
e.Zone.Admit(decoy, piloted: false);
var at = radians(40f);
decoy.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * 100f;
e.Shooter.VisibleEntities.Add(decoy);

View file

@ -71,8 +71,7 @@ public sealed class StatResolverTests : IDisposable
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Battery")), HardpointCell));
if (withBooster)
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Booster")), SecondHardpointCell));
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
return ship;
}
@ -287,9 +286,8 @@ public sealed class StatResolverTests : IDisposable
var hullItem = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
var ship = new Ship(items, zone, hullItem, new EntitySettings());
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Loop")), HardpointCell));
zone.Entities.Add(ship);
var error = Assert.Throws<InvalidOperationException>(() => ship.Activate());
var error = Assert.Throws<InvalidOperationException>(() => zone.Admit(ship, piloted: false));
Assert.Contains(ship.Name, error.Message); // the entity, by name ("Skiff", HullData.Name)
Assert.Contains(nameof(CapacitorData), error.Message);
Assert.Contains(nameof(CapacitorData.Capacity), error.Message);
@ -513,8 +511,7 @@ public sealed class StatResolverTests : IDisposable
Assert.True(ship.TryEquip(boost, cells[i + 1]));
}
ship.OverrideShutdown = true;
zone.Entities.Add(ship);
ship.Activate();
zone.Admit(ship, piloted: false);
Func<ConsumableItem> mint = null;
if (consumable != null)

View file

@ -147,8 +147,7 @@ public sealed class TargetingSystemTests : IDisposable
targetingItem = target.Equipment.Single(e => e.EquippableItem == targeting);
}
zone.Entities.Add(target);
target.Activate();
zone.Admit(target, piloted: false);
return (items, target, weapon, wideItem, narrowItem, targetingItem);
}
@ -160,8 +159,7 @@ public sealed class TargetingSystemTests : IDisposable
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull"));
var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 };
var observer = new Ship(items, zone, hull, new EntitySettings());
zone.Entities.Add(observer);
observer.Activate();
zone.Admit(observer, piloted: false);
return observer;
}
@ -351,9 +349,8 @@ public sealed class TargetingSystemTests : IDisposable
targeting.Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(targetingData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List<RoleFill>() });
Assert.True(ship.TryEquip(targeting));
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}

View file

@ -105,9 +105,8 @@ public sealed class ThrottleLockTests : IDisposable
}
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3)));
zone.Entities.Add(ship);
zone.Admit(ship, piloted: false);
ship.Aim = float3(0, 0, 1);
ship.Activate();
// A thruster is live from its first performance update; the allocator reads that every tick.
foreach (var item in ship.Equipment) item.UpdatePerformance();

View file

@ -130,10 +130,8 @@ public sealed class VapourCloudTests : IDisposable
// Held off until a test pulls the trigger: the dump vents on its first run.
lab.Dump.Enabled.Value = false;
zone.Entities.Add(lab.Observer);
zone.Entities.Add(lab.Target);
lab.Observer.Activate();
lab.Target.Activate();
zone.Admit(lab.Observer, piloted: false);
zone.Admit(lab.Target, piloted: false);
lab.Observer.Position = float3(0, 0, 0);
lab.Observer.Aim = float3(0, 0, 1);
lab.Target.Position = float3(0, 0, distance);