using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// Campaign CT slice CT2 (2026-08-24): unit tests for /// — the retail /// CharacterTitleTable port. Covers the full table replace /// (0x0029 CharacterTitle), the incremental add/set-display notice /// (0x002B UpdateTitle), the retail-verified unconditional-add / /// gated-display-set contract, and generation-reset clearing. /// public sealed class RuntimeCharacterTitleStateTests { [Fact] public void InitialState_IsEmptyWithDefaultDisplayTitle() { var titles = new RuntimeCharacterTitleState(); Assert.Equal(0u, titles.DisplayTitleId); Assert.Empty(titles.EarnedTitleIds); Assert.False(titles.HasEarnedTitle(13u)); } [Fact] public void ReplaceTable_SetsEarnedIdsAndDisplayTitle_FiresTableReplaced() { var titles = new RuntimeCharacterTitleState(); int tableReplacedCount = 0; titles.TableReplaced += () => tableReplacedCount++; titles.ReplaceTable(13u, [1u, 5u, 13u]); Assert.Equal(13u, titles.DisplayTitleId); Assert.Equal(new HashSet { 1u, 5u, 13u }, titles.EarnedTitleIds.ToHashSet()); Assert.True(titles.HasEarnedTitle(5u)); Assert.False(titles.HasEarnedTitle(99u)); Assert.Equal(1, tableReplacedCount); } [Fact] public void ReplaceTable_IsAWholesaleReplace_DropsIdsMissingFromTheNewTable() { var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(1u, [1u, 2u, 3u]); titles.ReplaceTable(1u, [1u]); Assert.Equal(new uint[] { 1u }, titles.EarnedTitleIds.ToArray()); Assert.False(titles.HasEarnedTitle(2u)); Assert.False(titles.HasEarnedTitle(3u)); } [Fact] public void ReplaceTable_DisplayTitleIdUnchanged_DoesNotFireDisplayTitleChanged() { var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(13u, [13u]); var fired = new List(); titles.DisplayTitleChanged += id => fired.Add(id); titles.ReplaceTable(13u, [13u, 14u]); Assert.Empty(fired); Assert.Equal(13u, titles.DisplayTitleId); } [Fact] public void ReplaceTable_DisplayTitleIdChanges_FiresDisplayTitleChangedWithNewId() { var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(13u, [13u, 14u]); var fired = new List(); titles.DisplayTitleChanged += id => fired.Add(id); titles.ReplaceTable(14u, [13u, 14u]); Assert.Equal([14u], fired); Assert.Equal(14u, titles.DisplayTitleId); } // ── 0x002B UpdateTitle: unconditional add, gated display-set ───────── // Retail's Handle_Social__AddOrSetCharacterTitle @0x00564260 ALWAYS // calls SendNotice_AddCharacterTitle, and additionally calls // SendNotice_SetDisplayCharacterTitle only when setAsDisplay != 0. [Fact] public void ApplyUpdateTitle_AlwaysAddsAndFiresTitleAdded_EvenWhenNotSetAsDisplay() { var titles = new RuntimeCharacterTitleState(); var added = new List(); titles.TitleAdded += id => added.Add(id); var displayChanged = new List(); titles.DisplayTitleChanged += id => displayChanged.Add(id); titles.ApplyUpdateTitle(7u, setAsDisplay: false); Assert.True(titles.HasEarnedTitle(7u)); Assert.Equal([7u], added); Assert.Empty(displayChanged); Assert.Equal(0u, titles.DisplayTitleId); } [Fact] public void ApplyUpdateTitle_SetAsDisplayTrue_AddsAndUpdatesDisplayTitle() { var titles = new RuntimeCharacterTitleState(); var added = new List(); titles.TitleAdded += id => added.Add(id); var displayChanged = new List(); titles.DisplayTitleChanged += id => displayChanged.Add(id); titles.ApplyUpdateTitle(13u, setAsDisplay: true); Assert.True(titles.HasEarnedTitle(13u)); Assert.Equal([13u], added); Assert.Equal([13u], displayChanged); Assert.Equal(13u, titles.DisplayTitleId); } [Fact] public void ApplyUpdateTitle_AlreadyEarnedId_StillFiresTitleAddedUnconditionally() { // Retail's own broadcast is unconditional — it does not check // membership before firing the notice. var titles = new RuntimeCharacterTitleState(); titles.ApplyUpdateTitle(7u, setAsDisplay: false); var added = new List(); titles.TitleAdded += id => added.Add(id); titles.ApplyUpdateTitle(7u, setAsDisplay: false); Assert.Equal([7u], added); Assert.Single(titles.EarnedTitleIds); } [Fact] public void ApplyUpdateTitle_SetAsDisplayOnAnUnearnedId_AddsItAndSetsDisplay() { // Retail sends UpdateTitle for a title the player just earned — the // id is not necessarily already in the earned set beforehand. var titles = new RuntimeCharacterTitleState(); titles.ApplyUpdateTitle(99u, setAsDisplay: true); Assert.True(titles.HasEarnedTitle(99u)); Assert.Equal(99u, titles.DisplayTitleId); } [Fact] public void ResetSession_ClearsEarnedIdsAndDisplayTitle() { var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(13u, [1u, 5u, 13u]); titles.ResetSession(); Assert.Equal(0u, titles.DisplayTitleId); Assert.Empty(titles.EarnedTitleIds); } [Fact] public void ResetSession_BumpsRevisionEvenWhenAlreadyEmpty() { var titles = new RuntimeCharacterTitleState(); long before = titles.Revision; titles.ResetSession(); Assert.True(titles.Revision > before); } [Fact] public void Snapshot_ReflectsDisplayTitleAndCount() { var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(13u, [1u, 5u, 13u]); RuntimeCharacterTitleSnapshot snapshot = titles.Snapshot; Assert.Equal(13u, snapshot.DisplayTitleId); Assert.Equal(3, snapshot.TitleCount); Assert.Equal(titles.Revision, snapshot.Revision); } [Fact] public void ReplaceTable_NullTitleIds_Throws() { var titles = new RuntimeCharacterTitleState(); Assert.Throws( () => titles.ReplaceTable(1u, null!)); } }