Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs-website/docs/server-features/test-actors.md
Original file line number Diff line number Diff line change
@@ -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 <id\|name> [--times N] [--interval ms]` | plain attacks against an object in view |
| `skill <number> <id\|name>` | 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.
1 change: 1 addition & 0 deletions docs-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const sidebars = {
label: 'Server features',
items: [
'server-features/bots',
'server-features/test-actors',
],
},
{
Expand Down
12 changes: 12 additions & 0 deletions src/GameLogic/TestActors/ActorCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// <copyright file="ActorCommand.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.TestActors;

/// <summary>
/// One thing a scenario tells an actor to do. Commands are executed by the actor's own
/// <see cref="ScriptedIntelligence"/>, serialized with its persistence lock.
/// </summary>
/// <param name="Name">The command name as it appears in the protocol and in the log.</param>
public abstract record ActorCommand(string Name);
34 changes: 34 additions & 0 deletions src/GameLogic/TestActors/ActorCommandResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// <copyright file="ActorCommandResult.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.TestActors;

/// <summary>
/// The outcome of one <see cref="ActorCommand"/>. A command is never silently ignored: it either
/// succeeded with a result, or it carries the code of the precondition which refused it.
/// </summary>
/// <param name="Ok"><c>true</c> when the command was carried out.</param>
/// <param name="Code">The machine readable failure code, e.g. <c>out_of_range</c>; <c>null</c> on success.</param>
/// <param name="Error">The human readable failure message; <c>null</c> on success.</param>
/// <param name="Fields">The result fields, e.g. the hits performed or the path length.</param>
public sealed record ActorCommandResult(bool Ok, string? Code, string? Error, IReadOnlyList<ActorEventField> Fields)
{
/// <summary>
/// Creates a successful result.
/// </summary>
/// <param name="fields">The result fields.</param>
/// <returns>The result.</returns>
public static ActorCommandResult Success(params ActorEventField[] fields)
=> new(true, null, null, fields);

/// <summary>
/// Creates a failed result.
/// </summary>
/// <param name="code">The failure code.</param>
/// <param name="error">The failure message.</param>
/// <param name="fields">The progress made before the failure, if any.</param>
/// <returns>The result.</returns>
public static ActorCommandResult Failure(string code, string error, params ActorEventField[] fields)
=> new(false, code, error, fields);
}
79 changes: 79 additions & 0 deletions src/GameLogic/TestActors/ActorErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// <copyright file="ActorErrorCodes.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.TestActors;

/// <summary>
/// The failure codes a command can answer with. They are part of the protocol: test scripts
/// branch on them.
/// </summary>
public static class ActorErrorCodes
{
/// <summary>The actor or its target stands in a safe zone, where attacks are forbidden.</summary>
public static readonly string SafeZone = "safezone";

/// <summary>The target is farther away than the character's attack or skill range.</summary>
public static readonly string OutOfRange = "out_of_range";

/// <summary>No object with that id or name is within the actor's view.</summary>
public static readonly string NotInView = "not_in_view";

/// <summary>The character has not learned the requested skill.</summary>
public static readonly string UnknownSkill = "unknown_skill";

/// <summary>The character cannot pay the skill's mana or ability cost.</summary>
public static readonly string InsufficientResources = "insufficient_resources";

/// <summary>
/// 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).
/// </summary>
public static readonly string NoPath = "no_path";

/// <summary>The target cannot be attacked (dead, not attackable, or the actor itself).</summary>
public static readonly string InvalidTarget = "invalid_target";

/// <summary>The actor is dead and cannot act.</summary>
public static readonly string Dead = "dead";

/// <summary>The actor is not (yet) in the world.</summary>
public static readonly string NotReady = "not_ready";

/// <summary>A <c>halt</c> or a later command interrupted this one.</summary>
public static readonly string Interrupted = "interrupted";

/// <summary>The requested warp list entry does not exist.</summary>
public static readonly string UnknownGate = "unknown_gate";

/// <summary>The warp was refused by the game's level, zen or map rules.</summary>
public static readonly string WarpRefused = "warp_refused";

/// <summary>The drop is not there any more, or could not be picked up.</summary>
public static readonly string PickupFailed = "pickup_failed";

/// <summary>The command threw; the message carries the exception.</summary>
public static readonly string Failed = "failed";

/// <summary>The account is already animated by an actor, a bot or a connected client.</summary>
public static readonly string InUse = "in_use";

/// <summary>No actor animates the given account.</summary>
public static readonly string UnknownActor = "unknown_actor";

/// <summary>This process does not host the requested game server.</summary>
public static readonly string UnknownServer = "unknown_server";

/// <summary>The account or its character could not be loaded, so the actor never entered the world.</summary>
public static readonly string SpawnFailed = "spawn_failed";

/// <summary>The request was not a JSON object, or its <c>cmd</c> is unknown.</summary>
public static readonly string BadRequest = "bad_request";

/// <summary>
/// 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.
/// </summary>
public static readonly string SkillRefused = "skill_refused";
}
19 changes: 19 additions & 0 deletions src/GameLogic/TestActors/ActorEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// <copyright file="ActorEvent.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.TestActors;

/// <summary>
/// One recorded observation of a <see cref="ScriptedPlayer"/>: 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.
/// </summary>
/// <param name="Seq">
/// The sequence number, strictly increasing per actor. Readers use it to fetch only what they have
/// not seen yet.
/// </param>
/// <param name="Utc">The time the event was recorded.</param>
/// <param name="Type">The event type, e.g. <c>hit</c>, <c>stat</c>, <c>killed</c>, <c>chat</c>.</param>
/// <param name="Fields">The type specific fields, in the order they should be written.</param>
public sealed record ActorEvent(long Seq, DateTime Utc, string Type, IReadOnlyList<ActorEventField> Fields);
14 changes: 14 additions & 0 deletions src/GameLogic/TestActors/ActorEventField.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// <copyright file="ActorEventField.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.TestActors;

/// <summary>
/// One field of an <see cref="ActorEvent"/>. 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.
/// </summary>
/// <param name="Name">The field name as it appears in the JSON output.</param>
/// <param name="Value">The value; <c>null</c> is written as JSON <c>null</c>.</param>
public readonly record struct ActorEventField(string Name, object? Value);
Loading