diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 6dcd61811..7c93ba979 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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) }; diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 26b08c22d..022c2fdc4 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -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 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(); + 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().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 Locations = new List(); } +// 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 Type; + public GalaxyZone[] Zones; +} + public class GalaxyQuest { public Story Story; diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 1758074fa..90ef03566 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -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 Provinces; +} + +[MessagePackObject] +public class SavedProvince +{ + [Key(0)] + public int Seed; + + [Key(1)] + public CultRecordRef 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, diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index b90b1252d..c92fdbf63 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -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] diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ca6764626..6ba9ef7d7 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -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: diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 2055b07c6..634b6f9cc 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -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 Yields = new List(); } -// 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 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 Kinds = new List(); + + // 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 Ores = new List(); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ProvinceKindWeight +{ + [JsonProperty("kind"), Key(0)] + public CultRecordRef Kind; + + [JsonProperty("weight"), Key(1)] + public float Weight = 1; +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ProvinceOre +{ + [JsonProperty("commodity"), Key(0)] + public CultRecordRef 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 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 Assign(string site, CultCache catalog, CultRecordRef province) + { + var type = catalog.Get(province); var kinds = catalog.GetAll() - .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(kind.Key); - pick -= kind.Weight; - } - return new CultRecordRef(kinds[kinds.Length - 1].Key); + return new CultRecordRef(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 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 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 Commodity, float Amount)[] Yield(CultRecordRef kind, + CultRecordRef province, CultCache catalog) + { + var data = catalog.Get(kind); + if (data == null) return Array.Empty<(CultRecordRef, 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 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 { 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[] Deal(int regionKey, int seedCount, uint runSeed, CultCache catalog) + { + var types = catalog.GetAll() + .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[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 allowed) + { + var reached = new bool[adjacency.Length]; + var stack = new Stack(); + 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(); + 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)] diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2a31df2b8..5b82a9a1a 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -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)); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 52c2fe53f..5ac322c01 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -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));