Merge ore geography: provinces inside boss-bounded regions, region decks deal province types, field kinds and yield read the province (aetheria-living-world). Unity batch compile clean on Starfire at the merge; Soul held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 17:11:49 +01:00
commit bef6a90f58
9 changed files with 1342 additions and 36 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

@ -81,8 +81,7 @@ public abstract class BodyData
[JsonProperty("mass"), Key(3)]
public float Mass = 0;
[CultReference(typeof(SimpleCommodityData), many: true), JsonProperty("resources"), Key(4)]
public Dictionary<CultRecordRef<SimpleCommodityData>, float> Resources = new Dictionary<CultRecordRef<SimpleCommodityData>, float>();
// MessagePack key 4 belonged to the removed stored Resources; do not reuse it. A belt's yield is derived by FieldKinds.Yield.
[JsonProperty("bodyRadiusMul")] [Key(5)]
public float BodyRadiusMultiplier = 1;
@ -114,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
{
@ -129,41 +129,283 @@ 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;
var factor = Factor(type?.Kinds.Where(entry => entry.Kind.Key.Equals(key)).Select(entry => (double) entry.Weight), 1.0);
// Both must be positive: two negatives do not multiply into a weight.
return (Key: key, Weight: kind.GenerationWeight > 0f && factor > 0.0 ? kind.GenerationWeight * factor : 0.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));
}
@ -522,12 +522,6 @@ public static class ZoneGenerator
public static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>
factions.Where(f => f != owner && f != nearest).ToArray();
// static float ResourceValue(ref Random random, ZoneGenerationSettings settings, SimpleCommodityData resource, float density)
// {
// return random.NextPowerDistribution(resource.Minimum, resource.Maximum, resource.Exponent,
// 1 / lerp(settings.ResourceDensityMinimum, settings.ResourceDensityMaximum, density));
// }
public static GeneratorPlanet[] GenerateEntities(ZoneGenerationSettings settings, PlanetSettings planetSettings, ref Random random, float mass, float radius, float2 fixedPosition,
float rootReach)
{

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));

View file

@ -0,0 +1,993 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
// Ore geography (aetheria-living-world, cut ore-geography): regions cut at the links out of boss zones, provinces grown inside
// them, province types dealt per region from a deck, and the kind draw and ore yields they weight. Galaxies are built the way
// the game builds them, from the authored settings and the shipped catalog.
public sealed class OreGeographyTests : IDisposable
{
private static readonly AuthoredSettings Authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot());
private static readonly CultCache Shared = RestoredHullsTests.OpenCatalog();
private static readonly Dictionary<(bool, uint), Galaxy> Built = new();
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-oregeography-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new();
private int _count;
public OreGeographyTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var cache in _caches) cache.Dispose();
Directory.Delete(_root, true);
}
// ---- fixtures
private (string Catalog, string Run) NewPaths()
{
var dir = Path.Combine(_root, $"cache{_count++}");
Directory.CreateDirectory(dir);
return (Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"));
}
// An empty writable catalog.
private CultCache Empty()
{
var (catalog, run) = NewPaths();
var cache = AetheriaStores.Open(catalog, run, catalogWritable: true);
_caches.Add(cache);
return cache;
}
private static CultCache OpenCopy(string catalog, string run)
{
var cache = new CultCache(TestCatalog.Registry());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, false), AetheriaStores.CatalogTypes);
cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes);
return cache;
}
// A writable copy of the shipped catalog and a scratch run store.
private (CultCache Cache, string Catalog, string Run) Shipped()
{
var (catalog, run) = NewPaths();
File.Copy(TestCatalog.Repo, catalog);
var cache = OpenCopy(catalog, run);
_caches.Add(cache);
return (cache, catalog, run);
}
private static CultRecordRef<T> Put<T>(CultCache cache, string key, T document) where T : class
{
cache.Commit(batch => batch.Upsert(typeof(T), document, new CultRecordKey(key)));
return new CultRecordRef<T>(new CultRecordKey(key));
}
private static CultRecordRef<FieldKindData> Kind(CultCache cache, string name, float weight) =>
Put(cache, "field-kind:" + name, new FieldKindData { Name = name, CrossSection = 1f, GenerationWeight = weight });
private static CultRecordRef<ProvinceData> Province(CultCache cache, string name, int cards,
ProvinceKindWeight[] kinds = null, ProvinceOre[] ores = null) =>
Put(cache, "province:" + name, new ProvinceData
{
Name = name, GenerationWeight = cards,
Kinds = kinds?.ToList() ?? new List<ProvinceKindWeight>(), Ores = ores?.ToList() ?? new List<ProvinceOre>()
});
private static ProvinceKindWeight KindFactor(CultRecordRef<FieldKindData> kind, float weight) => new ProvinceKindWeight {Kind = kind, Weight = weight};
private static ProvinceOre OreFactor(CultRecordRef<SimpleCommodityData> ore, float weight) => new ProvinceOre {Commodity = ore, Weight = weight};
private static CultRecordRef<SimpleCommodityData> Ore(string name) => new CultRecordRef<SimpleCommodityData>(new CultRecordKey("ore:" + name));
private static FieldYield Yield(string ore, float abundance, bool regional = false) => new FieldYield {Commodity = Ore(ore), Abundance = abundance, Regional = regional};
private static int[][] Graph(int count, params (int, int)[] links)
{
var adjacency = Enumerable.Range(0, count).Select(_ => new List<int>()).ToArray();
foreach (var (a, b) in links)
{
adjacency[a].Add(b);
adjacency[b].Add(a);
}
return adjacency.Select(list => list.OrderBy(i => i).ToArray()).ToArray();
}
private static int[][] Line(int count) => Graph(count, Enumerable.Range(0, count - 1).Select(i => (i, i + 1)).ToArray());
private static int[][] AdjacencyOf(Galaxy galaxy) =>
galaxy.Zones.Select(zone => zone.AdjacentZones.Select(next => Array.IndexOf(galaxy.Zones, next)).ToArray()).ToArray();
private static int[] BossesOf(Galaxy galaxy) => galaxy.BossZones.Values.Select(zone => Array.IndexOf(galaxy.Zones, zone)).ToArray();
private static int ExitOf(Galaxy galaxy) => galaxy.Exit == null ? -1 : Array.IndexOf(galaxy.Zones, galaxy.Exit);
private static int[] RegionsOf(Galaxy galaxy) => Provinces.Regions(AdjacencyOf(galaxy), BossesOf(galaxy), ExitOf(galaxy));
private static bool[] ReachableAvoiding(int[][] adjacency, int from, int avoid)
{
var reached = new bool[adjacency.Length];
var stack = new Stack<int>();
reached[from] = true;
stack.Push(from);
while (stack.Count > 0)
foreach (var next in adjacency[stack.Pop()])
if (next != avoid && !reached[next])
{
reached[next] = true;
stack.Push(next);
}
return reached;
}
private static Galaxy Build(CultCache cache, bool prelude, uint seed, TutorialGenerationSettings cast = null) => prelude
? new Galaxy(cast ?? Authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings"), Authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed)
: new Galaxy(Authored.Read<SectorGenerationSettings>("SectorGenerationSettings"), Authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
cache, _ => { }, null, seed);
// Galaxy refuses some seeds (RestoredHullsTests calls it seed-dependent flakiness); those are skipped.
private static Galaxy TryBuild(CultCache cache, bool prelude, uint seed, TutorialGenerationSettings cast = null)
{
try { return Build(cache, prelude, seed, cast); }
catch (Exception) { return null; }
}
private static Galaxy SharedGalaxy(bool prelude, uint seed)
{
lock (Built)
return Built.TryGetValue((prelude, seed), out var known) ? known : Built[(prelude, seed)] = TryBuild(Shared, prelude, seed);
}
private static IEnumerable<Galaxy> Galaxies(bool prelude, uint last) =>
Enumerable.Range(1, (int) last).Select(seed => SharedGalaxy(prelude, (uint) seed)).Where(galaxy => galaxy != null);
// The demo's cast: the same fixed cast, with the province size chosen.
private static TutorialGenerationSettings Cast(int provinceZones)
{
var cast = DemoTerminus.Cast;
return new TutorialGenerationSettings
{
ProtagonistFaction = cast.ProtagonistFaction, AntagonistFaction = cast.AntagonistFaction, BufferFaction = cast.BufferFaction,
NeutralFactions = cast.NeutralFactions, QuestFaction = cast.QuestFaction, ZoneCount = cast.ZoneCount, LinkDensity = cast.LinkDensity,
ProvinceZones = provinceZones
};
}
private static Galaxy DemoGalaxy(CultCache cache, uint seed, int provinceZones = Provinces.DefaultZonesPerProvince)
{
var galaxy = TryBuild(cache, true, seed, Cast(provinceZones));
galaxy?.PlaceGate(galaxy.ResolveFaction(DemoTerminus.Cast.AntagonistFaction));
return galaxy;
}
private static bool ExitIsACutVertex(Galaxy galaxy)
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var start = Enumerable.Range(0, adjacency.Length).First(zone => zone != exit);
return ReachableAvoiding(adjacency, start, exit).Count(reached => reached) < adjacency.Length - 1;
}
private static string[] Shape(Galaxy galaxy) => galaxy.Provinces
.Select(province => $"{Array.IndexOf(galaxy.Zones, province.Seed)}:{province.Type.Key.Value}:" +
string.Join(",", province.Zones.Select(zone => Array.IndexOf(galaxy.Zones, zone)).OrderBy(i => i)))
.ToArray();
// The live run as plain documents, as the game writes it, then Continue: Capture, Commit, and a Galaxy from the committed save.
private (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy)
{
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
var zone = new Zone(items, RestoredHullsTests.AuthoredPlanetSettings(), new ZonePack(), galaxy.Entrance, galaxy);
return RunSave.Capture(cache, galaxy, zone, null, new SavedActionBarBinding[0], 0);
}
private static Galaxy Continue(CultCache cache, SavedGame game, SavedZone[] zones)
{
RunSave.Commit(cache, game, zones, new ProvenanceLedger());
return new Galaxy(cache, cache.GetGlobal<SavedGame>(), _ => { });
}
// A sector galaxy built from the authored sector settings with the province size chosen.
private static Galaxy SectorGalaxy(CultCache cache, uint seed, int provinceZones)
{
var authored = Authored.Read<SectorGenerationSettings>("SectorGenerationSettings");
try
{
return new Galaxy(new SectorGenerationSettings
{
LinkDensity = authored.LinkDensity, ZoneCount = authored.ZoneCount, MegaCount = authored.MegaCount,
BossCount = authored.BossCount, ProvinceZones = provinceZones
},
Authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"), cache, _ => { }, null, seed);
}
catch (Exception) { return null; }
}
// A sector galaxy whose boss zones cut it into several regions, with the province types dealt to them.
private static Galaxy MultiRegionSector(CultCache cache, int provinceZones = Provinces.DefaultZonesPerProvince) =>
Enumerable.Range(1, 40).Select(seed => SectorGalaxy(cache, (uint) seed, provinceZones))
.First(galaxy => galaxy != null && RegionsOf(galaxy).Distinct().Count() > 1);
// ---- the kind draw and the yields
// The pick as it stood before ore geography, spelled out here: one weighted draw by a stable hash of the site.
private static string BasePick(string site, CultCache catalog)
{
var kinds = catalog.GetAll<FieldKindData>()
.Where(kind => kind.GenerationWeight > 0f)
.Select(kind => (Key: catalog.RefOf(kind).Key, Weight: kind.GenerationWeight))
.OrderBy(kind => kind.Key.Value, StringComparer.Ordinal)
.ToArray();
if (kinds.Length == 0) return null;
var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight);
foreach (var kind in kinds)
{
if (pick < kind.Weight) return kind.Key.Value;
pick -= kind.Weight;
}
return kinds[kinds.Length - 1].Key.Value;
}
[Fact]
public void NoProvinceTypeIsTodaysPick()
{
var cache = Empty();
Kind(cache, "a", 1f);
Kind(cache, "b", 2f);
Kind(cache, "never", 0f);
Kind(cache, "c", 3.5f);
var dangling = new CultRecordRef<ProvinceData>(new CultRecordKey("province:missing"));
var seen = new HashSet<string>();
for (var i = 0; i < 2000; i++)
{
var site = $"belt-{i}";
var expected = BasePick(site, cache);
seen.Add(expected);
Assert.Equal(expected, FieldKinds.Assign(site, cache, default).Key.Value);
Assert.Equal(expected, FieldKinds.Assign(site, cache, dangling).Key.Value);
}
Assert.Equal(3, seen.Count);
Assert.False(FieldKinds.Assign("belt-0", Empty(), default).IsSet());
}
[Fact]
public void AKindWhoseCombinedWeightIsNotPositiveIsNeverDrawn()
{
var cache = Empty();
var negative = Kind(cache, "negative", -1f);
Kind(cache, "positive", 1f);
var flipped = Province(cache, "flipped", 1, new[] {KindFactor(negative, -1f)});
for (var i = 0; i < 500; i++)
{
Assert.Equal("field-kind:positive", FieldKinds.Assign($"belt-{i}", cache, default).Key.Value);
Assert.Equal("field-kind:positive", FieldKinds.Assign($"belt-{i}", cache, flipped).Key.Value);
}
// With nothing positive left the belt has no kind, rather than the negative one.
var only = Empty();
var lone = Kind(only, "negative", -1f);
Assert.False(FieldKinds.Assign("belt-0", only, Province(only, "flipped", 1, new[] {KindFactor(lone, -1f)})).IsSet());
}
[Fact]
public void ProvinceWeightsTheKindDraw()
{
var cache = Empty();
var light = Kind(cache, "light", 1f);
var middle = Kind(cache, "middle", 2f);
Kind(cache, "heavy", 3f);
Kind(cache, "never", 0f);
var province = Province(cache, "p", 1, new[] {KindFactor(light, 0f), KindFactor(middle, 3f)});
var counts = new Dictionary<string, int>();
const int sites = 3000;
for (var i = 0; i < sites; i++)
{
var picked = FieldKinds.Assign($"belt-{i}", cache, province).Key.Value;
counts[picked] = counts.GetValueOrDefault(picked) + 1;
}
// light 1 x 0 = 0, middle 2 x 3 = 6, heavy 3 x 1 (unlisted) = 3
Assert.Equal(0, counts.GetValueOrDefault("field-kind:light"));
Assert.Equal(0, counts.GetValueOrDefault("field-kind:never"));
Assert.InRange(counts["field-kind:middle"] / (float) sites, 2f / 3 - .04f, 2f / 3 + .04f);
Assert.InRange(counts["field-kind:heavy"] / (float) sites, 1f / 3 - .04f, 1f / 3 + .04f);
// A province that zeroes every kind leaves the belt without one rather than ignoring it.
var barren = Province(cache, "barren", 1, new[] {KindFactor(light, 0f), KindFactor(middle, 0f), KindFactor(new CultRecordRef<FieldKindData>(new CultRecordKey("field-kind:heavy")), 0f)});
Assert.False(FieldKinds.Assign("belt-0", cache, barren).IsSet());
}
[Fact]
public void YieldScalesAbundanceByProvince()
{
var cache = Empty();
var kind = Put(cache, "field-kind:rock", new FieldKindData
{
Name = "rock",
Yields = new List<FieldYield> {Yield("iron", .5f), Yield("copper", 1f), Yield("copper", 1f), Yield("nothing", 0f), Yield("tin", 1f)}
});
var province = Province(cache, "p", 1, ores: new[] {OreFactor(Ore("iron"), 4f), OreFactor(Ore("tin"), 0f)});
var here = FieldKinds.Yield(kind, province, cache);
// copper's two entries are summed; iron is .5 x 4; tin is scaled to nothing; the zero-abundance ore is dropped; commodity key order
Assert.Equal(new[] {("ore:copper", 2f), ("ore:iron", 2f)}, here.Select(y => (y.Commodity.Key.Value, y.Amount)).ToArray());
var nowhere = FieldKinds.Yield(kind, default, cache);
Assert.Equal(new[] {("ore:copper", 2f), ("ore:iron", .5f), ("ore:tin", 1f)}, nowhere.Select(y => (y.Commodity.Key.Value, y.Amount)).ToArray());
Assert.Empty(FieldKinds.Yield(default, province, cache));
Assert.Empty(FieldKinds.Yield(new CultRecordRef<FieldKindData>(new CultRecordKey("field-kind:missing")), province, cache));
}
[Fact]
public void ARegionalOreYieldsOnlyWhereItsProvinceListsIt()
{
var cache = Empty();
var kind = Put(cache, "field-kind:rock", new FieldKindData
{
Name = "rock",
Yields = new List<FieldYield> {Yield("stone", 2f), Yield("iridium", 3f, regional: true)}
});
var lists = Province(cache, "lists", 1, ores: new[] {OreFactor(Ore("iridium"), 1f)});
var silent = Province(cache, "silent", 1);
string[] Held(CultRecordRef<ProvinceData> province) => FieldKinds.Yield(kind, province, cache).Select(y => y.Commodity.Key.Value).ToArray();
Assert.Equal(new[] {"ore:iridium", "ore:stone"}, Held(lists));
Assert.Equal(new[] {"ore:stone"}, Held(silent));
Assert.Equal(new[] {"ore:stone"}, Held(default));
Assert.Equal(3f, FieldKinds.Yield(kind, lists, cache).Single(y => y.Commodity.Key.Value == "ore:iridium").Amount);
}
// ---- regions
[Fact]
public void ARegionIsCutAtTheLinksOutOfABossZone()
{
// Two boss cut vertices on a path give three regions; each boss zone stays with the zone before it.
Assert.Equal(new[] {0, 0, 0, 3, 3, 5, 5}, Provinces.Regions(Line(7), new[] {2, 4}, 6));
// A boss zone with two links into the far side has both cut, and the far neighbours are one region.
var diamond = Graph(7, (0, 1), (1, 2), (2, 3), (2, 4), (3, 5), (4, 5), (5, 6));
Assert.Equal(new[] {0, 0, 0, 3, 3, 3, 3}, Provinces.Regions(diamond, new[] {2}, 6));
// A branch hanging off a boss zone is not on the exit side, so it stays near.
var pendant = Graph(6, (0, 1), (1, 2), (2, 3), (3, 4), (2, 5));
Assert.Equal(new[] {0, 0, 0, 3, 3, 0}, Provinces.Regions(pendant, new[] {2}, 4));
// Two adjacent boss zones: the link between them is the first one's crossing link, and the second is alone on its near side.
Assert.Equal(new[] {0, 0, 2, 3, 3}, Provinces.Regions(Line(5), new[] {1, 2}, 4));
// The exit may be zone 0.
Assert.Equal(new[] {0, 0, 2, 2, 2}, Provinces.Regions(Line(5), new[] {2}, 0));
// No boss zone, no exit, or a boss zone that is the exit: nothing is cut.
Assert.All(Provinces.Regions(Line(7), new int[0], 6), region => Assert.Equal(0, region));
Assert.All(Provinces.Regions(Line(7), new[] {2, 4}, -1), region => Assert.Equal(0, region));
Assert.All(Provinces.Regions(Line(7), new[] {6}, 6), region => Assert.Equal(0, region));
// Every zone is in exactly one region, named by its least zone.
var regions = Provinces.Regions(diamond, new[] {2}, 6);
for (var zone = 0; zone < regions.Length; zone++)
{
Assert.True(regions[zone] <= zone);
Assert.Equal(regions[zone], regions[regions[zone]]);
}
}
[Fact]
public void EveryBossZoneIsInTheRegionOfItsEntranceSide()
{
var checkedBosses = 0;
foreach (var galaxy in Galaxies(false, 8))
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var regionOf = RegionsOf(galaxy);
var bosses = BossesOf(galaxy).Distinct().Where(boss => boss != exit).ToArray();
foreach (var boss in bosses)
{
var exitSide = ReachableAvoiding(adjacency, exit, boss);
foreach (var next in adjacency[boss])
{
if (exitSide[next]) Assert.NotEqual(regionOf[boss], regionOf[next]);
else Assert.Equal(regionOf[boss], regionOf[next]);
}
checkedBosses++;
}
Assert.Equal(bosses.Length + 1, regionOf.Distinct().Count());
}
Assert.True(checkedBosses >= 4, $"only {checkedBosses} boss zones were exercised");
}
// ---- provinces
[Fact]
public void PartitionIsContiguousCompleteAndInsideRegions()
{
var galaxies = Galaxies(false, 6).Concat(Galaxies(true, 6)).ToArray();
Assert.True(galaxies.Length >= 6);
foreach (var galaxy in galaxies)
{
var regionOf = RegionsOf(galaxy);
Assert.All(galaxy.Zones, zone => Assert.NotNull(zone.Province));
var members = galaxy.Provinces.SelectMany(province => province.Zones).ToArray();
Assert.Equal(galaxy.Zones.Length, members.Length);
Assert.Equal(galaxy.Zones.Length, members.Distinct().Count());
Assert.Equal(galaxy.Provinces.Select(province => Array.IndexOf(galaxy.Zones, province.Seed)).OrderBy(i => i), galaxy.Provinces.Select(province => Array.IndexOf(galaxy.Zones, province.Seed)));
foreach (var province in galaxy.Provinces)
{
Assert.Contains(province.Seed, province.Zones);
Assert.All(province.Zones, zone => Assert.Same(province, zone.Province));
var indices = province.Zones.Select(zone => Array.IndexOf(galaxy.Zones, zone)).ToArray();
Assert.Single(indices.Select(zone => regionOf[zone]).Distinct());
var reached = new HashSet<GalaxyZone> {province.Seed};
var stack = new Stack<GalaxyZone>(reached);
while (stack.Count > 0)
foreach (var next in stack.Pop().AdjacentZones.Where(next => province.Zones.Contains(next) && reached.Add(next)))
stack.Push(next);
Assert.Equal(province.Zones.Length, reached.Count);
}
}
}
[Fact]
public void PartitionCountFollowsProvinceZones()
{
int Count(int[][] graph, int[] bosses, int exit, int perProvince, out int[] seedOf)
{
var regionOf = Provinces.Regions(graph, bosses, exit);
seedOf = Provinces.Partition(graph, regionOf, 7u, perProvince);
return seedOf.Distinct().Count();
}
Assert.Equal(3, Count(Line(20), new int[0], -1, 8, out _)); // 2.5 rounds up
Assert.Equal(2, Count(Line(12), new int[0], -1, 8, out _)); // 1.5 rounds up
Assert.Equal(1, Count(Line(5), new int[0], -1, 8, out _)); // a region smaller than ProvinceZones is one province
Assert.Equal(5, Count(Line(20), new int[0], -1, 4, out _));
Assert.Equal(1, Count(Line(20), new int[0], -1, 100, out _));
// Per region: a boss zone at 11 on a path of 30 leaves regions of 12 and 18 zones.
var path = Line(30);
var count = Count(path, new[] {11}, 29, 6, out var seedOf);
Assert.Equal(2 + 3, count);
Assert.Equal(2, seedOf.Take(12).Distinct().Count());
Assert.Equal(3, seedOf.Skip(12).Distinct().Count());
Assert.All(seedOf.Distinct(), seed => Assert.Equal(seed, seedOf[seed]));
}
[Fact]
public void PartitionIsDeterministic()
{
var links = new List<(int, int)>();
for (var i = 0; i < 36; i++)
{
if (i % 6 != 5) links.Add((i, i + 1));
if (i + 6 < 36) links.Add((i, i + 6));
}
var grid = Graph(36, links.ToArray());
var regionOf = Provinces.Regions(grid, new int[0], -1);
var first = Provinces.Partition(grid, regionOf, 1u, 8);
Assert.Equal(first, Provinces.Partition(grid, regionOf, 1u, 8));
Assert.Equal(first.Distinct().Count(), 5); // 36 / 8 = 4.5 rounds up
var differing = Enumerable.Range(2, 30).Count(seed => !Provinces.Partition(grid, regionOf, (uint) seed, 8).SequenceEqual(first));
Assert.True(differing > 20, $"only {differing} of 30 other run seeds moved the seeds");
}
[Fact]
public void TheDemoGalaxyIsOneRegionOfSeveralProvinces()
{
var cutVertexExits = 0;
var checkedGalaxies = 0;
for (uint seed = 1; seed <= 24; seed++)
{
var galaxy = TryBuild(Shared, true, seed, Cast(Provinces.DefaultZonesPerProvince));
if (galaxy == null) continue;
checkedGalaxies++;
Assert.All(RegionsOf(galaxy), region => Assert.Equal(0, region));
Assert.Equal(8, galaxy.Provinces.Length); // 64 zones / 8 per province
var before = Shape(galaxy);
galaxy.PlaceGate(galaxy.ResolveFaction(DemoTerminus.Cast.AntagonistFaction));
if (ExitIsACutVertex(galaxy)) cutVertexExits++;
// The boss zone is the exit: re-deriving from the saved graph, boss zones and Exit still finds one region, and PlaceGate moved nothing.
Assert.All(RegionsOf(galaxy), region => Assert.Equal(0, region));
Assert.Equal(before, Shape(galaxy));
}
Assert.True(checkedGalaxies >= 10);
Assert.True(cutVertexExits >= 3, $"only {cutVertexExits} demo galaxies had a cut-vertex Exit, so the case that splits a naive cut went unexercised");
}
[Fact]
public void ProvincesTakeNothingFromTheGalaxyStream()
{
string[] Describe(Galaxy g) => g.Zones.Select((zone, i) =>
$"{zone.Name}|{zone.Position.x:R},{zone.Position.y:R}|{string.Join(",", zone.AdjacentZones.Select(next => Array.IndexOf(g.Zones, next)))}|{zone.Owner?.Name}|{string.Join(",", zone.Factions.Select(f => f.Name))}")
.Concat(new[]
{
$"entrance {Array.IndexOf(g.Zones, g.Entrance)}", $"exit {ExitOf(g)}",
"bosses " + string.Join(",", g.BossZones.Select(b => $"{b.Key.Name}={Array.IndexOf(g.Zones, b.Value)}")),
"homes " + string.Join(",", g.HomeZones.Select(b => $"{b.Key.Name}={Array.IndexOf(g.Zones, b.Value)}"))
}).ToArray();
var (cache, _, _) = Shipped();
Province(cache, "one", 2);
Province(cache, "two", 1);
var compared = 0;
foreach (var prelude in new[] {false, true})
for (uint seed = 1; seed <= 4; seed++)
{
var without = SharedGalaxy(prelude, seed);
var with = TryBuild(cache, prelude, seed);
Assert.Equal(without == null, with == null);
if (without == null) continue;
Assert.Equal(Describe(without), Describe(with));
Assert.All(without.Provinces, province => Assert.False(province.Type.IsSet()));
Assert.All(with.Provinces, province => Assert.True(province.Type.IsSet()));
compared++;
}
Assert.True(compared >= 4);
}
[Fact]
public void PartitionSeedsAreTheLeastHashThenTheFarthestAndTiesGoToTheEarlierSeed()
{
var line = Line(9);
var regionOf = Provinces.Regions(line, new int[0], -1);
var zones = Enumerable.Range(0, 9).ToArray();
var tied = 0;
for (uint seed = 1; seed <= 60; seed++)
{
uint Hash(int zone) => $"{seed}:province-seed:{zone}".StableHash();
var first = zones.OrderBy(Hash).ThenBy(zone => zone).First();
var reach = zones.Max(zone => Math.Abs(zone - first));
var second = zones.Where(zone => Math.Abs(zone - first) == reach).OrderBy(Hash).First();
if (zones.Any(zone => Math.Abs(zone - first) == Math.Abs(zone - second))) tied++;
// 9 zones at 5 per province is two seeds; a zone midway between them goes to the first.
var expected = zones.Select(zone => Math.Abs(zone - first) <= Math.Abs(zone - second) ? first : second).ToArray();
Assert.Equal(expected, Provinces.Partition(line, regionOf, seed, 5));
// One province: the seed is the least hash.
Assert.All(Provinces.Partition(line, regionOf, seed, 100), seedOf => Assert.Equal(first, seedOf));
}
Assert.True(tied > 5, "fixture: too few run seeds put a zone midway between the seeds");
}
[Fact]
public void ListingAnEntryTwiceSumsItsWeights()
{
var cache = Empty();
var a = Kind(cache, "a", 1f);
Kind(cache, "b", 1f);
var twice = Province(cache, "twice", 1, new[] {KindFactor(a, 2f), KindFactor(a, 2f)}, new[] {OreFactor(Ore("iron"), 2f), OreFactor(Ore("iron"), 3f)});
var rock = Put(cache, "field-kind:rock", new FieldKindData {Name = "rock", GenerationWeight = 0f, Yields = new List<FieldYield> {Yield("iron", 1f)}});
Assert.Equal(5f, FieldKinds.Yield(rock, twice, cache).Single().Amount);
const int sites = 3000;
var share = Enumerable.Range(0, sites).Count(i => FieldKinds.Assign($"belt-{i}", cache, twice).Key.Value == "field-kind:a") / (float) sites;
Assert.InRange(share, 4f / 5 - .04f, 4f / 5 + .04f); // a: 1 x (2 + 2) against b: 1
Assert.InRange(Enumerable.Range(0, sites).Count(i => FieldKinds.Assign($"belt-{i}", cache, default).Key.Value == "field-kind:a") / (float) sites, .46f, .54f);
}
// ---- the deal
private static int[] Counts(CultRecordRef<ProvinceData>[] hand, params string[] types) =>
types.Select(type => hand.Count(card => card.Key.Value == "province:" + type)).ToArray();
[Fact]
public void ARegionWithAsManyProvincesAsCardsHoldsEveryType()
{
var cache = Empty();
Province(cache, "a", 3);
Province(cache, "b", 1);
Province(cache, "c", 2);
Province(cache, "zero", 0);
for (uint seed = 1; seed <= 40; seed++)
{
Assert.Equal(new[] {3, 1, 2}, Counts(Provinces.Deal(5, 6, seed, cache), "a", "b", "c"));
Assert.Equal(new[] {6, 2, 4}, Counts(Provinces.Deal(5, 12, seed, cache), "a", "b", "c"));
var more = Provinces.Deal(5, 8, seed, cache);
Assert.All(new[] {"a", "b", "c"}, type => Assert.Contains(more, card => card.Key.Value == "province:" + type));
Assert.DoesNotContain(more, card => card.Key.Value == "province:zero");
}
}
[Fact]
public void TheSameRunSeedDealsTheSameHand()
{
var cache = Empty();
Province(cache, "a", 1);
Province(cache, "b", 1);
Province(cache, "never", 0);
string[] Hand(int region, uint seed) => Provinces.Deal(region, 6, seed, cache).Select(card => card.Key.Value).ToArray();
for (uint seed = 1; seed <= 20; seed++)
{
Assert.Equal(Hand(3, seed), Hand(3, seed));
Assert.DoesNotContain("province:never", Hand(3, seed));
}
Assert.Contains(Enumerable.Range(2, 30), seed => !Hand(3, (uint) seed).SequenceEqual(Hand(3, 1u)));
// No card to deal: every province is unset, whether the catalog holds no type or only types of weight zero.
Assert.All(Provinces.Deal(0, 4, 1u, Empty()), card => Assert.False(card.IsSet()));
var zeroes = Empty();
Province(zeroes, "never", 0);
var hand = Provinces.Deal(0, 4, 1u, zeroes);
Assert.Equal(4, hand.Length);
Assert.All(hand, card => Assert.False(card.IsSet()));
}
[Fact]
public void CardCountNotProbabilityIsTheWeight()
{
var cache = Empty();
Province(cache, "common", 3);
Province(cache, "rare", 1);
for (uint seed = 1; seed <= 200; seed++)
{
Assert.Equal(new[] {3, 1}, Counts(Provinces.Deal(0, 4, seed, cache), "common", "rare"));
Assert.Equal(new[] {6, 2}, Counts(Provinces.Deal(0, 8, seed, cache), "common", "rare"));
}
}
[Fact]
public void ARegionsHandDoesNotDependOnOtherRegions()
{
var (cache, _, _) = Shipped();
Province(cache, "a", 1);
Province(cache, "b", 1);
Province(cache, "c", 1);
var hands = new List<string>();
var regionsChecked = 0;
foreach (var galaxy in new[] {false, true}.SelectMany(prelude => Enumerable.Range(1, 5).Select(seed => TryBuild(cache, prelude, (uint) seed))).Where(g => g != null))
{
var regionOf = RegionsOf(galaxy);
foreach (var region in Enumerable.Range(0, galaxy.Zones.Length).GroupBy(zone => regionOf[zone]))
{
var provinces = galaxy.Provinces.Where(province => regionOf[Array.IndexOf(galaxy.Zones, province.Seed)] == region.Key).ToArray();
var expected = Provinces.Deal(region.Key, provinces.Length, galaxy.Seed, cache);
// Keyed by the region's least zone: nothing outside the region and the run seed enters the hand.
Assert.Equal(expected.Select(card => card.Key.Value), provinces.Select(province => province.Type.Key.Value));
if (provinces.Length >= 3) hands.Add(string.Join(",", expected.Select(card => card.Key.Value)));
regionsChecked++;
}
}
Assert.True(regionsChecked >= 10);
Assert.True(hands.Distinct().Count() > 1, "every region of three or more provinces was dealt the same hand");
}
[Fact]
public void TheDealDrawsFromTheDeckInKeyOrderAndReshufflesWhenItRunsOut()
{
var cache = Empty();
Province(cache, "c", 3);
Province(cache, "a", 2);
Province(cache, "b", 1);
for (uint seed = 1; seed <= 30; seed++)
{
// The rule restated: cards per type in key order, one weighted pick per draw at the draw's own site.
var types = new[] {"province:a", "province:b", "province:c"};
var full = new[] {2, 1, 3};
var remaining = (int[]) full.Clone();
var expected = new List<string>();
for (var n = 0; n < 10; n++)
{
if (remaining.Sum() == 0) remaining = (int[]) full.Clone();
var pick = $"{seed}:province-deal:7:{n}".StableHash() / 4294967296.0 * remaining.Sum();
var card = 0;
while (pick >= remaining[card]) pick -= remaining[card++];
remaining[card]--;
expected.Add(types[card]);
}
Assert.Equal(expected, Provinces.Deal(7, 10, seed, cache).Select(card => card.Key.Value));
}
}
// ---- belts
private static IEnumerable<(AsteroidBeltData Belt, GalaxyZone Zone, ZonePack Pack)> Belts(CultCache cache, Galaxy galaxy, ItemManager items, int zones)
{
foreach (var zone in galaxy.Zones.Take(zones))
{
var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance);
foreach (var belt in pack.Planets.Select(body => cache.Get(body)).OfType<AsteroidBeltData>())
yield return (belt, zone, pack);
}
}
private ItemManager Items(CultCache cache) => new ItemManager(1u, cache, new ProvenanceLedger(), Authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
private static void SilenceShippedKinds(CultCache cache)
{
foreach (var existing in cache.GetAll<FieldKindData>().ToArray())
{
existing.GenerationWeight = 0f;
cache.Upsert(existing);
}
}
[Fact]
public void GeneratedBeltsDrawByTheirZonesProvince()
{
var firstOnly = 0;
var secondOnly = 0;
for (uint seed = 1; seed <= 25 && (firstOnly < 3 || secondOnly < 3); seed++)
{
var (cache, _, _) = Shipped();
SilenceShippedKinds(cache);
var a = Kind(cache, "a", 1f);
var b = Kind(cache, "b", 1f);
Province(cache, "no-a", 1, new[] {KindFactor(a, 0f)});
Province(cache, "no-b", 1, new[] {KindFactor(b, 0f)});
var galaxy = TryBuild(cache, true, seed, Cast(Provinces.DefaultZonesPerProvince));
if (galaxy == null) continue;
foreach (var (belt, zone, _) in Belts(cache, galaxy, Items(cache), 24))
{
var kind = belt.Kind.Key.Value;
if (zone.Province.Type.Key.Value == "province:no-a")
{
Assert.Equal("field-kind:b", kind);
firstOnly++;
}
else
{
Assert.Equal("field-kind:a", kind);
secondOnly++;
}
}
}
Assert.True(firstOnly >= 3 && secondOnly >= 3, $"belts exercised: {firstOnly} and {secondOnly}");
}
[Fact]
public void FirstLoadAssignsByTheZonesProvince()
{
for (uint seed = 1; seed <= 25; seed++)
{
var (cache, catalogPath, runPath) = Shipped();
SilenceShippedKinds(cache);
var galaxy = TryBuild(cache, true, seed, Cast(Provinces.DefaultZonesPerProvince));
if (galaxy == null) continue;
var items = Items(cache);
// Generated while no kind could be picked: every belt is kindless, as one saved before field kinds existed.
var generated = Belts(cache, galaxy, items, 16).ToArray();
if (generated.Length < 4) continue;
Assert.All(generated, g => Assert.False(g.Belt.Kind.IsSet()));
var a = Kind(cache, "a", 1f);
Kind(cache, "b", 1f);
var noA = Province(cache, "no-a", 1, new[] {KindFactor(a, 0f)});
var packs = generated.GroupBy(g => g.Pack).ToArray();
var inProvince = packs[0].First().Zone;
inProvince.Province = new GalaxyProvince {Seed = inProvince, Type = noA, Zones = new[] {inProvince}};
var settings = RestoredHullsTests.AuthoredPlanetSettings();
var loaded = new Zone(items, settings, packs[0].Key, inProvince, galaxy);
foreach (var key in packs[0].Select(g => cache.RefOf(g.Belt).Key))
Assert.Equal(FieldKinds.Assign(key.Value, cache, noA).Key.Value, ((AsteroidBeltData) loaded.Planets[key]).Kind.Key.Value);
Assert.All(packs[0].Select(g => cache.RefOf(g.Belt).Key), key => Assert.Equal("field-kind:b", ((AsteroidBeltData) loaded.Planets[key]).Kind.Key.Value));
// No GalaxyZone: the unweighted pick.
var unweighted = 0;
foreach (var pack in packs.Skip(1))
{
var bare = new Zone(items, settings, pack.Key, null, null);
foreach (var key in pack.Select(g => cache.RefOf(g.Belt).Key))
{
var kind = ((AsteroidBeltData) bare.Planets[key]).Kind.Key.Value;
Assert.Equal(FieldKinds.Assign(key.Value, cache, default).Key.Value, kind);
if (kind == "field-kind:a") unweighted++;
}
}
Assert.True(unweighted > 0, "fixture: no unweighted belt drew the kind the province forbids, so the province could not be told from none");
// Kept: a kind that would now win every pick changes nothing about a belt that has one.
var keys = packs[0].Select(g => cache.RefOf(g.Belt).Key).ToArray();
var assigned = keys.ToDictionary(key => key.Value, key => ((AsteroidBeltData) loaded.Planets[key]).Kind.Key.Value);
Kind(cache, "z", 1e6f);
var again = new Zone(items, settings, packs[0].Key, null, null);
foreach (var key in keys) Assert.Equal(assigned[key.Value], ((AsteroidBeltData) again.Planets[key]).Kind.Key.Value);
// Saved: a fresh open of the stores reads the assignment back.
cache.FlushAsync().Wait();
cache.Dispose();
_caches.Remove(cache);
var reopened = OpenCopy(catalogPath, runPath);
_caches.Add(reopened);
foreach (var key in keys) Assert.Equal(assigned[key.Value], ((AsteroidBeltData) reopened.Get(key)).Kind.Key.Value);
return;
}
Assert.Fail("no seed generated enough belts; the first-load path went unexercised");
}
// ---- save and continue
[Fact]
public void ProvincesSurviveSaveAndContinue()
{
var (cache, catalog, run) = Shipped();
Province(cache, "a", 1);
Province(cache, "b", 1);
Province(cache, "c", 1);
// Four zones to a province, so a galaxy's saved provinces differ from what the save constructor's default would derive.
var galaxy = Enumerable.Range(1, 25).Select(seed => DemoGalaxy(cache, (uint) seed, 4)).First(g => g != null);
Assert.Equal(16, galaxy.Provinces.Length);
var (game, zones) = Capture(cache, galaxy);
Assert.Equal(galaxy.Provinces.Length, game.Provinces.Count);
RunSave.Commit(cache, game, zones, new ProvenanceLedger());
cache.FlushAsync().Wait();
cache.Dispose();
_caches.Remove(cache);
// Reopened from disk with a type of overwhelming weight added: a saved run keeps what it was dealt.
var reopened = OpenCopy(catalog, run);
_caches.Add(reopened);
Put(reopened, "province:huge", new ProvinceData {Name = "huge", GenerationWeight = 1000000});
var restored = new Galaxy(reopened, reopened.GetGlobal<SavedGame>(), _ => { });
Assert.Equal(Shape(galaxy), Shape(restored));
Assert.DoesNotContain(restored.Provinces, province => province.Type.Key.Value == "province:huge");
}
[Fact]
public void AnOlderSaveGetsProvincesByTheSameRule()
{
var (cache, _, _) = Shipped();
var a = Province(cache, "a", 1);
Province(cache, "b", 1);
Province(cache, "c", 1);
var galaxy = Enumerable.Range(1, 40).Select(seed => DemoGalaxy(cache, (uint) seed)).First(g => g != null && ExitIsACutVertex(g));
// No key 15: the same Regions, Partition and Deal, from the saved graph, boss zones, Exit and seed.
var (older, olderZones) = Capture(cache, galaxy);
older.Provinces = null;
foreach (var zone in olderZones) zone.Province = 0;
Assert.Equal(Shape(galaxy), Shape(Continue(cache, older, olderZones)));
// A saved type that no longer resolves takes the card the region's Deal gives that province's place; the others are kept.
var (game, zones) = Capture(cache, galaxy);
game.Provinces[0].Type = new CultRecordRef<ProvinceData>(new CultRecordKey("province:gone"));
foreach (var province in game.Provinces.Skip(1)) province.Type = a;
var deal = Provinces.Deal(0, galaxy.Provinces.Length, galaxy.Seed, cache);
Assert.Contains(deal.Skip(1), card => card.Key.Value != a.Key.Value);
var restored = Continue(cache, game, zones);
Assert.Equal(deal[0].Key.Value, restored.Provinces[0].Type.Key.Value);
Assert.All(restored.Provinces.Skip(1), province => Assert.Equal(a.Key.Value, province.Type.Key.Value));
Assert.Equal(galaxy.Provinces.Select(p => Array.IndexOf(galaxy.Zones, p.Seed)), restored.Provinces.Select(p => Array.IndexOf(restored.Zones, p.Seed)));
}
[Fact]
public void ASectorGalaxyBuildsItsProvincesFromItsProvinceZones()
{
var (cache, _, _) = Shipped();
var seed = Enumerable.Range(1, 40).First(s => SectorGalaxy(cache, (uint) s, 4) != null && SectorGalaxy(cache, (uint) s, 40) != null);
foreach (var perProvince in new[] {4, 40})
{
var galaxy = SectorGalaxy(cache, (uint) seed, perProvince);
var regionOf = RegionsOf(galaxy);
var expected = Enumerable.Range(0, regionOf.Length).GroupBy(zone => regionOf[zone])
.Sum(region => Math.Min(region.Count(), Math.Max(1, (int) Math.Round(region.Count() / (double) perProvince, MidpointRounding.AwayFromZero))));
Assert.Equal(expected, galaxy.Provinces.Length);
}
Assert.True(SectorGalaxy(cache, (uint) seed, 4).Provinces.Length > SectorGalaxy(cache, (uint) seed, 40).Provinces.Length);
}
[Fact]
public void ASectorGalaxysProvincesSurviveSaveAndContinue()
{
var (cache, catalog, run) = Shipped();
Province(cache, "a", 1);
Province(cache, "b", 1);
Province(cache, "c", 1);
var galaxy = MultiRegionSector(cache, 4);
Assert.True(RegionsOf(galaxy).Distinct().Count() > 1);
var (game, zones) = Capture(cache, galaxy);
Assert.Equal(galaxy.Provinces.Length, game.Provinces.Count);
RunSave.Commit(cache, game, zones, new ProvenanceLedger());
cache.FlushAsync().Wait();
cache.Dispose();
_caches.Remove(cache);
// Reopened from disk with a type of overwhelming weight added: a saved run keeps what it was dealt.
var reopened = OpenCopy(catalog, run);
_caches.Add(reopened);
Put(reopened, "province:huge", new ProvinceData {Name = "huge", GenerationWeight = 1000000});
var restored = new Galaxy(reopened, reopened.GetGlobal<SavedGame>(), _ => { });
Assert.Equal(Shape(galaxy), Shape(restored));
Assert.DoesNotContain(restored.Provinces, province => province.Type.Key.Value == "province:huge");
}
[Fact]
public void ASectorGalaxysOlderSaveGetsProvincesByTheSameRule()
{
var (cache, _, _) = Shipped();
Province(cache, "a", 2);
Province(cache, "b", 1);
Province(cache, "c", 1);
var galaxy = MultiRegionSector(cache);
var shape = Shape(galaxy);
// No key 15: every region is re-derived (Regions, Partition, Deal) from the saved graph, boss zones, Exit and seed.
var (older, olderZones) = Capture(cache, galaxy);
older.Provinces = null;
foreach (var zone in olderZones) zone.Province = 0;
var rederived = Continue(cache, older, olderZones);
Assert.Equal(shape, Shape(rederived));
// And the re-derived galaxy saves and continues to the same provinces.
var (again, againZones) = Capture(cache, rederived);
Assert.Equal(shape, Shape(Continue(cache, again, againZones)));
}
// ---- the documents
[Fact]
public void YieldsAndProvincesRoundTrip()
{
var (cache, catalog, run) = Shipped();
var ore = Ore("iron");
var kind = Put(cache, "field-kind:rock", new FieldKindData {Name = "rock", Yields = new List<FieldYield> {Yield("iron", 1.5f), Yield("gold", 2f, regional: true)}});
var province = Put(cache, "province:rich", new ProvinceData
{
Name = "rich", GenerationWeight = 3,
Kinds = new List<ProvinceKindWeight> {KindFactor(kind, 2.5f)}, Ores = new List<ProvinceOre> {OreFactor(ore, 4f)}
});
cache.FlushAsync().Wait();
cache.Dispose();
_caches.Remove(cache);
var reopened = OpenCopy(catalog, run);
_caches.Add(reopened);
var rock = reopened.Get(kind);
Assert.Equal(new[] {("ore:iron", 1.5f, false), ("ore:gold", 2f, true)}, rock.Yields.Select(y => (y.Commodity.Key.Value, y.Abundance, y.Regional)).ToArray());
var rich = reopened.Get(province);
Assert.Equal(3, rich.GenerationWeight);
Assert.Equal(new[] {("field-kind:rock", 2.5f)}, rich.Kinds.Select(k => (k.Kind.Key.Value, k.Weight)).ToArray());
Assert.Equal(new[] {("ore:iron", 4f)}, rich.Ores.Select(o => (o.Commodity.Key.Value, o.Weight)).ToArray());
// A kind written before key 3 loads with no yields.
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly);
var old = MessagePackSerializer.Deserialize<FieldKindData>(MessagePackSerializer.Serialize(new object[] {"Old", 1.5f, 2f}), options);
Assert.Equal("Old", old.Name);
Assert.Equal(2f, old.GenerationWeight);
Assert.Empty(old.Yields);
}
[Fact]
public void ABodySavedWithResourcesStillLoads()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly);
var belt = new AsteroidBeltData {Name = "retired-key", Mass = 12f, Asteroids = new Asteroid[0]};
var bytes = MessagePackSerializer.Serialize(typeof(AsteroidBeltData), belt, options);
// The same record with a non-empty key 4, the stored Resources of a body from before the key was retired.
var source = new System.Buffers.ReadOnlySequence<byte>(bytes);
var reader = new MessagePackReader(source);
var length = reader.ReadArrayHeader();
Assert.True(length > 4);
var output = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(output);
writer.WriteArrayHeader(length);
for (var key = 0; key < length; key++)
{
var start = reader.Position;
reader.Skip();
if (key == 4)
{
writer.WriteMapHeader(1);
writer.Write("ore:iron");
writer.Write(3.5f);
}
else writer.WriteRaw(source.Slice(start, reader.Position));
}
writer.Flush();
var loaded = (AsteroidBeltData) MessagePackSerializer.Deserialize(typeof(AsteroidBeltData), output.WrittenMemory, options);
Assert.Equal("retired-key", loaded.Name);
Assert.Equal(12f, loaded.Mass);
Assert.Empty(loaded.Asteroids);
}
}