From ff75b8ea34181c62d252d9c32d9b77832f2159e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:09:12 +0200 Subject: [PATCH] Scenarios Cut 1: a stance survives docking; it goes only with an entity gone for good Every entity's zone-removal subscription dropped its IFF override for whatever left the zone, and TryDock removes the docking ship, so a scenario stance was lost one way (the other entity forgot; the docked ship, deactivated, kept its own) as soon as the player docked. The removal event already tells the two apart: TryDock parents the ship to its dock before removing it, and every leaving-for-good path (death, warp, unload) leaves with no parent. The override is now dropped only for an entity that leaves unparented. Tests: a hostile stance holds both ways, including the live hostility each side tracks, across dock and undock; a destroyed entity takes the player's stance toward it with it. UndockingAdmitsWithNoAgent commits Soul's probe: undock admits unpiloted, active, with no agent. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 50 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 078f2ebda..162848b9b 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -204,7 +204,9 @@ public abstract class Entity VisibleEntities.Remove(remove.Value); VisibleEnemies.Remove(remove.Value); VisibleFriendlies.Remove(remove.Value); - _iffOverrides.Remove(remove.Value); + // A stance lasts the sitting: it goes with an entity gone for good (destroyed, warped away, unloaded), not + // with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on undock. + if (remove.Value.Parent == null) _iffOverrides.Remove(remove.Value); if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs)) { foreach (var s in grudgeSubs) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5065e4398..0f93591ad 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -243,6 +243,56 @@ public sealed class RunStartTests : IDisposable Assert.False(neutral.EntityHostility[player]); } + // A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity + // gone for good: once an entity is destroyed, the player holds no stance toward it. + [Fact] + public void AStanceSurvivesDockingButNotDestruction() + { + var zone = Arena(null); + var longinus = Bare(Hull("LonginusX")); + var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0), + Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false), + Placed(longinus, -300, 0, ScenarioStance.Hostile, piloted: false))); + var player = staged.Player; + var hostile = staged.Entities[0]; + var doomed = staged.Entities[1]; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + Assert.True(player.IsHostileTo(hostile), "the player's stance was lost over docking"); + Assert.True(hostile.IsHostileTo(player), "the entity's stance was lost over docking"); + Assert.True(player.EntityHostility[hostile], "the player's live hostility was lost over docking"); + Assert.True(hostile.EntityHostility[player], "the entity's live hostility was lost over docking"); + + // Presets carry no faction, so without its stance the derived rule is not hostile + Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless"); + Assert.True(player.IsHostileTo(doomed)); + doomed.Hull.Durability = 0; + doomed.HullDamage.OnNext(1); + SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone"); + Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it"); + } + + // Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent. + [Fact] + public void UndockingAdmitsWithNoAgent() + { + var zone = Arena(null); + var player = Stage(zone, null).Player; + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var agents = zone.Agents.Count; + + Assert.NotNull(station.TryDock(player)); + SafeAssert.NotIn(zone, player, "a docked ship is out of the zone"); + Assert.True(station.TryUndock(player)); + SafeAssert.In(zone, player, "an undocked ship is back in the zone"); + Assert.Equal(agents, zone.Agents.Count); + Assert.Equal(0, AgentsOf(zone, player)); + Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active"); + } + // Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep // them. The arena is the galaxy's entrance pack. The entrance holds a story station, placed as the narrative would // place one, so a story station's own turrets are dropped too.