Boss gate: a boss zone spawns its faction's boss, and RunGoal derives whether the exit gate is open

EntityPack.Boss (key 21) carries Entity.IsBoss through save and Continue. GenerateZone adds one boss
per BossZones entry before the ambient return, flying the faction's BossHull or, with the gap logged,
its most expensive available ship hull. RunGoal (ServerShared) is the one owner of boss life and exit
state, derived from Zone.Entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 01:13:08 +02:00
commit db4617d832
8 changed files with 285 additions and 5 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

@ -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,41 @@
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 is open in the galaxy's exit zone once that zone holds no living boss.
public static bool ExitOpen(Zone zone) =>
zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && !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

@ -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

@ -337,6 +337,20 @@ 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);
}
if (!ambient) return pack;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;

View file

@ -0,0 +1,187 @@
/* 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();
var unsold = _cache.GetAll<HullData>().FirstOrDefault(hull => hull.HullType == HullType.Ship && !sold.Contains(hull));
Assert.True(unsold != null, "the catalog needs a ship hull no product sells for this test");
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 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");
}
}
}
}