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:
Erik 2026-07-12 22:30:55 +02:00
parent 00fe993f6f
commit 8e9c538519
8 changed files with 109 additions and 14 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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)

View file

@ -16,6 +16,7 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
private readonly SettingsStore _store;
private readonly Func<string> _characterKey;
private readonly Func<(int Width, int Height)> _screenSize;
private readonly HashSet<string> _stateManagedVisibilityWindows;
private readonly List<RetailWindowHandle> _attached = new();
private bool _restoring;
private bool _disposed;
@ -24,12 +25,16 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
RetailWindowManager manager,
SettingsStore store,
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));
_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<string>(StringComparer.Ordinal)
: new HashSet<string>(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,9 +79,12 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_attached.Add(handle);
handle.Moved += OnChanged;
handle.Resized += 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,9 +141,12 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
Collapsed: layout.Collapsed,
Maximized: layout.Maximized));
if (restoreVisibility)
{
if (layout.Visible) handle.Show();
else handle.Hide();
}
}
private (int Width, int Height) ValidScreenSize()
{
@ -168,9 +184,12 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
{
handle.Moved -= OnChanged;
handle.Resized -= OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown -= OnChanged;
handle.Hidden -= OnChanged;
}
}
_attached.Clear();
}
}

View file

@ -170,5 +170,14 @@ public sealed class CombatState
public void OnCombatCommenceAttack()
=> 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);
}
}

View file

@ -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<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", "combat", default));
Assert.False(saved.Visible);
}
private static RetailWindowHandle Mount(
UiRoot root,
string name,

View file

@ -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);
}
}