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