Merge eureka/aetheria-release-boss-gate (with eureka/aetheria-release-demo-galaxy) into master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 14:03:32 +02:00
commit e3a457793b
20 changed files with 772 additions and 33 deletions

View file

@ -16,6 +16,8 @@ public abstract class Entity
{
public Zone Zone;
public Faction Faction;
// The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it.
public bool IsBoss;
public EquippableItem Hull;
public EquippedItem EquippedHull;

View file

@ -28,6 +28,7 @@ public static class EntitySerializer
else throw new ArgumentException("Attempted to pack an instance of abstract class Entity!");
pack.Settings = entity.Settings;
pack.Boss = entity.IsBoss;
pack.Hull = entity.Hull;
pack.Name = entity.Name;
@ -81,6 +82,7 @@ public static class EntitySerializer
private static void Restore(ItemManager itemManager, Zone zone, EntityPack pack, Entity entity)
{
entity.Name = pack.Name;
entity.IsBoss = pack.Boss;
entity.Faction = itemManager.ItemData.Get(pack.Faction);
entity.Children = pack.Children.Select(c =>
{
@ -187,6 +189,8 @@ public abstract class EntityPack
[Key(14)] public EntitySettings Settings;
[Key(15)] public CultRecordRef<Faction> Faction;
[Key(16)] public int[][] WeaponGroups;
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
[Key(21)] public bool Boss;
private int _price;

View file

@ -24,7 +24,7 @@ public class Galaxy
public Faction[] Factions { get; }
public GalaxyZone[] Zones { get; }
public GalaxyZone Entrance { get; }
public GalaxyZone Exit { get; }
public GalaxyZone Exit { get; private set; }
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
@ -143,6 +143,18 @@ public class Galaxy
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
}
// A cast member of a prelude galaxy, by name. A name the catalog does not hold refuses the galaxy, naming the field.
private Faction ResolveCast(string field, string name) =>
ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog");
// Gives a prelude galaxy its gate: the antagonist's home is the boss zone and the exit. The only writer of Exit
// besides the constructors.
public void PlaceGate(Faction antagonist)
{
BossZones[antagonist] = HomeZones[antagonist];
Exit = HomeZones[antagonist];
}
// How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a
// prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next.
public int InfluenceOf(Faction faction) =>
@ -169,20 +181,23 @@ public class Galaxy
var factions = new List<Faction>();
var protagonistFaction = ResolveFaction(settings.ProtagonistFaction);
var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction);
factions.Add(protagonistFaction);
var antagonistFaction = ResolveFaction(settings.AntagonistFaction);
var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction);
factions.Add(antagonistFaction);
var bufferFaction = ResolveFaction(settings.BufferFaction);
var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction);
factions.Add(bufferFaction);
var questFaction = ResolveFaction(settings.QuestFaction);
factions.Add(questFaction);
// A cast without a quest faction places no quest zone.
var questFaction = string.IsNullOrEmpty(settings.QuestFaction)
? null
: ResolveCast(nameof(settings.QuestFaction), settings.QuestFaction);
if (questFaction != null) factions.Add(questFaction);
var neutralFactions = settings.NeutralFactions
.Select(ResolveFaction)
.Select(name => ResolveCast(nameof(settings.NeutralFactions), name))
.ToArray();
factions.AddRange(neutralFactions);
@ -223,17 +238,20 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
var potentialQuestZones = Zones
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
CalculateFactionInfluence(progressCallback);
if (questFaction != null)
{
var potentialQuestZones = Zones
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
CalculateFactionInfluence(progressCallback);
}
Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]);

View file

@ -68,7 +68,10 @@ public sealed class GalaxyStage
}
// A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud.
public Galaxy Prelude()
public Galaxy Prelude() => Prelude(_prelude);
// A prelude galaxy of the given fixed cast, centred in dense cloud.
public Galaxy Prelude(TutorialGenerationSettings cast)
{
var background = Copy(_preludeBackground);
var random = new Random(Seed);
@ -78,7 +81,7 @@ public sealed class GalaxyStage
background.NoisePosition = random.NextFloat() * 1000;
_progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}");
} while (background.CloudDensity(float2(.5f)) < .5f);
return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
return new Galaxy(cast, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
}
private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) =>

View file

@ -140,6 +140,18 @@ public class LoadoutGenerator
// required item falls back to any manufacturer when none of those make one, which means the product table
// lacks variety, so it is logged rather than hidden.
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
var (available, preferManufacturers) = AvailableProducts(filter, required);
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
}
// The products this faction can be offered for a design kind, and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : EquippableItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
@ -160,11 +172,27 @@ public class LoadoutGenerator
preferManufacturers = false;
}
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
return (available, preferManufacturers);
}
// A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
// the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
if (bossHull != null)
{
var boss = GenerateShipLoadout(hull => hull == bossHull);
if (boss != null) return boss;
ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
}
else
ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts<HullData>(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
return GenerateShipLoadout(hull => hull == priciest);
}
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a

View file

@ -0,0 +1,42 @@
using System.Linq;
using CultMath;
using static CultMath.math;
// The run's goal: whether a zone's boss lives, and so whether the exit gate is open. Engine-free and stateless; the
// answer is derived from Zone.Entities (the death path removes the dead, Zone.cs), so no "boss defeated" flag exists to
// disagree with it, and no presentation decides it.
public static class RunGoal
{
public static Entity Boss(Zone zone) => zone.Entities.FirstOrDefault(entity => entity.IsBoss);
public static bool BossAlive(Zone zone) => Boss(zone) != null;
// The exit gate fails closed: it is open only in the galaxy's exit zone, once a boss was spawned there and no living
// boss remains. An exit zone that never got a boss stays sealed, so a missing boss can never read as a dead one.
public static bool ExitOpen(Zone zone) =>
zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && zone.Pack.BossSpawned && !BossAlive(zone);
// Where the exit gate sits: at the adjacency wormholes' radius (the zone's radius times the ratio), in the middle of
// the widest angular gap between them, so it shares a point with none. A zone with no neighbours gets the +x axis.
public static float2 ExitGatePosition(Zone zone, float wormholeDistanceRatio)
{
var radius = zone.Pack.Radius * wormholeDistanceRatio;
var angles = zone.GalaxyZone.AdjacentZones
.Select(adjacent => normalize(adjacent.Position - zone.GalaxyZone.Position))
.Select(direction => atan2(direction.y, direction.x))
.OrderBy(angle => angle)
.ToArray();
if (angles.Length == 0) return float2(radius, 0);
var bestAngle = 0f;
var bestGap = -1f;
for (var i = 0; i < angles.Length; i++)
{
var next = i + 1 < angles.Length ? angles[i + 1] : angles[0] + 2 * PI;
if (next - angles[i] <= bestGap) continue;
bestGap = next - angles[i];
bestAngle = angles[i] + bestGap / 2;
}
return float2(cos(bestAngle), sin(bestAngle)) * radius;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de03c3545cb04ce38bf85b713dc5df74

View file

@ -0,0 +1,41 @@
using float2 = CultMath.float2;
// The demo: one gate, one region and one boss, with a fixed cast. The Pirates are the protagonist's ostensible
// employer, Zhestokost the antagonist whose home is the boss zone and the exit, Lucent Media and Aeronautics Unlimited
// the neutrals. A prelude galaxy of that cast; the player, who is not a Pirate, flies the base Longinus.
public sealed class DemoTerminus : Scenario
{
public static readonly TutorialGenerationSettings Cast = new TutorialGenerationSettings
{
ProtagonistFaction = "Pirates",
AntagonistFaction = "Zhe",
BufferFaction = "Luc",
NeutralFactions = new[] { "Aero" },
QuestFaction = null,
ZoneCount = 64,
LinkDensity = .5f
};
public override string Name => "Terminus (Demo)";
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
"Neutral: Lucent Media and Aeronautics Unlimited.";
public override Galaxy Generate(GalaxyStage stage)
{
var galaxy = stage.Prelude(Cast);
galaxy.PlaceGate(galaxy.ResolveFaction(Cast.AntagonistFaction));
return galaxy;
}
public override void Stage(ScenarioStage stage)
{
var galaxy = stage.Galaxy;
var pirates = galaxy.ResolveFaction(Cast.ProtagonistFaction);
galaxy.FactionRelationships[pirates] = FactionRelationship.Friendly;
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.AntagonistFaction)] = FactionRelationship.Hated;
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.BufferFaction)] = FactionRelationship.Neutral;
foreach (var neutral in Cast.NeutralFactions)
galaxy.FactionRelationships[galaxy.ResolveFaction(neutral)] = FactionRelationship.Neutral;
stage.Player(stage.Generated("Longinus"), float2.zero);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ba7152d3c8b46a98924a4148be550db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +1,11 @@
// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check
// one system each, listed only in editor and development builds.
// Every way to set up a new run, in the order New Game lists them. Modes are what a release build lists; Development
// are the other ways the game sets up a run and Tests are arenas built to check one system each, both listed only in
// editor and development builds.
public static class Scenarios
{
public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Modes = { new DemoTerminus() };
public static readonly Scenario[] Development = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Tests =
{

View file

@ -4,4 +4,6 @@ public class Wormhole
{
public float2 Position;
public GalaxyZone Target;
// The run's exit gate: it leads nowhere (Target is null) and ends the run when RunGoal.ExitOpen.
public bool Exit;
}

View file

@ -190,6 +190,7 @@ public class Zone
Orbits = Orbits.Keys.Select(key => new CultRecordRef<OrbitData>(key)).ToList(),
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(key)).ToList(),
Time = _time,
BossSpawned = Pack.BossSpawned,
// Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and
// not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since
// they last respawned.

View file

@ -43,6 +43,11 @@ public class ZonePack
// default instance.
[JsonProperty("chunkWear"), Key(6)]
public List<ChunkWearPack> ChunkWear;
// True once this zone's boss has been spawned. The dead boss leaves no entity, so this is the one fact that tells a
// slain boss from one that never existed; RunGoal.ExitOpen reads it. Written only by ZoneGenerator, carried by PackZone.
[JsonProperty("bossSpawned"), Key(7)]
public bool BossSpawned;
}
// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk

View file

@ -337,6 +337,24 @@ public static class ZoneGenerator
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
}
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
// wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives.
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
{
var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout();
if (boss == null)
{
itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
continue;
}
boss.Boss = true;
pack.Entities.Add(boss);
pack.BossSpawned = true;
}
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
if (!ambient) return pack;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;