Merge cuts loot-1-present and loot-2 (Soul verdicts cut-loot-1-present.s1, cut-loot-2.s2)

Conflicts: Behaviors.cs keeps Union(48, PickupData) and Union(49, LaunchSuppressorData);
Zone.cs keeps the departure record on _entities with master's Visibility (emissions-heard);
ContactRevealTests.cs keeps master's removal of the emission-departure test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 08:27:47 +02:00
commit fbe05c7992
84 changed files with 1881 additions and 657 deletions

View file

@ -189,6 +189,7 @@ public interface IPowerConsumer
Union(45, typeof(MunitionSeekerData)),
Union(46, typeof(MunitionControlData)),
Union(47, typeof(MunitionFuelData)),
Union(48, typeof(PickupData)),
Union(49, typeof(LaunchSuppressorData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData

View file

@ -0,0 +1,171 @@
using System;
using System.Linq;
using CultMath;
using MessagePack;
using Newtonsoft.Json;
using static CultMath.math;
using float2 = CultMath.float2;
// The tractor beam: the capability that lets a ship collect floating items. It spins its beam up in sim time toward
// the pilot's command, and once the beam is up claims the free item nearest its axis and runs one timed grab
// (extend, envelop, pull) that ends in Zone.TryPickUp, the one commit of a pickup. Its stats are flat
// (Min == Max), as the other behaviours that carry their own timings.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class PickupData : BehaviorData
{
// How far along the beam's axis an item can be claimed, and how far off the axis.
[Inspectable, JsonProperty("range"), Key(1), RuntimeInspectable]
public PerformanceStat Range = new PerformanceStat();
[Inspectable, JsonProperty("beamRadius"), Key(2), RuntimeInspectable]
public PerformanceStat BeamRadius = new PerformanceStat();
// The spin-up (0..1) needed to claim an item, and to keep a grab going.
[Inspectable, JsonProperty("grabPower"), Key(3), RuntimeInspectable]
public PerformanceStat GrabPower = new PerformanceStat();
// Seconds of each phase of a grab.
[Inspectable, JsonProperty("extendTime"), Key(4), RuntimeInspectable]
public PerformanceStat ExtendTime = new PerformanceStat();
[Inspectable, JsonProperty("envelopTime"), Key(5), RuntimeInspectable]
public PerformanceStat EnvelopTime = new PerformanceStat();
[Inspectable, JsonProperty("pullTime"), Key(6), RuntimeInspectable]
public PerformanceStat PullTime = new PerformanceStat();
// Spin-up and spin-down, in power per sim second.
[Inspectable, JsonProperty("spinRate"), Key(7), RuntimeInspectable]
public PerformanceStat SpinRate = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new Pickup(this, item);
}
// A beam is a piece of gear the pilot holds on; no consumable carries one.
public override Behavior CreateInstance(ConsumableItemEffect item) =>
throw new NotSupportedException("pickup: gear only");
}
// Owns this ship's beam power and its one grab. Power is written only here; Zone owns the held item's pose.
public class Pickup : Behavior, IAlwaysUpdatedBehavior
{
private readonly PickupData _data;
// Power and the grab clock are kept in double so a fixed tick count lands on the same side of a boundary
// whatever dt the zone steps by.
private double _power;
private bool _grabbing;
private FloatingBodyId _target;
private double _elapsed;
// How many of Envelop and Pull have been published so far (0, 1 or 2).
private int _published;
public Pickup(PickupData data, EquippedItem item) : base(data, item)
{
_data = data;
}
// The beam's spun-up state in [0, 1]: what the pilot's command has driven it to.
public float Power => (float) _power;
public void Update(float dt)
{
var active = Item.Active.Value;
var command = active ? Math.Clamp(Entity.TractorCommand, 0f, 1f) : 0f;
var step = (double) Evaluate(_data.SpinRate) * dt;
_power = Math.Clamp(_power + Math.Clamp(command - _power, -step, step), 0.0, 1.0);
var zone = Entity.Zone;
var grabPower = Evaluate(_data.GrabPower);
var extend = (double) Evaluate(_data.ExtendTime);
var envelop = (double) Evaluate(_data.EnvelopTime);
var pull = (double) Evaluate(_data.PullTime);
var range = Evaluate(_data.Range);
var radius = Evaluate(_data.BeamRadius);
var origin = Entity.Position.xz;
if (!_grabbing)
{
if (!active || _power < grabPower) return;
var item = Select(zone, origin, range, radius);
if (item == null || !zone.TryHold(Entity, item.Id)) return;
_grabbing = true;
_target = item.Id;
_elapsed = 0;
_published = 0;
Publish(GrabPhase.Started, item, 0);
Publish(GrabPhase.Extend, item, (float) extend);
return;
}
var held = zone.FloatingItems.FirstOrDefault(f => f.Id.Equals(_target));
if (held == null || !ReferenceEquals(held.Holder, Entity))
{
_grabbing = false;
Entity.Capabilities.PublishGrab(new GrabEvent(_target, GrabPhase.Cancelled, float3(0), 0));
return;
}
var pulling = _elapsed >= extend + envelop;
if (!active || _power < grabPower || (!pulling && !InBeam(held.Body.Position, origin, range, radius, out _)))
{
_grabbing = false;
zone.Free(_target);
Publish(GrabPhase.Cancelled, held, 0);
return;
}
_elapsed += dt;
if (_published < 1 && _elapsed >= extend) { _published = 1; Publish(GrabPhase.Envelop, held, (float) envelop); }
if (_published < 2 && _elapsed >= extend + envelop) { _published = 2; Publish(GrabPhase.Pull, held, (float) pull); }
if (_published == 2)
zone.SetHeldProgress(_target, pull > 0 ? (float) ((_elapsed - extend - envelop) / pull) : 1f);
if (_elapsed < extend + envelop + pull) return;
_grabbing = false;
if (zone.TryPickUp(Entity, _target)) Publish(GrabPhase.Completed, held, 0);
else
{
zone.Free(_target);
Publish(GrabPhase.Cancelled, held, 0);
}
}
// The one beam region: within Range along the aim axis (never behind) and BeamRadius off it. Selection claims
// from it and the grab holds on to its item only while the item stays in it. A zero aim has no region.
private bool InBeam(float2 position, float2 origin, float range, float radius, out float off)
{
off = 0;
var aim = Entity.Aim.xz;
if (lengthsq(aim) <= 0) return false;
var axis = normalize(aim);
var to = position - origin;
var along = dot(to, axis);
off = length(to - axis * along);
return along >= 0 && along <= range && off <= radius;
}
// The free item nearest the beam axis within the beam region that some cargo bay can store whole (the bay's own
// TryFindSpace, the test TryStore opens with); ties go to the lowest id. A zero aim selects nothing.
private FloatingItem Select(Zone zone, float2 origin, float range, float radius)
{
FloatingItem best = null;
var bestOff = float.MaxValue;
foreach (var floating in zone.FloatingItems)
{
if (floating.Holder != null) continue;
if (!InBeam(floating.Body.Position, origin, range, radius, out var off)) continue;
if (!Entity.CargoBays.Any(bay => bay.TryFindSpace(floating.Item))) continue;
if (off < bestOff || (off == bestOff && floating.Id.Value < best.Id.Value))
{
best = floating;
bestOff = off;
}
}
return best;
}
private void Publish(GrabPhase phase, FloatingItem item, float duration) =>
Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, Entity.Position.y, item.Body.Position.y), duration));
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac7678d240564331be7486588d28c5ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -52,19 +52,18 @@ public enum GrabPhase
Cancelled
}
// The target is identified by a plain handle, not a Unity type: the simulation will own the real
// target identity (a loot drop or item instance id) once the pickup capability exists. Today's only
// publisher (the FieldShieldTest scene) hands out its own handles; nothing here interprets them.
// One phase of a grab. The target is the floating item the Pickup behaviour holds (the FieldShieldTest scene
// publishes its own handles as ids); nothing here interprets it.
public readonly struct GrabEvent
{
public readonly int TargetHandle;
public readonly FloatingBodyId Target;
public readonly GrabPhase Phase;
public readonly float3 TargetPosition;
public readonly float PhaseDuration;
public GrabEvent(int targetHandle, GrabPhase phase, float3 targetPosition, float phaseDuration)
public GrabEvent(FloatingBodyId target, GrabPhase phase, float3 targetPosition, float phaseDuration)
{
TargetHandle = targetHandle;
Target = target;
Phase = phase;
TargetPosition = targetPosition;
PhaseDuration = phaseDuration;

View file

@ -153,7 +153,11 @@ public abstract class Entity
public bool OverrideShutdown { get; set; }
public float TractorPower { get; set; }
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
public float TractorCommand;
// The beam's spun-up state: Pickup's, or 0 without one.
public float TractorPower => GetBehavior<Pickup>()?.Power ?? 0f;
public bool Active
{
@ -1549,7 +1553,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

@ -42,7 +42,8 @@ public struct KinematicBody
}
// An item adrift in a zone: a death drop or jettisoned cargo. Zone.Release is its only adder; Zone's floating-item
// step (expiry) and TryPickUp are its only removers. Runtime state, never saved.
// step (expiry) and TryPickUp are its only removers. Zone.TryHold and Free are the only writers of Holder, the
// entity grabbing it with a tractor beam; a held item is placed by Zone, not drifted. Runtime state, never saved.
public sealed class FloatingItem
{
public FloatingBodyId Id;
@ -50,4 +51,9 @@ public sealed class FloatingItem
public KinematicBody Body;
public double ReleasedAt;
public float Lifetime;
// The entity holding it, or null when it floats free; HeldFrom is where it was claimed and HeldProgress how far
// along its pull to the holder (0..1).
public Entity Holder;
public float2 HeldFrom;
public float HeldProgress;
}

View file

@ -54,6 +54,7 @@ public class LoadoutGenerator
var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
OutfitEntity(entity);
EquipPickup(entity);
StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits);
return EntitySerializer.Pack(entity);
}
@ -287,6 +288,24 @@ public class LoadoutGenerator
FillInterior(entity);
}
// Every ship carries a tractor beam: picking up needs a pickup behaviour (scenarios ruling, "everyone gets tractor
// beams"). It goes in after the shared outfitting, which stations and turrets also run and which adds none, into
// whatever interior the hull has left; a hull with no cell for one cannot be generated.
private void EquipPickup(Entity entity)
{
var emptyShape = entity.UnoccupiedSpace;
var (product, design) = RandomProduct<GearData>(2, item =>
item.Behaviors.Any(b => b is PickupData) &&
item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
if (design == null) throw new InvalidLoadoutException("No compatible tractor beam found for ship!");
design.Shape.FitsWithin(emptyShape, out var rotation, out var position);
var beam = ItemManager.CreateInstance(product) as EquippableItem;
beam.Rotation = rotation;
if (!entity.TryEquip(beam, position))
throw new InvalidLoadoutException("Failed to equip selected tractor beam!");
}
private void EquipHardpoints(Entity entity)
{
var hullData = ItemManager.GetData(entity.Hull) as HullData;

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.
@ -26,7 +48,8 @@ public class Zone
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
// Floating items (death drops, jettisoned cargo), in release order. Zone owns their existence: Release is the one
// adder; StepFloatingItems (expiry) and TryPickUp are the only removers. Runtime state, never saved.
// adder; StepFloatingItems (expiry) and TryPickUp are the only removers. TryHold, Free and Remove-of-the-holder
// are the only writers of FloatingItem.Holder. Runtime state, never saved.
public ReactiveCollection<FloatingItem> FloatingItems = new ReactiveCollection<FloatingItem>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -105,30 +128,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 +150,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)));
Targets.Add(new EntityTargets());
@ -205,17 +208,32 @@ 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();
foreach (var floating in FloatingItems)
if (ReferenceEquals(floating.Holder, entity)) Free(floating.Id);
return true;
}
private Agent CreateAgent(Ship ship)
{
var agent = new Minion(ship);
@ -231,7 +249,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,
@ -315,14 +333,44 @@ public class Zone
// The one commit of a pickup: the first of the taker's cargo bays that accepts the item stores it and the item
// stops floating. Otherwise nothing changes.
// An item held by another entity is refused: one holder per item holds at the commit as well.
public bool TryPickUp(Entity taker, FloatingBodyId id)
{
var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id));
if (floating == null || !taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false;
if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false;
if (!taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false;
FloatingItems.Remove(floating);
return true;
}
// The tractor beam's claim on a floating item: false if it is absent or already held. The item stays where it is
// until its pull begins; StepFloatingItems places it from then on.
public bool TryHold(Entity holder, FloatingBodyId id)
{
var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id));
if (floating == null || floating.Holder != null) return false;
floating.Holder = holder;
floating.HeldFrom = floating.Body.Position;
floating.HeldProgress = 0;
return true;
}
// How far along its pull to the holder a held item is, 0..1.
public void SetHeldProgress(FloatingBodyId id, float progress)
{
var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id));
if (floating?.Holder != null) floating.HeldProgress = saturate(progress);
}
// Lets a held item go: it floats on from where it is with the Velocity and Drift it had.
public void Free(FloatingBodyId id)
{
var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id));
if (floating == null) return;
floating.Holder = null;
floating.HeldProgress = 0;
}
// Called from the death subscription, before the entity leaves the zone: one generator per death. Each equipped
// item but the hull drops on a LootDropProbability draw; every cargo stack drops whole. Each drop leaves from the
// dead entity's position at a random heading. Draw order: Equipment order (the probability draw, then the heading
@ -344,12 +392,18 @@ public class Zone
}
// After StepClouds and before FireControl.Step: each floating item drifts as a KinematicBody on the zone's force
// and is removed once its lifetime has run out.
// and is removed once its lifetime has run out. A held item is neither integrated nor expired: it is placed
// between where it was claimed and its holder, now that the entities have moved.
private void StepFloatingItems(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var floating in FloatingItems.ToArray())
{
if (floating.Holder != null)
{
floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, smoothstep(0f, 1f, floating.HeldProgress));
continue;
}
floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings);
if (floating.ReleasedAt + floating.Lifetime <= _time) FloatingItems.Remove(floating);
}