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..e4d5537fb --- /dev/null +++ b/docs-website/docs/server-features/test-actors.md @@ -0,0 +1,115 @@ +--- +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. 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. 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 +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. 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"} +{"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**. 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. + +## 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; `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 | +| `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. 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/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/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..8ef75fdac --- /dev/null +++ b/src/GameLogic/TestActors/ActorErrorCodes.cs @@ -0,0 +1,79 @@ +// +// 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 static readonly string SafeZone = "safezone"; + + /// The target is farther away than the character's attack or skill range. + public static readonly string OutOfRange = "out_of_range"; + + /// No object with that id or name is within the actor's view. + public static readonly string NotInView = "not_in_view"; + + /// The character has not learned the requested skill. + public static readonly string UnknownSkill = "unknown_skill"; + + /// 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, 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). + public static readonly string InvalidTarget = "invalid_target"; + + /// The actor is dead and cannot act. + public static readonly string Dead = "dead"; + + /// The actor is not (yet) in the world. + public static readonly string NotReady = "not_ready"; + + /// A halt or a later command interrupted this one. + public static readonly string Interrupted = "interrupted"; + + /// The requested warp list entry does not exist. + public static readonly string UnknownGate = "unknown_gate"; + + /// The warp was refused by the game's level, zen or map rules. + public static readonly string WarpRefused = "warp_refused"; + + /// The drop is not there any more, or could not be picked up. + public static readonly string PickupFailed = "pickup_failed"; + + /// The command threw; the message carries the exception. + public static readonly string Failed = "failed"; + + /// The account is already animated by an actor, a bot or a connected client. + public static readonly string InUse = "in_use"; + + /// No actor animates the given account. + public static readonly string UnknownActor = "unknown_actor"; + + /// This process does not host the requested game 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 static readonly string SpawnFailed = "spawn_failed"; + + /// The request was not a JSON object, or its cmd is unknown. + 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 static readonly 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..883a6d375 --- /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.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 static readonly int DefaultCapacity = 4096; + + /// + /// The number of events a live follower may fall behind before it starts losing the oldest ones. + /// + public static readonly 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; null uses . + public ActorEventLog(int? capacity = null) + { + var size = capacity ?? DefaultCapacity; + if (size < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity), size, "The event log needs room for at least one event."); + } + + this._ring = new ActorEvent?[size]; + } + + /// + /// 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; null uses . + /// The subscription; disposing it ends the stream. + public ActorEventSubscription Subscribe(long? sinceSequence = null, int? capacity = null) + { + var subscription = new ActorEventSubscription(this, capacity ?? DefaultSubscriptionCapacity); + 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..012946c99 --- /dev/null +++ b/src/GameLogic/TestActors/ActorHitRecorderPlugIn.cs @@ -0,0 +1,60 @@ +// +// 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.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). +/// +/// 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. +/// +/// +[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..683435411 --- /dev/null +++ b/src/GameLogic/TestActors/ActorObjects.cs @@ -0,0 +1,82 @@ +// +// 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.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 static readonly string ActorKind = "actor"; + + /// The kind of a server-side population bot. + public static readonly string BotKind = "bot"; + + /// The kind of a human player, connected or in an offline session. + public static readonly string PlayerKind = "player"; + + /// The kind of a monster. + public static readonly string MonsterKind = "monster"; + + /// The kind of a non-attackable NPC. + public static readonly string NpcKind = "npc"; + + /// The kind of a dropped item. + public static readonly string ItemKind = "item"; + + /// The kind of anything else. + public static readonly 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..35ba9a6c3 --- /dev/null +++ b/src/GameLogic/TestActors/ActorProtocolHandler.cs @@ -0,0 +1,282 @@ +// +// 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 (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); + } + } + + private static string? GetString(JsonElement request, string name) + => request.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : 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; + + private static string? GetTarget(JsonElement request) + { + if (request.TryGetProperty("target", out var value)) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out var number) ? number.ToString() : null, + _ => 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", (await this._registry.ListAsync().ConfigureAwait(false)).Count))).ConfigureAwait(false); + return; + + case "spawn": + var spawned = await this._registry.SpawnAsync( + GetInt32(request, "server") ?? 0, + GetString(request, "actor") ?? string.Empty, + GetByte(request, "slot")).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": + var actors = await this._registry.ListAsync().ConfigureAwait(false); + await WriteResultAsync(writer, id, ActorCommandResult.Success(new ActorEventField( + "actors", + 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", GetInt32(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 (await this._registry.FindAsync(loginName).ConfigureAwait(false) 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(GetByte(request, "x") ?? 0, GetByte(request, "y") ?? 0), + "attack" => new AttackCommand( + 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(GetUInt16(request, "id") ?? 0), + "warp" => new WarpCommand(GetInt32(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, long.MaxValue) ?? 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..74ac687e2 --- /dev/null +++ b/src/GameLogic/TestActors/ActorRegistry.cs @@ -0,0 +1,221 @@ +// +// 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.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. 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 +{ + 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.Contexts) + { + 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 async ValueTask> ListAsync() + { + await this._lock.WaitAsync().ConfigureAwait(false); + try + { + return this._actors.Values.ToList(); + } + finally + { + this._lock.Release(); + } + } + + /// + public async ValueTask FindAsync(string loginName) + { + await this._lock.WaitAsync().ConfigureAwait(false); + 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..4e7c6e11c --- /dev/null +++ b/src/GameLogic/TestActors/ActorState.cs @@ -0,0 +1,132 @@ +// +// 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.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..e46ad9c12 --- /dev/null +++ b/src/GameLogic/TestActors/BotsController.cs @@ -0,0 +1,177 @@ +// +// 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.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. 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 +{ + 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 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 accounts to set, or null to keep the configured one. + public static void Apply(BotConfiguration configuration, bool enabled, int? count) + { + configuration.Enabled = enabled; + if (count is { } accounts) + { + configuration.NumberOfAccounts = Math.Max(0, accounts); + } + } + + /// + /// Handles a bots request. + /// + /// One of on, off and status. + /// The number of bot accounts to set, if any. + /// The result. + public async ValueTask HandleAsync(string action, int? count) + { + if (this._locator.Contexts 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} ({Accounts} account(s), up to {Characters} character(s) each).", + enabled ? "on" : "off", + configuration.NumberOfAccounts, + configuration.MaxCharactersPerAccount); + 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 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(); + 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("presence_rotation", configuration?.PresenceRotation ?? false), + 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..d18ab3f87 --- /dev/null +++ b/src/GameLogic/TestActors/IActorFactory.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +/// +/// 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..ea805833b --- /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. + ValueTask> ListAsync(); + + /// + /// Gets the actor which animates the given account, if any. + /// + /// The login name. + /// The actor, or null. + ValueTask FindAsync(string loginName); +} diff --git a/src/GameLogic/TestActors/IGameServerContextLocator.cs b/src/GameLogic/TestActors/IGameServerContextLocator.cs new file mode 100644 index 000000000..21faeefd9 --- /dev/null +++ b/src/GameLogic/TestActors/IGameServerContextLocator.cs @@ -0,0 +1,59 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.TestActors; + +using MUnique.OpenMU.Interfaces; + +/// +/// Resolves the game server contexts which run in this process. +/// +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); +} + +/// +/// 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 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) + { + return this._gameServers.TryGetValue(serverId, out var gameServer) && gameServer is IGameServerContextProvider provider + ? provider.Context + : null; + } +} 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..81b382cef --- /dev/null +++ b/src/GameLogic/TestActors/RecordingViewPlugInContainer.cs @@ -0,0 +1,297 @@ +// +// 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.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..46e90eb75 --- /dev/null +++ b/src/GameLogic/TestActors/ScriptedIntelligence.cs @@ -0,0 +1,740 @@ +// +// 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.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; + } + + /// + /// 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; + 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. + } + } + + // 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", end.X), + new ActorEventField("y", end.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..9315fd7cb --- /dev/null +++ b/src/GameLogic/TestActors/ScriptedPlayer.cs @@ -0,0 +1,168 @@ +// +// 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.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..244f32e01 --- /dev/null +++ b/src/Network/LoginProbe/Program.cs @@ -0,0 +1,292 @@ +// +// 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.Globalization; +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.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 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]; + + /// 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 [--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."); + } + + 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]; + _ = 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(); + var index = 0; + while (index < args.Length) + { + if (index + 1 >= args.Length) + { + return null; + } + + var value = args[index + 1]; + switch (args[index]) + { + 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; + default: + return null; + } + + index += 2; + } + + if (string.IsNullOrEmpty(options.Account)) + { + return null; + } + + 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 new file mode 100644 index 000000000..bf5aa8f0f --- /dev/null +++ b/src/Network/LoginProbe/Readme.md @@ -0,0 +1,44 @@ +# 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 + +```text +MUnique.OpenMU.Network.LoginProbe --account [--host 127.0.0.1] + [--connect-port 44406] [--server 0] + [--hold ] +``` + +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 +{"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`. 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..788b0efee 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,38 @@ private async Task CreateHostAsync(string[] args) return host; } + /// + /// Adds the test actor control endpoint, but only when OPENMU_ACTOR_PORT names a + /// 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.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 {endPoint}", actorOptions.EndPoint); + services + .AddSingleton(actorOptions) + .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..393a9aa4c --- /dev/null +++ b/src/Startup/TestActors/ActorControlService.cs @@ -0,0 +1,173 @@ +// +// 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.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, 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 by naming its port. + /// + public static readonly string PortVariableName = "OPENMU_ACTOR_PORT"; + + /// + /// The environment variable which names the address to bind; unset, the loopback address. + /// + public static readonly string AddressVariableName = "OPENMU_ACTOR_ADDRESS"; + + /// + /// 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); + + private readonly IPEndPoint _endPoint; + private readonly IActorRegistry _registry; + private readonly ActorProtocolHandler _handler; + private readonly PlugInManager _plugInManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// 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, PlugInManager plugInManager, ILogger logger) + { + this._endPoint = options.EndPoint; + this._registry = registry; + this._handler = handler; + this._plugInManager = plugInManager; + this._logger = logger; + } + + /// + /// Gets the endpoint configured in the environment, or null when the endpoint is off (the default). + /// + 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) + { + this._plugInManager.RegisterPlugIn(); + return base.StartAsync(cancellationToken); + } + + /// + /// 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); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var listener = new TcpListener(this._endPoint); + try + { + listener.Start(); + this._logger.LogInformation("Actor control endpoint listening on {EndPoint}.", listener.LocalEndpoint); + + 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 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..aa773b0c7 --- /dev/null +++ b/src/Startup/TestActors/ActorEndpointOptions.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Startup.TestActors; + +using System.Net; + +/// +/// The options of the actor control endpoint. +/// +/// 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/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..74f3222c2 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorHitRecorderPlugInTests.cs @@ -0,0 +1,129 @@ +// +// 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 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..a6a41beb2 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorProtocolHandlerTests.cs @@ -0,0 +1,186 @@ +// +// 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 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. + /// + /// 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.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(); + 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.FindAsync("test1")).Returns(ValueTask.FromResult(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.ListAsync()).Returns(ValueTask.FromResult>([])); + registry.Setup(r => r.FindAsync(It.IsAny())).Returns(ValueTask.FromResult(null)); + return (new ActorProtocolHandler(registry.Object, CreateBotsController()), new ResponseCollector()); + } + + private static BotsController CreateBotsController() + { + var locator = new Mock(); + locator.Setup(l => l.Contexts).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..357a1aab7 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorRegistryTests.cs @@ -0,0 +1,262 @@ +// +// 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 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((await fixture.Registry.ListAsync().ConfigureAwait(false)).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(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(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)); + } + + /// + /// 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(await fixture.Registry.ListAsync().ConfigureAwait(false), 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.Contexts).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..553578d38 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ActorTestHelper.cs @@ -0,0 +1,81 @@ +// +// 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; + +/// +/// 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..ea0c131ce --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/BotsControllerTests.cs @@ -0,0 +1,107 @@ +// +// 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 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() + { + 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(5)); + Assert.That(roundTripped.PresenceRotation, Is.True); + } + + /// + /// Without a count, only the switch is written; the configured number of accounts stays. + /// + [Test] + public void SwitchingOnWithoutACountKeepsTheConfiguredAccounts() + { + var configuration = new BotConfiguration { NumberOfAccounts = 7, MaxCharactersPerAccount = 3, PresenceRotation = false }; + + BotsController.Apply(configuration, enabled: true, count: null); + + 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); + } + + /// + /// 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.Contexts).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.Contexts).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..3f38c937e --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TestActors/ScriptedIntelligenceTests.cs @@ -0,0 +1,254 @@ +// +// 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)); + } + + /// + /// 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. + /// + /// 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", + })); + } +}