From c8d40eaf74b6a21182e8469fc1dc8a75719a5f76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 06:41:33 +0200 Subject: [PATCH] Boss gate: the exit gate fails closed; it opens only for a boss that spawned and then died ZonePack.BossSpawned records the spawn, since a dead boss leaves no entity. RunGoal.ExitOpen requires it, so an exit zone without a boss stays sealed, and ZoneGenerator logs that configuration by field and code. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/RunGoal.cs | 5 ++- Assets/Scripts/ServerShared/Zone.cs | 1 + Assets/Scripts/ServerShared/ZoneData.cs | 5 +++ Assets/Scripts/ServerShared/ZoneGenerator.cs | 4 ++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 46 ++++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/RunGoal.cs b/Assets/Scripts/ServerShared/RunGoal.cs index 966789a36..ee34b1563 100644 --- a/Assets/Scripts/ServerShared/RunGoal.cs +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -11,9 +11,10 @@ public static class RunGoal 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. + // 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 && !BossAlive(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. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d3973d92e..3be6ad99c 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -182,6 +182,7 @@ public class Zone Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(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. diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 2216355d6..7dc02b878 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -43,6 +43,11 @@ public class ZonePack // default instance. [JsonProperty("chunkWear"), Key(6)] public List 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 diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 40078c562..156deb803 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -349,8 +349,12 @@ public static class ZoneGenerator } 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; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index b910f768b..b67d8c461 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -164,6 +164,52 @@ public sealed partial class RunStartTests 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(); + var pack = BossZonePack(galaxy, LoggingItems(log), 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()