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); 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. 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/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); 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(