fix #208: keep combat bar hidden in peace mode
Treat combat-window visibility as gameplay-state owned so character layout restoration cannot replay a stale visible bit. Reset combat state to NonCombat at character-session entry, matching retail ClientCombatSystem::Begin. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
00fe993f6f
commit
8e9c538519
8 changed files with 109 additions and 14 deletions
|
|
@ -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
|
**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
|
**Severity:** HIGH
|
||||||
**Component:** combat / input
|
**Component:** combat / input
|
||||||
|
|
||||||
|
|
@ -73,7 +97,7 @@ attack loop stops immediately and movement proceeds normally.
|
||||||
|
|
||||||
## #206 — Relogging into a monster leaves the player stuck
|
## #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
|
**Severity:** HIGH
|
||||||
**Component:** physics / login lifecycle
|
**Component:** physics / login lifecycle
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
default has `AdvancedCombatUI = false`. The separate advanced-combat surface is
|
||||||
still tracked by divergence row AP-110.
|
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
|
## Press / charge / release
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -2486,6 +2486,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_worldState.MarkPersistent(chosen.Id);
|
_worldState.MarkPersistent(chosen.Id);
|
||||||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
|
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
|
||||||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
|
Combat.Clear();
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||||
|
|
||||||
_activeToonKey = chosen.Name;
|
_activeToonKey = chosen.Name;
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Host.WindowManager,
|
Host.WindowManager,
|
||||||
persistence.Store,
|
persistence.Store,
|
||||||
persistence.CharacterKey,
|
persistence.CharacterKey,
|
||||||
persistence.ScreenSize);
|
persistence.ScreenSize,
|
||||||
|
stateManagedVisibilityWindows: [WindowNames.Combat]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bindings.Probe.Enabled)
|
if (bindings.Probe.Enabled)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
private readonly SettingsStore _store;
|
private readonly SettingsStore _store;
|
||||||
private readonly Func<string> _characterKey;
|
private readonly Func<string> _characterKey;
|
||||||
private readonly Func<(int Width, int Height)> _screenSize;
|
private readonly Func<(int Width, int Height)> _screenSize;
|
||||||
|
private readonly HashSet<string> _stateManagedVisibilityWindows;
|
||||||
private readonly List<RetailWindowHandle> _attached = new();
|
private readonly List<RetailWindowHandle> _attached = new();
|
||||||
private bool _restoring;
|
private bool _restoring;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -24,12 +25,16 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
RetailWindowManager manager,
|
RetailWindowManager manager,
|
||||||
SettingsStore store,
|
SettingsStore store,
|
||||||
Func<string> characterKey,
|
Func<string> characterKey,
|
||||||
Func<(int Width, int Height)> screenSize)
|
Func<(int Width, int Height)> screenSize,
|
||||||
|
IEnumerable<string>? stateManagedVisibilityWindows = null)
|
||||||
{
|
{
|
||||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
_characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey));
|
_characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey));
|
||||||
_screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize));
|
_screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize));
|
||||||
|
_stateManagedVisibilityWindows = stateManagedVisibilityWindows is null
|
||||||
|
? new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
: new HashSet<string>(stateManagedVisibilityWindows, StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (RetailWindowHandle handle in manager.Windows)
|
foreach (RetailWindowHandle handle in manager.Windows)
|
||||||
Attach(handle);
|
Attach(handle);
|
||||||
|
|
@ -54,7 +59,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
character, resolution, handle.Name, fallback);
|
character, resolution, handle.Name, fallback);
|
||||||
if (saved is not { } layout) continue;
|
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.
|
// Lazily migrate legacy position-only entries into the complete schema.
|
||||||
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
|
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
|
||||||
}
|
}
|
||||||
|
|
@ -70,8 +79,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
_attached.Add(handle);
|
_attached.Add(handle);
|
||||||
handle.Moved += OnChanged;
|
handle.Moved += OnChanged;
|
||||||
handle.Resized += OnChanged;
|
handle.Resized += OnChanged;
|
||||||
handle.Shown += OnChanged;
|
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
|
||||||
handle.Hidden += OnChanged;
|
{
|
||||||
|
handle.Shown += OnChanged;
|
||||||
|
handle.Hidden += OnChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChanged(RetailWindowHandle handle)
|
private void OnChanged(RetailWindowHandle handle)
|
||||||
|
|
@ -111,7 +123,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
private static void Apply(
|
private static void Apply(
|
||||||
RetailWindowHandle handle,
|
RetailWindowHandle handle,
|
||||||
UiWindowLayout layout,
|
UiWindowLayout layout,
|
||||||
(int Width, int Height) screen)
|
(int Width, int Height) screen,
|
||||||
|
bool restoreVisibility)
|
||||||
{
|
{
|
||||||
UiElement frame = handle.OuterFrame;
|
UiElement frame = handle.OuterFrame;
|
||||||
float width = ClampDimension(layout.Width, frame.Width, frame.MinWidth, frame.MaxWidth, screen.Width);
|
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,
|
Collapsed: layout.Collapsed,
|
||||||
Maximized: layout.Maximized));
|
Maximized: layout.Maximized));
|
||||||
|
|
||||||
if (layout.Visible) handle.Show();
|
if (restoreVisibility)
|
||||||
else handle.Hide();
|
{
|
||||||
|
if (layout.Visible) handle.Show();
|
||||||
|
else handle.Hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private (int Width, int Height) ValidScreenSize()
|
private (int Width, int Height) ValidScreenSize()
|
||||||
|
|
@ -168,8 +184,11 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||||
{
|
{
|
||||||
handle.Moved -= OnChanged;
|
handle.Moved -= OnChanged;
|
||||||
handle.Resized -= OnChanged;
|
handle.Resized -= OnChanged;
|
||||||
handle.Shown -= OnChanged;
|
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
|
||||||
handle.Hidden -= OnChanged;
|
{
|
||||||
|
handle.Shown -= OnChanged;
|
||||||
|
handle.Hidden -= OnChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_attached.Clear();
|
_attached.Clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,5 +170,14 @@ public sealed class CombatState
|
||||||
public void OnCombatCommenceAttack()
|
public void OnCombatCommenceAttack()
|
||||||
=> AttackCommenced?.Invoke();
|
=> AttackCommenced?.Invoke();
|
||||||
|
|
||||||
public void Clear() => _healthByGuid.Clear();
|
/// <summary>
|
||||||
|
/// Reset character-session combat state. Retail
|
||||||
|
/// <c>ClientCombatSystem::Begin</c> (0x0056A460) initializes the mode to
|
||||||
|
/// peace/non-combat along with the transient attack state.
|
||||||
|
/// </summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_healthByGuid.Clear();
|
||||||
|
SetCombatMode(CombatMode.NonCombat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,39 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
||||||
Assert.Null(store.LoadWindowLayout("default", "800x600", "radar", default));
|
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<UiWindowLayout>(
|
||||||
|
store.LoadWindowLayout("Alice", "800x600", "combat", default));
|
||||||
|
Assert.False(saved.Visible);
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailWindowHandle Mount(
|
private static RetailWindowHandle Mount(
|
||||||
UiRoot root,
|
UiRoot root,
|
||||||
string name,
|
string name,
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,17 @@ public sealed class CombatStateTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Clear_ResetsHealthCache()
|
public void Clear_ResetsCharacterSessionToPeaceMode()
|
||||||
{
|
{
|
||||||
var state = new CombatState();
|
var state = new CombatState();
|
||||||
state.OnUpdateHealth(1, 0.5f);
|
state.OnUpdateHealth(1, 0.5f);
|
||||||
state.OnUpdateHealth(2, 0.8f);
|
state.OnUpdateHealth(2, 0.8f);
|
||||||
|
state.SetCombatMode(CombatMode.Melee);
|
||||||
|
|
||||||
state.Clear();
|
state.Clear();
|
||||||
|
|
||||||
Assert.Equal(1f, state.GetHealthPercent(1)); // back to default
|
Assert.Equal(1f, state.GetHealthPercent(1)); // back to default
|
||||||
Assert.Equal(0, state.TrackedTargetCount);
|
Assert.Equal(0, state.TrackedTargetCount);
|
||||||
|
Assert.Equal(CombatMode.NonCombat, state.CurrentMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue