Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.
MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.
MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.
SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).
SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.
SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.
SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.
SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.
SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).
SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.
SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.
Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.
Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
from inside a flush callback can still be erased by the trailing
"_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
(same outcome whether the callback runs inside or outside the lock),
not reachable from any current caller, not a one-liner to close
correctly (needs a per-dirty-period generation token). Left as documented
in the review; worth closing before the Options panel ever flushes from
inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
touching files this round doesn't otherwise edit (SocialActions.cs,
CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
the "one-liner in a file already being edited" instruction.
Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).
Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
298 lines
15 KiB
C#
298 lines
15 KiB
C#
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
/// <summary>
|
|
/// Campaign OP slice OP1 (2026-08-10) conformance for
|
|
/// <see cref="CharacterOptionTable"/> — table completeness across the
|
|
/// complete <c>0x00..0x34</c> id space, the auto-save split pinned
|
|
/// id-by-id against
|
|
/// docs/research/2026-08-10-set-character-options-wire.md §3.2's
|
|
/// byte-verified table, the client-Defaults split against §8.2's, and
|
|
/// unknown-id rejection (ACE throws on an unmodeled id — one must never
|
|
/// reach the wire, wire doc §5.4.2).
|
|
/// </summary>
|
|
public sealed class CharacterOptionTableTests
|
|
{
|
|
// wire research §3.2 — CPlayerModule::IsAutoSaveOption @0x0059A600,
|
|
// byte-verified 0x34-byte table at VA 0x0059A62C. 21 ids.
|
|
private static readonly CharacterOptionId[] AutoSaveIds =
|
|
[
|
|
CharacterOptionId.AutoRepeatAttack,
|
|
CharacterOptionId.IgnoreAllegianceRequests,
|
|
CharacterOptionId.IgnoreFellowshipRequests,
|
|
CharacterOptionId.FellowshipShareXP,
|
|
CharacterOptionId.AcceptLootPermits,
|
|
CharacterOptionId.FellowshipShareLoot,
|
|
CharacterOptionId.FellowshipAutoAcceptRequests,
|
|
CharacterOptionId.UseChargeAttack,
|
|
CharacterOptionId.ListenToAllegianceChat,
|
|
CharacterOptionId.ListenToGeneralChat,
|
|
CharacterOptionId.ListenToTradeChat,
|
|
CharacterOptionId.ListenToLFGChat,
|
|
CharacterOptionId.ListenToRoleplayChat,
|
|
CharacterOptionId.AppearOffline,
|
|
CharacterOptionId.LeadMissileTargets,
|
|
CharacterOptionId.UseFastMissiles,
|
|
CharacterOptionId.ListenToSocietyChat,
|
|
CharacterOptionId.ShowHelm,
|
|
CharacterOptionId.UseMouseTurning,
|
|
CharacterOptionId.ShowCloak,
|
|
CharacterOptionId.LockUI,
|
|
];
|
|
|
|
// wire research §8.2 — PlayerModule::GetDefaultOptionValue @0x005D2A30,
|
|
// byte-verified 0x2B-byte table at VA 0x005D2A5C. 16 default-ON ids
|
|
// (every id past 0x2A falls off the end of that table and defaults to
|
|
// false, even though 3 of them are ON in the raw constructor word —
|
|
// see the divergence register row, D3/OP1).
|
|
private static readonly CharacterOptionId[] ClientDefaultOnIds =
|
|
[
|
|
CharacterOptionId.AutoRepeatAttack,
|
|
CharacterOptionId.IgnoreFellowshipRequests,
|
|
CharacterOptionId.AllowGive,
|
|
CharacterOptionId.ShowTooltips,
|
|
CharacterOptionId.ToggleRun,
|
|
CharacterOptionId.AutoTarget,
|
|
CharacterOptionId.VividTargetingIndicator,
|
|
CharacterOptionId.FellowshipShareXP,
|
|
CharacterOptionId.CoordinatesOnRadar,
|
|
CharacterOptionId.SpellDuration,
|
|
CharacterOptionId.UseChargeAttack,
|
|
CharacterOptionId.ListenToAllegianceChat,
|
|
CharacterOptionId.ListenToGeneralChat,
|
|
CharacterOptionId.ListenToTradeChat,
|
|
CharacterOptionId.ListenToLFGChat,
|
|
CharacterOptionId.LeadMissileTargets,
|
|
];
|
|
|
|
[Fact]
|
|
public void All_HasExactly53Entries_Ids0x00Through0x34Contiguous()
|
|
{
|
|
CharacterOptionTableEntry[] all = [.. CharacterOptionTable.All];
|
|
|
|
Assert.Equal(53, all.Length);
|
|
for (uint id = 0x00; id <= 0x34; id++)
|
|
{
|
|
Assert.True(
|
|
CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry),
|
|
$"id 0x{id:X2} missing from CharacterOptionTable");
|
|
Assert.Equal(id, (uint)entry.Id);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(AllModeledIds))]
|
|
public void IsAutoSave_MatchesByteVerifiedSplit(CharacterOptionId id)
|
|
{
|
|
bool expected = Array.IndexOf(AutoSaveIds, id) >= 0;
|
|
|
|
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
|
Assert.Equal(expected, entry.IsAutoSave);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(AllModeledIds))]
|
|
public void ClientDefault_MatchesByteVerifiedSplit(CharacterOptionId id)
|
|
{
|
|
bool expected = Array.IndexOf(ClientDefaultOnIds, id) >= 0;
|
|
|
|
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
|
Assert.Equal(expected, entry.ClientDefault);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoSaveIds_CountIs21()
|
|
{
|
|
Assert.Equal(21, AutoSaveIds.Length);
|
|
Assert.Equal(21, CharacterOptionTable.All.Count(static e => e.IsAutoSave));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientDefaultOnIds_CountIs16()
|
|
{
|
|
Assert.Equal(16, ClientDefaultOnIds.Length);
|
|
Assert.Equal(16, CharacterOptionTable.All.Count(static e => e.ClientDefault));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconstructedClientDefaultWords_MatchIndependentlyConfirmedConstants()
|
|
{
|
|
// wire research §1.4/§2.5: OR'ing every ClientDefault=true row's mask
|
|
// into its own word reconstructs EXACTLY CharacterOptions1.Default
|
|
// (0x50C4A54A, also the retail constructor literal) for Options1,
|
|
// and 0x00008700 for Options2 — the client Defaults-button value,
|
|
// deliberately NOT the same as the raw constructor default
|
|
// (0x00948700, RuntimeCharacterOptionsState.DefaultOptions2) because
|
|
// GetDefaultOptionValue's table stops at id 0x2A.
|
|
uint options1 = 0u;
|
|
uint options2 = 0u;
|
|
foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All)
|
|
{
|
|
if (!entry.ClientDefault) continue;
|
|
if (entry.IsOptions1) options1 |= entry.Mask;
|
|
else options2 |= entry.Mask;
|
|
}
|
|
|
|
Assert.Equal(0x50C4A54Au, options1);
|
|
Assert.Equal(0x00008700u, options2);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0x35u)] // CharacterOptions1Default — the WHOLE default mask, not a real option
|
|
[InlineData(0x36u)] // CharacterOptions2Default — same landmine, other word
|
|
[InlineData(0xFFFFu)]
|
|
[InlineData(0xFFFFFFFFu)]
|
|
public void TryGet_RejectsUnknownAndReservedIds(uint optionId)
|
|
{
|
|
Assert.False(CharacterOptionTable.TryGet(optionId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void SpotCheck_WordAndMaskAgainstVerbatimAcclientEnums()
|
|
{
|
|
// acclient.h:3404-3436 `enum CharacterOption` / :3451-3481
|
|
// `enum CharacterOptions2`.
|
|
Assert.True(CharacterOptionTable.TryGet(
|
|
CharacterOptionId.AutoRepeatAttack, out CharacterOptionTableEntry autoRepeat));
|
|
Assert.True(autoRepeat.IsOptions1);
|
|
Assert.Equal(0x00000002u, autoRepeat.Mask);
|
|
|
|
// PersistentAtDay (id 0x05) lives in Options2 despite its low id —
|
|
// acclient.h:3454 `PersistentAtDay_CharacterOptions2 = 0x1`.
|
|
Assert.True(CharacterOptionTable.TryGet(
|
|
CharacterOptionId.PersistentAtDay, out CharacterOptionTableEntry persistentAtDay));
|
|
Assert.False(persistentAtDay.IsOptions1);
|
|
Assert.Equal(0x00000001u, persistentAtDay.Mask);
|
|
|
|
Assert.True(CharacterOptionTable.TryGet(
|
|
CharacterOptionId.ListenToAllegianceChat, out CharacterOptionTableEntry allegiance));
|
|
Assert.True(allegiance.IsOptions1);
|
|
Assert.Equal(0x40000000u, allegiance.Mask);
|
|
|
|
// HearPkDeathMessages (0x34) — ACE-sourced, unverifiable against the
|
|
// 2013 binary; register row.
|
|
Assert.True(CharacterOptionTable.TryGet(
|
|
CharacterOptionId.HearPkDeathMessages, out CharacterOptionTableEntry pkDeath));
|
|
Assert.False(pkDeath.IsOptions1);
|
|
Assert.Equal(0x02000000u, pkDeath.Mask);
|
|
Assert.False(pkDeath.IsAutoSave);
|
|
}
|
|
|
|
public static IEnumerable<object[]> AllModeledIds()
|
|
{
|
|
for (uint id = 0x00; id <= 0x34; id++)
|
|
yield return [(CharacterOptionId)id];
|
|
}
|
|
|
|
// ── SHOULD-FIX MF-3 (Campaign OP OP1 review fix, mechanism lens,
|
|
// 2026-08-11): a hand-transcribed 53-row (word, mask) pin, the SAME
|
|
// shape as AutoSaveIds/ClientDefaultOnIds above — a transposition among
|
|
// the 37 non-ClientDefault masks (e.g. swapping DisplayAge's O2 0x20
|
|
// with DisplayNumberDeaths' O2 0x10) would previously pass the entire
|
|
// suite silently; this is the id-by-id guard against exactly that.
|
|
// Transcribed independently from named-retail/acclient.h:4162-4218
|
|
// (`enum PlayerOption`, the id space) cross-referenced by NAME against
|
|
// :3404-3436 (`enum CharacterOption`, Options1) and :3451-3481
|
|
// (`enum CharacterOptions2`) — not derived from CharacterOptionTable.cs.
|
|
[Theory]
|
|
[InlineData(CharacterOptionId.AutoRepeatAttack, true, 0x00000002u)]
|
|
[InlineData(CharacterOptionId.IgnoreAllegianceRequests, true, 0x00000004u)]
|
|
[InlineData(CharacterOptionId.IgnoreFellowshipRequests, true, 0x00000008u)]
|
|
[InlineData(CharacterOptionId.IgnoreTradeRequests, true, 0x00020000u)]
|
|
[InlineData(CharacterOptionId.DisableMostWeatherEffects, true, 0x00010000u)]
|
|
[InlineData(CharacterOptionId.PersistentAtDay, false, 0x00000001u)]
|
|
[InlineData(CharacterOptionId.AllowGive, true, 0x00000040u)]
|
|
[InlineData(CharacterOptionId.ViewCombatTarget, true, 0x00000080u)]
|
|
[InlineData(CharacterOptionId.ShowTooltips, true, 0x00000100u)]
|
|
[InlineData(CharacterOptionId.UseDeception, true, 0x00000200u)]
|
|
[InlineData(CharacterOptionId.ToggleRun, true, 0x00000400u)]
|
|
[InlineData(CharacterOptionId.StayInChatMode, true, 0x00000800u)]
|
|
[InlineData(CharacterOptionId.AdvancedCombatUI, true, 0x00001000u)]
|
|
[InlineData(CharacterOptionId.AutoTarget, true, 0x00002000u)]
|
|
[InlineData(CharacterOptionId.VividTargetingIndicator, true, 0x00008000u)]
|
|
[InlineData(CharacterOptionId.FellowshipShareXP, true, 0x00040000u)]
|
|
[InlineData(CharacterOptionId.AcceptLootPermits, true, 0x00080000u)]
|
|
[InlineData(CharacterOptionId.FellowshipShareLoot, true, 0x00100000u)]
|
|
[InlineData(CharacterOptionId.FellowshipAutoAcceptRequests, true, 0x20000000u)]
|
|
[InlineData(CharacterOptionId.SideBySideVitals, true, 0x00200000u)]
|
|
[InlineData(CharacterOptionId.CoordinatesOnRadar, true, 0x00400000u)]
|
|
[InlineData(CharacterOptionId.SpellDuration, true, 0x00800000u)]
|
|
[InlineData(CharacterOptionId.DisableHouseRestrictionEffects, true, 0x02000000u)]
|
|
[InlineData(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, true, 0x04000000u)]
|
|
[InlineData(CharacterOptionId.DisplayAllegianceLogonNotifications, true, 0x08000000u)]
|
|
[InlineData(CharacterOptionId.UseChargeAttack, true, 0x10000000u)]
|
|
[InlineData(CharacterOptionId.UseCraftSuccessDialog, true, 0x80000000u)]
|
|
[InlineData(CharacterOptionId.ListenToAllegianceChat, true, 0x40000000u)]
|
|
[InlineData(CharacterOptionId.DisplayDateOfBirth, false, 0x00000002u)]
|
|
[InlineData(CharacterOptionId.DisplayAge, false, 0x00000020u)]
|
|
[InlineData(CharacterOptionId.DisplayChessRank, false, 0x00000004u)]
|
|
[InlineData(CharacterOptionId.DisplayFishingSkill, false, 0x00000008u)]
|
|
[InlineData(CharacterOptionId.DisplayNumberDeaths, false, 0x00000010u)]
|
|
[InlineData(CharacterOptionId.DisplayTimeStamps, false, 0x00000040u)]
|
|
[InlineData(CharacterOptionId.SalvageMultiple, false, 0x00000080u)]
|
|
[InlineData(CharacterOptionId.ListenToGeneralChat, false, 0x00000100u)]
|
|
[InlineData(CharacterOptionId.ListenToTradeChat, false, 0x00000200u)]
|
|
[InlineData(CharacterOptionId.ListenToLFGChat, false, 0x00000400u)]
|
|
[InlineData(CharacterOptionId.ListenToRoleplayChat, false, 0x00000800u)]
|
|
[InlineData(CharacterOptionId.AppearOffline, false, 0x00001000u)]
|
|
[InlineData(CharacterOptionId.DisplayNumberCharacterTitles, false, 0x00002000u)]
|
|
[InlineData(CharacterOptionId.MainPackPreferred, false, 0x00004000u)]
|
|
[InlineData(CharacterOptionId.LeadMissileTargets, false, 0x00008000u)]
|
|
[InlineData(CharacterOptionId.UseFastMissiles, false, 0x00010000u)]
|
|
[InlineData(CharacterOptionId.FilterLanguage, false, 0x00020000u)]
|
|
[InlineData(CharacterOptionId.ConfirmVolatileRareUse, false, 0x00040000u)]
|
|
[InlineData(CharacterOptionId.ListenToSocietyChat, false, 0x00080000u)]
|
|
[InlineData(CharacterOptionId.ShowHelm, false, 0x00100000u)]
|
|
[InlineData(CharacterOptionId.DisableDistanceFog, false, 0x00200000u)]
|
|
[InlineData(CharacterOptionId.UseMouseTurning, false, 0x00400000u)]
|
|
[InlineData(CharacterOptionId.ShowCloak, false, 0x00800000u)]
|
|
[InlineData(CharacterOptionId.LockUI, false, 0x01000000u)]
|
|
// D3 / register row: ACE-sourced (ListenToPKDeathMessages), unverifiable
|
|
// against the 2013 binary — see the type doc on CharacterOptionTable.
|
|
[InlineData(CharacterOptionId.HearPkDeathMessages, false, 0x02000000u)]
|
|
public void WordAndMask_MatchesIndependentTranscriptionOfVerbatimAcclientEnums(
|
|
CharacterOptionId id, bool expectedIsOptions1, uint expectedMask)
|
|
{
|
|
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
|
Assert.Equal(expectedIsOptions1, entry.IsOptions1);
|
|
Assert.Equal(expectedMask, entry.Mask);
|
|
}
|
|
|
|
[Fact]
|
|
public void WordAndMask_AreAllPairwiseDistinct()
|
|
{
|
|
// N7 (blast lens): the reconstruction test above cannot catch a
|
|
// duplicate because OR is idempotent — this is the direct guard.
|
|
var pairs = CharacterOptionTable.All
|
|
.Select(static e => (e.IsOptions1, e.Mask))
|
|
.ToList();
|
|
Assert.Equal(53, pairs.Distinct().Count());
|
|
}
|
|
|
|
// ── S6 (Campaign OP OP1 review fix, blast lens, 2026-08-11): the write
|
|
// path (this table) and the read path (PlayerDescriptionParser.
|
|
// CharacterOptions1/2, consumed by TurbineChatMembershipGate and
|
|
// RuntimeSettingsController) define the SAME retail bits independently,
|
|
// in different projects, with nothing else asserting they agree — an
|
|
// edit to one without the other silently diverges the write path from
|
|
// the membership gate (the exact CH3 failure class). Covers every
|
|
// non-None/Default member of both parser enums.
|
|
[Theory]
|
|
[InlineData(CharacterOptionId.AllowGive, true, (uint)PlayerDescriptionParser.CharacterOptions1.AllowGive)]
|
|
[InlineData(CharacterOptionId.ListenToAllegianceChat, true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat)]
|
|
[InlineData(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, true, (uint)PlayerDescriptionParser.CharacterOptions1.DragItemOnPlayerOpensSecureTrade)]
|
|
[InlineData(CharacterOptionId.ListenToGeneralChat, false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)]
|
|
[InlineData(CharacterOptionId.ListenToTradeChat, false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat)]
|
|
[InlineData(CharacterOptionId.ListenToLFGChat, false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat)]
|
|
[InlineData(CharacterOptionId.ListenToRoleplayChat, false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)]
|
|
[InlineData(CharacterOptionId.ListenToSocietyChat, false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)]
|
|
public void CharacterOptionTable_AgreesWithPlayerDescriptionParserEnums(
|
|
CharacterOptionId id, bool expectedIsOptions1, uint expectedMask)
|
|
{
|
|
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
|
Assert.Equal(expectedIsOptions1, entry.IsOptions1);
|
|
Assert.Equal(expectedMask, entry.Mask);
|
|
}
|
|
}
|