Ore geography: yields, provinces cut inside boss-zone regions, types dealt per region

FieldYield and FieldKindData.Yields (key 3), ProvinceData, FieldKinds.Pick/Assign/Ensure/Yield
with a province type, Provinces.Regions/Partition/Deal, Galaxy.Provinces built once by the
generation and save constructors, SavedGame key 15 and SavedZone key 6, ProvinceZones setting.
Not yet built or run; tests follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 06:42:58 +01:00
commit 4848c5332f
8 changed files with 347 additions and 28 deletions

View file

@ -7,7 +7,7 @@ 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), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(ProvinceData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };

View file

@ -31,6 +31,10 @@ public class Galaxy
// The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds
// generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed).
public uint Seed { get; }
// The run's resource provinces, ordered by seed zone index; GalaxyZone.Province is each zone's. Built once, by the
// generation constructors or the save constructor (BuildProvinces), and never by PlaceGate, faction placement or naming.
public GalaxyProvince[] Provinces { get; private set; }
private GalaxyZone[] _exitPath;
private readonly CultCache _cache;
@ -89,6 +93,9 @@ public class Galaxy
if(savedGame.Exit != -1)
Exit = Zones[savedGame.Exit];
// A save from before ore geography has no provinces: it gets them by the rule generation uses.
BuildProvinces(global::Provinces.DefaultZonesPerProvince, savedGame.Provinces == null ? null : savedZones.Select(zone => zone.Province).ToArray(), savedGame.Provinces);
CalculateDistanceMatrix();
}
@ -127,6 +134,8 @@ public class Galaxy
PlaceFactionsMain(settings.BossCount, progressCallback);
BuildProvinces(settings.ProvinceZones);
CalculateFactionInfluence(progressCallback);
NamePlaces(ref random, progressCallback);
@ -135,6 +144,33 @@ public class Galaxy
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
}
// Regions, then each region's provinces and their types (Provinces.Regions, Partition, Deal); the one builder, called by
// the generation constructors and the save constructor. A save passes its membership and types: membership is kept, and a
// saved type that still resolves is kept, any other taking the card the region's Deal gives that province's place.
private void BuildProvinces(int zonesPerProvince, int[] savedSeedOf = null, List<SavedProvince> savedTypes = null)
{
var index = Zones.Select((zone, i) => (zone, i)).ToDictionary(pair => pair.zone, pair => pair.i);
var adjacency = Zones.Select(zone => zone.AdjacentZones.Select(next => index[next]).ToArray()).ToArray();
var regionOf = global::Provinces.Regions(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), Exit == null ? -1 : index[Exit]);
var seedOf = savedSeedOf ?? global::Provinces.Partition(adjacency, regionOf, Seed, zonesPerProvince);
var types = savedTypes?.ToDictionary(saved => saved.Seed, saved => saved.Type);
var provinces = new List<GalaxyProvince>();
foreach (var region in Enumerable.Range(0, Zones.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
{
var seeds = region.Select(zone => seedOf[zone]).Distinct().OrderBy(seed => seed).ToArray();
var hand = global::Provinces.Deal(region.Key, seeds.Length, Seed, _cache);
for (var place = 0; place < seeds.Length; place++)
{
var type = types != null && types.TryGetValue(seeds[place], out var saved) && _cache.Get(saved) != null ? saved : hand[place];
var province = new GalaxyProvince {Seed = Zones[seeds[place]], Type = type, Zones = region.Where(zone => seedOf[zone] == seeds[place]).Select(zone => Zones[zone]).ToArray()};
foreach (var zone in province.Zones) zone.Province = province;
provinces.Add(province);
}
}
Provinces = provinces.OrderBy(province => index[province.Seed]).ToArray();
}
public Faction ResolveFaction(string name)
{
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
@ -256,6 +292,9 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
// No boss zone and no Exit yet (PlaceGate comes after): the prelude is one region.
BuildProvinces(settings.ProvinceZones);
NamePlaces(ref random, progressCallback);
// progressCallback?.Invoke("Weaving Narrative");
@ -571,10 +610,20 @@ public class GalaxyZone
public Faction Owner;
public Zone Contents;
public ZonePack PackedContents;
public GalaxyProvince Province;
public bool NamedZone;
public List<LocationStory> Locations = new List<LocationStory>();
}
// A resource province (ruling provinces-within-regions): a contiguous group of zones inside one region, grown from Seed,
// whose Type (ProvinceData, unset when the catalog holds none) weights the kinds and ores of its belts.
public class GalaxyProvince
{
public GalaxyZone Seed;
public CultRecordRef<ProvinceData> Type;
public GalaxyZone[] Zones;
}
public class GalaxyQuest
{
public Story Story;

View file

@ -55,6 +55,22 @@ public class SavedGame
// by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops.
[Key(14)]
public uint Seed;
// The run's resource provinces (Galaxy.Provinces): each province's seed zone index and its type. Written only by
// RunSave.Capture and read only by Galaxy's save constructor; null in a save written before ore geography, which then
// gets Regions, Partition and Deal from its saved graph, boss zones, Exit and seed.
[Key(15)]
public List<SavedProvince> Provinces;
}
[MessagePackObject]
public class SavedProvince
{
[Key(0)]
public int Seed;
[Key(1)]
public CultRecordRef<ProvinceData> Type;
}
// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records;
@ -96,8 +112,11 @@ public static class RunSave
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
Contents = zone.Contents?.PackZone() ?? zone.PackedContents,
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner)
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner),
Province = Array.IndexOf(galaxy.Zones, zone.Province.Seed)
}).ToArray();
game.Provinces = galaxy.Provinces.Select(province => new SavedProvince
{Seed = Array.IndexOf(galaxy.Zones, province.Seed), Type = province.Type}).ToList();
return (game, zones);
}
@ -221,6 +240,11 @@ public class SavedZone
[Key(5)]
public ZonePack Contents;
// The seed zone index of the zone's province. Written only by RunSave.Capture and read only by Galaxy's save
// constructor; meaningful only when SavedGame.Provinces is present.
[Key(6)]
public int Province;
}
[MessagePackObject,

View file

@ -83,6 +83,8 @@ public class SectorGenerationSettings
{
public float LinkDensity = .5f;
public int ZoneCount = 128;
// Zones per resource province (Provinces.Partition): calibration, not a gate.
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
public int MegaCount;
public int BossCount;
}
@ -97,6 +99,8 @@ public class TutorialGenerationSettings
public string QuestFaction;
public float LinkDensity = .5f;
public int ZoneCount = 32;
// Zones per resource province (Provinces.Partition): calibration, not a gate.
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
}
[Serializable, MessagePackObject(keyAsPropertyName:true), JsonObject]

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.Value, belt, cache);
FieldKinds.Ensure(body.Key.Value, belt, cache, GalaxyZone?.Province?.Type ?? default);
AsteroidBelts[body.Key] = new AsteroidBelt(belt);
break;
case SunData sun:

View file

@ -113,7 +113,8 @@ public class AsteroidBeltData : BodyData
}
// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's
// reflectivity comes from its field's kind; Cut 5 adds the yield table.
// reflectivity comes from its field's kind; its yields (ore geography, ruling ores-are-regional) are the ores a belt of
// this kind holds before a province weights them. FieldKinds.Yield is the one reader.
[CultDocument("aetheria.fieldkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FieldKindData
{
@ -128,41 +129,282 @@ public class FieldKindData
// How often generation picks this kind, relative to the others (FieldKinds.Assign). Zero never picks it.
[JsonProperty("generationWeight"), Key(2)]
public float GenerationWeight = 1;
// The ores a belt of this kind holds. Empty on a kind from before this key (MessagePack leaves a missing trailing key at its default).
[JsonProperty("yields"), Key(3)]
public List<FieldYield> Yields = new List<FieldYield>();
}
// Mining Cut 3 (Q16 B): the one owner of which kind a belt is.
// One ore a kind of field holds: Abundance of Commodity before the belt's province weights it. A Regional ore is held only
// where the province lists it (ruling ores-are-regional: some ores exist only in some provinces); any other entry is held
// everywhere, scaled by the province's factor for it.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FieldYield
{
[JsonProperty("commodity"), Key(0)]
public CultRecordRef<SimpleCommodityData> Commodity;
[JsonProperty("abundance"), Key(1)]
public float Abundance;
[JsonProperty("regional"), Key(2)]
public bool Regional;
}
// Ore geography (rulings provinces-within-regions, ores-are-regional): a kind of ground, authored. A region deals its
// provinces' types from a deck holding GenerationWeight cards of each type (ruling province-types-dealt-per-region);
// a province's type weights which kinds its belts draw and which ores a kind holds there.
[CultDocument("aetheria.provincedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProvinceData
{
[CultName, JsonProperty("name"), Key(0)]
public string Name = "";
// The number of cards of this type in a region's deck. Zero is never dealt.
[JsonProperty("generationWeight"), Key(1)]
public int GenerationWeight = 1;
// Multiplies the kind's own GenerationWeight in a belt's draw; a kind not listed is multiplied by one.
[JsonProperty("kinds"), Key(2)]
public List<ProvinceKindWeight> Kinds = new List<ProvinceKindWeight>();
// Multiplies an ore's Abundance wherever it is held; an ore not listed is multiplied by one, or by zero when the
// kind's entry for it is Regional.
[JsonProperty("ores"), Key(3)]
public List<ProvinceOre> Ores = new List<ProvinceOre>();
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProvinceKindWeight
{
[JsonProperty("kind"), Key(0)]
public CultRecordRef<FieldKindData> Kind;
[JsonProperty("weight"), Key(1)]
public float Weight = 1;
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProvinceOre
{
[JsonProperty("commodity"), Key(0)]
public CultRecordRef<SimpleCommodityData> Commodity;
[JsonProperty("weight"), Key(1)]
public float Weight = 1;
}
// Mining Cut 3 (Q16 B): the one owner of which kind a belt is. Ore geography: and of what it yields.
public static class FieldKinds
{
// 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(string site, CultCache catalog)
// The one weighted pick: an index into weights, chosen by a stable hash of the site (the same site and the same
// weights always give the same index), never one whose weight is not positive. -1 when no weight is positive.
internal static int Pick(string site, double[] weights)
{
var pick = site.StableHash() / 4294967296.0 * weights.Sum();
var last = -1;
for (var i = 0; i < weights.Length; i++)
{
if (weights[i] <= 0) continue;
last = i;
if (pick < weights[i]) return i;
pick -= weights[i];
}
return last;
}
// A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site, catalog and province
// type always give the same kind. A kind's weight is its GenerationWeight times its province's factor for it; a belt with
// no province type (unset, or naming nothing the catalog holds) draws unweighted. 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(string site, CultCache catalog, CultRecordRef<ProvinceData> province)
{
var type = catalog.Get(province);
var kinds = catalog.GetAll<FieldKindData>()
.Where(kind => kind.GenerationWeight > 0f)
.Select(kind => (Key: catalog.RefOf(kind).Key, Weight: kind.GenerationWeight))
.Select(kind =>
{
var key = catalog.RefOf(kind).Key;
return (Key: key, Weight: kind.GenerationWeight * Factor(
type?.Kinds.Where(entry => entry.Kind.Key.Equals(key)).Select(entry => (double) entry.Weight), 1.0));
})
.Where(kind => kind.Weight > 0.0)
.OrderBy(kind => kind.Key.Value, StringComparer.Ordinal)
.ToArray();
if (kinds.Length == 0) return default;
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);
pick -= kind.Weight;
}
return new CultRecordRef<FieldKindData>(kinds[kinds.Length - 1].Key);
return new CultRecordRef<FieldKindData>(kinds[Pick(site, kinds.Select(kind => kind.Weight).ToArray())].Key);
}
// A province's factor for one entry: the sum of its listings, or the default when it lists none.
private static double Factor(IEnumerable<double> listed, double unlisted)
{
var weights = listed?.ToArray();
return weights == null || weights.Length == 0 ? unlisted : weights.Sum();
}
// 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(string site, AsteroidBeltData belt, CultCache cache)
public static void Ensure(string site, AsteroidBeltData belt, CultCache cache, CultRecordRef<ProvinceData> province)
{
if (belt.Kind.IsSet()) return;
belt.Kind = Assign(site, cache);
belt.Kind = Assign(site, cache, province);
if (belt.Kind.IsSet()) cache.Upsert(belt);
}
// What a belt of this kind holds in a zone of this province: each of the kind's yields, its Abundance times the
// province's factor for the ore (unlisted: one, or zero when the yield is Regional), summed per commodity, in commodity
// key order, zero amounts dropped. Derived on every call, never stored. Empty for an unset kind.
public static (CultRecordRef<SimpleCommodityData> Commodity, float Amount)[] Yield(CultRecordRef<FieldKindData> kind,
CultRecordRef<ProvinceData> province, CultCache catalog)
{
var data = catalog.Get(kind);
if (data == null) return Array.Empty<(CultRecordRef<SimpleCommodityData>, float)>();
var type = catalog.Get(province);
return data.Yields
.Select(yield => (yield.Commodity, Amount: yield.Abundance * Factor(
type?.Ores.Where(ore => ore.Commodity.Key.Equals(yield.Commodity.Key)).Select(ore => (double) ore.Weight), yield.Regional ? 0.0 : 1.0)))
.GroupBy(yield => yield.Commodity.Key.Value, StringComparer.Ordinal)
.OrderBy(group => group.Key, StringComparer.Ordinal)
.Select(group => (group.First().Commodity, Amount: (float) group.Sum(yield => yield.Amount)))
.Where(yield => yield.Amount > 0f)
.ToArray();
}
}
// Ore geography (rulings provinces-within-regions, boss-zone-is-near-side, province-partition-by-size, province-types-dealt-per-region).
// Pure functions of the zone graph, the run seed and the catalog: nothing here draws from Galaxy's Random stream, stores membership
// or reads who owns what. Galaxy builds provinces once, in its generation and save constructors, and nothing re-partitions them.
public static class Provinces
{
// Zones per province when a settings object does not say (SectorGenerationSettings.ProvinceZones): calibration, not a rule.
public const int DefaultZonesPerProvince = 8;
// Each zone's region, named by the region's least zone index. A boss zone sits on the near side of its border: its
// crossing links are its links to zones in the component of exit in the graph without it, and cutting every boss zone's
// crossing links leaves the regions as the connected components. Every zone, boss zones included, is in exactly one.
// exit unset (-1), or a boss zone that is the exit, cuts nothing.
public static int[] Regions(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
{
var cut = new HashSet<(int, int)>();
if (exit >= 0)
foreach (var boss in bossZones.Distinct().Where(boss => boss != exit))
{
var exitSide = Flood(adjacency, exit, (a, b) => b != boss);
foreach (var next in adjacency[boss])
if (exitSide[next]) cut.Add((Math.Min(boss, next), Math.Max(boss, next)));
}
var regionOf = new int[adjacency.Length];
Array.Fill(regionOf, -1);
for (var start = 0; start < adjacency.Length; start++)
{
if (regionOf[start] >= 0) continue;
var members = Flood(adjacency, start, (a, b) => !cut.Contains((Math.Min(a, b), Math.Max(a, b))));
for (var i = 0; i < members.Length; i++) if (members[i]) regionOf[i] = start;
}
return regionOf;
}
// Each zone's province, named by the province's seed zone index. A region of size s takes max(1, round(s / zonesPerProvince))
// seeds: the first is the zone with the least hash of the run seed, each next the zone farthest (in-region hops) from the
// seeds chosen so far, ties by that hash; then every zone joins the nearest seed, ties to the earlier seed. A province is
// contiguous and never crosses a region.
public static int[] Partition(int[][] adjacency, int[] regionOf, uint runSeed, int zonesPerProvince)
{
var seedOf = new int[adjacency.Length];
uint Hash(int index) => $"{runSeed}:province-seed:{index}".StableHash();
foreach (var region in Enumerable.Range(0, adjacency.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
{
var zones = region.ToArray();
var count = Math.Min(zones.Length, Math.Max(1, (int) Math.Round((double) zones.Length / Math.Max(1, zonesPerProvince), MidpointRounding.AwayFromZero)));
var seeds = new List<int> { zones.OrderBy(Hash).ThenBy(zone => zone).First() };
while (seeds.Count < count)
{
var hops = Grow(adjacency, regionOf, seeds.ToArray()).Hops;
seeds.Add(zones.Where(zone => !seeds.Contains(zone)).OrderByDescending(zone => hops[zone]).ThenBy(Hash).ThenBy(zone => zone).First());
}
var owner = Grow(adjacency, regionOf, seeds.ToArray()).Owner;
foreach (var zone in zones) seedOf[zone] = seeds[owner[zone]];
}
return seedOf;
}
// A region's hand of province types, one per province in ascending seed-zone order. Types are dealt without replacement from
// a deck of GenerationWeight cards per type (catalog types in key order), each draw one weighted pick over the cards remaining
// at a site named by the run seed, the region and the draw's place; the deck is restored whole when it runs out. An empty
// deck deals every province an unset type.
public static CultRecordRef<ProvinceData>[] Deal(int regionKey, int seedCount, uint runSeed, CultCache catalog)
{
var types = catalog.GetAll<ProvinceData>()
.Where(type => type.GenerationWeight > 0)
.Select(type => (Ref: catalog.RefOf(type), Cards: type.GenerationWeight))
.OrderBy(type => type.Ref.Key.Value, StringComparer.Ordinal)
.ToArray();
var hand = new CultRecordRef<ProvinceData>[seedCount];
if (types.Length == 0) return hand;
var remaining = types.Select(type => type.Cards).ToArray();
for (var n = 0; n < seedCount; n++)
{
if (remaining.Sum() == 0) remaining = types.Select(type => type.Cards).ToArray();
var card = FieldKinds.Pick($"{runSeed}:province-deal:{regionKey}:{n}", remaining.Select(cards => (double) cards).ToArray());
remaining[card]--;
hand[n] = types[card].Ref;
}
return hand;
}
// Every zone reachable from start over the links the filter allows.
private static bool[] Flood(int[][] adjacency, int start, Func<int, int, bool> allowed)
{
var reached = new bool[adjacency.Length];
var stack = new Stack<int>();
reached[start] = true;
stack.Push(start);
while (stack.Count > 0)
{
var zone = stack.Pop();
foreach (var next in adjacency[zone])
if (!reached[next] && allowed(zone, next))
{
reached[next] = true;
stack.Push(next);
}
}
return reached;
}
// Breadth-first growth from seeds inside their regions, level by level: each zone's hops to the nearest seed and the rank
// (place in seeds) of the earliest seed among those that reach it first.
private static (int[] Owner, int[] Hops) Grow(int[][] adjacency, int[] regionOf, int[] seeds)
{
var owner = new int[adjacency.Length];
var hops = new int[adjacency.Length];
Array.Fill(owner, -1);
for (var rank = 0; rank < seeds.Length; rank++)
{
owner[seeds[rank]] = rank;
hops[seeds[rank]] = 0;
}
var frontier = seeds.ToList();
for (var level = 1; frontier.Count > 0; level++)
{
var claims = new Dictionary<int, int>();
foreach (var zone in frontier)
foreach (var next in adjacency[zone])
if (owner[next] < 0 && regionOf[next] == regionOf[zone])
claims[next] = claims.TryGetValue(next, out var rank) ? Math.Min(rank, owner[zone]) : owner[zone];
foreach (var claim in claims)
{
owner[claim.Key] = claim.Value;
hops[claim.Key] = level;
}
frontier = claims.Keys.OrderBy(zone => zone).ToList();
}
return (owner, hops);
}
}
[CultDocument("aetheria.gasgiantdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -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($"{seed}:belt:{index}", generatedBelt, cache);
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache, galaxyZone.Province?.Type ?? default);
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($"{seed}:swarm:{swarmHosts.Count}", swarm, cache);
FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache, galaxyZone.Province?.Type ?? default);
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($"{seed}:lane:{shaped.Count}", laned, cache);
FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache, galaxyZone.Province?.Type ?? default);
pack.Planets.Add(cache.Upsert(lanedBody));
}

View file

@ -640,8 +640,8 @@ public sealed partial class MiningCut3Tests : IDisposable
for (var i = 0; i < 2000; i++)
{
var key = $"belt-{i}";
var picked = FieldKinds.Assign(key, s.Cache);
Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key));
var picked = FieldKinds.Assign(key, s.Cache, default);
Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache, default).Key));
counts[s.Cache.Get(picked).Name]++;
}
Assert.Equal(0, counts["Never"]);
@ -650,7 +650,7 @@ public sealed partial class MiningCut3Tests : IDisposable
Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f);
var empty = BuildScene(new[] { never });
Assert.False(FieldKinds.Assign("belt-0", empty.Cache).IsSet());
Assert.False(FieldKinds.Assign("belt-0", empty.Cache, default).IsSet());
}
// Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the
@ -665,7 +665,7 @@ public sealed partial class MiningCut3Tests : IDisposable
var beltKey = s.Belts[0];
var belt = (AsteroidBeltData) s.Zone.Planets[beltKey];
Assert.True(belt.Kind.IsSet());
Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache).Key));
Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache, default).Key));
var assigned = belt.Kind.Key;
// Saved: a fresh open of the same stores reads the kind back.
@ -679,7 +679,7 @@ public sealed partial class MiningCut3Tests : IDisposable
// Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it.
reopened.Upsert(Kind("Overwhelming", 9f, 1e6f));
Assert.False(FieldKinds.Assign(beltKey.Value, reopened).Key.Equals(assigned));
Assert.False(FieldKinds.Assign(beltKey.Value, reopened, default).Key.Equals(assigned));
var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { });
var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null);
Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned));