# RunicNPC Runic Gateway's own NPC plugin for [Rust](https://rust.facepunch.com/), on **Oxide and Carbon**. Other plugins drive it through an API, and admins use it directly in game through chat commands. It works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicGateway) server the 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: v1.0.0, stage 9's release.** The NPC, its profiles, placements and routes; factions, > fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the `/rnpc` commands, other > plugins the API (version 6). On a Runic Gateway server RunicNPC is **required** (D310): the website > authors its profiles and faction table, edits its placements, and every NPC an event places is > RunicNPC's. ## Requirements - **[Kits](https://umod.org/plugins/kits)** — required. A profile names kits, and that is how every RunicNPC NPC is equipped (D217). The plugin declares `// Requires: Kits`, so neither framework loads it without Kits. - Oxide **2.0.7726** or Carbon **2.0.259**, or newer: the builds it has been loaded on (`plugin.toml`). ## Installing it On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other server: 1. Download `runicnpc-.tar.gz` and `SHA256SUMS` from this repository's [releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the tarball against it (`sha256sum -c SHA256SUMS`). 2. Copy `runicnpc/RunicNPC.cs` into `oxide/plugins/` or `carbon/plugins/`. The framework compiles and loads it on the write. `runicnpc/manifest.json`, beside it, states the release's version, commit, API version, framework floors, required plugins, and the sha256 of every file it ships. ## Checking it ``` rnpc.status ``` Answers at the server console and over RCON: the version, the API version, and which of the 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.` 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`. Every command and its permission is in [`docs/runicnpc/COMMANDS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/COMMANDS.md), and installing and running it in [`docs/runicnpc/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/INSTALL.md). ## For other plugins Every call is prefixed `RunicNpc_` and reached through `Call`: ```csharp [PluginReference] private Plugin RunicNPC; int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` The API is version 6, 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 and the faction table, placements (created and named as in game, from a map point), routes, escorts, allies and tethers, bosses, loot, 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 | Path | What | |---|---| | `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`); `RunicNpcHarness.cs`, the stage 1 and 5 measurement plugin; `RunicNpcTest.cs`, the test harness (`rnt.run all`, then `rnt.after` after a reload or restart) and stage 9's performance check (`rnt.cost`, below); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). `drill/` is the staging drill's (below). | | `.gitea/workflows/staging-drill.yml` | The staging drill, the week before each forced wipe. | ## 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=55/55 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 # the Carbon rig's RustDedicated_Data/Managed dotnet run --project tools/fieldlist -- # 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. ## The update surface: what a Rust update can break Everything RunicNPC takes from Rust's own classes for the swap and the brain, in one place. A Rust update that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9c, below) checks exactly this list against every new staging build. **Caught by** says how: *compile* means the plugin no longer compiles against the new assembly; *field list* means `tools/fieldlist`'s regenerated list differs from the plugin's (and `rnpc.status` says `missing=` or `added=` at run time); *run time* means only a running server shows it, so the drill's live half and `rnpc.status` are what catch it. | Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by | |---|---|---|---| | `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (55 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list | | `ScientistBrain` | Subclassed by `RunicNpcBrain`; the swap copies `SwapBrainFields` (32 fields of `BaseAIBrain`) | `#region The swap` | compile; field list | | `ScientistNPC` / `HumanNPC` virtuals | `displayName`, `AttackerInfo(PlayerLifeStory.DeathInfo)`, `ShotTest(float)`, `TriggerDown()`, `Hurt(HitInfo)`, `OnDied(HitInfo)` overridden | `RunicNpcPlayer` | compile | | `IAIAttack`, `IAISenses` | Re-implemented on `RunicNpcPlayer`: `IsTargetInRange`, `EngagementRange`, `GetBestTarget`, `AttackTick`, `IsTarget`, `IsThreat`, `IsFriendly`. Rust's `HumanNPC.IsTarget` is not virtual, which is why | `RunicNpcPlayer` | compile; a changed meaning only at run time | | `BaseAIBrain` | `AddStates()` and `Think(float)` overridden; `states`, `CurrentState`, `Navigator`, `Senses`, `Events` used; `AIState` values `Roam`, `Chase`, `Combat`, `TakeCover`, `Cover`, `MoveTowards`, `MoveToVector3`, `FollowPath`, `NavigateHome`, `Flee`, `Blinded` replaced or held | `RunicNpcBrain` | compile; a state Rust stops adding only at run time | | `BaseAIBrain.BasicAIState` | Subclassed six times (`WanderState`, `RnRoamState`, `RnGuardState`, `RouteState`, `PursueState`, `HoldState`): `StateEnter`, `StateThink`, `StateLeave` | `#region The NPC` | compile | | `BaseNavigator` | `SetDestination`, `Stop`, `Moving`, `Agent`, `IsOnNavMeshLink`, `NavigationSpeed` | the states | compile | | `AIMemory` / `AIBrainSenses` | `Memory.Targets`, `SetKnown`, `IsLOS`, `Entity`; `DelaySenseUpdate` | the states, the fight | compile | | `BaseCombatEntity.faction` | Our NPC's target is set to `Faction.Horror` for the length of our own shot, because `BaseProjectile.ServerUse` drops NPC-on-NPC hits otherwise (stage 5) | `RunicNpcPlayer.Mark` | run time (the drill's fight checks) | | `NPCAutoTurret` | Harmony prefixes on the private `Ignore(BasePlayer)` and `IsEntityHostile(BaseCombatEntity)`, found by reflection (D267) | `#region Factions`, `SentryPatch` | run time: `rnpc.status` says whether the patch applied | | `HackableLockedCrate` | The private `hackSeconds`, set by reflection for a loot table's locked crate (stage 7) | `#region Loot` | run time | | `RelationshipManager`, `ClanManager` | Teams and clans for allies (D257) | `#region Factions` | compile | | `Rust.Ai.Gen2.RustNavMeshHelpers` | `SamplePosition` for every placement's navmesh check | `#region Rust's facts` | compile | The **hooks** RunicNPC listens to are listed by `rnpc.status`, which names any that has never fired; one that stays silent after an update has probably been renamed (neither framework reports a hook that matches nothing). ## The staging drill Facepunch puts each Rust build on Steam's `staging` branch days before it ships, and Rust changes on the monthly forced wipe, the first Thursday. `.gitea/workflows/staging-drill.yml` works in the seven days before each forced wipe, which leaves a week to fix what it finds, and checks every new staging build in that week (PLAN.md stage 9c, D307–D309, D317, D318). It is scheduled daily and stops at once outside the week. A run by hand always goes ahead. - **Static, about a minute, no server** (`tools/drill/static.sh`): Rust's managed assemblies from the staging branch and Oxide's staging build. It asks whether Oxide has caught up with Rust (`tools/fieldlist --oxide-behind`), regenerates the swap's field list and compares it, and compiles the plugin against them. - **Live, once per build** (`tools/drill/drill.js live`): the `rust-staging` server (Oxide, a 3500 map) installs the same pair at start (`tools/drill/rig-start.sh`), loads RunicNPC, Kits and `tools/RunicNpcTest.cs`, and runs `rnt.run all`. When both 6000-map rigs are running, `rust-carbon` is stopped for it and started again. - **Report:** anything that fails or differs opens one issue for that build, labelled `staging-drill`. The last build checked is kept on the `drill` branch. A build is the manifest of Rust's Linux depot, as Steam names it. Oxide's build for a branch carries its own patched `Assembly-CSharp.dll`, and while it is older than Rust's, an Oxide server on that pair dies at boot. So a build waits, opening nothing, until Oxide catches up, for up to three days. After that it is reported as "Oxide has not caught up". To rehearse it on a pair known to match, run the workflow by hand with `branch: public`: it reports in the job summary only. By hand, from Git Bash or Linux: ```bash BRANCH=public DEPOTDOWNLOADER=/path/to/DepotDownloader bash tools/drill/static.sh # → drill-work/static.json PANEL_KEY_FILE=/path/to/key node tools/drill/drill.js live --branch public # → drill-work/live.json node tools/drill/drill.js report --state /tmp/drill.json # public: prints only ``` ## Performance: `rnt.cost` Stage 9's check (D306): 100 of RunicNPC's NPCs, idle and fighting 20 stand-in players, against the same session's empty server, on a 6000 map. It passes when they add no more than stage 1 measured, +1.6 ms idle and +5.2 ms fighting to the median frame. Load `tools/RunicNpcTest.cs` on a rig (it needs the hidden Kits kit `rnhrevolver`), back up `data/RunicNPC/` (it replaces the profiles), stop the other rig, and run `rnt.cost [seconds]`; the result is in `data/RunicNpcTest.json`. ## Releases Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a release (`.gitea/workflows/release.yml`). The version comes from Conventional Commits since the last tag. `v1.0.0` is stage 9's release, the one `module-rust` requires; it was cut by a `feat!:` commit, because the stages before it were all `feat:` and would have made it `v0.2.0`. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) — including the AI-disclosure rule — and report security problems privately as [SECURITY.md](SECURITY.md) describes. ## License GPL-3.0-or-later — see [LICENSE.md](LICENSE.md).