Aetheria/Assets/Scripts/ServerShared/ItemData.cs

934 lines
43 KiB
C#
Raw Normal View History

/* 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 System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
using System.Reflection;
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using int2 = CultMath.int2;
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class Shape
{
[JsonProperty("name"), Key(0)] public bool[,] Cells;
private bool _dirty = true;
public Shape()
{
Cells = new bool[1,1];
Cells[0, 0] = true;
}
public Shape(int width, int height)
{
Cells = new bool[width, height];
}
[IgnoreMember]
public int Width
{
get { return Cells.GetLength(0); }
set { Resize(value, Height); }
}
[IgnoreMember]
public int Height
{
get { return Cells.GetLength(1); }
set { Resize(Width, value); }
}
public void Resize(int width, int height)
{
var newCells = new bool[width, height];
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
newCells[x, y] = x >= Width || y >= Height || Cells[x, y];
}
}
Cells = newCells;
}
private static int mod(int x, int m) {
int r = x%m;
return r<0 ? r+m : r;
}
public int2 Rotate(int2 position, ItemRotation rotation)
{
return rotation switch
{
ItemRotation.Clockwise => int2(position.y, Width - 1 - position.x),
ItemRotation.Reversed => int2(Width - 1 - position.x, Height - 1 - position.y),
ItemRotation.CounterClockwise => int2(Height - 1 - position.y, position.x),
_ => int2(position.x, position.y)
};
}
private int2[] _cachedShapeCoordinates;
[IgnoreMember]
public int2[] Coordinates
{
get
{
if (_dirty)
{
_cachedShapeCoordinates = EnumerateShapeCoordinates().ToArray();
_dirty = false;
}
return _cachedShapeCoordinates;
}
}
private IEnumerable<int2> EnumerateShapeCoordinates()
{
for (int y = 0; y < Height; y++)
{
for(int x = 0; x < Width; x++)
{
if(Cells[x,y]) yield return int2(x, y);
}
}
}
private int2[] _cachedAllShapeCoordinates;
[IgnoreMember]
public int2[] AllCoordinates => _cachedAllShapeCoordinates ?? (_cachedAllShapeCoordinates = EnumerateAllShapeCoordinates().ToArray());
private IEnumerable<int2> EnumerateAllShapeCoordinates()
{
for (int y = 0; y < Height; y++)
{
for(int x = 0; x < Width; x++)
{
yield return int2(x, y);
}
}
}
private float2? _centerOfMass;
[IgnoreMember]
public float2 CenterOfMass => _centerOfMass ?? (_centerOfMass = Coordinates
.Aggregate(float2.zero, (total, coord) => total + coord) / Coordinates.Length).Value;
public bool this[int2 pos] {
get { return pos.x >= 0 && pos.y >= 0 && pos.x < Width && pos.y < Height && Cells[pos.x, pos.y]; }
set
{
if (pos.x < 0 || pos.y < 0 || pos.x >= Width || pos.y >= Height) return;
_dirty = true;
Cells[pos.x, pos.y] = value;
}
}
public Shape Shrink()
{
var shape = new Shape(Width, Height);
foreach(var shapeCoord in Coordinates)
shape[shapeCoord] = (
this[shapeCoord + int2(-1,-1)] && this[shapeCoord + int2(0,-1)] && this[shapeCoord + int2(1,-1)] &&
this[shapeCoord + int2(-1,0)] && this[shapeCoord + int2(1,0)] &&
this[shapeCoord + int2(-1,1)] && this[shapeCoord + int2(0,1)] && this[shapeCoord + int2(1,1)]
);
return shape;
}
public Shape Inset(Shape inset, int2 insetPosition, ItemRotation rotation = ItemRotation.None)
{
var shape = new Shape(max(Width,insetPosition.x + inset.Width - 1), max(Height, insetPosition.y + inset.Height - 1));
foreach (var v in inset.Coordinates)
{
var insetCoord = inset.Rotate(v, rotation) + insetPosition;
shape[insetCoord] = true;
}
return shape;
}
public Shape Expand()
{
var shape = new Shape(Width, Height);
foreach (var shapeCoord in AllCoordinates)
shape[shapeCoord] = (
this[shapeCoord + int2(-1,-1)] || this[shapeCoord + int2(0,-1)] || this[shapeCoord + int2(1,-1)] ||
this[shapeCoord + int2(-1,0)] || this[shapeCoord] || this[shapeCoord + int2(1,0)] ||
this[shapeCoord + int2(-1,1)] || this[shapeCoord + int2(0,1)] || this[shapeCoord + int2(1,1)]
);
return shape;
}
// TODO: Use the power of math to optimize this function!
// Try to find a rotation and position with which a shape can be placed to fit within another shape
public bool FitsWithin(Shape other, out ItemRotation rotation, out int2 position)
{
// Try every item orientation
foreach(var rot in (ItemRotation[])Enum.GetValues(typeof(ItemRotation)))
{
rotation = rot;
if (FitsWithin(other, rot, out var pos))
{
position = pos;
return true;
}
}
rotation = ItemRotation.None;
position = int2.zero;
return false;
}
// Try to find a position with which a shape can be placed to fit within another shape
public bool FitsWithin(Shape other, ItemRotation rotation, out int2 position)
{
var width = rotation == ItemRotation.Clockwise || rotation == ItemRotation.CounterClockwise ? Height : Width;
var height = rotation == ItemRotation.Clockwise || rotation == ItemRotation.CounterClockwise ? Width : Height;
// Try every item position that could possibly fit
for(int x = 0; x < other.Width - width + 1; x++)
{
for (int y = 0; y < other.Height - height + 1; y++)
{
position = int2(x, y);
One loose fit rule for hardpoint gear Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you from putting a small reactor in a large reactor's hardpoint." There is one rule, HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset, and Entity.ItemFits (equipping, save load, presets) now asks it instead of repeating the containment test. Generation's hardpoint picks, HasHome (so station stock and loadouts), product reuse and `AetherDb hardpoint-fit` all read Takes. Generation keeps its own placement preference, named IsFilledBy and not a fit rule: it offers gear that fills the hardpoint when any is on offer and falls back to anything that fits only when none is, so loadouts change only where a hardpoint used to stay empty. RestoredHullsTests pick fillers through IsFilledBy. Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now have a home and are offered. Still homeless: CShot RainbowLite Lazer, ChargeBlast SG, ChargeBlast+-, OK Disperser. Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7 design no hull fits is offered only once a hull that takes it exists; a 5x5 Reactor hardpoint nothing fills still gets a reactor; equipping and the rule agree for every hull, hardpoint and design; the homeless list is stated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
if (FitsAt(other, rotation, position)) return true;
}
}
position = int2.zero;
return false;
}
One loose fit rule for hardpoint gear Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you from putting a small reactor in a large reactor's hardpoint." There is one rule, HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset, and Entity.ItemFits (equipping, save load, presets) now asks it instead of repeating the containment test. Generation's hardpoint picks, HasHome (so station stock and loadouts), product reuse and `AetherDb hardpoint-fit` all read Takes. Generation keeps its own placement preference, named IsFilledBy and not a fit rule: it offers gear that fills the hardpoint when any is on offer and falls back to anything that fits only when none is, so loadouts change only where a hardpoint used to stay empty. RestoredHullsTests pick fillers through IsFilledBy. Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now have a home and are offered. Still homeless: CShot RainbowLite Lazer, ChargeBlast SG, ChargeBlast+-, OK Disperser. Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7 design no hull fits is offered only once a hull that takes it exists; a 5x5 Reactor hardpoint nothing fills still gets a reactor; equipping and the rule agree for every hull, hardpoint and design; the homeless list is stated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
// Whether this shape, turned to a rotation and placed at a position, lies wholly within another shape's cells
public bool FitsAt(Shape other, ItemRotation rotation, int2 position)
{
foreach (var v in Coordinates)
if (!other[Rotate(v, rotation) + position]) return false;
return true;
}
// Set every cell on the line from a to b to true according to Bresenham's Line Algorithm
public void SetLine(float2 a, float2 b)
{
if (a.Equals(b))
{
return;
}
// If line gradient is steep, swap x and y
bool steep = Math.Abs(b.y - a.y) > Math.Abs(b.x - a.x);
if (steep)
{
a = new float2(a.y, a.x);
b = new float2(b.y, b.x);
}
// If b is closer to the origin than a, swap a and b.
if (a.x > b.x)
{
var temp = a;
a = b;
b = temp;
}
float dx = b.x - a.x;
float dy = b.y - a.y;
float derr = Math.Abs(dy / dx); // dx != 0
int y = (int) Math.Round(a.y);
int xStart = (int) Math.Round(a.x);
float error = (a.y - y) + (xStart - a.x) * derr;
for (int x = xStart; x <= (int) Math.Round(b.x); x++)
{
if (x >= 0 && x < Width && y >= 0 && y < Height)
{
Cells[steep ? y : x, steep ? x : y] = true;
}
error += derr;
if (error >= 0.5f)
{
y += Math.Sign(dy);
error -= 1f;
}
}
}
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[JsonObject(MemberSerialization.OptIn)]
public abstract class ItemData
{
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, CultName, JsonProperty("name"), Key(1)]
public string Name;
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, CultInspectorTextArea, JsonProperty("description"), Key(2)]
public string Description;
// MessagePack key 3 belonged to a removed creator/faction reference field; do not reuse it. That provenance
// now lives on FactionProductData, and branding is derived, never stored on the design.
[Inspectable, JsonProperty("mass"), Key(4)]
public float Mass;
[InspectableSchematicShape, JsonProperty("shape"), Key(5)]
public Shape Shape;
// Heat needed to change temperature of 1 gram by 1 degree
[Inspectable, JsonProperty("specificHeat"), Key(6)]
public float SpecificHeat = 1;
[Inspectable, JsonProperty("conductivity"), Key(7)]
public float Conductivity = 1;
[Inspectable, JsonProperty("price"), Key(8)]
public int Price = 0;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject]
public class SimpleCommodityData : ItemData
{
// MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them.
[Inspectable, JsonProperty("maxStackSize"), Key(9)]
public int MaxStack = 10;
[Inspectable, JsonProperty("category"), Key(10)]
public SimpleCommodityCategory Category;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[JsonObject(MemberSerialization.OptIn)]
public abstract class CraftedItemData : ItemData
{
// The named slots this design is assembled from. A stat may read the quality of the part filling one.
[Inspectable, JsonProperty("roles"), Key(9)]
public List<ItemRole> Roles = new List<ItemRole>();
}
// One slot in a design: every laser has a focusing array. A role is a name and nothing else. What fills it is
// a quality, authored per role by each manufacturer's product; the part itself earns a record when crafting
// needs one to exist.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ItemRole
{
[Inspectable, JsonProperty("name"), Key(0)]
public string Name;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject]
public class CompoundCommodityData : CraftedItemData
{
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)]
public Dictionary<CultRecordRef<PersonalityAttribute>, float> DemandProfile = new Dictionary<CultRecordRef<PersonalityAttribute>, float>();
[Inspectable, JsonProperty("category"), Key(11)]
public CompoundCommodityCategory Category;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject]
public class ConsumableItemData : CraftedItemData
{
[Inspectable, JsonProperty("behaviors"), Key(10)]
public List<BehaviorData> Behaviors = new List<BehaviorData>();
[Inspectable, JsonProperty("stackable"), Key(11)]
public bool Stackable;
[Inspectable, JsonProperty("duration"), Key(12)]
public float Duration;
Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
[Inspectable, CultInspectorAssetGuid, JsonProperty("icon"), Key(13)]
public string Icon;
[Inspectable, JsonProperty("effectiveness"), Key(14)]
public BezierCurve Effectiveness;
}
[JsonObject(MemberSerialization.OptIn)]
public abstract class EquippableItemData : CraftedItemData
{
Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
[Inspectable, CultInspectorAssetGuid, JsonProperty("schematic"), Key(10)]
public string Schematic;
[Inspectable, JsonProperty("behaviors"), Key(11)]
public List<BehaviorData> Behaviors = new List<BehaviorData>();
[Inspectable, JsonProperty("durability"), Key(12)]
public float Durability;
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
// R-heat requires every equippable to carry a coherent heat response (StatValidation.ValidateHeatResponse
// refuses a zero-span range), so these four fields default to a legal, non-degenerate placeholder rather
// than to a zero-span trap -- the same shape HeatResponseTests' own fixture authors by hand. A design that
// cares about its thermal behaviour overrides all four; one that does not (most test fixtures, most gear
// that predates R-heat and has not been reviewed) still opens and evaluates sanely instead of refusing to
// load or resolving to a permanently-dead Performance().
[InspectableTemperature, JsonProperty("minTemp"), Key(13)]
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
public float MinimumTemperature = 0;
[InspectableTemperature, JsonProperty("maxTemp"), Key(14)]
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
public float MaximumTemperature = 100;
// [InspectableField, JsonProperty("durabilityExponent"), Key(15), SimplePerformanceStat]
// public PerformanceStat DurabilityExponent = new PerformanceStat();
//
// [InspectableField, JsonProperty("heatExponent"), Key(16), SimplePerformanceStat]
// public PerformanceStat HeatExponent = new PerformanceStat();
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// MessagePack key 17 belonged to HeatPerformanceCurve, a BezierCurve; R-heat (docs/stats-and-power-cut.md)
// replaced the authored shape with minimum, maximum, optimum and plateau width. Do not reuse.
[Inspectable, JsonProperty("resilience"), Key(18)]
public float ThermalResilience = 1;
// [Inspectable, JsonProperty("sfx"), Key(19)]
// public string SoundEffectTrigger;
Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
[Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)]
public string ActionBarIcon;
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, JsonProperty("soundBank"), Key(21)]
public uint SoundBank;
[Inspectable, JsonProperty("audioStats"), Key(22)]
public List<AudioStat> AudioStats = new List<AudioStat>();
[IgnoreMember]
public abstract HardpointType HardpointType { get; }
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// R-heat (docs/stats-and-power-cut.md): the authored shape is minimum, maximum (both above), optimum and
// plateau width. Where the part likes to be, and how forgiving it is. Authored, not derived -- the 100-sample
// bezier scan that used to infer this from HeatPerformanceCurve is gone, and so is its cache.
[InspectableTemperature, JsonProperty("optimalTemperature"), Key(30)]
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
public float OptimalTemperature = 50;
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// The band, centered on OptimalTemperature, across which performance is 1. Plateau width is the
// operational-lifespan lever: inside it, Wear's thermal term is zero (Performance() returns exactly 1), so a
// well-managed item takes wear only from deltaTemp (Entity.cs UpdatePerformance).
[Inspectable, JsonProperty("plateauWidth"), Key(31)]
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
public float PlateauWidth = 20;
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// Performance is 1 across the plateau and falls linearly to 0 at each bound -- asymmetric whenever the
// optimum is off-centre, which is how most gear behaves and how negent gear inverts. The plateau clamps to
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
// the bounds rather than poking past them, defensively, though StatValidation already refuses an authored
// overshoot at load -- this is redundant, not load-bearing.
public float Performance(float temperature)
{
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f;
var halfPlateau = PlateauWidth * 0.5f;
var plateauLow = max(MinimumTemperature, OptimalTemperature - halfPlateau);
var plateauHigh = min(MaximumTemperature, OptimalTemperature + halfPlateau);
if (temperature >= plateauLow && temperature <= plateauHigh) return 1f;
if (temperature < plateauLow)
return saturate(unlerp(MinimumTemperature, plateauLow, temperature));
return saturate(unlerp(MaximumTemperature, plateauHigh, temperature));
}
}
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class AudioStat
{
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, JsonProperty("parameter"), Key(0)]
public uint Parameter;
[Inspectable, JsonProperty("stat"), Key(1)]
public PerformanceStat Stat = new PerformanceStat();
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.geardata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class GearData : EquippableItemData
{
[Inspectable, JsonProperty("hardpointType"), Key(23)]
public HardpointType Hardpoint;
[IgnoreMember] public override HardpointType HardpointType => Hardpoint;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.cargobaydata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class CargoBayData : EquippableItemData
{
[Inspectable, JsonProperty("interiorShape"), Key(24)]
public Shape InteriorShape;
[IgnoreMember] public override HardpointType HardpointType => HardpointType.Tool;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.dockingbaydata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class DockingBayData : CargoBayData
{
[Inspectable, JsonProperty("maxSize"), Key(25)]
public int2 MaxSize;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.weaponitemdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class WeaponItemData : GearData
{
[Inspectable, JsonProperty("range"), Key(24)]
public WeaponRange WeaponRange;
[Inspectable, JsonProperty("caliber"), Key(25)]
public WeaponCaliber WeaponCaliber;
[Inspectable, JsonProperty("weaponType"), Key(26)]
public WeaponType WeaponType;
[Inspectable, JsonProperty("fireTypes"), Key(27)]
public WeaponFireType WeaponFireTypes;
[Inspectable, JsonProperty("modifiers"), Key(28)]
public WeaponModifiers WeaponModifiers;
// Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in
// world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so
// CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers
// no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour").
[Inspectable, JsonProperty("blastRadius"), Key(29)]
public float? BlastRadius;
// Cut 12.4(a): what kind of blast BlastRadius describes. Nullable under the 7.4 rule -- null means no
// blast, and every shipped record reads null. FireControl.Fire freezes this from the weapon only when
// BlastRadius > 0; a fuse without a radius, or a radius without a fuse, is inert and resolves as a direct
// hit (nothing polices that, per Q12-6). Key 30 and 31 belong to EquippableItemData's OptimalTemperature
// and PlateauWidth, inherited here -- the next free key across WeaponItemData's own ancestry is 32.
[Inspectable, JsonProperty("fuse"), Key(32)]
public WeaponFuse? Fuse;
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class HullData : EquippableItemData
{
[Inspectable, JsonProperty("hardpoints"), Key(23)]
public List<HardpointData> Hardpoints = new List<HardpointData>();
Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
[Inspectable, CultInspectorAssetGuid, JsonProperty("prefab"), Key(24)]
public string Prefab;
[Inspectable, JsonProperty("hullType"), Key(25)]
public HullType HullType;
[Inspectable, JsonProperty("gridOffset"), Key(26)]
public float GridOffset;
[Inspectable, JsonProperty("armor"), Key(27)]
public float Armor;
[Inspectable, JsonProperty("drag"), Key(28)]
public float Drag;
[Inspectable, JsonProperty("canTow"), Key(29)]
public bool CanTow;
// The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull
// names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key.
[Inspectable, JsonProperty("visual"), Key(32)]
public CultRecordRef<ShipAuthoring> Visual;
[IgnoreMember]
public Shape InteriorCells
{
get
{
if (_interiorCells == null)
{
_interiorCells = Shape.Shrink();
}
return _interiorCells;
}
}
private Shape _interiorCells;
[IgnoreMember] public override HardpointType HardpointType => HardpointType.Hull;
}
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
public class HardpointData
{
[Inspectable, JsonProperty("type"), Key(0)] public HardpointType Type;
[Inspectable, JsonProperty("position"), Key(1)] public int2 Position;
[Inspectable, JsonProperty("shape"), Key(2)] public Shape Shape = new Shape();
[Inspectable, JsonProperty("transform"), Key(3)] public string Transform;
[Inspectable, JsonProperty("rotation"), Key(4)] public ItemRotation Rotation;
[Inspectable, JsonProperty("armor"), Key(5)] public float Armor;
// Full arc width in degrees a weapon on this hardpoint may bear across; 0 means "use
// GameplaySettings.FiringArc" (docs/fire-control-cut.md R6). Turrets author 360 (Q1).
[Inspectable, JsonProperty("firingArc"), Key(6)] public float FiringArc;
public override string ToString()
{
return $"{Enum.GetName(typeof(HardpointType), Type)} Hardpoint {Rotation.Arrow()}";
}
One loose fit rule for hardpoint gear Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you from putting a small reactor in a large reactor's hardpoint." There is one rule, HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset, and Entity.ItemFits (equipping, save load, presets) now asks it instead of repeating the containment test. Generation's hardpoint picks, HasHome (so station stock and loadouts), product reuse and `AetherDb hardpoint-fit` all read Takes. Generation keeps its own placement preference, named IsFilledBy and not a fit rule: it offers gear that fills the hardpoint when any is on offer and falls back to anything that fits only when none is, so loadouts change only where a hardpoint used to stay empty. RestoredHullsTests pick fillers through IsFilledBy. Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now have a home and are offered. Still homeless: CShot RainbowLite Lazer, ChargeBlast SG, ChargeBlast+-, OK Disperser. Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7 design no hull fits is offered only once a hull that takes it exists; a 5x5 Reactor hardpoint nothing fills still gets a reactor; equipping and the rule agree for every hull, hardpoint and design; the homeless list is stated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
// The one fit rule for hardpoint gear (operator, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
// from putting a small reactor in a large reactor's hardpoint"): gear of this hardpoint's type fits when its shape,
// laid in at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset
// from the hardpoint's origin, which is where equipping places it.
public bool Takes(EquippableItemData design) =>
One loose fit rule for hardpoint gear Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you from putting a small reactor in a large reactor's hardpoint." There is one rule, HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same rule at one offset, and Entity.ItemFits (equipping, save load, presets) now asks it instead of repeating the containment test. Generation's hardpoint picks, HasHome (so station stock and loadouts), product reuse and `AetherDb hardpoint-fit` all read Takes. Generation keeps its own placement preference, named IsFilledBy and not a fit rule: it offers gear that fills the hardpoint when any is on offer and falls back to anything that fits only when none is, so loadouts change only where a hardpoint used to stay empty. RestoredHullsTests pick fillers through IsFilledBy. Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now have a home and are offered. Still homeless: CShot RainbowLite Lazer, ChargeBlast SG, ChargeBlast+-, OK Disperser. Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7 design no hull fits is offered only once a hull that takes it exists; a 5x5 Reactor hardpoint nothing fills still gets a reactor; equipping and the rule agree for every hull, hardpoint and design; the homeless list is stated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
design.HardpointType == Type && design.Shape.FitsWithin(Shape, Rotation, out _);
public bool TakesAt(EquippableItemData design, int2 offset) =>
design.HardpointType == Type && design.Shape.FitsAt(Shape, Rotation, offset);
// Generation's placement preference, not a fit rule: gear that fits and fills every cell of the hardpoint.
public bool IsFilledBy(EquippableItemData design) =>
Takes(design) && design.Shape.Coordinates.Length == Shape.Coordinates.Length;
[IgnoreMember]
public float3 TintColor
{
get
{
return GetColor(Type);
}
}
public static float3 GetColor(HardpointType type)
{
if (_tintColors == null)
{
var hardpointTypes = (HardpointType[]) Enum.GetValues(typeof(HardpointType));
_tintColors = hardpointTypes.ToDictionary(x => x,
x => ColorMath.HsvToRgb(float3(frac((float)(int)x/hardpointTypes.Length + .25f), 1, 1)));
}
return _tintColors.ContainsKey(type) ? _tintColors[type] : _tintColors[HardpointType.Hull];
}
private static Dictionary<HardpointType, float3> _tintColors;
}
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context
// factors that used to be three separate Evaluate bodies. EquippedItem, ConsumableItemEffect and the
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
// unequipped case (ItemManager's own private context) are the three implementations; each supplies only the
// sources it has, and says so through what it returns rather than through a second, quietly different formula.
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// Cut 1 (docs/stats-and-power-cut.md): each factor now takes the declaring term's own exponent rather than
// reading a fixed field off the stat, because the stat no longer carries one fixed field per source.
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
public interface IStatContext
{
Lot Lot { get; }
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
float HeatFactor(float exponent);
float DurabilityFactor(float exponent);
// Progress through a consumable effect's duration, standing in for "condition" on a consumable the way heat
// does for equipped gear. Only ConsumableItemEffect has one; every other context is the identity (1).
float ConsumableProgressFactor(float exponent);
// Cut 6 (the power bus) wires this to the bus's grant ratio. F1 (docs/stats-and-power-cut.md, operator
// ruling 2026-09-19): this is not one of the terms blended into the Min/Max interpolation -- PerformanceStat
// applies it as a separate multiplier on the fully-resolved value, so zero supply always resolves to zero.
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
float PowerSupplyFactor(float exponent);
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
float ScaleModifier(PerformanceStat stat);
float ConstantModifier(PerformanceStat stat);
}
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// The declared sources a stat term can read. Closed: adding a member is a schema change with a census
// (docs/stats-and-power-cut.md §2).
public enum StatSource
{
Heat,
Durability,
Quality,
PowerSupply,
ConsumableProgress
}
// One declared dependency of a stat: a source and the exponent it is raised to inside the stat's Min/Max
// interpolation. Role is meaningful only for Quality; PerformanceStat.Evaluate refuses it elsewhere.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class StatTerm
{
[JsonProperty("source"), Key(0)] public StatSource Source;
[JsonProperty("exponent"), Key(1)] public float Exponent;
// The design role whose part quality this term reads; unset reads the item's own workmanship quality.
// Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension.
[Inspectable, JsonProperty("role"), Key(2)] public string Role;
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class PerformanceStat
{
[JsonProperty("min"), Key(0)] public float Min;
[JsonProperty("max"), Key(1)] public float Max;
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// MessagePack keys 2, 3, 4 and 5 belonged to fixed exponent fields (HeatExponentMultiplier,
// DurabilityExponentMultiplier, QualityExponent, FromRole) now expressed as declared Terms below; do not
// reuse them.
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
[Inspectable, JsonProperty("terms"), Key(6)]
public List<StatTerm> Terms = new List<StatTerm>();
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// Cut 2 (docs/stats-and-power-cut.md): this catalog object used to hold two Dictionary<Entity, ...> fields
// here, keyed by every entity that ever evaluated it, with no removal -- the leak (§0.3). A stat is shared;
// a resolved value, and the modifiers that shape it, are not. Both now live in the entity's own StatResolver
// (StatResolver.cs), reachable only from the entity, so they die when the entity does instead of when the
// process does. Do not reintroduce per-entity state here under any name.
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// The one evaluation path. A stat with no terms resolves to Max (the identity factor, 1, times Min/Max
// interpolation lands on the top) -- that is the same number a stat with all-zero exponents produced before
// this cut, because pow(x, 0) == 1 regardless of x. Callers keep their own NaN handling: an unequipped read
// throws with diagnostic detail, an equipped or consumable read falls back to Min. That disagreement is not
// named as a defect, so it is not touched here.
//
// F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is not folded into the
// interpolation factor with every other source. Heat, Durability and Quality all describe "how worn/hot/well
// made is this part" -- a part that is still there, just less good at its job, so blending toward Min is
// right for them. Power is not a degradation of the part; it is whether the part is receiving anything to
// work with at all, so it multiplies the fully-interpolated, fully-modified value instead: a PowerSupply term
Resolve request/brownout conflict: evaluate power requests nominally A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00
// still lives in this stat's own Terms list (it is still authored per stat, still censused in
// PowerRequestFields below), it is just applied as a separate multiplier rather than contributing to
// `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole
// expression 0 regardless of Min (pow(0, exponent) == 0 for any exponent > 0), and a Min == Max stat still
// responds to a PowerSupply term because the multiplier no longer has to move a degenerate interpolation.
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
public float Evaluate(IStatContext context)
{
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
var factor = 1f;
var powerMultiplier = 1f;
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
foreach (var term in Terms)
{
if (term.Source == StatSource.PowerSupply)
{
powerMultiplier *= context.PowerSupplyFactor(term.Exponent);
continue;
}
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
factor *= term.Source switch
{
// F6 (docs/stats-and-power-cut.md Cut 2 Soul pass): nothing ever calls Resolver.InvalidateSource
// for StatSource.Quality. Harmless today because EquippedItem.Lot is set once in the constructor
// and never reassigned, so the value this reads never actually moves under a live resolver entry.
// §0b designs an upgrade as a lot swap the resolver must see ("an upgrade mints a new lot and
// repoints the item, which the resolver sees as a quality-source change") -- that repoint does not
// exist yet. Whichever cut adds it must also call InvalidateSource(item, StatSource.Quality) at
// the point EquippedItem.Lot is reassigned, or every Quality-termed stat keeps the pre-upgrade
// value forever.
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),
StatSource.Heat => context.HeatFactor(term.Exponent),
StatSource.Durability => context.DurabilityFactor(term.Exponent),
StatSource.ConsumableProgress => context.ConsumableProgressFactor(term.Exponent),
_ => throw new ArgumentOutOfRangeException(nameof(term.Source), term.Source, $"Unknown StatSource on {Min}-{Max} stat")
};
}
return (lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this)) * powerMultiplier;
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
}
}
// Cut 1's loud refusal: the heat-response shape (min, max, optimum, plateau width) must describe a coherent
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
// range. Runs at catalog load (AetheriaStores.Open) and at every catalog write that goes through
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository. F7
// (docs/stats-and-power-cut.md, Soul pass 2026-09-19) found four holes where the two named here left off, not
// one:
// - CultCache Studio's generic document editor writes straight through CultCache, bypassing Upsert, and Studio
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
// exposes no per-document validation hook to attach to yet. Still open -- Studio is not this repository's to
// patch from here.
// - tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair existing
// records in place and so cannot go through Upsert (its cache.UpsertAsync(document) does not accept a
// caller-supplied key -- see AetheriaStores.cs's own Validate comment); each lands its changes through
// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`, CultCache's own batch API,
// keyed explicitly to preserve the record's existing identity. `brownout-migrate apply` is how the shipped
// catalog's PowerSupply terms were actually written (Cut 7), and all three now call
// `AetheriaStores.Validate(document)` immediately before that commit -- the same checks Upsert runs, just
// invoked directly since the identity-preserving write path cannot call Upsert itself. Closed: a dangling-ref
// fixup, a shield migration or a brownout migration can no longer land an EquippableItemData or
// ConsumableItemData with an invalid heat response, an unresolvable stat modifier reference, or a power
// request that depends on its own supply.
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
public static class StatValidation
{
public static void ValidateHeatResponse(EquippableItemData data)
{
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) ||
float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth))
throw new InvalidOperationException(
$"{data.Name}: heat response carries NaN (min {data.MinimumTemperature}, max {data.MaximumTemperature}, " +
$"optimum {data.OptimalTemperature}, plateau {data.PlateauWidth})");
if (data.MinimumTemperature == data.MaximumTemperature)
throw new InvalidOperationException(
$"{data.Name}: MinimumTemperature and MaximumTemperature are both {data.MinimumTemperature} -- a " +
"zero-span range is dead at every temperature, which is an authoring error, not an authored immunity");
if (data.OptimalTemperature < data.MinimumTemperature || data.OptimalTemperature > data.MaximumTemperature)
Cut 1: terms replace the four fixed exponents; R-heat PerformanceStat's four fixed fields (HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a declared List<StatTerm> (source + exponent + role), walked by the one evaluation path Cut 0 unified. A stat with no terms resolves to Max (the same identity a stat with all-zero exponents already produced), so the migration is exact: a zero multiplier becomes no term, not a term with exponent 0. R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred OptimalTemperature and its cache) is retired in favor of an authored minimum, maximum, optimum and plateau width, with linear falloff outside the plateau. StatValidation refuses an optimum outside its bounds or a negative plateau width, loudly, at catalog load. The plateau's consequence rides along for free: Wear's thermal term is pow(ThermalPerformance, ...), and Performance() now returns exactly 1 across the plateau, so a well-managed item takes wear only from deltaTemp there -- the operational-lifespan lever the operator asked for. ConsumableItemEffect's progress-through-duration substitution for "heat" becomes its own StatSource (ConsumableProgress) rather than an unconditional override applied regardless of what a stat declares. Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against Terms/authored heat fields; HeatResponseTests.cs (new) pins load validation, the plateau shape, and the three wear-lever claims (held inside takes no thermal wear, held outside does, a fast swing across still wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py (new, 10 rules including a no-op control; all pass). 64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones. Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass; Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found" compute-shader error, reproduced against Cut 0's own landing state with no shader/compute file in this diff -- flagged for Soul, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
throw new InvalidOperationException(
$"{data.Name}: OptimalTemperature {data.OptimalTemperature} lies outside its bounds " +
$"[{data.MinimumTemperature}, {data.MaximumTemperature}]");
if (data.PlateauWidth < 0)
throw new InvalidOperationException($"{data.Name}: PlateauWidth {data.PlateauWidth} is negative");
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
var halfPlateau = data.PlateauWidth * 0.5f;
var plateauLow = data.OptimalTemperature - halfPlateau;
var plateauHigh = data.OptimalTemperature + halfPlateau;
if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)
throw new InvalidOperationException(
$"{data.Name}: plateau [{plateauLow}, {plateauHigh}] pokes past its bounds " +
$"[{data.MinimumTemperature}, {data.MaximumTemperature}] -- the plateau must clamp to the bounds, " +
"not exceed them");
Cut 0: collapse the three disagreeing stat evaluate bodies into one ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and EquippedItem.Evaluate each hand-computed the same quality/durability/heat/ modifier arithmetic with small, undocumented disagreements (docs/stats-and- power-cut.md Sec 0.2). Collapse the arithmetic into one function, PerformanceStat.Evaluate(IStatContext), fed by three IStatContext implementations: EquippedItem (the only one with heat, live durability and modifiers), ConsumableItemEffect (progress substitutes for heat, unconditionally, matching its prior behaviour exactly) and a new UnequippedStatContext in ItemManager (no heat, no modifiers, by name). No behaviour change beyond the two disagreements the cut map names as defects. Each context's original NaN handling is preserved exactly (unequipped throws with diagnostic detail; equipped/consumable fall back to Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so the ~81 existing call sites are untouched. Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before new tests are added; a real-catalog probe over GameData/Aetheria.cc found the new unequipped path reproduces the old formula exactly across 3123 (item, quality, durability, stat) cases; Unity batchmode compiles with 0 error CS and the Cut 1/2/3 shield probes still pass; AetherDb census, factions, dangling, station-fit, loadout 1 and hardpoint-fit are byte-identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
}
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a
// modifier can point at any design or behaviour without a hard type reference -- but before this cut an
// unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first
// ApplyModifier threw an unhelpful NullReferenceException at random equip time. Resolving it here, once,
// memoized, and failing loudly at catalog load or Upsert (through ValidateStatModifiers below) names the
// record instead of leaving the bug for whoever equips the item first.
private static readonly Dictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields =
new Dictionary<(string, string), (Type, FieldInfo)>();
private static Type[] _statReferenceTypes;
private static Type[] StatReferenceTypes => _statReferenceTypes ??= typeof(BehaviorData).GetAllChildClasses()
.Concat(typeof(EquippableItemData).GetAllChildClasses()).ToArray();
public static (Type Type, FieldInfo Field) ResolveStatField(StatReference reference)
{
var key = (reference.Target, reference.Stat);
if (_resolvedStatFields.TryGetValue(key, out var cached)) return cached;
var targetType = StatReferenceTypes.FirstOrDefault(t => t.Name == reference.Target);
var field = targetType?.GetFields().FirstOrDefault(f => f.FieldType == typeof(PerformanceStat) && f.Name == reference.Stat);
if (targetType == null || field == null)
throw new InvalidOperationException(
$"stat modifier targets \"{reference.Target}.{reference.Stat}\", which does not resolve to a PerformanceStat field");
var resolved = (targetType, field);
_resolvedStatFields[key] = resolved;
return resolved;
}
// Runs wherever ValidateHeatResponse runs (AetheriaStores.Open, CultRecordRefs.Upsert): every StatModifierData
// authored on this design must resolve, named here rather than at whichever equip happens to hit it first.
public static void ValidateStatModifiers(string ownerName, IEnumerable<BehaviorData> behaviors)
{
foreach (var behavior in behaviors)
if (behavior is StatModifierData modifier)
try
{
ResolveStatField(modifier.Stat);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"{ownerName}: {ex.Message}");
}
}
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
// Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently
// fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled
// -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other
// direction: a StatTerm naming a role the design itself never declared is an authoring mistake, not a lot
// predating the role, and must fail loudly rather than silently reading generic quality). Every PerformanceStat
// field on every behaviour is walked; only StatSource.Quality terms carry a Role at all (Evaluate ignores Role
// for every other source).
public static void ValidateRoleUsage(string ownerName, List<ItemRole> roles, IEnumerable<BehaviorData> behaviors)
{
var declared = new HashSet<string>((roles ?? new List<ItemRole>()).Select(r => r.Name));
foreach (var behavior in behaviors ?? Enumerable.Empty<BehaviorData>())
{
if (behavior == null) continue;
foreach (var field in behavior.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)))
{
if (!(field.GetValue(behavior) is PerformanceStat stat) || stat.Terms == null) continue;
foreach (var term in stat.Terms)
{
if (term.Source != StatSource.Quality || string.IsNullOrEmpty(term.Role)) continue;
if (!declared.Contains(term.Role))
throw new InvalidOperationException(
$"{ownerName}: {behavior.GetType().Name}.{field.Name} names role \"{term.Role}\", " +
$"which this design does not declare (declared: {(declared.Count == 0 ? "none" : string.Join(", ", declared))})");
}
}
}
}
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
// Cut 6 (docs/stats-and-power-cut.md): "a stat that decides a power request may not depend on power supply,
// directly or through a modifier chain, and no stat may depend on itself." IPowerConsumer.PowerRequest(dt)
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
// never hands the bus a number it invented on the spot -- each implementation evaluates a fixed set of
// PerformanceStat fields on its own BehaviorData. Those fields are a compile-time fact about the C# type, not
// something authored per catalog instance, so they are named here once -- the same (type, field) address
// ResolveStatField already uses for StatReference -- rather than adding a second reflection idiom or a
// per-instance flag. Naming it here (not a new interface member on IPowerConsumer) means the check needs no
Resolve request/brownout conflict: evaluate power requests nominally A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00
// Behavior instance, no EquippedItem and no Entity: TryGetPowerRequestBehaviorName below can run over the
// catalog's own Data objects with nothing else in hand.
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
//
Resolve request/brownout conflict: evaluate power requests nominally A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00
// F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named every field a PowerRequest implementation
// actually reads, not just each consumer's top-level request field: Radiator.PowerRequest also reads
// PumpedHeat and WasteHeat, AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and
// PassiveCoupling, Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and RestoreDuration,
// InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and Sensor.
// PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Audited directly against each
// PowerRequest method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into)
// rather than guessed from the behaviour's public surface; still the complete census a modifier chain must
// be checked against (see TryGetPowerRequestBehaviorName and StatModifier.ValidateNoPowerSupplyChain below).
//
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): F6 also
// added a static check here -- ValidateNoPowerSupplyOnRequest, refusing any of these fields whose own Terms
// named PowerSupply outright -- on the premise that PowerRequest reads these stats the same way Execute
// does. It does not any more: every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now
// calls EquippedItem.EvaluateNominalPower (Entity.cs) for a field named here, which pins that stat's own
// PowerSupplyFactor to 1 regardless of what Terms it declares -- so a direct PowerSupply term on one of
// these fields can no longer make the request depend on its own answer, and that static check was deleted
// rather than left enforcing an invariant nominal evaluation had already made false (it would otherwise
// still refuse the six shipped records this ruling exists to make legal again). The census below still has
// exactly one live reader: TryGetPowerRequestBehaviorName, for the modifier-chain half of the rule
// (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower forwards
// ScaleModifier/ConstantModifier unchanged (real, not nominal), so a modifier chain that reaches a
// power-tainted magnitude stat still corrupts a nominal read too, and that is what remains forbidden.
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
public static readonly (Type Type, string Field)[] PowerRequestFields =
{
(typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)),
(typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)),
(typeof(ConstantWeaponData), nameof(WeaponData.Energy)),
(typeof(InstantWeaponData), nameof(WeaponData.Energy)),
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
(typeof(InstantWeaponData), nameof(InstantWeaponData.Cooldown)),
(typeof(InstantWeaponData), nameof(InstantWeaponData.Count)),
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
(typeof(SensorData), nameof(SensorData.PingEnergy)),
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
(typeof(SensorData), nameof(SensorData.PingCooldown)),
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
(typeof(ShieldData), nameof(ShieldData.Capacity)),
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
(typeof(ShieldData), nameof(ShieldData.RefillDuration)),
(typeof(ShieldData), nameof(ShieldData.RestoreDuration)),
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
(typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)),
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
(typeof(RadiatorData), nameof(RadiatorData.PumpedHeat)),
(typeof(RadiatorData), nameof(RadiatorData.WasteHeat)),
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
(typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)),
F6: name every field a power request actually reads, not just its top-level one StatValidation.PowerRequestFields registered only each consumer's own top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on), on the premise that PowerRequest reads exactly one PerformanceStat. That was false for five of the eight consumers: Radiator.PowerRequest also reads PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest also reads PingCooldown. Any of those could carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it feeds -- exactly Soul's reproduced "OK Disperser" oscillation (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates). Audited directly against each PowerRequest method's own body (and the RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from the behaviour's public surface. This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries exactly six violations -- cold like my heart RadiatorData.PumpedHeat Arctica RadiatorData.PumpedHeat Skiron RadiatorData.PumpedHeat OK Disperser RadiatorData.PumpedHeat Iapyx RadiatorData.PumpedHeat Traction AetherDriveData.Torque -- re-authoring them (moving the PowerSupply term off the now-covered field, or redesigning Radiator/AetherDrive's own PowerRequest to read a nominal/undegraded value instead) is catalog and design work for Self/roles authoring, not done here. BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's "curve on the performance stat," which is now correctly refused since both behaviours' PowerRequest reads that same stat. Neither behaviour has an untouched field left to curve, so there is currently no valid catalog shape that gives them a working brownout curve without also feeding it back into their own request -- a real, scoped conflict between Cut 6 and Cut 7 for these two consumers. The six affected tests are marked Skip with a header comment explaining the conflict rather than silently reworked or deleted; Thruster and ConstantWeapon are unaffected (their own PowerRequest never reads the separate field their tests curve). Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs: UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail under the reverted (top-level-field-only) registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:14 +02:00
(typeof(AetherDriveData), nameof(AetherDriveData.Torque)),
(typeof(AetherDriveData), nameof(AetherDriveData.LambdaMultiplier)),
(typeof(AetherDriveData), nameof(AetherDriveData.MaximumRpm)),
(typeof(AetherDriveData), nameof(AetherDriveData.PassiveCoupling)),
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
};
// Whether `stat` is the request stat of some IPowerConsumer behaviour on `data` -- used by StatModifier's
// equip-time chain check to tell "an ordinary modifier target" from "a modifier target that would corrupt a
// power request." Returns the owning behaviour's type name for the error message.
public static bool TryGetPowerRequestBehaviorName(EquippableItemData data, PerformanceStat stat, out string behaviorName)
{
foreach (var behavior in data.Behaviors)
foreach (var (type, field) in PowerRequestFields)
{
if (!type.IsInstanceOfType(behavior)) continue;
if (ReferenceEquals(type.GetField(field)?.GetValue(behavior), stat))
{
behaviorName = type.Name;
return true;
}
}
behaviorName = null;
return false;
}
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[CultDocument("aetheria.personalityattribute", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class PersonalityAttribute
{
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
[Inspectable, CultName, JsonProperty("name"), Key(1)]
public string Name;
[Inspectable, JsonProperty("low"), Key(2)]
public string LowName;
[Inspectable, JsonProperty("high"), Key(3)]
public string HighName;
}