From 443d431e0dd237759fc6108f43bfb6c155bad355 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 07:08:32 -0500 Subject: [PATCH 1/4] Refresh the weapon tray after SetControlledActorId again #136 moved SetControlledActorId after the initial mission state, so the retail client loads its MissionTrack options against the current mission list. The weapon drawer refresh and the weapon and ability selections stayed where they were, now before the controlled actor, and AssigningPlayerRefreshesTheWeaponDrawerAfterSelectingTheControlledActor failed on every run since. They now follow SetControlledActorId: options and mission state still come first, and the tray still gets its contents after its actor, then its selection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../Managers/ManifestationManager.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Rasa.Game/Managers/ManifestationManager.cs b/src/Rasa.Game/Managers/ManifestationManager.cs index 7aeb906d..528eecae 100644 --- a/src/Rasa.Game/Managers/ManifestationManager.cs +++ b/src/Rasa.Game/Managers/ManifestationManager.cs @@ -1955,18 +1955,6 @@ public void AssignPlayer(Client client) // tracking from these MissionTrack options. client.CallMethod(SysEntity.ClientMethodId, new CharacterOptionsPacket(player.CharacterOptions)); - // Inventory deltas precede LoginOk. Refresh the tray after its controlled actor - // exists so the initial image does not depend on opening the equipment selector. - client.CallMethod(SysEntity.ClientInventoryManagerId, - new Packets.Inventory.Server.InventoryCreatePacket( - InventoryType.WeaponDrawerInventory, player.Inventory.WeaponDrawer.ToList(), - player.Inventory.WeaponDrawer.Count)); - client.CallMethod(player.EntityId, new WeaponDrawerSlotPacket(player.ActiveWeapon, false)); - - // The armed ability too, with its loadout page: not requested, so the client takes it - // as its requested slot as well. - client.CallMethod(player.EntityId, new AbilityDrawerSlotPacket(player.CurrentAbilityDrawer, false)); - client.CallMethod(SysEntity.ClientGameMapId, new SetSkyTimePacket { RunningTime = 6666666 }); // ToDo add actual time how long map is running client.CallMethod(SysEntity.ClientMethodId, new SetCurrentContextIdPacket(client.Player.MapChannel.MapInfo.MapContextId)); @@ -1985,6 +1973,18 @@ public void AssignPlayer(Client client) // character options and initial mission state must be sent first. client.CallMethod(SysEntity.ClientMethodId, new SetControlledActorIdPacket(player.EntityId)); + // Inventory deltas precede LoginOk. Refresh the tray after its controlled actor + // exists so the initial image does not depend on opening the equipment selector. + client.CallMethod(SysEntity.ClientInventoryManagerId, + new Packets.Inventory.Server.InventoryCreatePacket( + InventoryType.WeaponDrawerInventory, player.Inventory.WeaponDrawer.ToList(), + player.Inventory.WeaponDrawer.Count)); + client.CallMethod(player.EntityId, new WeaponDrawerSlotPacket(player.ActiveWeapon, false)); + + // The armed ability too, with its loadout page: not requested, so the client takes it + // as its requested slot as well. + client.CallMethod(player.EntityId, new AbilityDrawerSlotPacket(player.CurrentAbilityDrawer, false)); + _characterManager.OfferStartingExperienceMission(client); MissionApplication.Instance.OfferArrivalMissions(client); From 0b8df08fb18f0b553bb1ec087ce0a0848674b296 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 07:08:32 -0500 Subject: [PATCH 2/4] Keep a Bootcamp mission id out of MissionApplication #138 named the retry mission's id in a doc comment, and GenericMissionRuntimeFilesDoNotContainBootcampMissionIdSwitches guards the generic mission runtime against Bootcamp ids. The comment names the mission instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- src/Rasa.Game/Missions/MissionApplication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rasa.Game/Missions/MissionApplication.cs b/src/Rasa.Game/Missions/MissionApplication.cs index e11bbfa6..a3f6d9d2 100644 --- a/src/Rasa.Game/Missions/MissionApplication.cs +++ b/src/Rasa.Game/Missions/MissionApplication.cs @@ -3504,7 +3504,7 @@ internal MissionConversationState ClassifyNpcConversation( /// (overheadwindow.py draws OVERHEAD_MISSION_UNAVAILABLE over the giver). /// /// A mission asking for anything else is not ahead, it is beside: Bootcamp's retry - /// (2005) wants Calling for Reinforcements failed, and its giver is not to wear the + /// mission wants Calling for Reinforcements failed, and its giver is not to wear the /// icon for everyone who has not failed it. So a required Failed state, a mission that /// has to be held at the time, a player flag, a map or a custom requirement keeps the /// mission out of this list whatever else it asks. From 02be3931f5502ee43bab52396c7c2619c9fb0154 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 07:08:33 -0500 Subject: [PATCH 3/4] Serialize scene due timers and resume with scene commits SceneApplication.Tick took due timers from SceneDueQueue (a PriorityQueue and a Dictionary) outside _dispatchGate, and Resume re-attached runs from their rows outside it too, while commits from client threads run under it. Two overlapping evaluations could corrupt the queue, or one could re-attach a run a revision behind the other's commit, so both commits were rejected ("Concurrent scene revision") and the deadline never completed. - SceneDueQueue locks its own state. - Tick takes due work and submits it under the dispatch gate. - Resume runs under the dispatch gate. ConcurrentDeadlineEvaluationCommitsOneCompletion is back in the suite: it failed 12 in 500 iterations under load before, 0 in 2000 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- src/Rasa.Game/Missions/SceneApplication.cs | 43 ++++++++++++------- src/Rasa.Missions/Scenes/SceneDueQueue.cs | 41 +++++++++++------- .../Missions/MissionDeadlineTests.cs | 5 --- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/Rasa.Game/Missions/SceneApplication.cs b/src/Rasa.Game/Missions/SceneApplication.cs index f30713b9..5accc610 100644 --- a/src/Rasa.Game/Missions/SceneApplication.cs +++ b/src/Rasa.Game/Missions/SceneApplication.cs @@ -141,7 +141,15 @@ internal void StageExperience(uint characterId, MapChannel map) Submit(runId, new SceneObservation(SceneEventKind.Signal, _runs[runId].Run.Generation, SequenceId: trigger.SequenceId)); } + // Under the dispatch gate: it re-attaches runs from their rows, and a commit landing in + // between would leave the resident a revision behind ("Concurrent scene revision"). internal void Resume(Client client) + { + lock (_dispatchGate) + ResumeCore(client); + } + + private void ResumeCore(Client client) { if (client?.Player?.MapChannel == null || client.State != Data.ClientState.Ingame || client.PendingTransfer != null) return; @@ -1537,22 +1545,25 @@ internal bool Tick(MapChannel map, SceneTickScope scope = SceneTickScope.All) foreach (var retry in _messageRetries.Where(entry => entry.Value <= _utcNow() && _runs.TryGetValue(entry.Key, out var run) && run.Map == map).ToArray()) DrainMessages(retry.Key); - foreach (var due in _due.TakeDue(_utcNow())) - { - if (!_runs.TryGetValue(due.RunId, out var resident)) - continue; - if (resident.Map != map) - { _due.Schedule(due); continue; } - if (scope == SceneTickScope.Deadlines && !due.ObjectiveId.HasValue || - scope == SceneTickScope.Scripts && due.ObjectiveId.HasValue) - { _due.Schedule(due); continue; } - if (!Submit(due.RunId, new SceneObservation(due.ObjectiveId.HasValue - ? SceneEventKind.ObjectiveDeadlineElapsed : SceneEventKind.TimerElapsed, - due.Generation, due.Name, SequenceId: due.SequenceId))) - _due.Schedule(due with { DueAtUtc = _utcNow().AddSeconds(1) }); - else - changed = true; - } + // Taken and submitted under the dispatch gate, as Submit commits: two overlapping ticks + // (the map's, a deadline evaluation) each see a timer either still due or committed. + lock (_dispatchGate) + foreach (var due in _due.TakeDue(_utcNow())) + { + if (!_runs.TryGetValue(due.RunId, out var resident)) + continue; + if (resident.Map != map) + { _due.Schedule(due); continue; } + if (scope == SceneTickScope.Deadlines && !due.ObjectiveId.HasValue || + scope == SceneTickScope.Scripts && due.ObjectiveId.HasValue) + { _due.Schedule(due); continue; } + if (!SubmitCore(due.RunId, new SceneObservation(due.ObjectiveId.HasValue + ? SceneEventKind.ObjectiveDeadlineElapsed : SceneEventKind.TimerElapsed, + due.Generation, due.Name, SequenceId: due.SequenceId))) + _due.Schedule(due with { DueAtUtc = _utcNow().AddSeconds(1) }); + else + changed = true; + } if (scope != SceneTickScope.Deadlines) { _world.Tick(map, _utcNow()); diff --git a/src/Rasa.Missions/Scenes/SceneDueQueue.cs b/src/Rasa.Missions/Scenes/SceneDueQueue.cs index 9cc1b90d..7a45534f 100644 --- a/src/Rasa.Missions/Scenes/SceneDueQueue.cs +++ b/src/Rasa.Missions/Scenes/SceneDueQueue.cs @@ -5,34 +5,45 @@ namespace Rasa.Missions.Scenes { public sealed record SceneDueWork(string RunId, uint Generation, string Name, DateTime DueAtUtc, uint SequenceId, uint? ObjectiveId = null); + // Thread-safe: the map thread takes due work while client threads schedule and cancel it. public sealed class SceneDueQueue { + private readonly object _gate = new(); private readonly PriorityQueue _queue = new(); private readonly Dictionary<(string Run, string Name), SceneDueWork> _current = new(); public void Schedule(SceneDueWork work) { - _current[(work.RunId, work.Name)] = work; - _queue.Enqueue(work, work.DueAtUtc.Ticks); + lock (_gate) + { + _current[(work.RunId, work.Name)] = work; + _queue.Enqueue(work, work.DueAtUtc.Ticks); + } + } + public void Cancel(string runId, string name) + { + lock (_gate) + _current.Remove((runId, name)); } - public void Cancel(string runId, string name) => _current.Remove((runId, name)); public void Cancel(string runId) { - foreach (var key in new List<(string Run, string Name)>(_current.Keys)) - if (key.Run == runId) - _current.Remove(key); + lock (_gate) + foreach (var key in new List<(string Run, string Name)>(_current.Keys)) + if (key.Run == runId) + _current.Remove(key); } public IReadOnlyList TakeDue(DateTime utcNow) { var due = new List(); - while (_queue.TryPeek(out var work, out var ticks) && ticks <= utcNow.Ticks) - { - _queue.Dequeue(); - var key = (work.RunId, work.Name); - if (!_current.TryGetValue(key, out var current) || !ReferenceEquals(work, current)) - continue; - _current.Remove(key); - due.Add(work); - } + lock (_gate) + while (_queue.TryPeek(out var work, out var ticks) && ticks <= utcNow.Ticks) + { + _queue.Dequeue(); + var key = (work.RunId, work.Name); + if (!_current.TryGetValue(key, out var current) || !ReferenceEquals(work, current)) + continue; + _current.Remove(key); + due.Add(work); + } return due; } } diff --git a/src/Rasa.Test/Missions/MissionDeadlineTests.cs b/src/Rasa.Test/Missions/MissionDeadlineTests.cs index 1f2f7b36..f820e8a3 100644 --- a/src/Rasa.Test/Missions/MissionDeadlineTests.cs +++ b/src/Rasa.Test/Missions/MissionDeadlineTests.cs @@ -92,11 +92,6 @@ public void DeadlineExpiryCompletesAcrossReconnectWithoutReplay() } [TestMethod] - // Catches a real race: SceneApplication.Tick takes due timers from SceneDueQueue (a PriorityQueue - // and a Dictionary, unsynchronized) outside _dispatchGate, so two overlapping evaluations can - // each skip the deadline and neither commits it (expected 1, actual 0). Seen on loaded CI runners. - [Ignore("Quarantined: exposes an unsynchronized due-timer queue; see the linked issue.")] - [GitHubWorkItem("https://github.com/InfiniteRasa/Rasa.NET/issues/132")] public void ConcurrentDeadlineEvaluationCommitsOneCompletion() { using var context = MissionTestContext.WithCustomDefinitions( From fb3ac926fae3e88fa7bbed170b8ccee4990927f6 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 07:08:33 -0500 Subject: [PATCH 4/4] Test the Forean escort route without the base's fights An escort engages hostiles within 20 of its owner, so at the reclaimed base it fought the Thrax, and where that fight left it at the last tick depended on creature timers that run on Environment.TickCount64. The test, which is about the route, removes the creatures hostile to the escorts first, and is back in the suite: it failed 6 in 15 runs before, 0 in 40 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../BootcampCaptureTheFlagInteractionTests.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Rasa.Test/Missions/BootcampCaptureTheFlagInteractionTests.cs b/src/Rasa.Test/Missions/BootcampCaptureTheFlagInteractionTests.cs index e84e9dd8..2c2c05a7 100644 --- a/src/Rasa.Test/Missions/BootcampCaptureTheFlagInteractionTests.cs +++ b/src/Rasa.Test/Missions/BootcampCaptureTheFlagInteractionTests.cs @@ -258,11 +258,6 @@ public void YoungbloodArrivesAtTheBaseAndRemainsForTheNextMissionAfterReconnect( } [TestMethod] - // Creature timers (buffs, bombs, habits) run on Environment.TickCount64, so how far the - // escort gets in 1,200 simulated ticks depends on how fast the machine runs them: this fails - // on a loaded CI runner, and in class order on Windows. It needs an injectable clock. - [Ignore("Quarantined: depends on wall-clock time; see the linked issue.")] - [GitHubWorkItem("https://github.com/InfiniteRasa/Rasa.NET/issues/132")] public void ForeanEscortsCanFollowFromTheCaveExitToTheReclaimedBase() { using var harness = BootcampRuntimeTestHarness.Create(useWorldContent: true); @@ -282,10 +277,12 @@ public void ForeanEscortsCanFollowFromTheCaveExitToTheReclaimedBase() CellManager.Instance.UpdateVisibility(harness.Client); harness.Client.Player.Attributes[Attributes.Health] = new ActorAttributes(Attributes.Health, 100000, 100000, 100000, 0, 0); - // The route, not the fights on it: the Thrax at the base deal real damage (creature - // attacks), and a Forean killed there would lie where it fell. - foreach (var forean in foreans) - forean.Attributes[Attributes.Health] = new ActorAttributes(Attributes.Health, 100000, 100000, 100000, 0, 0); + // The route, not the fights on it: an escort engages the Thrax at the base within 20 of + // its owner, and where that fight has it at the last tick depends on creature timers + // that run on Environment.TickCount64 (windups, habits), so on how fast the machine runs. + foreach (var hostile in Actors(harness).Where(actor => + foreans.Any(forean => CreatureManager.IsHostileTarget(harness.BootcampMap, forean, actor))).ToArray()) + CellManager.Instance.RemoveCreatureFromWorld(harness.BootcampMap, hostile); var route = harness.BootcampMap.NavMesh.FindPath(exit.Position, destination, out var complete); AdvanceCombat(harness, 1200);