diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d6037b6d..f1a36036 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,9 +46,33 @@ Copy this block when adding a new issue: --- -## #207 — Repeat attack continues after movement begins +## #208 — Combat bar appears after logging in peacefully **Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate +**Severity:** MEDIUM +**Component:** retained UI / combat lifecycle + +**Description:** The basic attack bar appeared immediately after login even +though the character always enters the world in peace mode. + +**Root cause:** The combat controller mounted the bar hidden and correctly +followed `CombatState`, but the later per-character layout restore reapplied a +previously saved `Visible=true` bit. Window persistence therefore overruled the +mode-owned visibility from retail `gmCombatUI::RecvNotice_SetCombatMode`. + +**Resolution:** Retained-window persistence now distinguishes state-managed +visibility from user-managed visibility. The combat window still restores its +position, but ignores and no longer saves show/hide transitions. Character +session entry also resets `CombatState` to retail's initial NonCombat mode. + +**Research:** `docs/research/2026-07-11-retail-combat-bar-pseudocode.md` + +**Acceptance:** Log in while in peace mode: no attack bar is visible. Enter +melee or missile combat: it appears. Return to peace: it disappears. + +## #207 — Repeat attack continues after movement begins + +**Status:** DONE — 2026-07-12, user confirmed movement stops repeat attacks **Severity:** HIGH **Component:** combat / input @@ -73,7 +97,7 @@ attack loop stops immediately and movement proceeds normally. ## #206 — Relogging into a monster leaves the player stuck -**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate +**Status:** DONE — 2026-07-12, user confirmed occupied-position relog works **Severity:** HIGH **Component:** physics / login lifecycle diff --git a/docs/research/2026-07-11-retail-combat-bar-pseudocode.md b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md index db835127..a3d529ee 100644 --- a/docs/research/2026-07-11-retail-combat-bar-pseudocode.md +++ b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md @@ -65,6 +65,12 @@ The current acdream port binds the basic page because the persisted gameplay default has `AdvancedCombatUI = false`. The separate advanced-combat surface is still tracked by divergence row AP-110. +Visibility is combat-state-owned, not a user window preference. Per-character +window persistence may restore the bar's authored position, but must not restore +or save its visible bit: `RecvNotice_SetCombatMode` is the sole visibility +authority. Character-session initialization likewise starts at `NonCombat`, as +shown by `ClientCombatSystem::Begin` (`0x0056A460`). + ## Press / charge / release ```text diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 07ed89e0..eed616b5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2486,6 +2486,7 @@ public sealed class GameWindow : IDisposable _worldState.MarkPersistent(chosen.Id); AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); + Combat.Clear(); _liveSession.EnterWorld(user, characterIndex: 0); _activeToonKey = chosen.Name; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8c03695b..e4343b97 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -147,7 +147,8 @@ public sealed class RetailUiRuntime : IDisposable Host.WindowManager, persistence.Store, persistence.CharacterKey, - persistence.ScreenSize); + persistence.ScreenSize, + stateManagedVisibilityWindows: [WindowNames.Combat]); } if (bindings.Probe.Enabled) diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index e4b8eb28..3f5ff430 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -16,6 +16,7 @@ public sealed class RetailWindowLayoutPersistence : IDisposable private readonly SettingsStore _store; private readonly Func _characterKey; private readonly Func<(int Width, int Height)> _screenSize; + private readonly HashSet _stateManagedVisibilityWindows; private readonly List _attached = new(); private bool _restoring; private bool _disposed; @@ -24,12 +25,16 @@ public sealed class RetailWindowLayoutPersistence : IDisposable RetailWindowManager manager, SettingsStore store, Func characterKey, - Func<(int Width, int Height)> screenSize) + Func<(int Width, int Height)> screenSize, + IEnumerable? stateManagedVisibilityWindows = null) { _manager = manager ?? throw new ArgumentNullException(nameof(manager)); _store = store ?? throw new ArgumentNullException(nameof(store)); _characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey)); _screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize)); + _stateManagedVisibilityWindows = stateManagedVisibilityWindows is null + ? new HashSet(StringComparer.Ordinal) + : new HashSet(stateManagedVisibilityWindows, StringComparer.Ordinal); foreach (RetailWindowHandle handle in manager.Windows) Attach(handle); @@ -54,7 +59,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable character, resolution, handle.Name, fallback); if (saved is not { } layout) continue; - Apply(handle, layout, screen); + Apply( + handle, + layout, + screen, + restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name)); // Lazily migrate legacy position-only entries into the complete schema. _store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle)); } @@ -70,8 +79,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable _attached.Add(handle); handle.Moved += OnChanged; handle.Resized += OnChanged; - handle.Shown += OnChanged; - handle.Hidden += OnChanged; + if (!_stateManagedVisibilityWindows.Contains(handle.Name)) + { + handle.Shown += OnChanged; + handle.Hidden += OnChanged; + } } private void OnChanged(RetailWindowHandle handle) @@ -111,7 +123,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable private static void Apply( RetailWindowHandle handle, UiWindowLayout layout, - (int Width, int Height) screen) + (int Width, int Height) screen, + bool restoreVisibility) { UiElement frame = handle.OuterFrame; float width = ClampDimension(layout.Width, frame.Width, frame.MinWidth, frame.MaxWidth, screen.Width); @@ -128,8 +141,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable Collapsed: layout.Collapsed, Maximized: layout.Maximized)); - if (layout.Visible) handle.Show(); - else handle.Hide(); + if (restoreVisibility) + { + if (layout.Visible) handle.Show(); + else handle.Hide(); + } } private (int Width, int Height) ValidScreenSize() @@ -168,8 +184,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable { handle.Moved -= OnChanged; handle.Resized -= OnChanged; - handle.Shown -= OnChanged; - handle.Hidden -= OnChanged; + if (!_stateManagedVisibilityWindows.Contains(handle.Name)) + { + handle.Shown -= OnChanged; + handle.Hidden -= OnChanged; + } } _attached.Clear(); } diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 1143115e..4a18a3ae 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -170,5 +170,14 @@ public sealed class CombatState public void OnCombatCommenceAttack() => AttackCommenced?.Invoke(); - public void Clear() => _healthByGuid.Clear(); + /// + /// Reset character-session combat state. Retail + /// ClientCombatSystem::Begin (0x0056A460) initializes the mode to + /// peace/non-combat along with the transient attack state. + /// + public void Clear() + { + _healthByGuid.Clear(); + SetCombatMode(CombatMode.NonCombat); + } } diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index d97b96c8..287e0338 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -66,6 +66,39 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable Assert.Null(store.LoadWindowLayout("default", "800x600", "radar", default)); } + [Fact] + public void Restore_StateManagedWindow_PreservesAuthoritativeVisibility() + { + var store = new SettingsStore(PathName); + store.SaveWindowLayout( + "Alice", + "800x600", + "combat", + new UiWindowLayout(44f, 55f, 200f, 100f, true, false, false)); + + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = Mount(root, "combat"); + handle.Hide(); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, + store, + () => "Alice", + () => (800, 600), + stateManagedVisibilityWindows: ["combat"]); + + persistence.RestoreAll(); + + Assert.Equal((44f, 55f), (handle.Left, handle.Top)); + Assert.False(handle.IsVisible); + + // Combat mode owns subsequent show/hide transitions as well; they must + // not turn into saved user visibility preferences. + handle.Show(); + UiWindowLayout saved = Assert.IsType( + store.LoadWindowLayout("Alice", "800x600", "combat", default)); + Assert.False(saved.Visible); + } + private static RetailWindowHandle Mount( UiRoot root, string name, diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index 0478c69d..0a0ad587 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -128,15 +128,17 @@ public sealed class CombatStateTests } [Fact] - public void Clear_ResetsHealthCache() + public void Clear_ResetsCharacterSessionToPeaceMode() { var state = new CombatState(); state.OnUpdateHealth(1, 0.5f); state.OnUpdateHealth(2, 0.8f); + state.SetCombatMode(CombatMode.Melee); state.Clear(); Assert.Equal(1f, state.GetHealthPercent(1)); // back to default Assert.Equal(0, state.TrackedTargetCount); + Assert.Equal(CombatMode.NonCombat, state.CurrentMode); } }