feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's template-list mechanism and OP3's OptionPage model: 6 authored group headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK death messages", AP-193) in research doc §2's authored order, each row resolved by PlayerOption id through CharacterOptionTable, seeded from live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable. ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption @0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm the directive was followed), labels/tooltips resolved by name from string table 0x23000003 (never hard-coded English), and registered with OptionsPanelController.CharacterPage. Apply/Reset/Defaults (0x100001FC/FD/FE) are now wired per-page via a scoped subtree search (UiElement.FindDescendant, promoted from UiTabPanel) since Character/ Chat/Config each author their own physical instance under the SAME element ids. Consumers: Group A (29 ids) wire+store only via the existing SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display Timestamps prefixes new transcript lines (RuntimeCommunicationState. DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off (WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as Default Movement inverts the walk-mode modifier's default (RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget (CharacterOptionCombatSettingsSource), VividTargetingIndicator/ CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local GameplaySettings record to the canonical server bit — closing two previously-unfiled divergences where AutoRepeatAttack and AcceptCorpseLootingPermissions never reached the wire despite being retail auto-save ids. TS-73 narrowed to its two still-open cases; TS-75..TS-80 file the genuine gaps (no day/night force, no weather- particle/profanity-filter/salvage/housing/pickup-preference subsystem, fellowship-create's unaudited client-sourced field) rather than inventing stand-ins. Conformance: CharacterOptionsPageControllerTests pins all 50 rows against CharacterOptionTable in both directions (an invented or dropped row fails the build), the authored group/order row-by-row, and the build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against the committed fixture. 52 new tests; full solution suite 13,008 passed / 4 skipped / 0 failed (was 12,956/4/0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7e9f372ce8
commit
22b86b9ff4
25 changed files with 1674 additions and 41 deletions
|
|
@ -286,6 +286,45 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.Equal(beforeRevision, options.Revision);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): GetOptionBit — the read
|
||||
// counterpart every Character-tab row seed and every re-pointed
|
||||
// Group-C consumer polls.
|
||||
|
||||
[Theory]
|
||||
[InlineData(CharacterOptionId.AutoTarget)]
|
||||
[InlineData(CharacterOptionId.ViewCombatTarget)]
|
||||
[InlineData(CharacterOptionId.ListenToGeneralChat)]
|
||||
[InlineData(CharacterOptionId.DisableDistanceFog)]
|
||||
public void GetOptionBit_RoundTripsWithSetOptionBit(CharacterOptionId id)
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
|
||||
options.SetOptionBit((uint)id, true);
|
||||
Assert.True(options.GetOptionBit(id));
|
||||
Assert.True(options.GetOptionBit((uint)id));
|
||||
|
||||
options.SetOptionBit((uint)id, false);
|
||||
Assert.False(options.GetOptionBit(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOptionBit_UnrecognizedId_ReturnsFalse()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
Assert.False(options.GetOptionBit(0xFFFFu));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOptionBit_ReflectsReplace_NotJustSetOptionBit()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
Assert.True(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); // default ON
|
||||
|
||||
options.Replace(options.Options1, 0u); // every Options2 bit off, incl. ListenToGeneralChat
|
||||
|
||||
Assert.False(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat));
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared
|
||||
// local-write-then-send/dirty seam, + the dirty/flush state machine ────
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,59 @@ public sealed class RuntimeCommunicationStateTests
|
|||
Assert.Equal("", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): DisplayTimestampsSource —
|
||||
// PlayerOption DisplayTimeStamps prefix.
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsUnbound_NoPrefix()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState();
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsFalse_NoPrefix()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => false };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_PrefixesTranscriptLine()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
string text = state.Chat.Snapshot()[0].Text;
|
||||
Assert.EndsWith("Your spell fizzled.", text);
|
||||
Assert.NotEqual("Your spell fizzled.", text);
|
||||
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
|
||||
// hour, zero-padded minute:second, trailing space before the
|
||||
// text) — .NET "H:mm:ss " is the exact equivalent.
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox()
|
||||
{
|
||||
// Retail's own ClientLocal (0x1A) exemption already skips the
|
||||
// whole AddTextToScroll destination — timestamps are a transcript
|
||||
// concept, never applied to the transient SpewBox line.
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Out of Range!", RetailLogTextType.ClientLocal);
|
||||
|
||||
state.SpewBox.Tick(0d);
|
||||
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ResetsSpewBox()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -574,4 +574,31 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
|||
controller.ApplyServerPhysicsState(initial));
|
||||
Assert.Equal(pushed, body.State);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): RunAsDefaultMovementSource —
|
||||
// PlayerOption RunAsDefaultMovement, polled.
|
||||
|
||||
[Fact]
|
||||
public void RunAsDefaultMovement_Unbound_DefaultsToTrue()
|
||||
{
|
||||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
|
||||
Assert.Null(movement.RunAsDefaultMovementSource);
|
||||
Assert.True(movement.RunAsDefaultMovement);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void RunAsDefaultMovement_ReflectsBoundSourceLive(bool value)
|
||||
{
|
||||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
bool current = value;
|
||||
movement.RunAsDefaultMovementSource = () => current;
|
||||
|
||||
Assert.Equal(value, movement.RunAsDefaultMovement);
|
||||
|
||||
current = !value;
|
||||
Assert.Equal(!value, movement.RunAsDefaultMovement); // polled, not cached
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue