feat(ui): port retail gameplay indicator bar
Promote all seven LayoutDesc 0x21000071 controls to retained buttons, drive link quality, effects, Vitae, and burden from live state, and route Character Information plus end-session confirmation through the shared UI owners. Keep network timing in WorldSession and pin retail thresholds, flash cadence, authored states, and action routing with focused conformance tests. Release build and all 5,807 tests pass with five intentional skips. Connected visual gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
06016014bc
commit
52c529be86
15 changed files with 857 additions and 204 deletions
|
|
@ -44,6 +44,39 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #224 — Gameplay indicator bar only implemented effect icons
|
||||
|
||||
**Status:** IN-PROGRESS
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-17
|
||||
**Component:** retained UI / gameplay indicators / session telemetry
|
||||
|
||||
**Description:** Retail LayoutDesc `0x21000071` is one seven-control strip for
|
||||
link quality, helpful effects, harmful effects, vitae, burden/character
|
||||
information, mini-game state, and end-character-session. acdream only promoted
|
||||
the two effects controls to behavioral buttons; the other custom classes fell
|
||||
back to inert generic DAT elements.
|
||||
|
||||
**Root cause / status:** `DatWidgetFactory` recognized only
|
||||
`gmUIElement_EffectsIndicator`. The retained controller consequently had no
|
||||
live bindings for session packet age, Vitae, or the player load ratio and did
|
||||
not dispatch Character Information or the shared logout confirmation path.
|
||||
|
||||
**Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`;
|
||||
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
|
||||
`src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`.
|
||||
|
||||
**Research:**
|
||||
`docs/research/2026-07-17-retail-indicator-bar-pseudocode.md`.
|
||||
|
||||
**Acceptance:** All seven authored controls render their retail sprites. Link
|
||||
quality follows the 5/20/40-second thresholds and bad-link flash cadence;
|
||||
helpful, harmful, Vitae, and burden react to live state; the burden icon opens
|
||||
Character Information; effects open their matching panels; crossed swords use
|
||||
the shared confirmation dialog and close gracefully only after acceptance.
|
||||
|
||||
---
|
||||
|
||||
## #223 — Primary panel resize stopped before the screen edge
|
||||
|
||||
**Status:** DONE — 2026-07-17 (connected visual gate accepted)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-07-16. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, authored effect-indicator buttons, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
|
||||
**Status:** Living document. Updated 2026-07-17. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, the complete authored seven-control gameplay indicator strip, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
|
||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||
|
||||
---
|
||||
|
|
@ -20,8 +20,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
|
|||
3,956-row CSV is no longer a production dependency.
|
||||
2. **Done (automated):** authored Magic combat page, eight favorite tabs,
|
||||
equipped caster endowment, scoped retail keys, spellbook/component book,
|
||||
positive/negative active-enchantment panels, Helpful/Harmful indicator
|
||||
buttons, and the retail one-active-main-panel lifecycle. Connected-gate
|
||||
positive/negative active-enchantment panels, the seven-control link/effect/
|
||||
Vitae/burden/mini-game/session indicator strip, and the retail
|
||||
one-active-main-panel lifecycle. Connected-gate
|
||||
correction now also pins type-12 spellbook tabs, retail zero text margins,
|
||||
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
|
||||
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
|
||||
|
|
|
|||
|
|
@ -706,7 +706,11 @@ targeted/untargeted messages. Live enchantment add/remove/dispel/purge state is
|
|||
fully parsed with layer identity and client-clock normalization. The authored
|
||||
Magic combat page, eight favorite tabs, caster endowment, spellbook/component
|
||||
book, scoped keys, positive/negative effects windows, and the authored
|
||||
Helpful/Harmful indicators are mounted in the retained runtime. Main-panel
|
||||
seven-control gameplay indicator strip are mounted in the retained runtime.
|
||||
The strip now uses retail DAT states for link quality, Helpful/Harmful effects,
|
||||
Vitae, burden, mini-game availability, and end-character-session; live link
|
||||
age, player load, and enchantment registries drive their retail thresholds and
|
||||
the burden/effect/logout actions use the shared panel/dialog owners. Main-panel
|
||||
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
|
||||
active only for the connected single-client magic
|
||||
UI/casting visual gate and the final two-client portal observer gate.
|
||||
|
|
|
|||
166
docs/research/2026-07-17-retail-indicator-bar-pseudocode.md
Normal file
166
docs/research/2026-07-17-retail-indicator-bar-pseudocode.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Retail gameplay indicator bar pseudocode (2026-07-17)
|
||||
|
||||
## Scope and oracle
|
||||
|
||||
This note covers retail LayoutDesc `0x21000071` (`gmIndicatorsUI`) and its
|
||||
seven 20x20 children. The behavior oracle is the named Sept-2013 retail
|
||||
decomp. The retained port continues to use the authored LayoutDesc states and
|
||||
sprites; code only chooses a state and dispatches the authored input action.
|
||||
|
||||
Named retail functions:
|
||||
|
||||
- `gmIndicatorsUI::ListenToElementMessage @ 0x004BFA90`
|
||||
- `gmUIElement_VitaeIndicator::PostInit @ 0x004E5EA0`
|
||||
- `gmUIElement_VitaeIndicator::Update @ 0x004E5FE0`
|
||||
- `gmUIElement_MiniGameIndicator::PostInit @ 0x004E6390`
|
||||
- `gmUIElement_MiniGameIndicator::RecvNotice_BeginGame @ 0x004E6450`
|
||||
- `gmUIElement_MiniGameIndicator::RecvNotice_EndGame @ 0x004E6470`
|
||||
- `gmUIElement_LinkStatusIndicator::SetLinkState @ 0x004E65E0`
|
||||
- `gmUIElement_LinkStatusIndicator::PostInit @ 0x004E66D0`
|
||||
- `gmUIElement_LinkStatusIndicator::UpdateLinkState @ 0x004E6730`
|
||||
- `gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0`
|
||||
- `gmUIElement_EffectsIndicator::PostInit @ 0x004E6930`
|
||||
- `gmUIElement_EffectsIndicator::Update @ 0x004E6A50`
|
||||
- `gmUIElement_BurdenIndicator::PostInit @ 0x004E6B80`
|
||||
- `gmUIElement_BurdenIndicator::Update @ 0x004E6CE0`
|
||||
- `LinkStatusHolder::GetConnectionStatus @ 0x00411380`
|
||||
- `LinkStatusHolder::OnHeartbeat @ 0x004113D0`
|
||||
- `gmEffectsUI::SpellEffectMatchesUIType @ 0x004B76C0`
|
||||
- `gmGamePlayUI::RecvNotice_EndCharacterSession @ 0x004EBEA0`
|
||||
|
||||
The installed retail LayoutDesc establishes the fixed child order and art:
|
||||
|
||||
| X | Element | Retail class | Meaning / input action |
|
||||
|---:|---:|---:|---|
|
||||
| 5 | `0x100000F8` | `0x10000003` | link status / `LinkStatusPanel` |
|
||||
| 25 | `0x100000F5` | `0x10000002` | beneficial enchantments / `HelpfulSpellsPanel` |
|
||||
| 45 | `0x100000F6` | `0x10000002` | harmful enchantments / `HarmfulSpellsPanel` |
|
||||
| 65 | `0x100000F4` | `0x10000006` | vitae / `VitaePanel` |
|
||||
| 85 | `0x100000F7` | `0x10000001` | burden / `CharacterInformationPanel` |
|
||||
| 105 | `0x100000F3` | `0x10000004` | mini-game |
|
||||
| 125 | `0x100000FA` | ordinary button | end character session |
|
||||
|
||||
## Pseudocode
|
||||
|
||||
### Link indicator
|
||||
|
||||
```text
|
||||
PostInit:
|
||||
base.PostInit()
|
||||
listen to global time notices
|
||||
semanticLinkState = Good
|
||||
SetState(Connection_good = 17)
|
||||
|
||||
On each global-time notice:
|
||||
if now - lastUpdate >= 4.0 seconds:
|
||||
(secondsSinceServerPacket, connected) = GetConnectionStatus()
|
||||
if !connected or secondsSinceServerPacket >= 40: Disconnected
|
||||
else if secondsSinceServerPacket >= 20: Bad
|
||||
else if secondsSinceServerPacket >= 5: Uncertain
|
||||
else: Good
|
||||
SetLinkState(result)
|
||||
lastUpdate = now
|
||||
|
||||
if semanticLinkState == Bad and now - lastFlash >= 0.75 seconds:
|
||||
toggle visual state between Connection_uncertain (18)
|
||||
and Connection_bad (19)
|
||||
lastFlash = now
|
||||
|
||||
SetLinkState(newState):
|
||||
if newState == semanticLinkState: return
|
||||
semanticLinkState = newState
|
||||
set authored state Good=17, Uncertain=18, Bad=19, Disconnected=20
|
||||
when entering Bad: lastFlash = now
|
||||
|
||||
OnHeartbeat:
|
||||
lastHeardFromCurrentServer = now
|
||||
packetLoss = linkAverages.averagePacketLoss
|
||||
```
|
||||
|
||||
acdream records the last successfully decoded server datagram in
|
||||
`WorldSession`; the retained controller reads an immutable link snapshot. This
|
||||
keeps socket/session facts out of the UI while preserving retail's thresholds
|
||||
and cadence.
|
||||
|
||||
### Helpful and harmful effects
|
||||
|
||||
```text
|
||||
On PlayerDesc or enchantment change:
|
||||
helpfulCount = 0
|
||||
harmfulCount = 0
|
||||
for each raw multiplicative/additive enchantment node:
|
||||
spell = lookup spell metadata
|
||||
if spell flags contain Beneficial: helpfulCount++
|
||||
else: harmfulCount++
|
||||
Helpful button = Normal when helpfulCount > 0, else Ghosted
|
||||
Harmful button = Normal when harmfulCount > 0, else Ghosted
|
||||
```
|
||||
|
||||
The indicator counts raw bucket-1/bucket-2 nodes. It does not use the effects
|
||||
panel's later spell-category duel projection.
|
||||
|
||||
### Vitae
|
||||
|
||||
```text
|
||||
On PlayerDesc or VitaeChanged:
|
||||
if enchantment registry has Vitae and vitae.modifier < 1.0:
|
||||
SetState(Normal = 1)
|
||||
else:
|
||||
SetState(Ghosted = 13)
|
||||
```
|
||||
|
||||
### Burden / character information
|
||||
|
||||
```text
|
||||
On PlayerDesc or LoadChanged:
|
||||
if InqLoad fails: load = absent
|
||||
if load is absent or load < 1.0: SetState(Unencumbered = 14)
|
||||
else if load < 2.0: SetState(Encumbered = 15)
|
||||
else: SetState(Heavily_encumbered = 16)
|
||||
|
||||
On click:
|
||||
dispatch CharacterInformationPanel
|
||||
```
|
||||
|
||||
`InqLoad` is the existing retail burden ratio:
|
||||
`EncumbranceVal / EncumbranceCapacity(Strength, augmentation)`.
|
||||
|
||||
### Mini-game
|
||||
|
||||
```text
|
||||
On BeginGame notice: SetState(Normal = 1)
|
||||
On EndGame notice: SetState(Ghosted = 13)
|
||||
```
|
||||
|
||||
The authored ghosted state is rendered now. The notices remain owned by the
|
||||
still-unported mini-game subsystem (existing divergence AP-110), so this task
|
||||
does not manufacture a game-active state from unrelated packets.
|
||||
|
||||
### End character session
|
||||
|
||||
```text
|
||||
When child 0x100000FA sends Click:
|
||||
SendNotice_EndCharacterSession(1)
|
||||
|
||||
RecvNotice_EndCharacterSession(1):
|
||||
build the shared logout confirmation dialog using
|
||||
ID_Client_EndCharacterSessionConfirm
|
||||
on acceptance, end the character session
|
||||
```
|
||||
|
||||
The acdream port reuses `RetailDialogFactory`; acceptance closes the current
|
||||
client, whose existing `WorldSession.Dispose` sends retail's graceful
|
||||
CharacterLogOff and transport Disconnect packets.
|
||||
|
||||
## Cross-checks and retained boundaries
|
||||
|
||||
- The already-ported `BurdenMath` matches retail
|
||||
`EncumbranceSystem::EncumbranceCapacity @ 0x004FCC00` and
|
||||
`EncumbranceSystem::Load @ 0x004FCC40`.
|
||||
- The existing enchantment parser preserves the separate Mult, Add, Vitae, and
|
||||
Cooldown registry buckets used by the retail indicator queries.
|
||||
- ACE/reference source is not present in this worktree. No server behavior is
|
||||
inferred for the strip: state is derived only from packets already parsed by
|
||||
the client, and the named retail client remains the behavior oracle.
|
||||
- Full Link Status, Vitae, and mini-game panels remain tracked by AP-110. This
|
||||
port does not replace them with an ad-hoc panel.
|
||||
|
|
@ -2310,6 +2310,17 @@ public sealed class GameWindow : IDisposable
|
|||
() => 1.0,
|
||||
() => _settingsVm?.DisplayDraft.ShowFps
|
||||
?? _persistedDisplay.ShowFps),
|
||||
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
|
||||
SpellBook,
|
||||
Objects,
|
||||
() => _playerServerGuid,
|
||||
() => LocalPlayer.GetAttribute(
|
||||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
|
||||
is { } strength ? (int?)strength.Current : null,
|
||||
() => _liveSession?.LinkStatus
|
||||
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
|
||||
ClientTimerNow,
|
||||
() => _window?.Close()),
|
||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||
Objects,
|
||||
() => Shortcuts,
|
||||
|
|
|
|||
|
|
@ -77,8 +77,13 @@ public static class DatWidgetFactory
|
|||
{
|
||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
||||
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
|
||||
EffectsIndicatorController.RetailClassId => BuildButton(
|
||||
info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_EffectsIndicator
|
||||
IndicatorBarController.BurdenClassId
|
||||
or IndicatorBarController.EffectsClassId
|
||||
or IndicatorBarController.LinkClassId
|
||||
or IndicatorBarController.MiniGameClassId
|
||||
or IndicatorBarController.VitaeClassId => BuildButton(
|
||||
info, resolve, elementFont, fontResolve, stringResolve),
|
||||
// gmUIElement_*Indicator custom button classes
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
|
||||
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
|
||||
/// reflect whether matching enchantments exist and send panel visibility notices
|
||||
/// for gmPanelUI slots 4 and 5.
|
||||
/// </summary>
|
||||
public sealed class EffectsIndicatorController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000071u;
|
||||
public const uint RetailClassId = 0x10000002u;
|
||||
public const uint HelpfulButtonId = 0x100000F5u;
|
||||
public const uint HarmfulButtonId = 0x100000F6u;
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly UiButton _helpful;
|
||||
private readonly UiButton _harmful;
|
||||
private readonly Action<uint> _togglePanel;
|
||||
private bool _disposed;
|
||||
|
||||
private EffectsIndicatorController(
|
||||
Spellbook spellbook,
|
||||
UiButton helpful,
|
||||
UiButton harmful,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_helpful = helpful;
|
||||
_harmful = harmful;
|
||||
_togglePanel = togglePanel;
|
||||
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
|
||||
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
|
||||
_spellbook.EnchantmentsChanged += Update;
|
||||
Update();
|
||||
}
|
||||
|
||||
public static EffectsIndicatorController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(togglePanel);
|
||||
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|
||||
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
|
||||
return null;
|
||||
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
bool helpful = HasMatchingEffect(beneficial: true);
|
||||
bool harmful = HasMatchingEffect(beneficial: false);
|
||||
_helpful.TrySetRetailState(helpful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
_harmful.TrySetRetailState(harmful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
private bool HasMatchingEffect(bool beneficial)
|
||||
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
|
||||
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
|
||||
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
|
||||
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
|
||||
// by spell category first.
|
||||
record.Bucket is 1u or 2u
|
||||
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
|
||||
&& metadata.IsBeneficial == beneficial);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.EnchantmentsChanged -= Update;
|
||||
_helpful.OnClick = null;
|
||||
_harmful.OnClick = null;
|
||||
}
|
||||
}
|
||||
275
src/AcDream.App/UI/Layout/IndicatorBarController.cs
Normal file
275
src/AcDream.App/UI/Layout/IndicatorBarController.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained port of retail <c>gmIndicatorsUI</c> LayoutDesc
|
||||
/// <c>0x21000071</c> and its custom indicator buttons. The DAT owns geometry,
|
||||
/// sprites, and state names; this controller owns only live state selection and
|
||||
/// authored input actions.
|
||||
/// </summary>
|
||||
public sealed class IndicatorBarController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000071u;
|
||||
|
||||
public const uint BurdenClassId = 0x10000001u;
|
||||
public const uint EffectsClassId = 0x10000002u;
|
||||
public const uint LinkClassId = 0x10000003u;
|
||||
public const uint MiniGameClassId = 0x10000004u;
|
||||
public const uint VitaeClassId = 0x10000006u;
|
||||
|
||||
public const uint MiniGameButtonId = 0x100000F3u;
|
||||
public const uint VitaeButtonId = 0x100000F4u;
|
||||
public const uint HelpfulButtonId = 0x100000F5u;
|
||||
public const uint HarmfulButtonId = 0x100000F6u;
|
||||
public const uint BurdenButtonId = 0x100000F7u;
|
||||
public const uint LinkButtonId = 0x100000F8u;
|
||||
public const uint EndCharacterSessionButtonId = 0x100000FAu;
|
||||
|
||||
public const uint UnencumberedState = 14u;
|
||||
public const uint EncumberedState = 15u;
|
||||
public const uint HeavilyEncumberedState = 16u;
|
||||
public const uint ConnectionGoodState = 17u;
|
||||
public const uint ConnectionUncertainState = 18u;
|
||||
public const uint ConnectionBadState = 19u;
|
||||
public const uint ConnectionDisconnectedState = 20u;
|
||||
|
||||
private const uint EncumbranceValProperty = 5u;
|
||||
private const uint EncumbranceAugProperty = 0xE6u;
|
||||
private const double LinkUpdateSeconds = 4.0;
|
||||
private const double LinkFlashSeconds = 0.75;
|
||||
|
||||
private readonly IndicatorBarBindings _bindings;
|
||||
private readonly UiButton _link;
|
||||
private readonly UiButton _helpful;
|
||||
private readonly UiButton _harmful;
|
||||
private readonly UiButton _vitae;
|
||||
private readonly UiButton _burden;
|
||||
private readonly UiButton _miniGame;
|
||||
private readonly UiButton _endCharacterSession;
|
||||
private LinkSemanticState _linkState = LinkSemanticState.Good;
|
||||
private double _lastLinkUpdate;
|
||||
private double _lastLinkFlash;
|
||||
private bool _disposed;
|
||||
|
||||
private enum LinkSemanticState
|
||||
{
|
||||
Good = 1,
|
||||
Uncertain = 2,
|
||||
Bad = 3,
|
||||
Disconnected = 4,
|
||||
}
|
||||
|
||||
private IndicatorBarController(
|
||||
IndicatorBarBindings bindings,
|
||||
UiButton link,
|
||||
UiButton helpful,
|
||||
UiButton harmful,
|
||||
UiButton vitae,
|
||||
UiButton burden,
|
||||
UiButton miniGame,
|
||||
UiButton endCharacterSession)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_link = link;
|
||||
_helpful = helpful;
|
||||
_harmful = harmful;
|
||||
_vitae = vitae;
|
||||
_burden = burden;
|
||||
_miniGame = miniGame;
|
||||
_endCharacterSession = endCharacterSession;
|
||||
|
||||
_helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects);
|
||||
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
|
||||
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
|
||||
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
|
||||
|
||||
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments;
|
||||
bindings.Objects.ObjectAdded += OnObjectChanged;
|
||||
bindings.Objects.ObjectUpdated += OnObjectChanged;
|
||||
bindings.Objects.ObjectRemoved += OnObjectChanged;
|
||||
bindings.Objects.ObjectMoved += OnObjectMoved;
|
||||
bindings.Objects.ContainerContentsReplaced += OnContainerContentsReplaced;
|
||||
bindings.Objects.Cleared += OnObjectsCleared;
|
||||
|
||||
// gmUIElement_LinkStatusIndicator::PostInit @ 0x004E66D0 starts
|
||||
// Good and does not query until the four-second time notice cadence.
|
||||
double now = bindings.CurrentTime();
|
||||
_lastLinkUpdate = now;
|
||||
_lastLinkFlash = now;
|
||||
_link.TrySetRetailState(ConnectionGoodState);
|
||||
_miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted);
|
||||
UpdateEnchantments();
|
||||
UpdateBurden();
|
||||
}
|
||||
|
||||
public static IndicatorBarController? Bind(
|
||||
ImportedLayout layout,
|
||||
IndicatorBarBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (layout.FindElement(LinkButtonId) is not UiButton link
|
||||
|| layout.FindElement(HelpfulButtonId) is not UiButton helpful
|
||||
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful
|
||||
|| layout.FindElement(VitaeButtonId) is not UiButton vitae
|
||||
|| layout.FindElement(BurdenButtonId) is not UiButton burden
|
||||
|| layout.FindElement(MiniGameButtonId) is not UiButton miniGame
|
||||
|| layout.FindElement(EndCharacterSessionButtonId) is not UiButton endSession)
|
||||
return null;
|
||||
|
||||
return new IndicatorBarController(
|
||||
bindings, link, helpful, harmful, vitae, burden, miniGame, endSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0</c>.
|
||||
/// The absolute monotonic clock is injected so the retail 4.0/0.75-second
|
||||
/// boundaries are deterministic in tests.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
double now = _bindings.CurrentTime();
|
||||
if (now - _lastLinkUpdate >= LinkUpdateSeconds)
|
||||
{
|
||||
UpdateLinkState(now, _bindings.LinkStatus());
|
||||
_lastLinkUpdate = now;
|
||||
}
|
||||
|
||||
if (_linkState == LinkSemanticState.Bad
|
||||
&& now - _lastLinkFlash >= LinkFlashSeconds)
|
||||
{
|
||||
_link.TrySetRetailState(
|
||||
_link.ActiveRetailStateId == ConnectionUncertainState
|
||||
? ConnectionBadState
|
||||
: ConnectionUncertainState);
|
||||
_lastLinkFlash = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail BeginGame/EndGame notice surface for the future mini-game owner.</summary>
|
||||
public void SetMiniGameActive(bool active)
|
||||
=> _miniGame.TrySetRetailState(
|
||||
active ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted);
|
||||
|
||||
private void UpdateLinkState(double now, LinkStatusSnapshot snapshot)
|
||||
{
|
||||
// gmUIElement_LinkStatusIndicator::UpdateLinkState @ 0x004E6730.
|
||||
LinkSemanticState state = !snapshot.Connected || snapshot.SecondsSinceLastPacket >= 40d
|
||||
? LinkSemanticState.Disconnected
|
||||
: snapshot.SecondsSinceLastPacket >= 20d
|
||||
? LinkSemanticState.Bad
|
||||
: snapshot.SecondsSinceLastPacket >= 5d
|
||||
? LinkSemanticState.Uncertain
|
||||
: LinkSemanticState.Good;
|
||||
SetLinkState(state, now);
|
||||
}
|
||||
|
||||
private void SetLinkState(LinkSemanticState state, double now)
|
||||
{
|
||||
// gmUIElement_LinkStatusIndicator::SetLinkState @ 0x004E65E0 does
|
||||
// nothing for an unchanged semantic state (including an active flash).
|
||||
if (_linkState == state) return;
|
||||
_linkState = state;
|
||||
uint visual = state switch
|
||||
{
|
||||
LinkSemanticState.Good => ConnectionGoodState,
|
||||
LinkSemanticState.Uncertain => ConnectionUncertainState,
|
||||
LinkSemanticState.Bad => ConnectionBadState,
|
||||
LinkSemanticState.Disconnected => ConnectionDisconnectedState,
|
||||
_ => throw new InvalidOperationException($"Unknown link state {state}."),
|
||||
};
|
||||
_link.TrySetRetailState(visual);
|
||||
if (state == LinkSemanticState.Bad)
|
||||
_lastLinkFlash = now;
|
||||
}
|
||||
|
||||
private void UpdateEnchantments()
|
||||
{
|
||||
_helpful.TrySetRetailState(HasMatchingEffect(beneficial: true)
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
_harmful.TrySetRetailState(HasMatchingEffect(beneficial: false)
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
|
||||
// gmUIElement_VitaeIndicator::Update @ 0x004E5FE0 enables the
|
||||
// indicator only when the singleton Vitae modifier is below 1.0.
|
||||
bool hasVitae = _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record =>
|
||||
record.Bucket == 4u
|
||||
&& record.StatModValue is float modifier
|
||||
&& modifier < 1f);
|
||||
_vitae.TrySetRetailState(hasVitae
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
private bool HasMatchingEffect(bool beneficial)
|
||||
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this split.
|
||||
=> _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record =>
|
||||
// CountSpellsInList @ 0x00593CD0 scans raw mult/add nodes; it
|
||||
// does not use the effects-list spell-category duel projection.
|
||||
record.Bucket is 1u or 2u
|
||||
&& _bindings.Spellbook.TryGetMetadata(
|
||||
record.SpellId, out SpellMetadata metadata)
|
||||
&& metadata.IsBeneficial == beneficial);
|
||||
|
||||
private void OnObjectChanged(ClientObject _) => UpdateBurden();
|
||||
private void OnObjectMoved(ClientObjectMove _) => UpdateBurden();
|
||||
private void OnContainerContentsReplaced(uint _) => UpdateBurden();
|
||||
private void OnObjectsCleared() => UpdateBurden();
|
||||
|
||||
private void UpdateBurden()
|
||||
{
|
||||
uint player = _bindings.PlayerGuid();
|
||||
ClientObject? playerObject = _bindings.Objects.Get(player);
|
||||
int strength = _bindings.Strength() ?? 10;
|
||||
int augmentation = playerObject?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
|
||||
int capacity = BurdenMath.EncumbranceCapacity(strength, augmentation);
|
||||
int burden = playerObject?.Properties.Ints.TryGetValue(
|
||||
EncumbranceValProperty, out int wireBurden) == true
|
||||
? wireBurden
|
||||
: _bindings.Objects.SumCarriedBurden(player);
|
||||
float load = BurdenMath.LoadRatio(capacity, burden);
|
||||
|
||||
// gmUIElement_BurdenIndicator::Update @ 0x004E6CE0.
|
||||
_burden.TrySetRetailState(load < 1f
|
||||
? UnencumberedState
|
||||
: load < 2f ? EncumberedState : HeavilyEncumberedState);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments;
|
||||
_bindings.Objects.ObjectAdded -= OnObjectChanged;
|
||||
_bindings.Objects.ObjectUpdated -= OnObjectChanged;
|
||||
_bindings.Objects.ObjectRemoved -= OnObjectChanged;
|
||||
_bindings.Objects.ObjectMoved -= OnObjectMoved;
|
||||
_bindings.Objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_bindings.Objects.Cleared -= OnObjectsCleared;
|
||||
_link.OnClick = null;
|
||||
_helpful.OnClick = null;
|
||||
_harmful.OnClick = null;
|
||||
_vitae.OnClick = null;
|
||||
_burden.OnClick = null;
|
||||
_miniGame.OnClick = null;
|
||||
_endCharacterSession.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IndicatorBarBindings(
|
||||
Spellbook Spellbook,
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
Func<int?> Strength,
|
||||
Func<LinkStatusSnapshot> LinkStatus,
|
||||
Func<double> CurrentTime,
|
||||
Action<uint> TogglePanel,
|
||||
Action RequestEndCharacterSession);
|
||||
|
|
@ -8,6 +8,7 @@ using AcDream.App.UI.Layout;
|
|||
using AcDream.App.UI.Testing;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
|
|
@ -73,6 +74,15 @@ public sealed record FpsRuntimeBindings(
|
|||
Func<double> DegradeMultiplier,
|
||||
Func<bool> IsVisible);
|
||||
|
||||
public sealed record IndicatorRuntimeBindings(
|
||||
Spellbook Spellbook,
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
Func<int?> Strength,
|
||||
Func<LinkStatusSnapshot> LinkStatus,
|
||||
Func<double> CurrentTime,
|
||||
Action EndCharacterSession);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
|
||||
|
|
@ -142,6 +152,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
MagicRuntimeBindings Magic,
|
||||
JumpPowerbarRuntimeBindings JumpPowerbar,
|
||||
FpsRuntimeBindings Fps,
|
||||
IndicatorRuntimeBindings Indicators,
|
||||
ToolbarRuntimeBindings Toolbar,
|
||||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
|
|
@ -234,7 +245,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { get; private set; }
|
||||
public EffectsUiController? NegativeEffectsController { get; private set; }
|
||||
public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
|
||||
public IndicatorBarController? IndicatorBarController { get; private set; }
|
||||
public JumpPowerbarController? JumpPowerbarController { get; private set; }
|
||||
public RetailFpsController? FpsController { get; private set; }
|
||||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
|
|
@ -263,6 +274,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
SpellcastingUiController?.Tick();
|
||||
PositiveEffectsController?.Tick();
|
||||
NegativeEffectsController?.Tick();
|
||||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
DialogFactory?.Tick();
|
||||
|
|
@ -894,24 +906,32 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void MountIndicators()
|
||||
{
|
||||
ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
|
||||
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
|
||||
Console.WriteLine("[UI] indicator bar: LayoutDesc 0x21000071 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
|
||||
IndicatorRuntimeBindings b = _bindings.Indicators;
|
||||
IndicatorBarController? controller = Layout.IndicatorBarController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
panelId => _panelUi.TogglePanel(panelId));
|
||||
new IndicatorBarBindings(
|
||||
b.Spellbook,
|
||||
b.Objects,
|
||||
b.PlayerGuid,
|
||||
b.Strength,
|
||||
b.LinkStatus,
|
||||
b.CurrentTime,
|
||||
panelId => _panelUi.TogglePanel(panelId),
|
||||
RequestEndCharacterSession));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
|
||||
Console.WriteLine("[UI] indicator bar: one or more authored controls are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
EffectsIndicatorController = controller;
|
||||
IndicatorBarController = controller;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
layout.Root,
|
||||
|
|
@ -930,7 +950,26 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
|
||||
Console.WriteLine("[UI] retail seven-control indicator bar from LayoutDesc 0x21000071.");
|
||||
}
|
||||
|
||||
private void RequestEndCharacterSession()
|
||||
{
|
||||
const string fallback = "Are you sure you want to end this character session?";
|
||||
string message;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
message = new DatStringResolver(_bindings.Assets.Dats).Resolve(
|
||||
0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
|
||||
?? fallback;
|
||||
}
|
||||
|
||||
ShowConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted)
|
||||
_bindings.Indicators.EndCharacterSession();
|
||||
});
|
||||
}
|
||||
|
||||
private void MountJumpPowerbar()
|
||||
|
|
|
|||
13
src/AcDream.Core.Net/LinkStatusSnapshot.cs
Normal file
13
src/AcDream.Core.Net/LinkStatusSnapshot.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable network-owned input for retail's link-status presentation.
|
||||
/// <see cref="WorldSession"/> records the last successfully decoded server
|
||||
/// datagram; UI code applies retail's display thresholds and cadence.
|
||||
/// </summary>
|
||||
public readonly record struct LinkStatusSnapshot(
|
||||
bool Connected,
|
||||
double SecondsSinceLastPacket)
|
||||
{
|
||||
public static LinkStatusSnapshot Disconnected => new(false, 0d);
|
||||
}
|
||||
|
|
@ -519,6 +519,32 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
public State CurrentState { get; private set; } = State.Disconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Network-owned source for retail
|
||||
/// <c>LinkStatusHolder::GetConnectionStatus @ 0x00411380</c>. The age is
|
||||
/// measured from the last successfully decoded server datagram using the
|
||||
/// monotonic Stopwatch clock; presentation thresholds remain in the UI.
|
||||
/// </summary>
|
||||
public LinkStatusSnapshot LinkStatus => BuildLinkStatus(
|
||||
CurrentState,
|
||||
Volatile.Read(ref _lastInboundPacketTicks),
|
||||
Stopwatch.GetTimestamp(),
|
||||
Stopwatch.Frequency);
|
||||
|
||||
internal static LinkStatusSnapshot BuildLinkStatus(
|
||||
State state,
|
||||
long lastInboundPacketTicks,
|
||||
long nowTicks,
|
||||
long frequency)
|
||||
{
|
||||
bool connected = state is not State.Disconnected and not State.Failed;
|
||||
if (!connected || frequency <= 0)
|
||||
return LinkStatusSnapshot.Disconnected;
|
||||
|
||||
long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
|
||||
return new LinkStatusSnapshot(true, elapsed / (double)frequency);
|
||||
}
|
||||
|
||||
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
|
||||
public ushort InstanceSequence => _instanceSequence;
|
||||
public ushort ServerControlSequence => _serverControlSequence;
|
||||
|
|
@ -547,6 +573,7 @@ public sealed class WorldSession : IDisposable
|
|||
public CharacterList.Parsed? Characters { get; private set; }
|
||||
|
||||
private readonly NetClient _net;
|
||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||
private readonly IPEndPoint _loginEndpoint;
|
||||
private readonly IPEndPoint _connectEndpoint;
|
||||
private readonly FragmentAssembler _assembler = new();
|
||||
|
|
@ -852,6 +879,11 @@ public sealed class WorldSession : IDisposable
|
|||
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
|
||||
if (!dec.IsOk) return;
|
||||
|
||||
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
|
||||
// last-heard clock only for valid server traffic. Record at decode
|
||||
// acceptance, before any heavy render-thread message handling.
|
||||
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
|
||||
|
||||
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
|
||||
// server packet with sequence > 0 and no ACK flag of its own. This
|
||||
// is the proper holtburger pattern (every received packet gets an
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class EffectsIndicatorControllerTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void AuthoredFixture_BuildsRetailIndicatorButtons()
|
||||
{
|
||||
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
|
||||
Assert.Equal(0x10000610u, info.Id);
|
||||
Assert.Equal(150f, info.Width);
|
||||
Assert.Equal(30f, info.Height);
|
||||
|
||||
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
|
||||
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enchantments_GhostAndEnableMatchingIndicator_ThenClickCorrectPanel()
|
||||
{
|
||||
var table = SpellTable.LoadFromReader(new StringReader(
|
||||
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n"));
|
||||
var spellbook = new Spellbook(table);
|
||||
ImportedLayout layout = BuildSyntheticLayout();
|
||||
var toggled = new List<uint>();
|
||||
using EffectsIndicatorController controller = EffectsIndicatorController.Bind(
|
||||
layout, spellbook, toggled.Add)!;
|
||||
UiButton helpful = Assert.IsType<UiButton>(
|
||||
layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
|
||||
UiButton harmful = Assert.IsType<UiButton>(
|
||||
layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
|
||||
|
||||
Assert.False(helpful.Enabled);
|
||||
Assert.False(harmful.Enabled);
|
||||
|
||||
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
42u, 10u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
|
||||
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
43u, 11u, 60f, 2u, Bucket: 8u, SpellCategory: 43u));
|
||||
Assert.False(helpful.Enabled);
|
||||
Assert.False(harmful.Enabled);
|
||||
|
||||
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u, PowerLevel: 1u));
|
||||
Assert.True(helpful.Enabled);
|
||||
Assert.False(harmful.Enabled);
|
||||
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
|
||||
Assert.Equal([RetailPanelCatalog.PositiveEffects], toggled);
|
||||
|
||||
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
43u, 1u, 60f, 2u, Bucket: 2u, SpellCategory: 99u, PowerLevel: 7u));
|
||||
// The harmful spell wins the shared category in the effects list, but
|
||||
// retail's raw registry counters keep both indicator types enabled.
|
||||
Assert.True(helpful.Enabled);
|
||||
Assert.True(harmful.Enabled);
|
||||
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
|
||||
Assert.Equal(
|
||||
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
|
||||
toggled);
|
||||
|
||||
spellbook.OnEnchantmentRemoved(1u, 42u);
|
||||
Assert.False(helpful.Enabled);
|
||||
Assert.True(harmful.Enabled);
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildSyntheticLayout()
|
||||
{
|
||||
var root = new ElementInfo { Id = 1u, Type = 3u, Width = 150f, Height = 30f };
|
||||
var helpful = ButtonInfo(EffectsIndicatorController.HelpfulButtonId);
|
||||
var harmful = ButtonInfo(EffectsIndicatorController.HarmfulButtonId);
|
||||
return LayoutImporter.BuildFromInfos(root, [helpful, harmful], NoTex, null);
|
||||
}
|
||||
|
||||
private static ElementInfo ButtonInfo(uint id)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = id,
|
||||
Type = EffectsIndicatorController.RetailClassId,
|
||||
Width = 20f,
|
||||
Height = 20f,
|
||||
DefaultStateId = UiButtonStateMachine.Normal,
|
||||
};
|
||||
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
|
||||
{
|
||||
Id = UiButtonStateMachine.Normal,
|
||||
Name = "Normal",
|
||||
};
|
||||
info.States[UiButtonStateMachine.Ghosted] = new UiStateInfo
|
||||
{
|
||||
Id = UiButtonStateMachine.Ghosted,
|
||||
Name = "Ghosted",
|
||||
};
|
||||
return info;
|
||||
}
|
||||
}
|
||||
216
tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs
Normal file
216
tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class IndicatorBarControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void AuthoredFixture_BuildsAllSevenRetailIndicatorButtons()
|
||||
{
|
||||
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
|
||||
Assert.Equal(0x10000610u, info.Id);
|
||||
Assert.Equal(150f, info.Width);
|
||||
Assert.Equal(30f, info.Height);
|
||||
|
||||
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
|
||||
uint[] ids =
|
||||
[
|
||||
IndicatorBarController.LinkButtonId,
|
||||
IndicatorBarController.HelpfulButtonId,
|
||||
IndicatorBarController.HarmfulButtonId,
|
||||
IndicatorBarController.VitaeButtonId,
|
||||
IndicatorBarController.BurdenButtonId,
|
||||
IndicatorBarController.MiniGameButtonId,
|
||||
IndicatorBarController.EndCharacterSessionButtonId,
|
||||
];
|
||||
foreach (uint id in ids)
|
||||
Assert.IsType<UiButton>(layout.FindElement(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enchantments_UpdateHelpfulHarmfulAndVitae_AndDispatchPanels()
|
||||
{
|
||||
var h = CreateHarness();
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
UiButton helpful = h.Button(IndicatorBarController.HelpfulButtonId);
|
||||
UiButton harmful = h.Button(IndicatorBarController.HarmfulButtonId);
|
||||
UiButton vitae = h.Button(IndicatorBarController.VitaeButtonId);
|
||||
|
||||
Assert.False(helpful.Enabled);
|
||||
Assert.False(harmful.Enabled);
|
||||
Assert.False(vitae.Enabled);
|
||||
|
||||
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u));
|
||||
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
43u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 99u));
|
||||
Assert.True(helpful.Enabled);
|
||||
Assert.True(harmful.Enabled);
|
||||
|
||||
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
|
||||
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
|
||||
Assert.Equal(
|
||||
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
|
||||
h.ToggledPanels);
|
||||
|
||||
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
50u, 3u, 60f, 1u, StatModValue: 1f, Bucket: 4u));
|
||||
Assert.False(vitae.Enabled);
|
||||
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
|
||||
Assert.True(vitae.Enabled);
|
||||
Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId);
|
||||
|
||||
h.Spellbook.OnEnchantmentRemoved(3u, 50u);
|
||||
Assert.False(vitae.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1499, IndicatorBarController.UnencumberedState)]
|
||||
[InlineData(1500, IndicatorBarController.EncumberedState)]
|
||||
[InlineData(2999, IndicatorBarController.EncumberedState)]
|
||||
[InlineData(3000, IndicatorBarController.HeavilyEncumberedState)]
|
||||
public void Burden_UsesExactRetailLoadBoundaries(int burden, uint expectedState)
|
||||
{
|
||||
var h = CreateHarness(strength: 10);
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
h.Objects.UpdateIntProperty(Player, 5u, burden);
|
||||
|
||||
UiButton button = h.Button(IndicatorBarController.BurdenButtonId);
|
||||
Assert.Equal(expectedState, button.ActiveRetailStateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_ClickOpensCharacterInformationPanel()
|
||||
{
|
||||
var h = CreateHarness();
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
UiButton burden = h.Button(IndicatorBarController.BurdenButtonId);
|
||||
|
||||
burden.OnEvent(new UiEvent(0, burden, UiEventType.Click));
|
||||
|
||||
Assert.Equal([RetailPanelCatalog.Character], h.ToggledPanels);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, 4.999, IndicatorBarController.ConnectionGoodState)]
|
||||
[InlineData(true, 5.0, IndicatorBarController.ConnectionUncertainState)]
|
||||
[InlineData(true, 19.999, IndicatorBarController.ConnectionUncertainState)]
|
||||
[InlineData(true, 20.0, IndicatorBarController.ConnectionBadState)]
|
||||
[InlineData(true, 39.999, IndicatorBarController.ConnectionBadState)]
|
||||
[InlineData(true, 40.0, IndicatorBarController.ConnectionDisconnectedState)]
|
||||
[InlineData(false, 0.0, IndicatorBarController.ConnectionDisconnectedState)]
|
||||
public void LinkStatus_UsesRetailThresholds(
|
||||
bool connected,
|
||||
double age,
|
||||
uint expectedState)
|
||||
{
|
||||
var h = CreateHarness();
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
h.LinkStatus = new LinkStatusSnapshot(connected, age);
|
||||
h.Time = 4.0;
|
||||
|
||||
controller.Tick();
|
||||
|
||||
Assert.Equal(
|
||||
expectedState,
|
||||
h.Button(IndicatorBarController.LinkButtonId).ActiveRetailStateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinkStatus_UpdatesAtFourSeconds_AndBadFlashesEveryPointSevenFive()
|
||||
{
|
||||
var h = CreateHarness();
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
UiButton link = h.Button(IndicatorBarController.LinkButtonId);
|
||||
h.LinkStatus = new LinkStatusSnapshot(true, 20d);
|
||||
|
||||
h.Time = 3.999;
|
||||
controller.Tick();
|
||||
Assert.Equal(IndicatorBarController.ConnectionGoodState, link.ActiveRetailStateId);
|
||||
|
||||
h.Time = 4.0;
|
||||
controller.Tick();
|
||||
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
|
||||
|
||||
h.Time = 4.749;
|
||||
controller.Tick();
|
||||
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
|
||||
|
||||
h.Time = 4.75;
|
||||
controller.Tick();
|
||||
Assert.Equal(IndicatorBarController.ConnectionUncertainState, link.ActiveRetailStateId);
|
||||
|
||||
h.Time = 5.5;
|
||||
controller.Tick();
|
||||
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MiniGameNoticesAndEndSessionActionUseAuthoredButtons()
|
||||
{
|
||||
var h = CreateHarness();
|
||||
using IndicatorBarController controller = h.Controller;
|
||||
UiButton miniGame = h.Button(IndicatorBarController.MiniGameButtonId);
|
||||
UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
|
||||
|
||||
Assert.False(miniGame.Enabled);
|
||||
controller.SetMiniGameActive(true);
|
||||
Assert.True(miniGame.Enabled);
|
||||
controller.SetMiniGameActive(false);
|
||||
Assert.False(miniGame.Enabled);
|
||||
|
||||
endSession.OnEvent(new UiEvent(0, endSession, UiEventType.Click));
|
||||
Assert.Equal(1, h.EndSessionRequests);
|
||||
}
|
||||
|
||||
private static Harness CreateHarness(int strength = 10)
|
||||
{
|
||||
var table = SpellTable.LoadFromReader(new StringReader(
|
||||
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n50,Vitae,0x0\n"));
|
||||
var spellbook = new Spellbook(table);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadIndicatorsInfos(), NoTex, datFont: null);
|
||||
var harness = new Harness(layout, spellbook, objects, strength);
|
||||
harness.Controller = IndicatorBarController.Bind(
|
||||
layout,
|
||||
new IndicatorBarBindings(
|
||||
spellbook,
|
||||
objects,
|
||||
() => Player,
|
||||
() => strength,
|
||||
() => harness.LinkStatus,
|
||||
() => harness.Time,
|
||||
harness.ToggledPanels.Add,
|
||||
() => harness.EndSessionRequests++))!;
|
||||
return harness;
|
||||
}
|
||||
|
||||
private sealed class Harness(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
int strength)
|
||||
{
|
||||
public ImportedLayout Layout { get; } = layout;
|
||||
public Spellbook Spellbook { get; } = spellbook;
|
||||
public ClientObjectTable Objects { get; } = objects;
|
||||
public int Strength { get; } = strength;
|
||||
public List<uint> ToggledPanels { get; } = [];
|
||||
public double Time { get; set; }
|
||||
public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d);
|
||||
public int EndSessionRequests { get; set; }
|
||||
public IndicatorBarController Controller { get; set; } = null!;
|
||||
|
||||
public UiButton Button(uint id) => Assert.IsType<UiButton>(Layout.FindElement(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
(0x2100002Eu, "character_2100002E.json"),
|
||||
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
|
||||
(0x2100006Cu, "vitals_2100006C.json"),
|
||||
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
|
||||
(IndicatorBarController.LayoutId, "indicators_21000071.json"),
|
||||
(0x21000072u, "powerbar_21000072.json"),
|
||||
(0x21000073u, "combat_21000073.json"),
|
||||
(0x21000074u, "radar_21000074.json"),
|
||||
|
|
|
|||
46
tests/AcDream.Core.Net.Tests/WorldSessionLinkStatusTests.cs
Normal file
46
tests/AcDream.Core.Net.Tests/WorldSessionLinkStatusTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using AcDream.Core.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionLinkStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(WorldSession.State.Disconnected)]
|
||||
[InlineData(WorldSession.State.Failed)]
|
||||
public void DisconnectedStates_DoNotPublishPacketAge(WorldSession.State state)
|
||||
{
|
||||
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
|
||||
state, lastInboundPacketTicks: 100, nowTicks: 900, frequency: 100);
|
||||
|
||||
Assert.False(snapshot.Connected);
|
||||
Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WorldSession.State.Handshaking)]
|
||||
[InlineData(WorldSession.State.InCharacterSelect)]
|
||||
[InlineData(WorldSession.State.EnteringWorld)]
|
||||
[InlineData(WorldSession.State.InWorld)]
|
||||
public void ConnectedStates_PublishMonotonicPacketAge(WorldSession.State state)
|
||||
{
|
||||
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
|
||||
state, lastInboundPacketTicks: 100, nowTicks: 850, frequency: 100);
|
||||
|
||||
Assert.True(snapshot.Connected);
|
||||
Assert.Equal(7.5d, snapshot.SecondsSinceLastPacket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockRegression_ClampsPacketAgeToZero()
|
||||
{
|
||||
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
|
||||
WorldSession.State.InWorld,
|
||||
lastInboundPacketTicks: 200,
|
||||
nowTicks: 100,
|
||||
frequency: 100);
|
||||
|
||||
Assert.True(snapshot.Connected);
|
||||
Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue