diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 5ee4b474a..ba90abd62 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -362,9 +362,8 @@ public class ActionGameManager : MonoBehaviour 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 + // The exit gate leads nowhere: TryWin consults RunGoal and ends the run when the gate is open. + if (!TryWin()) { Dialog.Clear(); Dialog.Title.text = $"The exit gate is sealed. {RunGoal.Boss(Zone)?.Name ?? "Its guardian"} holds it shut."; @@ -1199,13 +1198,17 @@ public class ActionGameManager : MonoBehaviour SavePlayerSettings(); } - private void Win() + // The one way a run is won: RunGoal decides whether the exit is open (read before EndRun clears the galaxy), the + // caller shows its own refusal. + private bool TryWin() { + if (!RunGoal.ExitOpen(Zone)) return false; 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(); + return true; } private void Die(CauseOfDeath cause) diff --git a/Assets/Scripts/ServerShared/RunGoal.cs b/Assets/Scripts/ServerShared/RunGoal.cs index ee34b1563..454cc5cd2 100644 --- a/Assets/Scripts/ServerShared/RunGoal.cs +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -17,7 +17,9 @@ public static class RunGoal 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. + // the widest angular gap between them, so it shares a point with none. Of exactly equal gaps the first in ascending angle + // wins (the <= below): placement is not saved, so this rule is what keeps the gate still between builds. A zone with no + // neighbours gets the +x axis. public static float2 ExitGatePosition(Zone zone, float wormholeDistanceRatio) { var radius = zone.Pack.Radius * wormholeDistanceRatio; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 745a6c2a2..d1107b959 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -190,6 +190,41 @@ public sealed partial class RunStartTests Assert.False(RunGoal.ExitOpen(exit)); } + // The gate is the exit zone's alone. The main galaxy puts bosses on choke points that are not its exit: such a zone's + // boss spawns and dies, and no gate opens, because the exit-zone condition is what keeps a slain boss from winning. + [Fact] + public void Exit_open_only_in_the_exit_zone() + { + var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); + var others = galaxy.BossZones.Values.Where(zone => zone != galaxy.Exit).Distinct().ToArray(); + Assert.NotEmpty(others); + foreach (var bossZone in others) + { + var zone = ZoneOf(galaxy, bossZone); + Assert.True(zone.Pack.BossSpawned); + foreach (var boss in zone.Entities.Where(entity => entity.IsBoss).ToArray()) + boss.HeatstrokeDeath.OnNext(Unit.Default); + Assert.False(RunGoal.BossAlive(zone)); + Assert.False(RunGoal.ExitOpen(zone)); + } + } + + // A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one. + [Fact] + public void Exit_sealed_when_no_ship_hull_is_on_offer() + { + var galaxy = DemoGalaxy(1); + foreach (var product in _cache.GetAll().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray()) + Assert.True(_cache.Remove(_cache.RefOf(product).Key)); + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost")); + Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); + } + // 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() @@ -265,4 +300,28 @@ public sealed partial class RunStartTests 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}"); } + + // Two neighbours straight across leave two gaps of exactly pi: the first in ascending angle (from -pi) wins, so the + // gate sits at +radius on y, not -radius. + [Fact] + public void Exit_gate_takes_the_first_of_equal_gaps() + { + var galaxy = DemoGalaxy(1); + var zone = ZoneOf(galaxy, galaxy.Exit); + const float ratio = 0.75f; + var originalPosition = galaxy.Exit.Position; + var origin = galaxy.Exit.Position = math.float2(500, -300); + var original = galaxy.Exit.AdjacentZones; + galaxy.Exit.AdjacentZones = new List + { + new GalaxyZone { Position = math.float2(510, -300) }, + new GalaxyZone { Position = math.float2(490, -300) }, + }; + float2 gate; + try { gate = RunGoal.ExitGatePosition(zone, ratio); } + finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; } + + Assert.Equal(0, gate.x, 3); + Assert.Equal(zone.Pack.Radius * ratio, gate.y, 2); + } }