Aetheria/tests/Aetheria.Shared.Tests/OreGeographyTests.cs

1229 lines
60 KiB
C#

/* 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");
}
// ---- one exit per region
private const uint OneExitSeeds = 60;
private static int[][] Without(int[][] adjacency, params (int, int)[] removed) =>
adjacency.Select((next, zone) => next.Where(other => !removed.Contains((zone, other)) && !removed.Contains((other, zone))).ToArray()).ToArray();
private static int[] Hops(int[][] adjacency, int from)
{
var hops = Enumerable.Repeat(-1, adjacency.Length).ToArray();
hops[from] = 0;
var queue = new Queue<int>(new[] {from});
while (queue.Count > 0)
{
var zone = queue.Dequeue();
foreach (var next in adjacency[zone])
if (hops[next] < 0)
{
hops[next] = hops[zone] + 1;
queue.Enqueue(next);
}
}
return hops;
}
private static int[] ExitPathOf(Galaxy galaxy) => galaxy.ExitPath.Select(zone => Array.IndexOf(galaxy.Zones, zone)).ToArray();
private static int[] OtherBosses(Galaxy galaxy) => BossesOf(galaxy).Distinct().Where(boss => boss != ExitOf(galaxy)).ToArray();
private static Galaxy[] SectorGalaxies()
{
// Every seed builds: a generator exception here is a defect in what these tests cover, never a seed to skip.
lock (Built)
return Enumerable.Range(1, (int) OneExitSeeds).Select(seed =>
Built.TryGetValue((false, (uint) seed), out var known) && known != null ? known : Built[(false, (uint) seed)] = Build(Shared, false, (uint) seed)).ToArray();
}
[Fact]
public void EveryBossZoneHasOneExit()
{
var checkedBosses = 0;
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance);
var crossing = Provinces.CrossingLinks(adjacency, BossesOf(galaxy), exit);
foreach (var boss in OtherBosses(galaxy))
{
var exits = crossing.Where(link => link.Boss == boss).ToArray();
Assert.Single(exits);
Assert.False(ReachableAvoiding(Without(adjacency, (boss, exits[0].Far)), entrance, -1)[exit]);
checkedBosses++;
}
Assert.Empty(Provinces.ExtraExits(adjacency, BossesOf(galaxy), exit, ExitPathOf(galaxy)));
}
Assert.True(checkedBosses >= 40, $"only {checkedBosses} boss zones were exercised");
}
[Fact]
public void TheFarSideStaysConnected()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var exit = ExitOf(galaxy);
var entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance);
Assert.All(Hops(adjacency, entrance), hops => Assert.True(hops >= 0));
var path = ExitPathOf(galaxy);
foreach (var boss in OtherBosses(galaxy))
{
// Without the boss zone the graph is exactly two components: the entrance's, and the exit's holding every later boss.
var near = ReachableAvoiding(adjacency, entrance, boss);
var far = ReachableAvoiding(adjacency, exit, boss);
for (var zone = 0; zone < adjacency.Length; zone++)
if (zone != boss) Assert.True(near[zone] ^ far[zone], $"zone {zone} is on neither or both sides of boss zone {boss}");
foreach (var later in OtherBosses(galaxy).Where(other => Array.IndexOf(path, other) > Array.IndexOf(path, boss)))
Assert.True(far[later]);
}
Assert.Equal(OtherBosses(galaxy).Length + 1, RegionsOf(galaxy).Distinct().Count());
}
}
[Fact]
public void LinksStaySymmetric()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
for (var zone = 0; zone < adjacency.Length; zone++)
{
Assert.Equal(adjacency[zone].Length, adjacency[zone].Distinct().Count());
Assert.DoesNotContain(zone, adjacency[zone]);
foreach (var next in adjacency[zone]) Assert.Contains(zone, adjacency[next]);
}
}
}
[Fact]
public void ExtraExitsOfHandBuiltGraphs()
{
// A boss zone with two links into the far side lists both, and ExtraExits spares the one on the exit path.
var diamond = Graph(7, (0, 1), (1, 2), (2, 3), (2, 4), (3, 5), (4, 5), (5, 6));
Assert.Equal(new[] {(2, 3), (2, 4)}, Provinces.CrossingLinks(diamond, new[] {2}, 6));
Assert.Equal(new[] {(2, 4)}, Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2, 3, 5, 6}));
Assert.Equal(new[] {(2, 3)}, Provinces.ExtraExits(diamond, new[] {2}, 6, new[] {0, 1, 2, 4, 5, 6}));
// Three links: the two off the path are extra.
var fan = Graph(8, (0, 1), (1, 2), (2, 3), (2, 4), (2, 5), (3, 6), (4, 6), (5, 6), (6, 7));
Assert.Equal(new[] {(2, 3), (2, 4), (2, 5)}, Provinces.CrossingLinks(fan, new[] {2}, 7));
Assert.Equal(new[] {(2, 3), (2, 5)}, Provinces.ExtraExits(fan, new[] {2}, 7, new[] {0, 1, 2, 4, 6, 7}));
// A branch hanging off a boss zone and the link back to the near side are never crossing links.
var pendant = Graph(6, (0, 1), (1, 2), (2, 3), (3, 4), (2, 5));
Assert.Equal(new[] {(2, 3)}, Provinces.CrossingLinks(pendant, new[] {2}, 4));
Assert.Empty(Provinces.ExtraExits(pendant, new[] {2}, 4, new[] {0, 1, 2, 3, 4}));
// A boss zone that is the exit, or no exit at all, lists nothing; neither does no boss zone. A repeated boss zone counts once.
Assert.Empty(Provinces.CrossingLinks(diamond, new[] {6}, 6));
Assert.Empty(Provinces.CrossingLinks(diamond, new[] {2}, -1));
Assert.Empty(Provinces.ExtraExits(diamond, new[] {2}, -1, new[] {0, 1, 2, 3, 5, 6}));
Assert.Empty(Provinces.CrossingLinks(diamond, new int[0], 6));
Assert.Equal(2, Provinces.CrossingLinks(diamond, new[] {2, 2}, 6).Length);
// Two bosses in a line each keep their one link.
Assert.Empty(Provinces.ExtraExits(Line(7), new[] {2, 4}, 6, Enumerable.Range(0, 7).ToArray()));
// Two diamonds in a row: each boss zone keeps its path link, and pruning again finds nothing.
var chain = Graph(8, (0, 1), (1, 2), (1, 3), (2, 4), (3, 4), (4, 5), (4, 6), (5, 7), (6, 7));
var path = new[] {0, 1, 2, 4, 5, 7};
var extra = Provinces.ExtraExits(chain, new[] {4, 1}, 7, path);
Assert.Equal(new[] {(1, 3), (4, 6)}, extra);
var pruned = Without(chain, extra);
Assert.Empty(Provinces.ExtraExits(pruned, new[] {4, 1}, 7, path));
Assert.Equal(Provinces.Regions(chain, new[] {4, 1}, 7), Provinces.Regions(pruned, new[] {4, 1}, 7));
// Crossing links come ordered by far zone whatever order the adjacency lists them in.
var shuffled = diamond.Select(next => next.Reverse().ToArray()).ToArray();
Assert.Equal(new[] {4, 3, 1}, shuffled[2]);
Assert.Equal(new[] {(2, 3), (2, 4)}, Provinces.CrossingLinks(shuffled, new[] {2}, 6));
Assert.Equal(new[] {(2, 3)}, Provinces.ExtraExits(shuffled, new[] {2}, 6, new[] {0, 1, 2, 4, 5, 6}));
// A boss zone at the start of the exit path keeps the link to its successor too.
var atStart = Graph(4, (0, 1), (0, 2), (1, 3), (2, 3));
Assert.Equal(new[] {(0, 2)}, Provinces.ExtraExits(atStart, new[] {0}, 3, new[] {0, 1, 3}));
Assert.Equal(new[] {(0, 1)}, Provinces.ExtraExits(atStart, new[] {0}, 3, new[] {0, 2, 3}));
// A boss zone off the path, last on it, or followed by a zone it has no crossing link to has no exit to keep: it throws, naming the boss zone.
foreach (var broken in new[] {new[] {0, 1, 5, 6}, new[] {0, 1, 2}, new[] {0, 1, 2, 5, 6}})
Assert.Contains("boss zone 2", Assert.Throws<InvalidOperationException>(() => Provinces.ExtraExits(diamond, new[] {2}, 6, broken)).Message);
}
[Fact]
public void DistancesAreOfTheFinalGraph()
{
// Built here, not shared: a galaxy another test built first would put the generator's lines under that test's coverage alone.
foreach (var galaxy in Enumerable.Range(1, (int) OneExitSeeds).Select(seed => Build(Shared, false, (uint) seed)).ToArray())
{
var adjacency = AdjacencyOf(galaxy);
for (var zone = 0; zone < adjacency.Length; zone++)
{
var hops = Hops(adjacency, zone);
Assert.Equal(hops.Sum(), galaxy.Zones[zone].Isolation);
for (var other = 0; other < adjacency.Length; other++)
Assert.Equal(hops[other], galaxy.Zones[zone].Distance[galaxy.Zones[other]]);
}
// Homes were placed on the pruned graph: each boss mega's boss zone is within its influence of its home.
foreach (var pair in galaxy.BossZones)
Assert.True(Hops(adjacency, Array.IndexOf(galaxy.Zones, galaxy.HomeZones[pair.Key]))[Array.IndexOf(galaxy.Zones, pair.Value)] <= galaxy.InfluenceOf(pair.Key));
}
}
[Fact]
public void TheExitPathSurvivesPruning()
{
foreach (var galaxy in SectorGalaxies())
{
var adjacency = AdjacencyOf(galaxy);
var path = ExitPathOf(galaxy);
for (var i = 0; i + 1 < path.Length; i++) Assert.Contains(path[i + 1], adjacency[path[i]]);
Assert.All(BossesOf(galaxy), boss => Assert.Contains(boss, path));
}
}
[Fact]
public void PruningIsDeterministic()
{
foreach (var seed in new uint[] {1, 7, 23})
{
var a = TryBuild(Shared, false, seed);
var b = TryBuild(Shared, false, seed);
Assert.NotNull(a);
Assert.Equal(AdjacencyOf(a), AdjacencyOf(b));
Assert.Equal(a.Zones.Select(zone => zone.Name), b.Zones.Select(zone => zone.Name));
Assert.Equal(Shape(a), Shape(b));
}
}
[Fact]
public void ASavedBorderIsWhatLoads()
{
var (cache, _, _) = Shipped();
var galaxy = MultiRegionSector(cache);
var bosses = OtherBosses(galaxy);
// Capture then Continue keeps one exit per boss zone.
var (game, zones) = Capture(cache, galaxy);
var loaded = Continue(cache, game, zones);
Assert.Equal(AdjacencyOf(galaxy), AdjacencyOf(loaded));
Assert.Equal(bosses.Length, Provinces.CrossingLinks(AdjacencyOf(loaded), BossesOf(loaded), ExitOf(loaded)).Length);
// A save given a second crossing link loads with it: load never prunes, and the regions are still the bosses and one.
var adjacency = AdjacencyOf(galaxy);
var boss = bosses[0];
var far = Provinces.CrossingLinks(adjacency, BossesOf(galaxy), ExitOf(galaxy)).First(link => link.Boss == boss).Far;
var extra = adjacency[far].First(zone => zone != boss && !BossesOf(galaxy).Contains(zone) && !adjacency[boss].Contains(zone));
var (older, olderZones) = Capture(cache, galaxy);
olderZones[boss].AdjacentZones = olderZones[boss].AdjacentZones.Append(extra).ToArray();
olderZones[extra].AdjacentZones = olderZones[extra].AdjacentZones.Append(boss).ToArray();
var restored = Continue(cache, older, olderZones);
Assert.Equal(2, Provinces.CrossingLinks(AdjacencyOf(restored), BossesOf(restored), ExitOf(restored)).Count(link => link.Boss == boss));
Assert.Contains(restored.Zones[extra], restored.Zones[boss].AdjacentZones);
Assert.Equal(bosses.Length + 1, RegionsOf(restored).Distinct().Count());
}
[Fact]
public void ThePreludeIsNotPruned()
{
var (cache, _, _) = Shipped();
var galaxy = Enumerable.Range(1, 25).Select(seed => TryBuild(cache, true, (uint) seed, Cast(Provinces.DefaultZonesPerProvince))).First(g => g != null);
Assert.Empty(galaxy.BossZones);
var before = AdjacencyOf(galaxy);
galaxy.PlaceGate(galaxy.ResolveFaction(DemoTerminus.Cast.AntagonistFaction));
Assert.Equal(before, AdjacencyOf(galaxy));
}
// ---- 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);
}
}