Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 340f1dc8f5 | |||
| 9424dca6d1 | |||
| bf2940b819 | |||
| e2c0db2a5d | |||
| fe20ca41af | |||
| 9c366ab5b5 | |||
| 0a57d783ca | |||
| 15120ef80d | |||
| f8084955e8 | |||
| 914047f1b0 | |||
| 18c90961ba |
@@ -38,9 +38,12 @@
|
||||
# sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs
|
||||
# on is the API version other plugins call, from plugin.toml.
|
||||
#
|
||||
# 4. NO BUNDLE DISPATCH, YET. The installer does not know RunicNPC until stage 4
|
||||
# makes it a third artefact of the Rust bundle (D224). That stage adds the
|
||||
# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose.
|
||||
# 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third
|
||||
# artefact (D224): the installer's compose job carries the latest RunicNPC
|
||||
# release that answers the API the bridge declares (Rust-Plugins' overlay.toml
|
||||
# `runicnpc_api`). The last step here asks RunicGateway/installer to
|
||||
# recompose, as Rust-Plugins' release does; the installer's nightly cron covers
|
||||
# a dispatch that did not arrive.
|
||||
#
|
||||
# The version is stamped into the shipped copy's `[Info(…)]` attribute, so
|
||||
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
|
||||
@@ -55,7 +58,9 @@
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or
|
||||
# the organisation's):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
|
||||
# tag and create the release.
|
||||
# tag and create the release. The final step also dispatches
|
||||
# RunicGateway/installer's bundle workflow, so the token
|
||||
# needs write on that repo too; without it that step warns.
|
||||
# REGISTRY_USER — the Gitea username that token belongs to.
|
||||
|
||||
name: Release plugin
|
||||
@@ -71,6 +76,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
INSTALLER_REPO: RunicGateway/installer
|
||||
REPO: RunicGateway/runicnpc-rust
|
||||
ARTIFACT: runicnpc
|
||||
PLUGIN: plugin/RunicNPC.cs
|
||||
@@ -474,3 +480,27 @@ jobs:
|
||||
fi
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
|
||||
# ── Recompose the installer's bundle manifest (D224) ─────────────────
|
||||
# DISPATCH, DON'T WAIT, as Rust-Plugins' release does. A dropped dispatch
|
||||
# costs latency, not correctness: the installer's nightly cron recomputes
|
||||
# the bundle from whatever the latest releases actually are.
|
||||
- name: Ask the installer repo to recompose its bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"main"}' \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
|
||||
case "$HTTP" in
|
||||
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
|
||||
403|404)
|
||||
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
|
||||
*)
|
||||
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
|
||||
esac
|
||||
|
||||
50
README.md
50
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 4.** The NPC, its profiles, placements and routes, the API other plugins call, and
|
||||
> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the
|
||||
> profiles and lists, edits and creates placements through the bridge (API 3).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -43,6 +44,26 @@ Answers at the server console and over RCON: the version, the API version, and w
|
||||
plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has
|
||||
renamed it, because neither framework reports a hook that matches nothing.
|
||||
|
||||
## In game
|
||||
|
||||
`/rnpc` in chat, and the same verbs as `rnpc.<verb>` in a console (F1, the server console or RCON).
|
||||
`/rnpc` alone lists the ones you may use. Grant `runicnpc.place` to place and manage NPCs, and
|
||||
`runicnpc.admin` for everything (it includes `runicnpc.place`); the server console has both.
|
||||
|
||||
```
|
||||
/rnpc place bandit count=3 respawn=300 mode=group move=route:gate
|
||||
> Placed bandit-1: 3 × 'bandit' (roamer, route:gate), respawn 300 s, group.
|
||||
/rnpc path record gate then walk, and at each point: /rnpc path point
|
||||
/rnpc path save loop (or back, to walk it back and forth)
|
||||
/rnpc near · /rnpc info · /rnpc rename bandit-1 gateguards · /rnpc remove
|
||||
```
|
||||
|
||||
`place` uses the spot you look at, `here` the spot you stand on; the server console gives
|
||||
`at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement
|
||||
answers with what the server's NPCs cost. On a standalone server, profiles are made from a console
|
||||
with `rnpc.profile create|set|delete`. The full list is in
|
||||
[PLAN.md §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md).
|
||||
|
||||
## For other plugins
|
||||
|
||||
Every call is prefixed `RunicNpc_` and reached through `Call`:
|
||||
@@ -53,9 +74,11 @@ 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 3, 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 (created and named as in game, from a map point),
|
||||
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 +87,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 (see `tools/rigs.example.json`). |
|
||||
| `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, 3 and 4 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,11 @@
|
||||
# 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.
|
||||
# 2 was stage 2's whole API, PLAN.md §4.
|
||||
# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
|
||||
# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md.
|
||||
api = 3
|
||||
|
||||
# ── Framework floors ─────────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
3184
plugin/RunicNPC.cs
3184
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'])
|
||||
|
||||
2063
tools/RunicNpcHarness.cs
Normal file
2063
tools/RunicNpcHarness.cs
Normal file
File diff suppressed because it is too large
Load Diff
1204
tools/RunicNpcTest.cs
Normal file
1204
tools/RunicNpcTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
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