From 56f6f8e20158100aa0faf0eda2c78f83fc7fb54f Mon Sep 17 00:00:00 2001 From: alandarev Date: Tue, 15 Sep 2026 22:04:56 +0300 Subject: [PATCH 1/4] Add scripted test actors and a login probe A test actor is a connection-less player which executes commands (walk, attack, skill, say, pickup, warp) and records what the game would have shown to its client as a stream of JSON events with attribution (who hit whom). It exists to test game mechanics - PvP, parties, damage - without a client. - GameLogic/TestActors: ScriptedPlayer (a Player, not an OfflinePlayer, so that bots, mini games and the admin panel treat it as a human), event log, recording view plug-in container, hit recorder, command loop, registry, protocol handler and JSON serialisation. - Startup/TestActors: an NDJSON-over-TCP control endpoint hosted as a BackgroundService. It is off by default and only started when the OPENMU_ACTOR_PORT environment variable names a port; it has no authentication and is meant for loopback use on development servers. Registered in the all-in-one Startup host only. - Network/LoginProbe: a console tool which logs an account in over the real wire (connect server -> game server) and holds the session, for tests which need a genuine client connection. - tests/MUnique.OpenMU.Tests/TestActors: 43 unit tests. --- src/GameLogic/TestActors/ActorCommand.cs | 12 + .../TestActors/ActorCommandResult.cs | 34 + src/GameLogic/TestActors/ActorErrorCodes.cs | 76 ++ src/GameLogic/TestActors/ActorEvent.cs | 19 + src/GameLogic/TestActors/ActorEventField.cs | 14 + src/GameLogic/TestActors/ActorEventLog.cs | 187 +++++ .../TestActors/ActorEventSubscription.cs | 75 ++ .../TestActors/ActorHitRecorderPlugIn.cs | 57 ++ src/GameLogic/TestActors/ActorJson.cs | 77 ++ src/GameLogic/TestActors/ActorObjects.cs | 83 +++ .../TestActors/ActorPathHeuristic.cs | 26 + .../TestActors/ActorProtocolHandler.cs | 247 +++++++ src/GameLogic/TestActors/ActorRegistry.cs | 220 ++++++ src/GameLogic/TestActors/ActorState.cs | 133 ++++ src/GameLogic/TestActors/AttackCommand.cs | 13 + src/GameLogic/TestActors/BotsController.cs | 183 +++++ src/GameLogic/TestActors/IActorFactory.cs | 41 ++ src/GameLogic/TestActors/IActorRegistry.cs | 46 ++ .../TestActors/IGameServerContextLocator.cs | 63 ++ src/GameLogic/TestActors/PickupCommand.cs | 11 + .../RecordingViewPlugInContainer.cs | 298 ++++++++ src/GameLogic/TestActors/SayCommand.cs | 11 + .../TestActors/ScriptedIntelligence.cs | 692 ++++++++++++++++++ src/GameLogic/TestActors/ScriptedPlayer.cs | 169 +++++ src/GameLogic/TestActors/SkillCommand.cs | 12 + src/GameLogic/TestActors/WalkCommand.cs | 13 + src/GameLogic/TestActors/WarpCommand.cs | 11 + src/MUnique.OpenMU.sln | 7 + .../MUnique.OpenMU.Network.LoginProbe.csproj | 32 + src/Network/LoginProbe/Program.cs | 274 +++++++ src/Network/MUnique.OpenMU.Network.csproj | 5 + src/Startup/Program.cs | 29 + src/Startup/TestActors/ActorControlService.cs | 199 +++++ .../TestActors/ActorEndpointOptions.cs | 13 + .../TestActors/ActorEventLogTests.cs | 141 ++++ .../TestActors/ActorHitRecorderPlugInTests.cs | 130 ++++ .../TestActors/ActorProtocolHandlerTests.cs | 156 ++++ .../TestActors/ActorRegistryTests.cs | 263 +++++++ .../TestActors/ActorTestHelper.cs | 82 +++ .../TestActors/BotsControllerTests.cs | 104 +++ .../TestActors/ScriptedIntelligenceTests.cs | 206 ++++++ .../TestActors/ScriptedPlayerTests.cs | 127 ++++ 42 files changed, 4591 insertions(+) create mode 100644 src/GameLogic/TestActors/ActorCommand.cs create mode 100644 src/GameLogic/TestActors/ActorCommandResult.cs create mode 100644 src/GameLogic/TestActors/ActorErrorCodes.cs create mode 100644 src/GameLogic/TestActors/ActorEvent.cs create mode 100644 src/GameLogic/TestActors/ActorEventField.cs create mode 100644 src/GameLogic/TestActors/ActorEventLog.cs create mode 100644 src/GameLogic/TestActors/ActorEventSubscription.cs create mode 100644 src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs create mode 100644 src/GameLogic/TestActors/ActorJson.cs create mode 100644 src/GameLogic/TestActors/ActorObjects.cs create mode 100644 src/GameLogic/TestActors/ActorPathHeuristic.cs create mode 100644 src/GameLogic/TestActors/ActorProtocolHandler.cs create mode 100644 src/GameLogic/TestActors/ActorRegistry.cs create mode 100644 src/GameLogic/TestActors/ActorState.cs create mode 100644 src/GameLogic/TestActors/AttackCommand.cs create mode 100644 src/GameLogic/TestActors/BotsController.cs create mode 100644 src/GameLogic/TestActors/IActorFactory.cs create mode 100644 src/GameLogic/TestActors/IActorRegistry.cs create mode 100644 src/GameLogic/TestActors/IGameServerContextLocator.cs create mode 100644 src/GameLogic/TestActors/PickupCommand.cs create mode 100644 src/GameLogic/TestActors/RecordingViewPlugInContainer.cs create mode 100644 src/GameLogic/TestActors/SayCommand.cs create mode 100644 src/GameLogic/TestActors/ScriptedIntelligence.cs create mode 100644 src/GameLogic/TestActors/ScriptedPlayer.cs create mode 100644 src/GameLogic/TestActors/SkillCommand.cs create mode 100644 src/GameLogic/TestActors/WalkCommand.cs create mode 100644 src/GameLogic/TestActors/WarpCommand.cs create mode 100644 src/Network/LoginProbe/MUnique.OpenMU.Network.LoginProbe.csproj create mode 100644 src/Network/LoginProbe/Program.cs create mode 100644 src/Startup/TestActors/ActorControlService.cs create mode 100644 src/Startup/TestActors/ActorEndpointOptions.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ActorEventLogTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/TestActors/ScriptedPlayerTests.cs diff --git a/src/GameLogic/TestActors/ActorCommand.cs b/src/GameLogic/TestActors/ActorCommand.cs new file mode 100644 index 000000000..6e5ff22dc --- /dev/null +++ b/src/GameLogic/TestActors/ActorCommand.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// One thing a scenario tells an actor to do. Commands are executed by the actor's own +/// , serialized with its persistence lock. +/// +/// The command name as it appears in the protocol and in the log. +public abstract record ActorCommand(string Name); diff --git a/src/GameLogic/TestActors/ActorCommandResult.cs b/src/GameLogic/TestActors/ActorCommandResult.cs new file mode 100644 index 000000000..8b1ed9a09 --- /dev/null +++ b/src/GameLogic/TestActors/ActorCommandResult.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// The outcome of one . A command is never silently ignored: it either +/// succeeded with a result, or it carries the code of the precondition which refused it. +/// +/// true when the command was carried out. +/// The machine readable failure code, e.g. out_of_range; null on success. +/// The human readable failure message; null on success. +/// The result fields, e.g. the hits performed or the path length. +public sealed record ActorCommandResult(bool Ok, string? Code, string? Error, IReadOnlyList Fields) +{ + /// + /// Creates a successful result. + /// + /// The result fields. + /// The result. + public static ActorCommandResult Success(params ActorEventField[] fields) + => new(true, null, null, fields); + + /// + /// Creates a failed result. + /// + /// The failure code. + /// The failure message. + /// The progress made before the failure, if any. + /// The result. + public static ActorCommandResult Failure(string code, string error, params ActorEventField[] fields) + => new(false, code, error, fields); +} diff --git a/src/GameLogic/TestActors/ActorErrorCodes.cs b/src/GameLogic/TestActors/ActorErrorCodes.cs new file mode 100644 index 000000000..a410bd922 --- /dev/null +++ b/src/GameLogic/TestActors/ActorErrorCodes.cs @@ -0,0 +1,76 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// The failure codes a command can answer with. They are part of the protocol: test scripts +/// branch on them. +/// +public static class ActorErrorCodes +{ + /// The actor or its target stands in a safe zone, where attacks are forbidden. + public const string SafeZone = "safezone"; + + /// The target is farther away than the character's attack or skill range. + public const string OutOfRange = "out_of_range"; + + /// No object with that id or name is within the actor's view. + public const string NotInView = "not_in_view"; + + /// The character has not learned the requested skill. + public const string UnknownSkill = "unknown_skill"; + + /// The character cannot pay the skill's mana or ability cost. + public const string InsufficientResources = "insufficient_resources"; + + /// The path finder found no way to the requested position. + public const string NoPath = "no_path"; + + /// The target cannot be attacked (dead, not attackable, or the actor itself). + public const string InvalidTarget = "invalid_target"; + + /// The actor is dead and cannot act. + public const string Dead = "dead"; + + /// The actor is not (yet) in the world. + public const string NotReady = "not_ready"; + + /// A halt or a later command interrupted this one. + public const string Interrupted = "interrupted"; + + /// The requested warp list entry does not exist. + public const string UnknownGate = "unknown_gate"; + + /// The warp was refused by the game's level, zen or map rules. + public const string WarpRefused = "warp_refused"; + + /// The drop is not there any more, or could not be picked up. + public const string PickupFailed = "pickup_failed"; + + /// The command threw; the message carries the exception. + public const string Failed = "failed"; + + /// The account is already animated by an actor, a bot or a connected client. + public const string InUse = "in_use"; + + /// No actor animates the given account. + public const string UnknownActor = "unknown_actor"; + + /// This process does not host the requested game server. + public const string UnknownServer = "unknown_server"; + + /// The account or its character could not be loaded, so the actor never entered the world. + public const string SpawnFailed = "spawn_failed"; + + /// The request was not a JSON object, or its cmd is unknown. + public const string BadRequest = "bad_request"; + + /// + /// The game refused the skill without telling why - its plugin returns silently when the + /// character is stunned, in a safe zone, lacks mana or a requirement, or the target is not a + /// legal one for that skill. + /// + public const string SkillRefused = "skill_refused"; +} diff --git a/src/GameLogic/TestActors/ActorEvent.cs b/src/GameLogic/TestActors/ActorEvent.cs new file mode 100644 index 000000000..2ffc6710b --- /dev/null +++ b/src/GameLogic/TestActors/ActorEvent.cs @@ -0,0 +1,19 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// One recorded observation of a : what the game would have sent to the +/// client of that character, flattened into names, ids and numbers so it can be written as one JSON +/// line and asserted on from a shell. +/// +/// +/// The sequence number, strictly increasing per actor. Readers use it to fetch only what they have +/// not seen yet. +/// +/// The time the event was recorded. +/// The event type, e.g. hit, stat, killed, chat. +/// The type specific fields, in the order they should be written. +public sealed record ActorEvent(long Seq, DateTime Utc, string Type, IReadOnlyList Fields); diff --git a/src/GameLogic/TestActors/ActorEventField.cs b/src/GameLogic/TestActors/ActorEventField.cs new file mode 100644 index 000000000..40ccc9c87 --- /dev/null +++ b/src/GameLogic/TestActors/ActorEventField.cs @@ -0,0 +1,14 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// One field of an . Values are deliberately limited to strings, numbers and +/// booleans - an event never holds a reference to a game entity, so a reader can keep it forever +/// without pinning a player, monster or item. +/// +/// The field name as it appears in the JSON output. +/// The value; null is written as JSON null. +public readonly record struct ActorEventField(string Name, object? Value); diff --git a/src/GameLogic/TestActors/ActorEventLog.cs b/src/GameLogic/TestActors/ActorEventLog.cs new file mode 100644 index 000000000..e9447092c --- /dev/null +++ b/src/GameLogic/TestActors/ActorEventLog.cs @@ -0,0 +1,187 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Threading; +using System.Threading.Channels; + +/// +/// The event stream of one : a bounded ring of the most recent events +/// plus a fan-out to live followers. +/// +/// +/// Every writer is a game thread (a view call, the hit plugin point, a command), so appending must +/// never block and never wait for a reader. A follower which does not keep up therefore loses the +/// oldest events of its own channel and is told about it with a lag event instead of stalling +/// the game. The ring itself is unaffected by slow readers. +/// +public sealed class ActorEventLog +{ + /// + /// The number of events kept per actor. The specification asks for at least 1000; 4096 gives a + /// scenario room to run for a while before it has to read. + /// + public const int DefaultCapacity = 4096; + + /// + /// The number of events a live follower may fall behind before it starts losing the oldest ones. + /// + public const int DefaultSubscriptionCapacity = 1024; + + private readonly object _syncRoot = new(); + private readonly ActorEvent?[] _ring; + private readonly List _subscriptions = new(); + + private long _nextSequence = 1; + private int _count; + private int _nextIndex; + + /// + /// Initializes a new instance of the class. + /// + /// The number of events to keep; defaults to . + public ActorEventLog(int capacity = DefaultCapacity) + { + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "The event log needs room for at least one event."); + } + + this._ring = new ActorEvent?[capacity]; + } + + /// + /// Gets the number of events this log keeps. + /// + public int Capacity => this._ring.Length; + + /// + /// Gets the sequence number of the most recently assigned event, or 0 when nothing happened yet. + /// + public long LastSequence + { + get + { + lock (this._syncRoot) + { + return this._nextSequence - 1; + } + } + } + + /// + /// Appends an event to the log and hands it to every live follower. + /// + /// The event type. + /// The fields of the event. + /// The appended event, including the sequence number it was given. + public ActorEvent Append(string type, params ActorEventField[] fields) + { + lock (this._syncRoot) + { + // Lag events are created (and therefore numbered) BEFORE the event they precede, so a + // follower's stream stays strictly increasing. + foreach (var subscription in this._subscriptions) + { + var dropped = subscription.TakeDropCount(); + if (dropped > 0) + { + subscription.Write(this.CreateEvent("lag", [new ActorEventField("dropped", dropped)])); + } + } + + var appended = this.CreateEvent(type, fields); + this._ring[this._nextIndex] = appended; + this._nextIndex = (this._nextIndex + 1) % this._ring.Length; + if (this._count < this._ring.Length) + { + this._count++; + } + + foreach (var subscription in this._subscriptions) + { + subscription.Write(appended); + } + + return appended; + } + } + + /// + /// Gets the kept events which are newer than the given sequence number. + /// + /// The last sequence number the reader has already seen; 0 returns everything kept. + /// The matching events, oldest first. + public IReadOnlyList Since(long sequence) + { + lock (this._syncRoot) + { + return this.SnapshotSince(sequence); + } + } + + /// + /// Subscribes to the live stream of this log. + /// + /// + /// When given, the kept events newer than this sequence number are delivered first, so a follower + /// misses nothing between reading the history and subscribing. + /// + /// The follower's buffer size; defaults to . + /// The subscription; disposing it ends the stream. + public ActorEventSubscription Subscribe(long? sinceSequence = null, int capacity = DefaultSubscriptionCapacity) + { + var subscription = new ActorEventSubscription(this, capacity); + lock (this._syncRoot) + { + if (sinceSequence is { } since) + { + foreach (var backlogEvent in this.SnapshotSince(since)) + { + subscription.Write(backlogEvent); + } + } + + this._subscriptions.Add(subscription); + } + + return subscription; + } + + /// + /// Removes a subscription; called by . + /// + /// The subscription to remove. + internal void Unsubscribe(ActorEventSubscription subscription) + { + lock (this._syncRoot) + { + this._subscriptions.Remove(subscription); + } + } + + private ActorEvent CreateEvent(string type, IReadOnlyList fields) + { + // Lag events take a sequence number from the same counter but are not kept in the ring: they + // belong to one follower's stream, not to the actor's history. A reader therefore sees + // strictly increasing numbers, with a gap where a lag event was. + return new ActorEvent(this._nextSequence++, DateTime.UtcNow, type, fields); + } + + private IReadOnlyList SnapshotSince(long sequence) + { + var result = new List(this._count); + var start = (this._nextIndex - this._count + this._ring.Length) % this._ring.Length; + for (var i = 0; i < this._count; i++) + { + if (this._ring[(start + i) % this._ring.Length] is { } kept && kept.Seq > sequence) + { + result.Add(kept); + } + } + + return result; + } +} diff --git a/src/GameLogic/TestActors/ActorEventSubscription.cs b/src/GameLogic/TestActors/ActorEventSubscription.cs new file mode 100644 index 000000000..868c17b17 --- /dev/null +++ b/src/GameLogic/TestActors/ActorEventSubscription.cs @@ -0,0 +1,75 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Threading; +using System.Threading.Channels; + +/// +/// A live follower of an . +/// +public sealed class ActorEventSubscription : IDisposable +{ + private readonly ActorEventLog _log; + private readonly Channel _channel; + + private int _dropCount; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The log this subscription belongs to. + /// The buffer size before the oldest events are dropped. + internal ActorEventSubscription(ActorEventLog log, int capacity) + { + this._log = log; + this._channel = Channel.CreateBounded( + new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }, + _ => Interlocked.Increment(ref this._dropCount)); + } + + /// + /// Gets the reader of the live stream. + /// + public ChannelReader Reader => this._channel.Reader; + + /// + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._log.Unsubscribe(this); + this._channel.Writer.TryComplete(); + } + + /// + /// Hands an event to this follower. Never blocks: when the buffer is full, the oldest event is + /// dropped and counted, so the next append is preceded by a lag event. + /// + /// The event to deliver. + internal void Write(ActorEvent actorEvent) + { + this._channel.Writer.TryWrite(actorEvent); + } + + /// + /// Reads and resets the number of events dropped since the last call. + /// + /// The number of dropped events. + internal int TakeDropCount() + { + return Interlocked.Exchange(ref this._dropCount, 0); + } +} diff --git a/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs new file mode 100644 index 000000000..a1ebd69ba --- /dev/null +++ b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs @@ -0,0 +1,57 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.ComponentModel.DataAnnotations; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Records every hit an actor deals or receives, with the attribution the client views do not carry. +/// +/// +/// only tells the victim how much +/// damage it took - not who dealt it - so this plugin point is the only place where attacker and +/// victim are both known. It is therefore the single source of hit events: neither the +/// recording view container nor a command result appends one, so a hit is recorded exactly once per +/// involved actor (once on each side when two actors fight each other). +/// +[PlugIn] +[Display(Name = "Test actor hit recorder", Description = "Records hits dealt and received by scripted test actors in their event stream.")] +[Guid("2A7C4B18-6E5D-4C93-9F21-8D0B6A3E57C4")] +public class ActorHitRecorderPlugIn : IAttackableGotHitPlugIn +{ + /// + public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo) + { + if (attacker is ScriptedPlayer dealingActor && !ReferenceEquals(attacker, attackable)) + { + dealingActor.EventLog.Append( + "hit", + new ActorEventField("direction", "dealt"), + new ActorEventField("target_id", ActorObjects.GetId(attackable)), + new ActorEventField("target", ActorObjects.GetName(attackable)), + new ActorEventField("target_kind", ActorObjects.GetKind(attackable)), + new ActorEventField("health_damage", hitInfo.HealthDamage), + new ActorEventField("shield_damage", hitInfo.ShieldDamage), + new ActorEventField("attributes", hitInfo.Attributes.ToString())); + } + + if (attackable is ScriptedPlayer receivingActor && !ReferenceEquals(attacker, attackable)) + { + receivingActor.EventLog.Append( + "hit", + new ActorEventField("direction", "received"), + new ActorEventField("attacker_id", ActorObjects.GetId(attacker)), + new ActorEventField("attacker", ActorObjects.GetName(attacker)), + new ActorEventField("attacker_kind", ActorObjects.GetKind(attacker)), + new ActorEventField("health_damage", hitInfo.HealthDamage), + new ActorEventField("shield_damage", hitInfo.ShieldDamage), + new ActorEventField("attributes", hitInfo.Attributes.ToString())); + } + } +} diff --git a/src/GameLogic/TestActors/ActorJson.cs b/src/GameLogic/TestActors/ActorJson.cs new file mode 100644 index 000000000..0f8ccfa3a --- /dev/null +++ b/src/GameLogic/TestActors/ActorJson.cs @@ -0,0 +1,77 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Text.Json; + +/// +/// Writes the single-line JSON objects of the control protocol. +/// +public static class ActorJson +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = false, + }; + + /// + /// Writes one response line. + /// + /// The request id to echo; may be null. + /// The result of the command. + /// The JSON line, without the newline. + public static string WriteResponse(string? id, ActorCommandResult result) + { + var response = new Dictionary { ["ok"] = result.Ok }; + if (id is not null) + { + response["id"] = id; + } + + if (!result.Ok) + { + response["code"] = result.Code; + response["error"] = result.Error; + } + + foreach (var field in result.Fields) + { + response[field.Name] = field.Value; + } + + return JsonSerializer.Serialize(response, Options); + } + + /// + /// Writes one event line of a followed stream. + /// + /// The event. + /// The JSON line, without the newline. + public static string WriteEvent(ActorEvent actorEvent) + => JsonSerializer.Serialize(new Dictionary { ["event"] = ToDictionary(actorEvent) }, Options); + + /// + /// Flattens an event into the object the protocol writes: sequence number, timestamp, type and + /// then its own fields. + /// + /// The event. + /// The dictionary. + public static Dictionary ToDictionary(ActorEvent actorEvent) + { + var result = new Dictionary + { + ["seq"] = actorEvent.Seq, + ["utc"] = actorEvent.Utc.ToString("O"), + ["type"] = actorEvent.Type, + }; + + foreach (var field in actorEvent.Fields) + { + result[field.Name] = field.Value; + } + + return result; + } +} diff --git a/src/GameLogic/TestActors/ActorObjects.cs b/src/GameLogic/TestActors/ActorObjects.cs new file mode 100644 index 000000000..08a20e232 --- /dev/null +++ b/src/GameLogic/TestActors/ActorObjects.cs @@ -0,0 +1,83 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.NPC; + +/// +/// Describes the objects of the game world the way the actor's event stream and command results +/// refer to them: by id, by name and by kind, never by reference. +/// +public static class ActorObjects +{ + /// The kind of a scripted actor. + public const string ActorKind = "actor"; + + /// The kind of a server-side population bot. + public const string BotKind = "bot"; + + /// The kind of a human player, connected or in an offline session. + public const string PlayerKind = "player"; + + /// The kind of a monster. + public const string MonsterKind = "monster"; + + /// The kind of a non-attackable NPC. + public const string NpcKind = "npc"; + + /// The kind of a dropped item. + public const string ItemKind = "item"; + + /// The kind of anything else. + public const string UnknownKind = "unknown"; + + /// + /// Gets the kind of the given game object. + /// + /// The object; may be null. + /// One of the kind constants of this class. + public static string GetKind(object? gameObject) + { + return gameObject switch + { + ScriptedPlayer => ActorKind, + Player { Account.IsBot: true } => BotKind, + Player => PlayerKind, + Monster => MonsterKind, + NonPlayerCharacter => NpcKind, + DroppedItem => ItemKind, + DroppedMoney => ItemKind, + _ => UnknownKind, + }; + } + + /// + /// Gets the name a scenario can address the given game object by. + /// + /// The object; may be null. + /// The character name, the monster designation, the item name, or an empty string. + public static string GetName(object? gameObject) + { + return gameObject switch + { + Player player => player.Name, + NonPlayerCharacter npc => npc.Definition.Designation.ToString() ?? string.Empty, + DroppedItem droppedItem => droppedItem.Item.ToString() ?? string.Empty, + DroppedMoney droppedMoney => $"{droppedMoney.Amount} Zen", + _ => string.Empty, + }; + } + + /// + /// Gets the id of the given game object, which is unique on its map while it exists. + /// + /// The object; may be null. + /// The id, or 0 when the object has none. + public static ushort GetId(object? gameObject) + { + return gameObject is IIdentifiable identifiable ? identifiable.Id : (ushort)0; + } +} diff --git a/src/GameLogic/TestActors/ActorPathHeuristic.cs b/src/GameLogic/TestActors/ActorPathHeuristic.cs new file mode 100644 index 000000000..47b856241 --- /dev/null +++ b/src/GameLogic/TestActors/ActorPathHeuristic.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.Pathfinding; + +/// +/// The straight-line estimate which steers the actor's path finder towards its target. +/// +/// +/// The path finder searches the whole map for an actor (see +/// ), so it needs a heuristic to stay cheap - without one it +/// expands the map in all directions. The engine's own Euclidean heuristic is internal, so +/// this is the same two lines in our assembly. +/// +public sealed class ActorPathHeuristic : IHeuristic +{ + /// + public int HeuristicEstimateMultiplier { get; set; } + + /// + public int CalculateHeuristicDistance(Point location, Point target) + => (int)(this.HeuristicEstimateMultiplier * location.EuclideanDistanceTo(target)); +} diff --git a/src/GameLogic/TestActors/ActorProtocolHandler.cs b/src/GameLogic/TestActors/ActorProtocolHandler.cs new file mode 100644 index 000000000..f9886543f --- /dev/null +++ b/src/GameLogic/TestActors/ActorProtocolHandler.cs @@ -0,0 +1,247 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Reflection; +using System.Text.Json; +using System.Threading; + +/// +/// Speaks the newline-delimited JSON protocol of the control endpoint: one request object per line, +/// one response object per line, plus a streaming mode for events --follow. +/// +/// +/// Deliberately free of sockets, so the whole protocol can be tested without opening a port. +/// +public sealed class ActorProtocolHandler +{ + private readonly IActorRegistry _registry; + private readonly BotsController _bots; + + /// + /// Initializes a new instance of the class. + /// + /// The actor registry. + /// The population bot switch. + public ActorProtocolHandler(IActorRegistry registry, BotsController bots) + { + this._registry = registry; + this._bots = bots; + } + + /// + /// Gets the version this endpoint answers ping with. + /// + public static string Version { get; } = + typeof(ActorProtocolHandler).Assembly.GetCustomAttribute()?.Version ?? "0.0.0"; + + /// + /// Handles one request line. + /// + /// The received line. + /// Where the response lines go. + /// Cancelled when the connection goes away or the server stops. + /// The task. + public async ValueTask HandleLineAsync(string line, Func writer, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + string? id = null; + try + { + using var document = JsonDocument.Parse(line); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + await WriteFailureAsync(writer, null, ActorErrorCodes.BadRequest, "A request must be a JSON object.").ConfigureAwait(false); + return; + } + + var request = document.RootElement; + id = GetString(request, "id"); + var command = GetString(request, "cmd"); + if (string.IsNullOrEmpty(command)) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, "The request has no 'cmd'.").ConfigureAwait(false); + return; + } + + await this.DispatchAsync(command, request, id, writer, cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, $"The request is not valid JSON: {ex.Message}").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.Failed, ex.Message).ConfigureAwait(false); + } + } + + private static string? GetString(JsonElement request, string name) + => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static long? GetNumber(JsonElement request, string name) + => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt64() : null; + + private static bool GetBool(JsonElement request, string name) + => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + + private static string? GetTarget(JsonElement request) + { + if (request.TryGetProperty("target", out var value)) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.GetInt64().ToString(), + _ => null, + }; + } + + return null; + } + + private static ValueTask WriteFailureAsync(Func writer, string? id, string code, string error) + => writer(ActorJson.WriteResponse(id, ActorCommandResult.Failure(code, error))); + + private static ValueTask WriteResultAsync(Func writer, string? id, ActorCommandResult result) + => writer(ActorJson.WriteResponse(id, result)); + + private async ValueTask DispatchAsync(string command, JsonElement request, string? id, Func writer, CancellationToken cancellationToken) + { + switch (command) + { + case "ping": + await WriteResultAsync(writer, id, ActorCommandResult.Success( + new ActorEventField("version", Version), + new ActorEventField("actors", this._registry.List().Count))).ConfigureAwait(false); + return; + + case "spawn": + var spawned = await this._registry.SpawnAsync( + (int)(GetNumber(request, "server") ?? 0), + GetString(request, "actor") ?? string.Empty, + GetNumber(request, "slot") is { } slot ? (byte)slot : null).ConfigureAwait(false); + await WriteResultAsync(writer, id, spawned).ConfigureAwait(false); + return; + + case "stop": + await WriteResultAsync(writer, id, await this._registry.StopAsync(GetString(request, "actor") ?? string.Empty).ConfigureAwait(false)).ConfigureAwait(false); + return; + + case "list": + await WriteResultAsync(writer, id, ActorCommandResult.Success(new ActorEventField( + "actors", + this._registry.List().Select(a => ActorState.Summary(a.AccountLoginName ?? string.Empty, a)).ToList()))).ConfigureAwait(false); + return; + + case "bots": + await WriteResultAsync( + writer, + id, + await this._bots.HandleAsync(GetString(request, "action") ?? "status", (int?)GetNumber(request, "count")).ConfigureAwait(false)).ConfigureAwait(false); + return; + + default: + await this.DispatchActorCommandAsync(command, request, id, writer, cancellationToken).ConfigureAwait(false); + return; + } + } + + private async ValueTask DispatchActorCommandAsync(string command, JsonElement request, string? id, Func writer, CancellationToken cancellationToken) + { + var loginName = GetString(request, "actor") ?? string.Empty; + if (this._registry.Find(loginName) is not { } actor) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.UnknownActor, $"No actor animates '{loginName}'.").ConfigureAwait(false); + return; + } + + switch (command) + { + case "state": + await WriteResultAsync(writer, id, ActorCommandResult.Success( + new ActorEventField("state", ActorState.Full(loginName, actor)))).ConfigureAwait(false); + return; + + case "nearby": + await WriteResultAsync(writer, id, ActorCommandResult.Success( + new ActorEventField("objects", ActorState.Nearby(actor)))).ConfigureAwait(false); + return; + + case "halt": + var interrupted = actor.Intelligence?.Halt() ?? false; + await WriteResultAsync(writer, id, ActorCommandResult.Success( + new ActorEventField("interrupted", interrupted))).ConfigureAwait(false); + return; + + case "events": + await this.StreamEventsAsync(actor, request, id, writer, cancellationToken).ConfigureAwait(false); + return; + + default: + await this.ExecuteActorCommandAsync(command, request, actor, id, writer).ConfigureAwait(false); + return; + } + } + + private async ValueTask ExecuteActorCommandAsync(string command, JsonElement request, ScriptedPlayer actor, string? id, Func writer) + { + ActorCommand? actorCommand = command switch + { + "walk" => new WalkCommand((byte)(GetNumber(request, "x") ?? 0), (byte)(GetNumber(request, "y") ?? 0)), + "attack" => new AttackCommand( + GetTarget(request) ?? string.Empty, + (int)(GetNumber(request, "times") ?? 1), + (int)(GetNumber(request, "interval") ?? 0)), + "skill" => new SkillCommand((ushort)(GetNumber(request, "skill") ?? 0), GetTarget(request) ?? string.Empty), + "say" => new SayCommand(GetString(request, "text") ?? string.Empty), + "pickup" => new PickupCommand((ushort)(GetNumber(request, "id") ?? 0)), + "warp" => new WarpCommand((int)(GetNumber(request, "gate") ?? -1)), + _ => null, + }; + + if (actorCommand is null) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, $"Unknown command '{command}'.").ConfigureAwait(false); + return; + } + + if (actor.Intelligence is not { } intelligence) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.NotReady, "The actor is not (yet) able to act.").ConfigureAwait(false); + return; + } + + await WriteResultAsync(writer, id, await intelligence.ExecuteAsync(actorCommand).ConfigureAwait(false)).ConfigureAwait(false); + } + + private async ValueTask StreamEventsAsync(ScriptedPlayer actor, JsonElement request, string? id, Func writer, CancellationToken cancellationToken) + { + var since = GetNumber(request, "since") ?? 0; + if (!GetBool(request, "follow")) + { + await WriteResultAsync(writer, id, ActorCommandResult.Success( + new ActorEventField("events", actor.EventLog.Since(since).Select(ActorJson.ToDictionary).ToList()), + new ActorEventField("last_seq", actor.EventLog.LastSequence))).ConfigureAwait(false); + return; + } + + // Stream mode: this connection belongs to the follower until it goes away. + using var subscription = actor.EventLog.Subscribe(since); + await WriteResultAsync(writer, id, ActorCommandResult.Success(new ActorEventField("follow", true))).ConfigureAwait(false); + await foreach (var actorEvent in subscription.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + await writer(ActorJson.WriteEvent(actorEvent)).ConfigureAwait(false); + } + } +} diff --git a/src/GameLogic/TestActors/ActorRegistry.cs b/src/GameLogic/TestActors/ActorRegistry.cs new file mode 100644 index 000000000..cee5072cd --- /dev/null +++ b/src/GameLogic/TestActors/ActorRegistry.cs @@ -0,0 +1,220 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Threading; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// The actors of this process. +/// +/// +/// Spawning takes the account the way a real client does - through +/// , which is the cross-server +/// lock - and additionally scans the target game server's players for the same login name, because +/// population bots and offline sessions bypass the login server. Both checks and the insertion run +/// under one lock, so two concurrent spawns of one account yield exactly one actor. +/// +public sealed class ActorRegistry : IActorRegistry +{ + private readonly Dictionary _actors = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly IGameServerContextLocator _locator; + private readonly IActorFactory _factory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Resolves the game server contexts of this process. + /// Creates the actors. + /// The logger. + public ActorRegistry(IGameServerContextLocator locator, IActorFactory factory, ILogger logger) + { + this._locator = locator; + this._factory = factory; + this._logger = logger; + } + + /// + public async ValueTask SpawnAsync(int serverId, string loginName, byte? characterSlot) + { + if (string.IsNullOrWhiteSpace(loginName)) + { + return ActorCommandResult.Failure(ActorErrorCodes.UnknownActor, "No account was given."); + } + + if (this._locator.GetContext(serverId) is not { } context) + { + return ActorCommandResult.Failure(ActorErrorCodes.UnknownServer, $"There is no game server {serverId} in this process."); + } + + await this._lock.WaitAsync().ConfigureAwait(false); + var loginServerAcquired = false; + try + { + if (this._actors.ContainsKey(loginName)) + { + return InUse(loginName, "an actor"); + } + + // Every game server of this process, not just the target one: the population is split + // over the servers (BotServerPartition), so a bot animating this account may well live + // on another one - and two players driving one character means two persistence contexts + // saving it, which is exactly the corruption the bot code warns about. + foreach (var (serverIdOfContext, otherContext) in this._locator.GetContexts()) + { + var players = await otherContext.GetPlayersAsync().ConfigureAwait(false); + if (players.FirstOrDefault(p => string.Equals(p.Account?.LoginName, loginName, StringComparison.OrdinalIgnoreCase)) is { } occupant) + { + return InUse(loginName, $"{DescribeOccupant(occupant)} on game server {serverIdOfContext}"); + } + } + + if (!await context.LoginServer.TryLoginAsync(loginName, context.Id).ConfigureAwait(false)) + { + return InUse(loginName, "a connected client or another game server"); + } + + loginServerAcquired = true; + + var actor = await this._factory.CreateAsync(context, loginName, characterSlot).ConfigureAwait(false); + if (actor is null) + { + return ActorCommandResult.Failure( + ActorErrorCodes.SpawnFailed, + $"The actor for {loginName} could not be started; see the server log."); + } + + this._actors[loginName] = actor; + loginServerAcquired = false; + return ActorCommandResult.Success(ActorState.Describe(loginName, actor)); + } + finally + { + if (loginServerAcquired + && this._locator.GetContext(serverId) is { } releaseContext) + { + await releaseContext.LoginServer.LogOffAsync(loginName, releaseContext.Id).ConfigureAwait(false); + } + + this._lock.Release(); + } + } + + /// + public async ValueTask StopAsync(string loginName) + { + await this._lock.WaitAsync().ConfigureAwait(false); + ScriptedPlayer? actor; + try + { + if (!this._actors.Remove(loginName, out actor)) + { + return ActorCommandResult.Failure(ActorErrorCodes.UnknownActor, $"No actor animates {loginName}."); + } + } + finally + { + this._lock.Release(); + } + + await this.StopAndDisposeAsync(loginName, actor).ConfigureAwait(false); + return ActorCommandResult.Success(new ActorEventField("actor", loginName)); + } + + /// + public async ValueTask StopAllAsync() + { + await this._lock.WaitAsync().ConfigureAwait(false); + List> actors; + try + { + actors = this._actors.ToList(); + this._actors.Clear(); + } + finally + { + this._lock.Release(); + } + + foreach (var (loginName, actor) in actors) + { + await this.StopAndDisposeAsync(loginName, actor).ConfigureAwait(false); + } + + return actors.Count; + } + + /// + public IReadOnlyList List() + { + this._lock.Wait(); + try + { + return this._actors.Values.ToList(); + } + finally + { + this._lock.Release(); + } + } + + /// + public ScriptedPlayer? Find(string loginName) + { + this._lock.Wait(); + try + { + return this._actors.GetValueOrDefault(loginName); + } + finally + { + this._lock.Release(); + } + } + + private static string DescribeOccupant(Player occupant) + { + return occupant switch + { + ScriptedPlayer => "an actor", + _ when occupant.Account?.IsBot == true => "a bot", + OfflinePlayer => "an offline session", + _ => "a connected client", + }; + } + + private static ActorCommandResult InUse(string loginName, string by) + => ActorCommandResult.Failure( + ActorErrorCodes.InUse, + $"The account {loginName} is already in use by {by}.", + new ActorEventField("actor", loginName)); + + private async ValueTask StopAndDisposeAsync(string loginName, ScriptedPlayer actor) + { + var context = actor.GameContext as IGameServerContext; + try + { + await actor.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error while stopping the actor {Actor}.", loginName); + } + finally + { + if (context is not null) + { + await context.LoginServer.LogOffAsync(loginName, context.Id).ConfigureAwait(false); + } + + await actor.DisposeAsync().ConfigureAwait(false); + this._logger.LogInformation("Actor {Actor} stopped.", loginName); + } + } +} diff --git a/src/GameLogic/TestActors/ActorState.cs b/src/GameLogic/TestActors/ActorState.cs new file mode 100644 index 000000000..9690bbfde --- /dev/null +++ b/src/GameLogic/TestActors/ActorState.cs @@ -0,0 +1,133 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// Turns an actor and its surroundings into the flat dictionaries the control endpoint answers with. +/// +public static class ActorState +{ + /// + /// Describes an actor as one result field, as spawn and list report it. + /// + /// The login name of the account. + /// The actor. + /// The field. + public static ActorEventField Describe(string loginName, ScriptedPlayer actor) + => new("actor", Summary(loginName, actor)); + + /// + /// The short form: who the actor is and where it stands. + /// + /// The login name of the account. + /// The actor. + /// The summary. + public static Dictionary Summary(string loginName, ScriptedPlayer actor) + { + return new Dictionary + { + ["actor"] = loginName, + ["character"] = actor.Name, + ["map"] = actor.CurrentMap?.Definition.Name.ToString() ?? string.Empty, + ["map_number"] = actor.CurrentMap?.Definition.Number ?? -1, + ["x"] = actor.Position.X, + ["y"] = actor.Position.Y, + ["alive"] = actor.IsAlive, + }; + } + + /// + /// The full state a scenario asserts on. + /// + /// The login name of the account. + /// The actor. + /// The state. + public static Dictionary Full(string loginName, ScriptedPlayer actor) + { + var attributes = actor.Attributes; + var state = Summary(loginName, actor); + state["class"] = actor.SelectedCharacter?.CharacterClass?.Name.ToString() ?? string.Empty; + state["level"] = (int)(attributes?[Stats.Level] ?? 0); + state["master_level"] = (int)(attributes?[Stats.MasterLevel] ?? 0); + state["health"] = (int)(attributes?[Stats.CurrentHealth] ?? 0); + state["max_health"] = (int)(attributes?[Stats.MaximumHealth] ?? 0); + state["shield"] = (int)(attributes?[Stats.CurrentShield] ?? 0); + state["max_shield"] = (int)(attributes?[Stats.MaximumShield] ?? 0); + state["mana"] = (int)(attributes?[Stats.CurrentMana] ?? 0); + state["max_mana"] = (int)(attributes?[Stats.MaximumMana] ?? 0); + state["ability"] = (int)(attributes?[Stats.CurrentAbility] ?? 0); + state["max_ability"] = (int)(attributes?[Stats.MaximumAbility] ?? 0); + state["zen"] = actor.Money; + state["hero_state"] = actor.SelectedCharacter?.State.ToString() ?? string.Empty; + state["target"] = actor.LastAttackedTarget.TryGetTarget(out var target) && target is not null + ? ActorObjects.GetName(target) + : null; + state["party"] = actor.Party?.PartyList.Select(m => m.Name).ToList() ?? []; + state["in_view"] = Nearby(actor); + state["last_seq"] = actor.EventLog.LastSequence; + return state; + } + + /// + /// The objects currently in the actor's view. + /// + /// The actor. + /// The objects, with id, kind, name, position and - where it applies - the alive flag. + public static List> Nearby(ScriptedPlayer actor) + { + var result = new List>(); + if (actor.CurrentMap is not { } map) + { + return result; + } + + var position = actor.Position; + var range = actor.InfoRange; + + foreach (var attackable in map.GetAttackablesInRange(position, range)) + { + if (ReferenceEquals(attackable, actor)) + { + continue; + } + + result.Add(Describe(attackable, attackable.IsAlive)); + } + + foreach (var npc in map.GetNpcsInRange(position, range)) + { + if (npc is IAttackable) + { + // Already listed above; monsters are attackable NPCs. + continue; + } + + result.Add(Describe(npc, null)); + } + + foreach (var drop in map.GetDropsInRange(position, range)) + { + result.Add(Describe(drop, null)); + } + + return result; + } + + private static Dictionary Describe(ILocateable gameObject, bool? alive) + { + return new Dictionary + { + ["id"] = gameObject.Id, + ["kind"] = ActorObjects.GetKind(gameObject), + ["name"] = ActorObjects.GetName(gameObject), + ["x"] = gameObject.Position.X, + ["y"] = gameObject.Position.Y, + ["alive"] = alive, + }; + } +} diff --git a/src/GameLogic/TestActors/AttackCommand.cs b/src/GameLogic/TestActors/AttackCommand.cs new file mode 100644 index 000000000..607b3bc44 --- /dev/null +++ b/src/GameLogic/TestActors/AttackCommand.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Performs plain attacks against an object in view. +/// +/// The target's id or character name. +/// How many attacks to perform. +/// The delay between two attacks, in milliseconds. +public sealed record AttackCommand(string Target, int Times, int IntervalMs) : ActorCommand("attack"); diff --git a/src/GameLogic/TestActors/BotsController.cs b/src/GameLogic/TestActors/BotsController.cs new file mode 100644 index 000000000..bbbde453f --- /dev/null +++ b/src/GameLogic/TestActors/BotsController.cs @@ -0,0 +1,183 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.PlugIns; + +/// +/// The bots command of the control endpoint: switches the server's own population bots on and +/// off, and reports how many of them are animated. +/// +/// +/// The switch goes through the persisted of the bot feature - the +/// same route the admin panel's plugin dialog takes - so it survives a restart and is visible in the +/// panel. listens to the configuration's PropertyChanged and +/// pushes it to the live plugin. +/// +public sealed class BotsController +{ + /// + /// The number of animated bot characters bots on asks for when no count is given. + /// + public const int DefaultBotCount = 4; + + private readonly IGameServerContextLocator _locator; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Resolves the game server contexts of this process. + /// The logger. + public BotsController(IGameServerContextLocator locator, ILogger logger) + { + this._locator = locator; + this._logger = logger; + } + + /// + /// Applies the requested bot configuration to the given one, so that is + /// the number of animated bot characters in total - not accounts times characters. + /// + /// The configuration to change. + /// Whether the feature should be on. + /// The number of bot characters; ignored when switching off. + public static void Apply(BotConfiguration configuration, bool enabled, int? count) + { + configuration.Enabled = enabled; + if (enabled) + { + configuration.NumberOfAccounts = Math.Max(0, count ?? DefaultBotCount); + + // One character per account, so the requested number is what stands in the world, and no + // bot logs out again while a scenario runs. + configuration.MaxCharactersPerAccount = 1; + configuration.PresenceRotation = false; + } + } + + /// + /// Handles a bots request. + /// + /// One of on, off and status. + /// The requested number of bot characters, for on. + /// The result. + public async ValueTask HandleAsync(string action, int? count) + { + if (this._locator.GetContexts() is not { Count: > 0 } contexts) + { + return ActorCommandResult.Failure(ActorErrorCodes.UnknownServer, "This process hosts no game server."); + } + + var context = contexts[0].Context; + switch (action) + { + case "on": + case "off": + var enabled = action == "on"; + if (BotFeaturePlugIn.GetConfiguration(context) is not { } configuration) + { + return ActorCommandResult.Failure( + ActorErrorCodes.Failed, + "The bot feature plugin is not loaded in this server."); + } + + Apply(configuration, enabled, count); + if (await this.PersistAsync(context, configuration).ConfigureAwait(false) is { } failure) + { + return failure; + } + + this._logger.LogInformation( + "Bots switched {State} ({Count} account(s), one character each).", + enabled ? "on" : "off", + configuration.NumberOfAccounts); + return await this.StatusAsync(contexts).ConfigureAwait(false); + + case "status": + return await this.StatusAsync(contexts).ConfigureAwait(false); + + default: + return ActorCommandResult.Failure( + ActorErrorCodes.BadRequest, + $"Unknown bots action '{action}'; expected 'on', 'off' or 'status'."); + } + } + + private async ValueTask PersistAsync(IGameServerContext context, BotConfiguration configuration) + { + try + { + // A fresh context, so the PlugInConfiguration entity is tracked and the change is saved; + // the cached in-memory configuration graph is not tracked. + using var persistenceContext = context.PersistenceContextProvider.CreateNewContext(); + var typeId = typeof(BotFeaturePlugIn).GUID; + var gameConfiguration = (await persistenceContext.GetAsync().ConfigureAwait(false)).FirstOrDefault(); + var entity = gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId); + if (entity is null) + { + return ActorCommandResult.Failure( + ActorErrorCodes.Failed, + "The bot plugin configuration row does not exist in the database."); + } + + entity.SetConfiguration(configuration, context.PlugInManager.CustomConfigReferenceHandler); + await persistenceContext.SaveChangesAsync().ConfigureAwait(false); + return null; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Failed to persist the bot plugin configuration."); + return ActorCommandResult.Failure(ActorErrorCodes.Failed, ex.Message); + } + } + + private async ValueTask StatusAsync(IReadOnlyList<(int ServerId, IGameServerContext Context)> contexts) + { + var configuration = BotFeaturePlugIn.GetConfiguration(contexts[0].Context); + var perServer = new List>(); + var total = 0; + foreach (var (serverId, context) in contexts) + { + // The plugin's own per-server BotManager is private, so the animated bots are counted + // where they actually are: among the players of that server's world. + var players = await context.GetPlayersAsync().ConfigureAwait(false); + var bots = players.Where(p => p.Account?.IsBot == true).ToList(); + total += bots.Count; + perServer.Add(new Dictionary + { + ["server"] = serverId, + ["animated"] = bots.Count, + + // Where they actually are, so a scenario can walk an actor to one instead of + // hunting for it: a bot's saved position is only where it last logged out. + ["bots"] = bots.Select(bot => new Dictionary + { + ["account"] = bot.Account?.LoginName ?? string.Empty, + ["character"] = bot.Name, + ["id"] = bot.Id, + ["level"] = (int)(bot.Attributes?[Stats.Level] ?? 0), + ["map"] = bot.CurrentMap?.Definition.Name.ToString() ?? string.Empty, + ["map_number"] = bot.CurrentMap?.Definition.Number ?? -1, + ["x"] = bot.Position.X, + ["y"] = bot.Position.Y, + ["alive"] = bot.IsAlive, + }).ToList(), + }); + } + + return ActorCommandResult.Success( + new ActorEventField("enabled", configuration?.Enabled ?? false), + new ActorEventField("accounts", configuration?.NumberOfAccounts ?? 0), + new ActorEventField("characters_per_account", configuration?.MaxCharactersPerAccount ?? 0), + new ActorEventField("servers", perServer), + new ActorEventField("animated", total)); + } +} diff --git a/src/GameLogic/TestActors/IActorFactory.cs b/src/GameLogic/TestActors/IActorFactory.cs new file mode 100644 index 000000000..f31d2682f --- /dev/null +++ b/src/GameLogic/TestActors/IActorFactory.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.GameLogic; + +/// +/// Creates the actors the manages. +/// +public interface IActorFactory +{ + /// + /// Creates an actor and lets it enter the world. + /// + /// The game server context to spawn on. + /// The login name of an existing account. + /// The character slot to animate; null takes the lowest slot. + /// The actor, or null when it could not enter the world. + ValueTask CreateAsync(IGameServerContext context, string loginName, byte? characterSlot); +} + +/// +/// Creates real actors which load their account from the database, like a bot does. +/// +public sealed class ActorFactory : IActorFactory +{ + /// + public async ValueTask CreateAsync(IGameServerContext context, string loginName, byte? characterSlot) + { + var actor = new ScriptedPlayer(context); + if (await actor.InitializeAsync(loginName, characterSlot).ConfigureAwait(false)) + { + return actor; + } + + await actor.DisposeAsync().ConfigureAwait(false); + return null; + } +} diff --git a/src/GameLogic/TestActors/IActorRegistry.cs b/src/GameLogic/TestActors/IActorRegistry.cs new file mode 100644 index 000000000..6d24266de --- /dev/null +++ b/src/GameLogic/TestActors/IActorRegistry.cs @@ -0,0 +1,46 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Keeps the actors of this process, keyed by the login name of the account they animate. +/// +public interface IActorRegistry +{ + /// + /// Spawns an actor for the given account on the given game server. + /// + /// The id of the game server to spawn on. + /// The login name of an existing account. + /// The character slot to animate; null takes the lowest slot. + /// The spawned actor, or the reason why it was refused. + ValueTask SpawnAsync(int serverId, string loginName, byte? characterSlot); + + /// + /// Stops an actor through the normal logout path, so its progress is saved. + /// + /// The login name of the actor. + /// The result; a failure when no such actor is animated. + ValueTask StopAsync(string loginName); + + /// + /// Stops every actor. Used on shutdown, before the game servers stop. + /// + /// The number of actors which were stopped. + ValueTask StopAllAsync(); + + /// + /// Gets the currently animated actors. + /// + /// The actors. + IReadOnlyList List(); + + /// + /// Gets the actor which animates the given account, if any. + /// + /// The login name. + /// The actor, or null. + ScriptedPlayer? Find(string loginName); +} diff --git a/src/GameLogic/TestActors/IGameServerContextLocator.cs b/src/GameLogic/TestActors/IGameServerContextLocator.cs new file mode 100644 index 000000000..799011251 --- /dev/null +++ b/src/GameLogic/TestActors/IGameServerContextLocator.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Interfaces; + +/// +/// Resolves the game server contexts which run in this process. +/// +public interface IGameServerContextLocator +{ + /// + /// Gets the context of the game server with the given id. + /// + /// The server id. + /// The context, or null when this process does not host that server. + IGameServerContext? GetContext(int serverId); + + /// + /// Gets every game server context of this process, by server id. + /// + /// The contexts. + IReadOnlyList<(int ServerId, IGameServerContext Context)> GetContexts(); +} + +/// +/// Resolves the contexts from the game servers of the all-in-one host, which implement +/// . +/// +public sealed class GameServerContextLocator : IGameServerContextLocator +{ + private readonly IDictionary _gameServers; + + /// + /// Initializes a new instance of the class. + /// + /// The game servers of this process. + public GameServerContextLocator(IDictionary gameServers) + { + this._gameServers = gameServers; + } + + /// + public IGameServerContext? GetContext(int serverId) + { + return this._gameServers.TryGetValue(serverId, out var gameServer) && gameServer is IGameServerContextProvider provider + ? provider.Context + : null; + } + + /// + public IReadOnlyList<(int ServerId, IGameServerContext Context)> GetContexts() + { + return this._gameServers + .Where(pair => pair.Value is IGameServerContextProvider) + .Select(pair => (pair.Key, ((IGameServerContextProvider)pair.Value).Context)) + .OrderBy(pair => pair.Key) + .ToList(); + } +} diff --git a/src/GameLogic/TestActors/PickupCommand.cs b/src/GameLogic/TestActors/PickupCommand.cs new file mode 100644 index 000000000..93857fb42 --- /dev/null +++ b/src/GameLogic/TestActors/PickupCommand.cs @@ -0,0 +1,11 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Picks up a dropped item which is in view. +/// +/// The id of the drop. +public sealed record PickupCommand(ushort DropId) : ActorCommand("pickup"); diff --git a/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs b/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs new file mode 100644 index 000000000..b76086094 --- /dev/null +++ b/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs @@ -0,0 +1,298 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.Character; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// +/// The "client" of a : instead of sending packets, every view call it +/// implements is written to the actor's . +/// +/// +/// Like the offline player's container it answers null for every view it does not implement - +/// the game logic looks views up null-conditionally throughout - so only the callbacks a scenario +/// asserts on have to be covered. Hits are deliberately NOT recorded here: +/// carries no attacker, so the attribution comes from +/// instead, and recording both would double every hit. +/// +public sealed class RecordingViewPlugInContainer : + ICustomPlugInContainer, + IMapChangePlugIn, + IRespawnAfterDeathPlugIn, + IObjectGotKilledPlugIn, + IUpdateStatsPlugIn, + IChatViewPlugIn, + IShowDroppedItemsPlugIn, + IDroppedItemsDisappearedPlugIn, + INewPlayersInScopePlugIn, + INewNpcsInScopePlugIn, + IObjectsOutOfScopePlugIn, + IObjectMovedPlugIn, + IShowMessagePlugIn, + IShowSkillAnimationPlugIn +{ + /// + /// The attributes whose changes are recorded as stat events. The engine pushes an update + /// for EVERY attribute it recalculates - an idle actor produced ~600 events per minute of shield + /// recovery bookkeeping alone, which wrapped the event ring within minutes and buried the changes + /// a scenario actually asserts on. Only the combat-relevant stats are kept. + /// + private static readonly HashSet RecordedStats = + [ + Stats.CurrentHealth, + Stats.MaximumHealth, + Stats.CurrentShield, + Stats.MaximumShield, + Stats.CurrentMana, + Stats.MaximumMana, + Stats.CurrentAbility, + Stats.MaximumAbility, + Stats.Level, + Stats.MasterLevel, + ]; + + private readonly ScriptedPlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The actor whose views are recorded. + public RecordingViewPlugInContainer(ScriptedPlayer player) + { + this._player = player; + } + + private ActorEventLog Log => this._player.EventLog; + + /// + public T? GetPlugIn() + where T : class, IViewPlugIn + { + // Everything this container implements is served by itself; every other view stays null, + // exactly as the offline player's container does it. + return this as T; + } + + /// + public async ValueTask MapChangeAsync() + { + // What OfflineMapChangePlugIn does: without it the character never finishes entering the map. + await this._player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + this.Log.Append( + "map", + new ActorEventField("map", this._player.CurrentMap?.Definition.Name.ToString() ?? string.Empty), + new ActorEventField("map_number", this._player.CurrentMap?.Definition.Number ?? -1), + new ActorEventField("x", this._player.Position.X), + new ActorEventField("y", this._player.Position.Y)); + } + + /// + public ValueTask MapChangeFailedAsync() + { + this.Log.Append("error", new ActorEventField("code", "map_change_failed")); + return ValueTask.CompletedTask; + } + + /// + public ValueTask RespawnAsync() + { + // The engine respawns server-side; the actor just notes where it woke up and keeps running. + this.Log.Append( + "respawn", + new ActorEventField("map", this._player.CurrentMap?.Definition.Name.ToString() ?? string.Empty), + new ActorEventField("x", this._player.Position.X), + new ActorEventField("y", this._player.Position.Y)); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ObjectGotKilledAsync(IAttackable killedObject, IAttacker? killerObject) + { + this.Log.Append( + "killed", + new ActorEventField("victim_id", ActorObjects.GetId(killedObject)), + new ActorEventField("victim", ActorObjects.GetName(killedObject)), + new ActorEventField("victim_kind", ActorObjects.GetKind(killedObject)), + new ActorEventField("killer_id", ActorObjects.GetId(killerObject)), + new ActorEventField("killer", ActorObjects.GetName(killerObject)), + new ActorEventField("killer_kind", ActorObjects.GetKind(killerObject))); + return ValueTask.CompletedTask; + } + + /// + public ValueTask UpdateStatsAsync(AttributeDefinition attribute, float value) + { + if (!RecordedStats.Contains(attribute)) + { + return ValueTask.CompletedTask; + } + + this.Log.Append( + "stat", + new ActorEventField("attribute", attribute.Designation ?? string.Empty), + new ActorEventField("value", value)); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ChatMessageAsync(string message, string sender, ChatMessageType type) + { + this.Log.Append( + "chat", + new ActorEventField("sender", sender), + new ActorEventField("message", message), + new ActorEventField("chat_type", type.ToString())); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ShowDroppedItemsAsync(IEnumerable droppedItems, bool freshDrops) + { + foreach (var droppedItem in droppedItems) + { + this.Log.Append( + "drop", + new ActorEventField("id", droppedItem.Id), + new ActorEventField("name", ActorObjects.GetName(droppedItem)), + new ActorEventField("x", droppedItem.Position.X), + new ActorEventField("y", droppedItem.Position.Y), + new ActorEventField("fresh", freshDrops)); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DroppedItemsDisappearedAsync(IEnumerable disappearedItemIds) + { + foreach (var itemId in disappearedItemIds) + { + this.Log.Append("drop_gone", new ActorEventField("id", itemId)); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask NewPlayersInScopeAsync(IEnumerable newObjects, bool isSpawned = true) + { + foreach (var newObject in newObjects) + { + this.AppendInView(newObject, isSpawned); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask NewNpcsInScopeAsync(IEnumerable newObjects, bool isSpawned = true) + { + foreach (var newObject in newObjects) + { + this.AppendInView(newObject, isSpawned); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask ObjectsOutOfScopeAsync(IEnumerable objects) + { + foreach (var goneObject in objects) + { + this.Log.Append("out_of_view", new ActorEventField("id", goneObject.Id)); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask ObjectMovedAsync(ILocateable movedObject, MoveType moveType) + { + this.Log.Append( + "moved", + new ActorEventField("id", ActorObjects.GetId(movedObject)), + new ActorEventField("name", ActorObjects.GetName(movedObject)), + new ActorEventField("kind", ActorObjects.GetKind(movedObject)), + new ActorEventField("x", movedObject.Position.X), + new ActorEventField("y", movedObject.Position.Y), + new ActorEventField("move_type", moveType.ToString())); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ShowMessageAsync(string message, MessageType messageType) + { + this.Log.Append( + "message", + new ActorEventField("message", message), + new ActorEventField("message_type", messageType.ToString())); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ShowSkillAnimationAsync(IAttacker attacker, IAttackable? target, Skill skill, bool effectApplied) + { + return this.AppendSkillAsync(attacker, target, (short)skill.Number, effectApplied); + } + + /// + public ValueTask ShowSkillAnimationAsync(IAttacker attacker, IAttackable? target, short skillNumber, bool effectApplied) + { + return this.AppendSkillAsync(attacker, target, skillNumber, effectApplied); + } + + /// + public ValueTask ShowComboAnimationAsync(IAttacker attacker, IAttackable? target) + { + this.Log.Append( + "combo", + new ActorEventField("attacker", ActorObjects.GetName(attacker)), + new ActorEventField("target", ActorObjects.GetName(target))); + return ValueTask.CompletedTask; + } + + /// + public ValueTask ShowNovaStartAsync(IAttacker attacker) + { + this.Log.Append("nova_start", new ActorEventField("attacker", ActorObjects.GetName(attacker))); + return ValueTask.CompletedTask; + } + + private ValueTask AppendSkillAsync(IAttacker attacker, IAttackable? target, short skillNumber, bool effectApplied) + { + this.Log.Append( + "skill", + new ActorEventField("skill", skillNumber), + new ActorEventField("attacker_id", ActorObjects.GetId(attacker)), + new ActorEventField("attacker", ActorObjects.GetName(attacker)), + new ActorEventField("target_id", ActorObjects.GetId(target)), + new ActorEventField("target", ActorObjects.GetName(target)), + new ActorEventField("applied", effectApplied)); + return ValueTask.CompletedTask; + } + + private void AppendInView(ILocateable inViewObject, bool isSpawned) + { + this.Log.Append( + "in_view", + new ActorEventField("id", ActorObjects.GetId(inViewObject)), + new ActorEventField("name", ActorObjects.GetName(inViewObject)), + new ActorEventField("kind", ActorObjects.GetKind(inViewObject)), + new ActorEventField("x", inViewObject.Position.X), + new ActorEventField("y", inViewObject.Position.Y), + new ActorEventField("alive", inViewObject is IAttackable attackable ? attackable.IsAlive : (object?)null), + new ActorEventField("spawned", isSpawned)); + } +} diff --git a/src/GameLogic/TestActors/SayCommand.cs b/src/GameLogic/TestActors/SayCommand.cs new file mode 100644 index 000000000..760451d86 --- /dev/null +++ b/src/GameLogic/TestActors/SayCommand.cs @@ -0,0 +1,11 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Says something, including the chat commands a client can send (e.g. /ver). +/// +/// The message. +public sealed record SayCommand(string Text) : ActorCommand("say"); diff --git a/src/GameLogic/TestActors/ScriptedIntelligence.cs b/src/GameLogic/TestActors/ScriptedIntelligence.cs new file mode 100644 index 000000000..307de04c0 --- /dev/null +++ b/src/GameLogic/TestActors/ScriptedIntelligence.cs @@ -0,0 +1,692 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using System.Threading; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlayerActions; +using MUnique.OpenMU.GameLogic.PlayerActions.Chat; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.GameLogic.PlayerActions.Skills; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Pathfinding; + +/// +/// What the MU Helper AI is to an offline player, this is to a scripted actor: the one place which +/// drives the character - except that it executes commands instead of hunting. +/// +/// +/// Every command runs inside , +/// so a command never overlaps the periodic save or another command, and the engine's non-thread-safe +/// attribute system is only ever touched from this one flow. Long commands (a walk, a repeated attack) +/// release the lock between their steps, so a halt or a later command can interrupt them: the +/// interrupted command answers its caller with and the +/// progress it made, and an interrupted event is recorded. +/// +public sealed class ScriptedIntelligence : IAsyncDisposable +{ + private const byte MeleeAttackRange = 1; + private const byte BowAttackRange = 6; + + /// How many steps one call takes at most. + private const int MaxStepsPerWalk = 16; + + private const int WalkPollMilliseconds = 100; + + /// + /// How many nodes the actor's path finder may expand before it gives up on a target. + /// + private const int PathSearchLimit = 20000; + + private static readonly TargetedSkillDefaultPlugin DefaultSkillPlugin = new(); + + private readonly ScriptedPlayer _player; + private readonly Channel _queue; + private readonly CancellationTokenSource _stopSource = new(); + private readonly object _syncRoot = new(); + + private CancellationTokenSource? _currentSource; + private Task? _loopTask; + private PathFinder? _pathFinder; + + /// + /// Initializes a new instance of the class. + /// + /// The actor this intelligence drives. + public ScriptedIntelligence(ScriptedPlayer player) + { + this._player = player; + this._queue = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + } + + /// + /// Starts the command loop. + /// + public void Start() + { + this._loopTask ??= Task.Run(() => this.RunAsync(this._stopSource.Token)); + } + + /// + /// Queues a command. A command which is still in flight is interrupted first: at most one long + /// command runs per actor, and the last one wins. + /// + /// The command to execute. + /// The result of the command, once it ran. + public ValueTask ExecuteAsync(ActorCommand command) + { + this.CancelCurrent(); + var pending = new PendingCommand(command); + if (!this._queue.Writer.TryWrite(pending)) + { + return ValueTask.FromResult(ActorCommandResult.Failure( + ActorErrorCodes.NotReady, + "The actor is stopping and does not accept commands any more.")); + } + + return new ValueTask(pending.Completion.Task); + } + + /// + /// Cancels the command in flight, keeping the actor in the world. + /// + /// true if there was a command to interrupt. + public bool Halt() + { + return this.CancelCurrent(); + } + + /// + public async ValueTask DisposeAsync() + { + this._queue.Writer.TryComplete(); + await this._stopSource.CancelAsync().ConfigureAwait(false); + if (this._loopTask is { } loopTask) + { + try + { + await loopTask.ConfigureAwait(false); + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "The command loop of actor {Actor} failed while stopping.", this._player.AccountLoginName); + } + } + + this._stopSource.Dispose(); + } + + private static byte GetEffectiveAttackRange(Player player) + { + // The same rule the MU Helper's CombatHandler applies: there is no attack-range attribute. + if (player.Attributes is { } attributes + && (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0)) + { + return BowAttackRange; + } + + return MeleeAttackRange; + } + + private bool CancelCurrent() + { + CancellationTokenSource? source; + lock (this._syncRoot) + { + source = this._currentSource; + } + + if (source is null || source.IsCancellationRequested) + { + return false; + } + + source.Cancel(); + return true; + } + + private async Task RunAsync(CancellationToken stopToken) + { + try + { + await foreach (var pending in this._queue.Reader.ReadAllAsync(stopToken).ConfigureAwait(false)) + { + using var commandSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken); + lock (this._syncRoot) + { + this._currentSource = commandSource; + } + + ActorCommandResult result; + try + { + result = await this.ExecuteCommandAsync(pending.Command, commandSource.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Actor {Actor} failed to execute {Command}.", this._player.AccountLoginName, pending.Command.Name); + result = ActorCommandResult.Failure(ActorErrorCodes.Failed, ex.Message); + } + finally + { + lock (this._syncRoot) + { + this._currentSource = null; + } + } + + this.LogOutcome(pending.Command, result); + pending.Completion.TrySetResult(result); + } + } + catch (OperationCanceledException) + { + // The actor is stopping. + } + finally + { + while (this._queue.Reader.TryRead(out var pending)) + { + pending.Completion.TrySetResult(ActorCommandResult.Failure( + ActorErrorCodes.NotReady, + "The actor stopped before the command could run.")); + } + } + } + + private ValueTask ExecuteCommandAsync(ActorCommand command, CancellationToken cancellationToken) + { + if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld) + { + return ValueTask.FromResult(ActorCommandResult.Failure( + ActorErrorCodes.NotReady, + $"The actor is in state '{this._player.PlayerState.CurrentState.Name}'.")); + } + + if (!this._player.IsAlive) + { + return ValueTask.FromResult(ActorCommandResult.Failure(ActorErrorCodes.Dead, "The actor is dead.")); + } + + return command switch + { + WalkCommand walk => this.WalkAsync(walk, cancellationToken), + AttackCommand attack => this.AttackAsync(attack, cancellationToken), + SkillCommand skill => this.SkillAsync(skill), + SayCommand say => this.SayAsync(say), + PickupCommand pickup => this.PickupAsync(pickup), + WarpCommand warp => this.WarpAsync(warp), + _ => ValueTask.FromResult(ActorCommandResult.Failure(ActorErrorCodes.Failed, $"Unknown command '{command.Name}'.")), + }; + } + + private async ValueTask WalkAsync(WalkCommand command, CancellationToken cancellationToken) + { + var target = new Point(command.X, command.Y); + var start = this._player.Position; + if (start == target) + { + // Already there: a walk to the current tile is done, not a missing path. + return ActorCommandResult.Success( + new ActorEventField("steps", 0), + new ActorEventField("walked", 0), + new ActorEventField("x", start.X), + new ActorEventField("y", start.Y)); + } + + var path = this.FindPath(target); + if (path is null || path.Count == 0) + { + return ActorCommandResult.Failure( + ActorErrorCodes.NoPath, + $"No path from {start} to {target}."); + } + + // The engine walks at most a handful of steps per request, so a long path is handed over in + // chunks - the actor stays interruptible between them, and the persistence lock is only held + // while a chunk is handed over, never while walking it. + var walked = 0; + for (var offset = 0; offset < path.Count; offset += MaxStepsPerWalk) + { + if (cancellationToken.IsCancellationRequested) + { + return await this.InterruptWalkAsync(path.Count, walked).ConfigureAwait(false); + } + + var chunk = path.Skip(offset).Take(MaxStepsPerWalk).ToList(); + await this._player.RunPersistenceExclusiveAsync( + () => this.StartWalkAsync(chunk), + CancellationToken.None).ConfigureAwait(false); + + while (this._player.IsWalking) + { + if (cancellationToken.IsCancellationRequested) + { + return await this.InterruptWalkAsync(path.Count, walked).ConfigureAwait(false); + } + + try + { + await Task.Delay(WalkPollMilliseconds, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Handled by the check at the top of the loop. + } + } + + walked += chunk.Count; + } + + return ActorCommandResult.Success( + new ActorEventField("steps", path.Count), + new ActorEventField("walked", walked), + new ActorEventField("x", this._player.Position.X), + new ActorEventField("y", this._player.Position.Y)); + } + + private async ValueTask InterruptWalkAsync(int planned, int walked) + { + await this._player.StopWalkingAsync().ConfigureAwait(false); + return this.Interrupted( + "walk", + new ActorEventField("steps", planned), + new ActorEventField("walked", walked), + new ActorEventField("x", this._player.Position.X), + new ActorEventField("y", this._player.Position.Y)); + } + + /// + /// Finds the way to the target over the whole map. + /// + /// + /// Deliberately NOT the game context's pooled path finders: those use a + /// whose 16 tile segment both caps a request at about 12 tiles + /// and refuses some shorter ones outright, because the segment is placed around the midpoint and + /// can leave the start tile outside it. A scenario would have to chain lucky waypoints. The + /// actor keeps its own full-grid path finder instead, so one walk is one request to + /// anywhere on the current map, and the pooled finders stay available for the engine. + /// Safe-zone tiles are included, or an actor standing in town could not move at all. + /// + private IList? FindPath(Point target) + { + if (this._player.CurrentMap is not { } map) + { + return null; + } + + this._pathFinder ??= new PathFinder(new FullGridNetwork(true)) + { + SearchLimit = PathSearchLimit, + Heuristic = new ActorPathHeuristic(), + }; + + this._pathFinder.ResetPathFinder(); + return this._pathFinder.FindPath(this._player.Position, target, map.Terrain.AIgrid, true); + } + + private async ValueTask StartWalkAsync(IList chunk) + { + var steps = new WalkingStep[chunk.Count]; + for (var i = 0; i < chunk.Count; i++) + { + var previous = i == 0 ? this._player.Position : steps[i - 1].To; + steps[i] = new WalkingStep(previous, chunk[i].Point, previous.GetDirectionTo(chunk[i].Point)); + } + + await this._player.WalkToAsync(steps[^1].To, steps).ConfigureAwait(false); + } + + private async ValueTask AttackAsync(AttackCommand command, CancellationToken cancellationToken) + { + var times = Math.Max(1, command.Times); + var hits = new List(); + string? stoppedBecause = null; + + for (var i = 0; i < times; i++) + { + if (cancellationToken.IsCancellationRequested) + { + return this.Interrupted("attack", new ActorEventField("hits", hits)); + } + + var attempt = await this._player.RunPersistenceExclusiveAsync( + () => this.AttackOnceAsync(command.Target), + CancellationToken.None).ConfigureAwait(false); + + if (attempt.Failure is { } failure) + { + if (i == 0) + { + return failure; + } + + stoppedBecause = failure.Code; + break; + } + + hits.Add(attempt.Hit); + + if (i + 1 < times && command.IntervalMs > 0) + { + try + { + await Task.Delay(command.IntervalMs, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return this.Interrupted("attack", new ActorEventField("hits", hits)); + } + } + } + + return ActorCommandResult.Success( + new ActorEventField("hits", hits), + new ActorEventField("stopped_because", stoppedBecause)); + } + + private async ValueTask<(ActorCommandResult? Failure, Dictionary? Hit)> AttackOnceAsync(string targetSpec) + { + var (target, failure) = this.ResolveAttackTarget(targetSpec, GetEffectiveAttackRange(this._player)); + if (failure is not null || target is null) + { + return (failure ?? ActorCommandResult.Failure(ActorErrorCodes.NotInView, $"{targetSpec} is not in view."), null); + } + + var hitInfo = await target.AttackByAsync(this._player, null, false).ConfigureAwait(false); + this._player.Logger.LogInformation( + "Actor {Actor} ({Character}) attacked {Target} (id {TargetId}): {Damage} damage.", + this._player.AccountLoginName, + this._player.Name, + ActorObjects.GetName(target), + target.Id, + hitInfo?.HealthDamage ?? 0); + + return (null, new Dictionary + { + ["target_id"] = target.Id, + ["target"] = ActorObjects.GetName(target), + ["target_kind"] = ActorObjects.GetKind(target), + ["health_damage"] = hitInfo?.HealthDamage ?? 0, + ["shield_damage"] = hitInfo?.ShieldDamage ?? 0, + ["miss"] = hitInfo is null || (hitInfo.Value.HealthDamage == 0 && hitInfo.Value.ShieldDamage == 0), + }); + } + + private async ValueTask SkillAsync(SkillCommand command) + { + return await this._player.RunPersistenceExclusiveAsync( + async () => + { + if (this._player.SkillList?.GetSkill(command.SkillNumber) is not { Skill: { } skill } skillEntry) + { + return ActorCommandResult.Failure( + ActorErrorCodes.UnknownSkill, + $"The character has not learned skill {command.SkillNumber}."); + } + + var range = skill.Range > 0 ? (byte)skill.Range : GetEffectiveAttackRange(this._player); + var (target, failure) = this.ResolveAttackTarget(command.Target, range); + if (failure is not null || target is null) + { + return failure ?? ActorCommandResult.Failure(ActorErrorCodes.NotInView, $"'{command.Target}' is not in view."); + } + + if (this.FindUnaffordableRequirement(skill, skillEntry) is { } missing) + { + return ActorCommandResult.Failure( + ActorErrorCodes.InsufficientResources, + $"The character cannot pay the {missing} cost of skill {command.SkillNumber}."); + } + + var strategy = this._player.GameContext.PlugInManager.GetStrategy(skill.Number) + ?? DefaultSkillPlugin; + + // The engine's skill plugin answers a refused cast by returning silently (stunned, + // safe zone, target restriction, missing mana or requirements, the speed-hack check). + // A command must never report a no-op as success, so the actor's own stream decides: + // a cast which got as far as the skill animation appended a 'skill' event, and one + // which landed appended a 'hit' event as well. + var sequenceBefore = this._player.EventLog.LastSequence; + await strategy.PerformSkillAsync(this._player, target, command.SkillNumber).ConfigureAwait(false); + var recorded = this._player.EventLog.Since(sequenceBefore); + var performed = recorded.Any(e => e.Type is "skill" or "hit"); + if (!performed) + { + var refusal = $"The game refused skill {command.SkillNumber} without a reason: check the character's mana," + + " its skill requirements (weapon, level), the target's restrictions, and that it is not in a safe zone."; + return ActorCommandResult.Failure( + ActorErrorCodes.SkillRefused, + refusal, + new ActorEventField("skill", skill.Number), + new ActorEventField("target_id", target.Id)); + } + + var hit = recorded.FirstOrDefault(e => e.Type == "hit"); + var healthDamage = hit?.Fields.FirstOrDefault(f => f.Name == "health_damage").Value ?? 0u; + + this._player.Logger.LogInformation( + "Actor {Actor} ({Character}) cast skill {Skill} on {Target} (id {TargetId}).", + this._player.AccountLoginName, + this._player.Name, + skill.Number, + ActorObjects.GetName(target), + target.Id); + + return ActorCommandResult.Success( + new ActorEventField("skill", skill.Number), + new ActorEventField("target_id", target.Id), + new ActorEventField("target", ActorObjects.GetName(target)), + new ActorEventField("target_alive", target.IsAlive), + new ActorEventField("hit", hit is not null), + new ActorEventField("health_damage", healthDamage)); + }, + CancellationToken.None).ConfigureAwait(false); + } + + private async ValueTask SayAsync(SayCommand command) + { + return await this._player.RunPersistenceExclusiveAsync( + async () => + { + await new ChatMessageAction() + .ChatMessageAsync(this._player, this._player.Name, command.Text, false) + .ConfigureAwait(false); + this._player.Logger.LogInformation( + "Actor {Actor} ({Character}) said {Message}.", + this._player.AccountLoginName, + this._player.Name, + command.Text); + return ActorCommandResult.Success(new ActorEventField("message", command.Text)); + }, + CancellationToken.None).ConfigureAwait(false); + } + + private async ValueTask PickupAsync(PickupCommand command) + { + return await this._player.RunPersistenceExclusiveAsync( + async () => + { + if (this._player.CurrentMap?.GetDrop(command.DropId) is null) + { + return ActorCommandResult.Failure( + ActorErrorCodes.NotInView, + $"There is no drop with id {command.DropId} on this map."); + } + + await new PickupItemAction().PickupItemAsync(this._player, command.DropId).ConfigureAwait(false); + var stillThere = this._player.CurrentMap?.GetDrop(command.DropId) is not null; + if (stillThere) + { + return ActorCommandResult.Failure( + ActorErrorCodes.PickupFailed, + $"The drop {command.DropId} could not be picked up."); + } + + this._player.Logger.LogInformation( + "Actor {Actor} ({Character}) picked up drop {DropId}.", + this._player.AccountLoginName, + this._player.Name, + command.DropId); + return ActorCommandResult.Success(new ActorEventField("id", command.DropId)); + }, + CancellationToken.None).ConfigureAwait(false); + } + + private async ValueTask WarpAsync(WarpCommand command) + { + return await this._player.RunPersistenceExclusiveAsync( + async () => + { + var warpInfo = this._player.GameContext.Configuration.WarpList + .FirstOrDefault(w => w.Index == command.GateNumber); + if (warpInfo is null) + { + return ActorCommandResult.Failure( + ActorErrorCodes.UnknownGate, + $"There is no warp list entry with index {command.GateNumber}."); + } + + var mapBefore = this._player.CurrentMap; + await new WarpAction().WarpToAsync(this._player, warpInfo).ConfigureAwait(false); + if (ReferenceEquals(mapBefore, this._player.CurrentMap) && warpInfo.Gate?.Map != mapBefore?.Definition) + { + // WarpAction answers a refused warp with a blue message only; the map tells us. + return ActorCommandResult.Failure( + ActorErrorCodes.WarpRefused, + $"The warp to '{warpInfo.Name}' was refused (level, zen or map rules)."); + } + + this._player.Logger.LogInformation( + "Actor {Actor} ({Character}) warped to {Warp}.", + this._player.AccountLoginName, + this._player.Name, + warpInfo.Name); + + return ActorCommandResult.Success( + new ActorEventField("warp", warpInfo.Name.ToString()), + new ActorEventField("map", this._player.CurrentMap?.Definition.Name.ToString() ?? string.Empty), + new ActorEventField("x", this._player.Position.X), + new ActorEventField("y", this._player.Position.Y)); + }, + CancellationToken.None).ConfigureAwait(false); + } + + private string? FindUnaffordableRequirement(Skill skill, SkillEntry skillEntry) + { + if (this._player.Attributes is not { } attributes) + { + return "attribute"; + } + + foreach (var requirement in skill.ConsumeRequirements) + { + if (requirement.Attribute is not { } attribute) + { + continue; + } + + var required = this._player.GetRequiredValue(requirement, skillEntry); + if (attributes[attribute] < required) + { + return attribute.Designation ?? "resource"; + } + } + + return null; + } + + private (IAttackable? Target, ActorCommandResult? Failure) ResolveAttackTarget(string targetSpec, byte range) + { + if (this._player.CurrentMap is not { } map) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.NotReady, "The actor is not on a map.")); + } + + var candidates = map.GetAttackablesInRange(this._player.Position, this._player.InfoRange); + var target = ushort.TryParse(targetSpec, out var id) + ? candidates.FirstOrDefault(c => c.Id == id) + : candidates.FirstOrDefault(c => string.Equals(ActorObjects.GetName(c), targetSpec, StringComparison.OrdinalIgnoreCase)); + + if (target is null) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.NotInView, $"{targetSpec} is not in the actor's view.")); + } + + if (ReferenceEquals(target, this._player)) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.InvalidTarget, "An actor cannot attack itself.")); + } + + if (!target.IsAlive) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.InvalidTarget, $"{targetSpec} is dead.")); + } + + if (this._player.IsAtSafezone()) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.SafeZone, "The actor stands in a safe zone.")); + } + + if (target.IsAtSafezone()) + { + return (null, ActorCommandResult.Failure(ActorErrorCodes.SafeZone, $"{targetSpec} stands in a safe zone.")); + } + + if (!target.IsInRange(this._player.Position, range)) + { + var distance = this._player.Position.EuclideanDistanceTo(target.Position); + return (null, ActorCommandResult.Failure( + ActorErrorCodes.OutOfRange, + $"{targetSpec} is {distance:0.#} tiles away, the attack range is {range}.")); + } + + return (target, null); + } + + private ActorCommandResult Interrupted(string command, params ActorEventField[] progress) + { + this._player.EventLog.Append("interrupted", new ActorEventField("command", command)); + this._player.Logger.LogInformation( + "The {Command} command of actor {Actor} was interrupted.", + command, + this._player.AccountLoginName); + return ActorCommandResult.Failure(ActorErrorCodes.Interrupted, $"The {command} command was interrupted.", progress); + } + + private void LogOutcome(ActorCommand command, ActorCommandResult result) + { + if (result.Ok || result.Code == ActorErrorCodes.Interrupted) + { + return; + } + + this._player.Logger.LogWarning( + "Actor {Actor} refused {Command}: {Code} - {Error}", + this._player.AccountLoginName, + command.Name, + result.Code, + result.Error); + this._player.EventLog.Append( + "error", + new ActorEventField("command", command.Name), + new ActorEventField("code", result.Code), + new ActorEventField("error", result.Error)); + } + + private sealed record PendingCommand(ActorCommand Command) + { + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/src/GameLogic/TestActors/ScriptedPlayer.cs b/src/GameLogic/TestActors/ScriptedPlayer.cs new file mode 100644 index 000000000..90877b683 --- /dev/null +++ b/src/GameLogic/TestActors/ScriptedPlayer.cs @@ -0,0 +1,169 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A scripted actor: a connection-less player which enters the world as an existing account's +/// character and then does what a scenario tells it to. +/// +/// +/// It derives from and NOT from +/// on purpose: the bot code treats +/// offline players as one of its own (BotSelfDefensePlugIn only registers an aggressor which +/// is not OfflinePlayer, the mini-game handler skips offline party leaders, and the admin +/// panel lists them as offline accounts). An actor has to be a human stand-in, so it repeats the +/// ~15 lines of the offline login sequence instead of inheriting them. +/// +public class ScriptedPlayer : Player +{ + private readonly ActorEventLog _eventLog = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The game context of the game server this actor plays on. + public ScriptedPlayer(IGameContext gameContext) + : base(gameContext) + { + } + + /// + /// Gets the event stream of this actor: everything the game would have shown to its client. + /// + public ActorEventLog EventLog => this._eventLog; + + /// + /// Gets the login name of the account this actor animates. + /// + public string? AccountLoginName { get; private set; } + + /// + /// Gets the intelligence which executes the actor's commands, once it entered the world. + /// + public ScriptedIntelligence? Intelligence { get; private set; } + + /// + /// Logs the account's character in and enters the world, the way an offline player does it. + /// + /// The login name of an existing account. + /// The character slot to animate; null takes the lowest slot. + /// true if the actor entered the world. + public async ValueTask InitializeAsync(string loginName, byte? characterSlot = null) + { + try + { + // The actor's own persistence context, like a bot's: no entity of another player's + // context is ever attached here. + var account = await this.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false); + if (account is null) + { + this.Logger.LogError("Actor account {LoginName} could not be loaded.", loginName); + return false; + } + + var character = characterSlot is { } slot + ? account.Characters.FirstOrDefault(c => c.CharacterSlot == slot) + : account.Characters.OrderBy(c => c.CharacterSlot).FirstOrDefault(); + if (character is null) + { + this.Logger.LogError("Actor account {LoginName} has no character in slot {Slot}.", loginName, characterSlot); + return false; + } + + return await this.EnterWorldAsync(account, character).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to initialize the actor for account {LoginName}.", loginName); + return false; + } + } + + /// + /// Enters the world with an already loaded account and character. This is the login sequence + /// itself, split out so it can be driven with a prepared account (tests) as well as with one + /// loaded from the database. + /// + /// The account to animate. + /// The character of that account to animate. + /// true if the actor entered the world. + public async ValueTask EnterWorldAsync(Account account, Character character) + { + this.Account = account; + this.AccountLoginName = account.LoginName; + + await this.PlayerState.TryAdvanceToAsync(MUnique.OpenMU.GameLogic.PlayerState.LoginScreen).ConfigureAwait(false); + await this.PlayerState.TryAdvanceToAsync(MUnique.OpenMU.GameLogic.PlayerState.Authenticated).ConfigureAwait(false); + await this.PlayerState.TryAdvanceToAsync(MUnique.OpenMU.GameLogic.PlayerState.CharacterSelection).ConfigureAwait(false); + + await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); + + // Selecting the character is what enters the world: it runs OnPlayerEnteredWorldAsync, + // which calls ClientReadyAfterMapChangeAsync itself. Calling it again (as the offline + // player does) only earns an "already on map" warning in the log. + await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); + + if (this.PlayerState.CurrentState != MUnique.OpenMU.GameLogic.PlayerState.EnteredWorld) + { + this.Logger.LogError( + "Actor {LoginName} did not enter the world; it is in state {State}.", + this.AccountLoginName, + this.PlayerState.CurrentState.Name); + return false; + } + + this.Intelligence = new ScriptedIntelligence(this); + this.Intelligence.Start(); + + this._eventLog.Append( + "spawned", + new ActorEventField("actor", this.AccountLoginName ?? string.Empty), + new ActorEventField("character", this.Name), + new ActorEventField("map", this.CurrentMap?.Definition.Name.ToString() ?? string.Empty), + new ActorEventField("x", this.Position.X), + new ActorEventField("y", this.Position.Y)); + + this.Logger.LogInformation( + "Actor {LoginName} entered the world as {Character} on {Map} at {Position}.", + this.AccountLoginName, + this.Name, + this.CurrentMap?.Definition.Name, + this.Position); + + return true; + } + + /// + /// Stops the actor: the normal logout path, which saves the character's progress and releases + /// the account. + /// + /// The task. + public async ValueTask StopAsync() + { + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override ICustomPlugInContainer CreateViewPlugInContainer() + => new RecordingViewPlugInContainer(this); + + /// + protected override async ValueTask InternalDisconnectAsync() + { + if (this.Intelligence is { } intelligence) + { + this.Intelligence = null; + await intelligence.DisposeAsync().ConfigureAwait(false); + } + + await base.InternalDisconnectAsync().ConfigureAwait(false); + } +} diff --git a/src/GameLogic/TestActors/SkillCommand.cs b/src/GameLogic/TestActors/SkillCommand.cs new file mode 100644 index 000000000..a57ff8525 --- /dev/null +++ b/src/GameLogic/TestActors/SkillCommand.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Casts a learned skill on a target. +/// +/// The number of the skill. +/// The target's id or character name. +public sealed record SkillCommand(ushort SkillNumber, string Target) : ActorCommand("skill"); diff --git a/src/GameLogic/TestActors/WalkCommand.cs b/src/GameLogic/TestActors/WalkCommand.cs new file mode 100644 index 000000000..97807e00e --- /dev/null +++ b/src/GameLogic/TestActors/WalkCommand.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Walks to a position of the current map, using the server's path finder - one request, like a +/// client's click. +/// +/// The target X coordinate. +/// The target Y coordinate. +public sealed record WalkCommand(byte X, byte Y) : ActorCommand("walk"); diff --git a/src/GameLogic/TestActors/WarpCommand.cs b/src/GameLogic/TestActors/WarpCommand.cs new file mode 100644 index 000000000..b6c251ddf --- /dev/null +++ b/src/GameLogic/TestActors/WarpCommand.cs @@ -0,0 +1,11 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Uses an entry of the game's warp list. +/// +/// The index of the warp list entry. +public sealed record WarpCommand(int GateNumber) : ActorCommand("warp"); diff --git a/src/MUnique.OpenMU.sln b/src/MUnique.OpenMU.sln index 73c6d1294..98a8bb549 100644 --- a/src/MUnique.OpenMU.sln +++ b/src/MUnique.OpenMU.sln @@ -172,6 +172,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MUnique.OpenMU.Web.Shared", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MUnique.OpenMU.Web.Tests", "..\tests\MUnique.OpenMU.Web.Tests\MUnique.OpenMU.Web.Tests.csproj", "{10ECBF9E-7245-46C5-BE16-F5EE8661C9A8}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MUnique.OpenMU.Network.LoginProbe", "Network\LoginProbe\MUnique.OpenMU.Network.LoginProbe.csproj", "{C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -388,6 +390,10 @@ Global {10ECBF9E-7245-46C5-BE16-F5EE8661C9A8}.Debug|Any CPU.Build.0 = Debug|Any CPU {10ECBF9E-7245-46C5-BE16-F5EE8661C9A8}.Release|Any CPU.ActiveCfg = Release|Any CPU {10ECBF9E-7245-46C5-BE16-F5EE8661C9A8}.Release|Any CPU.Build.0 = Release|Any CPU + {C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -449,6 +455,7 @@ Global {4430D6A8-54B5-412B-9DBA-07B3E3580AF7} = {565BED68-B303-4A3D-A8C0-0088D2A50C46} {1E5B9402-7F94-1B10-15B7-901A5DB39D2C} = {820453DE-DA7A-454C-A077-BED8F0FA9764} {10ECBF9E-7245-46C5-BE16-F5EE8661C9A8} = {6443D0E9-82F8-4A04-BB9C-72CE36E1D952} + {C9F5A7D2-4B18-4E3C-9A67-5D2B8F41C0E9} = {C814AC11-2CAD-44F2-80DF-B37FD785E80E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {28C13837-B616-4790-84D6-4BDCB4A8166D} diff --git a/src/Network/LoginProbe/MUnique.OpenMU.Network.LoginProbe.csproj b/src/Network/LoginProbe/MUnique.OpenMU.Network.LoginProbe.csproj new file mode 100644 index 000000000..a2c8f6f88 --- /dev/null +++ b/src/Network/LoginProbe/MUnique.OpenMU.Network.LoginProbe.csproj @@ -0,0 +1,32 @@ + + + + Exe + net10.0 + enable + nullable;CS4014;VSTHRD110;VSTHRD100 + false + false + MUnique.OpenMU.Network.LoginProbe + MUnique.OpenMU.Network.LoginProbe + + + + bin\Debug\ + bin\Debug\MUnique.OpenMU.Network.LoginProbe.xml + + + bin\Release\ + bin\Release\MUnique.OpenMU.Network.LoginProbe.xml + + + + + + + + + + + + diff --git a/src/Network/LoginProbe/Program.cs b/src/Network/LoginProbe/Program.cs new file mode 100644 index 000000000..d847fb135 --- /dev/null +++ b/src/Network/LoginProbe/Program.cs @@ -0,0 +1,274 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.LoginProbe; + +using System; +using System.Buffers; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.Packets.ConnectServer; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Network.Xor; +using Pipelines.Sockets.Unofficial; + +/// +/// A wire-level login probe: it does what a real client does - ask the connect server for a game +/// server, log in there, and hold the session - so a scenario can prove what the server does while +/// an account is genuinely connected (e.g. that a scripted actor refuses to animate it). +/// +/// +/// Meant to be driven from test scripts. It speaks the protocol through the server's own network +/// stack (MUnique.OpenMU.Network), so it stays correct when the encryption or the packet +/// layout changes. +/// +public static class Program +{ + /// The client version a Season 6 Episode 3 client reports ("20404" in ASCII). + private static readonly byte[] ClientVersion = [0x32, 0x30, 0x34, 0x30, 0x34]; + + /// The client serial of the open-source client. + private static readonly byte[] ClientSerial = Encoding.ASCII.GetBytes("k1Pk2jcET48mxL3b"); + + /// + /// Runs the probe. + /// + /// The command line arguments; see . + /// 0 when the login succeeded, 1 when it failed, 2 on an argument error. + public static async Task Main(string[] args) + { + var options = ProbeOptions.Parse(args); + if (options is null) + { + Usage(); + return 2; + } + + try + { + var (gameServerHost, gameServerPort) = await ResolveGameServerAsync(options).ConfigureAwait(false); + Write(new { step = "server_selected", host = gameServerHost, port = gameServerPort, server = options.ServerId }); + return await LoginAndHoldAsync(options, gameServerHost, gameServerPort).ConfigureAwait(false); + } + catch (Exception ex) + { + Write(new { ok = false, step = "failed", error = ex.Message }); + return 1; + } + } + + private static void Usage() + { + Console.Error.WriteLine("usage: MUnique.OpenMU.Network.LoginProbe --account [--password ] [--host 127.0.0.1]"); + Console.Error.WriteLine(" [--connect-port 44406] [--server 0] [--hold ]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Logs into the game server like a real client and holds the session."); + Console.Error.WriteLine("Writes one JSON object per step to stdout; exit 0 on a successful login."); + } + + private static void Write(object payload) + { + Console.WriteLine(JsonSerializer.Serialize(payload)); + Console.Out.Flush(); + } + + /// + /// Asks the connect server which game server to use, exactly like a client does. + /// + private static async Task<(string Host, ushort Port)> ResolveGameServerAsync(ProbeOptions options) + { + using var client = new TcpClient(); + await client.ConnectAsync(options.Host, options.ConnectPort).ConfigureAwait(false); + using var stream = client.GetStream(); + stream.ReadTimeout = 10000; + + // The connect server greets with a hello packet; it is not needed, but it has to be read. + await ReadPacketAsync(stream).ConfigureAwait(false); + + var serverListRequest = new byte[ServerListRequest.Length]; + _ = new ServerListRequest(serverListRequest); + await stream.WriteAsync(serverListRequest).ConfigureAwait(false); + await ReadPacketAsync(stream).ConfigureAwait(false); + + var infoRequest = new byte[ConnectionInfoRequest.Length]; + var request = new ConnectionInfoRequest(infoRequest) { ServerId = (ushort)options.ServerId }; + await stream.WriteAsync(infoRequest).ConfigureAwait(false); + + var response = await ReadPacketAsync(stream).ConfigureAwait(false); + var info = new ConnectionInfo(response); + return (info.IpAddress, info.Port); + } + + private static async Task ReadPacketAsync(NetworkStream stream) + { + var header = new byte[3]; + await stream.ReadExactlyAsync(header.AsMemory(0, 1)).ConfigureAwait(false); + int length; + int headerSize; + if (header[0] is 0xC1 or 0xC3) + { + await stream.ReadExactlyAsync(header.AsMemory(1, 1)).ConfigureAwait(false); + length = header[1]; + headerSize = 2; + } + else + { + await stream.ReadExactlyAsync(header.AsMemory(1, 2)).ConfigureAwait(false); + length = (header[1] << 8) | header[2]; + headerSize = 3; + } + + var packet = new byte[length]; + header.AsSpan(0, headerSize).CopyTo(packet); + await stream.ReadExactlyAsync(packet.AsMemory(headerSize, length - headerSize)).ConfigureAwait(false); + return packet; + } + + /// + /// Logs in on the game server and keeps the connection open for the requested time. + /// + private static async Task LoginAndHoldAsync(ProbeOptions options, string gameServerHost, ushort gameServerPort) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(IPAddress.Parse(gameServerHost), gameServerPort).ConfigureAwait(false); + var socketConnection = SocketConnection.Create(socket); + + var encryptionFactory = new OpenSourceClientNetworkEncryptionFactoryPlugIn(); + using var connection = new Connection( + socketConnection, + encryptionFactory.CreateDecryptor(socketConnection.Input, DataDirection.ServerToClient), + encryptionFactory.CreateEncryptor(socketConnection.Output, DataDirection.ClientToServer), + new NullLogger()); + + var loginResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.PacketReceived += packet => + { + HandlePacket(packet, loginResult); + return ValueTask.CompletedTask; + }; + connection.Disconnected += () => + { + loginResult.TrySetException(new IOException("The game server closed the connection.")); + return ValueTask.CompletedTask; + }; + + _ = connection.BeginReceiveAsync(); + + // The client waits for the server's greeting before it sends its credentials. + await Task.Delay(500).ConfigureAwait(false); + + await connection.SendLoginLongPasswordAsync( + Encrypt(options.Account), + Encrypt(options.Password), + (uint)Environment.TickCount, + ClientVersion, + ClientSerial).ConfigureAwait(false); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var result = await loginResult.Task.WaitAsync(timeout.Token).ConfigureAwait(false); + var ok = result == LoginResponse.LoginResult.Okay; + Write(new { ok, step = "login", account = options.Account, result = result.ToString() }); + if (!ok) + { + await connection.DisconnectAsync().ConfigureAwait(false); + return 1; + } + + if (options.HoldSeconds > 0) + { + Write(new { ok = true, step = "holding", seconds = options.HoldSeconds }); + await Task.Delay(TimeSpan.FromSeconds(options.HoldSeconds)).ConfigureAwait(false); + } + + await connection.DisconnectAsync().ConfigureAwait(false); + Write(new { ok = true, step = "disconnected", account = options.Account }); + return 0; + } + + private static void HandlePacket(ReadOnlySequence packet, TaskCompletionSource loginResult) + { + var data = packet.ToArray(); + if (data.Length >= LoginResponse.Length + && data[0] == LoginResponse.HeaderType + && data[2] == LoginResponse.Code + && data[3] == LoginResponse.SubCode) + { + loginResult.TrySetResult(new LoginResponse(data).Success); + } + } + + /// + /// Encrypts a credential the way the client does, with the well-known three byte key. + /// + private static Memory Encrypt(string value) + { + var buffer = new byte[10]; + Encoding.ASCII.GetBytes(value).AsSpan(0, Math.Min(value.Length, buffer.Length)).CopyTo(buffer); + new Xor3Encryptor(0).Encrypt(buffer); + return buffer; + } + + private sealed class ProbeOptions + { + public string Host { get; private set; } = "127.0.0.1"; + + public int ConnectPort { get; private set; } = 44406; + + public int ServerId { get; private set; } + + public int HoldSeconds { get; private set; } = 30; + + public string Account { get; private set; } = string.Empty; + + public string Password { get; private set; } = string.Empty; + + public static ProbeOptions? Parse(string[] args) + { + var options = new ProbeOptions(); + for (var i = 0; i < args.Length; i++) + { + if (i + 1 >= args.Length) + { + return null; + } + + var value = args[i + 1]; + switch (args[i]) + { + case "--host": options.Host = value; break; + case "--connect-port": options.ConnectPort = int.Parse(value); break; + case "--server": options.ServerId = int.Parse(value); break; + case "--hold": options.HoldSeconds = int.Parse(value); break; + case "--account": options.Account = value; break; + case "--password": options.Password = value; break; + default: return null; + } + + i++; + } + + if (string.IsNullOrEmpty(options.Account)) + { + return null; + } + + if (string.IsNullOrEmpty(options.Password)) + { + // The test accounts of the local stack use the login name as the password. + options.Password = options.Account; + } + + return options; + } + } +} diff --git a/src/Network/MUnique.OpenMU.Network.csproj b/src/Network/MUnique.OpenMU.Network.csproj index 08c9ea065..ef737290c 100644 --- a/src/Network/MUnique.OpenMU.Network.csproj +++ b/src/Network/MUnique.OpenMU.Network.csproj @@ -45,6 +45,11 @@ + + + + + diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 7990ffd9a..7371a47a4 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -20,6 +20,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.FriendServer; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.TestActors; using MUnique.OpenMU.GameServer; using MUnique.OpenMU.GuildServer; using MUnique.OpenMU.Interfaces; @@ -34,6 +35,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.Persistence.Initialization.Version075; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; +using MUnique.OpenMU.Startup.TestActors; using MUnique.OpenMU.Web.AdminPanel; using MUnique.OpenMU.Web.AdminPanel.API; using MUnique.OpenMU.Web.Map.Map; @@ -333,6 +335,8 @@ private async Task CreateHostAsync(string[] args) .AddNetworkObservation() .AddControllers().AddApplicationPart(typeof(ServerController).Assembly); + this.AddActorControlEndpoint(builder.Services); + var host = builder.Build(); // NpgsqlLoggingConfiguration.InitializeLogging(host.Services.GetRequiredService()) @@ -354,6 +358,31 @@ private async Task CreateHostAsync(string[] args) return host; } + /// + /// Adds the test actor control endpoint, but only when OPENMU_ACTOR_PORT names a + /// port: it is unauthenticated development tooling and stays off by default. It is registered + /// after the server containers on purpose - hosted services are stopped in reverse order, so the + /// actors are logged out (and their progress saved) before the game servers go down. + /// + /// The service collection. + private void AddActorControlEndpoint(IServiceCollection services) + { + if (ActorControlService.GetConfiguredPort() is not { } actorPort) + { + return; + } + + this._logger.Information("Actor control endpoint enabled on port {port}", actorPort); + services + .AddSingleton(new ActorEndpointOptions(actorPort)) + .AddSingleton(_ => new GameServerContextLocator(this._gameServers)) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddHostedService(); + } + private IIpAddressResolver CreateIpResolver(IServiceProvider serviceProvider, string[] args) { (IpResolverType IpResolver, string? IpResolverParameter)? settings = default; diff --git a/src/Startup/TestActors/ActorControlService.cs b/src/Startup/TestActors/ActorControlService.cs new file mode 100644 index 000000000..9e1f19ea6 --- /dev/null +++ b/src/Startup/TestActors/ActorControlService.cs @@ -0,0 +1,199 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Startup.TestActors; + +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.GameLogic.TestActors; + +/// +/// The local control endpoint: a plain TCP listener which speaks newline-delimited JSON. +/// +/// +/// Development tooling, without any authentication: it is only started when +/// names a port, the local stack publishes that port on the +/// host's loopback address only, and the release deployment never sets the variable. +/// +public sealed class ActorControlService : BackgroundService +{ + /// + /// The environment variable which enables the endpoint and names its port. + /// + public const string EnvironmentVariableName = "OPENMU_ACTOR_PORT"; + + /// + /// UTF-8 without a byte order mark: the first line of a connection must be plain JSON, or a + /// strict reader (stdlib Python, jq) chokes on the BOM. + /// + private static readonly Encoding LineEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + /// + /// How long the SIGTERM handler waits for the actors to log out before it lets the process die. + /// Docker's default grace period is ten seconds. + /// + private static readonly TimeSpan SignalStopTimeout = TimeSpan.FromSeconds(8); + + private readonly int _port; + private readonly IActorRegistry _registry; + private readonly ActorProtocolHandler _handler; + private readonly ILogger _logger; + private PosixSignalRegistration? _sigTermRegistration; + + /// + /// Initializes a new instance of the class. + /// + /// The endpoint options, i.e. the port. + /// The actor registry, so shutdown can stop every actor. + /// The protocol handler. + /// The logger. + public ActorControlService(ActorEndpointOptions options, IActorRegistry registry, ActorProtocolHandler handler, ILogger logger) + { + this._port = options.Port; + this._registry = registry; + this._handler = handler; + this._logger = logger; + } + + /// + /// Reads the configured port from the environment. + /// + /// The port, or null when the endpoint is off (the default). + public static int? GetConfiguredPort() + { + var value = Environment.GetEnvironmentVariable(EnvironmentVariableName); + return int.TryParse(value, out var port) && port is > 0 and <= 65535 ? port : null; + } + + /// + /// Stops every actor through the normal logout path, before the game servers of this host stop. + /// + /// The cancellation token. + /// The task. + public override async Task StopAsync(CancellationToken cancellationToken) + { + var stopped = await this._registry.StopAllAsync().ConfigureAwait(false); + if (stopped > 0) + { + this._logger.LogInformation("Stopped {Count} actor(s) before shutdown.", stopped); + } + + await base.StopAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override void Dispose() + { + this._sigTermRegistration?.Dispose(); + base.Dispose(); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // `docker stop` sends SIGTERM, but this host's main loop only stops the services from an + // AppDomain.ProcessExit handler, which the runtime cuts short - so the hosted services' + // StopAsync usually never runs in a container, and an actor's unsaved progress (anything + // since the last periodic save) would be lost on every `dev down`. Logging the actors out + // straight from the signal handler makes the shutdown promise hold regardless. + this._sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => this.StopActorsOnSignal()); + + var listener = new TcpListener(IPAddress.Any, this._port); + try + { + listener.Start(); + this._logger.LogInformation("Actor control endpoint listening on 0.0.0.0:{Port}.", this._port); + + while (!stoppingToken.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(stoppingToken).ConfigureAwait(false); + _ = this.HandleClientAsync(client, stoppingToken); + } + } + catch (OperationCanceledException) + { + // The host is stopping. + } + catch (Exception ex) + { + this._logger.LogError(ex, "The actor control endpoint stopped unexpectedly."); + } + finally + { + listener.Stop(); + } + } + + private void StopActorsOnSignal() + { + try + { + this._logger.LogInformation("SIGTERM received: logging the actors out before the game servers stop."); + + // Blocking is the point: the handler runs on the signal thread and the process is about + // to die, so the logout has to finish here rather than on some continuation which will + // never be scheduled. Bounded, so a stuck actor cannot hold the shutdown open. +#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks + var stopTask = Task.Run(() => this._registry.StopAllAsync().AsTask()); + if (!stopTask.Wait(SignalStopTimeout)) + { + this._logger.LogWarning("The actors did not stop within {Timeout}; their last progress may be lost.", SignalStopTimeout); + return; + } + + this._logger.LogInformation("Stopped {Count} actor(s) on SIGTERM.", stopTask.Result); +#pragma warning restore VSTHRD002 + } + catch (Exception ex) + { + this._logger.LogError(ex, "Failed to stop the actors on SIGTERM."); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken stoppingToken) + { + using var connectionSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var cancellationToken = connectionSource.Token; + try + { + using (client) + { + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, LineEncoding, leaveOpen: true); + await using var writer = new StreamWriter(stream, LineEncoding, leaveOpen: true) { AutoFlush = true, NewLine = "\n" }; + + async ValueTask WriteLineAsync(string line) + { + await writer.WriteLineAsync(line.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + while (!cancellationToken.IsCancellationRequested + && await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + // A malformed line is answered with an error; the connection stays open and the + // other connections and actors are unaffected. + await this._handler.HandleLineAsync(line, WriteLineAsync, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // The connection or the host went away. + } + catch (IOException) + { + // The client hung up, e.g. an interrupted 'events --follow'. + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "An actor control connection failed."); + } + } +} diff --git a/src/Startup/TestActors/ActorEndpointOptions.cs b/src/Startup/TestActors/ActorEndpointOptions.cs new file mode 100644 index 000000000..d658b7667 --- /dev/null +++ b/src/Startup/TestActors/ActorEndpointOptions.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Startup.TestActors; + +using MUnique.OpenMU.GameLogic.TestActors; + +/// +/// The options of the actor control endpoint. +/// +/// The TCP port to listen on inside the container. +public sealed record ActorEndpointOptions(int Port); diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorEventLogTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorEventLogTests.cs new file mode 100644 index 000000000..0aebd88d3 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorEventLogTests.cs @@ -0,0 +1,141 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using System.Threading; +using MUnique.OpenMU.GameLogic.TestActors; + +/// +/// Tests for the bounded event ring and its live fan-out. +/// +[TestFixture] +public class ActorEventLogTests +{ + /// + /// Events get strictly increasing sequence numbers and a UTC timestamp. + /// + [Test] + public void AppendAssignsIncreasingSequenceNumbers() + { + var log = new ActorEventLog(16); + + var first = log.Append("chat", new ActorEventField("message", "hello")); + var second = log.Append("chat", new ActorEventField("message", "world")); + + Assert.That(first.Seq, Is.EqualTo(1)); + Assert.That(second.Seq, Is.GreaterThan(first.Seq)); + Assert.That(log.LastSequence, Is.EqualTo(second.Seq)); + Assert.That(first.Utc.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(first.Fields.Single().Value, Is.EqualTo("hello")); + } + + /// + /// The ring keeps the last N events, and the sequence numbers stay strictly increasing across + /// the wrap-around. + /// + [Test] + public void RingKeepsTheLastEventsWithIncreasingSequences() + { + var log = new ActorEventLog(4); + for (var i = 0; i < 10; i++) + { + log.Append("tick", new ActorEventField("i", i)); + } + + var kept = log.Since(0); + + Assert.That(kept.Count, Is.EqualTo(4)); + Assert.That(kept.Select(e => e.Seq), Is.EqualTo(new long[] { 7, 8, 9, 10 })); + Assert.That(kept.Select(e => e.Seq), Is.Ordered.Ascending); + Assert.That(kept.First().Fields.Single().Value, Is.EqualTo(6)); + } + + /// + /// A reader can fetch only what it has not seen yet. + /// + [Test] + public void SinceReturnsOnlyNewerEvents() + { + var log = new ActorEventLog(16); + log.Append("a"); + var second = log.Append("b"); + log.Append("c"); + + var newer = log.Since(second.Seq); + + Assert.That(newer.Select(e => e.Type), Is.EqualTo(new[] { "c" })); + Assert.That(log.Since(log.LastSequence), Is.Empty); + } + + /// + /// A follower receives events live, and gets the backlog it asks for first. + /// + [Test] + public async Task SubscriberReceivesBacklogAndLiveEventsAsync() + { + var log = new ActorEventLog(16); + log.Append("old"); + + using var subscription = log.Subscribe(sinceSequence: 0); + log.Append("live"); + + var first = await this.ReadNextAsync(subscription).ConfigureAwait(false); + var second = await this.ReadNextAsync(subscription).ConfigureAwait(false); + + Assert.That(first.Type, Is.EqualTo("old")); + Assert.That(second.Type, Is.EqualTo("live")); + } + + /// + /// A follower which does not read never blocks the writer: it loses the oldest events of its own + /// buffer and is told so with a lag event. + /// + [Test] + public async Task SlowSubscriberGetsALagEventAndNeverBlocksTheWriterAsync() + { + var log = new ActorEventLog(64); + using var subscription = log.Subscribe(capacity: 4); + + for (var i = 0; i < 20; i++) + { + log.Append("tick", new ActorEventField("i", i)); + } + + var received = new List(); + while (subscription.Reader.TryRead(out var actorEvent)) + { + received.Add(actorEvent); + } + + Assert.That(log.LastSequence, Is.GreaterThanOrEqualTo(20), "the writer appended everything without blocking"); + Assert.That(received.Any(e => e.Type == "lag"), Is.True, "the slow follower was told that it lost events"); + Assert.That(received.Last().Type, Is.EqualTo("tick"), "the most recent events survive"); + Assert.That(received.Select(e => e.Seq), Is.Ordered.Ascending); + await Task.CompletedTask.ConfigureAwait(false); + } + + /// + /// Disposing a subscription ends the stream and detaches it from the log. + /// + [Test] + public void DisposedSubscriptionStopsReceiving() + { + var log = new ActorEventLog(16); + var subscription = log.Subscribe(); + subscription.Dispose(); + + log.Append("after"); + + Assert.That(subscription.Reader.TryRead(out _), Is.False); + Assert.That(subscription.Reader.Completion.IsCompleted, Is.True); + } + + private async ValueTask ReadNextAsync(ActorEventSubscription subscription) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + return await subscription.Reader.ReadAsync(timeout.Token).ConfigureAwait(false); + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs new file mode 100644 index 000000000..ce90343e6 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs @@ -0,0 +1,130 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Tests; +using Moq; + +/// +/// Tests the hit attribution: exactly one record per hit and involved actor, never a duplicate. +/// +[TestFixture] +public class ActorHitRecorderPlugInTests +{ + private static readonly HitInfo Hit = new(57, 3, DamageAttributes.Undefined); + + /// + /// A hit taken from another player is recorded once, on the victim's log, naming the attacker. + /// + /// The task. + [Test] + public async Task ReceivedHitNamesItsAttackerAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var victim = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var attacker = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + attacker.SelectedCharacter!.Name = "Human"; + var lastSeq = victim.EventLog.LastSequence; + + new ActorHitRecorderPlugIn().AttackableGotHit(victim, attacker, Hit); + + var hits = victim.EventLog.Since(lastSeq).Where(e => e.Type == "hit").ToList(); + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That(hits[0].Fields.First(f => f.Name == "direction").Value, Is.EqualTo("received")); + Assert.That(hits[0].Fields.First(f => f.Name == "attacker").Value, Is.EqualTo("Human")); + Assert.That(hits[0].Fields.First(f => f.Name == "attacker_kind").Value, Is.EqualTo(ActorObjects.PlayerKind)); + Assert.That(hits[0].Fields.First(f => f.Name == "health_damage").Value, Is.EqualTo(57u)); + } + + /// + /// A hit dealt to a non-actor is recorded once, on the attacking actor's log. + /// + /// The task. + [Test] + public async Task DealtHitNamesItsTargetAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var target = new Mock(); + target.Setup(t => t.Id).Returns(123); + var lastSeq = actor.EventLog.LastSequence; + + new ActorHitRecorderPlugIn().AttackableGotHit(target.Object, actor, Hit); + + var hits = actor.EventLog.Since(lastSeq).Where(e => e.Type == "hit").ToList(); + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That(hits[0].Fields.First(f => f.Name == "direction").Value, Is.EqualTo("dealt")); + Assert.That(hits[0].Fields.First(f => f.Name == "target_id").Value, Is.EqualTo((ushort)123)); + } + + /// + /// Actor versus actor: one record on each side, and never two on either. + /// + /// The task. + [Test] + public async Task ActorVersusActorRecordsOneHitOnEachSideAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var attacker = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var victim = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + var attackerSeq = attacker.EventLog.LastSequence; + var victimSeq = victim.EventLog.LastSequence; + + new ActorHitRecorderPlugIn().AttackableGotHit(victim, attacker, Hit); + + var dealt = attacker.EventLog.Since(attackerSeq).Where(e => e.Type == "hit").ToList(); + var received = victim.EventLog.Since(victimSeq).Where(e => e.Type == "hit").ToList(); + Assert.That(dealt.Count, Is.EqualTo(1)); + Assert.That(received.Count, Is.EqualTo(1)); + Assert.That(dealt[0].Fields.First(f => f.Name == "direction").Value, Is.EqualTo("dealt")); + Assert.That(dealt[0].Fields.First(f => f.Name == "target").Value, Is.EqualTo("Actor2")); + Assert.That(dealt[0].Fields.First(f => f.Name == "target_kind").Value, Is.EqualTo(ActorObjects.ActorKind)); + Assert.That(received[0].Fields.First(f => f.Name == "direction").Value, Is.EqualTo("received")); + Assert.That(received[0].Fields.First(f => f.Name == "attacker").Value, Is.EqualTo("Actor1")); + Assert.That(received[0].Fields.First(f => f.Name == "attacker_kind").Value, Is.EqualTo(ActorObjects.ActorKind)); + } + + /// + /// A hit which involves no actor at all records nothing. + /// + /// The task. + [Test] + public async Task HitBetweenNonActorsRecordsNothingAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var bystander = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var attacker = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var victim = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var lastSeq = bystander.EventLog.LastSequence; + + new ActorHitRecorderPlugIn().AttackableGotHit(victim, attacker, Hit); + + Assert.That(bystander.EventLog.Since(lastSeq).Where(e => e.Type == "hit"), Is.Empty); + } + + /// + /// A bot is attributed as such, so a scenario can tell self-defence from a human's attack. + /// + /// The task. + [Test] + public async Task BotAttackerIsAttributedAsBotAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var victim = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + bot.Account!.IsBot = true; + bot.SelectedCharacter!.Name = "BotName"; + var lastSeq = victim.EventLog.LastSequence; + + new ActorHitRecorderPlugIn().AttackableGotHit(victim, bot, Hit); + + var hit = victim.EventLog.Since(lastSeq).Single(e => e.Type == "hit"); + Assert.That(hit.Fields.First(f => f.Name == "attacker_kind").Value, Is.EqualTo(ActorObjects.BotKind)); + Assert.That(hit.Fields.First(f => f.Name == "attacker").Value, Is.EqualTo("BotName")); + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs new file mode 100644 index 000000000..f2072a814 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs @@ -0,0 +1,156 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Text.Json; +using System.Threading.Tasks; +using System.Threading; +using MUnique.OpenMU.GameLogic.TestActors; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +/// +/// Tests the line protocol without opening a socket. +/// +[TestFixture] +public class ActorProtocolHandlerTests +{ + /// + /// A ping is answered with the build version. + /// + /// The task. + [Test] + public async Task PingIsAnsweredAsync() + { + var (handler, lines) = CreateHandler(); + + await handler.HandleLineAsync("""{"id":"1","cmd":"ping"}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + var response = lines.Single(); + Assert.That(response.GetProperty("ok").GetBoolean(), Is.True); + Assert.That(response.GetProperty("id").GetString(), Is.EqualTo("1")); + Assert.That(response.GetProperty("version").GetString(), Is.Not.Empty); + } + + /// + /// A line which is not a JSON object gets an error response - and the caller keeps the + /// connection, because nothing throws. + /// + /// The task. + [Test] + public async Task MalformedLineIsAnsweredWithAnErrorAsync() + { + var (handler, lines) = CreateHandler(); + + await handler.HandleLineAsync("this is not json", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + await handler.HandleLineAsync("[1,2,3]", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + await handler.HandleLineAsync("""{"cmd":"ping"}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + Assert.That(lines.Count, Is.EqualTo(3)); + Assert.That(lines[0].GetProperty("ok").GetBoolean(), Is.False); + Assert.That(lines[0].GetProperty("code").GetString(), Is.EqualTo(ActorErrorCodes.BadRequest)); + Assert.That(lines[1].GetProperty("code").GetString(), Is.EqualTo(ActorErrorCodes.BadRequest)); + Assert.That(lines[2].GetProperty("ok").GetBoolean(), Is.True, "the connection still works after a malformed line"); + } + + /// + /// An unknown command is refused instead of being ignored. + /// + /// The task. + [Test] + public async Task UnknownCommandIsRefusedAsync() + { + var (handler, lines) = CreateHandler(); + + await handler.HandleLineAsync("""{"id":"7","cmd":"nonsense"}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + Assert.That(lines.Single().GetProperty("ok").GetBoolean(), Is.False); + Assert.That(lines.Single().GetProperty("id").GetString(), Is.EqualTo("7")); + } + + /// + /// A command for an account which is not animated names the actor in the error. + /// + /// The task. + [Test] + public async Task CommandForAnUnknownActorIsRefusedAsync() + { + var (handler, lines) = CreateHandler(); + + await handler.HandleLineAsync("""{"cmd":"state","actor":"test9"}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + Assert.That(lines.Single().GetProperty("code").GetString(), Is.EqualTo(ActorErrorCodes.UnknownActor)); + Assert.That(lines.Single().GetProperty("error").GetString(), Does.Contain("test9")); + } + + /// + /// The registry is asked to spawn, and the failure it reports is passed through with its code. + /// + /// The task. + [Test] + public async Task SpawnPassesTheRegistryResultThroughAsync() + { + var registry = new Mock(); + registry.Setup(r => r.List()).Returns([]); + registry.Setup(r => r.SpawnAsync(0, "test1", null)) + .Returns(ValueTask.FromResult(ActorCommandResult.Failure(ActorErrorCodes.InUse, "The account 'test1' is already in use by a bot."))); + var lines = new ResponseCollector(); + var handler = new ActorProtocolHandler(registry.Object, CreateBotsController()); + + await handler.HandleLineAsync("""{"cmd":"spawn","actor":"test1"}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + Assert.That(lines.Single().GetProperty("code").GetString(), Is.EqualTo(ActorErrorCodes.InUse)); + registry.Verify(r => r.SpawnAsync(0, "test1", null), Times.Once); + } + + /// + /// The actor's event history is returned as one array of flattened events. + /// + /// The task. + [Test] + public async Task EventsReturnTheHistorySinceASequenceAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + actor.EventLog.Append("chat", new ActorEventField("message", "hello")); + var registry = new Mock(); + registry.Setup(r => r.Find("test1")).Returns(actor); + var lines = new ResponseCollector(); + var handler = new ActorProtocolHandler(registry.Object, CreateBotsController()); + + await handler.HandleLineAsync("""{"cmd":"events","actor":"test1","since":0}""", lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + var events = lines.Single().GetProperty("events").EnumerateArray().ToList(); + Assert.That(events, Is.Not.Empty); + Assert.That(events.Select(e => e.GetProperty("seq").GetInt64()), Is.Ordered.Ascending); + Assert.That(events.Last().GetProperty("type").GetString(), Is.EqualTo("chat")); + Assert.That(events.Last().GetProperty("message").GetString(), Is.EqualTo("hello")); + Assert.That(events.Last().GetProperty("utc").GetString(), Is.Not.Empty); + } + + private static (ActorProtocolHandler Handler, ResponseCollector Lines) CreateHandler() + { + var registry = new Mock(); + registry.Setup(r => r.List()).Returns([]); + registry.Setup(r => r.Find(It.IsAny())).Returns((ScriptedPlayer?)null); + return (new ActorProtocolHandler(registry.Object, CreateBotsController()), new ResponseCollector()); + } + + private static BotsController CreateBotsController() + { + var locator = new Mock(); + locator.Setup(l => l.GetContexts()).Returns([]); + return new BotsController(locator.Object, new NullLogger()); + } + + private sealed class ResponseCollector : List + { + public ValueTask WriteAsync(string line) + { + this.Add(JsonDocument.Parse(line).RootElement.Clone()); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs new file mode 100644 index 000000000..27e80d9a5 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs @@ -0,0 +1,263 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using System.Threading; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Tests; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +/// +/// Tests the registry: one actor per account, whatever else already animates it. +/// +[TestFixture] +public class ActorRegistryTests +{ + /// + /// An account which is held by the login server (a connected client, or an actor on another + /// game server) is refused. + /// + /// The task. + [Test] + public async Task AccountHeldByTheLoginServerIsRefusedAsync() + { + var fixture = await Fixture.CreateAsync(connectedAccounts: ["test1"]).ConfigureAwait(false); + + var result = await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.InUse)); + Assert.That(fixture.FactoryCalls, Is.EqualTo(0)); + } + + /// + /// An account animated by a population bot is refused, although bots never touch the login + /// server. + /// + /// The task. + [Test] + public async Task AccountAnimatedByABotIsRefusedAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + var bot = await PlayerTestHelper.CreatePlayerAsync(fixture.GameContext).ConfigureAwait(false); + bot.Account!.LoginName = "test1"; + bot.Account.IsBot = true; + fixture.PlayersInWorld.Add(bot); + + var result = await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.InUse)); + Assert.That(fixture.FactoryCalls, Is.EqualTo(0)); + } + + /// + /// A bot on ANOTHER game server of this process is found too: the bot population is split over + /// the servers, and two players driving one character would corrupt it. + /// + /// The task. + [Test] + public async Task AccountAnimatedOnAnotherGameServerIsRefusedAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + var bot = await PlayerTestHelper.CreatePlayerAsync(fixture.GameContext).ConfigureAwait(false); + bot.Account!.LoginName = "bot0001"; + bot.Account.IsBot = true; + fixture.PlayersOnOtherServer.Add(bot); + + var result = await fixture.Registry.SpawnAsync(0, "bot0001", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.InUse)); + Assert.That(result.Error, Does.Contain("a bot on game server 1")); + Assert.That(fixture.FactoryCalls, Is.EqualTo(0)); + } + + /// + /// A game server this process does not host is reported as such. + /// + /// The task. + [Test] + public async Task UnknownGameServerIsReportedAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + + var result = await fixture.Registry.SpawnAsync(7, "test1", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.UnknownServer)); + } + + /// + /// Ten concurrent spawns of one account produce exactly one actor. + /// + /// The task. + [Test] + public async Task ConcurrentSpawnsProduceExactlyOneActorAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + + var results = await Task.WhenAll(Enumerable.Range(0, 10) + .Select(_ => Task.Run(async () => await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false)))) + .ConfigureAwait(false); + + Assert.That(results.Count(r => r.Ok), Is.EqualTo(1)); + Assert.That(results.Where(r => !r.Ok).Select(r => r.Code), Is.All.EqualTo(ActorErrorCodes.InUse)); + Assert.That(fixture.Registry.List().Count, Is.EqualTo(1)); + Assert.That(fixture.FactoryCalls, Is.EqualTo(1)); + } + + /// + /// A spawned actor is found by its login name, and stopping it takes it out of the list. + /// + /// The task. + [Test] + public async Task SpawnStopRoundTripAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + + var spawn = await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false); + Assert.That(spawn.Ok, Is.True, spawn.Error); + Assert.That(fixture.Registry.Find("test1"), Is.Not.Null); + + var stop = await fixture.Registry.StopAsync("test1").ConfigureAwait(false); + + Assert.That(stop.Ok, Is.True, stop.Error); + Assert.That(fixture.Registry.Find("test1"), Is.Null); + Assert.That(fixture.Registry.List(), Is.Empty); + + var unknown = await fixture.Registry.StopAsync("test1").ConfigureAwait(false); + Assert.That(unknown.Code, Is.EqualTo(ActorErrorCodes.UnknownActor)); + } + + /// + /// The shutdown path stops every actor. + /// + /// The task. + [Test] + public async Task StopAllStopsEveryActorAsync() + { + var fixture = await Fixture.CreateAsync().ConfigureAwait(false); + await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false); + await fixture.Registry.SpawnAsync(0, "test2", null).ConfigureAwait(false); + + var stopped = await fixture.Registry.StopAllAsync().ConfigureAwait(false); + + Assert.That(stopped, Is.EqualTo(2)); + Assert.That(fixture.Registry.List(), Is.Empty); + } + + private sealed class Fixture + { + private Fixture(IGameContext gameContext, ActorRegistry registry, List playersInWorld, List playersOnOtherServer, Func factoryCalls) + { + this.GameContext = gameContext; + this.Registry = registry; + this.PlayersInWorld = playersInWorld; + this.PlayersOnOtherServer = playersOnOtherServer; + this.FactoryCallCounter = factoryCalls; + } + + public IGameContext GameContext { get; } + + public ActorRegistry Registry { get; } + + public List PlayersInWorld { get; } + + public List PlayersOnOtherServer { get; } + + public int FactoryCalls => this.FactoryCallCounter(); + + private Func FactoryCallCounter { get; } + + public static async ValueTask CreateAsync(IEnumerable? connectedAccounts = null) + { + var gameContext = ActorTestHelper.CreateGameContext(); + var playersInWorld = new List(); + var playersOnOtherServer = new List(); + var loginServer = new FakeLoginServer(connectedAccounts); + + var serverContext = new Mock(); + serverContext.Setup(c => c.Id).Returns(0); + serverContext.Setup(c => c.LoginServer).Returns(loginServer); + serverContext.Setup(c => c.GetPlayersAsync()).Returns(() => ValueTask.FromResult>(playersInWorld.ToList())); + + // A second game server of the same process, as the local stack runs it. + var otherContext = new Mock(); + otherContext.Setup(c => c.Id).Returns((byte)1); + otherContext.Setup(c => c.LoginServer).Returns(loginServer); + otherContext.Setup(c => c.GetPlayersAsync()).Returns(() => ValueTask.FromResult>(playersOnOtherServer.ToList())); + + var locator = new Mock(); + locator.Setup(l => l.GetContext(0)).Returns(serverContext.Object); + locator.Setup(l => l.GetContext(It.Is(id => id != 0))).Returns((IGameServerContext?)null); + locator.Setup(l => l.GetContexts()).Returns([(0, serverContext.Object), (1, otherContext.Object)]); + + var factory = new CountingActorFactory(gameContext); + var registry = new ActorRegistry(locator.Object, factory, new NullLogger()); + await Task.CompletedTask.ConfigureAwait(false); + return new Fixture(gameContext, registry, playersInWorld, playersOnOtherServer, () => factory.Calls); + } + } + + private sealed class CountingActorFactory : IActorFactory + { + private readonly IGameContext _gameContext; + private int _calls; + + public CountingActorFactory(IGameContext gameContext) + { + this._gameContext = gameContext; + } + + public int Calls => this._calls; + + public async ValueTask CreateAsync(IGameServerContext context, string loginName, byte? characterSlot) + { + var call = Interlocked.Increment(ref this._calls); + return await ActorTestHelper.CreateActorAsync(this._gameContext, loginName, $"Actor{call}").ConfigureAwait(false); + } + } + + private sealed class FakeLoginServer : ILoginServer + { + private readonly HashSet _connected; + + public FakeLoginServer(IEnumerable? connectedAccounts) + { + this._connected = new HashSet(connectedAccounts ?? [], StringComparer.OrdinalIgnoreCase); + } + + public Task TryLoginAsync(string accountName, byte serverId) + { + lock (this._connected) + { + return Task.FromResult(this._connected.Add(accountName)); + } + } + + public ValueTask LogOffAsync(string accountName, byte serverId) + { + lock (this._connected) + { + this._connected.Remove(accountName); + } + + return ValueTask.CompletedTask; + } + + public ValueTask> GetSnapshotAsync() + { + lock (this._connected) + { + return ValueTask.FromResult(this._connected.ToDictionary(a => a, _ => (byte)0)); + } + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs new file mode 100644 index 000000000..a4adb9f18 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs @@ -0,0 +1,82 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.Tests; + +/// +/// Builds scripted actors on top of the in-memory game context of the upstream test helpers. +/// +public static class ActorTestHelper +{ + /// + /// The terrain byte of a walkable tile inside a safe zone. + /// + private const byte SafezoneTerrain = 1; + + /// + /// The terrain byte of a tile which cannot be walked on. + /// + private const byte BlockedTerrain = 2; + + /// + /// The view range of the test context, as in the game's default configuration. + /// + private const byte DefaultInfoRange = 12; + + /// + /// Creates an in-memory game context whose (only) map is walkable everywhere, except for the + /// given special tiles. Must be called before the map is used, because the terrain is read once. + /// + /// The tiles which belong to a safe zone. + /// The tiles which cannot be walked on. + /// The game context. + public static IGameContext CreateGameContext(IEnumerable? safezoneTiles = null, IEnumerable? blockedTiles = null) + { + var gameContext = GameContextTestHelper.CreateGameContext(); + + // The upstream helper leaves the view range at 0, which would put nothing at all into an + // actor's view; the default configuration of the game uses 12. + gameContext.Configuration.InfoRange = DefaultInfoRange; + var terrain = gameContext.Configuration.Maps.First().TerrainData!; + foreach (var tile in safezoneTiles ?? []) + { + terrain[3 + tile.X + (tile.Y << 8)] = SafezoneTerrain; + } + + foreach (var tile in blockedTiles ?? []) + { + terrain[3 + tile.X + (tile.Y << 8)] = BlockedTerrain; + } + + return gameContext; + } + + /// + /// Creates an actor and lets it enter the world of the given context, the way + /// does it once the account is loaded. + /// + /// The game context. + /// The login name the actor is addressed by. + /// The name of its character. + /// The actor, in the world. + public static async ValueTask CreateActorAsync(IGameContext gameContext, string loginName, string characterName) + { + var template = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + var account = template.Account!; + var character = template.SelectedCharacter!; + account.LoginName = loginName; + character.Name = characterName; + + var actor = new ScriptedPlayer(gameContext); + var entered = await actor.EnterWorldAsync(account, character).ConfigureAwait(false); + Assert.That(entered, Is.True, $"the actor '{loginName}' should have entered the world"); + return actor; + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs new file mode 100644 index 000000000..ed0f3b213 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs @@ -0,0 +1,104 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.PlugIns; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +/// +/// Tests the population bot switch. +/// +[TestFixture] +public class BotsControllerTests +{ + /// + /// bots on --count N writes N accounts with one character each and no presence rotation, + /// and that configuration survives the round trip through the persisted plugin configuration. + /// + [Test] + public void BotConfigurationRoundTripsThroughThePlugInConfiguration() + { + var configuration = new BotConfiguration { NumberOfAccounts = 10, MaxCharactersPerAccount = 5, PresenceRotation = true }; + var entity = new PlugInConfiguration { TypeId = typeof(BotFeaturePlugIn).GUID, IsActive = true }; + + BotsController.Apply(configuration, enabled: true, count: 2); + entity.SetConfiguration(configuration, null); + var roundTripped = entity.GetConfiguration(null); + + Assert.That(roundTripped, Is.Not.Null); + Assert.That(roundTripped!.Enabled, Is.True); + Assert.That(roundTripped.NumberOfAccounts, Is.EqualTo(2)); + Assert.That(roundTripped.MaxCharactersPerAccount, Is.EqualTo(1)); + Assert.That(roundTripped.PresenceRotation, Is.False); + } + + /// + /// Without a count, the documented default population is requested. + /// + [Test] + public void DefaultCountIsUsedWhenNoneIsGiven() + { + var configuration = new BotConfiguration(); + + BotsController.Apply(configuration, enabled: true, count: null); + + Assert.That(configuration.NumberOfAccounts, Is.EqualTo(BotsController.DefaultBotCount)); + Assert.That(configuration.MaxCharactersPerAccount, Is.EqualTo(1)); + } + + /// + /// Switching off leaves the population settings alone - the accounts stay, the bots just log out. + /// + [Test] + public void SwitchingOffKeepsThePopulationSettings() + { + var configuration = new BotConfiguration(); + BotsController.Apply(configuration, enabled: true, count: 3); + + BotsController.Apply(configuration, enabled: false, count: null); + + Assert.That(configuration.Enabled, Is.False); + Assert.That(configuration.NumberOfAccounts, Is.EqualTo(3)); + } + + /// + /// An unknown action is refused with a message which names the ones that exist. + /// + /// The task. + [Test] + public async Task UnknownActionIsRefusedAsync() + { + var locator = new Mock(); + locator.Setup(l => l.GetContexts()).Returns([(0, new Mock().Object)]); + var controller = new BotsController(locator.Object, new NullLogger()); + + var result = await controller.HandleAsync("nonsense", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.BadRequest)); + Assert.That(result.Error, Does.Contain("status")); + } + + /// + /// A process without a game server says so instead of pretending. + /// + /// The task. + [Test] + public async Task NoGameServerIsReportedAsync() + { + var locator = new Mock(); + locator.Setup(l => l.GetContexts()).Returns([]); + var controller = new BotsController(locator.Object, new NullLogger()); + + var result = await controller.HandleAsync("status", null).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.UnknownServer)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs new file mode 100644 index 000000000..ceccc19b3 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs @@ -0,0 +1,206 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests the command execution: every command is either carried out or refused with a code, and a +/// long command can be interrupted without losing the actor. +/// +[TestFixture] +public class ScriptedIntelligenceTests +{ + /// + /// A skill the character never learned is refused. + /// + /// The task. + [Test] + public async Task UnknownSkillIsRefusedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new SkillCommand(9999, "Actor2")).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.UnknownSkill)); + } + + /// + /// A target which is nowhere near the actor is refused before anything happens. + /// + /// The task. + [Test] + public async Task AttackOnAnUnknownTargetIsRefusedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new AttackCommand("NotHere", 1, 0)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.NotInView)); + } + + /// + /// A target in view but out of the character's melee range is refused, with the distance in the + /// message. + /// + /// The task. + [Test] + public async Task AttackOutOfRangeIsRefusedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var target = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + await target.MoveAsync(new Point(105, 100)).ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new AttackCommand("Actor2", 1, 0)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.OutOfRange)); + Assert.That(result.Error, Does.Contain("5")); + } + + /// + /// Attacking from within a safe zone is refused - the engine would silently do nothing. + /// + /// The task. + [Test] + public async Task AttackFromTheSafezoneIsRefusedAsync() + { + var safezone = new Point(50, 50); + var gameContext = ActorTestHelper.CreateGameContext(safezoneTiles: [safezone, new Point(51, 50)]); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var target = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + await actor.MoveAsync(safezone).ConfigureAwait(false); + await target.MoveAsync(new Point(51, 50)).ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new AttackCommand("Actor2", 1, 0)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.SafeZone)); + } + + /// + /// A walk reaches its target and reports the planned path length. + /// + /// The task. + [Test] + public async Task WalkReachesTheTargetAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new WalkCommand(104, 104)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.True, result.Error); + Assert.That(result.Fields.First(f => f.Name == "steps").Value, Is.EqualTo(4)); + Assert.That(actor.Position, Is.EqualTo(new Point(104, 104))); + } + + /// + /// Walking to a tile the path finder cannot reach is refused instead of silently doing nothing. + /// + /// The task. + [Test] + public async Task WalkWithoutAPathIsRefusedAsync() + { + var blocked = new Point(80, 80); + var gameContext = ActorTestHelper.CreateGameContext(blockedTiles: [blocked]); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await actor.MoveAsync(new Point(78, 80)).ConfigureAwait(false); + + var result = await actor.Intelligence!.ExecuteAsync(new WalkCommand(blocked.X, blocked.Y)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.NoPath)); + } + + /// + /// A halt during a repeated attack ends the command with interrupted and the hits + /// performed so far, records an interrupted event, and leaves the actor in the world. + /// + /// The task. + [Test] + public async Task HaltInterruptsARepeatedAttackAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var target = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + await target.MoveAsync(new Point(100, 101)).ConfigureAwait(false); + var lastSeq = actor.EventLog.LastSequence; + + var attack = actor.Intelligence!.ExecuteAsync(new AttackCommand("Actor2", 20, 1000)); + await Task.Delay(300).ConfigureAwait(false); + actor.Intelligence.Halt(); + var result = await attack.ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.Interrupted)); + var hits = (IReadOnlyList)result.Fields.First(f => f.Name == "hits").Value!; + Assert.That(hits.Count, Is.InRange(1, 3)); + Assert.That(actor.EventLog.Since(lastSeq).Any(e => e.Type == "interrupted"), Is.True); + Assert.That(actor.PlayerState.CurrentState, Is.EqualTo(MUnique.OpenMU.GameLogic.PlayerState.EnteredWorld)); + Assert.That((await gameContext.GetPlayersAsync().ConfigureAwait(false)), Contains.Item(actor)); + } + + /// + /// A halt during a multi-step walk stops the actor where it is, answers the caller with + /// interrupted and the progress, and keeps the actor in the world. + /// + /// The task. + [Test] + public async Task HaltInterruptsAWalkAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + var lastSeq = actor.EventLog.LastSequence; + + // 12 tiles is what one path finder request covers, like a client's click. + var walk = actor.Intelligence!.ExecuteAsync(new WalkCommand(112, 112)); + await Task.Delay(300).ConfigureAwait(false); + actor.Intelligence.Halt(); + var result = await walk.ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.Interrupted)); + Assert.That(result.Fields.First(f => f.Name == "steps").Value, Is.GreaterThan(0)); + Assert.That(actor.IsWalking, Is.False, "the halt stopped the walk"); + Assert.That(actor.EventLog.Since(lastSeq).Any(e => e.Type == "interrupted"), Is.True); + Assert.That((await gameContext.GetPlayersAsync().ConfigureAwait(false)), Contains.Item(actor)); + } + + /// + /// A second command interrupts the one in flight the same way a halt does, and then runs. + /// + /// The task. + [Test] + public async Task ASecondCommandInterruptsTheFirstAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var target = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + await target.MoveAsync(new Point(100, 101)).ConfigureAwait(false); + + var firstAttack = actor.Intelligence!.ExecuteAsync(new AttackCommand("Actor2", 20, 1000)); + await Task.Delay(300).ConfigureAwait(false); + var second = actor.Intelligence.ExecuteAsync(new SayCommand("interrupting")); + + var firstResult = await firstAttack.ConfigureAwait(false); + var secondResult = await second.ConfigureAwait(false); + + Assert.That(firstResult.Code, Is.EqualTo(ActorErrorCodes.Interrupted)); + Assert.That(secondResult.Ok, Is.True, secondResult.Error); + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ScriptedPlayerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedPlayerTests.cs new file mode 100644 index 000000000..b2310da61 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedPlayerTests.cs @@ -0,0 +1,127 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.TestActors; + +using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic; + +/// +/// Tests for the actor itself: its login sequence and its recording views. +/// +[TestFixture] +public class ScriptedPlayerTests +{ + /// + /// The actor logs the character in without a connection and ends up in the world, as a player + /// of the game context - not as an offline player. + /// + /// The task. + [Test] + public async Task ActorEntersTheWorldAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + + Assert.That(actor.PlayerState.CurrentState, Is.EqualTo(PlayerState.EnteredWorld)); + Assert.That(actor.AccountLoginName, Is.EqualTo("test1")); + Assert.That(actor.Name, Is.EqualTo("Actor1")); + Assert.That(actor.CurrentMap, Is.Not.Null); + Assert.That(actor, Is.Not.InstanceOf(), "bots must treat an actor like a human player"); + Assert.That((await gameContext.GetPlayersAsync().ConfigureAwait(false)), Contains.Item(actor)); + Assert.That(actor.Intelligence, Is.Not.Null); + } + + /// + /// Entering the world is recorded, so a scenario can wait for it. + /// + /// The task. + [Test] + public async Task EnteringTheWorldIsRecordedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + + var spawned = actor.EventLog.Since(0).FirstOrDefault(e => e.Type == "spawned"); + Assert.That(spawned, Is.Not.Null); + Assert.That(spawned!.Fields.First(f => f.Name == "character").Value, Is.EqualTo("Actor1")); + } + + /// + /// The recording container answers the views the actor records, and null for everything + /// else - the way the offline player's container does. + /// + /// The task. + [Test] + public async Task ViewContainerAnswersRecordedViewsOnlyAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Not.Null); + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Not.Null); + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Not.Null); + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Not.Null); + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Not.Null); + + // Deliberately not implemented: hits are attributed by the hit recorder plugin instead. + Assert.That(actor.ViewPlugIns.GetPlugIn(), Is.Null); + } + + /// + /// A kill in the actor's view is recorded with both names. + /// + /// The task. + [Test] + public async Task KillIsRecordedWithKillerAndVictimAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await using var victim = await ActorTestHelper.CreateActorAsync(gameContext, "test2", "Actor2").ConfigureAwait(false); + var lastSeq = actor.EventLog.LastSequence; + + await actor.ViewPlugIns.GetPlugIn()!.ObjectGotKilledAsync(victim, actor).ConfigureAwait(false); + + var killed = actor.EventLog.Since(lastSeq).Single(e => e.Type == "killed"); + Assert.That(killed.Fields.First(f => f.Name == "victim").Value, Is.EqualTo("Actor2")); + Assert.That(killed.Fields.First(f => f.Name == "killer").Value, Is.EqualTo("Actor1")); + Assert.That(killed.Fields.First(f => f.Name == "killer_kind").Value, Is.EqualTo(ActorObjects.ActorKind)); + } + + /// + /// Stat changes reach the stream with the attribute's designation and the new value. + /// + /// The task. + [Test] + public async Task StatChangesAreRecordedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var view = actor.ViewPlugIns.GetPlugIn()!; + var lastSeq = actor.EventLog.LastSequence; + + await view.UpdateStatsAsync(MUnique.OpenMU.GameLogic.Attributes.Stats.CurrentHealth, 42).ConfigureAwait(false); + await view.UpdateStatsAsync(MUnique.OpenMU.GameLogic.Attributes.Stats.CurrentShield, 7).ConfigureAwait(false); + await view.UpdateStatsAsync(MUnique.OpenMU.GameLogic.Attributes.Stats.CurrentMana, 13).ConfigureAwait(false); + await view.UpdateStatsAsync(MUnique.OpenMU.GameLogic.Attributes.Stats.CurrentAbility, 3).ConfigureAwait(false); + + // Bookkeeping attributes the engine recalculates constantly are not recorded, or they would + // wrap the ring within minutes (see design decision 4). + await view.UpdateStatsAsync(MUnique.OpenMU.GameLogic.Attributes.Stats.ShieldRecoveryMultiplier, 0.002f).ConfigureAwait(false); + + var stats = actor.EventLog.Since(lastSeq).Where(e => e.Type == "stat").ToList(); + Assert.That(stats.Count, Is.EqualTo(4)); + Assert.That(stats[0].Fields.First(f => f.Name == "attribute").Value, Is.EqualTo("Current Health")); + Assert.That(stats[0].Fields.First(f => f.Name == "value").Value, Is.EqualTo(42f)); + Assert.That(stats.Select(s => s.Fields.First(f => f.Name == "attribute").Value), Is.EqualTo(new object[] + { + "Current Health", "Current Shield", "Current Mana", "Current Ability", + })); + } +} From 43a6b7d3730b3ce9bbcccf8426e8d5d78cbc6746 Mon Sep 17 00:00:00 2001 From: alandarev Date: Tue, 15 Sep 2026 22:04:56 +0300 Subject: [PATCH 2/4] Document the test actors and the login probe Adds the server-features page for the actors to the docs website, registers it in the sidebar, and gives the login probe a Readme like the other nested tool projects. --- .../docs/server-features/test-actors.md | 96 +++++++++++++++++++ docs-website/sidebars.js | 1 + src/Network/LoginProbe/Readme.md | 42 ++++++++ 3 files changed, 139 insertions(+) create mode 100644 docs-website/docs/server-features/test-actors.md create mode 100644 src/Network/LoginProbe/Readme.md diff --git a/docs-website/docs/server-features/test-actors.md b/docs-website/docs/server-features/test-actors.md new file mode 100644 index 000000000..ecb3f3f84 --- /dev/null +++ b/docs-website/docs/server-features/test-actors.md @@ -0,0 +1,96 @@ +--- +title: Scripted test actors +sidebar_label: Test actors +sidebar_position: 2 +description: Connection-less players which execute commands and report what happens to them, for testing game mechanics without a client. +--- + +# Scripted test actors + +A test actor is a character played by a script instead of by a person: it logs +in without a connection, does what a command tells it — walk there, attack +that, cast this, say something, pick that up, warp — and records everything the +game would have shown to its client as a stream of JSON events. + +It exists so that game mechanics can be exercised and asserted automatically. +PvP needs two players, a party needs several, and a victim's health is only +known to the victim itself; with actors, all of that is a shell script. The +feature is off unless the server is told to open its control port, and it is +meant for development and test servers, not for a live one. + +## How it works + +**An actor is a player, not a bot.** It derives from `Player` and repeats the +login sequence of the connection-less offline player (the same class which +keeps a character playing after its owner logs out, see [bots](bots.md)), but +it is deliberately *not* an `OfflinePlayer`: the server-side bots only defend themselves against players +which are not offline players, mini games skip offline party leaders, and the +admin panel lists them as offline accounts. An actor has to be a human +stand-in, so everything which asks "is this a real player" answers yes. + +**Commands run on the actor's own tick.** Each command executes inside the +player's persistence lock, so it never overlaps the periodic save or another +command, and the engine's attribute system is only ever touched from that one +flow. Long commands — a walk across the map, a repeated attack — release the +lock between their steps and can be interrupted: `halt`, or simply the next +command, ends the one in flight, which answers its caller with the progress it +made. + +**Every command answers.** A command either succeeds with a result or fails +with a code (`out_of_range`, `safezone`, `not_in_view`, `unknown_skill`, +`no_path`, `interrupted`, …). Nothing is silently ignored — a test which +"passes" because nothing happened is worse than no test. + +**Events carry the attribution the client protocol does not.** The view a +client receives tells it that it was hit, but not by whom, so hits are recorded +from the `IAttackableGotHitPlugIn` plugin point instead, which knows both +sides. Each hit produces exactly one event on the attacker's stream and one on +the victim's, naming the other side and its kind (player, bot, monster). Kills, +stat changes (health, shield, mana, ability), chat, drops, map changes and +objects entering or leaving view come from the actor's own recording view +container. Every event has a strictly increasing sequence number and a UTC +timestamp, so a reader can ask for everything after what it already saw, or +follow the stream live. + +## The control endpoint + +The server opens a TCP port when the environment variable `OPENMU_ACTOR_PORT` +is set to a port number, and does not open it otherwise. The protocol is +newline-delimited JSON: one request object per line, one response object per +line, with a streaming mode for following events. + +```json +{"id":"1","cmd":"spawn","actor":"test1"} +{"ok":true,"id":"1","actor":{"actor":"test1","character":"test1Dk","map":"Lorencia","x":125,"y":132,"alive":true}} +``` + +The endpoint is registered by the all-in-one `MUnique.OpenMU.Startup` host +only; the distributed (Dapr) game server host does not open it. + +The endpoint has **no authentication**. Bind it to a loopback address, never +expose it, and leave the variable unset on any server which is not yours to +test on. An account which is already animated — by another actor, by a +population bot on any game server of the process, or by a connected client — is +refused, so an actor can never drive a character someone else is driving. + +## Commands + +| Command | What it does | +|---|---| +| `spawn` / `stop` | animate an account's character; log it out again (saving its progress) | +| `list` / `state` / `nearby` | which actors exist; one actor's full state; what it can see | +| `walk x y` | walk to a position of the current map, one path-finder request over the whole map | +| `attack [--times N] [--interval ms]` | plain attacks against an object in view | +| `skill ` | cast a learned skill | +| `say`, `pickup`, `warp` | chat (including chat commands), pick up a drop, use a warp list entry | +| `halt` | cancel the walk or attack in flight, keeping the actor in the world | +| `events [--since N] [--follow]` | the actor's event stream | + +## Population bots as opponents + +The [bots](bots.md) feature pairs naturally with actors: bots are legal PvP +targets which fight back under the game's own self-defence rules, so an actor +has something to fight without a second script. The endpoint can switch them +on and off (`bots on --count N`, `bots off`, `bots status`) through the same +persisted plugin configuration the admin panel edits, and reports where the +animated bots currently are. diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index c3b98717a..33dcc365a 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -58,6 +58,7 @@ const sidebars = { label: 'Server features', items: [ 'server-features/bots', + 'server-features/test-actors', ], }, { diff --git a/src/Network/LoginProbe/Readme.md b/src/Network/LoginProbe/Readme.md new file mode 100644 index 000000000..eb78c4bc6 --- /dev/null +++ b/src/Network/LoginProbe/Readme.md @@ -0,0 +1,42 @@ +# Login Probe + +A console program which logs an account into a running server the way a game +client does — ask the connect server for a game server, connect to it, send the +encrypted login packet — and then holds the session open for a while. + +It exists for tests and diagnostics which need a *real* client connection +rather than a simulated player: checking that an account is refused a second +login, that the connect server hands out the right endpoint, or that the login +path still works after a change to the encryption or the packet definitions. +Because it speaks the protocol through +[Network](../Readme.md) and [Packets](../Packets), it stays correct when those +change. + +## Usage + +``` +MUnique.OpenMU.Network.LoginProbe --account [--password ] + [--host 127.0.0.1] [--connect-port 44406] + [--server 0] [--hold ] +``` + +The password defaults to the account name, which is what the shipped test +accounts use. One JSON object per step is written to standard output: + +```json +{"step":"server_selected","host":"127.127.127.127","port":55902,"server":0} +{"ok":true,"step":"login","account":"test4","result":"Okay"} +{"ok":true,"step":"holding","seconds":30} +{"ok":true,"step":"disconnected","account":"test4"} +``` + +The exit code is 0 when the login succeeded, 1 when the server refused it or +the connection failed, and 2 for a usage error. + +## Contents + +* `Program` - argument parsing, the connect server conversation + (`ServerListRequest` → `ConnectionInfoRequest` → `ConnectionInfo`), and the + game server login: a `Connection` with the client side of + `OpenSourceClientNetworkEncryptionFactoryPlugIn`, credentials encrypted with + `Xor3Encryptor`, and a wait for `LoginResponse`. From b53243b68ee023c3886a42e32becc11ea8f0a80e Mon Sep 17 00:00:00 2001 From: alandarev Date: Tue, 15 Sep 2026 23:06:40 +0300 Subject: [PATCH 3/4] Address the static analysis findings of the test actors The Codacy run on the pull request reported findings in the added files; this fixes the ones which are real, without changing behaviour: - public constants of the actor protocol (error codes, object kinds, the environment variable name and the defaults) became public static readonly fields, so they are not inlined into consumers (SonarC# S2339). The two event log defaults were used as optional parameter values, which needs a compile time constant, so those parameters are nullable now; - removed the usings which the enclosing namespace already provides (S1128), and the stray blank line they left behind; - the argument parser of the login probe got one statement per line, an invariant culture for its int.Parse calls and a while loop instead of a for loop whose counter it advanced twice (S122, S4056, S127), and no longer keeps the unused result of the connection info request (S1481); - IGameServerContextLocator.GetContexts() and ActorControlService.GetConfiguredPort() became properties (S4049); - the catch which only rethrew a cancellation is now an exception filter on the general catch (S2737), and the comment which read like commented out code was rewritten (S125); - the documentation page rewraps an over-long line and the tool readme labels its fenced block (markdownlint MD013, MD040). Verified: 43 unit tests pass, the login probe and the Startup image build without a new StyleCop warning in the touched files, the docs website builds, and the image answers spawn/state/stop on the control port while staying silent without OPENMU_ACTOR_PORT. --- .../docs/server-features/test-actors.md | 9 ++-- src/GameLogic/TestActors/ActorErrorCodes.cs | 40 +++++++++--------- src/GameLogic/TestActors/ActorEventLog.cs | 22 +++++----- .../TestActors/ActorHitRecorderPlugIn.cs | 1 - src/GameLogic/TestActors/ActorObjects.cs | 15 ++++--- .../TestActors/ActorProtocolHandler.cs | 6 +-- src/GameLogic/TestActors/ActorRegistry.cs | 3 +- src/GameLogic/TestActors/ActorState.cs | 1 - src/GameLogic/TestActors/BotsController.cs | 10 ++--- src/GameLogic/TestActors/IActorFactory.cs | 2 - .../TestActors/IGameServerContextLocator.cs | 30 ++++++------- .../RecordingViewPlugInContainer.cs | 1 - .../TestActors/ScriptedIntelligence.cs | 1 - src/GameLogic/TestActors/ScriptedPlayer.cs | 1 - src/Network/LoginProbe/Program.cs | 42 ++++++++++++------- src/Network/LoginProbe/Readme.md | 2 +- src/Startup/Program.cs | 2 +- src/Startup/TestActors/ActorControlService.cs | 14 ++++--- .../TestActors/ActorHitRecorderPlugInTests.cs | 1 - .../TestActors/ActorProtocolHandlerTests.cs | 2 +- .../TestActors/ActorRegistryTests.cs | 3 +- .../TestActors/ActorTestHelper.cs | 1 - .../TestActors/BotsControllerTests.cs | 4 +- 23 files changed, 105 insertions(+), 108 deletions(-) diff --git a/docs-website/docs/server-features/test-actors.md b/docs-website/docs/server-features/test-actors.md index ecb3f3f84..1503652b3 100644 --- a/docs-website/docs/server-features/test-actors.md +++ b/docs-website/docs/server-features/test-actors.md @@ -23,10 +23,11 @@ meant for development and test servers, not for a live one. **An actor is a player, not a bot.** It derives from `Player` and repeats the login sequence of the connection-less offline player (the same class which keeps a character playing after its owner logs out, see [bots](bots.md)), but -it is deliberately *not* an `OfflinePlayer`: the server-side bots only defend themselves against players -which are not offline players, mini games skip offline party leaders, and the -admin panel lists them as offline accounts. An actor has to be a human -stand-in, so everything which asks "is this a real player" answers yes. +it is deliberately *not* an `OfflinePlayer`: the server-side bots only defend +themselves against players which are not offline players, mini games skip +offline party leaders, and the admin panel lists them as offline accounts. An +actor has to be a human stand-in, so everything which asks "is this a real +player" answers yes. **Commands run on the actor's own tick.** Each command executes inside the player's persistence lock, so it never overlaps the periodic save or another diff --git a/src/GameLogic/TestActors/ActorErrorCodes.cs b/src/GameLogic/TestActors/ActorErrorCodes.cs index a410bd922..da212586b 100644 --- a/src/GameLogic/TestActors/ActorErrorCodes.cs +++ b/src/GameLogic/TestActors/ActorErrorCodes.cs @@ -11,66 +11,66 @@ namespace MUnique.OpenMU.GameLogic.TestActors; public static class ActorErrorCodes { /// The actor or its target stands in a safe zone, where attacks are forbidden. - public const string SafeZone = "safezone"; + public static readonly string SafeZone = "safezone"; /// The target is farther away than the character's attack or skill range. - public const string OutOfRange = "out_of_range"; + public static readonly string OutOfRange = "out_of_range"; /// No object with that id or name is within the actor's view. - public const string NotInView = "not_in_view"; + public static readonly string NotInView = "not_in_view"; /// The character has not learned the requested skill. - public const string UnknownSkill = "unknown_skill"; + public static readonly string UnknownSkill = "unknown_skill"; /// The character cannot pay the skill's mana or ability cost. - public const string InsufficientResources = "insufficient_resources"; + public static readonly string InsufficientResources = "insufficient_resources"; /// The path finder found no way to the requested position. - public const string NoPath = "no_path"; + public static readonly string NoPath = "no_path"; /// The target cannot be attacked (dead, not attackable, or the actor itself). - public const string InvalidTarget = "invalid_target"; + public static readonly string InvalidTarget = "invalid_target"; /// The actor is dead and cannot act. - public const string Dead = "dead"; + public static readonly string Dead = "dead"; /// The actor is not (yet) in the world. - public const string NotReady = "not_ready"; + public static readonly string NotReady = "not_ready"; /// A halt or a later command interrupted this one. - public const string Interrupted = "interrupted"; + public static readonly string Interrupted = "interrupted"; /// The requested warp list entry does not exist. - public const string UnknownGate = "unknown_gate"; + public static readonly string UnknownGate = "unknown_gate"; /// The warp was refused by the game's level, zen or map rules. - public const string WarpRefused = "warp_refused"; + public static readonly string WarpRefused = "warp_refused"; /// The drop is not there any more, or could not be picked up. - public const string PickupFailed = "pickup_failed"; + public static readonly string PickupFailed = "pickup_failed"; /// The command threw; the message carries the exception. - public const string Failed = "failed"; + public static readonly string Failed = "failed"; /// The account is already animated by an actor, a bot or a connected client. - public const string InUse = "in_use"; + public static readonly string InUse = "in_use"; /// No actor animates the given account. - public const string UnknownActor = "unknown_actor"; + public static readonly string UnknownActor = "unknown_actor"; /// This process does not host the requested game server. - public const string UnknownServer = "unknown_server"; + public static readonly string UnknownServer = "unknown_server"; /// The account or its character could not be loaded, so the actor never entered the world. - public const string SpawnFailed = "spawn_failed"; + public static readonly string SpawnFailed = "spawn_failed"; /// The request was not a JSON object, or its cmd is unknown. - public const string BadRequest = "bad_request"; + public static readonly string BadRequest = "bad_request"; /// /// The game refused the skill without telling why - its plugin returns silently when the /// character is stunned, in a safe zone, lacks mana or a requirement, or the target is not a /// legal one for that skill. /// - public const string SkillRefused = "skill_refused"; + public static readonly string SkillRefused = "skill_refused"; } diff --git a/src/GameLogic/TestActors/ActorEventLog.cs b/src/GameLogic/TestActors/ActorEventLog.cs index e9447092c..883a6d375 100644 --- a/src/GameLogic/TestActors/ActorEventLog.cs +++ b/src/GameLogic/TestActors/ActorEventLog.cs @@ -4,7 +4,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; -using System.Threading; using System.Threading.Channels; /// @@ -23,12 +22,12 @@ public sealed class ActorEventLog /// The number of events kept per actor. The specification asks for at least 1000; 4096 gives a /// scenario room to run for a while before it has to read. /// - public const int DefaultCapacity = 4096; + public static readonly int DefaultCapacity = 4096; /// /// The number of events a live follower may fall behind before it starts losing the oldest ones. /// - public const int DefaultSubscriptionCapacity = 1024; + public static readonly int DefaultSubscriptionCapacity = 1024; private readonly object _syncRoot = new(); private readonly ActorEvent?[] _ring; @@ -41,15 +40,16 @@ public sealed class ActorEventLog /// /// Initializes a new instance of the class. /// - /// The number of events to keep; defaults to . - public ActorEventLog(int capacity = DefaultCapacity) + /// The number of events to keep; null uses . + public ActorEventLog(int? capacity = null) { - if (capacity < 1) + var size = capacity ?? DefaultCapacity; + if (size < 1) { - throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "The event log needs room for at least one event."); + throw new ArgumentOutOfRangeException(nameof(capacity), size, "The event log needs room for at least one event."); } - this._ring = new ActorEvent?[capacity]; + this._ring = new ActorEvent?[size]; } /// @@ -129,11 +129,11 @@ public IReadOnlyList Since(long sequence) /// When given, the kept events newer than this sequence number are delivered first, so a follower /// misses nothing between reading the history and subscribing. /// - /// The follower's buffer size; defaults to . + /// The follower's buffer size; null uses . /// The subscription; disposing it ends the stream. - public ActorEventSubscription Subscribe(long? sinceSequence = null, int capacity = DefaultSubscriptionCapacity) + public ActorEventSubscription Subscribe(long? sinceSequence = null, int? capacity = null) { - var subscription = new ActorEventSubscription(this, capacity); + var subscription = new ActorEventSubscription(this, capacity ?? DefaultSubscriptionCapacity); lock (this._syncRoot) { if (sinceSequence is { } since) diff --git a/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs index a1ebd69ba..42386512a 100644 --- a/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs +++ b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using System.ComponentModel.DataAnnotations; using System.Runtime.InteropServices; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.PlugIns; diff --git a/src/GameLogic/TestActors/ActorObjects.cs b/src/GameLogic/TestActors/ActorObjects.cs index 08a20e232..683435411 100644 --- a/src/GameLogic/TestActors/ActorObjects.cs +++ b/src/GameLogic/TestActors/ActorObjects.cs @@ -4,7 +4,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.NPC; /// @@ -14,25 +13,25 @@ namespace MUnique.OpenMU.GameLogic.TestActors; public static class ActorObjects { /// The kind of a scripted actor. - public const string ActorKind = "actor"; + public static readonly string ActorKind = "actor"; /// The kind of a server-side population bot. - public const string BotKind = "bot"; + public static readonly string BotKind = "bot"; /// The kind of a human player, connected or in an offline session. - public const string PlayerKind = "player"; + public static readonly string PlayerKind = "player"; /// The kind of a monster. - public const string MonsterKind = "monster"; + public static readonly string MonsterKind = "monster"; /// The kind of a non-attackable NPC. - public const string NpcKind = "npc"; + public static readonly string NpcKind = "npc"; /// The kind of a dropped item. - public const string ItemKind = "item"; + public static readonly string ItemKind = "item"; /// The kind of anything else. - public const string UnknownKind = "unknown"; + public static readonly string UnknownKind = "unknown"; /// /// Gets the kind of the given game object. diff --git a/src/GameLogic/TestActors/ActorProtocolHandler.cs b/src/GameLogic/TestActors/ActorProtocolHandler.cs index f9886543f..7a556df24 100644 --- a/src/GameLogic/TestActors/ActorProtocolHandler.cs +++ b/src/GameLogic/TestActors/ActorProtocolHandler.cs @@ -76,11 +76,7 @@ public async ValueTask HandleLineAsync(string line, Func writ { await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, $"The request is not valid JSON: {ex.Message}").ConfigureAwait(false); } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { await WriteFailureAsync(writer, id, ActorErrorCodes.Failed, ex.Message).ConfigureAwait(false); } diff --git a/src/GameLogic/TestActors/ActorRegistry.cs b/src/GameLogic/TestActors/ActorRegistry.cs index cee5072cd..335b04cfd 100644 --- a/src/GameLogic/TestActors/ActorRegistry.cs +++ b/src/GameLogic/TestActors/ActorRegistry.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using System.Threading; using Microsoft.Extensions.Logging; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Offline; /// @@ -66,7 +65,7 @@ public async ValueTask SpawnAsync(int serverId, string login // over the servers (BotServerPartition), so a bot animating this account may well live // on another one - and two players driving one character means two persistence contexts // saving it, which is exactly the corruption the bot code warns about. - foreach (var (serverIdOfContext, otherContext) in this._locator.GetContexts()) + foreach (var (serverIdOfContext, otherContext) in this._locator.Contexts) { var players = await otherContext.GetPlayersAsync().ConfigureAwait(false); if (players.FirstOrDefault(p => string.Equals(p.Account?.LoginName, loginName, StringComparison.OrdinalIgnoreCase)) is { } occupant) diff --git a/src/GameLogic/TestActors/ActorState.cs b/src/GameLogic/TestActors/ActorState.cs index 9690bbfde..4e7c6e11c 100644 --- a/src/GameLogic/TestActors/ActorState.cs +++ b/src/GameLogic/TestActors/ActorState.cs @@ -4,7 +4,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; /// diff --git a/src/GameLogic/TestActors/BotsController.cs b/src/GameLogic/TestActors/BotsController.cs index bbbde453f..164d9f9f8 100644 --- a/src/GameLogic/TestActors/BotsController.cs +++ b/src/GameLogic/TestActors/BotsController.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.PlugIns; @@ -26,7 +25,7 @@ public sealed class BotsController /// /// The number of animated bot characters bots on asks for when no count is given. /// - public const int DefaultBotCount = 4; + public static readonly int DefaultBotCount = 4; private readonly IGameServerContextLocator _locator; private readonly ILogger _logger; @@ -71,7 +70,7 @@ public static void Apply(BotConfiguration configuration, bool enabled, int? coun /// The result. public async ValueTask HandleAsync(string action, int? count) { - if (this._locator.GetContexts() is not { Count: > 0 } contexts) + if (this._locator.Contexts is not { Count: > 0 } contexts) { return ActorCommandResult.Failure(ActorErrorCodes.UnknownServer, "This process hosts no game server."); } @@ -115,8 +114,9 @@ public async ValueTask HandleAsync(string action, int? count { try { - // A fresh context, so the PlugInConfiguration entity is tracked and the change is saved; - // the cached in-memory configuration graph is not tracked. + // A fresh persistence context is needed here, because only then the plugin configuration + // entity is tracked and the change is saved - the cached in-memory configuration graph + // is not tracked. using var persistenceContext = context.PersistenceContextProvider.CreateNewContext(); var typeId = typeof(BotFeaturePlugIn).GUID; var gameConfiguration = (await persistenceContext.GetAsync().ConfigureAwait(false)).FirstOrDefault(); diff --git a/src/GameLogic/TestActors/IActorFactory.cs b/src/GameLogic/TestActors/IActorFactory.cs index f31d2682f..d18ab3f87 100644 --- a/src/GameLogic/TestActors/IActorFactory.cs +++ b/src/GameLogic/TestActors/IActorFactory.cs @@ -4,8 +4,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; -using MUnique.OpenMU.GameLogic; - /// /// Creates the actors the manages. /// diff --git a/src/GameLogic/TestActors/IGameServerContextLocator.cs b/src/GameLogic/TestActors/IGameServerContextLocator.cs index 799011251..21faeefd9 100644 --- a/src/GameLogic/TestActors/IGameServerContextLocator.cs +++ b/src/GameLogic/TestActors/IGameServerContextLocator.cs @@ -4,7 +4,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.Interfaces; /// @@ -12,18 +11,17 @@ namespace MUnique.OpenMU.GameLogic.TestActors; /// public interface IGameServerContextLocator { + /// + /// Gets every game server context of this process, by server id. + /// + IReadOnlyList<(int ServerId, IGameServerContext Context)> Contexts { get; } + /// /// Gets the context of the game server with the given id. /// /// The server id. /// The context, or null when this process does not host that server. IGameServerContext? GetContext(int serverId); - - /// - /// Gets every game server context of this process, by server id. - /// - /// The contexts. - IReadOnlyList<(int ServerId, IGameServerContext Context)> GetContexts(); } /// @@ -43,6 +41,14 @@ public GameServerContextLocator(IDictionary gameServers) this._gameServers = gameServers; } + /// + public IReadOnlyList<(int ServerId, IGameServerContext Context)> Contexts => + this._gameServers + .Where(pair => pair.Value is IGameServerContextProvider) + .Select(pair => (pair.Key, ((IGameServerContextProvider)pair.Value).Context)) + .OrderBy(pair => pair.Key) + .ToList(); + /// public IGameServerContext? GetContext(int serverId) { @@ -50,14 +56,4 @@ public GameServerContextLocator(IDictionary gameServers) ? provider.Context : null; } - - /// - public IReadOnlyList<(int ServerId, IGameServerContext Context)> GetContexts() - { - return this._gameServers - .Where(pair => pair.Value is IGameServerContextProvider) - .Select(pair => (pair.Key, ((IGameServerContextProvider)pair.Value).Context)) - .OrderBy(pair => pair.Key) - .ToList(); - } } diff --git a/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs b/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs index b76086094..81b382cef 100644 --- a/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs +++ b/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; diff --git a/src/GameLogic/TestActors/ScriptedIntelligence.cs b/src/GameLogic/TestActors/ScriptedIntelligence.cs index 307de04c0..811d64903 100644 --- a/src/GameLogic/TestActors/ScriptedIntelligence.cs +++ b/src/GameLogic/TestActors/ScriptedIntelligence.cs @@ -9,7 +9,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.PlayerActions; using MUnique.OpenMU.GameLogic.PlayerActions.Chat; diff --git a/src/GameLogic/TestActors/ScriptedPlayer.cs b/src/GameLogic/TestActors/ScriptedPlayer.cs index 90877b683..9315fd7cb 100644 --- a/src/GameLogic/TestActors/ScriptedPlayer.cs +++ b/src/GameLogic/TestActors/ScriptedPlayer.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.PlugIns; diff --git a/src/Network/LoginProbe/Program.cs b/src/Network/LoginProbe/Program.cs index d847fb135..cf1911735 100644 --- a/src/Network/LoginProbe/Program.cs +++ b/src/Network/LoginProbe/Program.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.Network.LoginProbe; using System; using System.Buffers; +using System.Globalization; using System.IO; using System.Net; using System.Net.Sockets; @@ -14,7 +15,6 @@ namespace MUnique.OpenMU.Network.LoginProbe; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; -using MUnique.OpenMU.Network; using MUnique.OpenMU.Network.Packets.ClientToServer; using MUnique.OpenMU.Network.Packets.ConnectServer; using MUnique.OpenMU.Network.Packets.ServerToClient; @@ -101,7 +101,7 @@ private static void Write(object payload) await ReadPacketAsync(stream).ConfigureAwait(false); var infoRequest = new byte[ConnectionInfoRequest.Length]; - var request = new ConnectionInfoRequest(infoRequest) { ServerId = (ushort)options.ServerId }; + _ = new ConnectionInfoRequest(infoRequest) { ServerId = (ushort)options.ServerId }; await stream.WriteAsync(infoRequest).ConfigureAwait(false); var response = await ReadPacketAsync(stream).ConfigureAwait(false); @@ -235,26 +235,40 @@ private sealed class ProbeOptions public static ProbeOptions? Parse(string[] args) { var options = new ProbeOptions(); - for (var i = 0; i < args.Length; i++) + var index = 0; + while (index < args.Length) { - if (i + 1 >= args.Length) + if (index + 1 >= args.Length) { return null; } - var value = args[i + 1]; - switch (args[i]) + var value = args[index + 1]; + switch (args[index]) { - case "--host": options.Host = value; break; - case "--connect-port": options.ConnectPort = int.Parse(value); break; - case "--server": options.ServerId = int.Parse(value); break; - case "--hold": options.HoldSeconds = int.Parse(value); break; - case "--account": options.Account = value; break; - case "--password": options.Password = value; break; - default: return null; + case "--host": + options.Host = value; + break; + case "--connect-port": + options.ConnectPort = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "--server": + options.ServerId = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "--hold": + options.HoldSeconds = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "--account": + options.Account = value; + break; + case "--password": + options.Password = value; + break; + default: + return null; } - i++; + index += 2; } if (string.IsNullOrEmpty(options.Account)) diff --git a/src/Network/LoginProbe/Readme.md b/src/Network/LoginProbe/Readme.md index eb78c4bc6..ce854322e 100644 --- a/src/Network/LoginProbe/Readme.md +++ b/src/Network/LoginProbe/Readme.md @@ -14,7 +14,7 @@ change. ## Usage -``` +```text MUnique.OpenMU.Network.LoginProbe --account [--password ] [--host 127.0.0.1] [--connect-port 44406] [--server 0] [--hold ] diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 7371a47a4..41d151205 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -367,7 +367,7 @@ private async Task CreateHostAsync(string[] args) /// The service collection. private void AddActorControlEndpoint(IServiceCollection services) { - if (ActorControlService.GetConfiguredPort() is not { } actorPort) + if (ActorControlService.ConfiguredPort is not { } actorPort) { return; } diff --git a/src/Startup/TestActors/ActorControlService.cs b/src/Startup/TestActors/ActorControlService.cs index 9e1f19ea6..191fa0ca2 100644 --- a/src/Startup/TestActors/ActorControlService.cs +++ b/src/Startup/TestActors/ActorControlService.cs @@ -27,7 +27,7 @@ public sealed class ActorControlService : BackgroundService /// /// The environment variable which enables the endpoint and names its port. /// - public const string EnvironmentVariableName = "OPENMU_ACTOR_PORT"; + public static readonly string EnvironmentVariableName = "OPENMU_ACTOR_PORT"; /// /// UTF-8 without a byte order mark: the first line of a connection must be plain JSON, or a @@ -63,13 +63,15 @@ public ActorControlService(ActorEndpointOptions options, IActorRegistry registry } /// - /// Reads the configured port from the environment. + /// Gets the port configured in the environment, or null when the endpoint is off (the default). /// - /// The port, or null when the endpoint is off (the default). - public static int? GetConfiguredPort() + public static int? ConfiguredPort { - var value = Environment.GetEnvironmentVariable(EnvironmentVariableName); - return int.TryParse(value, out var port) && port is > 0 and <= 65535 ? port : null; + get + { + var value = Environment.GetEnvironmentVariable(EnvironmentVariableName); + return int.TryParse(value, out var port) && port is > 0 and <= 65535 ? port : null; + } } /// diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs index ce90343e6..74f3222c2 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs @@ -7,7 +7,6 @@ namespace MUnique.OpenMU.Tests.TestActors; using System.Threading.Tasks; using MUnique.OpenMU.GameLogic.TestActors; using MUnique.OpenMU.GameLogic; -using MUnique.OpenMU.Tests; using Moq; /// diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs index f2072a814..3599e8ffd 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs @@ -141,7 +141,7 @@ private static (ActorProtocolHandler Handler, ResponseCollector Lines) CreateHan private static BotsController CreateBotsController() { var locator = new Mock(); - locator.Setup(l => l.GetContexts()).Returns([]); + locator.Setup(l => l.Contexts).Returns([]); return new BotsController(locator.Object, new NullLogger()); } diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs index 27e80d9a5..22f89ef50 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs @@ -9,7 +9,6 @@ namespace MUnique.OpenMU.Tests.TestActors; using MUnique.OpenMU.GameLogic.TestActors; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.Interfaces; -using MUnique.OpenMU.Tests; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -197,7 +196,7 @@ public static async ValueTask CreateAsync(IEnumerable? connecte var locator = new Mock(); locator.Setup(l => l.GetContext(0)).Returns(serverContext.Object); locator.Setup(l => l.GetContext(It.Is(id => id != 0))).Returns((IGameServerContext?)null); - locator.Setup(l => l.GetContexts()).Returns([(0, serverContext.Object), (1, otherContext.Object)]); + locator.Setup(l => l.Contexts).Returns([(0, serverContext.Object), (1, otherContext.Object)]); var factory = new CountingActorFactory(gameContext); var registry = new ActorRegistry(locator.Object, factory, new NullLogger()); diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs index a4adb9f18..553578d38 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs @@ -8,7 +8,6 @@ namespace MUnique.OpenMU.Tests.TestActors; using MUnique.OpenMU.GameLogic.TestActors; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.Pathfinding; -using MUnique.OpenMU.Tests; /// /// Builds scripted actors on top of the in-memory game context of the upstream test helpers. diff --git a/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs index ed0f3b213..1ae53c70e 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs @@ -75,7 +75,7 @@ public void SwitchingOffKeepsThePopulationSettings() public async Task UnknownActionIsRefusedAsync() { var locator = new Mock(); - locator.Setup(l => l.GetContexts()).Returns([(0, new Mock().Object)]); + locator.Setup(l => l.Contexts).Returns([(0, new Mock().Object)]); var controller = new BotsController(locator.Object, new NullLogger()); var result = await controller.HandleAsync("nonsense", null).ConfigureAwait(false); @@ -93,7 +93,7 @@ public async Task UnknownActionIsRefusedAsync() public async Task NoGameServerIsReportedAsync() { var locator = new Mock(); - locator.Setup(l => l.GetContexts()).Returns([]); + locator.Setup(l => l.Contexts).Returns([]); var controller = new BotsController(locator.Object, new NullLogger()); var result = await controller.HandleAsync("status", null).ConfigureAwait(false); From 166ca3edec0df7daa4415d5f253f394693b62ec5 Mon Sep 17 00:00:00 2001 From: alandarev Date: Wed, 16 Sep 2026 13:30:52 +0300 Subject: [PATCH 4/4] Address the review of the test actors Changes requested in the review of the pull request, in its order: - The control endpoint binds the loopback address by default. The variable OPENMU_ACTOR_PORT still takes a bare port, which now means 127.0.0.1:port; an operator who wants another address names it explicitly as host:port (0.0.0.0:55990 inside a container, whose port publishing then contains it). A value which is neither is logged and ignored, so the endpoint stays off. The class remark and the docs page now describe what the code does. - ActorHitRecorderPlugIn is no longer a discoverable [PlugIn]: it gets no persisted configuration, is not listed in the admin panel and does not run on servers which never enable the actors. The control endpoint registers it at the IAttackableGotHitPlugIn point when it starts. - IActorRegistry.List/Find became ListAsync/FindAsync and await the registry's semaphore, which is held across the whole spawn; no caller blocks a thread pool thread on it any more. - A walk verifies its progress: after each chunk the actor's position is compared with the chunk's last node, and a refused or truncated walk answers no_path with the position and steps actually reached instead of ok. The final success also asserts that the actor stands on the target. - bots on/off writes only Enabled, plus NumberOfAccounts when --count is given; MaxCharactersPerAccount and PresenceRotation are left as the operator configured them, and bots status reports all three. - Numeric request fields are range-checked (byte, ushort, int, and a minimum of 1 for attack --times); a value outside its range answers bad_request instead of being wrapped by a cast. - The SIGTERM handler is gone from ActorControlService; the actors are stopped from StopAsync only, like every other hosted service. Making the host stop its hosted services on SIGTERM is a separate change. - The login probe reads the password from LOGINPROBE_PASSWORD instead of a --password argument, so it is neither visible in the process list nor kept in a shell history. Tests: the walk tests cover a refused and a truncated walk, the protocol tests cover out-of-range numbers for every numeric field, and the bots tests assert that the untouched settings survive. 53 tests pass. --- .../docs/server-features/test-actors.md | 40 +++++-- src/GameLogic/TestActors/ActorErrorCodes.cs | 5 +- .../TestActors/ActorHitRecorderPlugIn.cs | 8 +- .../TestActors/ActorProtocolHandler.cs | 71 +++++++++--- src/GameLogic/TestActors/ActorRegistry.cs | 12 ++- src/GameLogic/TestActors/BotsController.cs | 32 +++--- src/GameLogic/TestActors/IActorRegistry.cs | 4 +- .../TestActors/ScriptedIntelligence.cs | 55 +++++++++- src/Network/LoginProbe/Program.cs | 22 ++-- src/Network/LoginProbe/Readme.md | 10 +- src/Startup/Program.cs | 19 ++-- src/Startup/TestActors/ActorControlService.cs | 102 +++++++----------- .../TestActors/ActorEndpointOptions.cs | 34 +++++- .../TestActors/ActorProtocolHandlerTests.cs | 38 ++++++- .../TestActors/ActorRegistryTests.cs | 10 +- .../TestActors/BotsControllerTests.cs | 21 ++-- .../TestActors/ScriptedIntelligenceTests.cs | 48 +++++++++ 17 files changed, 367 insertions(+), 164 deletions(-) diff --git a/docs-website/docs/server-features/test-actors.md b/docs-website/docs/server-features/test-actors.md index 1503652b3..e4d5537fb 100644 --- a/docs-website/docs/server-features/test-actors.md +++ b/docs-website/docs/server-features/test-actors.md @@ -40,13 +40,21 @@ made. **Every command answers.** A command either succeeds with a result or fails with a code (`out_of_range`, `safezone`, `not_in_view`, `unknown_skill`, `no_path`, `interrupted`, …). Nothing is silently ignored — a test which -"passes" because nothing happened is worse than no test. +"passes" because nothing happened is worse than no test. A `walk` only +succeeds when the actor stands on the requested tile; when the engine refuses +or cuts the path short (the path finder and the movement check do not agree on +every tile), it answers `no_path` with the position actually reached. A +numeric field outside its range (`"x":300`) is refused as `bad_request` +instead of being wrapped into a plausible-looking value. **Events carry the attribution the client protocol does not.** The view a client receives tells it that it was hit, but not by whom, so hits are recorded from the `IAttackableGotHitPlugIn` plugin point instead, which knows both -sides. Each hit produces exactly one event on the attacker's stream and one on -the victim's, naming the other side and its kind (player, bot, monster). Kills, +sides. The recorder is not a regular, discoverable plugin: it is registered at +that plugin point by the control endpoint when it starts, so a server which +never enables the actors neither runs it nor lists it. Each hit produces +exactly one event on the attacker's stream and one on the victim's, naming the +other side and its kind (player, bot, monster). Kills, stat changes (health, shield, mana, ability), chat, drops, map changes and objects entering or leaving view come from the actor's own recording view container. Every event has a strictly increasing sequence number and a UTC @@ -56,9 +64,14 @@ follow the stream live. ## The control endpoint The server opens a TCP port when the environment variable `OPENMU_ACTOR_PORT` -is set to a port number, and does not open it otherwise. The protocol is -newline-delimited JSON: one request object per line, one response object per -line, with a streaming mode for following events. +is set to a port number, and does not open it otherwise. It binds the loopback +address (`127.0.0.1`), so the endpoint is reachable from the same machine +only, unless `OPENMU_ACTOR_ADDRESS` explicitly names another address +(`0.0.0.0`, `::1`) — inside a container, for instance, whose port publishing +then decides who can reach it. An invalid port or address is logged and +ignored, and the endpoint stays off. The protocol is newline-delimited JSON: +one request object per line, one response object per line, with a streaming +mode for following events. ```json {"id":"1","cmd":"spawn","actor":"test1"} @@ -68,8 +81,8 @@ line, with a streaming mode for following events. The endpoint is registered by the all-in-one `MUnique.OpenMU.Startup` host only; the distributed (Dapr) game server host does not open it. -The endpoint has **no authentication**. Bind it to a loopback address, never -expose it, and leave the variable unset on any server which is not yours to +The endpoint has **no authentication**. Keep it on a loopback address, never +publish it, and leave the variable unset on any server which is not yours to test on. An account which is already animated — by another actor, by a population bot on any game server of the process, or by a connected client — is refused, so an actor can never drive a character someone else is driving. @@ -80,7 +93,7 @@ refused, so an actor can never drive a character someone else is driving. |---|---| | `spawn` / `stop` | animate an account's character; log it out again (saving its progress) | | `list` / `state` / `nearby` | which actors exist; one actor's full state; what it can see | -| `walk x y` | walk to a position of the current map, one path-finder request over the whole map | +| `walk x y` | walk to a position of the current map, one path-finder request over the whole map; `no_path` with the reached position when the way turns out to be blocked | | `attack [--times N] [--interval ms]` | plain attacks against an object in view | | `skill ` | cast a learned skill | | `say`, `pickup`, `warp` | chat (including chat commands), pick up a drop, use a warp list entry | @@ -92,6 +105,11 @@ refused, so an actor can never drive a character someone else is driving. The [bots](bots.md) feature pairs naturally with actors: bots are legal PvP targets which fight back under the game's own self-defence rules, so an actor has something to fight without a second script. The endpoint can switch them -on and off (`bots on --count N`, `bots off`, `bots status`) through the same +on and off (`bots on [--count N]`, `bots off`, `bots status`) through the same persisted plugin configuration the admin panel edits, and reports where the -animated bots currently are. +animated bots currently are. The switch changes nothing it was not asked for: +`bots on` sets `Enabled`, `--count N` additionally sets the number of bot +accounts, and both survive a restart like any other edit of that +configuration. The characters per account and the presence rotation stay as +the operator configured them; `bots status` reports all three settings, so a +scenario knows what population to expect. diff --git a/src/GameLogic/TestActors/ActorErrorCodes.cs b/src/GameLogic/TestActors/ActorErrorCodes.cs index da212586b..8ef75fdac 100644 --- a/src/GameLogic/TestActors/ActorErrorCodes.cs +++ b/src/GameLogic/TestActors/ActorErrorCodes.cs @@ -25,7 +25,10 @@ public static class ActorErrorCodes /// The character cannot pay the skill's mana or ability cost. public static readonly string InsufficientResources = "insufficient_resources"; - /// The path finder found no way to the requested position. + /// + /// The path finder found no way to the requested position, or the walk was stopped by a blocked + /// tile before reaching it (the result then carries the position actually reached). + /// public static readonly string NoPath = "no_path"; /// The target cannot be attacked (dead, not attackable, or the actor itself). diff --git a/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs index 42386512a..012946c99 100644 --- a/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs +++ b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs @@ -7,7 +7,6 @@ namespace MUnique.OpenMU.GameLogic.TestActors; using System.ComponentModel.DataAnnotations; using System.Runtime.InteropServices; using MUnique.OpenMU.GameLogic.PlugIns; -using MUnique.OpenMU.PlugIns; /// /// Records every hit an actor deals or receives, with the attribution the client views do not carry. @@ -18,8 +17,13 @@ namespace MUnique.OpenMU.GameLogic.TestActors; /// victim are both known. It is therefore the single source of hit events: neither the /// recording view container nor a command result appends one, so a hit is recorded exactly once per /// involved actor (once on each side when two actors fight each other). +/// +/// Deliberately not a [PlugIn]: it is not discovered with the regular plugins, gets no +/// persisted configuration and does not appear in the admin panel of a server which never enables +/// the actors. The control endpoint registers it at the plugin point when it starts, so the recorder +/// exists exactly when actors can. +/// /// -[PlugIn] [Display(Name = "Test actor hit recorder", Description = "Records hits dealt and received by scripted test actors in their event stream.")] [Guid("2A7C4B18-6E5D-4C93-9F21-8D0B6A3E57C4")] public class ActorHitRecorderPlugIn : IAttackableGotHitPlugIn diff --git a/src/GameLogic/TestActors/ActorProtocolHandler.cs b/src/GameLogic/TestActors/ActorProtocolHandler.cs index 7a556df24..35ba9a6c3 100644 --- a/src/GameLogic/TestActors/ActorProtocolHandler.cs +++ b/src/GameLogic/TestActors/ActorProtocolHandler.cs @@ -76,6 +76,10 @@ public async ValueTask HandleLineAsync(string line, Func writ { await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, $"The request is not valid JSON: {ex.Message}").ConfigureAwait(false); } + catch (ArgumentException ex) + { + await WriteFailureAsync(writer, id, ActorErrorCodes.BadRequest, ex.Message).ConfigureAwait(false); + } catch (Exception ex) when (ex is not OperationCanceledException) { await WriteFailureAsync(writer, id, ActorErrorCodes.Failed, ex.Message).ConfigureAwait(false); @@ -85,8 +89,42 @@ public async ValueTask HandleLineAsync(string line, Func writ private static string? GetString(JsonElement request, string name) => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; - private static long? GetNumber(JsonElement request, string name) - => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt64() : null; + /// + /// Reads an optional integer field and checks that it lies within the allowed range. + /// + /// + /// A value outside the range is an , which the caller answers with + /// - never a wrapped cast: a typo like "x":300 + /// must be refused instead of walking to x=44. + /// + private static long? GetNumber(JsonElement request, string name, long min, long max) + { + if (!request.TryGetProperty(name, out var value)) + { + return null; + } + + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var number)) + { + throw new ArgumentException($"'{name}' must be an integer."); + } + + if (number < min || number > max) + { + throw new ArgumentException($"'{name}' must be between {min} and {max}, but is {number}."); + } + + return number; + } + + private static int? GetInt32(JsonElement request, string name, int min = 0, int max = int.MaxValue) + => (int?)GetNumber(request, name, min, max); + + private static byte? GetByte(JsonElement request, string name) + => (byte?)GetNumber(request, name, byte.MinValue, byte.MaxValue); + + private static ushort? GetUInt16(JsonElement request, string name) + => (ushort?)GetNumber(request, name, ushort.MinValue, ushort.MaxValue); private static bool GetBool(JsonElement request, string name) => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; @@ -98,7 +136,7 @@ private static bool GetBool(JsonElement request, string name) return value.ValueKind switch { JsonValueKind.String => value.GetString(), - JsonValueKind.Number => value.GetInt64().ToString(), + JsonValueKind.Number => value.TryGetInt64(out var number) ? number.ToString() : null, _ => null, }; } @@ -119,14 +157,14 @@ private async ValueTask DispatchAsync(string command, JsonElement request, strin case "ping": await WriteResultAsync(writer, id, ActorCommandResult.Success( new ActorEventField("version", Version), - new ActorEventField("actors", this._registry.List().Count))).ConfigureAwait(false); + new ActorEventField("actors", (await this._registry.ListAsync().ConfigureAwait(false)).Count))).ConfigureAwait(false); return; case "spawn": var spawned = await this._registry.SpawnAsync( - (int)(GetNumber(request, "server") ?? 0), + GetInt32(request, "server") ?? 0, GetString(request, "actor") ?? string.Empty, - GetNumber(request, "slot") is { } slot ? (byte)slot : null).ConfigureAwait(false); + GetByte(request, "slot")).ConfigureAwait(false); await WriteResultAsync(writer, id, spawned).ConfigureAwait(false); return; @@ -135,16 +173,17 @@ await WriteResultAsync(writer, id, ActorCommandResult.Success( return; case "list": + var actors = await this._registry.ListAsync().ConfigureAwait(false); await WriteResultAsync(writer, id, ActorCommandResult.Success(new ActorEventField( "actors", - this._registry.List().Select(a => ActorState.Summary(a.AccountLoginName ?? string.Empty, a)).ToList()))).ConfigureAwait(false); + actors.Select(a => ActorState.Summary(a.AccountLoginName ?? string.Empty, a)).ToList()))).ConfigureAwait(false); return; case "bots": await WriteResultAsync( writer, id, - await this._bots.HandleAsync(GetString(request, "action") ?? "status", (int?)GetNumber(request, "count")).ConfigureAwait(false)).ConfigureAwait(false); + await this._bots.HandleAsync(GetString(request, "action") ?? "status", GetInt32(request, "count")).ConfigureAwait(false)).ConfigureAwait(false); return; default: @@ -156,7 +195,7 @@ await WriteResultAsync( private async ValueTask DispatchActorCommandAsync(string command, JsonElement request, string? id, Func writer, CancellationToken cancellationToken) { var loginName = GetString(request, "actor") ?? string.Empty; - if (this._registry.Find(loginName) is not { } actor) + if (await this._registry.FindAsync(loginName).ConfigureAwait(false) is not { } actor) { await WriteFailureAsync(writer, id, ActorErrorCodes.UnknownActor, $"No actor animates '{loginName}'.").ConfigureAwait(false); return; @@ -194,15 +233,15 @@ private async ValueTask ExecuteActorCommandAsync(string command, JsonElement req { ActorCommand? actorCommand = command switch { - "walk" => new WalkCommand((byte)(GetNumber(request, "x") ?? 0), (byte)(GetNumber(request, "y") ?? 0)), + "walk" => new WalkCommand(GetByte(request, "x") ?? 0, GetByte(request, "y") ?? 0), "attack" => new AttackCommand( GetTarget(request) ?? string.Empty, - (int)(GetNumber(request, "times") ?? 1), - (int)(GetNumber(request, "interval") ?? 0)), - "skill" => new SkillCommand((ushort)(GetNumber(request, "skill") ?? 0), GetTarget(request) ?? string.Empty), + GetInt32(request, "times", min: 1) ?? 1, + GetInt32(request, "interval") ?? 0), + "skill" => new SkillCommand(GetUInt16(request, "skill") ?? 0, GetTarget(request) ?? string.Empty), "say" => new SayCommand(GetString(request, "text") ?? string.Empty), - "pickup" => new PickupCommand((ushort)(GetNumber(request, "id") ?? 0)), - "warp" => new WarpCommand((int)(GetNumber(request, "gate") ?? -1)), + "pickup" => new PickupCommand(GetUInt16(request, "id") ?? 0), + "warp" => new WarpCommand(GetInt32(request, "gate") ?? -1), _ => null, }; @@ -223,7 +262,7 @@ private async ValueTask ExecuteActorCommandAsync(string command, JsonElement req private async ValueTask StreamEventsAsync(ScriptedPlayer actor, JsonElement request, string? id, Func writer, CancellationToken cancellationToken) { - var since = GetNumber(request, "since") ?? 0; + var since = GetNumber(request, "since", 0, long.MaxValue) ?? 0; if (!GetBool(request, "follow")) { await WriteResultAsync(writer, id, ActorCommandResult.Success( diff --git a/src/GameLogic/TestActors/ActorRegistry.cs b/src/GameLogic/TestActors/ActorRegistry.cs index 335b04cfd..74ac687e2 100644 --- a/src/GameLogic/TestActors/ActorRegistry.cs +++ b/src/GameLogic/TestActors/ActorRegistry.cs @@ -16,7 +16,9 @@ namespace MUnique.OpenMU.GameLogic.TestActors; /// , which is the cross-server /// lock - and additionally scans the target game server's players for the same login name, because /// population bots and offline sessions bypass the login server. Both checks and the insertion run -/// under one lock, so two concurrent spawns of one account yield exactly one actor. +/// under one lock, so two concurrent spawns of one account yield exactly one actor. The lock is +/// therefore held across awaits, which is why even the lookups are asynchronous: a list or +/// a command arriving during a spawn waits for it without blocking a thread. /// public sealed class ActorRegistry : IActorRegistry { @@ -150,9 +152,9 @@ public async ValueTask StopAllAsync() } /// - public IReadOnlyList List() + public async ValueTask> ListAsync() { - this._lock.Wait(); + await this._lock.WaitAsync().ConfigureAwait(false); try { return this._actors.Values.ToList(); @@ -164,9 +166,9 @@ public IReadOnlyList List() } /// - public ScriptedPlayer? Find(string loginName) + public async ValueTask FindAsync(string loginName) { - this._lock.Wait(); + await this._lock.WaitAsync().ConfigureAwait(false); try { return this._actors.GetValueOrDefault(loginName); diff --git a/src/GameLogic/TestActors/BotsController.cs b/src/GameLogic/TestActors/BotsController.cs index 164d9f9f8..e46ad9c12 100644 --- a/src/GameLogic/TestActors/BotsController.cs +++ b/src/GameLogic/TestActors/BotsController.cs @@ -18,15 +18,13 @@ namespace MUnique.OpenMU.GameLogic.TestActors; /// The switch goes through the persisted of the bot feature - the /// same route the admin panel's plugin dialog takes - so it survives a restart and is visible in the /// panel. listens to the configuration's PropertyChanged and -/// pushes it to the live plugin. +/// pushes it to the live plugin. Because the write is persistent, it is kept to what was asked for: +/// , plus +/// when a count is given. The characters per account and the presence rotation stay whatever the +/// operator configured; status reports them, so a scenario knows what population to expect. /// public sealed class BotsController { - /// - /// The number of animated bot characters bots on asks for when no count is given. - /// - public static readonly int DefaultBotCount = 4; - private readonly IGameServerContextLocator _locator; private readonly ILogger _logger; @@ -42,23 +40,17 @@ public BotsController(IGameServerContextLocator locator, ILogger } /// - /// Applies the requested bot configuration to the given one, so that is - /// the number of animated bot characters in total - not accounts times characters. + /// Applies the requested switch to the given configuration, touching nothing which was not asked for. /// /// The configuration to change. /// Whether the feature should be on. - /// The number of bot characters; ignored when switching off. + /// The number of bot accounts to set, or null to keep the configured one. public static void Apply(BotConfiguration configuration, bool enabled, int? count) { configuration.Enabled = enabled; - if (enabled) + if (count is { } accounts) { - configuration.NumberOfAccounts = Math.Max(0, count ?? DefaultBotCount); - - // One character per account, so the requested number is what stands in the world, and no - // bot logs out again while a scenario runs. - configuration.MaxCharactersPerAccount = 1; - configuration.PresenceRotation = false; + configuration.NumberOfAccounts = Math.Max(0, accounts); } } @@ -66,7 +58,7 @@ public static void Apply(BotConfiguration configuration, bool enabled, int? coun /// Handles a bots request. /// /// One of on, off and status. - /// The requested number of bot characters, for on. + /// The number of bot accounts to set, if any. /// The result. public async ValueTask HandleAsync(string action, int? count) { @@ -95,9 +87,10 @@ public async ValueTask HandleAsync(string action, int? count } this._logger.LogInformation( - "Bots switched {State} ({Count} account(s), one character each).", + "Bots switched {State} ({Accounts} account(s), up to {Characters} character(s) each).", enabled ? "on" : "off", - configuration.NumberOfAccounts); + configuration.NumberOfAccounts, + configuration.MaxCharactersPerAccount); return await this.StatusAsync(contexts).ConfigureAwait(false); case "status": @@ -177,6 +170,7 @@ private async ValueTask StatusAsync(IReadOnlyList<(int Serve new ActorEventField("enabled", configuration?.Enabled ?? false), new ActorEventField("accounts", configuration?.NumberOfAccounts ?? 0), new ActorEventField("characters_per_account", configuration?.MaxCharactersPerAccount ?? 0), + new ActorEventField("presence_rotation", configuration?.PresenceRotation ?? false), new ActorEventField("servers", perServer), new ActorEventField("animated", total)); } diff --git a/src/GameLogic/TestActors/IActorRegistry.cs b/src/GameLogic/TestActors/IActorRegistry.cs index 6d24266de..ea805833b 100644 --- a/src/GameLogic/TestActors/IActorRegistry.cs +++ b/src/GameLogic/TestActors/IActorRegistry.cs @@ -35,12 +35,12 @@ public interface IActorRegistry /// Gets the currently animated actors. /// /// The actors. - IReadOnlyList List(); + ValueTask> ListAsync(); /// /// Gets the actor which animates the given account, if any. /// /// The login name. /// The actor, or null. - ScriptedPlayer? Find(string loginName); + ValueTask FindAsync(string loginName); } diff --git a/src/GameLogic/TestActors/ScriptedIntelligence.cs b/src/GameLogic/TestActors/ScriptedIntelligence.cs index 811d64903..46e90eb75 100644 --- a/src/GameLogic/TestActors/ScriptedIntelligence.cs +++ b/src/GameLogic/TestActors/ScriptedIntelligence.cs @@ -138,6 +138,24 @@ private static byte GetEffectiveAttackRange(Player player) return MeleeAttackRange; } + /// + /// Counts how many steps of a chunk were taken, judged by where the actor stands afterwards: + /// the whole chunk when it reached the last node, otherwise the steps up to the node it stands on + /// (zero when the engine refused the chunk and the actor did not move). + /// + private static int StepsWalked(IList chunk, Point position) + { + for (var i = chunk.Count - 1; i >= 0; i--) + { + if (chunk[i].Point == position) + { + return i + 1; + } + } + + return 0; + } + private bool CancelCurrent() { CancellationTokenSource? source; @@ -285,14 +303,45 @@ await this._player.RunPersistenceExclusiveAsync( } } - walked += chunk.Count; + // The engine may refuse a chunk (first step blocked) or truncate it (a later step + // blocked) without saying so, because the path finder works on the AI grid while the + // movement check uses the walk map, and the two disagree on safe zones and temporarily + // blocked tiles. Either case ends the walk here: the position is the only reliable + // report of what actually happened, and the next chunk would not start where it assumes. + var reached = this._player.Position; + var chunkEnd = chunk[^1].Point; + walked += StepsWalked(chunk, reached); + if (reached != chunkEnd) + { + return ActorCommandResult.Failure( + ActorErrorCodes.NoPath, + $"The walk stopped at {reached}, {walked} of {path.Count} steps in; the tile after it is blocked.", + new ActorEventField("steps", path.Count), + new ActorEventField("walked", walked), + new ActorEventField("x", reached.X), + new ActorEventField("y", reached.Y)); + } + } + + var end = this._player.Position; + if (end != target) + { + // Should not happen after the per-chunk check, but a walk which claims success must have + // arrived; anything else is reported as the failure it is. + return ActorCommandResult.Failure( + ActorErrorCodes.NoPath, + $"The walk ended at {end} instead of {target}.", + new ActorEventField("steps", path.Count), + new ActorEventField("walked", walked), + new ActorEventField("x", end.X), + new ActorEventField("y", end.Y)); } return ActorCommandResult.Success( new ActorEventField("steps", path.Count), new ActorEventField("walked", walked), - new ActorEventField("x", this._player.Position.X), - new ActorEventField("y", this._player.Position.Y)); + new ActorEventField("x", end.X), + new ActorEventField("y", end.Y)); } private async ValueTask InterruptWalkAsync(int planned, int walked) diff --git a/src/Network/LoginProbe/Program.cs b/src/Network/LoginProbe/Program.cs index cf1911735..244f32e01 100644 --- a/src/Network/LoginProbe/Program.cs +++ b/src/Network/LoginProbe/Program.cs @@ -34,6 +34,13 @@ namespace MUnique.OpenMU.Network.LoginProbe; /// public static class Program { + /// + /// The environment variable which carries the password, so it neither shows up in the process + /// list nor lands in a shell history. Unset, the password is the account name, which is what + /// the shipped test accounts use. + /// + public static readonly string PasswordVariableName = "LOGINPROBE_PASSWORD"; + /// The client version a Season 6 Episode 3 client reports ("20404" in ASCII). private static readonly byte[] ClientVersion = [0x32, 0x30, 0x34, 0x30, 0x34]; @@ -69,9 +76,11 @@ public static async Task Main(string[] args) private static void Usage() { - Console.Error.WriteLine("usage: MUnique.OpenMU.Network.LoginProbe --account [--password ] [--host 127.0.0.1]"); + Console.Error.WriteLine("usage: MUnique.OpenMU.Network.LoginProbe --account [--host 127.0.0.1]"); Console.Error.WriteLine(" [--connect-port 44406] [--server 0] [--hold ]"); Console.Error.WriteLine(); + Console.Error.WriteLine($"The password is read from the environment variable {PasswordVariableName};"); + Console.Error.WriteLine("when it is unset, the account name is used as the password."); Console.Error.WriteLine("Logs into the game server like a real client and holds the session."); Console.Error.WriteLine("Writes one JSON object per step to stdout; exit 0 on a successful login."); } @@ -261,9 +270,6 @@ private sealed class ProbeOptions case "--account": options.Account = value; break; - case "--password": - options.Password = value; - break; default: return null; } @@ -276,11 +282,9 @@ private sealed class ProbeOptions return null; } - if (string.IsNullOrEmpty(options.Password)) - { - // The test accounts of the local stack use the login name as the password. - options.Password = options.Account; - } + options.Password = Environment.GetEnvironmentVariable(PasswordVariableName) is { Length: > 0 } password + ? password + : options.Account; return options; } diff --git a/src/Network/LoginProbe/Readme.md b/src/Network/LoginProbe/Readme.md index ce854322e..bf5aa8f0f 100644 --- a/src/Network/LoginProbe/Readme.md +++ b/src/Network/LoginProbe/Readme.md @@ -15,12 +15,14 @@ change. ## Usage ```text -MUnique.OpenMU.Network.LoginProbe --account [--password ] - [--host 127.0.0.1] [--connect-port 44406] - [--server 0] [--hold ] +MUnique.OpenMU.Network.LoginProbe --account [--host 127.0.0.1] + [--connect-port 44406] [--server 0] + [--hold ] ``` -The password defaults to the account name, which is what the shipped test +The password is taken from the environment variable `LOGINPROBE_PASSWORD`, so +it neither shows up in the process list nor in a shell history; when the +variable is unset, the account name is used, which is what the shipped test accounts use. One JSON object per step is written to standard output: ```json diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 41d151205..788b0efee 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -360,21 +360,28 @@ private async Task CreateHostAsync(string[] args) /// /// Adds the test actor control endpoint, but only when OPENMU_ACTOR_PORT names a - /// port: it is unauthenticated development tooling and stays off by default. It is registered - /// after the server containers on purpose - hosted services are stopped in reverse order, so the - /// actors are logged out (and their progress saved) before the game servers go down. + /// port; it binds loopback unless OPENMU_ACTOR_ADDRESS says otherwise. It is + /// unauthenticated development tooling and stays off by default, and it is registered after + /// the server containers on purpose - + /// hosted services are stopped in reverse order, so the actors are logged out (and their + /// progress saved) before the game servers go down. /// /// The service collection. private void AddActorControlEndpoint(IServiceCollection services) { - if (ActorControlService.ConfiguredPort is not { } actorPort) + if (ActorControlService.ConfiguredOptions is not { } actorOptions) { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ActorControlService.PortVariableName))) + { + this._logger.Warning("{portVariable} is set, but it or {addressVariable} is not valid; the actor control endpoint stays off.", ActorControlService.PortVariableName, ActorControlService.AddressVariableName); + } + return; } - this._logger.Information("Actor control endpoint enabled on port {port}", actorPort); + this._logger.Information("Actor control endpoint enabled on {endPoint}", actorOptions.EndPoint); services - .AddSingleton(new ActorEndpointOptions(actorPort)) + .AddSingleton(actorOptions) .AddSingleton(_ => new GameServerContextLocator(this._gameServers)) .AddSingleton() .AddSingleton() diff --git a/src/Startup/TestActors/ActorControlService.cs b/src/Startup/TestActors/ActorControlService.cs index 191fa0ca2..393a9aa4c 100644 --- a/src/Startup/TestActors/ActorControlService.cs +++ b/src/Startup/TestActors/ActorControlService.cs @@ -7,71 +7,83 @@ namespace MUnique.OpenMU.Startup.TestActors; using System.IO; using System.Net; using System.Net.Sockets; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.TestActors; +using MUnique.OpenMU.PlugIns; /// /// The local control endpoint: a plain TCP listener which speaks newline-delimited JSON. /// /// /// Development tooling, without any authentication: it is only started when -/// names a port, the local stack publishes that port on the -/// host's loopback address only, and the release deployment never sets the variable. +/// names a port, and it binds the loopback address unless +/// explicitly names another one (see +/// ). So setting the port on a host which is run +/// directly opens the endpoint for local processes only; a container has to ask for +/// 0.0.0.0 and rely on its port publishing. /// public sealed class ActorControlService : BackgroundService { /// - /// The environment variable which enables the endpoint and names its port. + /// The environment variable which enables the endpoint by naming its port. /// - public static readonly string EnvironmentVariableName = "OPENMU_ACTOR_PORT"; + public static readonly string PortVariableName = "OPENMU_ACTOR_PORT"; /// - /// UTF-8 without a byte order mark: the first line of a connection must be plain JSON, or a - /// strict reader (stdlib Python, jq) chokes on the BOM. + /// The environment variable which names the address to bind; unset, the loopback address. /// - private static readonly Encoding LineEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + public static readonly string AddressVariableName = "OPENMU_ACTOR_ADDRESS"; /// - /// How long the SIGTERM handler waits for the actors to log out before it lets the process die. - /// Docker's default grace period is ten seconds. + /// UTF-8 without a byte order mark: the first line of a connection must be plain JSON, or a + /// strict reader (stdlib Python, jq) chokes on the BOM. /// - private static readonly TimeSpan SignalStopTimeout = TimeSpan.FromSeconds(8); + private static readonly Encoding LineEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - private readonly int _port; + private readonly IPEndPoint _endPoint; private readonly IActorRegistry _registry; private readonly ActorProtocolHandler _handler; + private readonly PlugInManager _plugInManager; private readonly ILogger _logger; - private PosixSignalRegistration? _sigTermRegistration; /// /// Initializes a new instance of the class. /// - /// The endpoint options, i.e. the port. + /// The endpoint options, i.e. the address and port. /// The actor registry, so shutdown can stop every actor. /// The protocol handler. + /// The plugin manager of the game servers, to register the hit recorder. /// The logger. - public ActorControlService(ActorEndpointOptions options, IActorRegistry registry, ActorProtocolHandler handler, ILogger logger) + public ActorControlService(ActorEndpointOptions options, IActorRegistry registry, ActorProtocolHandler handler, PlugInManager plugInManager, ILogger logger) { - this._port = options.Port; + this._endPoint = options.EndPoint; this._registry = registry; this._handler = handler; + this._plugInManager = plugInManager; this._logger = logger; } /// - /// Gets the port configured in the environment, or null when the endpoint is off (the default). + /// Gets the endpoint configured in the environment, or null when the endpoint is off (the default). /// - public static int? ConfiguredPort + public static ActorEndpointOptions? ConfiguredOptions + => ActorEndpointOptions.TryParse( + Environment.GetEnvironmentVariable(PortVariableName), + Environment.GetEnvironmentVariable(AddressVariableName)); + + /// + /// Registers the hit recorder, which is not a discoverable plugin on purpose, and starts listening. + /// + /// The cancellation token. + /// The task. + public override Task StartAsync(CancellationToken cancellationToken) { - get - { - var value = Environment.GetEnvironmentVariable(EnvironmentVariableName); - return int.TryParse(value, out var port) && port is > 0 and <= 65535 ? port : null; - } + this._plugInManager.RegisterPlugIn(); + return base.StartAsync(cancellationToken); } /// @@ -90,28 +102,14 @@ public override async Task StopAsync(CancellationToken cancellationToken) await base.StopAsync(cancellationToken).ConfigureAwait(false); } - /// - public override void Dispose() - { - this._sigTermRegistration?.Dispose(); - base.Dispose(); - } - /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // `docker stop` sends SIGTERM, but this host's main loop only stops the services from an - // AppDomain.ProcessExit handler, which the runtime cuts short - so the hosted services' - // StopAsync usually never runs in a container, and an actor's unsaved progress (anything - // since the last periodic save) would be lost on every `dev down`. Logging the actors out - // straight from the signal handler makes the shutdown promise hold regardless. - this._sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => this.StopActorsOnSignal()); - - var listener = new TcpListener(IPAddress.Any, this._port); + var listener = new TcpListener(this._endPoint); try { listener.Start(); - this._logger.LogInformation("Actor control endpoint listening on 0.0.0.0:{Port}.", this._port); + this._logger.LogInformation("Actor control endpoint listening on {EndPoint}.", listener.LocalEndpoint); while (!stoppingToken.IsCancellationRequested) { @@ -133,32 +131,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - private void StopActorsOnSignal() - { - try - { - this._logger.LogInformation("SIGTERM received: logging the actors out before the game servers stop."); - - // Blocking is the point: the handler runs on the signal thread and the process is about - // to die, so the logout has to finish here rather than on some continuation which will - // never be scheduled. Bounded, so a stuck actor cannot hold the shutdown open. -#pragma warning disable VSTHRD002 // Synchronously waiting on tasks or awaiters may cause deadlocks - var stopTask = Task.Run(() => this._registry.StopAllAsync().AsTask()); - if (!stopTask.Wait(SignalStopTimeout)) - { - this._logger.LogWarning("The actors did not stop within {Timeout}; their last progress may be lost.", SignalStopTimeout); - return; - } - - this._logger.LogInformation("Stopped {Count} actor(s) on SIGTERM.", stopTask.Result); -#pragma warning restore VSTHRD002 - } - catch (Exception ex) - { - this._logger.LogError(ex, "Failed to stop the actors on SIGTERM."); - } - } - private async Task HandleClientAsync(TcpClient client, CancellationToken stoppingToken) { using var connectionSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); diff --git a/src/Startup/TestActors/ActorEndpointOptions.cs b/src/Startup/TestActors/ActorEndpointOptions.cs index d658b7667..aa773b0c7 100644 --- a/src/Startup/TestActors/ActorEndpointOptions.cs +++ b/src/Startup/TestActors/ActorEndpointOptions.cs @@ -4,10 +4,38 @@ namespace MUnique.OpenMU.Startup.TestActors; -using MUnique.OpenMU.GameLogic.TestActors; +using System.Net; /// /// The options of the actor control endpoint. /// -/// The TCP port to listen on inside the container. -public sealed record ActorEndpointOptions(int Port); +/// The address and port to listen on. +public sealed record ActorEndpointOptions(IPEndPoint EndPoint) +{ + /// + /// Parses the values of the two environment variables: the port, which enables the endpoint, + /// and the optional address to bind. Without an address the IPv4 loopback address is bound; + /// an operator who explicitly wants another one names it - e.g. 0.0.0.0 inside a + /// container whose port publishing does the containment. + /// + /// The port variable's value. + /// The address variable's value, or null when it is unset. + /// The options, or null when the port is unset or either value is invalid. + public static ActorEndpointOptions? TryParse(string? port, string? address) + { + if (string.IsNullOrWhiteSpace(port) + || !int.TryParse(port.Trim(), out var portNumber) + || portNumber is <= 0 or > 65535) + { + return null; + } + + var bindAddress = IPAddress.Loopback; + if (!string.IsNullOrWhiteSpace(address) && !IPAddress.TryParse(address.Trim(), out bindAddress)) + { + return null; + } + + return new ActorEndpointOptions(new IPEndPoint(bindAddress, portNumber)); + } +} diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs index 3599e8ffd..a6a41beb2 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs @@ -70,6 +70,36 @@ public async Task UnknownCommandIsRefusedAsync() Assert.That(lines.Single().GetProperty("id").GetString(), Is.EqualTo("7")); } + /// + /// A numeric field outside its range is refused as bad_request - never wrapped into a + /// plausible-looking value, and never even reaching the registry or the actor. + /// + /// The request line. + /// The task. + [TestCase("""{"cmd":"spawn","actor":"test1","slot":-1}""")] + [TestCase("""{"cmd":"spawn","actor":"test1","slot":256}""")] + [TestCase("""{"cmd":"spawn","actor":"test1","server":1.5}""")] + [TestCase("""{"cmd":"walk","actor":"test1","x":300,"y":132}""")] + [TestCase("""{"cmd":"skill","actor":"test1","skill":-5,"target":"x"}""")] + [TestCase("""{"cmd":"pickup","actor":"test1","id":70000}""")] + [TestCase("""{"cmd":"attack","actor":"test1","target":"x","times":0}""")] + [TestCase("""{"cmd":"bots","action":"on","count":-1}""")] + public async Task NumberOutOfRangeIsRefusedAsync(string line) + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + var registry = new Mock(MockBehavior.Strict); + registry.Setup(r => r.FindAsync("test1")).Returns(ValueTask.FromResult(actor)); + var lines = new ResponseCollector(); + var handler = new ActorProtocolHandler(registry.Object, CreateBotsController()); + + await handler.HandleLineAsync(line, lines.WriteAsync, CancellationToken.None).ConfigureAwait(false); + + Assert.That(lines.Single().GetProperty("ok").GetBoolean(), Is.False); + Assert.That(lines.Single().GetProperty("code").GetString(), Is.EqualTo(ActorErrorCodes.BadRequest)); + registry.Verify(r => r.SpawnAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + /// /// A command for an account which is not animated names the actor in the error. /// @@ -93,7 +123,7 @@ public async Task CommandForAnUnknownActorIsRefusedAsync() public async Task SpawnPassesTheRegistryResultThroughAsync() { var registry = new Mock(); - registry.Setup(r => r.List()).Returns([]); + registry.Setup(r => r.ListAsync()).Returns(ValueTask.FromResult>([])); registry.Setup(r => r.SpawnAsync(0, "test1", null)) .Returns(ValueTask.FromResult(ActorCommandResult.Failure(ActorErrorCodes.InUse, "The account 'test1' is already in use by a bot."))); var lines = new ResponseCollector(); @@ -116,7 +146,7 @@ public async Task EventsReturnTheHistorySinceASequenceAsync() await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); actor.EventLog.Append("chat", new ActorEventField("message", "hello")); var registry = new Mock(); - registry.Setup(r => r.Find("test1")).Returns(actor); + registry.Setup(r => r.FindAsync("test1")).Returns(ValueTask.FromResult(actor)); var lines = new ResponseCollector(); var handler = new ActorProtocolHandler(registry.Object, CreateBotsController()); @@ -133,8 +163,8 @@ public async Task EventsReturnTheHistorySinceASequenceAsync() private static (ActorProtocolHandler Handler, ResponseCollector Lines) CreateHandler() { var registry = new Mock(); - registry.Setup(r => r.List()).Returns([]); - registry.Setup(r => r.Find(It.IsAny())).Returns((ScriptedPlayer?)null); + registry.Setup(r => r.ListAsync()).Returns(ValueTask.FromResult>([])); + registry.Setup(r => r.FindAsync(It.IsAny())).Returns(ValueTask.FromResult(null)); return (new ActorProtocolHandler(registry.Object, CreateBotsController()), new ResponseCollector()); } diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs index 22f89ef50..357a1aab7 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs @@ -108,7 +108,7 @@ public async Task ConcurrentSpawnsProduceExactlyOneActorAsync() Assert.That(results.Count(r => r.Ok), Is.EqualTo(1)); Assert.That(results.Where(r => !r.Ok).Select(r => r.Code), Is.All.EqualTo(ActorErrorCodes.InUse)); - Assert.That(fixture.Registry.List().Count, Is.EqualTo(1)); + Assert.That((await fixture.Registry.ListAsync().ConfigureAwait(false)).Count, Is.EqualTo(1)); Assert.That(fixture.FactoryCalls, Is.EqualTo(1)); } @@ -123,13 +123,13 @@ public async Task SpawnStopRoundTripAsync() var spawn = await fixture.Registry.SpawnAsync(0, "test1", null).ConfigureAwait(false); Assert.That(spawn.Ok, Is.True, spawn.Error); - Assert.That(fixture.Registry.Find("test1"), Is.Not.Null); + Assert.That(await fixture.Registry.FindAsync("test1").ConfigureAwait(false), Is.Not.Null); var stop = await fixture.Registry.StopAsync("test1").ConfigureAwait(false); Assert.That(stop.Ok, Is.True, stop.Error); - Assert.That(fixture.Registry.Find("test1"), Is.Null); - Assert.That(fixture.Registry.List(), Is.Empty); + Assert.That(await fixture.Registry.FindAsync("test1").ConfigureAwait(false), Is.Null); + Assert.That(await fixture.Registry.ListAsync().ConfigureAwait(false), Is.Empty); var unknown = await fixture.Registry.StopAsync("test1").ConfigureAwait(false); Assert.That(unknown.Code, Is.EqualTo(ActorErrorCodes.UnknownActor)); @@ -149,7 +149,7 @@ public async Task StopAllStopsEveryActorAsync() var stopped = await fixture.Registry.StopAllAsync().ConfigureAwait(false); Assert.That(stopped, Is.EqualTo(2)); - Assert.That(fixture.Registry.List(), Is.Empty); + Assert.That(await fixture.Registry.ListAsync().ConfigureAwait(false), Is.Empty); } private sealed class Fixture diff --git a/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs index 1ae53c70e..ea0c131ce 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs @@ -18,8 +18,9 @@ namespace MUnique.OpenMU.Tests.TestActors; public class BotsControllerTests { /// - /// bots on --count N writes N accounts with one character each and no presence rotation, - /// and that configuration survives the round trip through the persisted plugin configuration. + /// bots on --count N switches the feature on and sets N accounts, and that survives the + /// round trip through the persisted plugin configuration - while the settings it was not asked + /// about (characters per account, presence rotation) are left exactly as the operator had them. /// [Test] public void BotConfigurationRoundTripsThroughThePlugInConfiguration() @@ -34,22 +35,24 @@ public void BotConfigurationRoundTripsThroughThePlugInConfiguration() Assert.That(roundTripped, Is.Not.Null); Assert.That(roundTripped!.Enabled, Is.True); Assert.That(roundTripped.NumberOfAccounts, Is.EqualTo(2)); - Assert.That(roundTripped.MaxCharactersPerAccount, Is.EqualTo(1)); - Assert.That(roundTripped.PresenceRotation, Is.False); + Assert.That(roundTripped.MaxCharactersPerAccount, Is.EqualTo(5)); + Assert.That(roundTripped.PresenceRotation, Is.True); } /// - /// Without a count, the documented default population is requested. + /// Without a count, only the switch is written; the configured number of accounts stays. /// [Test] - public void DefaultCountIsUsedWhenNoneIsGiven() + public void SwitchingOnWithoutACountKeepsTheConfiguredAccounts() { - var configuration = new BotConfiguration(); + var configuration = new BotConfiguration { NumberOfAccounts = 7, MaxCharactersPerAccount = 3, PresenceRotation = false }; BotsController.Apply(configuration, enabled: true, count: null); - Assert.That(configuration.NumberOfAccounts, Is.EqualTo(BotsController.DefaultBotCount)); - Assert.That(configuration.MaxCharactersPerAccount, Is.EqualTo(1)); + Assert.That(configuration.Enabled, Is.True); + Assert.That(configuration.NumberOfAccounts, Is.EqualTo(7)); + Assert.That(configuration.MaxCharactersPerAccount, Is.EqualTo(3)); + Assert.That(configuration.PresenceRotation, Is.False); } /// diff --git a/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs index ceccc19b3..3f38c937e 100644 --- a/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs +++ b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs @@ -124,6 +124,54 @@ public async Task WalkWithoutAPathIsRefusedAsync() Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.NoPath)); } + /// + /// When the engine cuts the path short - the path finder's grid and the walk map disagree on a + /// tile - the walk does not claim success: it answers no_path with the position it + /// actually reached and the steps it actually took. + /// + /// The task. + [Test] + public async Task WalkCutShortByTheEngineReportsThePositionReachedAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + + // Only the movement check sees this block; the path finder still plans through it. + actor.CurrentMap!.Terrain.WalkMap[104, 104] = false; + + var result = await actor.Intelligence!.ExecuteAsync(new WalkCommand(104, 104)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.NoPath)); + Assert.That(actor.Position, Is.Not.EqualTo(new Point(104, 104))); + Assert.That(result.Fields.First(f => f.Name == "x").Value, Is.EqualTo(actor.Position.X)); + Assert.That(result.Fields.First(f => f.Name == "y").Value, Is.EqualTo(actor.Position.Y)); + Assert.That(result.Fields.First(f => f.Name == "walked").Value, Is.EqualTo(3)); + Assert.That(result.Fields.First(f => f.Name == "steps").Value, Is.EqualTo(4)); + } + + /// + /// When the engine refuses the walk outright (first step blocked on the walk map), the actor + /// has not moved and the command says so instead of counting the chunk as walked. + /// + /// The task. + [Test] + public async Task WalkRefusedByTheEngineIsNotReportedAsDoneAsync() + { + var gameContext = ActorTestHelper.CreateGameContext(); + await using var actor = await ActorTestHelper.CreateActorAsync(gameContext, "test1", "Actor1").ConfigureAwait(false); + await actor.MoveAsync(new Point(100, 100)).ConfigureAwait(false); + actor.CurrentMap!.Terrain.WalkMap[100, 101] = false; + + var result = await actor.Intelligence!.ExecuteAsync(new WalkCommand(100, 101)).ConfigureAwait(false); + + Assert.That(result.Ok, Is.False); + Assert.That(result.Code, Is.EqualTo(ActorErrorCodes.NoPath)); + Assert.That(actor.Position, Is.EqualTo(new Point(100, 100))); + Assert.That(result.Fields.First(f => f.Name == "walked").Value, Is.EqualTo(0)); + } + /// /// A halt during a repeated attack ends the command with interrupted and the hits /// performed so far, records an interrupted event, and leaves the actor in the world.