Merge origin/master into eureka/aetheria-release-link-rig-record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 12:31:49 +02:00
commit 577da4f75b
27 changed files with 1899 additions and 29 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -15,6 +16,9 @@ public static class AetheriaStores
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false)
{
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
if (!catalogWritable && !File.Exists(catalogPath))
throw new FileNotFoundException($"Catalog {catalogPath} does not exist; a read-only open never invents one.", catalogPath);
var cache = new CultCache();
try
{

View file

@ -187,6 +187,8 @@ public interface IPowerConsumer
// superseded draft quoted -- checked directly here rather than trusted from that document.
Union(39, typeof(TargetingSystemData)),
Union(40, typeof(MineLayerData)),
Union(41, typeof(ThrottleLockData)),
Union(42, typeof(VapourDumpData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var diff = entity.Position.xz - Entity.Position.xz;
var dist = length(diff);
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
float previous, next;
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
if (!_pingedEntities.Contains(entity) && dist < _pingRadius)
{
_pingedEntities.Add(entity);
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
}
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
ping
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
// multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
(ping
? visibility * sensitivity * response * distance
: visibility * sensitivity * response * dt / distance;
: visibility * sensitivity * response * dt / distance) * obscuration;
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
var diff = position - Entity.Position.xz;
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
Entity.Zone.Obscuration(Entity.Position.xz, position));
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using MessagePack;
using Newtonsoft.Json;
// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update
// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing
// itself; turning stays with the pilot.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class ThrottleLockData : BehaviorData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new ThrottleLock(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new ThrottleLock(this, item);
}
}
public class ThrottleLock : Behavior
{
public ThrottleLock(ThrottleLockData data, EquippedItem item) : base(data, item)
{
}
public ThrottleLock(ThrottleLockData data, ConsumableItemEffect item) : base(data, item)
{
}
public override bool Execute(float dt) => true;
}

View file

@ -0,0 +1,61 @@
using MessagePack;
using Newtonsoft.Json;
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
// quality applies as for any other stat. Later runs vent nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class VapourDumpData : BehaviorData
{
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
public PerformanceStat Radius = new PerformanceStat();
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
public PerformanceStat Opacity = new PerformanceStat();
// Seconds from venting until the cloud has faded away and is removed.
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
public PerformanceStat Lifetime = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new VapourDump(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new VapourDump(this, item);
}
}
public class VapourDump : Behavior
{
private VapourDumpData _data;
private bool _vented;
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
{
_data = data;
}
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
}
public override bool Execute(float dt)
{
if (_vented) return true;
_vented = true;
Entity.Zone.Vent(new VapourCloud
{
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
Radius = Evaluate(_data.Radius),
Opacity = Evaluate(_data.Opacity),
Lifetime = Evaluate(_data.Lifetime),
Venter = Entity
});
return true;
}
}

View file

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

View file

@ -203,12 +203,10 @@ public abstract class Entity
return presencePermitted;
}), initialValue: true);
foreach (var item in Equipment)
foreach (var behavior in item.Behaviors)
{
if(behavior is IInitializableBehavior initializableBehavior)
initializableBehavior.Initialize();
}
// One initialiser for everything that computes targets here: gear and the effects still running, so a
// consumable active across a refit retargets to the new items exactly as gear does.
InitializeBehaviors(Equipment.SelectMany(item => item.Behaviors));
InitializeBehaviors(_activeConsumables.SelectMany(effect => effect.Behaviors));
foreach(var entity in Zone.Entities)
{
EntityInfoGathered[entity] = 0;
@ -860,9 +858,23 @@ public abstract class Entity
public void ActivateConsumable(ConsumableItem item)
{
_activeConsumables.Add(new ConsumableItemEffect(item, this));
var effect = new ConsumableItemEffect(item, this);
// The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier)
// would otherwise reach its second tick uninitialised.
InitializeBehaviors(effect.Behaviors);
_activeConsumables.Add(effect);
}
private static void InitializeBehaviors(IEnumerable<Behavior> behaviors)
{
foreach (var behavior in behaviors.ToList())
if (behavior is IInitializableBehavior initializable)
initializable.Initialize();
}
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
{
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);
@ -1448,11 +1460,16 @@ public abstract class Entity
for (var i = 0; i < _activeConsumables.Count; i++)
{
_activeConsumables[i].Update(delta);
if (_activeConsumables[i].RemainingDuration < 0)
if (_activeConsumables[i].RemainingDuration <= 0)
{
// Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this
// list without ever telling the resolver, leaving its generation/cache/modifier entries
// reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.
// A StatModifier's attachments are keyed by its target items, not by the effect, so Forget alone
// leaves a boost attached to its targets: dispose each behaviour first.
foreach (var behavior in _activeConsumables[i].Behaviors)
if (behavior is IDisposable disposable)
disposable.Dispose();
Resolver.Forget(_activeConsumables[i]);
_activeConsumables.RemoveAt(i--);
}

View file

@ -183,6 +183,9 @@ public class ItemManager
};
}
if (design is ConsumableItemData)
return new ConsumableItem { Data = l.Design, Lot = lot };
return new CompoundCommodity
{
Data = l.Design, Lot = lot

View file

@ -233,27 +233,29 @@ public class Ship : Entity
if (_active && !_exitingWormhole && !_enteringWormhole)
{
RecalculateThrust();
// The lock replaces intent here, once; player and agent intent both pass through this one read.
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
foreach (var thruster in _allThrusters) thruster.Axis = 0;
var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count;
foreach (var thruster in _rightThrusters)
{
var thrust = 0f;
thrust += MovementDirection.x;
thrust += move.x;
if (RightStrafeTorqueThrusters.Contains(thruster))
thrust -= MovementDirection.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count;
foreach (var thruster in _leftThrusters)
{
var thrust = 0f;
thrust += -MovementDirection.x;
thrust += -move.x;
if (LeftStrafeTorqueThrusters.Contains(thruster))
thrust += MovementDirection.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
foreach (var thruster in _forwardThrusters) thruster.Axis += move.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y;
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;

View file

@ -141,6 +141,9 @@ public class StatResolver
{
set.Scale.Remove(modifierKey);
set.Constant.Remove(modifierKey);
// An entry with nothing attached is no entry: a modifier that is gone leaves no trace in the resolver.
if (set.Scale.Count == 0 && set.Constant.Count == 0)
_modifiers.Remove(entry);
}
_cache.Remove(entry);
}

View file

@ -0,0 +1,32 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
// Zone.Obscuration is the one reader detection uses.
public sealed class VapourCloud
{
public FloatingBodyId Id;
public KinematicBody Body;
public double VentedAt;
public float Lifetime;
public float Radius;
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
public float Opacity;
public Entity Venter;
// A cloud with no lifetime divides by zero; saturate maps the NaN and the infinity to nothing left.
public float OpacityAt(double now) =>
saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
// radius. A cloud past either end of the segment touches it only if it is near that end.
public bool Touches(float2 a, float2 b)
{
var ab = b - a;
var length2 = lengthsq(ab);
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
}
}

View file

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

View file

@ -20,6 +20,10 @@ public class Zone
// 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.
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -225,6 +229,7 @@ public class Zone
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
@ -238,6 +243,39 @@ public class Zone
Mines.Add(mine);
}
public void Vent(VapourCloud cloud)
{
cloud.Id = NextBodyId();
cloud.VentedAt = _time;
Clouds.Add(cloud);
}
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
public float Obscuration(float2 a, float2 b)
{
var through = 1f;
for (var i = 0; i < Clouds.Count; i++)
{
var cloud = Clouds[i];
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
}
return through;
}
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
// once its lifetime has run out. Nothing else removes a cloud.
private void StepClouds(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var cloud in Clouds.ToArray())
{
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
}
}
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The