feat: stage 2, the NPC and its API (API 2)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and ScientistBrain swapped for ours, copying a fixed field list generated from Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in data/RunicNPC/profiles.json in the approved shape (D238); placements persist with each/group respawn (D236) and wait for a missing profile or route (D237); roamers wander, follow Rust's monument paths, or walk a route (D233, D234), with our own chase where Rust's needs an AI zone; sentries hold their spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait for the navmesh and are spread over frames; caps are off by default and the cost warning is shown instead (D227). The whole PLAN.md section 4 API and its hooks are in, documented in docs/runicnpc/API.md. tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both rigs, and after a plugin reload and a server restart. checkPlugin no longer counts an override (RunicNpcPlayer.OnDied) as a hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
29
README.md
29
README.md
@@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG
|
||||
website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is
|
||||
[`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md).
|
||||
|
||||
> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns
|
||||
> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2.
|
||||
> **Status: stage 2.** The NPC, its profiles, placements and routes, and the API other plugins call.
|
||||
> The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in
|
||||
> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -53,9 +54,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`:
|
||||
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
|
||||
```
|
||||
|
||||
Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be
|
||||
documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook
|
||||
changes shape, not on every release.
|
||||
The API is version 2, documented in
|
||||
[`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md):
|
||||
spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises.
|
||||
The API version moves when a call or a raised hook changes shape, not on every release.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -64,7 +66,22 @@ changes shape, not on every release.
|
||||
| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. |
|
||||
| `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. |
|
||||
| `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). |
|
||||
| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). |
|
||||
| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). |
|
||||
|
||||
## After a Rust update: the swap's field list
|
||||
|
||||
Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232),
|
||||
generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=64/64 brain=32/32
|
||||
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
|
||||
the list was made. To regenerate it:
|
||||
|
||||
```bash
|
||||
node tools/managed.js carbon <dir> # the Carbon rig's RustDedicated_Data/Managed
|
||||
dotnet run --project tools/fieldlist -- <dir> # rewrites the block in plugin/RunicNPC.cs
|
||||
```
|
||||
|
||||
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which
|
||||
fields Rust itself serialises.
|
||||
|
||||
## Releases
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
# a RunicNPC too old for the bridge it is pairing with. Declaring it here is what
|
||||
# lets a bundle check the pair BEFORE an operator installs it.
|
||||
#
|
||||
# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0).
|
||||
api = 1
|
||||
# 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
|
||||
# Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md.
|
||||
api = 2
|
||||
|
||||
# ── Framework floors ─────────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
1950
plugin/RunicNPC.cs
1950
plugin/RunicNPC.cs
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,11 @@ function check(source, toml) {
|
||||
}
|
||||
|
||||
const methods = readMethods(source)
|
||||
const hooks = methods.filter((x) => HOOK_NAME.test(x.name))
|
||||
// An `override` is never a hook: a hook is a plugin method the framework finds by name, and the
|
||||
// plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's
|
||||
// own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the
|
||||
// framework, calls those.
|
||||
const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns))
|
||||
const hookNames = new Set(hooks.map((x) => x.name))
|
||||
|
||||
// 1. Every hook the plugin implements is one `rnpc.status` can report on.
|
||||
|
||||
@@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => {
|
||||
assert.ok(HOOK_NAME.test('CanBeTargeted'))
|
||||
})
|
||||
|
||||
test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => {
|
||||
const methods = ` private void OnServerInitialized()
|
||||
{
|
||||
}
|
||||
|
||||
public class RunicNpcPlayer : ScientistNPC
|
||||
{
|
||||
public override void OnDied(HitInfo info)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnStateChanged()
|
||||
{
|
||||
}
|
||||
}`
|
||||
|
||||
assert.deepEqual(check(source({ methods }), toml()), [])
|
||||
})
|
||||
|
||||
// ── The API ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => {
|
||||
@@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no
|
||||
|
||||
// Raise these floors as the plugin grows; they are what stops a regex that
|
||||
// matches nothing from passing every case above.
|
||||
assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`)
|
||||
assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`)
|
||||
assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser')
|
||||
assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser')
|
||||
|
||||
const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name)
|
||||
assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call')
|
||||
assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call')
|
||||
|
||||
assert.ok(readExpectedHooks(real).length >= 1)
|
||||
assert.deepEqual(readRequires(real), ['Kits'])
|
||||
|
||||
802
tools/RunicNpcTest.cs
Normal file
802
tools/RunicNpcTest.cs
Normal file
@@ -0,0 +1,802 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Oxide.Core;
|
||||
using Oxide.Core.Plugins;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
|
||||
namespace Oxide.Plugins
|
||||
{
|
||||
/// <summary>
|
||||
/// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives
|
||||
/// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks,
|
||||
/// as any other plugin would, and prints one line per assertion:
|
||||
///
|
||||
/// <code>[rnt] <group>.<check>: PASS|FAIL <detail></code>
|
||||
///
|
||||
/// Every line is also kept in <c>data/RunicNpcTest.json</c>, read back over the panel.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>rnt.run <group></c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
|
||||
/// <c>sentry</c>, <c>sleep</c>, <c>place</c>, or <c>all</c> for each in turn.
|
||||
/// <c>rnt.run place</c> leaves two placements behind for the restart check, and
|
||||
/// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing
|
||||
/// else is. <c>rnt.spawn [n]</c> spawns plugin-owned NPCs, for the check that they die with this plugin, and
|
||||
/// <c>rnt.clear</c> removes everything the harness made.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// It needs the hidden Kits kit <c>rnhrevolver</c> (stage 1's: a revolver, pistol ammo and
|
||||
/// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server
|
||||
/// managed: back up <c>data/RunicNPC/</c> first and restore it afterwards.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Targets and wake-ups are <b>stand-ins</b>, stage 1's fake players: <c>player.prefab</c> with
|
||||
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Info("RunicNpcTest", "Runic Gateway", "0.2.0")]
|
||||
[Description("RunicNPC stage 2 tests. A developer tool: never ship it.")]
|
||||
internal class RunicNpcTest : RustPlugin
|
||||
{
|
||||
[PluginReference] private Plugin RunicNPC;
|
||||
|
||||
private const string Kit = "rnhrevolver";
|
||||
private const string Owner = "plugin:RunicNpcTest";
|
||||
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
|
||||
private const ulong StandInId = 11400000101UL;
|
||||
|
||||
private readonly List<string> _lines = new List<string>();
|
||||
private readonly HashSet<BasePlayer> _standIns = new HashSet<BasePlayer>();
|
||||
private readonly List<BaseEntity> _built = new List<BaseEntity>();
|
||||
private Coroutine _running;
|
||||
private int _pass, _fail;
|
||||
|
||||
// What RunicNPC's hooks told us.
|
||||
private readonly List<string> _spawned = new List<string>();
|
||||
private readonly List<string> _died = new List<string>();
|
||||
private readonly List<string> _health = new List<string>();
|
||||
private readonly List<string> _despawned = new List<string>();
|
||||
private readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
|
||||
private int _hitsOnStandIns;
|
||||
|
||||
private static readonly MethodInfo LifeStoryStart =
|
||||
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
// ---- output ----
|
||||
|
||||
private void Check(string name, bool ok, string detail = "")
|
||||
{
|
||||
if (ok) _pass++; else _fail++;
|
||||
string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}";
|
||||
_lines.Add(line);
|
||||
Puts("[rnt] " + line);
|
||||
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
|
||||
}
|
||||
|
||||
private void Note(string line)
|
||||
{
|
||||
_lines.Add("# " + line);
|
||||
Puts("[rnt] # " + line);
|
||||
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
|
||||
}
|
||||
|
||||
// ---- lifecycle ----
|
||||
|
||||
private void Unload()
|
||||
{
|
||||
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
|
||||
ClearWorld();
|
||||
}
|
||||
|
||||
private void ClearWorld()
|
||||
{
|
||||
foreach (BasePlayer p in _standIns.ToList())
|
||||
if (p != null && !p.IsDestroyed) p.Kill();
|
||||
_standIns.Clear();
|
||||
foreach (BaseEntity e in _built)
|
||||
if (e != null && !e.IsDestroyed) e.Kill();
|
||||
_built.Clear();
|
||||
}
|
||||
|
||||
// ---- RunicNPC's API, typed ----
|
||||
|
||||
private T Api<T>(string call, params object[] args)
|
||||
{
|
||||
object r = RunicNPC?.Call(call, args);
|
||||
return r is T ? (T)r : default(T);
|
||||
}
|
||||
|
||||
private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) =>
|
||||
Api<BasePlayer>("RunicNpc_Spawn", at, profile, owner, overrides);
|
||||
|
||||
private List<Dictionary<string, object>> List(string owner) =>
|
||||
Api<List<Dictionary<string, object>>>("RunicNpc_List", owner) ?? new List<Dictionary<string, object>>();
|
||||
|
||||
private Dictionary<string, object> Describe(BasePlayer npc) =>
|
||||
npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value);
|
||||
|
||||
private static Vector3 V(object o)
|
||||
{
|
||||
var d = (Dictionary<string, object>)o;
|
||||
return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"]));
|
||||
}
|
||||
|
||||
private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude;
|
||||
|
||||
// ---- places ----
|
||||
|
||||
private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) =>
|
||||
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
|
||||
|
||||
private static MonumentInfo[] _monuments;
|
||||
|
||||
/// <summary>The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.)</summary>
|
||||
private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType<MonumentInfo>());
|
||||
|
||||
/// <summary>A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field.</summary>
|
||||
private static bool FindField(float clear, int seed, out Vector3 at)
|
||||
{
|
||||
var rng = new System.Random(seed);
|
||||
float half = World.Size / 2f - 300f;
|
||||
for (int i = 0; i < 4000; i++)
|
||||
{
|
||||
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
|
||||
p.y = TerrainMeta.HeightMap.GetHeight(p);
|
||||
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue;
|
||||
if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue;
|
||||
NavMeshHit hit;
|
||||
if (!SampleNavmesh(p, 2f, out hit)) continue;
|
||||
// The whole 35 m around it must be walkable too, for the wander and route tests.
|
||||
bool open = true;
|
||||
for (int k = 0; k < 8 && open; k++)
|
||||
{
|
||||
float a = k * Mathf.PI / 4f;
|
||||
NavMeshHit h2;
|
||||
open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2);
|
||||
}
|
||||
|
||||
if (!open) continue;
|
||||
at = hit.position;
|
||||
return true;
|
||||
}
|
||||
|
||||
at = Vector3.zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>A monument with an AI zone, where Rust's own roam has move points to follow.</summary>
|
||||
private static bool FindMonument(out Vector3 at, out string name)
|
||||
{
|
||||
foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1))
|
||||
{
|
||||
if (m == null) continue;
|
||||
AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false);
|
||||
if (zone == null) continue;
|
||||
NavMeshHit hit;
|
||||
if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue;
|
||||
at = hit.position;
|
||||
name = System.IO.Path.GetFileNameWithoutExtension(m.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
at = Vector3.zero;
|
||||
name = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private BasePlayer StandIn(Vector3 at, int index)
|
||||
{
|
||||
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
|
||||
if (p == null) return null;
|
||||
ulong id = StandInId + (ulong)index;
|
||||
p.userID = id;
|
||||
p.UserIDString = id.ToString();
|
||||
p.displayName = "RntStandIn" + index;
|
||||
p.enableSaving = false;
|
||||
p.Spawn();
|
||||
p.EndSleeping();
|
||||
LifeStoryStart?.Invoke(p, null);
|
||||
_standIns.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private void RemoveStandIns()
|
||||
{
|
||||
foreach (BasePlayer p in _standIns.ToList())
|
||||
if (p != null && !p.IsDestroyed) p.Kill();
|
||||
_standIns.Clear();
|
||||
}
|
||||
|
||||
// ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ----
|
||||
|
||||
private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}");
|
||||
|
||||
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
|
||||
{
|
||||
_died.Add($"{profile}/{owner}");
|
||||
_lastContributors.Clear();
|
||||
foreach (KeyValuePair<ulong, float> kv in contributors) _lastContributors[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}");
|
||||
|
||||
private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner);
|
||||
|
||||
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
|
||||
{
|
||||
BasePlayer p = victim as BasePlayer;
|
||||
if (p == null || !_standIns.Contains(p) || info == null) return;
|
||||
if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++;
|
||||
info.damageTypes.ScaleAll(0f);
|
||||
}
|
||||
|
||||
// ---- the profiles and routes the tests use ----
|
||||
|
||||
private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") =>
|
||||
JObject.FromObject(new
|
||||
{
|
||||
names = new[] { name },
|
||||
kits = new[] { kit },
|
||||
prefab = "scientistnpc_roam",
|
||||
role,
|
||||
movement = new { mode = movement, radius },
|
||||
health = 250f,
|
||||
damageDealt = 1f,
|
||||
damageTaken = new { head = 2f, body = 0.5f, legs = 1f },
|
||||
aimCone = 2f,
|
||||
ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f },
|
||||
visionCone = -0.8f,
|
||||
sleepDistance = 160f,
|
||||
healthThresholds = new[] { 0.5f }
|
||||
});
|
||||
|
||||
private Dictionary<string, string> SetTestProfiles(bool withEach = true)
|
||||
{
|
||||
var all = new JObject
|
||||
{
|
||||
["t_roam"] = Prof(),
|
||||
["t_sentry"] = Prof(role: "sentry", name: "TSentry"),
|
||||
["t_mon"] = Prof("monument", name: "TMon"),
|
||||
["t_route"] = Prof("route:t_loop", name: "TRoute"),
|
||||
["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad")
|
||||
};
|
||||
JObject leash = Prof(radius: 4f, name: "TLeash");
|
||||
leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f });
|
||||
all["t_leash"] = leash;
|
||||
if (withEach)
|
||||
all["t_each"] = Prof(name: "TEach");
|
||||
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
|
||||
}
|
||||
|
||||
private string SetLoop(Vector3 centre)
|
||||
{
|
||||
var points = new JArray();
|
||||
foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) })
|
||||
{
|
||||
NavMeshHit hit;
|
||||
SampleNavmesh(centre + o, 3f, out hit);
|
||||
points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z }));
|
||||
}
|
||||
|
||||
return Api<string>("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true }));
|
||||
}
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
[ConsoleCommand("rnt.run")]
|
||||
private void CmdRun(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
string group = arg.GetString(0, "all");
|
||||
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
|
||||
_running = ServerMgr.Instance.StartCoroutine(Run(group));
|
||||
arg.ReplyWith($"rnt: running {group}");
|
||||
}
|
||||
|
||||
[ConsoleCommand("rnt.after")]
|
||||
private void CmdAfter(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
After();
|
||||
}
|
||||
|
||||
/// <summary>Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it.</summary>
|
||||
[ConsoleCommand("rnt.spawn")]
|
||||
private void CmdSpawn(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
Vector3 field;
|
||||
if (!FindField(300f, 2024, out field)) return;
|
||||
int n = 0;
|
||||
for (int i = 0; i < arg.GetInt(0, 3); i++)
|
||||
if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++;
|
||||
arg.ReplyWith($"rnt: spawned {n} as {Owner}");
|
||||
}
|
||||
|
||||
[ConsoleCommand("rnt.clear")]
|
||||
private void CmdClear(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
ClearWorld();
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" })
|
||||
Api<bool>("RunicNpc_RemovePlacement", id);
|
||||
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
|
||||
arg.ReplyWith("rnt: cleared");
|
||||
}
|
||||
|
||||
private IEnumerator Run(string group)
|
||||
{
|
||||
_pass = _fail = 0;
|
||||
Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
|
||||
|
||||
Vector3 field;
|
||||
if (!FindField(300f, 2024, out field))
|
||||
{
|
||||
Check("setup.field", false, "no open field on the navmesh");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Dictionary<string, string> refused = SetTestProfiles();
|
||||
Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
|
||||
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
|
||||
Check("setup.route", route == null, route ?? "t_loop set");
|
||||
|
||||
string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "place" } : new[] { group };
|
||||
foreach (string g in groups)
|
||||
{
|
||||
IEnumerator inner = g == "api" ? Api(field)
|
||||
: g == "hooks" ? Hooks(field)
|
||||
: g == "move" ? Move(field)
|
||||
: g == "sentry" ? Sentry(field)
|
||||
: g == "sleep" ? Sleep(field)
|
||||
: g == "place" ? Place(field)
|
||||
: null;
|
||||
if (inner == null)
|
||||
{
|
||||
Check("setup.group", false, "no group " + g);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (inner.MoveNext()) yield return inner.Current;
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
RemoveStandIns();
|
||||
yield return new WaitForSeconds(1f);
|
||||
}
|
||||
|
||||
Note($"done {group}: {_pass} pass, {_fail} fail");
|
||||
_running = null;
|
||||
}
|
||||
|
||||
// ---- api: every call's answer ----
|
||||
|
||||
private IEnumerator Api(Vector3 field)
|
||||
{
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
JObject profiles = Api<JObject>("RunicNpc_Profiles");
|
||||
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
|
||||
Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
|
||||
string warning = Api<string>("RunicNpc_CostWarning", 10);
|
||||
Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning);
|
||||
|
||||
BasePlayer npc = Spawn(field, "t_roam");
|
||||
Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null");
|
||||
if (npc == null) yield break;
|
||||
yield return new WaitForSeconds(2f);
|
||||
|
||||
Check("api.isRunicNpc", Api<bool>("RunicNpc_IsRunicNpc", npc), "");
|
||||
Check("api.profileOf", Api<string>("RunicNpc_ProfileOf", npc) == "t_roam", Api<string>("RunicNpc_ProfileOf", npc));
|
||||
Check("api.name", npc.displayName == "TRoam", npc.displayName);
|
||||
Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}");
|
||||
Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving);
|
||||
Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc);
|
||||
HeldEntity held = npc.GetHeldEntity();
|
||||
Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held");
|
||||
Dictionary<string, object> d = Describe(npc);
|
||||
Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed");
|
||||
|
||||
BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } }));
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null");
|
||||
|
||||
Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, "");
|
||||
Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, "");
|
||||
Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, "");
|
||||
Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, "");
|
||||
Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, "");
|
||||
Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air");
|
||||
|
||||
int listed = List(Owner).Count;
|
||||
Check("api.listOwner", listed == 2, "listed " + listed);
|
||||
Check("api.despawn", Api<int>("RunicNpc_Despawn", ov.net.ID.Value) == 1, "");
|
||||
Check("api.despawnOwner", Api<int>("RunicNpc_DespawnOwner", Owner) == 1, "");
|
||||
Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count);
|
||||
}
|
||||
|
||||
// ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ----
|
||||
|
||||
private IEnumerator Hooks(Vector3 field)
|
||||
{
|
||||
_spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear();
|
||||
BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0);
|
||||
BasePlayer npc = Spawn(field, "t_roam");
|
||||
yield return new WaitForSeconds(1f);
|
||||
Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray()));
|
||||
|
||||
// 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection).
|
||||
float before = npc.health;
|
||||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f));
|
||||
float body = before - npc.health;
|
||||
Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)");
|
||||
|
||||
// To below half: the 0.5 threshold fires, once.
|
||||
while (npc.health / npc.MaxHealth() > 0.45f)
|
||||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f));
|
||||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f));
|
||||
Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray()));
|
||||
|
||||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
|
||||
yield return new WaitForSeconds(1f);
|
||||
Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray()));
|
||||
Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f,
|
||||
string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray()));
|
||||
Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray()));
|
||||
Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count);
|
||||
|
||||
BasePlayer second = Spawn(field, "t_roam");
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Api<int>("RunicNpc_Despawn", second.net.ID.Value);
|
||||
Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray()));
|
||||
}
|
||||
|
||||
// ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ----
|
||||
|
||||
private IEnumerator Move(Vector3 field)
|
||||
{
|
||||
// A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m).
|
||||
StandIn(field + new Vector3(0, 0, 100), 1);
|
||||
|
||||
BasePlayer wander = Spawn(field, "t_roam");
|
||||
BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route");
|
||||
Vector3 monument;
|
||||
string monumentName;
|
||||
BasePlayer mon = null;
|
||||
if (FindMonument(out monument, out monumentName))
|
||||
{
|
||||
StandIn(monument + new Vector3(0, 0, 100), 2);
|
||||
mon = Spawn(monument, "t_mon");
|
||||
}
|
||||
|
||||
Vector3 home = wander.transform.position;
|
||||
Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero;
|
||||
float wanderMax = 0f, wanderMoved = 0f, monMax = 0f;
|
||||
Route loop = JObject.FromObject(Api<JObject>("RunicNpc_Routes")["t_loop"]).ToObject<Route>();
|
||||
var reached = new bool[loop.points.Count];
|
||||
var order = new List<int>();
|
||||
|
||||
for (int t = 0; t < 90; t++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
if (wander != null && !wander.IsDestroyed)
|
||||
{
|
||||
float d = Flat(wander.transform.position, home);
|
||||
wanderMax = Mathf.Max(wanderMax, d);
|
||||
wanderMoved = Mathf.Max(wanderMoved, d);
|
||||
}
|
||||
|
||||
if (route != null && !route.IsDestroyed)
|
||||
for (int i = 0; i < loop.points.Count; i++)
|
||||
if (Flat(route.transform.position, loop.points[i].V) < 2.5f)
|
||||
{
|
||||
reached[i] = true;
|
||||
if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i);
|
||||
}
|
||||
|
||||
if (mon != null && !mon.IsDestroyed)
|
||||
monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome));
|
||||
}
|
||||
|
||||
Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot");
|
||||
Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15");
|
||||
Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray()));
|
||||
bool inOrder = true;
|
||||
for (int i = 1; i < order.Count; i++)
|
||||
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
|
||||
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
|
||||
if (mon != null)
|
||||
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
|
||||
else
|
||||
Check("move.monument.roams", false, "no monument with an AI zone");
|
||||
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
RemoveStandIns();
|
||||
|
||||
// The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back.
|
||||
BasePlayer leash = Spawn(field, "t_leash");
|
||||
yield return new WaitForSeconds(1f);
|
||||
Vector3 leashHome = leash.transform.position;
|
||||
StandIn(leashHome + new Vector3(25, 0, 0), 3);
|
||||
float leashMax = 0f;
|
||||
for (int t = 0; t < 40; t++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
if (leash == null || leash.IsDestroyed) break;
|
||||
leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome));
|
||||
}
|
||||
|
||||
Dictionary<string, object> d2 = Describe(leash);
|
||||
int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1;
|
||||
Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)");
|
||||
Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10");
|
||||
Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach");
|
||||
}
|
||||
|
||||
// ---- sentry: holds its spot, shoots, and may stand off the navmesh ----
|
||||
|
||||
private IEnumerator Sentry(Vector3 field)
|
||||
{
|
||||
BasePlayer sentry = Spawn(field, "t_sentry");
|
||||
yield return new WaitForSeconds(1f);
|
||||
Vector3 spot = sentry.transform.position;
|
||||
_hitsOnStandIns = 0;
|
||||
StandIn(spot + new Vector3(12, 0, 0), 4);
|
||||
float moved = 0f;
|
||||
for (int t = 0; t < 40; t++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
moved = Mathf.Max(moved, Flat(sentry.transform.position, spot));
|
||||
}
|
||||
|
||||
Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off");
|
||||
Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in");
|
||||
Dictionary<string, object> d = Describe(sentry);
|
||||
Note("sentry state: " + (d != null ? d["state"] : "?"));
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
RemoveStandIns();
|
||||
|
||||
// A player-built floor 4 m up, then a spot truly off the navmesh.
|
||||
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f));
|
||||
if (floor == null)
|
||||
{
|
||||
Check("sentry.offMesh.floor", false, "the floor prefab made nothing");
|
||||
yield break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
floor.enableSaving = false;
|
||||
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
|
||||
StabilityEntity stable = floor as StabilityEntity;
|
||||
if (stable != null) stable.grounded = true;
|
||||
floor.Spawn();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Check("sentry.offMesh.floor", false, e.ToString());
|
||||
yield break;
|
||||
}
|
||||
|
||||
_built.Add(floor);
|
||||
|
||||
// Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long
|
||||
// does Rust's navmesh take to cover it?
|
||||
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
|
||||
float spawnedAt = Time.realtimeSinceStartup;
|
||||
bool covered = false;
|
||||
int frames = 0;
|
||||
while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f)
|
||||
{
|
||||
NavMeshHit onTop;
|
||||
covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f;
|
||||
if (!covered)
|
||||
{
|
||||
frames++;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (floor == null || floor.IsDestroyed)
|
||||
{
|
||||
Check("sentry.floor", false, "the floor did not stay up");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Check("sentry.floor.meshCovers", covered, covered
|
||||
? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)"
|
||||
: "not covered within 10 s");
|
||||
BasePlayer roamer = Spawn(top, "t_roam");
|
||||
Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f),
|
||||
roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused");
|
||||
if (roamer != null) Api<int>("RunicNpc_Despawn", roamer.net.ID.Value);
|
||||
|
||||
// Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays.
|
||||
Vector3 air = field + new Vector3(0f, 40f, -20f);
|
||||
Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up");
|
||||
BasePlayer up = Spawn(air, "t_sentry");
|
||||
Check("sentry.offMesh.sentryPlaced", up != null, "");
|
||||
if (up != null)
|
||||
{
|
||||
StandIn(field + new Vector3(0, 0, 100), 5);
|
||||
yield return new WaitForSeconds(20f);
|
||||
bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f;
|
||||
Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ----
|
||||
|
||||
private IEnumerator Sleep(Vector3 field)
|
||||
{
|
||||
BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6);
|
||||
BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } }));
|
||||
yield return new WaitForSeconds(1f);
|
||||
Vector3 home = npc.transform.position;
|
||||
|
||||
// Let it wander off its spot.
|
||||
float away = 0f;
|
||||
for (int t = 0; t < 60 && away < 8f; t++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
away = Flat(npc.transform.position, home);
|
||||
}
|
||||
|
||||
Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave");
|
||||
keeper.Kill();
|
||||
_standIns.Remove(keeper);
|
||||
|
||||
string rest = "";
|
||||
bool sawGoingHome = false;
|
||||
float maxStep = 0f;
|
||||
Vector3 last = npc.transform.position;
|
||||
for (int t = 0; t < 90; t++)
|
||||
{
|
||||
yield return new WaitForSeconds(1f);
|
||||
maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last));
|
||||
last = npc.transform.position;
|
||||
rest = (string)Describe(npc)["rest"];
|
||||
if (rest == "GoingHome") sawGoingHome = true;
|
||||
if (rest == "Asleep") break;
|
||||
}
|
||||
|
||||
float fromHome = Flat(npc.transform.position, home);
|
||||
Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome);
|
||||
Check("sleep.asleep", rest == "Asleep", "rest=" + rest);
|
||||
Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home");
|
||||
Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second");
|
||||
|
||||
// Still for 10 s asleep.
|
||||
Vector3 sleptAt = npc.transform.position;
|
||||
yield return new WaitForSeconds(10f);
|
||||
Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, "");
|
||||
|
||||
StandIn(field + new Vector3(0, 0, 100), 7);
|
||||
yield return new WaitForSeconds(4f);
|
||||
rest = (string)Describe(npc)["rest"];
|
||||
Check("sleep.wakes", rest == "Awake", "rest=" + rest);
|
||||
}
|
||||
|
||||
// ---- place: each and group respawn, a missing profile waits, a missing route waits ----
|
||||
|
||||
private IEnumerator Place(Vector3 field)
|
||||
{
|
||||
Vector3 a = field + new Vector3(-40, 0, 0);
|
||||
Vector3 b = field + new Vector3(-40, 0, 30);
|
||||
StandIn(field + new Vector3(-40, 0, 110), 8);
|
||||
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, "");
|
||||
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, "");
|
||||
yield return new WaitForSeconds(4f);
|
||||
|
||||
List<Dictionary<string, object>> each = List("placement:t_each");
|
||||
List<Dictionary<string, object>> group = List("placement:t_group");
|
||||
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
|
||||
|
||||
// each: kill one; the other two stay, and it alone returns after ~20 s.
|
||||
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));
|
||||
Kill((ulong)each[0]["netId"]);
|
||||
yield return new WaitForSeconds(2f);
|
||||
Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count);
|
||||
yield return new WaitForSeconds(22f);
|
||||
List<Dictionary<string, object>> after = List("placement:t_each");
|
||||
Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}");
|
||||
|
||||
// group: kill one; nothing returns while two live. Kill the rest; all three return together.
|
||||
Kill((ulong)group[0]["netId"]);
|
||||
yield return new WaitForSeconds(24f);
|
||||
Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count);
|
||||
foreach (Dictionary<string, object> x in List("placement:t_group")) Kill((ulong)x["netId"]);
|
||||
yield return new WaitForSeconds(10f);
|
||||
Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count);
|
||||
yield return new WaitForSeconds(14f);
|
||||
Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count);
|
||||
|
||||
// D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return.
|
||||
SetTestProfiles(withEach: false);
|
||||
yield return new WaitForSeconds(2f);
|
||||
Dictionary<string, object> row = Row("t_each");
|
||||
Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row");
|
||||
SetTestProfiles();
|
||||
yield return new WaitForSeconds(4f);
|
||||
Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count);
|
||||
|
||||
JObject waitRoute = Placement("t_roam", a, 1, 20f, "each");
|
||||
waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f });
|
||||
Check("place.set.routeOverride", Api<string>("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, "");
|
||||
yield return new WaitForSeconds(2f);
|
||||
row = Row("t_waitroute");
|
||||
Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row");
|
||||
Api<bool>("RunicNpc_RemovePlacement", "t_waitroute");
|
||||
|
||||
Check("place.remove", Api<bool>("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, "");
|
||||
Check("place.refuse.badRespawnMode", Api<string>("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, "");
|
||||
|
||||
// Leave two for rnt.after, across a reload or a restart.
|
||||
Api<bool>("RunicNpc_RemovePlacement", "t_each");
|
||||
Api<string>("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each"));
|
||||
Api<string>("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each"));
|
||||
Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after");
|
||||
}
|
||||
|
||||
private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) =>
|
||||
JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });
|
||||
|
||||
private Dictionary<string, object> Row(string id) =>
|
||||
(Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).FirstOrDefault(r => (string)r["id"] == id);
|
||||
|
||||
private static void Kill(ulong netId)
|
||||
{
|
||||
BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer;
|
||||
if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a reload or restart: every placement is back to its count, every RunicNPC entity in
|
||||
/// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the
|
||||
/// saved-subclass symptom stage 1 found).
|
||||
/// </summary>
|
||||
private void After()
|
||||
{
|
||||
_pass = _fail = 0;
|
||||
List<Dictionary<string, object>> rows = Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
|
||||
foreach (Dictionary<string, object> r in rows)
|
||||
{
|
||||
JObject p = (JObject)r["placement"];
|
||||
Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}");
|
||||
}
|
||||
|
||||
var listed = new HashSet<ulong>(List(null).Select(d => (ulong)d["netId"]));
|
||||
List<BasePlayer> world = BaseNetworkable.serverEntities.OfType<BasePlayer>().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList();
|
||||
Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}");
|
||||
|
||||
int strays = 0;
|
||||
foreach (Dictionary<string, object> r in rows)
|
||||
{
|
||||
Vector3 at = V(((JObject)r["placement"])["position"].ToObject<Dictionary<string, object>>());
|
||||
strays += BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f);
|
||||
}
|
||||
|
||||
Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots");
|
||||
Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner);
|
||||
Note($"after: {_pass} pass, {_fail} fail");
|
||||
}
|
||||
|
||||
/// <summary>A route, as RunicNpc_Routes returns it.</summary>
|
||||
private sealed class Route
|
||||
{
|
||||
public List<P> points = new List<P>();
|
||||
public bool loop;
|
||||
}
|
||||
|
||||
private sealed class P
|
||||
{
|
||||
public float x, y, z;
|
||||
public Vector3 V => new Vector3(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
tools/fieldlist/FieldList.csproj
Normal file
18
tools/fieldlist/FieldList.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Generates the swap's field list (docs/runicnpc/PLAN.md D232) from Rust's unmodified
|
||||
Assembly-CSharp.dll. A developer tool: never shipped, not built by CI. See Program.cs.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
98
tools/fieldlist/Program.cs
Normal file
98
tools/fieldlist/Program.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232).
|
||||
//
|
||||
// node tools/managed.js carbon <dir> (once per Rust update)
|
||||
// dotnet run --project tools/fieldlist -- <dir> [plugin/RunicNPC.cs]
|
||||
//
|
||||
// <dir> must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields
|
||||
// public, so its Assembly-CSharp.dll no longer says what Rust itself serialises.
|
||||
//
|
||||
// The rule is stage 1's, which is Unity's own for a component's authored data: every instance
|
||||
// field from the component's type down to (not including) MonoBehaviour that is public and not
|
||||
// [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to
|
||||
// the unmodified assembly, it picks the same fields on both frameworks. The result replaces the
|
||||
// block between the two marker lines in the plugin, and the plugin resolves each name at load.
|
||||
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
if (args.Length < 1)
|
||||
{
|
||||
Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- <managed dir> [plugin/RunicNPC.cs]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string managed = args[0];
|
||||
string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs");
|
||||
|
||||
string[] dlls = Directory.GetFiles(managed, "*.dll");
|
||||
var resolver = new PathAssemblyResolver(dlls);
|
||||
using var context = new MetadataLoadContext(resolver, "mscorlib");
|
||||
Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll"));
|
||||
|
||||
List<string> Fields(string typeName)
|
||||
{
|
||||
Type type = rust.GetType(typeName, throwOnError: true)!;
|
||||
var names = new List<string>();
|
||||
for (Type? t = type; t != null && t.FullName != "UnityEngine.MonoBehaviour"; t = t.BaseType)
|
||||
{
|
||||
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (f.IsInitOnly || f.IsLiteral)
|
||||
continue;
|
||||
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) ||
|
||||
f.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField");
|
||||
if (!serialised || IsDelegate(f.FieldType))
|
||||
continue;
|
||||
names.Add($"{t.Name}.{f.Name}");
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
static bool IsDelegate(Type t)
|
||||
{
|
||||
for (Type? b = t; b != null; b = b.BaseType)
|
||||
if (b.FullName == "System.Delegate")
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
List<string> npc = Fields("ScientistNPC");
|
||||
List<string> brain = Fields("ScientistBrain");
|
||||
|
||||
Guid mvid = rust.ManifestModule.ModuleVersionId;
|
||||
|
||||
const string Open = " // <fieldlist>";
|
||||
const string Close = " // </fieldlist>";
|
||||
|
||||
var block = new StringBuilder();
|
||||
block.AppendLine(Open);
|
||||
block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}.");
|
||||
block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232).");
|
||||
Append(block, "SwapNpcFields", npc);
|
||||
Append(block, "SwapBrainFields", brain);
|
||||
block.Append(Close);
|
||||
|
||||
static void Append(StringBuilder sb, string name, List<string> fields)
|
||||
{
|
||||
sb.AppendLine($" private static readonly string[] {name} =");
|
||||
sb.AppendLine(" {");
|
||||
foreach (string f in fields)
|
||||
sb.AppendLine($" \"{f}\",");
|
||||
sb.AppendLine(" };");
|
||||
}
|
||||
|
||||
string text = File.ReadAllText(plugin);
|
||||
string nl = text.Contains("\r\n") ? "\r\n" : "\n";
|
||||
int start = text.IndexOf(Open, StringComparison.Ordinal);
|
||||
int end = text.IndexOf(Close, StringComparison.Ordinal);
|
||||
if (start < 0 || end < start)
|
||||
{
|
||||
Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl);
|
||||
File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]);
|
||||
Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid}).");
|
||||
return 0;
|
||||
49
tools/managed.js
Normal file
49
tools/managed.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory,
|
||||
// for tools/fieldlist, which reads Rust's own field declarations from them (D232).
|
||||
//
|
||||
// node tools/managed.js <rig> <out dir>
|
||||
//
|
||||
// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public,
|
||||
// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the
|
||||
// assembly as Facepunch shipped it.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { load, serverId, unmsys } = require('./panel')
|
||||
|
||||
const config = load()
|
||||
const [rig, out] = process.argv.slice(2).map(unmsys)
|
||||
if (!rig || !out) {
|
||||
console.error('usage: node tools/managed.js <rig> <out dir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const base = `${config.panel}/api/client/servers/${serverId(config, rig)}`
|
||||
const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' }
|
||||
const dir = '/RustDedicated_Data/Managed'
|
||||
|
||||
async function main() {
|
||||
const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers })
|
||||
if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`)
|
||||
const files = (await list.json()).data
|
||||
.map((f) => f.attributes)
|
||||
.filter((f) => f.is_file && f.name.endsWith('.dll'))
|
||||
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
let bytes = 0
|
||||
for (const f of files) {
|
||||
const target = path.join(out, f.name)
|
||||
if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue
|
||||
const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers })
|
||||
if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`)
|
||||
const body = await fetch((await link.json()).attributes.url)
|
||||
fs.writeFileSync(target, Buffer.from(await body.arrayBuffer()))
|
||||
bytes += f.size
|
||||
}
|
||||
console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message || e)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user