2021-03-18 00:36:34 +00:00
using System ;
2021-03-18 22:48:33 +00:00
using System.Collections.Generic ;
2026-08-10 14:57:50 +01:00
using System.Diagnostics ;
2021-03-18 00:36:34 +00:00
using System.Linq ;
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 ;
2026-09-14 17:39:12 +02:00
using CultMath ;
using static CultMath . math ;
using Random = CultMath . Random ;
2021-03-18 00:36:34 +00:00
public class LoadoutGenerator
{
public Random Random ;
public ItemManager ItemManager { get ; }
2021-05-14 03:03:28 +01:00
public Galaxy Galaxy { get ; }
public GalaxyZone Zone { get ; }
2021-03-20 04:20:17 +00:00
public Faction Faction { get ; }
2021-03-18 00:36:34 +00:00
public float PriceExponent { get ; }
public LoadoutGenerator (
2026-09-12 01:12:12 +02:00
ref Random random ,
ItemManager itemManager ,
Galaxy galaxy ,
GalaxyZone zone ,
Faction faction ,
2021-03-18 00:36:34 +00:00
float priceExponent )
{
Random = random ;
ItemManager = itemManager ;
2021-05-14 03:03:28 +01:00
Galaxy = galaxy ;
2021-03-18 00:36:34 +00:00
Zone = zone ;
Faction = faction ;
PriceExponent = priceExponent ;
}
2026-09-12 01:12:12 +02:00
2026-08-10 14:57:50 +01:00
public EntityPack GenerateShipLoadout ( Predicate < HullData > hullFilter = null )
2021-03-18 00:36:34 +00:00
{
2026-09-12 01:12:12 +02:00
var ( hullProduct , hullData ) = RandomHull ( HullType . Ship , hullFilter ) ;
2026-08-10 14:57:50 +01:00
if ( hullData = = null )
2022-07-12 14:22:23 +01:00
{
ItemManager . Log ( "Unable to generate ship loadout: no compatible hull found!" ) ;
return null ;
}
2026-09-12 01:12:12 +02:00
var hull = ItemManager . CreateInstance ( hullProduct ) as EquippableItem ;
2021-04-30 11:19:25 +01:00
if ( hull = = null )
ItemManager . Log ( "WHAT???" ) ;
2021-03-18 00:36:34 +00:00
var entity = new Ship ( ItemManager , null , hull , ItemManager . GameplaySettings . DefaultEntitySettings ) ;
entity . Faction = Faction ;
OutfitEntity ( entity ) ;
2026-08-10 14:57:50 +01:00
return EntitySerializer . Pack ( entity ) ;
2021-03-18 00:36:34 +00:00
}
2026-08-10 14:57:50 +01:00
public OrbitalEntityPack GenerateTurretLoadout ( )
2021-03-18 00:36:34 +00:00
{
2026-09-12 01:12:12 +02:00
var ( hullProduct , hullData ) = RandomHull ( HullType . Turret ) ;
2026-08-10 14:57:50 +01:00
if ( hullData = = null )
2022-07-12 14:22:23 +01:00
{
ItemManager . Log ( "Unable to generate turret loadout: no compatible hull found!" ) ;
return null ;
}
2026-09-12 01:12:12 +02:00
var hull = ItemManager . CreateInstance ( hullProduct ) as EquippableItem ;
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
var entity = new OrbitalEntity ( ItemManager , null , hull , default , ItemManager . GameplaySettings . DefaultEntitySettings ) ;
2021-03-18 00:36:34 +00:00
entity . Faction = Faction ;
OutfitEntity ( entity ) ;
2026-08-10 14:57:50 +01:00
return EntitySerializer . Pack ( entity ) as OrbitalEntityPack ;
2021-03-18 00:36:34 +00:00
}
2026-08-10 14:57:50 +01:00
public OrbitalEntityPack GenerateStationLoadout ( )
2021-03-20 04:20:17 +00:00
{
2026-09-12 01:12:12 +02:00
var ( hullProduct , hullData ) = RandomHull ( HullType . Station ) ;
2026-08-10 14:57:50 +01:00
if ( hullData = = null )
2022-07-12 14:22:23 +01:00
{
ItemManager . Log ( "Unable to generate station loadout: no compatible hull found!" ) ;
return null ;
}
2026-09-12 01:12:12 +02:00
var hull = ItemManager . CreateInstance ( hullProduct ) as EquippableItem ;
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
var entity = new OrbitalEntity ( ItemManager , null , hull , default , ItemManager . GameplaySettings . DefaultEntitySettings ) ;
2021-03-20 04:20:17 +00:00
entity . Faction = Faction ;
2026-09-11 23:23:19 +02:00
// Hardpoint gear goes in first so the docking bay can only take space that gear left free
EquipHardpoints ( entity ) ;
2021-03-20 04:20:17 +00:00
var emptyShape = entity . UnoccupiedSpace ;
2026-09-11 23:23:19 +02:00
2026-09-12 01:12:12 +02:00
var ( dockingBayProduct , dockingBayData ) = RandomProduct < DockingBayData > ( 2 , item = > item . Shape . FitsWithin ( emptyShape , out _ , out _ ) , required : true ) ;
2026-08-10 14:57:50 +01:00
if ( dockingBayData = = null ) throw new InvalidLoadoutException ( "No compatible docking bay found for station!" ) ;
2021-03-20 04:20:17 +00:00
2026-08-10 14:57:50 +01:00
dockingBayData . Shape . FitsWithin ( emptyShape , out var cargoRotation , out var cargoPosition ) ;
2026-09-12 01:12:12 +02:00
var dockingBay = ItemManager . CreateInstance ( dockingBayProduct ) as EquippableItem ;
2021-03-20 04:20:17 +00:00
dockingBay . Rotation = cargoRotation ;
if ( ! entity . TryEquip ( dockingBay , cargoPosition ) )
{
throw new InvalidLoadoutException ( "Failed to equip selected docking bay!" ) ;
}
2026-09-11 23:23:19 +02:00
2026-09-30 15:45:32 +02:00
// Every station carries a heater (operator, 2026-09-30): an idle station has no other heat source and cools
// toward freezing and invisibility. A heater is general gear whose thermostat runs its heat only below a
// target, the composition a cockpit uses: a low-pass Thermotoggle, then Heat, in its behaviours.
emptyShape = entity . UnoccupiedSpace ;
var ( heaterProduct , heaterData ) = RandomProduct < GearData > ( 2 , item = > IsHeater ( item ) & & item . Shape . FitsWithin ( emptyShape , out _ , out _ ) , required : true ) ;
if ( heaterData = = null ) throw new InvalidLoadoutException ( "No compatible heater found for station!" ) ;
heaterData . Shape . FitsWithin ( emptyShape , out var heaterRotation , out var heaterPosition ) ;
var heater = ItemManager . CreateInstance ( heaterProduct ) as EquippableItem ;
heater . Rotation = heaterRotation ;
if ( ! entity . TryEquip ( heater , heaterPosition ) )
throw new InvalidLoadoutException ( "Failed to equip selected heater!" ) ;
2026-09-11 23:23:19 +02:00
FillInterior ( entity ) ;
2021-03-20 04:20:17 +00:00
var cargo = entity . CargoBays . First ( ) ;
2026-09-12 01:12:12 +02:00
var inventory = RandomProducts < EquippableItemData > ( 16 , 1 ,
data = > ! ( data is HullData hull & & hull . HullType ! = HullType . Ship ) & & ! ( data is CargoBayData ) )
. OrderByDescending ( entry = > entry . design . Shape . Coordinates . Length ) ;
foreach ( var entry in inventory )
2021-03-20 04:20:17 +00:00
{
2026-09-12 01:12:12 +02:00
var instance = ItemManager . CreateInstance ( entry . product ) ;
2021-03-20 04:20:17 +00:00
cargo . TryStore ( instance ) ;
}
2021-06-18 12:32:03 +01:00
2026-08-10 14:57:50 +01:00
entity . CanTow = hullData . CanTow ;
2026-09-12 01:12:12 +02:00
2026-08-10 14:57:50 +01:00
return EntitySerializer . Pack ( entity ) as OrbitalEntityPack ;
2021-03-20 04:20:17 +00:00
}
2026-09-30 15:45:32 +02:00
private static bool IsHeater ( GearData item ) = >
item . HardpointType = = HardpointType . Tool & &
item . Behaviors . Any ( b = > b is ThermotoggleData { HighPass : false } ) & &
item . Behaviors . Any ( b = > b is HeatData ) ;
2026-09-11 23:23:19 +02:00
// Every entity needs a hull, so hulls are always required
2026-09-12 01:12:12 +02:00
public ( FactionProductData product , HullData design ) RandomHull ( HullType type , Predicate < HullData > hullFilter = null )
2021-03-18 00:36:34 +00:00
{
2026-09-12 01:12:12 +02:00
return RandomProduct < HullData > ( 0 , item = >
2026-08-10 14:57:50 +01:00
( hullFilter ? . Invoke ( item ) ? ? true ) & &
2026-09-11 23:23:19 +02:00
item . HullType = = type , required : true ) ;
2021-03-18 00:36:34 +00:00
}
2026-09-11 23:23:19 +02:00
2026-09-12 01:12:12 +02:00
// What a faction makes is which products it has, so generation selects branded products rather than designs.
// Products normally come only from manufacturers present in the galaxy and known to the zone's faction; a
// required item falls back to any manufacturer when none of those make one, which means the product table
// lacks variety, so it is logged rather than hidden.
public ( FactionProductData product , T design ) [ ] RandomProducts < T > ( int count , float sizeExponent , Predicate < T > filter = null , bool required = false ) where T : EquippableItemData
2021-03-18 00:36:34 +00:00
{
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
var hulls = ItemManager . ItemData . GetAll < HullData > ( ) . ToArray ( ) ;
2026-09-12 01:12:12 +02:00
var candidates = ItemManager . ItemData . GetAll < FactionProductData > ( )
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
. Select ( product = > ( product , design : ItemManager . ItemData . Get ( product . Design ) as T ) )
2026-09-12 01:12:12 +02:00
. Where ( entry = >
entry . design ! = null & &
entry . design . 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
entry . product . Manufacturer . IsSet ( ) & &
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
HasHome ( entry . design , hulls ) & &
2026-09-12 01:12:12 +02:00
( filter ? . Invoke ( entry . design ) ? ? true ) )
2026-09-11 23:23:19 +02:00
. ToArray ( ) ;
2026-09-12 01:12:12 +02:00
var available = candidates . Where ( entry = > IsAvailable ( entry . product ) ) . ToArray ( ) ;
2026-09-11 23:23:19 +02:00
var preferManufacturers = true ;
if ( available . Length = = 0 & & required & & candidates . Length > 0 )
{
2026-09-12 01:12:12 +02:00
ItemManager . Log ( $"No {typeof(T).Name} product available from manufacturers known to {Faction?.Name ?? " this galaxy "}; using any manufacturer. The product table needs more variety." ) ;
2026-09-11 23:23:19 +02:00
available = candidates ;
preferManufacturers = false ;
}
2026-09-12 01:12:12 +02:00
return available . WeightedRandomElements ( ref Random , entry = >
( preferManufacturers ? ManufacturerPreference ( entry . product . Manufacturer ) : 1 ) *
pow ( entry . design . Shape . Coordinates . Length , sizeExponent ) / // Prioritize larger items
pow ( entry . design . Price , PriceExponent ) , // Penalize item price to a controllable degree
2026-09-11 23:23:19 +02:00
count ) ;
2021-03-20 04:20:17 +00:00
}
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
private static bool HasHome ( EquippableItemData design , HullData [ ] hulls ) = >
design . HardpointType = = HardpointType . Tool | | design . HardpointType = = HardpointType . Hull | |
hulls . Any ( hull = > hull . Hardpoints . Any ( hardpoint = > hardpoint . Takes ( design ) ) ) ;
2026-09-12 23:22:36 +02:00
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
at the origin, as StartGame built it. With one, the player preset and the
entities at their authored place and heading with cargo, stance set both
ways through SetIff, a Minion for each piloted one; all through Zone.Admit.
Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:02 +02:00
// Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so
2026-09-30 23:13:34 +02:00
// presets and generation share one availability rule. In a galaxy, a faction always reaches its own manufacturer's
// gear, and otherwise gear made by a manufacturer in that galaxy that its allegiance names (operator, 2026-09-30:
// allegiance lists only other factions).
Make loadouts player-store design documents built all-or-nothing (Cut 10)
Loadout.cs owns loadouts: the document, capture, save and materialization.
- Loadout ("aetheria.loadout", player store) holds a hull design ref, and
per slot a hull cell, a rotation and an EquippableItemData design ref,
plus weapon groups as slot indices. It names no product, manufacturer,
unit roll, cargo, faction, position or behaviour state.
- Loadouts.Capture records the hull design and one slot per equipped item
(gear, cargo bays, docking bays). Entity.Equipment also holds the hull's
own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot.
- Loadouts.Save is the only writer: one Commit to the player store, reusing
the existing key for the name.
- Loadouts.Materialize is the only builder of a ship from a loadout. For
the hull and each slot in order it resolves the design and takes the
first product in catalog order that LoadoutGenerator.IsAvailable (now
public) accepts, with no any-manufacturer fallback. Every failing slot is
listed; units are built only after everything resolved, and any resolve
or fit failure returns null with no ship, no charge and no write.
- Loadouts.Price is the hull design's price plus each slot design's price.
- InventoryPanel: Save Loadout asks for a name (default: the ship's name)
and saves the capture. Restore Loadout, shown when docked with saved
loadouts, lists each with its price, debits credits only after
Materialize succeeds, then docks the ship as before; a failure opens
"Loadout cannot be built here" with one line per failing slot.
Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache,
LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its
rule mutated out. The fixture hull is 3x3, not the spec's 2x2:
HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior
cell a cargo bay can occupy.
Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
public bool IsAvailable ( FactionProductData product ) = >
2026-09-12 23:22:36 +02:00
Galaxy = = null | | Galaxy . IsPrelude | |
2026-09-30 23:13:34 +02:00
Faction ! = null & & ItemManager . ItemData . Get ( product . Manufacturer ) = = Faction | |
2026-09-12 01:12:12 +02:00
Galaxy . ContainsFaction ( product . Manufacturer ) & & ( Faction = = null | | Faction . Allegiance . ContainsKey ( product . Manufacturer ) ) ;
2026-09-11 23:23:19 +02:00
2026-09-12 01:12:12 +02:00
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
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
private float ManufacturerPreference ( CultRecordRef < Faction > manufacturer )
2021-03-20 04:20:17 +00:00
{
2026-09-12 23:22:36 +02:00
if ( Faction = = null | | Galaxy = = null ) return 1 ;
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
var maker = ItemManager . ItemData . Get ( manufacturer ) ;
var allegiance = maker = = Faction ? 1 :
2026-09-11 23:23:19 +02:00
Faction . Allegiance . TryGetValue ( manufacturer , out var a ) ? a : 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
var home = maker ! = null & & Galaxy . HomeZones . TryGetValue ( maker , out var h ) ? h : null ;
2026-09-11 23:23:19 +02:00
var distance = home ! = null & & Zone ? . Distance ! = null & & Zone . Distance . TryGetValue ( home , out var d ) ? d : 0 ;
return allegiance / ( 1 + distance ) ;
2021-03-18 00:36:34 +00:00
}
2026-09-11 23:23:19 +02:00
2026-09-12 01:12:12 +02:00
public ( FactionProductData product , T design ) RandomProduct < T > ( float sizeExponent , Predicate < T > filter = null , bool required = false ) where T : EquippableItemData
2026-09-11 23:23:19 +02:00
{
2026-09-12 01:12:12 +02:00
return RandomProducts ( 1 , sizeExponent , filter , required ) . FirstOrDefault ( ) ;
2026-09-11 23:23:19 +02:00
}
2026-09-12 01:12:12 +02:00
public ( FactionProductData product , T design ) RandomProduct < T > ( HardpointData hardpoint , float sizeExponent , Predicate < T > filter = null , bool required = false ) where T : EquippableItemData
2026-06-13 23:20:16 +01:00
{
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
// Every candidate passes the one fit rule, HardpointData.Takes. Generation prefers gear that fills the hardpoint
// and falls back to anything that fits only when nothing that fills it is on offer.
var filling = RandomProduct < T > ( sizeExponent , item = > hardpoint . IsFilledBy ( item ) & & ( filter ? . Invoke ( item ) ? ? true ) ) ;
if ( filling . design ! = null ) return filling ;
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
return RandomProduct < T > ( sizeExponent , item = > hardpoint . Takes ( item ) & & ( filter ? . Invoke ( item ) ? ? true ) , required ) ;
2026-06-14 05:25:56 +01:00
}
2021-03-18 00:36:34 +00:00
private void OutfitEntity ( Entity entity )
2026-09-11 23:23:19 +02:00
{
EquipHardpoints ( entity ) ;
FillInterior ( entity ) ;
}
private void EquipHardpoints ( Entity entity )
2021-03-18 00:36:34 +00:00
{
2026-08-10 14:57:50 +01:00
var hullData = ItemManager . GetData ( entity . Hull ) as HullData ;
foreach ( var v in hullData . Shape . Coordinates ) entity . HullConductivity [ v . x , v . y ] = true ;
2026-09-12 01:12:12 +02:00
var previousProducts = new List < ( FactionProductData product , GearData design ) > ( ) ;
2026-08-10 14:57:50 +01:00
foreach ( var hardpoint in hullData . Hardpoints . OrderByDescending ( h = > h . Shape . Coordinates . Length ) )
2021-03-18 00:36:34 +00:00
{
2026-08-10 14:57:50 +01:00
if ( hardpoint . Type = = HardpointType . ControlModule )
2021-03-18 00:36:34 +00:00
{
2026-09-12 01:12:12 +02:00
var ( controllerProduct , controllerData ) = RandomProduct < GearData > ( hardpoint , 2 ,
2026-09-11 23:23:19 +02:00
item = > item . Behaviors . Any ( b = > entity is Ship & & b is CockpitData | | entity is OrbitalEntity & & b is TurretControllerData ) , required : true ) ;
2026-09-12 01:12:12 +02:00
if ( controllerData = = null )
2021-03-27 10:03:24 +00:00
throw new InvalidLoadoutException ( "No compatible controller found for entity!" ) ;
2026-09-12 01:12:12 +02:00
var controller = ItemManager . CreateInstance ( controllerProduct ) as EquippableItem ;
2021-03-18 00:36:34 +00:00
if ( ! entity . TryEquip ( controller ) )
{
2026-08-10 14:57:50 +01:00
throw new InvalidLoadoutException ( $"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!" ) ;
2021-03-18 00:36:34 +00:00
}
}
else
{
2026-09-12 01:12:12 +02:00
// If a previously selected product fits, use that one (this is why we must process larger hardpoints first)
var entry = previousProducts
Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.
Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
. FirstOrDefault ( e = > hardpoint . Takes ( e . design ) ) ;
2026-09-12 01:12:12 +02:00
var previousItem = entity . Equipment . FirstOrDefault ( item = > item . Data = = entry . design ) ;
if ( entry . design = = null ) entry = RandomProduct < GearData > ( hardpoint , 2 ) ;
if ( entry . design = = null ) ItemManager . Log ( $"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!" ) ;
2021-03-18 00:36:34 +00:00
else
{
2026-09-17 04:16:23 +02:00
// Matching hardpoints carry matching units: same lot
var item = ( previousItem ! = null
? ItemManager . CreateInstance ( previousItem . EquippableItem . Lot )
: ItemManager . CreateInstance ( entry . product ) ) as EquippableItem ;
2021-03-18 00:36:34 +00:00
if ( ! entity . TryEquip ( item ) )
{
2026-08-10 14:57:50 +01:00
throw new InvalidLoadoutException ( $"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!" ) ;
2021-03-18 00:36:34 +00:00
}
2026-09-12 01:12:12 +02:00
previousProducts . Add ( entry ) ;
2021-03-18 00:36:34 +00:00
}
}
}
2026-09-11 23:23:19 +02:00
}
// Interior gear may use any free interior cell, including empty hardpoint cells
private void FillInterior ( Entity entity )
{
2021-03-20 04:20:17 +00:00
var emptyShape = entity . UnoccupiedSpace ;
2026-09-11 23:23:19 +02:00
2026-09-12 01:12:12 +02:00
var ( cargoProduct , cargoData ) = RandomProduct < CargoBayData > ( 3 , item = >
2026-08-10 14:57:50 +01:00
! ( item is DockingBayData ) & &
2026-09-11 23:23:19 +02:00
item . Shape . FitsWithin ( emptyShape , out _ , out _ ) , required : true ) ;
2026-08-10 14:57:50 +01:00
if ( cargoData = = null ) throw new InvalidLoadoutException ( "No compatible cargo bay found for entity!" ) ;
2021-03-20 04:20:17 +00:00
2026-08-10 14:57:50 +01:00
cargoData . Shape . FitsWithin ( emptyShape , out var cargoRotation , out var cargoPosition ) ;
2026-09-12 01:12:12 +02:00
var cargo = ItemManager . CreateInstance ( cargoProduct ) as EquippableItem ;
2021-03-20 04:20:17 +00:00
cargo . Rotation = cargoRotation ;
if ( ! entity . TryEquip ( cargo , cargoPosition ) )
throw new InvalidLoadoutException ( "Failed to equip selected cargo bay!" ) ;
emptyShape = entity . UnoccupiedSpace ;
2021-03-18 00:36:34 +00:00
2026-09-12 01:12:12 +02:00
var ( capacitorProduct , capacitorData ) = RandomProduct < GearData > ( 2 , item = >
2026-08-10 14:57:50 +01:00
item . Behaviors . Any ( b = > b is CapacitorData ) & &
2026-09-11 23:23:19 +02:00
item . Shape . FitsWithin ( emptyShape , out _ , out _ ) , required : true ) ;
2026-08-10 14:57:50 +01:00
if ( capacitorData = = null ) throw new InvalidLoadoutException ( "No compatible capacitor found for entity!" ) ;
2021-03-18 00:36:34 +00:00
2026-08-10 14:57:50 +01:00
capacitorData . Shape . FitsWithin ( emptyShape , out var capacitorRotation , out var capacitorPosition ) ;
2026-09-12 01:12:12 +02:00
var capacitor = ItemManager . CreateInstance ( capacitorProduct ) as EquippableItem ;
2021-03-18 00:36:34 +00:00
capacitor . Rotation = capacitorRotation ;
if ( ! entity . TryEquip ( capacitor , capacitorPosition ) )
throw new InvalidLoadoutException ( "Failed to equip selected capacitor!" ) ;
2026-09-19 13:02:37 +02:00
// Cut 2 (docs/fire-control-cut.md, Q4): required equipment for anything that can fire -- unaided
// fire is a last resort, not an alternative loadout choice. EquipHardpoints runs before FillInterior
// (OutfitEntity), so entity.Weapons is already populated here. Zenith and anything else with no
// weapon hardpoints gets none.
2026-09-19 18:31:46 +02:00
//
// Cut 6c, 6c.2: this used to be `required: true` and throw InvalidLoadoutException when no seller was
// available -- a galaxy that happened not to draw a targeting design's manufacturer failed entity
// generation outright, a single point of failure the ten-seed smoke never sampled. It degrades instead:
// required: false, so RandomProducts' own manufacturer-widening fallback never fires and an unmet
// requirement returns null quietly rather than searching outside the galaxy for one. An entity that
// ends up with no targeting system fires through FireControl's unaided fallback
// (GameplaySettings.UnaidedTracking, Cut 5.1) rather than not firing at all -- that floor is what makes
// this degrade safe. The gap is logged so it surfaces as content debt (AetherDb loadout reports it per
// seed) instead of vanishing silently.
2026-09-19 13:02:37 +02:00
if ( entity . Weapons . Any ( ) )
{
emptyShape = entity . UnoccupiedSpace ;
var ( targetingProduct , targetingData ) = RandomProduct < GearData > ( 2 , item = >
item . Behaviors . Any ( b = > b is TargetingSystemData ) & &
2026-09-19 18:31:46 +02:00
item . Shape . FitsWithin ( emptyShape , out _ , out _ ) ) ;
if ( targetingData = = null )
{
ItemManager . Log ( "No targeting system available for armed entity; it will fire unaided." ) ;
}
else
{
targetingData . Shape . FitsWithin ( emptyShape , out var targetingRotation , out var targetingPosition ) ;
var targetingSystem = ItemManager . CreateInstance ( targetingProduct ) as EquippableItem ;
targetingSystem . Rotation = targetingRotation ;
if ( ! entity . TryEquip ( targetingSystem , targetingPosition ) )
throw new InvalidLoadoutException ( "Failed to equip selected targeting system!" ) ;
}
2026-09-19 13:02:37 +02:00
}
2021-03-18 00:36:34 +00:00
}
2026-09-12 01:12:12 +02:00
2021-03-18 00:36:34 +00:00
}
public class InvalidLoadoutException : Exception
{
public InvalidLoadoutException ( )
{
}
public InvalidLoadoutException ( string message )
: base ( message )
{
}
public InvalidLoadoutException ( string message , Exception inner )
: base ( message , inner )
{
}
2026-09-12 01:12:12 +02:00
}