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:
commit
bef6a90f58
9 changed files with 1342 additions and 36 deletions
|
|
@ -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) };
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
993
tests/Aetheria.Shared.Tests/OreGeographyTests.cs
Normal file
993
tests/Aetheria.Shared.Tests/OreGeographyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue