Merge eureka/aetheria-living-world-station-kind-datum: station kinds are one catalog list; stations and fields are placed by seed; bad station packages are excluded by name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 02:14:33 +02:00
commit d233194cb2
24 changed files with 1060 additions and 52 deletions

View file

@ -7,17 +7,19 @@ using GameCult.Caching.MessagePack;
// Aetheria's stores: one home store per type, composed here and nowhere else.
public static class AetheriaStores
{
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
// A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog
// is told the hull's name and goes on.
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
// become catalog globals); the global check above is judged against whichever registry opened the cache.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
CultDocumentRegistry registry = null)
CultDocumentRegistry registry = null, Action<string> kindlessStation = null)
{
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
if (!catalogWritable && !File.Exists(catalogPath))
@ -42,6 +44,7 @@ public static class AetheriaStores
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
ValidateBins(cache);
StationKinds.Validate(cache, kindlessStation);
return cache;
}
catch

View file

@ -16,7 +16,8 @@ public static class EntitySerializer
{
Orbit = new CultRecordRef<OrbitData>(orbital.OrbitData),
SecurityLevel = orbital.SecurityLevel,
SecurityRadius = orbital.SecurityRadius
SecurityRadius = orbital.SecurityRadius,
Kind = orbital.Kind
};
else if (entity is Ship ship)
pack = new ShipPack
@ -120,6 +121,7 @@ public static class EntitySerializer
Restore(itemManager, zone, pack, entity);
entity.SecurityLevel = pack.SecurityLevel;
entity.SecurityRadius = pack.SecurityRadius;
entity.Kind = pack.Kind;
if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story];
return entity;
}
@ -214,6 +216,7 @@ public class OrbitalEntityPack : EntityPack
[Key(18)] public int Story = -1;
[Key(19)] public SecurityLevel SecurityLevel;
[Key(20)] public float SecurityRadius;
[Key(22)] public CultRecordRef<StationKindData> Kind;
}
[MessagePackObject,
@ -237,7 +240,7 @@ public abstract class EntityPack
[Key(14)] public EntitySettings Settings;
[Key(15)] public CultRecordRef<Faction> Faction;
[Key(16)] public int[][] WeaponGroups;
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
// Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22).
[Key(21)] public bool Boss;
private int _price;

View file

@ -549,6 +549,12 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("visual"), Key(32)]
public CultRecordRef<ShipAuthoring> Visual;
// What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind
// and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record.
// Key 33 is the next free key.
[Inspectable, JsonProperty("kind"), Key(33)]
public CultRecordRef<StationKindData> Kind;
// The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world
// units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not,
// as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5.

View file

@ -74,9 +74,12 @@ public class LoadoutGenerator
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
}
public OrbitalEntityPack GenerateStationLoadout()
// A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands
// (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind.
public OrbitalEntityPack GenerateStationLoadout(CultRecordRef<StationKindData> kind)
{
var (hullProduct, hullData) = RandomHull(HullType.Station);
var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind);
var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key));
if(hullData==null)
{
ItemManager.Log("Unable to generate station loadout: no compatible hull found!");
@ -85,6 +88,9 @@ public class LoadoutGenerator
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
entity.Kind = kind;
// A stand-in hull keeps its own model but not its own name: the station is what its kind says it is.
if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name;
// Hardpoint gear goes in first so the docking bay can only take space that gear left free
EquipHardpoints(entity);

View file

@ -17,6 +17,8 @@ public class OrbitalEntity : Entity
public float SecurityRadius;
public LocationStory Story;
public bool CanTow;
// What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind.
public CultRecordRef<StationKindData> Kind;
public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings)
{

View file

@ -108,6 +108,7 @@ public static class ShipModCatalog
Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item.");
if (shipIds.Contains(ship.Id))
Exclude(directory, $"ship ID collides with an existing catalog ship authoring record.");
if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault);
foreach (var product in package.Products)
{
if (!factionKeys.Contains(product.Manufacturer.Key))

View file

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of
// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and
// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can
// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:<id>.
[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class StationKindData
{
[CultName, JsonProperty("name"), Key(0)]
public string Name;
[JsonProperty("description"), Key(1)]
public string Description;
// A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the
// faction offers a model of this kind.
[JsonProperty("standIns"), Key(2)]
public List<StationKindStandIn> StandIns = new List<StationKindStandIn>();
public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id);
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class StationKindStandIn
{
[JsonProperty("faction"), Key(0)]
public CultRecordRef<Faction> Faction;
// The kind whose model this faction builds in place of the owning kind's.
[JsonProperty("kind"), Key(1)]
public CultRecordRef<StationKindData> Kind;
}
// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in.
public static class StationKinds
{
// The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it.
// Ordered by key so a choice never depends on store order. Empty for no faction.
public static CultRecordRef<StationKindData>[] KindsOf(CultCache catalog, Faction faction)
{
if (faction == null) return Array.Empty<CultRecordRef<StationKindData>>();
var kinds = new HashSet<string>(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal);
foreach (var kind in catalog.GetAll<StationKindData>())
if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction))
kinds.Add(catalog.RefOf(kind).Key.Value);
return Ordered(kinds);
}
// A station's kind: a stable pick among the faction's kinds by the station's site, which generation names by the zone's
// seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random
// stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some
// offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none.
public static CultRecordRef<StationKindData> Choose(CultCache catalog, Faction faction, string site, Action<string> log = null)
{
var pool = KindsOf(catalog, faction);
if (pool.Length == 0)
{
pool = Ordered(new HashSet<string>(
catalog.GetAll<FactionProductData>().Where(product => LoadoutGenerator.IsOffered(catalog, product))
.Select(product => catalog.Get(product.Design) as HullData)
.Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet())
.Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal));
if (pool.Length == 0) return default;
log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers.");
}
return pool[(int) (site.StableHash() % (uint) pool.Length)];
}
// The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model
// wins over a stale stand-in), else the kind its stand-in names, else the kind itself (the hull draw then falls back to
// any maker).
public static CultRecordRef<StationKindData> HullKindFor(CultCache catalog, Faction faction, CultRecordRef<StationKindData> kind)
{
if (!kind.IsSet()) return kind;
if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind;
var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction);
return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind;
}
// What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one.
// The one rule, applied to the catalog on Open and to each mod package on Compose.
public static string HullKindFault(CultCache catalog, HullData hull)
{
if (hull.HullType == HullType.Station)
{
if (!hull.Kind.IsSet())
return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be.";
if (catalog.Get(hull.Kind) == null)
return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds.";
}
else if (hull.Kind.IsSet())
return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one.";
return null;
}
// Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds
// and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and
// does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner
// of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused.
public static void Validate(CultCache catalog, Action<string> kindlessStation = null)
{
foreach (var hull in catalog.GetAll<HullData>())
{
var fault = HullKindFault(catalog, hull);
if (fault == null) continue;
if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault);
else throw new InvalidOperationException(fault);
}
foreach (var kind in catalog.GetAll<StationKindData>())
{
var self = catalog.RefOf(kind).Key;
for (var i = 0; i < kind.StandIns.Count; i++)
{
var entry = kind.StandIns[i];
var at = $"Station kind '{kind.Name}' StandIns[{i}]";
var faction = catalog.Get(entry.Faction);
if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds.");
var other = catalog.Get(entry.Kind);
if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds.");
if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself.");
if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction))
throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop.");
}
}
}
private static IEnumerable<HullData> OfferedStationHulls(CultCache catalog, Faction faction) =>
catalog.GetAll<FactionProductData>()
.Where(product => LoadoutGenerator.IsOffered(catalog, product) && catalog.Get(product.Manufacturer) == faction)
.Select(product => catalog.Get(product.Design) as HullData)
.Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet());
private static CultRecordRef<StationKindData>[] Ordered(IEnumerable<string> keys) =>
keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef<StationKindData>(new CultRecordKey(key))).ToArray();
}

View file

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

View file

@ -196,7 +196,7 @@ public class Zone
case AsteroidBeltData belt:
// Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by
// the rule generation uses, and keeps it.
FieldKinds.Ensure(body.Key, belt, cache);
FieldKinds.Ensure(body.Key.Value, belt, cache);
AsteroidBelts[body.Key] = new AsteroidBelt(belt);
break;
case SunData sun:

View file

@ -134,10 +134,11 @@ public class FieldKindData
// Mining Cut 3 (Q16 B): the one owner of which kind a belt is.
public static class FieldKinds
{
// A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same
// catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order.
// A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site and the same
// catalog always give the same kind. Generation names a belt by the zone's seed and the belt's place in generation
// order (never by the key the cache mints, which differs every run); a belt saved without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order.
// Unset when the catalog has no kind with positive weight.
public static CultRecordRef<FieldKindData> Assign(CultRecordKey field, CultCache catalog)
public static CultRecordRef<FieldKindData> Assign(string site, CultCache catalog)
{
var kinds = catalog.GetAll<FieldKindData>()
.Where(kind => kind.GenerationWeight > 0f)
@ -146,7 +147,7 @@ public static class FieldKinds
.ToArray();
if (kinds.Length == 0) return default;
var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight);
var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight);
foreach (var kind in kinds)
{
if (pick < kind.Weight) return new CultRecordRef<FieldKindData>(kind.Key);
@ -157,10 +158,10 @@ public static class FieldKinds
// Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says.
// Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule.
public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache)
public static void Ensure(string site, AsteroidBeltData belt, CultCache cache)
{
if (belt.Kind.IsSet()) return;
belt.Kind = Assign(field, cache);
belt.Kind = Assign(site, cache);
if (belt.Kind.IsSet()) cache.Upsert(belt);
}
}

View file

@ -119,7 +119,7 @@ public static class ZoneGenerator
pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList();
var beltOf = new Dictionary<GeneratorPlanet, AsteroidBeltData>();
pack.Planets = planets.Where(p=>!p.Empty).Select(planet =>
pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) =>
{
BodyType bodyType = planet.Belt ? BodyType.Asteroid :
planet.Mass > zoneSettings.SunMass ? BodyType.Sun :
@ -213,7 +213,7 @@ public static class ZoneGenerator
var bodyKey = cache.Upsert(planetData).Key;
planetData.Name = bodyKey.Value.Substring(0, 8);
// Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it.
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache);
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache);
return cache.Upsert(planetData);
}).ToList();
@ -239,7 +239,7 @@ public static class ZoneGenerator
BodyData swarmBody = swarm;
var swarmKey = cache.Upsert(swarmBody).Key;
swarm.Name = swarmKey.Value.Substring(0, 8);
FieldKinds.Ensure(swarmKey, swarm, cache);
FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache);
pack.Planets.Add(cache.Upsert(swarmBody));
}
@ -268,7 +268,7 @@ public static class ZoneGenerator
BodyData lanedBody = laned;
var lanedKey = cache.Upsert(lanedBody).Key;
laned.Name = lanedKey.Value.Substring(0, 8);
FieldKinds.Ensure(lanedKey, laned, cache);
FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache);
pack.Planets.Add(cache.Upsert(lanedBody));
}
@ -419,7 +419,8 @@ public static class ZoneGenerator
if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; }
var orbit = selectedStationOrbits[i];
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout();
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(
StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log));
if (station == null)
{
itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!");
@ -440,7 +441,8 @@ public static class ZoneGenerator
var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f));
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout();
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(
StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log));
if (station == null)
{
if (isTutorialEntrance)