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

@ -341,6 +341,19 @@ public class ActionGameManager : MonoBehaviour
foreach (var wormhole in ZoneRenderer.WormholeInstances.Keys)
{
if (!(length(wormhole.Position - CurrentEntity.Position.xz) < Settings.GameplaySettings.WormholeExitRadius)) continue;
if (wormhole.Exit)
{
// The exit gate leads nowhere: RunGoal decides whether it is open, and an open one ends the run.
if (RunGoal.ExitOpen(Zone)) Win();
else
{
Dialog.Clear();
Dialog.Title.text = $"The exit gate is sealed. {RunGoal.Boss(Zone)?.Name ?? "Its guardian"} holds it shut.";
Dialog.Show();
Dialog.MoveToCursor();
}
return;
}
EnterWormhole(wormhole);
}
Dock();
@ -1185,9 +1198,9 @@ public class ActionGameManager : MonoBehaviour
return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y);
}
private void Die(CauseOfDeath cause)
// The one way a run ends, won or lost: the run is cleared and the main menu returns.
private void EndRun()
{
var deathTime = Time.time;
UnbindEntity();
CurrentEntity = null;
MainMenu.gameObject.SetActive(true);
@ -1195,6 +1208,21 @@ public class ActionGameManager : MonoBehaviour
CurrentGalaxy = null;
RunSave.Clear(CultCache);
SavePlayerSettings();
}
private void Win()
{
EndRun();
Dialog.Clear();
Dialog.Title.text = "You have escaped the Terminus. This was the demo; the whole of it is yet to come.";
Dialog.Show();
Dialog.MoveToCursor();
}
private void Die(CauseOfDeath cause)
{
var deathTime = Time.time;
EndRun();
Observable.EveryUpdate()
.Where(_ => Time.time - deathTime < DeathPostTransitionTime)
.Subscribe(_ =>

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;

View file

@ -127,8 +127,8 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("Quit", Application.Quit);
}
// New Game: every way to set up a run, each with its brief. The test arenas are listed only in editor and
// development builds.
// New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways
// to set up a run and the test arenas are listed only in editor and development builds.
private void ShowScenarios()
{
_nextMenu.panel.Clear();
@ -136,6 +136,8 @@ public class MainMenu : MonoBehaviour
AddScenarios(Scenarios.Modes);
if (Debug.isDebugBuild)
{
_nextMenu.panel.AddSection("development");
AddScenarios(Scenarios.Development);
_nextMenu.panel.AddSection("test arenas");
AddScenarios(Scenarios.Tests);
}

View file

@ -233,6 +233,14 @@ public class ZoneRenderer : MonoBehaviour
Position = dir * zone.Pack.Radius * Settings.WormholeDistanceRatio
});
}
// The run's exit gate is presentation only: whether it opens is RunGoal.ExitOpen, read when it is used.
if (zone.GalaxyZone == zone.Galaxy.Exit)
AddWormhole(new Wormhole
{
Exit = true,
Position = RunGoal.ExitGatePosition(zone, Settings.WormholeDistanceRatio)
});
}
}

View file

@ -0,0 +1,241 @@
/* 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.Linq;
using CultMath;
using Xunit;
// The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the
// boss zone and the exit. The shipped catalog holds no Pirates faction yet, so each test that needs one adds it to its
// scratch copy of the catalog.
public sealed partial class RunStartTests
{
private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 };
// The catalog record the demo's protagonist needs, until the shipped catalog has one. Zone names need a name file,
// and the entrance's station needs a faction whose allegiance reaches some manufacturer, so it reaches every one.
private void AddPirates()
{
var pirates = new Faction
{
Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll<NameFile>().First())
};
foreach (var faction in _cache.GetAll<Faction>().ToArray()) pirates.Allegiance[_cache.RefOf(faction)] = 1;
_cache.Upsert(pirates);
}
private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed));
[Fact]
public void DemoTerminus_fixed_cast()
{
AddPirates();
foreach (var seed in DemoSeeds)
Assert.Equal(new[] { "Pirates", "Zhestokost", "Lucent Media", "Aeronautics Unlimited" },
DemoGalaxy(seed).Factions.Select(faction => faction.Name));
}
[Fact]
public void DemoTerminus_gate_at_antagonist_home()
{
AddPirates();
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
// Zones are compared by index: xunit formats a failing zone into its message, and a zone drags the galaxy
// graph in with it until the test host dies.
int At(GalaxyZone zone) => Array.IndexOf(galaxy.Zones, zone);
var zhestokost = galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
Assert.Equal(new[] { "Zhestokost" }, galaxy.BossZones.Keys.Select(faction => faction.Name));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.BossZones[zhestokost]));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.Exit));
Assert.NotEqual(At(galaxy.Entrance), At(galaxy.Exit));
}
}
// The player's standing is part of the run: Continue restores it, and the gate, through the saved game.
[Fact]
public void DemoTerminus_standing_survives_continue()
{
AddPirates();
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]);
RunSave.Commit(_cache, game, zones, _items.Lots);
var restored = new Galaxy(_cache, _cache.GetGlobal<SavedGame>(), _ => { });
Dictionary<string, FactionRelationship> Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]);
Assert.Equal(new Dictionary<string, FactionRelationship>
{
["Pirates"] = FactionRelationship.Friendly,
["Zhestokost"] = FactionRelationship.Hated,
["Lucent Media"] = FactionRelationship.Neutral,
["Aeronautics Unlimited"] = FactionRelationship.Neutral
}, Standing(restored));
Assert.Equal(Standing(galaxy), Standing(restored));
Assert.Equal(Array.IndexOf(galaxy.Zones, galaxy.Exit), Array.IndexOf(restored.Zones, restored.Exit));
Assert.Equal(
galaxy.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(galaxy.Zones, boss.Value))),
restored.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(restored.Zones, boss.Value))));
}
// On the shipped catalog, which has no Pirates, the run does not start: the galaxy refuses, naming the cast field,
// and the scenario never reaches staging.
[Fact]
public void DemoTerminus_missing_cast_refused()
{
Assert.DoesNotContain(_cache.GetAll<Faction>(), faction => faction.Name.StartsWith("Pirates", StringComparison.Ordinal));
var refusal = Assert.Throws<InvalidOperationException>(() => DemoGalaxy(1));
Assert.Contains("Pirates", refusal.Message);
Assert.Contains(nameof(TutorialGenerationSettings.ProtagonistFaction), refusal.Message);
}
// The operator's ruling is that the first release is a demo: the one scenario a release build lists says so by name.
[Fact]
public void DemoTerminus_is_named_a_demo()
{
var listed = Assert.Single(Scenarios.Modes);
Assert.IsType<DemoTerminus>(listed);
Assert.Equal("Terminus (Demo)", listed.Name);
}
// The run start as New Game runs it: the player is not a Pirate, so the hull is the base Longinus, made by Alakrita,
// not by the Pirates or any cast faction, with no faction on the ship, at the entrance zone's origin.
[Fact]
public void DemoTerminus_player_flies_a_non_pirates_brand_hull_the_longinus()
{
AddPirates();
foreach (var seed in DemoSeeds)
{
var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.Equal("Longinus", staged.Player.HullData.Name);
var design = _cache.RefOf(staged.Player.HullData).Key;
var product = Assert.Single(_cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(design));
var maker = _cache.Get(product.Manufacturer);
Assert.Equal("Alakrita", maker.Name);
Assert.DoesNotContain(galaxy.Factions, faction => faction.Name == maker.Name);
Assert.Null(staged.Player.Faction);
Assert.Equal(float3.zero, staged.Player.Position);
}
}
// The ruling says the Longinus with its default fit: the fit the game generates for that hull from the run's item
// draws, which Stage seeds from the galaxy's seed. The expected fit is that same generation replayed from the same
// seed, so no item list is spelled here; a bare hull, a preset, or one item swapped for another differs from it.
[Fact]
public void DemoTerminus_player_carries_the_longinus_default_fit()
{
AddPirates();
string Fit(Entity ship) => string.Join(";", ship.Equipment
.Select(item => $"{item.Data.Name}@{item.Position}/{item.EquippableItem.Rotation}")
.OrderBy(entry => entry, StringComparer.Ordinal));
foreach (var seed in DemoSeeds)
{
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
_items.Random = new CultMath.Random(galaxy.Seed * 0x9E3779B1u + 2);
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, arena.GalaxyZone, null, 2);
var expected = EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Longinus"));
Assert.NotEmpty(expected.Equipment);
Assert.Equal(Fit(expected), Fit(staged.Player));
}
}
// The demo region's size and connectivity are authored: 64 zones, links thinned to half. The reference galaxy is the
// same cast with the thinning written out here, generated at the same seed, so any other density is a different graph.
[Fact]
public void DemoTerminus_region_is_sixty_four_zones_linked_at_half_density()
{
AddPirates();
string Links(Galaxy g) => string.Join(";", g.Zones.Select(zone =>
string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(g.Zones, adjacent)).OrderBy(index => index))));
Galaxy Reference(uint seed, float density)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = "Pirates", AntagonistFaction = "Zhe", BufferFaction = "Luc",
NeutralFactions = new[] { "Aero" }, QuestFaction = null, ZoneCount = 64, LinkDensity = density
};
return RunStart.Generate(new Scripted(false, _ => { }, seed, galaxy => galaxy.Prelude(cast)), Inputs());
}
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
Assert.Equal(64, galaxy.Zones.Length);
Assert.Equal(Links(Reference(seed, .5f)), Links(galaxy));
Assert.NotEqual(Links(Reference(seed, 1f)), Links(galaxy));
}
}
// A cast that names no quest faction places no quest zone: one home for each of its four factions.
[Fact]
public void ACastNamingNoQuestFactionPlacesNoQuestZone()
{
AddPirates();
var galaxy = DemoGalaxy(1);
Assert.Equal(4, galaxy.HomeZones.Count);
Assert.Equal(4, galaxy.Factions.Length);
}
// A name that resolves to nothing refuses the galaxy whichever cast field names it, and the refusal names the field.
[Theory]
[InlineData(nameof(TutorialGenerationSettings.ProtagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.AntagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.BufferFaction))]
[InlineData(nameof(TutorialGenerationSettings.QuestFaction))]
[InlineData(nameof(TutorialGenerationSettings.NeutralFactions))]
public void AnUnresolvedCastNameRefusesTheGalaxyNamingItsField(string field)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = _tutorialSettings.ProtagonistFaction,
AntagonistFaction = _tutorialSettings.AntagonistFaction,
BufferFaction = _tutorialSettings.BufferFaction,
QuestFaction = _tutorialSettings.QuestFaction,
NeutralFactions = _tutorialSettings.NeutralFactions,
ZoneCount = _tutorialSettings.ZoneCount,
LinkDensity = _tutorialSettings.LinkDensity
};
typeof(TutorialGenerationSettings).GetField(field).SetValue(cast, field == nameof(cast.NeutralFactions) ? new[] { "Nonesuch" } : "Nonesuch");
var scenario = new Scripted(false, _ => { }, generate: galaxy => galaxy.Prelude(cast));
var refusal = Assert.Throws<InvalidOperationException>(() => RunStart.Generate(scenario, Inputs()));
Assert.Contains(field, refusal.Message);
}
// What a release build lists under New Game is the demo alone; the tutorial and the main galaxy are listed only in
// editor and development builds (MainMenu.ShowScenarios), beside the test arenas.
[Fact]
public void ReleaseBuildsListOnlyTheDemo()
{
Assert.Equal(new[] { typeof(DemoTerminus) }, Scenarios.Modes.Select(scenario => scenario.GetType()));
Assert.Equal(new[] { typeof(TutorialGalaxy), typeof(MainGalaxy) }, Scenarios.Development.Select(scenario => scenario.GetType()));
}
// The tutorial is the galaxy it was before the demo: its cast, homes, entrance and ownership at a fixed seed, with
// no exit and no boss zone.
[Fact]
public void TutorialGalaxy_unchanged()
{
var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed));
Assert.Equal(TutorialSeed1, TutorialShape(galaxy));
Assert.True(galaxy.Exit == null, "the tutorial has no exit");
Assert.True(galaxy.BossZones.Count == 0, $"the tutorial has {galaxy.BossZones.Count} boss zones");
}
private static string TutorialShape(Galaxy g) =>
string.Join(",", g.Factions.Select(f => f.Name)) +
" | homes " + string.Join(",", g.Factions.Select(f => g.HomeZones.TryGetValue(f, out var z) ? Array.IndexOf(g.Zones, z) : -2)) +
" | entrance " + Array.IndexOf(g.Zones, g.Entrance) +
" | zones " + g.Zones.Length +
" | owners " + string.Join(",", g.Zones.Select(z => z.Owner == null ? "-" : z.Owner.ShortName));
// Measured on 228f241e, before the demo cut, at seed 1.
private const string TutorialSeed1 =
"Miss Terri’s Sugariffic Snack Company,Zhestokost,Lucent Media,Adrasteia,Aeronautics Unlimited,Finch Cybernetics | homes 47,10,17,49,35,1 | entrance 25 | zones 64 | owners Zhestokost,Finch,Lucent,Zhestokost,Finch,Adrasteia,Zhestokost,Adrasteia,Zhestokost,Zhestokost,Zhestokost,Zhestokost,Finch,Lucent,Adrasteia,Adrasteia,Zhestokost,Lucent,Finch,Finch,Lucent,Lucent,Finch,AU,Adrasteia,-,-,Adrasteia,Lucent,-,AU,Lucent,Zhestokost,Lucent,Lucent,AU,Adrasteia,AU,Lucent,AU,Finch,Finch,-,Lucent,Zhestokost,Zhestokost,Miss Terri's,Miss Terri's,Miss Terri's,Adrasteia,Finch,-,-,Miss Terri's,Lucent,AU,Miss Terri's,Zhestokost,Lucent,Lucent,Lucent,Finch,-,-";
}

View file

@ -0,0 +1,278 @@
/* 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 System.Text.RegularExpressions;
using CultMath;
using UniRx;
using Xunit;
// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens
// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
// and the run save.
public sealed partial class RunStartTests
{
private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true)
{
items.Random = new CultMath.Random(7);
return ZoneGenerator.GenerateZone(items, _zoneSettings, galaxy, zone, galaxy.IsPrelude, ambient);
}
private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) =>
new Zone(_items, _planetSettings, BossZonePack(galaxy, _items, zone), zone, galaxy);
private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
private ItemManager LoggingItems(List<string> log) =>
new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings"), log.Add);
private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name;
private HullData[] SoldShipHulls() => _cache.GetAll<FactionProductData>()
.Where(product => product.Manufacturer.IsSet())
.Select(product => _cache.Get(product.Design) as HullData)
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
.Distinct().ToArray();
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
AddPirates();
foreach (var seed in DemoSeeds.Take(3))
{
var galaxy = DemoGalaxy(seed);
var antagonist = Zhestokost(galaxy);
foreach (var zone in galaxy.Zones)
{
var bosses = BossZonePack(galaxy, _items, zone).Entities.Where(entity => entity.Boss).ToArray();
if (zone != galaxy.Exit) { Assert.Empty(bosses); continue; }
var boss = Assert.Single(bosses);
Assert.IsType<ShipPack>(boss);
Assert.Same(antagonist, _cache.Get(boss.Faction));
// Generation is repeatable per zone: the same zone yields the same boss hull.
var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss);
Assert.Equal(HullName(_items, boss), HullName(_items, again));
}
}
}
// A zone that is not ambient still has its boss: the boss is the goal, not traffic.
[Fact]
public void Boss_spawns_in_a_zone_that_is_not_ambient()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false);
Assert.Single(pack.Entities, entity => entity.Boss);
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
}
[Fact]
public void Boss_hull_from_BossHull_else_fallback()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray();
var priciest = hulls.First();
var cheaper = hulls.Last();
Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
// Set and sold: the boss flies it, and nothing is logged about a gap.
antagonist.BossHull = _cache.RefOf(cheaper);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(cheaper.Name, HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("BossHull"));
// Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
antagonist.BossHull = default;
log.Clear();
boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(priciest.Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
}
// Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
[Fact]
public void Boss_hull_nobody_sells_falls_back()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var sold = SoldShipHulls();
// A ship hull no product sells, dearer than any that is sold: the fallback must not pick it.
var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 };
_cache.Upsert(unsold);
antagonist.BossHull = _cache.RefOf(unsold);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull"));
}
[Fact]
public void Exit_sealed_until_boss_dies()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var exit = ZoneOf(galaxy, galaxy.Exit);
var boss = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.True(RunGoal.BossAlive(exit));
Assert.False(RunGoal.ExitOpen(exit));
// Death removes the boss from the zone (the one death path), and that alone opens the gate.
boss.HeatstrokeDeath.OnNext(Unit.Default);
Assert.DoesNotContain(boss, exit.Entities);
Assert.False(RunGoal.BossAlive(exit));
Assert.True(RunGoal.ExitOpen(exit));
// No other zone is ever an exit, boss or no boss.
foreach (var zone in galaxy.Zones.Where(zone => zone != galaxy.Exit))
Assert.False(RunGoal.ExitOpen(ZoneOf(galaxy, zone)));
}
// Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after.
[Fact]
public void Boss_mark_survives_continue()
{
AddPirates();
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit);
var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss);
Assert.False(RunGoal.ExitOpen(galaxy.Exit.Contents));
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]);
RunSave.Commit(_cache, game, zones, _items.Lots);
var restored = new Galaxy(_cache, _cache.GetGlobal<SavedGame>(), _ => { });
var exit = new Zone(_items, _planetSettings, restored.Exit.PackedContents, restored.Exit, restored);
var after = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.Equal(before.Name, after.Name);
Assert.Equal(before.Faction.Name, after.Faction.Name);
Assert.False(RunGoal.ExitOpen(exit));
after.HeatstrokeDeath.OnNext(Unit.Default);
Assert.True(RunGoal.ExitOpen(exit));
}
// The gate fails closed. The main galaxy puts its bosses on choke points, not at its exit, so its exit zone never gets
// one: the gate stays sealed on arrival and when every boss in the galaxy dies, because a boss that never existed is
// not a dead one.
[Fact]
public void Exit_sealed_where_no_boss_was_spawned_main_galaxy()
{
foreach (var seed in new uint[] { 1, 2, 3, 4, 5, 6, 7, 8 })
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => seed));
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open on arrival");
foreach (var boss in exit.Entities.Where(entity => entity.IsBoss).ToArray())
boss.HeatstrokeDeath.OnNext(Unit.Default);
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open with no boss spawned");
}
}
// The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed.
[Fact]
public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated()
{
AddPirates();
var galaxy = DemoGalaxy(1);
galaxy.BossZones.Clear();
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
var exit = new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy);
Assert.False(RunGoal.ExitOpen(exit));
}
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
[Fact]
public void Exit_open_after_save_of_a_slain_boss()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default);
var repacked = exit.PackZone();
Assert.True(repacked.BossSpawned);
Assert.DoesNotContain(repacked.Entities, entity => entity.Boss);
Assert.True(RunGoal.ExitOpen(new Zone(_items, _planetSettings, repacked, galaxy.Exit, galaxy)));
}
// The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach.
[Fact]
public void Exit_gate_shares_no_point_with_a_wormhole()
{
AddPirates();
var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset"))
.Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(),
System.Globalization.CultureInfo.InvariantCulture);
var reach = AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings").WormholeExitRadius;
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
var zone = ZoneOf(galaxy, galaxy.Exit);
var gate = RunGoal.ExitGatePosition(zone, ratio);
foreach (var adjacent in galaxy.Exit.AdjacentZones)
{
var wormhole = math.normalize(adjacent.Position - galaxy.Exit.Position) * zone.Pack.Radius * ratio;
Assert.True(math.length(gate - wormhole) > 2 * reach, $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole");
}
}
}
// The gate sits on the wormholes' radius, in the middle of the widest gap between neighbours, whatever the
// neighbours' number and order: an empty ring, one, two straddling the +-pi seam, and eight spread evenly (every gap
// under one radian). Checked against a brute-force sweep of every direction.
[Theory]
[InlineData(new double[0])]
[InlineData(new[] { 40.0 })]
[InlineData(new[] { 170.0, -170.0 })]
[InlineData(new[] { 100.0, 10.0, 20.0, 350.0, -90.0 })]
[InlineData(new[] { 0.0, 45.0, 90.0, 135.0, 180.0, -135.0, -90.0, -45.0 })]
public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius(double[] neighbourDegrees)
{
AddPirates();
var galaxy = DemoGalaxy(1);
var zone = ZoneOf(galaxy, galaxy.Exit);
const float ratio = 0.75f;
var radius = zone.Pack.Radius * ratio;
// Away from the origin and with close neighbours, so a direction taken from raw positions instead of the
// offset from the zone cannot pass.
var originalPosition = galaxy.Exit.Position;
var origin = galaxy.Exit.Position = math.float2(500, -300);
var original = galaxy.Exit.AdjacentZones;
galaxy.Exit.AdjacentZones = neighbourDegrees
.Select(degrees => new GalaxyZone
{
Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 10
}).ToList();
float2 gate;
try { gate = RunGoal.ExitGatePosition(zone, ratio); }
finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; }
Assert.Equal(radius, math.length(gate), 2);
if (neighbourDegrees.Length == 0) { Assert.Equal(radius, gate.x, 2); Assert.Equal(0, gate.y, 2); return; }
double Separation(double angle) => neighbourDegrees.Min(degrees =>
{
var difference = Math.Abs(angle - degrees * Math.PI / 180) % (2 * Math.PI);
return difference > Math.PI ? 2 * Math.PI - difference : difference;
});
var best = Enumerable.Range(0, 7200).Max(step => Separation(step * Math.PI / 3600));
Assert.True(Separation(Math.Atan2(gate.y, gate.x)) >= best - 0.002,
$"the gate's nearest wormhole is {Separation(Math.Atan2(gate.y, gate.x))} rad away; the widest gap's middle is {best}");
}
}

View file

@ -44,7 +44,7 @@ public sealed partial class RunStartTests
public override void Stage(ScenarioStage stage) => _stage(stage);
}
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests);
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Development).Concat(Scenarios.Tests);
// The galaxy inputs the menu hands to RunStart, read from the authored settings.
private GalaxyStage Inputs(Func<uint> clock = null)
@ -96,6 +96,7 @@ public sealed partial class RunStartTests
[Fact]
public void EveryScenarioStages()
{
AddPirates();
Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count());
foreach (var scenario in EveryScenario)
{
@ -522,6 +523,7 @@ public sealed partial class RunStartTests
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
AddPirates();
var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList();
Assert.Empty(UnsoldAndUnplaced(runs));