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 <noreply@anthropic.com>
This commit is contained in:
parent
cb56557b4d
commit
c8d40eaf74
5 changed files with 59 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -182,6 +182,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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue