diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2653889 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run on Linux (the CI runner, the staging rig): a CRLF checkout breaks them. +*.sh text eol=lf diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7d32328..06ecca1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitea/workflows/staging-drill.yml b/.gitea/workflows/staging-drill.yml new file mode 100644 index 0000000..e97b88b --- /dev/null +++ b/.gitea/workflows/staging-drill.yml @@ -0,0 +1,149 @@ +# The staging drill (docs/runicnpc/PLAN.md stage 9c, D307-D309, D317, D318). +# +# Rust changes under RunicNPC on every forced wipe, and what breaks first is the subclass swap and +# the brain (README "The update surface"). Facepunch publishes each build on Steam's `staging` +# branch days before it ships. The forced wipe is monthly, the first Thursday, so the job does +# something only in the seven days before it, which leaves a week to fix what it finds (D318). It +# is scheduled daily and stops at its first step outside that week. In the week, it checks every +# new staging build: +# +# window (tools/drill/drill.js window): the week before a forced wipe? (A run by hand always is.) +# static half (tools/drill/static.sh, about a minute, no server): +# Rust's managed assemblies from Steam's staging branch and Oxide's staging build → +# has Oxide caught up · the swap's field list regenerated and compared · RunicNPC compiled. +# gate (tools/drill/drill.js gate): stop here when this build (the manifest of Rust's Linux +# depot) was already checked. +# live half (drill.js live): the `rust-staging` server installs the same pair at start, then +# RunicNPC, Kits and the test harness are loaded and `rnt.run all` runs. When both 6000-map rigs +# are running, `rust-carbon` is stopped for it and started again (D309). Skipped while Oxide is +# behind Rust: an Oxide server on that pair dies at boot. +# report (drill.js report): anything that fails or differs opens ONE issue for the build, labelled +# `staging-drill` (a later run on the same build comments on it). The result is kept on the +# `drill` branch (drill.json), like the installer's `bundles` branch. +# +# While Oxide's staging build is behind Rust's, a build waits up to three days, opening nothing; +# then it is reported as "Oxide has not caught up" (D317). +# +# Run by hand with `branch: public` to rehearse it on a pair known to match: it reports in the job +# summary only, and changes no state and opens no issue. +# +# The schedule runs only from the default branch (`main`), so it starts with the stage 9 cutover; +# before then, run it by hand on `edge`. +# +# Secrets (Settings → Actions → Secrets, the organisation's): +# PTERODACTYL_DRILL_KEY — the panel client key made for this job alone (D309). Never printed. +# REGISTRY_USER / REGISTRY_TOKEN — as release.yml: push the `drill` branch and open issues. + +name: Staging drill + +on: + schedule: + - cron: '23 10 * * *' + workflow_dispatch: + inputs: + branch: + description: 'Steam branch to drill (public = a rehearsal: job summary only)' + default: staging + force: + description: 'Check the build again even if it was checked' + type: boolean + default: false + +concurrency: + group: staging-drill + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/runicnpc-rust + +jobs: + drill: + runs-on: ubuntu-latest + # Rust's update, a 3500 map's boot and navmesh (about 18 minutes when a new Rust build + # regenerates the map), and the harness: under an hour. + timeout-minutes: 180 + env: + BRANCH: ${{ github.event.inputs.branch || 'staging' }} + WORK: ${{ github.workspace }}/drill-work + steps: + - uses: actions/checkout@v4 + + # 22 or later: drill.js talks to the panel's console over the global WebSocket. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: The week before a forced wipe? + id: window + run: node tools/drill/drill.js window ${{ github.event_name == 'workflow_dispatch' && '--force' || '' }} + + # tools/fieldlist and the compiler (Roslyn's csc.dll ships in the SDK). + - name: .NET SDK + if: ${{ steps.window.outputs.open == 'true' }} + run: | + set -euo pipefail + curl -sSL -o "$RUNNER_TEMP/dotnet-install.sh" https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh + bash "$RUNNER_TEMP/dotnet-install.sh" --channel 9.0 --install-dir "$HOME/.dotnet" > /dev/null + echo "$HOME/.dotnet" >> "$GITHUB_PATH" + echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV" + + - name: The drill branch (the last build checked) + if: ${{ steps.window.outputs.open == 'true' }} + run: | + set -euo pipefail + git config user.name "runicnpc-ci" + git config user.email "ci@whitlocktech.com" + rm -rf drill-state + git worktree prune + if git ls-remote --exit-code --heads origin drill > /dev/null 2>&1; then + git fetch origin drill + git worktree add -B drill drill-state origin/drill + else + # First run: a root commit with an empty tree, so the branch inherits no code history. + ROOT="$(git commit-tree "$(git hash-object -t tree /dev/null)" -m 'chore(drill): start the drill branch')" + git worktree add -B drill drill-state "$ROOT" + fi + [ -f drill-state/drill.json ] || echo '{}' > drill-state/drill.json + cat drill-state/drill.json + + - name: Static half + if: ${{ steps.window.outputs.open == 'true' }} + run: bash tools/drill/static.sh + + - name: A build not checked yet? + id: gate + if: ${{ steps.window.outputs.open == 'true' }} + run: node tools/drill/drill.js gate --state drill-state/drill.json --work "$WORK" ${{ github.event.inputs.force == 'true' && '--force' || '' }} + + - name: Live half (rust-staging) + id: live + if: ${{ steps.gate.outputs.run == 'true' }} + continue-on-error: true + env: + PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }} + run: node tools/drill/drill.js live --work "$WORK" --branch "$BRANCH" + + - name: Report + id: report + if: ${{ always() && steps.gate.outputs.run == 'true' }} + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: node tools/drill/drill.js report --state drill-state/drill.json --work "$WORK" + + - name: Keep the result on the drill branch + if: ${{ always() && steps.gate.outputs.run == 'true' && env.BRANCH == 'staging' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + cd drill-state + git add -A + if git diff --cached --quiet; then echo "no change"; exit 0; fi + node -e "const s=require('./drill.json').lastRun||{};console.log('chore(drill): '+(s.manifest||'?')+' '+(s.outcome||'')+' [skip ci]')" > ../drill-msg.txt + git commit -q -F ../drill-msg.txt + git push origin drill diff --git a/README.md b/README.md index 6b505de..891a161 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,11 @@ 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: 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 @@ -21,8 +24,8 @@ website authors its NPC profiles and events use its NPCs. The plan of record, st ## Installing it On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) -and the Pterodactyl egg will install it from the Rust bundle, pinned and checksummed (D224, from -stage 4). Until then, and on any other server: +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 @@ -43,6 +46,28 @@ 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.` 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`: @@ -53,9 +78,12 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("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 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 @@ -64,13 +92,100 @@ 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 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` then requires. +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 diff --git a/plugin.toml b/plugin.toml index 7d3515f..4fe8cc0 100644 --- a/plugin.toml +++ b/plugin.toml @@ -22,8 +22,18 @@ # 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. +# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the +# OnRunicNpcPlacementChanged hook. +# 4 was stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 +# fields, a placement's tether, and the OnRunicNpcEscortEnded hook. +# 5 was stage 6: the profile's `boss` and `use` blocks and the passive role, the hooks +# OnRunicNpcBossSpawned, _BossPhase, _BossDied and OnRunicNpcUsed, and `role` and `boss` in RunicNpc_List. +# Current: 6 — stage 7: the profile's `loot` block (the corpse's start, a table of always-rows and a capped +# pool, a crate, a locked crate's hack time, the corpse's time, and `dropTable` for one spawn's override), +# documented in docs/runicnpc/API.md. +api = 6 # ── Framework floors ───────────────────────────────────────────────────────── # @@ -42,6 +52,6 @@ min_carbon_version = "2.0.259" # `doctor`, which reports a missing one rather than installing it. The PR check # holds this list and the plugin's `// Requires:` lines equal. # -# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a -# profile that asks for one on a server without it is refused when it is saved. +# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6). A placement +# or an event step that asks for one on a server without it is refused (D272). requires_plugins = ["Kits"] diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index edfd207..67b29a8 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2,6 +2,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Oxide.Core; +using Oxide.Core.Plugins; +using Oxide.Game.Rust.Cui; +using UnityEngine; +using UnityEngine.AI; namespace Oxide.Plugins { @@ -9,9 +21,9 @@ namespace Oxide.Plugins /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. /// /// - /// This is the stage 0 plugin (docs/runicnpc/PLAN.md §9): it loads, answers its API version, - /// and reports on itself. It spawns nothing. Everything it will do is planned in that - /// document, stage by stage. + /// This is the stage 3 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements + /// and owners, the API other plugins call (stage 2), and the commands admins use in game + /// (stage 3, §5): /rnpc in chat and rnpc.<verb> in a console. /// /// /// @@ -20,11 +32,21 @@ namespace Oxide.Plugins /// and requires_plugins in plugin.toml are two statements of one fact, and the /// PR check holds them equal. /// + /// + /// + /// Our NPC is Rust's scientist with two components swapped (D223): the prefab's + /// ScientistNPC and ScientistBrain are replaced, before the entity spawns, by + /// and , carrying the prefab's + /// authored fields across. That swap and the brain's states are what a Rust update can + /// break, so they are kept in one place: the "the swap" and "the NPC" regions below. + /// /// [Info("RunicNPC", "Runic Gateway", "0.0.0")] [Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")] internal class RunicNPC : RustPlugin { + [PluginReference] private Plugin Kits; + /// /// The version of the API other plugins call (RunicNpc_*, PLAN.md §4). Declared /// twice, here and as api in plugin.toml, which the release copies into @@ -34,36 +56,4540 @@ namespace Oxide.Plugins /// /// It moves when a call or a raised hook changes shape, not on every release: the /// release version says what was built, this says what a caller can rely on. + /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 1; + private const int ApiVersion = 6; /// - /// Every hook this plugin declares. Both frameworks bind a hook by name and arity - /// through reflection, and neither says a word when a name matches nothing, so the - /// plugin counts its own: rnpc.status lists the ones that have never fired. The - /// list is seeded at zero, because a hook that never fired is exactly the one a - /// dictionary that learns names as they arrive could never report. + /// Every hook this plugin implements. rnpc.status reports which have fired, because + /// a hook that matches nothing is silent on both frameworks: this list is how a renamed + /// one is noticed. The PR check holds it equal to the methods below. /// private static readonly string[] ExpectedHooks = { - "OnServerInitialized" + "OnServerInitialized", + "OnServerSave", + "OnEntityKill", + "OnPluginUnloaded", + "OnEntitySpawned", + "OnEntityTakeDamage", + "CanBeTargeted", + "CanEntityTakeDamage", + "OnPluginLoaded", + "OnPlayerInput", + "OnPlayerDisconnected", + "OnCorpsePopulate" }; private readonly Dictionary _hookCounts = new Dictionary(); - // ---- lifecycle ---- + /// The loaded plugin, for the NPC and brain classes, which Rust instantiates. + private static RunicNPC _self; + + private bool _ready; + private bool _unloading; + + private static readonly Regex NameRule = new Regex("^[a-z0-9_-]{1,40}$"); + private static readonly Regex OwnerRule = new Regex(@"^(run|plugin):\S{1,64}$"); + + private const string DataProfiles = "RunicNPC/profiles"; + private const string DataPlacements = "RunicNPC/placements"; + private const string DataRoutes = "RunicNPC/routes"; + + /// How far from a placement's spot its NPCs after the first are scattered. + private const float Scatter = 3f; + + /// How far above or below a roamer's spot the navmesh it is put on may be. + private const float MaxDrop = 2f; + + #region Config + + private PluginConfig _config; + + /// + /// The plugin's config. No caps by default (D227): each is 0, meaning none, until an + /// admin sets it. The cost warning is shown instead, wherever NPCs are added. + /// + private sealed class PluginConfig + { + [JsonProperty("caps")] public CapsConfig Caps = new CapsConfig(); + + /// + /// Milliseconds per server frame the placement queue may spend spawning. Stage 1 + /// measured about 5 ms per NPC, and 100 in one frame as a half-second hitch. + /// + [JsonProperty("spawnBudgetMs")] public float SpawnBudgetMs = 8f; + } + + private sealed class CapsConfig + { + [JsonProperty("total")] public int Total; + [JsonProperty("perOwner")] public int PerOwner; + [JsonProperty("perProfile")] public int PerProfile; + [JsonProperty("spawnsPerSecond")] public float SpawnsPerSecond; + } + + protected override void LoadDefaultConfig() => _config = new PluginConfig(); + + protected override void LoadConfig() + { + base.LoadConfig(); + try + { + _config = Config.ReadObject(); + } + catch (Exception e) + { + PrintError($"The config could not be read, so the defaults are used: {e.Message}"); + _config = null; + } + + if (_config == null) + _config = new PluginConfig(); + if (_config.Caps == null) + _config.Caps = new CapsConfig(); + SaveConfig(); + } + + protected override void SaveConfig() => Config.WriteObject(_config, true); + + #endregion + + #region Data: profiles, placements, routes + + /// A position in a data file. + public sealed class Pos + { + [JsonProperty("x")] public float X; + [JsonProperty("y")] public float Y; + [JsonProperty("z")] public float Z; + + [JsonIgnore] public Vector3 V => new Vector3(X, Y, Z); + + public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z }; + } + + /// How a roamer moves (D233): wander, monument or route:<name>. + public sealed class Movement + { + [JsonProperty("mode")] public string Mode = "wander"; + [JsonProperty("radius")] public float Radius = 20f; + } + + public sealed class DamageTaken + { + [JsonProperty("head")] public float Head = 1f; + [JsonProperty("body")] public float Body = 1f; + [JsonProperty("legs")] public float Legs = 1f; + } + + public sealed class Ranges + { + [JsonProperty("sense")] public float Sense = 30f; + [JsonProperty("loseTarget")] public float LoseTarget = 40f; + [JsonProperty("chase")] public float Chase = 40f; + [JsonProperty("attack")] public float Attack = 30f; + } + + /// A named description of an NPC, in the shape the org lead approved (D238). + public sealed class Profile + { + [JsonProperty("names")] public List Names = new List(); + [JsonProperty("kits")] public List Kits = new List(); + [JsonProperty("prefab")] public string Prefab = "scientistnpc_roam"; + [JsonProperty("role")] public string Role = "roamer"; + [JsonProperty("movement")] public Movement Movement = new Movement(); + [JsonProperty("health")] public float Health = 150f; + [JsonProperty("damageDealt")] public float DamageDealt = 1f; + [JsonProperty("damageTaken")] public DamageTaken DamageTaken = new DamageTaken(); + [JsonProperty("aimCone")] public float AimCone = 2f; + [JsonProperty("ranges")] public Ranges Ranges = new Ranges(); + [JsonProperty("visionCone")] public float VisionCone = -0.8f; + [JsonProperty("sleepDistance")] public float SleepDistance = 160f; + [JsonProperty("healthThresholds")] public List HealthThresholds = new List(); + + // ---- stage 5 (D254–D261, D268, D271) ---- + + /// Its faction (D254), or null: then its allies are its own profile's NPCs (D256). + [JsonProperty("faction")] public string Faction; + + /// + /// Its own exceptions, which win over the faction table (D254): a faction, scientists, + /// animals or profile:<name>, each hostile, neutral or allied. + /// + [JsonProperty("relations")] public Dictionary Relations = new Dictionary(); + + /// Metres within which its allies join a fight one of them is in (D256); 0 is off. + [JsonProperty("alertRadius")] public float AlertRadius = 40f; + + /// default, ignore or always (D259, D264, D267). + [JsonProperty("turrets")] public string Turrets = "default"; + + /// D271: off makes it unkillable by players, on every server, PVE or not. + [JsonProperty("hurtByPlayers")] public bool HurtByPlayers = true; + + /// D271: off makes it harmless to players; it still fights NPCs and turrets. + [JsonProperty("hurtsPlayers")] public bool HurtsPlayers = true; + + /// The kit's extras it uses (D260); all off by default, and each one used is used up (D265). + [JsonProperty("kitUse")] public KitUse KitUse = new KitUse(); + + // ---- stage 6 (D273–D279) ---- + + /// The boss box (D273): null for an ordinary NPC. Any role but passive may carry it. + [JsonProperty("boss")] public BossBlock Boss; + + /// What pressing E on it does (D278); a passive profile's only, and required there. + [JsonProperty("use")] public UseBlock Use; + + // ---- stage 7 (D290–D301) ---- + + /// Its loot (§7): null keeps Rust's scientist loot and nothing more, as before stage 7. + [JsonProperty("loot")] public LootBlock Loot; + + [JsonIgnore] public bool IsSentry => Role == "sentry"; + + [JsonIgnore] public bool IsGuard => Role == "guard"; + + /// D278, D279: never fights, press E to talk; cannot be hurt unless its profile says so. + [JsonIgnore] public bool IsPassive => Role == "passive"; + } + + /// + /// D273–D277: a health bar, phases at health thresholds, and lines said in game. Phases + /// change the NPC's own copy of its profile, never the shared one. + /// + public sealed class BossBlock + { + /// D274: players within this many metres see the bar, and so does everyone who has hurt it. + [JsonProperty("barDistance")] public float BarDistance = 100f; + + /// D277: chat, or popup where PopupNotifications is loaded (chat where it is not). + [JsonProperty("announce")] public string Announce = "chat"; + + /// D286: said to everyone on the server. Blank says nothing. + [JsonProperty("spawnLine")] public string SpawnLine = ""; + + /// D286: said to everyone on the server. {killer} is the killing blow's player. + [JsonProperty("deathLine")] public string DeathLine = ""; + + [JsonProperty("phases")] public List Phases = new List(); + } + + /// D275: what happens when a boss falls to a fraction of its health. Every part is optional. + public sealed class Phase + { + [JsonProperty("at")] public float At; + + /// Replaces the profile's damageDealt from here on. + [JsonProperty("damageDealt", NullValueHandling = NullValueHandling.Ignore)] public float? DamageDealt; + + /// Replaces the profile's aimCone from here on. + [JsonProperty("aimCone", NullValueHandling = NullValueHandling.Ignore)] public float? AimCone; + + /// A multiple of Rust's own speed for the NPC: 1.3 is a third faster. + [JsonProperty("speed", NullValueHandling = NullValueHandling.Ignore)] public float? Speed; + + /// Replaces the profile's ranges from here on. + [JsonProperty("ranges", NullValueHandling = NullValueHandling.Ignore)] public Ranges Ranges; + + /// A Kits kit it swaps to: strip, give, equip (stage 6 spike, finding 3). + [JsonProperty("kit", NullValueHandling = NullValueHandling.Ignore)] public string Kit; + + [JsonProperty("adds", NullValueHandling = NullValueHandling.Ignore)] public Adds Adds; + + /// D286: said only to the players near the boss and those who have hurt it. + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] public string Line; + } + + /// D275, D287: NPCs of another profile placed around the boss. They fight on after it dies. + public sealed class Adds + { + [JsonProperty("profile")] public string Profile; + [JsonProperty("count")] public int Count = 1; + } + + /// + /// §7 (D290–D301): what the corpse starts with, a table rolled on top of it (rows that always roll and a + /// pool that picks at most N), an optional crate the table goes into instead, and the corpse's time. + /// + public sealed class LootBlock + { + /// D290: rust (Rust's scientist loot, the default), kit (what it carried), or none (D300: truly empty). + [JsonProperty("start")] public string Start = "rust"; + + /// D291: each row rolls on its own, with its chance. + [JsonProperty("always")] public List Always = new List(); + + /// D291: at most pick rows, drawn by weight; nothing is the weight of drawing none. + [JsonProperty("pool")] public LootPool Pool = new LootPool(); + + /// D292: null (the table goes in the corpse), wooden, military, elite or locked. + [JsonProperty("crate", NullValueHandling = NullValueHandling.Include)] public string Crate; + + /// D297: a locked crate's hack time, in seconds; Rust's own is 900. + [JsonProperty("hackSeconds")] public float HackSeconds = 900f; + + /// D294: null keeps Rust's time (600 s), 0 removes the corpse at once, otherwise seconds. + [JsonProperty("corpseSeconds", NullValueHandling = NullValueHandling.Include)] public float? CorpseSeconds; + + /// D296: off for an event's NPCs when its step says so; the start and the corpse's time still apply. + [JsonProperty("dropTable")] public bool DropTable = true; + } + + public sealed class LootRow + { + [JsonProperty("item")] public string Item; + [JsonProperty("min")] public int Min = 1; + [JsonProperty("max")] public int Max = 1; + + /// For an always-row: its chance, above 0 and at most 1. + [JsonProperty("chance")] public float Chance = 1f; + + /// For a pool row: its weight, above 0. + [JsonProperty("weight")] public float Weight = 1f; + + [JsonProperty("skin")] public ulong Skin; + } + + public sealed class LootPool + { + [JsonProperty("pick")] public int Pick; + [JsonProperty("nothing")] public float Nothing; + [JsonProperty("rows")] public List Rows = new List(); + } + + /// D278: a chat line picked at random from lines, or a window with a title, text and Close. + public sealed class UseBlock + { + /// chat or window. + [JsonProperty("mode")] public string Mode = "chat"; + [JsonProperty("lines")] public List Lines = new List(); + [JsonProperty("title")] public string Title = ""; + [JsonProperty("text")] public string Text = ""; + } + + /// D260: the behaviours a kit's extra items allow, each opted into. + public sealed class KitUse + { + [JsonProperty("heal")] public bool Heal; + [JsonProperty("grenades")] public bool Grenades; + [JsonProperty("melee")] public bool Melee; + [JsonProperty("rockets")] public bool Rockets; + [JsonProperty("flamethrower")] public bool Flamethrower; + + [JsonIgnore] public bool Any => Heal || Grenades || Melee || Rockets || Flamethrower; + } + + /// One row of the faction table: a pair, both ways (D268). + public sealed class FactionPair + { + [JsonProperty("a")] public string A; + [JsonProperty("b")] public string B; + [JsonProperty("relation")] public string Relation = "neutral"; + } + + private sealed class ProfileFile + { + /// True once a site has pushed profiles (D221); in-game edits are then refused. + [JsonProperty("managed")] public bool Managed; + [JsonProperty("profiles")] public Dictionary Profiles = new Dictionary(); + + /// The faction table (D254), pushed with the profiles; rnpc.faction edits it standalone. + [JsonProperty("factions")] public List Factions = new List(); + } + + /// + /// An admin's (or the site's) persistent placement (D222). Only this is saved: its NPCs + /// never are, and are spawned fresh from it at boot (§2). + /// + public sealed class Placement + { + [JsonProperty("profile")] public string Profile; + [JsonProperty("position")] public Pos Position; + [JsonProperty("yaw")] public float Yaw; + [JsonProperty("count")] public int Count = 1; + + /// Seconds from a death to the respawn. + [JsonProperty("respawn")] public float Respawn = 300f; + + /// each (the default) or group (D236). + [JsonProperty("respawnMode")] public string RespawnMode = "each"; + + /// Overrides the profile's movement for this placement only (D233); null keeps it. + [JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement; + + /// A ZoneManager zone its NPCs never leave (D272); null for none. + [JsonProperty("tether", NullValueHandling = NullValueHandling.Ignore)] public string Tether; + } + + private sealed class PlacementFile + { + [JsonProperty("placements")] public Dictionary Placements = new Dictionary(); + } + + /// + /// Points an admin records in game (stage 3) and a route:<name> roamer walks in + /// order (D233, D234). loop goes from the last point back to the first; otherwise the + /// route is walked back and forth. + /// + public sealed class Route + { + [JsonProperty("points")] public List Points = new List(); + [JsonProperty("loop")] public bool Loop = true; + } + + private sealed class RouteFile + { + [JsonProperty("routes")] public Dictionary Routes = new Dictionary(); + } + + private ProfileFile _profileFile = new ProfileFile(); + private PlacementFile _placementFile = new PlacementFile(); + private RouteFile _routeFile = new RouteFile(); + + /// The profiles that passed validation. Only these spawn. + private readonly Dictionary _profiles = new Dictionary(); + + /// The profiles that did not, and why. + private readonly Dictionary _refused = new Dictionary(); + + /// The one flag that marks placements for saving, on every change including removal (§2). + private bool _placementsDirty; + + private T ReadData(string name) where T : class, new() + { + try + { + T value = Interface.Oxide.DataFileSystem.ReadObject(name); + return value ?? new T(); + } + catch (Exception e) + { + PrintError($"data/{name}.json could not be read and is treated as empty: {e.Message}"); + return new T(); + } + } + + private void LoadData() + { + ReadProfileFile(); + _routeFile = ReadData(DataRoutes); + if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); + _placementFile = ReadData(DataPlacements); + if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary(); + + // Create the files on first boot: the plugin must make its own data directory, because one + // made from outside the game (a panel's file API) is not writable by it (PLAN.md §1.5). + SaveProfiles(); + SaveRoutes(); + SavePlacements(); + } + + private void ReadProfileFile() + { + JObject raw = ReadData(DataProfiles); + try + { + if (raw["profiles"] is JObject) + foreach (JProperty p in ((JObject)raw["profiles"]).Properties()) + NormalizePassive(p.Value as JObject); + _profileFile = raw.ToObject() ?? new ProfileFile(); + } + catch (Exception e) + { + PrintError($"data/{DataProfiles}.json could not be read and is treated as empty: {e.Message}"); + _profileFile = new ProfileFile(); + } + + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + if (_profileFile.Factions == null) _profileFile.Factions = new List(); + } + + /// + /// D279: a passive NPC cannot be hurt unless its profile says it can. hurtByPlayers is on + /// by default for every other role, so a passive profile that leaves it out is given "off" as + /// it is read, wherever a profile comes in: the file, a site's push, or rnpc.profile. + /// + private static void NormalizePassive(JObject profile) + { + if (profile != null && (string)profile["role"] == "passive" && profile["hurtByPlayers"] == null) + profile["hurtByPlayers"] = false; + } + + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); + + private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); + + private void SavePlacements() + { + Interface.Oxide.DataFileSystem.WriteObject(DataPlacements, _placementFile); + _placementsDirty = false; + } + + /// Validates every profile in the file; only the valid ones are used. + private void RevalidateProfiles() + { + _profiles.Clear(); + _refused.Clear(); + foreach (KeyValuePair kv in _profileFile.Profiles) + { + string error = NameRule.IsMatch(kv.Key) ? ValidateProfile(kv.Value) : "the name must be 1–40 of a-z, 0-9, _ and -"; + if (error == null) + _profiles[kv.Key] = kv.Value; + else + _refused[kv.Key] = error; + } + + foreach (KeyValuePair kv in _refused) + PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}"); + + string factions = ValidateFactions(_profileFile.Factions); + if (factions != null) + { + PrintWarning($"The faction table is ignored: {factions}"); + _factionTable.Clear(); + } + else + BuildFactionTable(_profileFile.Factions); + + RebuildHunts(); + } + + /// Null when the profile can spawn; otherwise the reason, for the log and the caller. + private string ValidateProfile(Profile p) + { + if (p == null) return "it is empty"; + if (p.Names == null || p.Names.Count == 0 || p.Names.Any(string.IsNullOrWhiteSpace)) + return "names: give at least one, and no blank ones"; + if (p.Kits == null || p.Kits.Count == 0) + return "kits: give at least one; Kits is how an NPC is equipped (D217)"; + foreach (string kit in p.Kits) + if (!KitExists(kit)) + return $"kits: this server has no kit '{kit}'"; + if (ResolvePrefab(p.Prefab) == null) + return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; + if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard" && p.Role != "passive") + return $"role: '{p.Role}' is not roamer, sentry, guard or passive"; + string movement = ValidateMovement(p.Movement); + if (movement != null) return movement; + if (p.Movement.Mode == "stand" && !p.IsPassive) + return "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry"; + if (!(p.Health > 0f)) return "health: must be above 0"; + if (p.DamageDealt < 0f) return "damageDealt: must not be negative"; + if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f) + return "damageTaken: head, body and legs must not be negative"; + if (p.AimCone < 0f) return "aimCone: must not be negative"; + if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f) + return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; + if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1"; + if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more"; + if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f))) + return "healthThresholds: fractions between 0 and 1"; + if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction))) + return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)"; + if (p.Relations == null) + return "relations: an object, even an empty one"; + foreach (KeyValuePair kv in p.Relations) + { + if (!IsFactionKey(kv.Key, true)) + return $"relations: '{kv.Key}' is not a faction, scientists, animals or profile:"; + if (!Relations.Contains(kv.Value)) + return $"relations.{kv.Key}: '{kv.Value}' is not hostile, neutral or allied"; + } + + if (!(p.AlertRadius >= 0f)) return "alertRadius: 0 (off) or more"; + if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always") + return $"turrets: '{p.Turrets}' is not default, ignore or always"; + if (p.KitUse == null) return "kitUse: an object, even an empty one"; + + // Stage 6 (D273–D279). + if (p.IsPassive) + { + if (p.Boss != null) return "boss: a passive NPC never fights, so it cannot be a boss"; + if (p.Use == null) return "use: a passive NPC says what pressing E on it does (D278)"; + } + + if (p.Use != null && !p.IsPassive) + return "use: only a passive NPC answers E (D278)"; + if (p.Use != null) + { + string use = ValidateUse(p.Use); + if (use != null) return use; + } + + if (p.Boss != null) + { + string boss = ValidateBoss(p.Boss); + if (boss != null) return boss; + } + + if (p.Loot != null) + { + string loot = ValidateLoot(p.Loot); + if (loot != null) return loot; + } + + return null; + } + + private const int MaxLootRows = 30; + + /// The crates a table may go into (D292), by name: Rust's prefab and its slots (stage 7 spike). + private static readonly Dictionary> LootCrates = new Dictionary> + { + ["wooden"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal_2.prefab", 6), + ["military"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal.prefab", 6), + ["elite"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_elite.prefab", 12), + ["locked"] = new KeyValuePair("assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", 24) + }; + + /// An NPC corpse's main container (its inventory's main container, which Rust gives the corpse). + private const int CorpseSlots = 24; + + private static string ValidateLootRow(LootRow r, string at, bool pool) + { + if (r == null) return $"{at}: it is empty"; + if (string.IsNullOrEmpty(r.Item) || ItemManager.FindItemDefinition(r.Item) == null) return $"{at}.item: '{r.Item}' is not an item this Rust has"; + if (r.Min < 1 || r.Max < r.Min) return $"{at}: min at least 1, and max at least min"; + if (!pool && !(r.Chance > 0f && r.Chance <= 1f)) return $"{at}.chance: above 0 and at most 1"; + if (pool && !(r.Weight > 0f)) return $"{at}.weight: above 0"; + return null; + } + + private static string ValidateLoot(LootBlock l) + { + if (l.Start != "rust" && l.Start != "kit" && l.Start != "none") return $"loot.start: '{l.Start}' is not rust, kit or none"; + if (l.Always == null) return "loot.always: a list, even an empty one"; + if (l.Pool == null || l.Pool.Rows == null) return "loot.pool: an object with rows, even an empty list"; + if (l.Always.Count > MaxLootRows || l.Pool.Rows.Count > MaxLootRows) return $"loot: at most {MaxLootRows} rows in always and in the pool"; + for (int i = 0; i < l.Always.Count; i++) + { + string e = ValidateLootRow(l.Always[i], $"loot.always[{i}]", false); + if (e != null) return e; + } + + for (int i = 0; i < l.Pool.Rows.Count; i++) + { + string e = ValidateLootRow(l.Pool.Rows[i], $"loot.pool.rows[{i}]", true); + if (e != null) return e; + } + + if (l.Pool.Pick < 0 || l.Pool.Pick > l.Pool.Rows.Count) return "loot.pool.pick: 0 to the number of pool rows"; + if (l.Pool.Nothing < 0f) return "loot.pool.nothing: 0 or more"; + + int slots = CorpseSlots; + if (l.Crate != null) + { + KeyValuePair crate; + if (!LootCrates.TryGetValue(l.Crate, out crate)) return $"loot.crate: '{l.Crate}' is not wooden, military, elite or locked"; + slots = crate.Value; + } + + if (l.Always.Count + l.Pool.Pick > slots) + return $"loot: {l.Always.Count} always-rows and {l.Pool.Pick} picked need {l.Always.Count + l.Pool.Pick} slots, and {(l.Crate != null ? "the " + l.Crate + " crate" : "the corpse")} has {slots}"; + if (!(l.HackSeconds >= 1f && l.HackSeconds <= 86400f)) return "loot.hackSeconds: 1 to 86400"; + if (l.CorpseSeconds.HasValue && !(l.CorpseSeconds.Value >= 0f)) return "loot.corpseSeconds: null (Rust's own time), 0 (none) or more"; + return null; + } + + private const int MaxLine = 256; + private const int MaxWindowText = 2000; + private const int MaxAdds = 20; + + private static string ValidateUse(UseBlock u) + { + if (u.Mode != "chat" && u.Mode != "window") + return $"use.mode: '{u.Mode}' is not chat or window"; + if (u.Mode == "chat" && (u.Lines == null || u.Lines.Count == 0 || u.Lines.Any(string.IsNullOrWhiteSpace))) + return "use.lines: a chat answer gives at least one line, and no blank ones"; + if (u.Lines != null && u.Lines.Any(l => l != null && l.Length > MaxLine)) + return $"use.lines: each line is at most {MaxLine} characters"; + if (u.Mode == "window" && string.IsNullOrWhiteSpace(u.Title) && string.IsNullOrWhiteSpace(u.Text)) + return "use: a window has a title or some text"; + if ((u.Title ?? "").Length > 80) return "use.title: at most 80 characters"; + if ((u.Text ?? "").Length > MaxWindowText) return $"use.text: at most {MaxWindowText} characters"; + return null; + } + + private string ValidateBoss(BossBlock b) + { + if (!(b.BarDistance > 0f && b.BarDistance <= 1000f)) return "boss.barDistance: above 0 and at most 1000 metres"; + if (b.Announce != "chat" && b.Announce != "popup") return $"boss.announce: '{b.Announce}' is not chat or popup"; + if ((b.SpawnLine ?? "").Length > MaxLine || (b.DeathLine ?? "").Length > MaxLine) + return $"boss: a line is at most {MaxLine} characters"; + if (b.Phases == null) return "boss.phases: a list, even an empty one"; + var seen = new HashSet(); + for (int i = 0; i < b.Phases.Count; i++) + { + Phase ph = b.Phases[i]; + string at = $"boss.phases[{i}]"; + if (ph == null) return $"{at}: it is empty"; + if (!(ph.At > 0f && ph.At < 1f)) return $"{at}.at: a fraction of its health between 0 and 1"; + if (!seen.Add(ph.At)) return $"{at}.at: two phases at {ph.At}"; + if (ph.DamageDealt.HasValue && ph.DamageDealt.Value < 0f) return $"{at}.damageDealt: must not be negative"; + if (ph.AimCone.HasValue && ph.AimCone.Value < 0f) return $"{at}.aimCone: must not be negative"; + if (ph.Speed.HasValue && !(ph.Speed.Value > 0f && ph.Speed.Value <= 5f)) return $"{at}.speed: above 0 and at most 5 (times Rust's own)"; + if (ph.Ranges != null && (!(ph.Ranges.Sense > 0f) || !(ph.Ranges.Attack > 0f) || ph.Ranges.LoseTarget < ph.Ranges.Sense || ph.Ranges.Chase < 0f)) + return $"{at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; + if (ph.Kit != null && !KitExists(ph.Kit)) return $"{at}.kit: this server has no kit '{ph.Kit}'"; + if (ph.Adds != null) + { + if (ph.Adds.Profile == null || !NameRule.IsMatch(ph.Adds.Profile)) return $"{at}.adds.profile: a profile name"; + if (ph.Adds.Count < 1 || ph.Adds.Count > MaxAdds) return $"{at}.adds.count: 1 to {MaxAdds}"; + Profile add; + if (_profileFile.Profiles.TryGetValue(ph.Adds.Profile, out add) && add != null && add.Boss != null) + return $"{at}.adds.profile: '{ph.Adds.Profile}' is a boss, and a boss does not summon bosses"; + } + + if ((ph.Line ?? "").Length > MaxLine) return $"{at}.line: at most {MaxLine} characters"; + } + + return null; + } + + // ---- the faction table (D254, D268) ---- + + /// Rust's own two factions (D254). Neither can be a profile's faction. + private static readonly HashSet BuiltInFactions = new HashSet { "scientists", "animals" }; + + private static readonly HashSet Relations = new HashSet { "hostile", "neutral", "allied" }; + + /// A faction name, one of Rust's two, or (where a profile names it) profile:<name>. + private static bool IsFactionKey(string key, bool profileAllowed) + { + if (string.IsNullOrEmpty(key)) return false; + if (BuiltInFactions.Contains(key) || NameRule.IsMatch(key)) return true; + return profileAllowed && key.StartsWith("profile:") && NameRule.IsMatch(key.Substring(8)); + } + + /// Null when the table can be used; otherwise the first problem. + private static string ValidateFactions(List pairs) + { + if (pairs == null) return "it is missing"; + var seen = new HashSet(); + for (int i = 0; i < pairs.Count; i++) + { + FactionPair f = pairs[i]; + if (f == null || !IsFactionKey(f.A, false) || !IsFactionKey(f.B, false)) + return $"factions[{i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals"; + if (f.A == f.B) + return $"factions[{i}]: a faction is always allied to itself"; + if (BuiltInFactions.Contains(f.A) && BuiltInFactions.Contains(f.B)) + return $"factions[{i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other"; + if (!Relations.Contains(f.Relation)) + return $"factions[{i}]: '{f.Relation}' is not hostile, neutral or allied"; + if (!seen.Add(PairKey(f.A, f.B))) + return $"factions[{i}]: {f.A} and {f.B} are given twice (one row is both ways, D268)"; + } + + return null; + } + + /// The faction table, by pair, either way round (D268). + private readonly Dictionary _factionTable = new Dictionary(); + + private static string PairKey(string a, string b) => string.CompareOrdinal(a, b) < 0 ? a + "|" + b : b + "|" + a; + + private void BuildFactionTable(List pairs) + { + _factionTable.Clear(); + foreach (FactionPair f in pairs) + _factionTable[PairKey(f.A, f.B)] = f.Relation; + } + + private static string ValidateMovement(Movement m) + { + if (m == null) return "movement: missing"; + string kind, route; + if (!ParseMode(m.Mode, out kind, out route)) + return $"movement.mode: '{m.Mode}' is not wander, monument, route: or (passive only) stand"; + if (kind == "wander" && !(m.Radius > 0f)) + return "movement.radius: a wanderer's radius must be above 0"; + return null; + } + + /// A sentry, or a passive NPC whose movement is stand (D288): it holds its spot, on the navmesh or off it. + private static bool Still(Profile p, Movement placementMovement) => + p.IsSentry || (p.IsPassive && (placementMovement ?? p.Movement)?.Mode == "stand"); + + private static bool ParseMode(string mode, out string kind, out string route) + { + kind = null; + route = null; + if (mode == "wander" || mode == "monument" || mode == "stand") + { + kind = mode; + return true; + } + + if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6))) + { + kind = "route"; + route = mode.Substring(6); + return true; + } + + return false; + } + + private string ValidatePlacement(Placement p) + { + if (p == null) return "it is empty"; + if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name"; + if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z)) + return "position: x, y and z"; + if (p.Count < 1) return "count: at least 1"; + if (!(p.Respawn >= 1f)) return "respawn: at least 1 second"; + if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group"; + if (p.Movement != null) + { + string movement = ValidateMovement(p.Movement); + if (movement != null) return movement; + } + + if (p.Tether != null && !TetherNameRule.IsMatch(p.Tether)) + return $"tether: '{p.Tether}' is not a ZoneManager zone id"; + + return null; + } + + /// ZoneManager's zone ids are free text; RunicNPC asks only that they be one plain word. + private static readonly Regex TetherNameRule = new Regex(@"^[A-Za-z0-9_.:-]{1,64}$"); + + private static string ValidateRoute(Route r) + { + if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two"; + for (int i = 0; i < r.Points.Count; i++) + { + NavMeshHit hit; + if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit)) + return $"points[{i}] is not on Rust's navmesh"; + } + + return null; + } + + #endregion + + #region Rust's facts: kits, prefabs, the navmesh + + private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b; + + private static Dictionary _prefabPaths; + + /// A scientist prefab's full path from its short name, read from the game's manifest. + private static string ResolvePrefab(string shortName) + { + if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_")) + return null; + if (_prefabPaths == null) + { + _prefabPaths = new Dictionary(); + // prefabProperties lists every prefab; the manifest's `entities` does not include the NPCs. + foreach (GameManifest.PrefabProperties prefab in GameManifest.Current.prefabProperties) + { + string path = prefab.name; + if (!path.StartsWith("assets/rust.ai/agents/npcplayer/humannpc/") || !path.EndsWith(".prefab")) + continue; + string name = path.Substring(path.LastIndexOf('/') + 1); + _prefabPaths[name.Substring(0, name.Length - ".prefab".Length)] = path; + } + } + + string full; + return _prefabPaths.TryGetValue(shortName, out full) ? full : null; + } + + /// + /// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2 + /// RustNavMeshAgent, and Unity's NavMesh queries see none of the open world. + /// + private static bool SampleNavmesh(Vector3 p, float radius, out NavMeshHit hit) => + Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); + + /// False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4). + private static bool NavmeshReady() => + Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt(); + + private static string Framework() => + AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide"; + + #endregion + + #region The swap (D223, D232) + + // + // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 5c6eb1c7-ffa2-4b1e-a218-0a66bcc56f9e. + // Do not edit by hand: regenerate after a Rust update (D232). + private static readonly string[] SwapNpcFields = + { + "ScientistNPC.RadioChatterEffects", + "ScientistNPC.DeathEffects", + "ScientistNPC.deathStatName", + "ScientistNPC.IdleChatterRepeatRange", + "ScientistNPC.radioChatterType", + "HumanNPC.AdditionalLosBlockingLayer", + "HumanNPC.LootSpawnSlots", + "HumanNPC.aimConeScale", + "HumanNPC.lastDismountTime", + "NPCPlayer.MovementTickStartDelay", + "NPCPlayer.VirtualInfoZone", + "NPCPlayer.finalDestination", + "NPCPlayer.loadouts", + "NPCPlayer.movementMask", + "NPCPlayer.LegacyNavigation", + "NPCPlayer.NavAgent", + "NPCPlayer.damageScale", + "NPCPlayer.shortRange", + "NPCPlayer.attackLengthMaxShortRangeScale", + "BasePlayer.debugCommandedNpcs", + "BasePlayer.GestureViewModel", + "BasePlayer.lastPlayerVisibility", + "BasePlayer.eyeHistory", + "BasePlayer.tickHistory", + "BasePlayer.fallDamageEffect", + "BasePlayer.drownEffect", + "BasePlayer.Belt", + "BaseCombatEntity.spawnDeployableCorpseOnDeath", + "BaseCombatEntity.deployableCorpsePrefab", + "BaseCombatEntity.skeletonProperties", + "BaseCombatEntity.baseProtection", + "BaseCombatEntity.startHealth", + "BaseCombatEntity.pickup", + "BaseCombatEntity.repair", + "BaseCombatEntity.ShowHealthInfo", + "BaseCombatEntity.showHealthInfoThreshold", + "BaseCombatEntity.lifestate", + "BaseCombatEntity.sendsHitNotification", + "BaseCombatEntity.sendsMeleeHitNotification", + "BaseCombatEntity.markAttackerHostile", + "BaseCombatEntity.faction", + "BaseEntity.faces", + "BaseEntity.bounds", + "BaseEntity.impactEffect", + "BaseEntity.enableSaving", + "BaseEntity.syncPosition", + "BaseEntity.model", + "BaseEntity.flags", + "BaseEntity.HasBrain", + "BaseNetworkable.postNetworkUpdateComponents", + "BaseNetworkable.prefabID", + "BaseNetworkable.globalBroadcast", + "BaseNetworkable.globalNetworkBehavior", + "BaseNetworkable.globalBuildingBlock", + "BaseNetworkable.networkRange", + }; + private static readonly string[] SwapBrainFields = + { + "BaseAIBrain.SendClientCurrentState", + "BaseAIBrain.UseQueuedMovementUpdates", + "BaseAIBrain.AllowedToSleep", + "BaseAIBrain.DefaultDesignSO", + "BaseAIBrain.Designs", + "BaseAIBrain.InstanceSpecificDesign", + "BaseAIBrain.SenseRange", + "BaseAIBrain.AttackRangeMultiplier", + "BaseAIBrain.TargetLostRange", + "BaseAIBrain.VisionCone", + "BaseAIBrain.CheckVisionCone", + "BaseAIBrain.CheckLOS", + "BaseAIBrain.IgnoreNonVisionSneakers", + "BaseAIBrain.IgnoreSneakersMaxDistance", + "BaseAIBrain.IgnoreNonVisionMaxDistance", + "BaseAIBrain.ListenRange", + "BaseAIBrain.SenseTypes", + "BaseAIBrain.HostileTargetsOnly", + "BaseAIBrain.IgnoreSafeZonePlayers", + "BaseAIBrain.MaxGroupSize", + "BaseAIBrain.MemoryDuration", + "BaseAIBrain.RefreshKnownLOS", + "BaseAIBrain.CanBeBlinded", + "BaseAIBrain.BlindDurationMultiplier", + "BaseAIBrain.MovementTickStartDelay", + "BaseAIBrain.ClientCurrentState", + "BaseAIBrain.mainInterestPoint", + "BaseAIBrain.UseAIDesign", + "BaseAIBrain.Pet", + "BaseAIBrain.CanUseHealingItems", + "BaseAIBrain.HealChance", + "BaseAIBrain.HealBelowHealthFraction", + }; + // + + private FieldInfo[] _npcFields; + private FieldInfo[] _brainFields; + private readonly List _fieldsMissing = new List(); + private readonly List _fieldsAdded = new List(); + + /// + /// Resolves the generated field list against the running game. A name Rust has since + /// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the + /// list is also checked against the rule it was generated with, so a field Rust has + /// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes + /// private fields public. + /// + private void ResolveFields() + { + _fieldsMissing.Clear(); + _fieldsAdded.Clear(); + _npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing); + _brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing); + + if (Framework() == "carbon") + { + _fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields)); + _fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields)); + } + + if (_fieldsMissing.Count > 0) + PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist)."); + if (_fieldsAdded.Count > 0) + PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist)."); + } + + private static FieldInfo[] ResolveFields(Type type, string[] names, List missing) + { + var chain = new List(); + for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType) + chain.Add(t); + + var found = new List(); + foreach (string name in names) + { + int dot = name.IndexOf('.'); + string typeName = name.Substring(0, dot); + Type owner = chain.FirstOrDefault(t => t.Name == typeName); + FieldInfo f = owner?.GetField(name.Substring(dot + 1), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (f == null) + missing.Add(name); + else + found.Add(f); + } + + return found.ToArray(); + } + + /// tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly. + private static List RuleFields(Type type) + { + var names = new List(); + for (Type t = type; t != null && t != typeof(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.IsDefined(typeof(SerializeField), false); + if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType)) + names.Add($"{t.Name}.{f.Name}"); + } + } + + return names; + } + + private static void CopyFields(FieldInfo[] fields, Component from, Component to) + { + foreach (FieldInfo f in fields) + f.SetValue(to, f.GetValue(from)); + } + + /// + /// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components + /// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1, + /// Q3: Senses.Init copies the sense values a single time). + /// + private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, bool still, out string error) + { + error = null; + BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false); + ScientistNPC stock = made as ScientistNPC; + ScientistBrain stockBrain = stock != null ? stock.GetComponent() : null; + if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain)) + { + error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)"; + if (made != null) UnityEngine.Object.Destroy(made.gameObject); + return null; + } + + GameObject go = stock.gameObject; + RunicNpcPlayer npc = go.AddComponent(); + RunicNpcBrain brain = go.AddComponent(); + CopyFields(_npcFields, stock, npc); + CopyFields(_brainFields, stockBrain, brain); + UnityEngine.Object.DestroyImmediate(stock, true); + UnityEngine.Object.DestroyImmediate(stockBrain, true); + + npc.enableSaving = false; + npc.startHealth = p.Health; + npc.damageScale = p.DamageDealt; + npc.aimConeScale = p.AimCone; + + brain.SenseRange = p.Ranges.Sense; + brain.TargetLostRange = p.Ranges.LoseTarget; + brain.VisionCone = p.VisionCone; + + // Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep. + brain.AllowedToSleep = false; + // A sentry, and a passive NPC that stands (D288), hold their spot and turn. + brain.Sentry = still; + return npc; + } + + #endregion + + #region The NPC + + /// + /// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the + /// victim's death screen, its attack range, and damage taken by body part. + /// + public class RunicNpcPlayer : ScientistNPC, IAIAttack, IAISenses + { + public string RnName = "Scientist"; + public string ProfileName; + public Profile Profile; + public string Owner; + public string PlacementId; + public int Slot = -1; + public Vector3 Home; + public float HomeYaw; + public string MoveKind = "wander"; + public float MoveRadius = 20f; + public string RouteName; + public string Kit; + public bool Died; + public readonly Dictionary Contributors = new Dictionary(); + public readonly HashSet ThresholdsFired = new HashSet(); + + public ulong NetId => net != null ? net.ID.Value : 0UL; + + public RunicNpcBrain RnBrain => Brain as RunicNpcBrain; + + public override string displayName => RnName; + + /// + /// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's + /// death screen, after BasePlayer's display name (stage 1, Q6). Ours puts the name back. + /// + public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) + { + base.AttackerInfo(info); + info.attackerName = displayName; + } + + /// + /// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through + /// IAIAttack, which this class re-implements, so the call lands here. + /// + public new bool IsTargetInRange(BaseEntity entity, out float dist) + { + dist = Vector3.Distance(entity.transform.position, transform.position); + return dist <= EngagementRange(); + } + + public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + + /// + /// D279: a passive NPC never fights. Rust's design picks its target from every player it has + /// sensed (HumanNPC.GetBestTarget reads Senses.Players, which IsTarget + /// does not filter) and shoots through AttackTick; both come through IAIAttack, which + /// this class re-implements, so a passive one has no target and no attack. + /// + public new BaseEntity GetBestTarget() => Profile != null && Profile.IsPassive ? null : base.GetBestTarget(); + + public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS) + { + if (Profile != null && Profile.IsPassive) + return; + base.AttackTick(delta, target, targetIsLOS); + } + + // ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ---- + + /// + /// Its own target: an NPC, an animal or a turret. Rust's design still owns every fight with a + /// player; this one is fought by when the design has none. + /// + public BaseCombatEntity NpcTarget; + + /// True while it fights : its roam state stands aside (D262). + public bool Engaged; + + /// Whom it escorts (D258, D269), or null. Its leash and home follow it. + public BaseEntity Escort; + + /// The clan, team or lone player it is allied to (D257, D270); 0 for none. + public long AllyClan; + public ulong AllyTeam; + public ulong AllyPlayer; + + /// The ZoneManager zone it never leaves (D272), or null. + public string Tether; + + public int HuntsVersion = -1; + public HashSet Hunts; + public float NextSense, NextLos, NextMove, NextAlert, NextDefend; + public bool TargetLos; + public float LastHurtAt = float.NegativeInfinity; + public float NextHeal, NextThrow, NextRocket, KitBusyUntil; + public int Heals, Throws, Rockets, MeleeHits, ShotsMarked; + + public bool HasAlly => AllyClan != 0L || AllyTeam != 0UL || AllyPlayer != 0UL; + + /// Where its leash is measured from: the escorted entity while it has one, else its spot. + public Vector3 Anchor => Escort != null && !Escort.IsDestroyed ? Escort.transform.position : Home; + + /// + /// Rust's HumanNPC.IsTarget (players, pets, scarecrows), less anyone it is allied to or + /// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here. + /// + public new bool IsTarget(BaseEntity entity) => + (Profile == null || !Profile.IsPassive) && base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + + // ---- stage 6: the boss (D273–D277) ---- + + /// The phases it has entered, by their at; each is entered once. + public readonly HashSet PhasesFired = new HashSet(); + + /// The phase it is in: 0 before the first. + public int Phase; + + /// Rust's own navigator speed at spawn, which a phase's speed multiplies. + public float BaseSpeed = -1f; + + /// Its bar needs a redraw: set on every hit, cleared by the next redraw (at most four a second). + public bool BarDirty; + + public bool IsBoss => Profile != null && Profile.Boss != null; + + public new bool IsThreat(BaseEntity entity) => IsTarget(entity); + + public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity); + + private BaseCombatEntity _marked; + private BaseCombatEntity.Faction _markedWas; + + /// + /// Rust drops every hit an NPC's bullet makes on another NPC unless the victim's faction is + /// Horror (BaseProjectile.ServerUse, stage 5 spike). So its own NPC target alone is + /// made Horror for the length of its own shot, then put back. ShotTest calls TriggerDown + /// inside itself: a mark is made only when none is in place, or the inner call would save + /// Horror as the faction to restore and leave the target Horror for everyone's bullets. + /// + private bool Mark() + { + if (_marked != null) + return false; + BaseCombatEntity t = NpcTarget; + if (!Engaged || t == null || t.IsDestroyed || !t.IsNpc) + return false; + _marked = t; + _markedWas = t.faction; + t.faction = BaseCombatEntity.Faction.Horror; + ShotsMarked++; + return true; + } + + private void Unmark(bool marked) + { + if (!marked) + return; + if (_marked != null && !_marked.IsDestroyed) + _marked.faction = _markedWas; + _marked = null; + } + + /// A single-shot weapon fires inside ShotTest, a burst weapon from TriggerDown. + public override bool ShotTest(float targetDist) + { + if (_self != null && !_self.FuelExtra(this)) + return false; + bool marked = Mark(); + try { return base.ShotTest(targetDist); } + finally { Unmark(marked); } + } + + public override void TriggerDown() + { + bool marked = Mark(); + try { base.TriggerDown(); } + finally { Unmark(marked); } + } + + public override void Hurt(HitInfo info) + { + if (Profile != null && info != null) + { + // A hit with no bone (fire, an explosion, a fall) reports every area at once, + // (HitArea)(-1), so only an exact area counts as head or legs; the rest is body. + HitArea area = info.boneArea; + float scale = area == HitArea.Head ? Profile.DamageTaken.Head + : area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs + : Profile.DamageTaken.Body; + if (!Mathf.Approximately(scale, 1f)) + info.damageTypes.ScaleAll(scale); + + // D271: "players can hurt it" off. RunicNPC's own rule, so it holds with a PVE plugin + // or without one; NextGenPVE ignores a "deny" answer (stage 5 spike). + BasePlayer by = info.InitiatorPlayer; + if (!Profile.HurtByPlayers && by != null && !by.IsNpc) + info.damageTypes.ScaleAll(0f); + } + + // A contributor is credited with the health it actually took, after Rust's own + // protection and never past zero. A killing blow is credited in OnDied, which + // base.Hurt reaches before it returns, so OnRunicNpcDied carries it. + BasePlayer attacker = info?.InitiatorPlayer; + ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL; + _pendingAttacker = attackerId; + _healthBefore = health; + base.Hurt(info); + _pendingAttacker = 0UL; + if (attackerId != 0UL && !Died) + Credit(attackerId, _healthBefore - health); + if (Profile != null && !IsDead()) + { + // D279: Rust's own Hurt teaches the NPC its attacker; a passive one forgets at once. + if (Profile.IsPassive && Brain?.Senses?.Memory != null) + { + Brain.Senses.Memory.Targets.Clear(); + Brain.Senses.Memory.Threats.Clear(); + } + + _self?.CheckThresholds(this); + if (IsBoss) + { + BarDirty = true; + _self?.CheckPhases(this, attackerId); + } + + // An unkillable NPC (D271) takes nothing and still answers the attack. + if (info != null && info.Initiator != null) + { + LastHurtAt = Time.time; + _self?.NpcHurt(this, info); + } + } + } + + private ulong _pendingAttacker; + private float _healthBefore; + + private void Credit(ulong id, float damage) + { + if (damage <= 0f) + return; + float sum; + Contributors.TryGetValue(id, out sum); + Contributors[id] = sum + damage; + } + + public override void OnDied(HitInfo info) + { + if (_pendingAttacker != 0UL) + Credit(_pendingAttacker, _healthBefore); + Died = true; + _self?.HandleDied(this, info); + + // D290: a kit start. Rust empties the inventory before it fills the corpse, so what it carried is + // taken out now and handed to the corpse in OnCorpsePopulate; anything not handed over is removed. + if (Profile?.Loot != null && Profile.Loot.Start == "kit") + { + KitCarried = new List(); + foreach (ItemContainer from in new[] { inventory.containerBelt, inventory.containerMain }) + foreach (Item item in from.itemList.ToList()) + { + item.RemoveFromContainer(); + KitCarried.Add(item); + } + List taken = KitCarried; + _self?.NextTick(() => + { + if (KitCarried != taken) + return; + foreach (Item item in taken) + if (item.parent == null) + item.Remove(); + KitCarried = null; + }); + } + + base.OnDied(info); + } + + /// D290: a kit start's items, between its death and its corpse being filled. + public List KitCarried; + } + + /// + /// Our brain: Rust's scientist brain, whose AI design still decides when to chase, fight + /// and take cover, with three things of ours (D233, D235): + /// + /// + /// the roam state replaced by our wander or route follower (a monument + /// roamer keeps Rust's own); + /// a sentry's moving states replaced by one that holds its spot and turns; + /// sleep: past the profile's distance from every player it walks home, then + /// stops thinking; a player in range wakes it. + /// + /// + /// The design references states by type, so a replaced state keeps its events: the + /// shooting still happens where Rust's design puts it. + /// + public class RunicNpcBrain : ScientistBrain + { + public bool Sentry; + + public enum RestState + { + Awake, + GoingHome, + Asleep + } + + public RestState Rest = RestState.Awake; + public int Leashes; + + private float _nextCheck; + private bool _playerNear = true; + private float _homeBy; + + private static readonly BasePlayer[] NearBuffer = new BasePlayer[4]; + + private static readonly AIState[] SentryHolds = + { + AIState.Roam, AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover, AIState.MoveTowards, + AIState.MoveToVector3, AIState.FollowPath, AIState.NavigateHome, AIState.Flee, AIState.Blinded + }; + + private static readonly HashSet Aggressive = new HashSet + { + AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover + }; + + public override void AddStates() + { + base.AddStates(); + RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; + if (npc == null) + return; + + if (Sentry) + { + foreach (AIState type in SentryHolds) + if (states.ContainsKey(type)) + Replace(new HoldState(type, Aggressive.Contains(type))); + return; + } + + // Every other NPC's roam is ours (stage 5): it stands aside while ours fights an NPC + // (D262), follows an escort (D269), and otherwise walks as its role and movement say. + // Rust's own roam and chase both need an AI zone's move points, so outside a monument + // they do nothing (stage 1, Q3; stage 2 found the chase's Error branch): a guard, a + // wanderer and a route walker get ours, and a monument roamer keeps both of Rust's. + BasicAIState inner; + if (npc.Profile != null && npc.Profile.IsGuard) + inner = new RnGuardState(); + else if (npc.MoveKind == "wander") + inner = new WanderState(); + else if (npc.MoveKind == "route") + inner = new RouteState(); + else + { + BasicAIState rust; + inner = states.TryGetValue(AIState.Roam, out rust) ? rust : null; + } + + if (inner != null) + Replace(new RnRoamState(inner)); + if (npc.MoveKind != "monument" || (npc.Profile != null && npc.Profile.IsGuard)) + Replace(new PursueState()); + } + + /// Whether Rust's design is fighting a player now; ours then leaves the fight to it. + public bool FightingAPlayer() + { + if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null) + return false; + foreach (BaseEntity e in Senses.Memory.Targets) + { + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc && p.IsAlive()) + return true; + } + + return false; + } + + /// Wakes a sleeping NPC at once, for an ally's alarm (D256) or a defence (D257). + public void Rouse() + { + if (Rest == RestState.Awake) + return; + bool asleep = Rest == RestState.Asleep; + Rest = RestState.Awake; + _nextCheck = Time.time + 10f; + _playerNear = true; + if (asleep) + ((IAISleepable)this).WakeAI(); + } + + private void Replace(BasicAIState state) + { + states.Remove(state.StateType); + AddState(state); + } + + public override void Think(float delta) + { + RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; + + // Rust's think manager can reach a brain before Unity has started it, and until then + // it has no navigator and no state: leave that think to Rust. + if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null) + { + base.Think(delta); + return; + } + + float now = Time.time; + if (now >= _nextCheck) + { + _nextCheck = now + 2f; + _playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance); + if (!Sentry && !Navigator.IsOnNavMeshLink) + Reground(npc); + _self?.CheckEscort(npc); + + // D272: outside its zone (pushed, or the zone moved), it forgets the fight and walks home. + if (!Sentry && npc.Tether != null && _self != null && !_self.InTether(npc, npc.transform.position)) + { + Tethered++; + Leash(npc); + } + } + + switch (Rest) + { + case RestState.Asleep: + if (!_playerNear) + { + lastThinkTime = now; + return; + } + + Rest = RestState.Awake; + ((IAISleepable)this).WakeAI(); + break; + + case RestState.GoingHome: + if (_playerNear) + { + Rest = RestState.Awake; + break; + } + + lastThinkTime = now; + if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy) + Sleep(); + else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) + Sleep(); + return; + + default: + if (!_playerNear && !npc.Engaged && (CurrentState == null || !CurrentState.AgrresiveState)) + { + // D235: walk home first, then sleep. Nothing thinks on the way: no player + // is within the sleep distance, so there is nothing to sense or fight. + Rest = RestState.GoingHome; + _homeBy = now + 120f; + lastThinkTime = now; + if (Sentry || !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) + Sleep(); + return; + } + + break; + } + + if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && + Flat(npc.transform.position - npc.Anchor) > npc.Profile.Ranges.Chase) + Leash(npc); + + base.Think(delta); + + // Stage 5: our own fight, after Rust's, so Rust's design still owns every fight with a + // player. It costs nothing for an NPC with no faction settings and no target (D255). + if (_self != null && !npc.IsDead()) + { + long t0 = Stopwatch.GetTimestamp(); + _self.Fight(npc, this, delta); + FightTicks++; + FightMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency; + } + } + + public int Tethered; + + /// What our own fight layer has cost, for rnpc.status and the cost warning. + public static double FightMs; + public static long FightTicks; + + /// + /// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air + /// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest + /// navmesh, as Rust's own navigator warps, and that spot is its home until it respawns. + /// + private void Reground(RunicNpcPlayer npc) + { + Vector3 at = npc.transform.position; + if (OffMeshReason(at) == null) + return; + NavMeshHit hit; + if (Navigator.Agent == null || !SampleNavmesh(at, Fallback, out hit)) + return; + + Navigator.Stop(); + Navigator.Agent.Warp(hit.position); + npc.transform.position = hit.position; + npc.Home = hit.position; + Regrounded++; + _self?.NoteRegrounded(npc, at, hit.position); + } + + public int Regrounded; + + /// Past its chase range from home: forget the target and head back. + public void Leash(RunicNpcPlayer npc) + { + Leashes++; + npc.NpcTarget = null; + npc.Engaged = false; + Rust.Ai.SimpleAIMemory memory = Senses.Memory; + memory.All.Clear(); + memory.Players.Clear(); + memory.Targets.Clear(); + memory.Threats.Clear(); + memory.LOS.Clear(); + Events.Memory.Entity.Clear(); + Senses.DelaySenseUpdate(5f); + npc.NextSense = Time.time + 5f; + Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast); + } + + private void Sleep() + { + Rest = RestState.Asleep; + Navigator.Stop(); + ((IAISleepable)this).SleepAI(); + } + + private static bool PlayerWithin(Vector3 at, float distance) => + BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0; + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + } + + /// D233 wander: a walkable point within the radius of home, walk there, finish. + public class WanderState : BaseAIBrain.BasicAIState + { + private StateStatus _status = StateStatus.Error; + + public WanderState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _status = StateStatus.Error; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return; + + for (int i = 0; i < 6; i++) + { + Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius; + NavMeshHit hit; + if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) && + (npc.Tether == null || _self == null || _self.InTether(npc, hit.position)) && + brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow)) + { + _status = StateStatus.Running; + return; + } + } + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + if (_status == StateStatus.Error) + return _status; + return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + } + } + + /// + /// The roam state of every NPC that is not a sentry (stage 5). It hands the NPC to one of + /// three things, in order: + /// + /// + /// an escort (D269): it keeps within a few metres of whom it escorts; + /// its own fight with an NPC (D231): it stands aside while + /// moves the NPC, and a route walker resumes at the point it was heading to (D262); + /// otherwise its role's own roam: a guard's hold, our wander or route, or Rust's own + /// monument roam. + /// + /// + public class RnRoamState : BaseAIBrain.BasicAIState + { + private readonly BaseAIBrain.BasicAIState _inner; + private bool _innerActive; + private float _nextFollow; + + /// How close an escort keeps, and how far it lets its charge get before it runs. + private const float Keep = 4f; + private const float Run = 12f; + + public RnRoamState(BaseAIBrain.BasicAIState inner) : base(AIState.Roam) + { + _inner = inner; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _innerActive = false; + _nextFollow = 0f; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc != null && !Elsewhere(npc)) + EnterInner(brain, entity); + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + + if (Elsewhere(npc)) + { + LeaveInner(brain, entity); + if (npc.Escort != null && !npc.Engaged) + Follow(brain, npc); + return StateStatus.Running; + } + + if (!_innerActive) + EnterInner(brain, entity); + return _inner.StateThink(delta, brain, entity); + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + LeaveInner(brain, entity); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static bool Elsewhere(RunicNpcPlayer npc) => npc.Engaged || (npc.Escort != null && !npc.Escort.IsDestroyed); + + private void EnterInner(BaseAIBrain brain, BaseEntity entity) + { + _inner.StateEnter(brain, entity); + _innerActive = true; + } + + private void LeaveInner(BaseAIBrain brain, BaseEntity entity) + { + if (!_innerActive) + return; + _inner.StateLeave(brain, entity); + _innerActive = false; + } + + /// D269: keep close to whom it escorts, running when they get away. + private void Follow(BaseAIBrain brain, RunicNpcPlayer npc) + { + if (Time.time < _nextFollow) + return; + _nextFollow = Time.time + 0.5f; + Vector3 there = npc.Escort.transform.position; + float distance = Vector3.Distance(npc.transform.position, there); + if (distance <= Keep + 1f) + { + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + return; + } + + // A spot a little short of the escorted entity, on the side ours comes from, so a squad + // gathers round rather than on top of it. + Vector3 from = (npc.transform.position - there); + from.y = 0f; + Vector3 goal = there + (from.sqrMagnitude > 0.01f ? from.normalized : Vector3.forward) * Keep; + NavMeshHit hit; + if (SampleNavmesh(goal, 3f, out hit) || SampleNavmesh(there, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, distance > Run ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); + } + } + + /// + /// A guard's roam (stage 5): it holds its spot facing the way it was placed, and walks back + /// to it after a chase. Its chase range is its leash, as for any roamer. + /// + public class RnGuardState : BaseAIBrain.BasicAIState + { + private float _next; + + public RnGuardState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _next = 0f; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + if (Time.time < _next) + return StateStatus.Running; + _next = Time.time + 1f; + + if (Flat(npc.transform.position - npc.Home) > 2f) + { + if (!brain.Navigator.Moving) + brain.Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + return StateStatus.Running; + } + + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + brain.Navigator.SetFacingDirectionOverride(Quaternion.Euler(0f, npc.HomeYaw, 0f) * Vector3.forward); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + } + + /// D233 route:<name>: walks a recorded route's points in order. + public class RouteState : BaseAIBrain.BasicAIState + { + private int _index = -1; + private int _step = 1; + private StateStatus _status = StateStatus.Error; + + public int Index => _index; + + public RouteState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _status = StateStatus.Error; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + Route route = npc != null ? _self?.RouteFor(npc) : null; + if (route == null) + return; + + if (_index < 0 || _index >= route.Points.Count) + _index = Nearest(route, entity.transform.position); + _status = Go(brain, route) ? StateStatus.Running : StateStatus.Error; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + if (_status == StateStatus.Error) + return _status; + + RunicNpcPlayer npc = entity as RunicNpcPlayer; + Route route = npc != null ? _self?.RouteFor(npc) : null; + if (route == null) + return _status = StateStatus.Error; + + if (_index >= route.Points.Count) + _index = Nearest(route, entity.transform.position); + if (!brain.Navigator.Moving || Vector3.Distance(entity.transform.position, route.Points[_index].V) < 1.5f) + { + Advance(route); + if (!Go(brain, route)) + return _status = StateStatus.Error; + } + + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + } + + /// Sets off for the current point, skipping any Rust cannot path to. + private bool Go(BaseAIBrain brain, Route route) + { + for (int tries = 0; tries < route.Points.Count; tries++) + { + if (brain.Navigator.SetDestination(route.Points[_index].V, BaseNavigator.NavigationSpeed.Slow)) + return true; + Advance(route); + } + + return false; + } + + private void Advance(Route route) + { + int n = route.Points.Count; + if (route.Loop) + { + _index = (_index + 1) % n; + return; + } + + if (_index + _step < 0 || _index + _step >= n) + _step = -_step; + _index += _step; + } + + private static int Nearest(Route route, Vector3 at) + { + int best = 0; + float bestDistance = float.MaxValue; + for (int i = 0; i < route.Points.Count; i++) + { + float d = (route.Points[i].V - at).sqrMagnitude; + if (d < bestDistance) + { + bestDistance = d; + best = i; + } + } + + return best; + } + } + + /// + /// Our chase, for a wander or route roamer: close to three quarters of the attack + /// range, never past the chase range from home. A target that stays out of reach while ours + /// stands at that edge for 5 s is given up (the leash), and ours walks home. + /// + public class PursueState : BaseAIBrain.BasicAIState + { + private float _next; + private float _edgeSince = -1f; + + public PursueState() : base(AIState.Chase) + { + AgrresiveState = true; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _next = 0f; + _edgeSince = -1f; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (target == null || npc == null || npc.Profile == null) + return StateStatus.Error; + + Vector3 here = entity.transform.position; + Vector3 there = target.transform.position; + float distance = Vector3.Distance(here, there); + if (distance <= 10f || brain.Senses.Memory.IsLOS(target)) + brain.Navigator.SetFacingDirectionEntity(target); + else + brain.Navigator.ClearFacingDirectionOverride(); + + if (Time.time < _next) + return StateStatus.Running; + _next = Time.time + UnityEngine.Random.Range(0.5f, 1f); + + float stopAt = Mathf.Max(2f, npc.Profile.Ranges.Attack * 0.75f); + if (distance <= stopAt) + { + brain.Navigator.Stop(); + _edgeSince = -1f; + return StateStatus.Running; + } + + Vector3 goal = there - (there - here).normalized * stopAt; + float chase = npc.Profile.Ranges.Chase; + Vector3 anchor = npc.Anchor; + Vector3 fromHome = goal - anchor; + fromHome.y = 0f; + bool clamped = chase > 0f && fromHome.magnitude > chase; + if (clamped) + goal = anchor + fromHome.normalized * chase; + + // D272: the zone's edge is a leash too. Past it, ours stands and waits, as at the chase range. + if (npc.Tether != null && _self != null && !_self.InTether(npc, goal)) + { + goal = here; + clamped = true; + } + + if (clamped && distance > npc.Profile.Ranges.Attack) + { + if (_edgeSince < 0f) + _edgeSince = Time.time; + if (Time.time - _edgeSince >= 5f) + { + (brain as RunicNpcBrain)?.Leash(npc); + return StateStatus.Error; + } + } + else + _edgeSince = -1f; + + NavMeshHit hit; + if (!SampleNavmesh(goal, 3f, out hit)) + return StateStatus.Error; + brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + brain.Navigator.ClearFacingDirectionOverride(); + } + } + + /// + /// A sentry's stand-in for every state that would move it: it stops, and turns to face the + /// design's current target. The design's attack events still run in it. + /// + public class HoldState : BaseAIBrain.BasicAIState + { + public HoldState(AIState type, bool aggressive) : base(type) + { + AgrresiveState = aggressive; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + brain.Navigator.Stop(); + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + if (target != null) + brain.Navigator.SetFacingDirectionEntity(target); + else + brain.Navigator.ClearFacingDirectionOverride(); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.ClearFacingDirectionOverride(); + } + } + + #endregion + + #region Factions, sensing and the fight (stage 5) + + // Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights + // (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258, + // D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272). + // An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think. + + /// Rust's scientists (not ours), for the profiles hostile to scientists. + private readonly HashSet _scientists = new HashSet(); + + /// Rust's animals, old and new, for the profiles hostile to animals. + private readonly HashSet _animals = new HashSet(); + + /// Our live NPCs by profile, for the profiles hostile to another profile. + private readonly Dictionary> _byProfile = new Dictionary>(); + + /// Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269). + private readonly HashSet _defenders = new HashSet(); + + /// Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts. + private int _huntsVersion; + + private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer); + + private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2; + + private void Track(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null || e is RunicNpcPlayer) + return; + if (IsScientist(e)) + _scientists.Add(e); + else if (IsAnimal(e)) + _animals.Add(e); + } + + private void Untrack(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null) + return; + _scientists.Remove(e); + _animals.Remove(e); + } + + private void TrackWorld() + { + _scientists.Clear(); + _animals.Clear(); + foreach (BaseNetworkable e in BaseNetworkable.serverEntities) + Track(e); + } + + private void RebuildHunts() => _huntsVersion++; + + /// A profile's faction, or profile:<name> when it has none: its allies are then its own (D256). + private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction; + + private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile); + + /// + /// How a profile treats a faction: its own exceptions first (D254), then its own faction + /// (allied), then the table, both ways (D268); neutral when nothing says otherwise. + /// + private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile) + { + string r; + if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r)) + return r; + if (p.Relations.TryGetValue(otherFaction, out r)) + return r; + string mine = FactionOf(profileName, p); + if (mine == otherFaction) + return "allied"; + return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral"; + } + + /// How one of ours treats an entity: a relation for NPCs and animals, null for anything else. + private string RelationTo(RunicNpcPlayer npc, BaseEntity e) + { + RunicNpcPlayer other = e as RunicNpcPlayer; + if (other != null) + return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName); + if (IsScientist(e)) + return RelationOf(npc.ProfileName, npc.Profile, "scientists", null); + if (IsAnimal(e)) + return RelationOf(npc.ProfileName, npc.Profile, "animals", null); + return null; + } + + /// D256: an ally is the same faction, or the same profile when there is no faction. + private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) => + a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b); + + /// The registry keys an NPC hunts: scientists, animals and ours:<profile>. Null for none (D255). + private HashSet HuntsOf(RunicNpcPlayer npc) + { + if (npc.HuntsVersion == _huntsVersion) + return npc.Hunts; + npc.HuntsVersion = _huntsVersion; + var hunts = new HashSet(); + if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile") + hunts.Add("scientists"); + if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile") + hunts.Add("animals"); + foreach (KeyValuePair kv in _profiles) + if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile") + hunts.Add("ours:" + kv.Key); + npc.Hunts = hunts.Count > 0 ? hunts : null; + return npc.Hunts; + } + + private IEnumerable Registry(string key) + { + if (key == "scientists") + return _scientists; + if (key == "animals") + return _animals; + HashSet ours; + return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast() : null; + } + + private void Index(RunicNpcPlayer npc, bool add) + { + HashSet set; + if (!_byProfile.TryGetValue(npc.ProfileName, out set)) + { + if (!add) + return; + _byProfile[npc.ProfileName] = set = new HashSet(); + } + + if (add) + set.Add(npc); + else + { + set.Remove(npc); + _defenders.Remove(npc); + } + } + + private void NoteDefender(RunicNpcPlayer npc) + { + if ((npc.HasAlly || npc.Escort != null) && !npc.Profile.IsPassive) + _defenders.Add(npc); + else + _defenders.Remove(npc); + } + + /// How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range. + private static float LeashOf(RunicNpcPlayer npc) => + npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense; + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + + // ---- the fight ---- + + /// LOS tests a sense may spend: the nearest few candidates only (stage 5 spike). + private const int LosBudget = 3; + + /// Rust's own line-of-sight mask for an NPC's shots. + private const int LosMask = 1218519041; + + private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; + private static readonly float[] BestD2 = new float[LosBudget]; + + /// + /// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours — + /// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257, + /// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack + /// routine the target, so every shot is Rust's. + /// + private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) + { + if (npc.Profile.IsPassive) + return; + float now = Time.time; + HashSet hunts = HuntsOf(npc); + BaseCombatEntity t = npc.NpcTarget; + Vector3 anchor = npc.Anchor; + float leash = LeashOf(npc); + + if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) || + Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f || + Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f))) + t = npc.NpcTarget = null; + + if (hunts != null && now >= npc.NextSense && TurretOf(t) == null) + { + npc.NextSense = now + 1f; + long s0 = Stopwatch.GetTimestamp(); + BaseCombatEntity found = Sense(npc, hunts, anchor, leash); + SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; + SenseRuns++; + if (found != null) + t = npc.NpcTarget = found; + } + + bool player = brain.FightingAPlayer(); + npc.Engaged = t != null && !player; + + if (npc.Profile.KitUse.Any) + KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now); + + if (!npc.Engaged) + return; + + if (now >= npc.NextLos) + { + npc.NextLos = now + 0.5f; + npc.TargetLos = Los(npc, t); + } + + brain.Navigator.SetFacingDirectionEntity(t); + float distance = Vector3.Distance(npc.transform.position, t.transform.position); + if (!npc.Profile.IsSentry) + { + if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f) + { + if (now >= npc.NextMove) + { + npc.NextMove = now + 1f; + Vector3 goal = t.transform.position; + Vector3 fromAnchor = goal - anchor; + fromAnchor.y = 0f; + if (fromAnchor.magnitude > leash) + goal = anchor + fromAnchor.normalized * leash; + NavMeshHit hit; + if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast); + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + } + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + + if (now >= npc.KitBusyUntil) + npc.AttackTick(delta, t, npc.TargetLos); + } + + /// The player Rust's design is fighting, for the kit's extras (D260). + private static BaseEntity PlayerTarget(RunicNpcBrain brain) + { + if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState) + return null; + return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + } + + public static double SenseMs; + public static long SenseRuns; + + /// + /// The nearest hostile within its sense range AND its leash around its anchor (D263), from the + /// registry rather than a physics query: distances first, then line of sight on the nearest + /// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight. + /// + private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet hunts, Vector3 anchor, float leash) + { + for (int i = 0; i < LosBudget; i++) + { + Best[i] = null; + BestD2[i] = float.MaxValue; + } + + Vector3 at = npc.transform.position; + float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense; + float r2 = sense * sense; + float leash2 = leash * leash; + foreach (string key in hunts) + { + IEnumerable set = Registry(key); + if (set == null) + continue; + foreach (BaseCombatEntity e in set) + { + if (e == null || e.IsDestroyed || e == npc || e.IsDead()) + continue; + Vector3 p = e.transform.position; + float d2 = (p - at).sqrMagnitude; + if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) + continue; + Vector3 fromAnchor = p - anchor; + if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2) + continue; + int k = LosBudget - 1; + while (k > 0 && BestD2[k - 1] > d2) + { + Best[k] = Best[k - 1]; + BestD2[k] = BestD2[k - 1]; + k--; + } + + Best[k] = e; + BestD2[k] = d2; + } + } + + for (int i = 0; i < LosBudget && Best[i] != null; i++) + if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i])) + return Best[i]; + return null; + } + + /// + /// A line from its eyes to the target's centre that counts the target's own collider as seen. + /// Not Rust's CanSeeTarget, even for a scientist or one of ours: its rays are unbounded, + /// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off + /// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's + /// design's to see; this is only for what our own fight takes on. + /// + private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t) + { + RaycastHit hit; + if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) + return true; + BaseEntity e = hit.GetEntity(); + return e != null && (e == t || e.EqualNetID(t) || (e.HasParent() && e.GetParentEntity() == t)); + } + + /// + /// The turret behind a hit. A flame turret hurts through its fireballs, whose creator is the + /// turret or the fireball that spawned them, so the chain is followed (stage 5 spike). + /// + private static BaseCombatEntity TurretOf(BaseEntity e) + { + for (int i = 0; i < 6 && e is FireBall; i++) + e = e.creatorEntity == e ? null : e.creatorEntity; + return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; + } + + /// + /// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's + /// people (D257), and NPCs it is allied to (D254). + /// + private bool Spares(RunicNpcPlayer npc, BaseEntity e) + { + if (e == null) + return false; + if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort))) + return true; + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc) + return npc.HasAlly && IsAllyMember(npc, p); + return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied"; + } + + // ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ---- + + /// Called from the NPC's own Hurt, after Rust's. + private void NpcHurt(RunicNpcPlayer npc, HitInfo info) + { + // D279: a passive NPC never fights back, and raises no alarm. + if (npc.Profile.IsPassive) + return; + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; + if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) + return; + + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(npc, ours)) + return; // an ally's fire or grenade (D266): no fight among friends. + + BasePlayer player = attacker as BasePlayer; + bool real = player != null && !player.IsNpc; + if (real && npc.HasAlly && IsAllyMember(npc, player)) + return; + + // D268: whoever shot it is fought back, whatever the table says. Rust's design already learns + // a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight. + if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)) + npc.NpcTarget = attacker; + + Alarm(npc, attacker, real); + } + + /// D256: every ally within the profile's alert radius learns the attacker, seen or not. + private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + float radius = npc.Profile.AlertRadius; + float now = Time.time; + if (radius <= 0f || now < npc.NextAlert) + return; + npc.NextAlert = now + 1f; + float r2 = radius * radius; + Vector3 at = npc.transform.position; + foreach (RunicNpcPlayer other in _npcs.Values) + { + if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other)) + continue; + if ((other.transform.position - at).sqrMagnitude > r2) + continue; + Learn(other, attacker, real); + Alarms++; + } + } + + public static long Alarms, Defences; + + /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. + private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + // D279: a passive NPC never fights, so an ally's alarm does not reach it. + if (npc.Profile.IsPassive || Spares(npc, attacker)) + return; + npc.RnBrain?.Rouse(); + if (real) + { + BaseAIBrain brain = npc.Brain; + if (brain?.Senses?.Memory != null) + brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses); + return; + } + + if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed) + npc.NpcTarget = attacker; + } + + // ---- allies: a clan, a team or a player (D257, D270) ---- + + private sealed class Members + { + public HashSet Ids = new HashSet(); + public float ReadAt = float.NegativeInfinity; + } + + private readonly Dictionary _members = new Dictionary(); + + /// Whether a player belongs to whom one of ours is allied to. + private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p) + { + if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer) + return true; + if (npc.AllyClan != 0L && p.clanId == npc.AllyClan) + return true; + return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam; + } + + /// + /// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when + /// the owner may be offline. Clan and team rosters are re-read every five seconds at most. + /// + private bool IsAllyMember(RunicNpcPlayer npc, ulong id) + { + if (id == 0UL) + return false; + if (npc.AllyPlayer == id) + return true; + if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id)) + return true; + return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id); + } + + private HashSet Roster(string key) + { + Members m; + if (!_members.TryGetValue(key, out m)) + _members[key] = m = new Members(); + float now = Time.realtimeSinceStartup; + if (now - m.ReadAt < 5f) + return m.Ids; + m.ReadAt = now; + m.Ids.Clear(); + if (key.StartsWith("team:")) + { + RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null + ? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5))) + : null; + if (team?.members != null) + foreach (ulong id in team.members) + m.Ids.Add(id); + } + else + { + IClan clan = ReadClan(long.Parse(key.Substring(5))); + if (clan?.Members != null) + foreach (ClanMember member in clan.Members) + m.Ids.Add(member.SteamId); + } + + return m.Ids; + } + + /// A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant. + private static IClan ReadClan(long clanId) + { + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + if (backend == null) + return null; + IClan clan; + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null; + } + + /// + /// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by + /// every defender within its leash of the victim; a player, by Rust's own design. + /// + private void Defend(BaseCombatEntity victim, HitInfo info) + { + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity; + if (attacker == null || attacker == victim || attacker.IsDead()) + return; + BasePlayer victimPlayer = victim as BasePlayer; + BasePlayer attackerPlayer = attacker as BasePlayer; + bool real = attackerPlayer != null && !attackerPlayer.IsNpc; + float now = Time.time; + + foreach (RunicNpcPlayer d in _defenders) + { + if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend) + continue; + + bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort)); + if (!charge && d.HasAlly) + { + charge = victimPlayer != null && !victimPlayer.IsNpc + ? IsAllyMember(d, victimPlayer) + : victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID); + } + + if (!charge) + continue; + + // The attacker is not fought if it is one of the side being defended. + if (real && d.HasAlly && IsAllyMember(d, attackerPlayer)) + continue; + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(d, ours)) + continue; + + float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius); + if (Flat(victim.transform.position - d.Anchor) > reach) + continue; + + d.NextDefend = now + 0.5f; + Learn(d, attacker, real); + Defences++; + } + } + + // ---- escort (D258, D269) ---- + + /// + /// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a + /// sentry never moves, and a dead or disconnected player cannot be escorted. + /// + private string SetEscort(RunicNpcPlayer npc, BaseEntity target) + { + if (target == null) + { + npc.Escort = null; + NoteDefender(npc); + return null; + } + + if (npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it cannot escort"; + if (target == npc || target.IsDestroyed) + return "there is nothing to escort"; + BasePlayer p = target as BasePlayer; + if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc) + return $"{p.displayName} is not on the server"; + BaseCombatEntity c = target as BaseCombatEntity; + if (c != null && c.IsDead()) + return "it is dead"; + + npc.Escort = target; + npc.NpcTarget = null; + NoteDefender(npc); + npc.RnBrain?.Rouse(); + return null; + } + + /// D269: whom it escorts died or left the server; it walks back to its spot. + private void CheckEscort(RunicNpcPlayer npc) + { + // Unity's == says a destroyed entity is null, which is exactly the case to catch here. + BaseEntity e = npc.Escort; + if (ReferenceEquals(e, null)) + return; + bool destroyed = e == null || e.IsDestroyed; + BasePlayer p = destroyed ? null : e as BasePlayer; + BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity; + bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); + if (!gone) + return; + string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName; + npc.Escort = null; + NoteDefender(npc); + npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner); + Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot."); + } + + // ---- the tether (D272) ---- + + [PluginReference] private Plugin ZoneManager; + + private bool _tetherWarned; + + /// + /// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held, + /// and the log says so once, rather than every NPC freezing where it stands. + /// + private bool InTether(RunicNpcPlayer npc, Vector3 at) + { + if (npc.Tether == null) + return true; + if (ZoneManager == null) + { + if (!_tetherWarned) + PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone."); + _tetherWarned = true; + return true; + } + + object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at); + return !(inside is bool) || (bool)inside; + } + + /// Null when a zone can hold NPCs at a point, else why not. + private string TetherRefusal(string zone, Vector3 at) + { + if (zone == null) + return null; + if (ZoneManager == null) + return "tether: ZoneManager is not loaded on this server"; + if (ZoneManager.Call("CheckZoneID", zone) == null) + return $"tether: this server has no zone '{zone}'"; + object inside = ZoneManager.Call("IsPositionInZone", zone, at); + if (inside is bool && !(bool)inside) + return $"tether: the spot is outside the zone '{zone}'"; + return null; + } + + // ---- the kit's extras (D260, D265) ---- + + /// The first item in its inventory whose held entity is a , or null. + private static Item FindHeld(RunicNpcPlayer npc) where T : HeldEntity + { + foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + if (item != null && item.GetHeldEntity() is T) + return item; + } + + return null; + } + + /// Puts an item in its hands; only the belt can be held, so it is moved there first. + private static bool Hold(RunicNpcPlayer npc, Item item) + { + if (item == null) + return false; + if (npc.GetActiveItem() == item) + return true; + if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt)) + return false; + npc.UpdateActiveItem(item.uid); + return true; + } + + /// Back to the main weapon (the belt's first slot, Rust's own rule). + private static void MainWeapon(RunicNpcPlayer npc) + { + Item main = npc.inventory.containerBelt?.GetSlot(0); + if (main != null && npc.GetActiveItem() != main) + npc.EquipWeapon(true); + } + + private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item; + + /// + /// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of + /// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in, + /// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m. + /// + private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now) + { + KitUse k = npc.Profile.KitUse; + if (now < npc.KitBusyUntil) + return; + + if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal) + { + npc.NextHeal = now + (Heal(npc) ? 6f : 15f); + if (now < npc.KitBusyUntil) + return; + } + + if (target == null || target.IsDestroyed) + { + MainWeapon(npc); + return; + } + + float distance = Vector3.Distance(npc.transform.position, target.transform.position); + + if (k.Melee && distance <= 2.5f) + { + Item melee = FindHeld(npc); + if (melee != null && !IsMain(npc, melee) && Hold(npc, melee)) + { + brain.Navigator.SetFacingDirectionEntity(target); + if (npc.MeleeAttack()) + npc.MeleeHits++; + return; + } + } + + if (k.Flamethrower && distance <= 7f) + { + Item flame = FindHeld(npc); + if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame)) + return; // Rust's own attack tick fires what it holds. + } + + if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow) + { + npc.NextThrow = now + 10f; + Item thrown = FindHeld(npc); + if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) && + npc.TryUseThrownWeapon(thrown, target, 10f)) + { + // Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon. + npc.Throws++; + npc.KitBusyUntil = now + 2.5f; + return; + } + } + + if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket) + { + npc.NextRocket = now + 2f; + // A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of + // ours) or an animal is never in Rust's player memory, so it is ours. + BaseCombatEntity aim = target as BaseCombatEntity; + BasePlayer human = aim as BasePlayer; + bool seen = human != null && !human.IsNpc + ? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim) + : aim != null && Los(npc, aim); + if (seen && FireRocket(npc, target)) + { + npc.KitBusyUntil = now + 1.5f; + return; + } + } + + Item held = npc.GetActiveItem(); + if (held != null && !IsMain(npc, held)) + { + HeldEntity h = held.GetHeldEntity() as HeldEntity; + bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f); + if (!keep) + MainWeapon(npc); + } + } + + /// D260: one syringe, medkit or bandage, used up, its health given at once. + private bool Heal(RunicNpcPlayer npc) + { + Item item = FindHeld(npc); + ItemModConsumable consumable = item?.info.GetComponent(); + if (consumable == null) + return false; + float amount = 0f; + foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects) + if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime) + amount += effect.amount; + if (amount <= 0f) + return false; + + Hold(npc, item); + npc.SignalBroadcast(BaseEntity.Signal.Attack); + item.UseItem(1); + npc.Heal(amount); + npc.Heals++; + npc.KitBusyUntil = Time.time + 1.5f; + timer.Once(1.2f, () => + { + if (npc != null && !npc.IsDestroyed && !npc.IsDead()) + MainWeapon(npc); + }); + return true; + } + + /// + /// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher + /// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the + /// target, and credited to the NPC. + /// + private bool FireRocket(RunicNpcPlayer npc, BaseEntity target) + { + Item launcher = FindHeld(npc); + if (launcher == null) + return false; + Item ammo = null; + ItemModProjectile projectile = null; + foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + { + ItemModProjectile p = item?.info.GetComponent(); + if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket")) + { + ammo = item; + projectile = p; + break; + } + } + + if (ammo != null) + break; + } + + if (ammo == null || !Hold(npc, launcher)) + return false; + + Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f; + Vector3 to = target.CenterPoint(); + BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from); + ServerProjectile mover = rocket != null ? rocket.GetComponent() : null; + if (mover == null) + { + if (rocket != null) + UnityEngine.Object.Destroy(rocket.gameObject); + return false; + } + + Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier); + npc.SetAimDirection(direction); + rocket.SetCreatorEntity(npc); + rocket.OwnerID = 0UL; + mover.InitializeVelocity(direction * mover.speed); + rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver); + rocket.Spawn(); + ammo.UseItem(1); + launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty); + npc.Rockets++; + npc.NextRocket = Time.time + 10f; + timer.Once(1.2f, () => + { + if (npc != null && !npc.IsDestroyed && !npc.IsDead()) + MainWeapon(npc); + }); + return true; + } + + /// The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach. + private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity) + { + Vector3 delta = to - from; + Vector3 flat = new Vector3(delta.x, 0f, delta.z); + float x = flat.magnitude; + if (gravity <= 0f || x < 0.5f) + return delta.normalized; + float y = delta.y; + float v2 = speed * speed; + float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2); + if (root < 0f) + return delta.normalized; + float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x)); + return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized; + } + + private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) + { + FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; + return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); + } + + /// + /// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an + /// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left + /// the NPC goes back to its main weapon. False means "no shot now". + /// + private bool FuelExtra(RunicNpcPlayer npc) + { + Item held = npc.GetActiveItem(); + FlameThrower f = held?.GetHeldEntity() as FlameThrower; + if (f == null || IsMain(npc, held) || f.ammo > 0) + return true; + Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null; + if (fuel == null) + { + MainWeapon(npc); + return false; + } + + int take = Mathf.Min(fuel.amount, f.maxAmmo); + fuel.UseItem(take); + f.ammo = take; + f.SendNetworkUpdateImmediate(); + return true; + } + + // ---- turrets (D259, D264, D267) ---- + + /// + /// turrets: ignore: no auto turret, flame turret or shotgun trap targets it. Answers only + /// for our own NPCs, and null for everything else. + /// + private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret) + { + MarkHook("CanBeTargeted"); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc?.Profile == null || npc.Profile.Turrets != "ignore") + return null; + return false; + } + + private HarmonyLib.Harmony _harmony; + private const string HarmonyId = "runicnpc.sentries"; + private string _sentryPatch = "not tried"; + + /// + /// D267: Outpost's and Bandit Camp's sentries refuse every scientist in Ignore and + /// IsEntityHostile, before any Oxide or Carbon hook runs, so turrets: always is a + /// Harmony patch on those two. Each answers "hostile" for our always NPCs alone and runs + /// Rust's own method for everything else, Rust's scientists and bandit guards included. + /// + private void PatchSentries() + { + try + { + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null); + MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null); + if (ignore == null || hostile == null) + { + _sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}"; + PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch})."); + return; + } + + _harmony = new HarmonyLib.Harmony(HarmonyId); + _harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix)))); + _harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix)))); + _sentryPatch = "patched"; + } + catch (Exception e) + { + _sentryPatch = "failed: " + e.Message; + PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message})."); + } + } + + private void UnpatchSentries() + { + try + { + _harmony?.UnpatchAll(HarmonyId); + } + catch (Exception e) + { + PrintWarning($"Harmony could not unpatch the sentries: {e.Message}"); + } + + _harmony = null; + } + + /// The two prefixes. Each answers for our always NPCs only; Rust's method runs for anything else. + public static class SentryPatch + { + public static long Answered; + + private static bool Always(BaseEntity e) + { + RunicNpcPlayer npc = e as RunicNpcPlayer; + return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead(); + } + + public static bool IgnorePrefix(BasePlayer __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = false; + Answered++; + return false; + } + + public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = true; + Answered++; + return false; + } + } + + // ---- PVE (D261, D271) ---- + + /// + /// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's + /// scientists are (D261), unless the profile turns a direction off (D271). Null for anything + /// that is not one of ours facing a player. + /// + private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("CanEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer victim = entity as RunicNpcPlayer; + BasePlayer attacker = info.InitiatorPlayer; + if (victim?.Profile != null && attacker != null && !attacker.IsNpc) + return victim.Profile.HurtByPlayers; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && player != null && !player.IsNpc) + return from.Profile.HurtsPlayers; + return null; + } + + /// + /// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271), + /// and whom ours escort or serve are defended (D257, D269). Null otherwise. + /// + private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc) + return true; + if (_defenders.Count > 0) + Defend(entity, info); + return null; + } + + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + if (_ready) + Track(entity); + } + + #endregion + + #region Registry, owners, spawning + + /// Every live NPC of ours, by net id. + private readonly Dictionary _npcs = new Dictionary(); + + private sealed class Slot + { + public RunicNpcPlayer Npc; + public float DiedAt = float.NegativeInfinity; + public bool Queued; + } + + private sealed class PlacementState + { + public string Id; + public Placement Data; + public Slot[] Slots; + public string Waiting; + public string LastError; + + /// Set while its spot is off the navmesh and its NPCs stand on the nearest (D239). + public string Note; + } + + private readonly Dictionary _placements = new Dictionary(); + + private sealed class SpawnRequest + { + public PlacementState Placement; + public int Slot; + } + + private readonly Queue _queue = new Queue(); + private bool _pumpScheduled; + private bool _navmeshWaitLogged; + private float _tokens; + private float _tokensAt; + + private int CountOwner(string owner) => _npcs.Values.Count(n => n.Owner == owner); + + private int CountProfile(string profile) => _npcs.Values.Count(n => n.ProfileName == profile); + + /// Null when a cap allows one more; otherwise which cap, for the caller. + private string CapRefusal(string owner, string profile) + { + CapsConfig caps = _config.Caps; + if (caps.Total > 0 && _npcs.Count >= caps.Total) + return $"the server's cap of {caps.Total} RunicNPC NPCs is reached"; + if (caps.PerOwner > 0 && CountOwner(owner) >= caps.PerOwner) + return $"the cap of {caps.PerOwner} per owner is reached for {owner}"; + if (caps.PerProfile > 0 && CountProfile(profile) >= caps.PerProfile) + return $"the cap of {caps.PerProfile} per profile is reached for '{profile}'"; + return null; + } + + /// The spawn-rate cap, a token bucket; always true when no rate is set. + private bool TakeSpawnToken() + { + float rate = _config.Caps.SpawnsPerSecond; + if (rate <= 0f) + return true; + float now = Time.realtimeSinceStartup; + _tokens = Mathf.Min(Mathf.Max(1f, rate), _tokens + (now - _tokensAt) * rate); + _tokensAt = now; + if (_tokens < 1f) + return false; + _tokens -= 1f; + return true; + } + + private static Profile Effective(Profile profile, JObject overrides) + { + JObject merged = JObject.FromObject(profile); + if (overrides != null) + merged.Merge(overrides, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace }); + return merged.ToObject(); + } + + /// + /// Spawns one NPC now, or returns null with the reason. Every path into the world comes + /// through here: the API, and the placement queue. + /// + private RunicNpcPlayer Spawn(string profileName, Profile p, Vector3 at, float yaw, string owner, Movement movementOverride, + string placementId, int slot, out string error) + { + if (!_ready || _unloading) { error = "RunicNPC is not ready"; return null; } + if (!NavmeshReady()) { error = "the map's navmesh is not built yet"; return null; } + + error = CapRefusal(owner, profileName); + if (error != null) return null; + + Movement movement = movementOverride ?? p.Movement; + string kind, route; + ParseMode(movement.Mode, out kind, out route); + if (kind == "stand" && !p.IsPassive) + { + error = "movement stand is a passive NPC's; a fighting NPC that stands is a sentry"; + return null; + } + + bool still = p.IsSentry || kind == "stand"; + if (!still && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + { + error = $"route '{route}' does not exist"; + return null; + } + + Vector3 position = at; + if (!still) + { + // Rust's sampler reaches further down than across: without the height check, a spot + // on a roof finds the ground below it (stage 2). + NavMeshHit hit; + if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop) + { + error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)"; + return null; + } + + position = hit.position; + } + + if (!TakeSpawnToken()) + { + error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached"; + return null; + } + + // A boss's phases change its stats, so it gets its own copy of the profile (D275). + if (p.Boss != null) + p = Effective(p, null); + + RunicNpcPlayer npc = CreateNpc(p, position, yaw, still, out error); + if (npc == null) + return null; + + npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)]; + npc.ProfileName = profileName; + npc.Profile = p; + npc.Owner = owner; + npc.PlacementId = placementId; + npc.Slot = slot; + npc.Home = position; + npc.HomeYaw = yaw; + npc.MoveKind = kind; + npc.MoveRadius = movement.Radius; + npc.RouteName = route; + + // CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this + // call, which GameManager.CreatePrefab makes when active is true. Without it the entity still + // spawns and thinks, but Unity never starts the brain: no design, no navigator, no state + // (stage 1, Q1). + npc.gameObject.AwakeFromInstantiate(); + npc.Spawn(); + npc.InitializeHealth(p.Health, p.Health); + + npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)]; + npc.inventory.Strip(); + object given = Kits?.Call("GiveKit", npc, npc.Kit); + if (given is string) + PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}"); + npc.EquipWeapon(); + + _npcs[npc.NetId] = npc; + Index(npc, true); + Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); + if (p.Boss != null) + BossSpawned(npc); + return npc; + } + + /// Removes one NPC from the registry, however it went: death, despawn or unload. + private void Unregister(RunicNpcPlayer npc) + { + if (!_npcs.Remove(npc.NetId)) + return; + Index(npc, false); + if (npc.IsBoss) + ClearBar(npc); + if (_addsOf.Count > 0) + foreach (ulong boss in _addsOf.Keys.ToList()) + if (_addsOf[boss].Remove(npc) && _addsOf[boss].Count == 0) + _addsOf.Remove(boss); + + if (npc.PlacementId != null) + { + PlacementState ps; + if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc) + { + ps.Slots[npc.Slot].Npc = null; + ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup; + } + } + + if (!npc.Died && !_unloading) + Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner); + } + + private int Despawn(IEnumerable npcs) + { + int n = 0; + foreach (RunicNpcPlayer npc in npcs.ToList()) + { + if (npc == null || npc.IsDestroyed) + continue; + Unregister(npc); + npc.Kill(); + n++; + } + + return n; + } + + /// + /// A death. It is unregistered at once, so a placement's respawn delay counts from the death, + /// not from whenever the body's entity is finally removed. + /// + private void HandleDied(RunicNpcPlayer npc, HitInfo info) + { + var contributors = new Dictionary(npc.Contributors); + Unregister(npc); + Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); + if (npc.IsBoss) + BossDied(npc, info, contributors); + } + + private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to) + { + string text = $"its ground went from under {npc.RnName} ({from.y - to.y:0.0} m), who now stands on the nearest navmesh until it respawns"; + PlacementState ps; + if (npc.PlacementId != null && _placements.TryGetValue(npc.PlacementId, out ps)) + ps.Note = text; + Puts($"{npc.Owner}: {text}."); + } + + private void CheckThresholds(RunicNpcPlayer npc) + { + float fraction = npc.health / npc.MaxHealth(); + foreach (float t in npc.Profile.HealthThresholds.OrderByDescending(x => x)) + if (fraction <= t && npc.ThresholdsFired.Add(t)) + Interface.CallHook("OnRunicNpcHealth", npc, npc.ProfileName, t); + } + + private Route RouteFor(RunicNpcPlayer npc) + { + Route route; + return npc.RouteName != null && _routeFile.Routes.TryGetValue(npc.RouteName, out route) && route.Points.Count > 0 ? route : null; + } + + #endregion + + #region Placements + + private void RebuildPlacements() + { + foreach (PlacementState ps in _placements.Values) + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + _placements.Clear(); + _queue.Clear(); + + foreach (KeyValuePair kv in _placementFile.Placements) + { + string error = ValidatePlacement(kv.Value); + if (error != null) + { + PrintWarning($"Placement '{kv.Key}' is ignored: {error}"); + continue; + } + + _placements[kv.Key] = NewState(kv.Key, kv.Value); + } + } + + private static PlacementState NewState(string id, Placement data) + { + var ps = new PlacementState { Id = id, Data = data, Slots = new Slot[data.Count] }; + for (int i = 0; i < ps.Slots.Length; i++) + ps.Slots[i] = new Slot(); + return ps; + } + + /// Why a placement cannot spawn right now, or null (D237: it waits, it is not removed). + private string WaitReason(Placement data) + { + Profile p; + if (!_profiles.TryGetValue(data.Profile, out p)) + return _refused.ContainsKey(data.Profile) ? $"profile '{data.Profile}' is refused: {_refused[data.Profile]}" : $"profile '{data.Profile}' is missing"; + Movement movement = data.Movement ?? p.Movement; + string kind, route; + ParseMode(movement.Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + return $"route '{route}' is missing"; + + // D272: a zone that has gone holds its placement back, as a missing route does, rather than + // letting NPCs that were meant to stay inside it roam the map. + if (data.Tether != null && (ZoneManager == null || ZoneManager.Call("CheckZoneID", data.Tether) == null)) + return ZoneManager == null ? $"its zone '{data.Tether}' needs ZoneManager, which is not loaded" : $"its zone '{data.Tether}' is missing"; + return null; + } + + /// Once a second: what each placement is waiting for, and which slots are due. + private void TickPlacements() + { + if (_placementsDirty) + SavePlacements(); + if (!_ready || _unloading || _holdForKits) + return; + + float now = Time.realtimeSinceStartup; + bool queued = false; + foreach (PlacementState ps in _placements.Values) + { + string waiting = WaitReason(ps.Data); + if (waiting != null) + { + if (ps.Waiting != waiting) + PrintWarning($"Placement '{ps.Id}' waits: {waiting}"); + ps.Waiting = waiting; + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + foreach (Slot s in ps.Slots) + { + s.DiedAt = float.NegativeInfinity; + s.Queued = false; + } + + continue; + } + + if (ps.Waiting != null) + Puts($"Placement '{ps.Id}' no longer waits."); + ps.Waiting = null; + + if (ps.Data.RespawnMode == "group") + { + if (ps.Slots.All(s => s.Npc == null && !s.Queued) && now >= ps.Slots.Max(s => s.DiedAt) + ps.Data.Respawn) + for (int i = 0; i < ps.Slots.Length; i++) + queued |= Enqueue(ps, i); + } + else + { + for (int i = 0; i < ps.Slots.Length; i++) + if (ps.Slots[i].Npc == null && !ps.Slots[i].Queued && now >= ps.Slots[i].DiedAt + ps.Data.Respawn) + queued |= Enqueue(ps, i); + } + } + + if (queued) + SchedulePump(); + } + + private bool Enqueue(PlacementState ps, int slot) + { + ps.Slots[slot].Queued = true; + _queue.Enqueue(new SpawnRequest { Placement = ps, Slot = slot }); + return true; + } + + private void SchedulePump() + { + if (_pumpScheduled || _unloading) + return; + _pumpScheduled = true; + NextFrame(Pump); + } + + private void PumpLater(float seconds) + { + _pumpScheduled = true; + timer.Once(seconds, () => + { + _pumpScheduled = false; + SchedulePump(); + }); + } + + /// + /// Spawns queued placement slots within the frame budget, and carries the rest to the next + /// frame (stage 1, Q5). Nothing spawns until the map's navmesh is built (Q4). + /// + private void Pump() + { + _pumpScheduled = false; + if (_unloading) + return; + + if (!NavmeshReady()) + { + if (!_navmeshWaitLogged) + Puts($"Waiting for the map's navmesh before spawning {_queue.Count} placement NPC(s); on a map's first boot this takes minutes."); + _navmeshWaitLogged = true; + PumpLater(5f); + return; + } + + Stopwatch clock = Stopwatch.StartNew(); + while (_queue.Count > 0 && clock.Elapsed.TotalMilliseconds < _config.SpawnBudgetMs) + { + SpawnRequest r = _queue.Peek(); + if (_config.Caps.SpawnsPerSecond > 0f && _tokens < 1f && !WouldHaveToken()) + { + PumpLater(0.25f); + return; + } + + _queue.Dequeue(); + Slot slot = r.Placement.Slots.Length > r.Slot ? r.Placement.Slots[r.Slot] : null; + if (slot == null || !_placements.ContainsKey(r.Placement.Id) || _placements[r.Placement.Id] != r.Placement) + continue; + slot.Queued = false; + if (slot.Npc != null || r.Placement.Waiting != null) + continue; + SpawnSlot(r.Placement, r.Slot); + } + + if (_queue.Count > 0) + SchedulePump(); + } + + private bool WouldHaveToken() + { + float rate = _config.Caps.SpawnsPerSecond; + return _tokens + (Time.realtimeSinceStartup - _tokensAt) * rate >= 1f; + } + + private void SpawnSlot(PlacementState ps, int slot) + { + Placement data = ps.Data; + Profile p = _profiles[data.Profile]; + Vector3 spot = data.Position.V; + Vector3 at = spot; + if (slot > 0) + { + // A scattered spot that leaves a small floor takes the placement's own spot instead. + Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; + at = spot + new Vector3(offset.x, 0f, offset.y); + if (!Still(p, data.Movement) && OffMeshReason(at) != null) + at = spot; + } + + // D239: the spot was on the navmesh when it was placed. If the world has changed under it (a + // player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns. + string off = Still(p, data.Movement) ? null : OffMeshReason(at); + NavMeshHit nearest; + if (off != null && SampleNavmesh(at, Fallback, out nearest)) + { + string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away"; + if (ps.Note != note) + PrintWarning($"Placement '{ps.Id}': {note}."); + ps.Note = note; + at = nearest.position; + } + else if (off == null && slot == 0) + ps.Note = null; + + string error; + RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error); + if (npc == null) + { + if (ps.LastError != error) + PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay."); + ps.LastError = error; + ps.Slots[slot].DiedAt = Time.realtimeSinceStartup; + return; + } + + ps.LastError = null; + ps.Slots[slot].Npc = npc; + npc.Tether = data.Tether; + } + + /// The count a placement plans, whether or not its NPCs are alive now. + private int PlannedTotal() => + _placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null); + + #endregion + + #region The cost warning (D227) + + /// + /// What NPCs cost the server, from stage 9's measurements of the + /// released NPC on both frameworks (PLAN.md §9, stage 9, D306; stage 1, Q5, first): the worst + /// of the two rigs, rounded up. Idle is said twice, because an NPC with no player within its + /// sleep distance sleeps (D235) and costs a sixth of an awake one. The warning says what NPCs + /// ADD to a frame, not an fps, since a server's own frame time varies far more than that. + /// + private static string CostWarning(int total) + { + if (total <= 0) + return "RunicNPC: no NPCs on this server."; + float hundreds = total / 100f; + string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : ""; + return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " + + $"{Mathf.Max(0.1f, hundreds * 3f):0.#} ms to every server frame while idle with a player near enough to wake them, " + + $"and about {Mathf.Max(0.1f, hundreds * 0.5f):0.#} ms once they sleep with nobody near. If all fight at once they add about " + + $"{hundreds * 9f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + + $"{Mathf.Max(0.3f, hundreds * 3f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " + + $"what fighting players does; on a map whose own AI is busy, each then reacts only every " + + $"{Mathf.Max(0.5f, hundreds * 7f):0.#} s."; + } + + #endregion + + #region Bosses (stage 6: D273–D277, D286, D287) + + [PluginReference] private Plugin PopupNotifications; + + private const string BarName = "rnpc.bar"; + private const string BarFill = "rnpc.bar.fill"; + private const string BarText = "rnpc.bar.text"; + + /// How far from the boss its adds are placed (D275), before each point is put on the navmesh. + private const float AddRing = 6f; + + /// The bosses alive now. + private readonly HashSet _bosses = new HashSet(); + + /// + /// Each boss's live adds, by the boss's net id, kept after the boss dies (D287) so that despawning + /// the boss's id removes them too. An add leaves its set when it dies or despawns. + /// + private readonly Dictionary> _addsOf = new Dictionary>(); + + /// Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274). + private readonly Dictionary _barShown = new Dictionary(); + + private int _barTick; + + private void BossSpawned(RunicNpcPlayer npc) + { + _bosses.Add(npc); + BossBlock b = npc.Profile.Boss; + if (!string.IsNullOrWhiteSpace(b.SpawnLine)) + Say(b, BossText(b.SpawnLine, npc, null), null); + Interface.CallHook("OnRunicNpcBossSpawned", npc, npc.ProfileName, npc.Owner); + } + + private void BossDied(RunicNpcPlayer npc, HitInfo info, Dictionary contributors) + { + BossBlock b = npc.Profile.Boss; + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + if (killer != null && killer.IsNpc) + killer = null; + if (!string.IsNullOrWhiteSpace(b.DeathLine)) + Say(b, BossText(b.DeathLine, npc, killer), null); + Interface.CallHook("OnRunicNpcBossDied", npc, npc.ProfileName, npc.Owner, info, contributors); + } + + /// A line's placeholders: {name} is the boss's name, {killer} the killing blow's player. + private static string BossText(string line, RunicNpcPlayer npc, BasePlayer killer) => + line.Replace("{name}", npc.RnName).Replace("{killer}", killer != null ? killer.displayName : ""); + + /// + /// D277, D286: a line said in game, to everyone ( null) or to the players + /// given. A popup where the profile asks for one and PopupNotifications is loaded; chat otherwise. + /// + private void Say(BossBlock b, string text, IEnumerable to) + { + bool popup = b.Announce == "popup" && PopupNotifications != null && PopupNotifications.IsLoaded; + if (to == null) + { + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, null, 0f); + else + PrintToChat(text); + return; + } + + foreach (BasePlayer p in to) + { + if (p == null || !p.IsConnected) + continue; + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, p, 0f); + else + p.ChatMessage(text); + } + } + + /// D286: the players a phase line reaches: within the bar's distance, or who have hurt it. + private static List Near(RunicNpcPlayer npc) + { + float r = npc.Profile.Boss.BarDistance; + Vector3 at = npc.transform.position; + return BasePlayer.activePlayerList + .Where(p => p != null && !p.IsNpc && ((p.transform.position - at).sqrMagnitude <= r * r || npc.Contributors.ContainsKey((ulong)p.userID))) + .ToList(); + } + + /// Called from the boss's Hurt: enters every phase its health has fallen to, highest first. + private void CheckPhases(RunicNpcPlayer npc, ulong attackerId) + { + float fraction = npc.health / npc.MaxHealth(); + foreach (Phase ph in npc.Profile.Boss.Phases.OrderByDescending(x => x.At)) + if (fraction <= ph.At && npc.PhasesFired.Add(ph.At)) + EnterPhase(npc, ph); + } + + /// D275: a phase changes the boss's own copy of its profile (Spawn made it), never the shared one. + private void EnterPhase(RunicNpcPlayer npc, Phase ph) + { + npc.Phase = npc.PhasesFired.Count; + Profile p = npc.Profile; + RunicNpcBrain brain = npc.RnBrain; + + if (ph.DamageDealt.HasValue) + { + p.DamageDealt = ph.DamageDealt.Value; + npc.damageScale = p.DamageDealt; + } + + if (ph.AimCone.HasValue) + { + p.AimCone = ph.AimCone.Value; + npc.aimConeScale = p.AimCone; + } + + if (ph.Ranges != null) + { + p.Ranges = new Ranges { Sense = ph.Ranges.Sense, LoseTarget = ph.Ranges.LoseTarget, Chase = ph.Ranges.Chase, Attack = ph.Ranges.Attack }; + if (brain != null) + { + brain.SenseRange = p.Ranges.Sense; + brain.TargetLostRange = p.Ranges.LoseTarget; + } + } + + // Rust sets the agent's speed from the navigator's on every move, so this holds. + if (ph.Speed.HasValue && brain?.Navigator != null) + { + if (npc.BaseSpeed < 0f) + npc.BaseSpeed = brain.Navigator.Speed; + brain.Navigator.Speed = npc.BaseSpeed * ph.Speed.Value; + } + + // The spike (finding 3): strip, give, equip. Without EquipWeapon it holds nothing. + if (ph.Kit != null) + { + npc.inventory.Strip(); + object given = Kits?.Call("GiveKit", npc, ph.Kit); + if (given is string) + PrintWarning($"Kits did not give '{ph.Kit}' to {npc.RnName} ({npc.ProfileName}) in phase {npc.Phase}: {given}"); + else + npc.Kit = ph.Kit; + npc.EquipWeapon(); + } + + if (ph.Adds != null) + SummonAdds(npc, ph.Adds); + + if (!string.IsNullOrWhiteSpace(ph.Line)) + Say(p.Boss, BossText(ph.Line, npc, null), Near(npc)); + + Interface.CallHook("OnRunicNpcBossPhase", npc, npc.ProfileName, npc.Owner, npc.Phase, ph.At); + } + + /// + /// D275, D287: adds of another profile on a ring around the boss, one per server frame (the + /// spike measured 4–8 ms each). They belong to the boss's owner, so an event's teardown removes + /// them, and they fight on after the boss dies. Each learns the boss's attackers nearby. + /// + private void SummonAdds(RunicNpcPlayer boss, Adds adds) + { + Profile p; + if (!_profiles.TryGetValue(adds.Profile, out p)) + { + PrintWarning($"{boss.RnName} ({boss.ProfileName}) cannot summon '{adds.Profile}': " + + (_refused.ContainsKey(adds.Profile) ? "it is refused: " + _refused[adds.Profile] : "there is no such profile")); + return; + } + + Vector3 centre = boss.transform.position; + string owner = boss.Owner; + ulong bossId = boss.NetId; + var attackers = boss.Contributors.Keys.ToList(); + float turn = UnityEngine.Random.value * Mathf.PI * 2f; + int i = 0; + Action next = null; + next = () => + { + if (_unloading || i >= adds.Count) + return; + float a = turn + i * Mathf.PI * 2f / adds.Count; + Vector3 ring = centre + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * AddRing; + string error; + NavMeshHit hit; + RunicNpcPlayer add = null; + if (SampleNavmesh(ring, 3f, out hit)) + add = Spawn(adds.Profile, p, hit.position, UnityEngine.Random.Range(0f, 360f), owner, null, null, -1, out error); + else + error = "no navmesh within 3 m of its spot"; + if (add == null) + PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}"); + else + { + HashSet mine; + if (!_addsOf.TryGetValue(bossId, out mine)) + _addsOf[bossId] = mine = new HashSet(); + mine.Add(add); + foreach (ulong id in attackers) + { + BasePlayer who = BasePlayer.FindByID(id); + if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f) + Learn(add, who, true); + } + } + + i++; + if (i < adds.Count) + NextFrame(next); + }; + next(); + } + + // ---- the bar (D274): the stage 6 spike's shape (finding 2) ---- + + /// + /// Four times a second: redraw each hurt boss's bar in place for its viewers. Once a second: + /// work out who sees which bar, adding and removing whole bars. + /// + private void BarTick() + { + if (_bosses.Count == 0 && _barShown.Count == 0) + return; + if (++_barTick % 4 == 0) + SweepViewers(); + foreach (RunicNpcPlayer boss in _bosses) + { + if (!boss.BarDirty || boss.IsDestroyed) + continue; + boss.BarDirty = false; + string json = null; + foreach (KeyValuePair kv in _barShown) + { + if (kv.Value != boss) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null || !p.IsConnected) + continue; + if (json == null) + json = BarJson(boss, true); + CuiHelper.AddUi(p, json); + } + } + } + + private void SweepViewers() + { + var want = new Dictionary(); + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || p.IsNpc || !p.IsConnected) + continue; + ulong id = (ulong)p.userID; + RunicNpcPlayer best = null; + float bestD = float.MaxValue; + foreach (RunicNpcPlayer b in _bosses) + { + if (b == null || b.IsDestroyed || b.IsDead()) + continue; + float d = (b.transform.position - p.transform.position).sqrMagnitude; + float r = b.Profile.Boss.BarDistance; + if ((d <= r * r || b.Contributors.ContainsKey(id)) && d < bestD) + { + best = b; + bestD = d; + } + } + + if (best != null) + want[id] = best; + } + + foreach (KeyValuePair kv in _barShown.ToList()) + { + RunicNpcPlayer now; + if (want.TryGetValue(kv.Key, out now) && now == kv.Value) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + + foreach (KeyValuePair kv in want) + { + if (_barShown.ContainsKey(kv.Key)) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null) + continue; + CuiHelper.AddUi(p, BarJson(kv.Value, false)); + _barShown[kv.Key] = kv.Value; + } + } + + /// A boss gone (death, despawn, unload): its bar comes off every screen showing it. + private void ClearBar(RunicNpcPlayer npc) + { + _bosses.Remove(npc); + foreach (KeyValuePair kv in _barShown.Where(x => x.Value == npc).ToList()) + { + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + } + + /// + /// The bar: a dark strip at the top of the screen, a red fill and the boss's name and health. + /// A redraw sends only the fill and the text, changed in place with CUI's update. + /// + private static string BarJson(RunicNpcPlayer boss, bool update) + { + float fraction = Mathf.Clamp01(boss.health / boss.MaxHealth()); + string text = $"{boss.RnName} {Mathf.CeilToInt(boss.health)} / {Mathf.CeilToInt(boss.MaxHealth())}"; + var c = new CuiElementContainer(); + if (!update) + c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.1 0.75" }, RectTransform = { AnchorMin = "0.35 0.905", AnchorMax = "0.65 0.935" } }, "Hud", BarName); + c.Add(new CuiPanel { Image = { Color = "0.72 0.12 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{fraction.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} 1" } }, BarName, BarFill); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, BarName, BarText); + if (update) + foreach (CuiElement e in c) + e.Update = true; + return CuiHelper.ToJson(c); + } + + #endregion + + #region Passive NPCs and press E (stage 6: D278, D279, D288) + + private const string WindowName = "rnpc.window"; + + /// How far a player's E reaches: Rust's own use distance is about this. + private const float UseReach = 3f; + + private static int _useMask; + + private readonly Dictionary _nextUse = new Dictionary(); + + /// + /// Neither framework raises a hook for E on an NPC, and Rust ignores it (stage 6 spike, finding + /// 1): so every player tick is checked for E pressed, at about 7 ns, and only then is a ray cast. + /// + private void OnPlayerInput(BasePlayer player, InputState input) + { + MarkHook("OnPlayerInput"); + if (input == null || !input.WasJustPressed(BUTTON.USE) || player == null || player.IsNpc) + return; + if (_useMask == 0) + _useMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + + RaycastHit hit; + if (!Physics.Raycast(player.eyes.HeadRay(), out hit, UseReach, _useMask, QueryTriggerInteraction.Ignore)) + return; + RunicNpcPlayer npc = hit.GetEntity() as RunicNpcPlayer; + if (npc?.Profile == null || !npc.Profile.IsPassive || npc.Profile.Use == null || npc.IsDead()) + return; + + ulong id = (ulong)player.userID; + float now = Time.realtimeSinceStartup, next; + if (_nextUse.TryGetValue(id, out next) && now < next) + return; + _nextUse[id] = now + 1f; + Use(npc, player); + } + + /// D278: a chat line picked at random, or a window. {player} in either is the player's name. + private void Use(RunicNpcPlayer npc, BasePlayer player) + { + UseBlock u = npc.Profile.Use; + if (u.Mode == "chat") + { + string line = u.Lines[UnityEngine.Random.Range(0, u.Lines.Count)].Replace("{player}", player.displayName); + player.ChatMessage($"{npc.RnName}: {line}"); + } + else + OpenWindow(player, npc, u); + + // One that stands turns to whoever spoke to it. + if (npc.RnBrain != null && npc.RnBrain.Sentry) + npc.SetAimDirection((player.eyes.position - npc.eyes.position).normalized); + + Interface.CallHook("OnRunicNpcUsed", npc, npc.ProfileName, player); + } + + private static void OpenWindow(BasePlayer player, RunicNpcPlayer npc, UseBlock u) + { + CuiHelper.DestroyUi(player, WindowName); + string title = string.IsNullOrWhiteSpace(u.Title) ? npc.RnName : u.Title; + var c = new CuiElementContainer(); + c.Add(new CuiPanel + { + Image = { Color = "0.08 0.08 0.08 0.92" }, + RectTransform = { AnchorMin = "0.32 0.3", AnchorMax = "0.68 0.72" }, + CursorEnabled = true + }, "Overlay", WindowName); + c.Add(new CuiLabel + { + Text = { Text = title.Replace("{player}", player.displayName), FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "0.91 0.76 0.44 1" }, + RectTransform = { AnchorMin = "0.05 0.85", AnchorMax = "0.95 0.96" } + }, WindowName); + c.Add(new CuiLabel + { + Text = { Text = (u.Text ?? "").Replace("{player}", player.displayName), FontSize = 14, Align = TextAnchor.UpperLeft, Color = "0.92 0.92 0.92 1" }, + RectTransform = { AnchorMin = "0.05 0.17", AnchorMax = "0.95 0.82" } + }, WindowName); + c.Add(new CuiButton + { + Button = { Close = WindowName, Color = "0.3 0.3 0.3 0.9" }, + Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter }, + RectTransform = { AnchorMin = "0.4 0.03", AnchorMax = "0.6 0.12" } + }, WindowName); + CuiHelper.AddUi(player, c); + } + + private void OnPlayerDisconnected(BasePlayer player, string reason) + { + MarkHook("OnPlayerDisconnected"); + if (player == null) + return; + ulong id = (ulong)player.userID; + _barShown.Remove(id); + _nextUse.Remove(id); + } + + #endregion + + #region The example profiles (stage 6: D280, D284, D289) + + private const string DataState = "RunicNPC/state"; + + /// The permission every example kit needs, which nobody is granted: no player can claim one by name (D284). + private const string ExamplePermission = "kits.runicnpc"; + + private sealed class StateFile + { + /// D284: set on the first load that wrote them, and never cleared. + [JsonProperty("examplesWritten")] public bool ExamplesWritten; + + /// D329: the example kits written that Kits has not loaded yet. The reload is owed until it has. + [JsonProperty("kitsReloadOwed")] public List KitsReloadOwed = new List(); + + /// D329: the loads that have asked for that reload. At most three, so a kit Kits never takes cannot loop. + [JsonProperty("kitsReloadTries")] public int KitsReloadTries; + } + + private const int KitsReloadTriesMax = 3; + + /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it). + private bool _holdForKits; + + /// Condition is filled in when the kit is written, from this Rust's item definition (D324). + private static JObject ExItem(string shortname, int amount, int position) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = 0f, MaxCondition = 0f, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject + { + ["Name"] = name, ["Description"] = "A RunicNPC example kit, for its example NPCs", ["RequiredPermission"] = ExamplePermission, + ["MaximumUses"] = 0, ["RequiredAuth"] = 0, ["Cooldown"] = 0, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray(); + + /// + /// D289: the four kits, each in its own outfit (D323): the raider in scrap armour, the camp guard in metal, + /// the sniper in hide, the Juggernaut in heavy plate. A short name this Rust does not know is left out with + /// a warning. + /// + private static Dictionary ExampleKits() => new Dictionary + { + ["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) }, + Wear("coffeecan.helmet", "roadsign.jacket", "roadsign.kilt", "hoodie", "pants", "shoes.boots", "burlap.gloves"), + new[] { ExItem("rifle.semiauto", 1, 0) }), + ["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) }, + Wear("metal.facemask", "metal.plate.torso", "tshirt.long", "pants", "shoes.boots", "roadsign.gloves"), + new[] { ExItem("smg.thompson", 1, 0) }), + ["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) }, + Wear("hat.boonie", "mask.balaclava", "attire.hide.poncho", "attire.hide.pants", "attire.hide.boots"), + new[] { ExItem("rifle.bolt", 1, 0) }), + ["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) }, + Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0) }) + }; + + private static JObject ExRanges(float sense, float lose, float chase, float attack) => + JObject.FromObject(new { sense, loseTarget = lose, chase, attack }); + + /// D289: the four profiles. + private static Dictionary ExampleProfiles() => new Dictionary + { + ["raider"] = new JObject + { + ["names"] = new JArray("Raider"), ["kits"] = new JArray("rnpc_raider"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 40f }), ["health"] = 150f, + ["ranges"] = ExRanges(30f, 40f, 40f, 30f) + }, + ["campguard"] = new JObject + { + ["names"] = new JArray("Camp Guard"), ["kits"] = new JArray("rnpc_campguard"), ["role"] = "guard", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 10f }), ["health"] = 200f, + ["ranges"] = ExRanges(35f, 45f, 30f, 30f) + }, + ["sniper"] = new JObject + { + ["names"] = new JArray("Sniper"), ["kits"] = new JArray("rnpc_sniper"), ["role"] = "sentry", ["health"] = 120f, + ["aimCone"] = 1f, ["ranges"] = ExRanges(120f, 130f, 0f, 120f) + }, + ["juggernaut"] = new JObject + { + ["names"] = new JArray("Juggernaut"), ["kits"] = new JArray("rnpc_juggernaut"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 30f }), ["health"] = 2500f, + ["ranges"] = ExRanges(40f, 50f, 40f, 35f), + ["boss"] = JObject.FromObject(new + { + barDistance = 100f, + announce = "chat", + spawnLine = "The Juggernaut has risen!", + deathLine = "The Juggernaut has fallen.", + phases = new object[] + { + new { at = 0.5f, adds = new { profile = "raider", count = 3 }, line = "The Juggernaut calls for help!" }, + new { at = 0.25f, damageDealt = 1.5f, speed = 1.3f, line = "The Juggernaut is enraged!" } + } + }) + } + }; + + /// + /// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no + /// API that creates one, D280) and the four profiles into ours, each only where its name is free. + /// The flag is set before anything is written, so this runs once whatever happens. The kits it wrote + /// are then owed a Kits reload (D329), which SettleKitsReload asks for; it reloads RunicNPC too (D283). + /// + private void WriteExamplesOnce() + { + StateFile state = ReadData(DataState); + if (state.ExamplesWritten) + return; + state.ExamplesWritten = true; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + + var addedKits = new List(); + try + { + JObject file = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data") ?? new JObject(); + JObject kits = file["_kits"] as JObject; + if (kits == null) + file["_kits"] = kits = new JObject(); + foreach (KeyValuePair kv in ExampleKits()) + { + if (kits.Properties().Any(x => string.Equals(x.Name, kv.Key, StringComparison.OrdinalIgnoreCase))) + continue; + foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" }) + { + var known = new JArray(); + foreach (JToken item in (JArray)kv.Value[list]) + { + ItemDefinition def = ItemManager.FindItemDefinition((string)item["Shortname"]); + if (def == null) + { + PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out."); + continue; + } + + // D324: Kits sets an item's condition and maximum straight from the file, so each is + // written at this Rust's full durability for it (0 where the item has none). + float max = def.condition.enabled ? def.condition.max : 0f; + item["Condition"] = max; + item["MaxCondition"] = max; + known.Add(item); + } + + kv.Value[list] = known; + } + + kits[kv.Key] = kv.Value; + addedKits.Add(kv.Key); + } + + if (addedKits.Count > 0) + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + } + catch (Exception e) + { + PrintError($"The example kits could not be written into Kits' data file: {e.Message}"); + addedKits.Clear(); + } + + var addedProfiles = new List(); + if (!_profileFile.Managed) + { + foreach (KeyValuePair kv in ExampleProfiles()) + { + if (_profileFile.Profiles.ContainsKey(kv.Key)) + continue; + _profileFile.Profiles[kv.Key] = kv.Value.ToObject(); + addedProfiles.Add(kv.Key); + } + + if (addedProfiles.Count > 0) + SaveProfiles(); + } + + Puts($"First load: example kits written into Kits' data file: {(addedKits.Count > 0 ? string.Join(", ", addedKits.ToArray()) : "none (their names are taken)")}; " + + $"example profiles: {(addedProfiles.Count > 0 ? string.Join(", ", addedProfiles.ToArray()) : _profileFile.Managed ? "none (a site manages this server's profiles)" : "none (their names are taken)")}. " + + "They are never written again, even if you delete them."); + + if (addedKits.Count == 0) + return; + + // Kits reads its file only when it loads (stage 6 spike, finding 5), so a reload is owed; SettleKitsReload asks for it. + state.KitsReloadOwed = addedKits; + state.KitsReloadTries = 0; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + } + + /// + /// D329: asks Kits to reload while the example kits written are still owed, and clears the debt once Kits + /// knows them all. The reload runs on a timer, which dies with RunicNPC: in the 9d session a second load of + /// RunicNPC within that second lost it, and the examples stayed refused until Kits was reloaded by hand. + /// Owing it in the state file lets any later load (or Kits coming back) finish the job. + /// + private void SettleKitsReload() + { + StateFile state = ReadData(DataState); + if (state.KitsReloadOwed == null || state.KitsReloadOwed.Count == 0) + return; + + string command = Framework() == "carbon" ? "c.reload" : "oxide.reload"; + if (state.KitsReloadOwed.All(KitExists) || state.KitsReloadTries >= KitsReloadTriesMax) + { + if (state.KitsReloadTries >= KitsReloadTriesMax && !state.KitsReloadOwed.All(KitExists)) + PrintWarning($"Kits has still not loaded the example kits ({string.Join(", ", state.KitsReloadOwed.ToArray())}) after " + + $"{KitsReloadTriesMax} reloads. Run `{command} Kits` once."); + state.KitsReloadOwed = new List(); + state.KitsReloadTries = 0; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + return; + } + + state.KitsReloadTries++; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + + // The reload takes RunicNPC down and back with it (D283); if this framework does not, OnPluginLoaded("Kits") + // picks the profiles up. Placements wait for it. + _holdForKits = true; + timer.Once(60f, () => _holdForKits = false); + timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits")); + } + + #endregion + + #region Loot (stage 7: D290–D301) + + private static readonly FieldInfo HackSecondsField = + typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + /// What the last corpses and crates got, for rnpc.status and the harness. + public static long LootRolls, LootCrateDrops, LootItems; + + /// + /// One of ours' corpse is about to be filled. Rust has just emptied its containers (all but the clothing) + /// and will next call its own ApplyLoot (Rust's scientist loot) unless this hook answers with the + /// corpse (stage 7 build). A subclass cannot do it itself: ApplyLoot is protected on Oxide and + /// public on Carbon, so no one override compiles on both (stage 7 spike). Here the corpse's start is made + /// (D290, D300), the table rolled into it or into a crate (D291, D292), and the corpse's time set (D294). + /// It answers with the corpse only for one of ours whose start is kit or none, to skip + /// Rust's loot; null for everything else. + /// + private object OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse) + { + MarkHook("OnCorpsePopulate"); + RunicNpcPlayer ours = npc as RunicNpcPlayer; + LootBlock l = ours?.Profile?.Loot; + if (l == null || corpse == null || corpse.containers == null || corpse.containers.Length == 0) + return null; + + // D290, D300: the start. + if (l.Start == "none" || l.Start == "kit") + { + // Container 1 is the clothing it wore: a kit start keeps it, "none" empties it too (D300). + for (int i = 0; i < corpse.containers.Length; i++) + if (l.Start == "none" || i != 1) + corpse.containers[i].Clear(); + ItemManager.DoRemoves(); + if (l.Start == "kit") + { + // What it carried, taken out of its inventory as it died (OnDied): by the time Rust fills the + // corpse it has emptied the NPC's inventory (stage 7 build). + if (ours.KitCarried != null) + { + foreach (Item item in ours.KitCarried) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + ours.KitCarried = null; + } + } + } + + // D291, D292, D296: the table. + if (l.DropTable) + { + List rolled = RollLoot(l); + LootRolls++; + if (rolled.Count > 0) + { + if (l.Crate != null) + DropCrate(l, corpse.transform.position, rolled); + else + foreach (Item item in rolled) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + } + } + + // D294: the corpse's time. Zero removes it once Rust has finished making it. + if (l.CorpseSeconds.HasValue) + { + if (l.CorpseSeconds.Value <= 0f) + NextTick(() => + { + if (corpse != null && !corpse.IsDestroyed) + corpse.Kill(); + }); + else + corpse.ResetRemovalTime(l.CorpseSeconds.Value); + } + + // A kit or empty start skips Rust's own loot; a "rust" start lets Rust add it after the table. + return l.Start == "kit" || l.Start == "none" ? corpse : null; + } + + /// + /// D291: every always-row rolls its own chance; the pool draws up to pick times by weight without + /// putting a row back, and a draw of "nothing" uses up a draw. So the pool never pays out more than + /// pick rows, whatever the luck. + /// + private static List RollLoot(LootBlock l) + { + var items = new List(); + foreach (LootRow r in l.Always) + if (UnityEngine.Random.value <= r.Chance) + AddRolled(items, r); + + var left = new List(l.Pool.Rows); + for (int draw = 0; draw < l.Pool.Pick && left.Count > 0; draw++) + { + float total = l.Pool.Nothing + left.Sum(r => r.Weight); + float x = UnityEngine.Random.value * total; + if (x < l.Pool.Nothing) + continue; + x -= l.Pool.Nothing; + for (int i = 0; i < left.Count; i++) + { + if (x < left[i].Weight || i == left.Count - 1) + { + AddRolled(items, left[i]); + left.RemoveAt(i); + break; + } + + x -= left[i].Weight; + } + } + + LootItems += items.Count; + return items; + } + + private static void AddRolled(List items, LootRow r) + { + Item item = ItemManager.CreateByName(r.Item, UnityEngine.Random.Range(r.Min, r.Max + 1), r.Skin); + if (item != null) + items.Add(item); + } + + /// + /// D292, D297, D301: one of Rust's crates beside the corpse, with Rust's own loot and refresh switched off + /// before it spawns (stage 7 spike), holding the table. It stays until emptied, as Rust's crates do; a + /// locked crate keeps Rust's own decay. Its hack time is its starting count, which Rust never resets. + /// + private void DropCrate(LootBlock l, Vector3 at, List items) + { + KeyValuePair kind = LootCrates[l.Crate]; + Vector3 spot = at + new Vector3(1.2f, 0f, 0f); + spot.y = TerrainMeta.HeightMap.GetHeight(spot); + RaycastHit hit; + if (Physics.Raycast(spot + Vector3.up * 3f, Vector3.down, out hit, 6f, LayerMask.GetMask("Terrain", "World", "Construction"), QueryTriggerInteraction.Ignore)) + spot = hit.point; + LootContainer crate = GameManager.server.CreateEntity(kind.Key, spot, Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f)) as LootContainer; + if (crate == null) + { + PrintWarning($"The {l.Crate} crate could not be made; its loot is dropped where the NPC died."); + foreach (Item item in items) + item.Drop(at + Vector3.up, Vector3.zero); + return; + } + + crate.initialLootSpawn = false; + crate.minSecondsBetweenRefresh = -1f; + crate.maxSecondsBetweenRefresh = -1f; + crate.enableSaving = false; + crate.Spawn(); + foreach (Item item in items) + if (!item.MoveToContainer(crate.inventory)) + item.Drop(spot + Vector3.up, Vector3.zero); + + HackableLockedCrate locked = crate as HackableLockedCrate; + if (locked != null && HackSecondsField != null) + HackSecondsField.SetValue(locked, HackableLockedCrate.requiredHackSeconds - l.HackSeconds); + LootCrateDrops++; + } + + #endregion + + #region Lifecycle and hooks private void Init() { + _self = this; foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; + RegisterVerbs(); } private void OnServerInitialized() { MarkHook("OnServerInitialized"); - Puts($"RunicNPC {Version} loaded, API {ApiVersion}. Stage 0: it spawns nothing yet."); + ResolveFields(); + LoadData(); + WriteExamplesOnce(); + SettleKitsReload(); + RevalidateProfiles(); + RebuildPlacements(); + TrackWorld(); + PatchSentries(); + _ready = true; + timer.Every(1f, TickPlacements); + timer.Every(0.25f, BarTick); + Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + + $"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}"); + TickPlacements(); + } + + private void OnServerSave() + { + MarkHook("OnServerSave"); + if (_placementsDirty) + SavePlacements(); + } + + private void OnEntityKill(BaseNetworkable entity) + { + MarkHook("OnEntityKill"); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc != null) + Unregister(npc); + else + Untrack(entity); + } + + /// A plugin's NPCs die with it (§2: plugin:<name>). + private void OnPluginUnloaded(Plugin plugin) + { + MarkHook("OnPluginUnloaded"); + if (plugin == null || plugin == this) + return; + string owner = "plugin:" + plugin.Name; + int n = Despawn(_npcs.Values.Where(x => x.Owner == owner)); + if (n > 0) + Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed."); + } + + /// + /// Kits came back (a reload, or the example kits' own, D284): the profiles' kits are checked again + /// and held placements go on. Where the framework reloads RunicNPC with Kits (D283), this is moot. + /// + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + if (plugin == null || plugin.Name != "Kits" || !_ready) + return; + _holdForKits = false; + SettleKitsReload(); + RevalidateProfiles(); + TickPlacements(); + } + + private void Unload() + { + _unloading = true; + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || !p.IsConnected) + continue; + CuiHelper.DestroyUi(p, BarName); + CuiHelper.DestroyUi(p, WindowName); + } + + _barShown.Clear(); + UnpatchSentries(); + Despawn(_npcs.Values); + _npcs.Clear(); + if (_placementsDirty) + SavePlacements(); + _self = null; } private void MarkHook(string name) @@ -73,23 +4599,1761 @@ namespace Oxide.Plugins _hookCounts[name] = count + 1; } - // ---- the API (PLAN.md §4) ---- - // - // Every call is private and prefixed `RunicNpc_`. Private because Oxide's `Call` reaches a - // non-public method by name, and a PUBLIC one only when it carries [HookMethod] — the trap - // HumanNPC's RefreshNPC and RemoveNPC fell into (PLAN.md §1.2). Prefixed because `Call` - // matches by name alone, and no hook of any other plugin may ever match one of ours. + #endregion + + #region The API (PLAN.md §4, docs/runicnpc/API.md) + + // Every call is private: Oxide's Call reaches a non-public method by name, and a public one only + // with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log. - /// The API's version. A caller that needs more refuses this RunicNPC and says so. private int RunicNpc_ApiVersion() => ApiVersion; - // ---- console ---- + private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides) + { + string error = null; + RunicNpcPlayer npc = null; + Profile p; + if (owner == null || !OwnerRule.IsMatch(owner)) + error = "owner must be run: or plugin: (placement: owners are RunicNPC's own)"; + else if (profile == null || !_profiles.TryGetValue(profile, out p)) + error = profile != null && _refused.ContainsKey(profile) ? $"profile '{profile}' is refused: {_refused[profile]}" : $"no profile '{profile}'"; + else + { + Profile effective = p; + if (overrides != null) + { + try + { + effective = Effective(p, overrides); + error = ValidateProfile(effective); + } + catch (Exception e) + { + error = "overrides could not be applied: " + e.Message; + } + } + + if (error == null) + npc = Spawn(profile, effective, at, 0f, owner, null, null, -1, out error); + } + + if (npc == null) + PrintWarning($"RunicNpc_Spawn('{profile}', {owner}) refused: {error}"); + return npc; + } /// - /// What this RunicNPC is and which of its hooks have fired. Answers over RCON as well - /// as at the console, and is the first thing to ask for when a server says it has no - /// RunicNPC. + /// Removes one NPC, and for a boss the adds it summoned, alive or not (D287): an event's teardown + /// removes what its step placed by net id, and a boss's adds are in no step's ledger. The boss may + /// already be dead; its adds are still found by its net id. /// + private int RunicNpc_Despawn(ulong netId) + { + var gone = new List(); + RunicNpcPlayer npc; + if (_npcs.TryGetValue(netId, out npc)) + gone.Add(npc); + HashSet adds; + if (_addsOf.TryGetValue(netId, out adds)) + { + gone.AddRange(adds); + _addsOf.Remove(netId); + } + + return Despawn(gone); + } + + private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner)); + + private List> RunicNpc_List(string owner) => + _npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList(); + + private static Dictionary Describe(RunicNpcPlayer n) + { + Vector3 p = n.transform.position; + RunicNpcBrain brain = n.RnBrain; + return new Dictionary + { + ["netId"] = n.NetId, + ["profile"] = n.ProfileName, + ["name"] = n.RnName, + ["owner"] = n.Owner, + ["placement"] = n.PlacementId, + ["kit"] = n.Kit, + ["position"] = new Dictionary { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z }, + ["home"] = new Dictionary { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z }, + ["health"] = n.health, + ["maxHealth"] = n.MaxHealth(), + ["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind, + ["rest"] = brain != null ? brain.Rest.ToString() : "?", + ["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none", + ["leashes"] = brain != null ? brain.Leashes : 0, + ["regrounded"] = brain != null ? brain.Regrounded : 0, + ["faction"] = n.Profile != null ? FactionOf(n) : null, + ["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null, + ["engaged"] = n.Engaged, + ["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null, + ["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null, + ["tether"] = n.Tether, + ["shotsMarked"] = n.ShotsMarked, + ["role"] = n.Profile?.Role, + ["boss"] = n.IsBoss ? new Dictionary { ["phase"] = n.Phase, ["damagers"] = n.Contributors.Count } : null, + ["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits } + }; + } + + private JObject RunicNpc_Profiles() + { + JObject all = JObject.FromObject(_profileFile); + all["refused"] = JObject.FromObject(_refused); + return all; + } + + /// + /// Replaces the whole profile set, as a site does on every push (D221), and marks the + /// server managed. Returns the profiles refused and why; the rest are in use at once. + /// + private Dictionary RunicNpc_SetProfiles(JObject all) + { + Dictionary profiles; + try + { + if (all != null) + foreach (JProperty p in all.Properties()) + NormalizePassive(p.Value as JObject); + profiles = all != null ? all.ToObject>() : new Dictionary(); + } + catch (Exception e) + { + return new Dictionary { ["*"] = "the profile set could not be read: " + e.Message }; + } + + _profileFile.Profiles = profiles; + _profileFile.Managed = true; + SaveProfiles(); + RevalidateProfiles(); + TickPlacements(); + return new Dictionary(_refused); + } + + private List> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary + { + ["id"] = ps.Id, + ["placement"] = JObject.FromObject(ps.Data), + ["alive"] = ps.Slots.Count(s => s.Npc != null), + ["waiting"] = ps.Waiting, + ["lastError"] = ps.LastError, + ["note"] = ps.Note + }).ToList(); + + /// Adds or replaces a placement (D222). Null on success; otherwise why not. + private string RunicNpc_SetPlacement(string id, JObject placement) + { + if (id == null || !NameRule.IsMatch(id)) + return "id: 1–40 of a-z, 0-9, _ and -"; + Placement data; + try + { + data = placement?.ToObject(); + } + catch (Exception e) + { + return "the placement could not be read: " + e.Message; + } + + return SetPlacement(id, data); + } + + /// + /// Adds or replaces a placement, for the API and rnpc place alike. A roamer's spot must + /// be on Rust's navmesh when it is set (D219); if the world changes under it later, its NPCs + /// stand on the nearest navmesh instead (D239, ). + /// + private string SetPlacement(string id, Placement data) + { + string error = ValidatePlacement(data); + if (error != null) + return error; + + Profile profile; + if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !Still(profile, data.Movement)) + { + string off = OffMeshReason(data.Position.V); + if (off != null) + return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)"; + } + + error = TetherRefusal(data.Tether, data.Position.V); + if (error != null) + return error; + + PlacementState old; + if (_placements.TryGetValue(id, out old)) + Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null)); + + _placementFile.Placements[id] = data; + _placements[id] = NewState(id, data); + _placementsDirty = true; + Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}"); + TickPlacements(); + PlacementChanged(id, "set"); + return null; + } + + private bool RunicNpc_RemovePlacement(string id) + { + PlacementState ps; + if (id == null || !_placements.TryGetValue(id, out ps)) + return false; + _placements.Remove(id); + _placementFile.Placements.Remove(id); + _placementsDirty = true; + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + PlacementChanged(id, "removed"); + return true; + } + + /// + /// API 3 (D249): creates a placement and names it as rnpc place does (D241, D246). A + /// position without y is a point on a map: it is put on the ground there (terrain and + /// rock, never a building, D245) and then checked against the navmesh like any other. + /// Answers {id, position, built, cost}, or {error}. + /// + private Dictionary RunicNpc_AddPlacement(JObject placement) + { + Placement data; + try + { + data = placement?.ToObject(); + } + catch (Exception e) + { + return new Dictionary { ["error"] = "the placement could not be read: " + e.Message }; + } + + if (data != null && data.Position != null && placement["position"]?["y"] == null) + { + Vector3 ground; + string off = GroundAt(data.Position.X, data.Position.Z, out ground); + if (off != null) + return new Dictionary { ["error"] = off }; + data.Position = Pos.Of(ground); + } + + string id; + bool built; + string error = CreatePlacement(data, out id, out built); + if (error != null) + { + PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}"); + return new Dictionary { ["error"] = error }; + } + + return new Dictionary + { + ["id"] = id, + ["position"] = new Dictionary { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z }, + ["built"] = built, + ["cost"] = CostWarning(PlannedTotal()) + }; + } + + /// API 3 (D249): renames a placement, as rnpc rename does. Null on success. + private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to); + + /// API 3 (D249): respawns a placement's NPCs now, as rnpc respawn. How many, or -1 if there is no such placement. + private int RunicNpc_RespawnPlacement(string id) + { + PlacementState ps; + if (id == null || !_placements.TryGetValue(id, out ps)) + return -1; + int n = RespawnNow(ps); + TickPlacements(); + return n; + } + + /// + /// The ground under a point of the map: terrain or rock, never anything built or a tree (D245: + /// a roof is an in-game placement). Null with the spot, or the reason there is none. + /// + private static string GroundAt(float x, float z, out Vector3 ground) + { + ground = Vector3.zero; + float half = World.Size / 2f; + if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half) + return $"({x:0}, {z:0}) is off the map"; + RaycastHit hit; + if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore)) + ground = hit.point; + else + ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z); + if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f) + return $"({x:0}, {z:0}) is under water"; + return null; + } + + /// + /// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in + /// game alike. change is set, removed or renamed (with previous). + /// + private static void PlacementChanged(string id, string change, string previous = null) => + Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous); + + private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes); + + /// Adds or replaces a route (D234). Every point must be on Rust's navmesh. + private string RunicNpc_SetRoute(string name, JObject route) + { + if (name == null || !NameRule.IsMatch(name)) + return "name: 1–40 of a-z, 0-9, _ and -"; + Route data; + try + { + data = route?.ToObject(); + } + catch (Exception e) + { + return "the route could not be read: " + e.Message; + } + + return SetRoute(name, data); + } + + private string SetRoute(string name, Route data) + { + string error = ValidateRoute(data); + if (error != null) + return error; + _routeFile.Routes[name] = data; + SaveRoutes(); + TickPlacements(); + return null; + } + + private bool RunicNpc_RemoveRoute(string name) + { + if (name == null || !_routeFile.Routes.Remove(name)) + return false; + SaveRoutes(); + return true; + } + + private bool RunicNpc_IsRunicNpc(BaseEntity entity) => entity is RunicNpcPlayer; + + private string RunicNpc_ProfileOf(BaseEntity entity) => (entity as RunicNpcPlayer)?.ProfileName; + + /// The cost warning (D227) for the NPCs this server plans, plus . + private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding)); + + // ---- API 4 (stage 5) ---- + + /// API 4 (D254): the faction table, one row per pair, both ways (D268). + private JArray RunicNpc_Factions() => JArray.FromObject(_profileFile.Factions); + + /// + /// API 4 (D254): replaces the faction table, as a site does with every profile push, and marks + /// the server managed (D221). Null on success; otherwise why not, and nothing changes. + /// + private string RunicNpc_SetFactions(JArray pairs) + { + List table; + try + { + table = pairs != null ? pairs.ToObject>() : new List(); + } + catch (Exception e) + { + return "the faction table could not be read: " + e.Message; + } + + string error = ValidateFactions(table); + if (error != null) + return error; + _profileFile.Factions = table; + _profileFile.Managed = true; + SaveProfiles(); + RevalidateProfiles(); + return null; + } + + /// + /// API 4 (D258, D269): one of ours escorts an entity, a player or anything else; null stops it. + /// Null on success, else why not. + /// + private string RunicNpc_Escort(ulong netId, BaseEntity target) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetEscort(npc, target); + } + + /// + /// API 4 (D257, D270): one of ours is allied to a clan (clan, the game's clan id), a team + /// (team), or a player and that player's team (player, a Steam id), or to nobody + /// (none). Null on success, else why not. + /// + private string RunicNpc_Ally(ulong netId, string kind, string id) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetAlly(npc, kind, id); + } + + /// API 4 (D272): holds one of ours inside a ZoneManager zone, or frees it with null. + private string RunicNpc_Tether(ulong netId, string zone) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + if (zone != null && npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it needs no zone"; + string error = TetherRefusal(zone, npc.transform.position); + if (error != null) + return error; + npc.Tether = zone; + return null; + } + + private string SetAlly(RunicNpcPlayer npc, string kind, string id) + { + long clan = 0L; + ulong team = 0UL, player = 0UL; + switch (kind) + { + case "none": + case null: + break; + case "clan": + if (!long.TryParse(id, out clan) || clan == 0L) + return "a clan is the game's clan id"; + if (ReadClan(clan) == null) + return $"this server has no clan {id}"; + break; + case "team": + if (!ulong.TryParse(id, out team) || team == 0UL || RelationshipManager.ServerInstance?.FindTeam(team) == null) + return $"this server has no team {id}"; + break; + case "player": + if (!ulong.TryParse(id, out player) || player == 0UL) + return "a player is a Steam id"; + BasePlayer p = BasePlayer.FindAwakeOrSleepingByID(player); + if (p != null && p.currentTeam != 0UL) + team = p.currentTeam; + break; + default: + return $"'{kind}' is not clan, team, player or none"; + } + + npc.AllyClan = clan; + npc.AllyTeam = team; + npc.AllyPlayer = player; + NoteDefender(npc); + return null; + } + + #endregion + + #region In game: the commands (PLAN.md §5, stage 3) + + // `/rnpc ` in chat and `rnpc.` in a console run the same code. Each verb sits behind a + // permission; the server console has them all. `runicnpc.admin` includes `runicnpc.place`. + + private const string PermPlace = "runicnpc.place"; + private const string PermAdmin = "runicnpc.admin"; + + /// How far an admin's look reaches, for place, info and remove. + private const float LookReach = 100f; + + /// How far below or around a spot the navmesh is looked for when saying why it is off it. + private const float Fallback = 20f; + + private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); + private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed"); + + /// What a map point is put down on (D245): terrain and rock, never a building or a tree. + private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World"); + + /// Who ran a command, and where the answer goes. + private sealed class Caller + { + /// Null for the server console. + public BasePlayer Player; + public bool Chat; + public readonly List Lines = new List(); + + public string Key => Player != null ? Player.UserIDString : "server"; + + public void Say(string line) => Lines.Add(line); + } + + private sealed class Verb + { + public string Permission; + public string Usage; + public Action Run; + public bool ConsoleOnly; + } + + private Dictionary _verbs; + + /// A route being recorded, per admin (D240). Kept in memory only: a reload discards it. + private sealed class Recording + { + public string Name; + public readonly List Points = new List(); + } + + private readonly Dictionary _recordings = new Dictionary(); + + private void RegisterVerbs() + { + permission.RegisterPermission(PermPlace, this); + permission.RegisterPermission(PermAdmin, this); + _verbs = new Dictionary + { + ["place"] = new Verb { Permission = PermPlace, Usage = "place [count= respawn= mode=each|group move=wander|monument|route: radius=]", Run = (c, a) => CmdPlace(c, a, false) }, + ["here"] = new Verb { Permission = PermPlace, Usage = "here [the same options]", Run = (c, a) => CmdPlace(c, a, true) }, + ["remove"] = new Verb { Permission = PermPlace, Usage = "remove [placement]", Run = CmdRemove }, + ["rename"] = new Verb { Permission = PermPlace, Usage = "rename ", Run = CmdRename }, + ["near"] = new Verb { Permission = PermPlace, Usage = "near [radius]", Run = CmdNear }, + ["info"] = new Verb { Permission = PermPlace, Usage = "info", Run = CmdInfo }, + ["profiles"] = new Verb { Permission = PermPlace, Usage = "profiles", Run = CmdProfiles }, + ["path"] = new Verb { Permission = PermPlace, Usage = "path record | point | undo | save [loop|back] | cancel | list | delete ", Run = CmdPath }, + ["tp"] = new Verb { Permission = PermAdmin, Usage = "tp ", Run = CmdTp }, + ["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn ", Run = CmdRespawn }, + ["clear"] = new Verb { Permission = PermAdmin, Usage = "clear ", Run = CmdClear }, + ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true }, + ["follow"] = new Verb { Permission = PermPlace, Usage = "follow ", Run = CmdFollow }, + ["faction"] = new Verb { Permission = PermAdmin, Usage = "faction list | set hostile|neutral|allied | clear ", Run = CmdFaction, ConsoleOnly = true } + }; + } + + private bool Allowed(Caller c, string perm) => + c.Player == null || permission.UserHasPermission(c.Player.UserIDString, PermAdmin) || + (perm == PermPlace && permission.UserHasPermission(c.Player.UserIDString, PermPlace)); + + private void Dispatch(Caller c, string verb, string[] args) + { + Verb v; + if (verb == null || verb == "help" || !_verbs.TryGetValue(verb, out v)) + { + if (verb != null && verb != "help") + c.Say($"There is no '{verb}'."); + string prefix = c.Chat ? "/rnpc " : "rnpc."; + List mine = _verbs.Where(kv => Allowed(c, kv.Value.Permission) && !(c.Chat && kv.Value.ConsoleOnly)) + .Select(kv => prefix + kv.Value.Usage).ToList(); + if (mine.Count == 0) + c.Say("You have no RunicNPC permissions."); + else + c.Lines.AddRange(mine); + return; + } + + if (!Allowed(c, v.Permission)) + { + c.Say($"You need the {v.Permission} permission."); + return; + } + + if (c.Chat && v.ConsoleOnly) + { + c.Say($"'{verb}' is a console command: open the console (F1) and type rnpc.{verb}."); + return; + } + + if (!_ready) + { + c.Say("RunicNPC is still starting."); + return; + } + + try + { + v.Run(c, args); + } + catch (Exception e) + { + c.Say($"'{verb}' failed: {e.Message}"); + PrintError($"rnpc {verb} {string.Join(" ", args)}: {e}"); + } + } + + [ChatCommand("rnpc")] + private void CmdChat(BasePlayer player, string command, string[] args) + { + var c = new Caller { Player = player, Chat = true }; + Dispatch(c, args.Length > 0 ? args[0].ToLowerInvariant() : null, args.Skip(1).ToArray()); + if (c.Lines.Count > 0) + player.ChatMessage(string.Join("\n", c.Lines.ToArray())); + } + + private void RunConsole(ConsoleSystem.Arg arg, string verb) + { + BasePlayer player = arg.Player(); + if (arg.Connection != null && player == null) + return; + var c = new Caller { Player = player }; + // Rust's Args element type has changed between versions (string, then StringView), so + // read each through GetString, which both have. + int count = arg.Args?.Length ?? 0; + Dispatch(c, verb, Enumerable.Range(0, count).Select(i => arg.GetString(i)).ToArray()); + arg.ReplyWith(string.Join(Environment.NewLine, c.Lines.ToArray())); + } + + [ConsoleCommand("rnpc.place")] private void CcPlace(ConsoleSystem.Arg arg) => RunConsole(arg, "place"); + [ConsoleCommand("rnpc.here")] private void CcHere(ConsoleSystem.Arg arg) => RunConsole(arg, "here"); + [ConsoleCommand("rnpc.remove")] private void CcRemove(ConsoleSystem.Arg arg) => RunConsole(arg, "remove"); + [ConsoleCommand("rnpc.rename")] private void CcRename(ConsoleSystem.Arg arg) => RunConsole(arg, "rename"); + [ConsoleCommand("rnpc.near")] private void CcNear(ConsoleSystem.Arg arg) => RunConsole(arg, "near"); + [ConsoleCommand("rnpc.info")] private void CcInfo(ConsoleSystem.Arg arg) => RunConsole(arg, "info"); + [ConsoleCommand("rnpc.profiles")] private void CcProfiles(ConsoleSystem.Arg arg) => RunConsole(arg, "profiles"); + [ConsoleCommand("rnpc.path")] private void CcPath(ConsoleSystem.Arg arg) => RunConsole(arg, "path"); + [ConsoleCommand("rnpc.tp")] private void CcTp(ConsoleSystem.Arg arg) => RunConsole(arg, "tp"); + [ConsoleCommand("rnpc.respawn")] private void CcRespawn(ConsoleSystem.Arg arg) => RunConsole(arg, "respawn"); + [ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear"); + [ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile"); + [ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help"); + [ConsoleCommand("rnpc.follow")] private void CcFollow(ConsoleSystem.Arg arg) => RunConsole(arg, "follow"); + [ConsoleCommand("rnpc.faction")] private void CcFaction(ConsoleSystem.Arg arg) => RunConsole(arg, "faction"); + + // ---- where an admin stands and looks ---- + + /// The ground, a floor or a roof the admin is looking at, within . + /// D325: how far in front of the admin rnpc here stands the NPC, centre to centre. + private const float HereAhead = 1f; + + /// + /// D325: the spot in front of the admin, on the ground, facing them. False, with the + /// admin's own spot and facing, when it does not fit: something in the way at chest height, no ground within + /// below their feet, or, for one that walks, no navmesh there. + /// + private static bool HereSpot(BasePlayer player, bool sentry, out Vector3 spot, out float yaw) + { + Vector3 feet = player.transform.position; + Vector3 ahead = player.eyes.HeadForward(); + ahead.y = 0f; + if (ahead.sqrMagnitude < 0.01f) + ahead = new Vector3(player.transform.forward.x, 0f, player.transform.forward.z); + ahead.Normalize(); + + Vector3 chest = feet + Vector3.up; + RaycastHit hit; + if (!Physics.SphereCast(chest, 0.3f, ahead, out hit, HereAhead, GroundMask, QueryTriggerInteraction.Ignore) && + Physics.Raycast(chest + ahead * HereAhead, Vector3.down, out hit, 1f + MaxDrop, GroundMask, QueryTriggerInteraction.Ignore) && + (sentry || OffMeshReason(hit.point) == null)) + { + spot = hit.point; + yaw = Quaternion.LookRotation(-ahead).eulerAngles.y; + return true; + } + + spot = feet; + yaw = player.eyes.rotation.eulerAngles.y; + return false; + } + + private static bool LookSpot(BasePlayer player, out Vector3 spot) + { + RaycastHit hit; + if (Physics.Raycast(player.eyes.HeadRay(), out hit, LookReach, GroundMask, QueryTriggerInteraction.Ignore)) + { + spot = hit.point; + return true; + } + + spot = Vector3.zero; + return false; + } + + /// + /// Our NPC the admin is looking at: the nearest whose body is within a metre of the line of + /// sight. It reads positions, not colliders, so it finds an NPC behind a bush too. + /// + private RunicNpcPlayer LookNpc(BasePlayer player) + { + Ray ray = player.eyes.HeadRay(); + RunicNpcPlayer best = null; + float bestAlong = float.MaxValue; + foreach (RunicNpcPlayer npc in _npcs.Values) + { + if (npc == null || npc.IsDestroyed) + continue; + Vector3 body = npc.transform.position + Vector3.up * 1f; + Vector3 to = body - ray.origin; + float along = Vector3.Dot(to, ray.direction); + if (along <= 0f || along > LookReach) + continue; + float off = (to - ray.direction * along).magnitude; + if (off <= 1.2f && along < bestAlong) + { + best = npc; + bestAlong = along; + } + } + + return best; + } + + /// Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not. + private static string OffMeshReason(Vector3 spot) + { + NavMeshHit hit; + if (SampleNavmesh(spot, Scatter, out hit) && Mathf.Abs(hit.position.y - spot.y) <= MaxDrop) + return null; + if (!SampleNavmesh(spot, Fallback, out hit)) + { + float above = spot.y - TerrainMeta.HeightMap.GetHeight(spot); + return above > MaxDrop ? $"it is {above:0.0} m above the ground, with no navmesh within {Fallback:0} m" : $"there is no navmesh within {Fallback:0} m"; + } + + float dy = spot.y - hit.position.y; + if (dy > MaxDrop) return $"it is {dy:0.0} m above the navmesh"; + if (dy < -MaxDrop) return $"it is {-dy:0.0} m below the navmesh"; + return $"it is {new Vector2(hit.position.x - spot.x, hit.position.z - spot.z).magnitude:0.0} m from the navmesh"; + } + + private static Rust.Ai.Gen2.RustNavMeshPath _path; + + /// + /// Whether an NPC can walk from one point to the other on Rust's navmesh. A route leg that + /// fails this leaves its NPC standing at the last point it reached (stage 3: a route recorded + /// up a hill a player walked, and the NPC never left its first point). + /// + private static bool Walkable(Vector3 from, Vector3 to) + { + if (_path == null) + _path = new Rust.Ai.Gen2.RustNavMeshPath(); + return Rust.Ai.Gen2.RustNavMeshHelpers.CalculatePath(from, to, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas, _path) && + _path.status == NavMeshPathStatus.PathComplete; + } + + /// D239: a spot on something players built, which can be destroyed under the NPC. + private static bool IsPlayerBuilt(Vector3 spot) + { + RaycastHit hit; + if (!Physics.Raycast(spot + Vector3.up * 0.5f, Vector3.down, out hit, 2.5f, BuiltMask, QueryTriggerInteraction.Ignore)) + return false; + BaseEntity entity = hit.GetEntity(); + return entity != null && (entity is StabilityEntity || entity.OwnerID != 0UL); + } + + private static string Meters(Vector3 a, Vector3 b) => $"{Vector3.Distance(a, b):0} m"; + + private static bool TryFloat(string s, out float f) => + float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f) && !float.IsNaN(f) && !float.IsInfinity(f); + + // ---- place and here (D239, D241, D242) ---- + + private sealed class PlaceOptions + { + public int? Count; + public float? Respawn; + public string Mode; + public string Move; + public float? Radius; + public Vector3? At; + public float? Yaw; + public string Tether; + } + + /// + /// D242's key=value options, in any order. The server console has no position, so it + /// alone may also give at=x,y,z and yaw=. + /// + private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o) + { + o = new PlaceOptions(); + var seen = new HashSet(); + for (int i = from; i < args.Length; i++) + { + int eq = args[i].IndexOf('='); + if (eq <= 0) + return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius= tether="; + string key = args[i].Substring(0, eq).ToLowerInvariant(); + string value = args[i].Substring(eq + 1); + if (!seen.Add(key)) + return $"{key}= is given twice"; + + float f; + switch (key) + { + case "count": + int n; + if (!int.TryParse(value, out n) || n < 1) return "count= must be a whole number, at least 1"; + o.Count = n; + break; + case "respawn": + if (!TryFloat(value, out f) || f < 1f) return "respawn= is in seconds, at least 1"; + o.Respawn = f; + break; + case "mode": + if (value != "each" && value != "group") return "mode= is each or group"; + o.Mode = value; + break; + case "move": + string kind, route; + if (!ParseMode(value, out kind, out route)) return "move= is wander, monument or route:"; + o.Move = value; + break; + case "radius": + if (!TryFloat(value, out f) || f <= 0f) return "radius= is in metres, above 0"; + o.Radius = f; + break; + case "at": + string[] xyz = value.Split(','); + float x, y, z; + if (c.Player != null) return "at= is for the server console; in game, look at the spot or stand on it"; + if (xyz.Length != 3 || !TryFloat(xyz[0], out x) || !TryFloat(xyz[1], out y) || !TryFloat(xyz[2], out z)) + return "at= is x,y,z"; + o.At = new Vector3(x, y, z); + break; + case "yaw": + if (c.Player != null) return "yaw= is for the server console"; + if (!TryFloat(value, out f)) return "yaw= is in degrees"; + o.Yaw = f; + break; + case "tether": + if (!TetherNameRule.IsMatch(value)) return "tether= is a ZoneManager zone id"; + o.Tether = value; + break; + default: + return $"there is no option {key}=. Options: count= respawn= mode= move= radius= tether="; + } + } + + return null; + } + + /// D241: the profile's name and the first free number. + private string NextPlacementId(string profile) + { + string stem = profile.Length > 34 ? profile.Substring(0, 34) : profile; + for (int n = 1; ; n++) + { + string id = $"{stem}-{n}"; + if (!_placementFile.Placements.ContainsKey(id) && !_placements.ContainsKey(id)) + return id; + } + } + + /// + /// Checks, names and sets a NEW placement: rnpc place, rnpc here and + /// RunicNpc_AddPlacement alike, so a placement made from a website is named and refused + /// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show. + /// + private string CreatePlacement(Placement data, out string id, out bool built) + { + id = null; + built = false; + Profile p; + if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p)) + { + string name = data?.Profile; + return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."; + } + + string error = ValidatePlacement(data); + if (error != null) + return "Not placed: " + error; + + string kind, route; + ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + return $"There is no route '{route}'. Record it first: rnpc path record {route}"; + + if (!NavmeshReady()) + return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; + + if (!Still(p, data.Movement)) + { + string off = OffMeshReason(data.Position.V); + if (off != null) + return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."; + built = IsPlayerBuilt(data.Position.V); + } + + string next = NextPlacementId(data.Profile); + error = SetPlacement(next, data); + if (error != null) + return "Not placed: " + error; + id = next; + return null; + } + + private void CmdPlace(Caller c, string[] args, bool here) + { + if (args.Length < 1) + { + c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}"); + return; + } + + string name = args[0]; + Profile p; + if (!_profiles.TryGetValue(name, out p)) + { + c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."); + return; + } + + PlaceOptions o; + string error = ParseOptions(c, args, 1, out o); + if (error != null) + { + c.Say(error); + return; + } + + Vector3 spot; + float yaw; + string hereNote = null; + if (o.At.HasValue) + { + spot = o.At.Value; + yaw = o.Yaw ?? 0f; + } + else if (c.Player == null) + { + c.Say("From the server console, give the spot as at=x,y,z."); + return; + } + else if (here) + { + if (!HereSpot(c.Player, p.IsSentry, out spot, out yaw)) + hereNote = $"There is no room {HereAhead:0} m in front of you, so it stands where you do."; + } + else + { + if (!LookSpot(c.Player, out spot)) + { + c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m."); + return; + } + + // It faces the admin who placed it. + Vector3 back = c.Player.transform.position - spot; + yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f; + } + + Movement movement = null; + if (o.Move != null || o.Radius.HasValue) + { + if (p.IsSentry) + { + c.Say($"'{name}' is a sentry and never moves: drop move= and radius=."); + return; + } + + movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius }; + error = ValidateMovement(movement); + if (error != null) + { + c.Say(error); + return; + } + } + + var data = new Placement + { + Profile = name, + Position = Pos.Of(spot), + Yaw = yaw, + Count = o.Count ?? 1, + Respawn = o.Respawn ?? 300f, + RespawnMode = o.Mode ?? "each", + Movement = movement, + Tether = o.Tether + }; + + if (o.Tether != null && p.IsSentry) + { + c.Say($"'{name}' is a sentry and never moves: drop tether=."); + return; + } + + string id; + bool built; + error = CreatePlacement(data, out id, out built); + if (error != null) + { + c.Say(error); + return; + } + + string kind, route; + ParseMode((movement ?? p.Movement).Mode, out kind, out route); + Movement m = movement ?? p.Movement; + c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + + $"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} ."); + if (hereNote != null) + c.Say(hereNote); + if (built) + c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh."); + c.Say(CostWarning(PlannedTotal())); + } + + // ---- remove, rename, near, info, profiles ---- + + /// A placement named in the command, or that of the NPC the admin is looking at. + private PlacementState NamedOrLooked(Caller c, string[] args, int at) + { + PlacementState ps; + if (args.Length > at) + { + if (_placements.TryGetValue(args[at], out ps)) + return ps; + c.Say($"There is no placement '{args[at]}'. rnpc near lists those around you."); + return null; + } + + if (c.Player == null) + { + c.Say("Name the placement."); + return null; + } + + RunicNpcPlayer npc = LookNpc(c.Player); + if (npc == null) + { + c.Say("Look at one of RunicNPC's NPCs, or name the placement."); + return null; + } + + if (npc.PlacementId == null || !_placements.TryGetValue(npc.PlacementId, out ps)) + { + c.Say($"{npc.RnName} is not from a placement: it belongs to {npc.Owner}. rnpc clear {npc.Owner} removes that owner's NPCs."); + return null; + } + + return ps; + } + + private void CmdRemove(Caller c, string[] args) + { + PlacementState ps = NamedOrLooked(c, args, 0); + if (ps == null) + return; + int alive = ps.Slots.Count(s => s.Npc != null); + RunicNpc_RemovePlacement(ps.Id); + c.Say($"Removed {ps.Id} and its {alive} NPC(s). {CostWarning(PlannedTotal())}"); + } + + private void CmdRename(Caller c, string[] args) + { + if (args.Length != 2) + { + c.Say("Usage: rnpc rename "); + return; + } + + string error = RenamePlacement(args[0], args[1]); + c.Say(error ?? $"Renamed {args[0]} to {args[1]}."); + } + + private string RenamePlacement(string from, string to) + { + PlacementState ps; + if (!_placements.TryGetValue(from, out ps)) + return $"There is no placement '{from}'."; + if (!NameRule.IsMatch(to)) + return "A name is 1–40 of a-z, 0-9, _ and -."; + if (_placements.ContainsKey(to) || _placementFile.Placements.ContainsKey(to)) + return $"'{to}' is taken."; + + _placements.Remove(from); + _placementFile.Placements.Remove(from); + ps.Id = to; + _placements[to] = ps; + _placementFile.Placements[to] = ps.Data; + foreach (Slot slot in ps.Slots) + { + if (slot.Npc == null) + continue; + slot.Npc.PlacementId = to; + slot.Npc.Owner = "placement:" + to; + } + + _placementsDirty = true; + Puts($"Placement '{from}' renamed to '{to}'."); + PlacementChanged(to, "renamed", from); + return null; + } + + private void CmdNear(Caller c, string[] args) + { + float radius = 50f; + if (args.Length > 0 && (!TryFloat(args[0], out radius) || radius <= 0f)) + { + c.Say("Usage: rnpc near [radius in metres]"); + return; + } + + if (c.Player == null) + { + c.Say("From the server console there is no 'near': rnpc.status counts everything."); + return; + } + + Vector3 here = c.Player.transform.position; + List places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius) + .OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList(); + List loose = _npcs.Values.Where(n => n.PlacementId == null && Vector3.Distance(n.transform.position, here) <= radius) + .OrderBy(n => Vector3.Distance(n.transform.position, here)).ToList(); + if (places.Count == 0 && loose.Count == 0) + { + c.Say($"Nothing of RunicNPC's within {radius:0} m."); + return; + } + + foreach (PlacementState ps in places) + { + Placement d = ps.Data; + string move = d.Movement != null ? $" · move {d.Movement.Mode}" : ""; + string state = ps.Waiting != null ? $" · waiting: {ps.Waiting}" : ps.LastError != null ? $" · {ps.LastError}" : ps.Note != null ? $" · {ps.Note}" : ""; + c.Say($"{ps.Id} · {d.Count} × {d.Profile} ({ps.Slots.Count(s => s.Npc != null)} alive) · {Meters(d.Position.V, here)} · respawn {d.Respawn:0} s {d.RespawnMode}{move}{state}"); + } + + foreach (RunicNpcPlayer n in loose) + c.Say($"{n.RnName} ({n.ProfileName}) · {n.Owner} · {Meters(n.transform.position, here)}"); + } + + private void CmdInfo(Caller c, string[] args) + { + if (c.Player == null) + { + c.Say("Info is for the NPC you look at, in game."); + return; + } + + RunicNpcPlayer npc = LookNpc(c.Player); + if (npc == null) + { + c.Say("Look at one of RunicNPC's NPCs."); + return; + } + + RunicNpcBrain brain = npc.RnBrain; + BaseEntity target = npc.GetBestTarget(); + string targetText = target == null ? "none" + : $"{(target is BasePlayer ? ((BasePlayer)target).displayName : target.ShortPrefabName)} {Meters(target.transform.position, npc.transform.position)}"; + string move = npc.Profile.IsSentry ? "holds its spot" : npc.MoveKind == "route" ? "route:" + npc.RouteName : npc.MoveKind == "wander" ? $"wander {npc.MoveRadius:0} m" : npc.MoveKind; + c.Say($"{npc.RnName} · profile {npc.ProfileName} ({npc.Profile.Role}) · {(npc.PlacementId != null ? "placement " + npc.PlacementId : npc.Owner)}"); + c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " + + $"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}"); + c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home"); + + BaseCombatEntity own = npc.NpcTarget; + HashSet hunts = HuntsOf(npc); + c.Say($"faction {FactionOf(npc)} · hunts {(hunts != null ? string.Join(", ", hunts.ToArray()) : "players only")} · " + + $"fighting {(own != null && !own.IsDestroyed ? $"{own.ShortPrefabName} {Meters(own.transform.position, npc.transform.position)}{(npc.Engaged ? "" : " (waits)")}" : "no NPC")}"); + var orders = new List(); + if (npc.Escort != null && !npc.Escort.IsDestroyed) + orders.Add("escorts " + (npc.Escort is BasePlayer ? ((BasePlayer)npc.Escort).displayName : npc.Escort.ShortPrefabName)); + if (npc.HasAlly) + orders.Add("allied to " + (npc.AllyClan != 0L ? "clan " + npc.AllyClan : npc.AllyTeam != 0UL ? "team " + npc.AllyTeam : "player " + npc.AllyPlayer)); + if (npc.Tether != null) + orders.Add("held in zone " + npc.Tether); + if (orders.Count > 0) + c.Say(string.Join(" · ", orders.ToArray())); + } + + // ---- follow (D258, D269) ---- + + /// + /// rnpc follow <placement> <player|me|off>: the placement's live NPCs escort a + /// player, for trying it out in game (D258). In memory only: a respawn or a reload ends it, and a + /// placement never escorts anyone of its own accord. + /// + private void CmdFollow(Caller c, string[] args) + { + if (args.Length < 2) + { + c.Say("Usage: rnpc " + _verbs["follow"].Usage); + return; + } + + PlacementState ps; + if (!_placements.TryGetValue(args[0], out ps)) + { + c.Say($"There is no placement '{args[0]}'. rnpc near lists those around you."); + return; + } + + BasePlayer target = null; + string who = args[1]; + if (who == "me") + { + if (c.Player == null) + { + c.Say("The server console cannot be followed; name a player."); + return; + } + + target = c.Player; + } + else if (who != "off") + { + target = BasePlayer.Find(who); + if (target == null) + { + c.Say($"No player on the server matches '{who}'."); + return; + } + } + + int n = 0; + foreach (Slot s in ps.Slots) + { + if (s.Npc == null || s.Npc.IsDestroyed) + continue; + string error = SetEscort(s.Npc, target); + if (error != null) + { + c.Say(error + "."); + return; + } + + n++; + } + + c.Say(n == 0 ? $"{ps.Id} has no live NPCs now." : target == null + ? $"{ps.Id}: {n} NPC(s) stop escorting and walk back to their spot." + : $"{ps.Id}: {n} NPC(s) escort {target.displayName}. They walk back to their spot if {target.displayName} dies or leaves; rnpc follow {ps.Id} off ends it."); + } + + // ---- faction (D254, D268) ---- + + /// rnpc.faction: the faction table on a standalone server; refused when a site manages it (D221). + private void CmdFaction(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "list"; + if (sub == "list") + { + List table = _profileFile.Factions; + var named = _profiles.Where(kv => !string.IsNullOrEmpty(kv.Value.Faction)).GroupBy(kv => kv.Value.Faction) + .Select(g => $"{g.Key} ({string.Join(", ", g.Select(kv => kv.Key).ToArray())})").ToList(); + c.Say($"Factions in use: {(named.Count > 0 ? string.Join("; ", named.ToArray()) : "none")}. Rust's own: scientists, animals."); + if (table.Count == 0) + c.Say("The table is empty: every pair is neutral, and a profile with no faction settings fights players only (D255)."); + foreach (FactionPair f in table.OrderBy(f => f.A).ThenBy(f => f.B)) + c.Say($"{f.A} ↔ {f.B}: {f.Relation}"); + if (_profileFile.Managed) + c.Say("Managed by this server's website (D221): edit the table there."); + return; + } + + if (_profileFile.Managed) + { + c.Say("This server's faction table is managed by its website (D221): edit it there."); + return; + } + + if ((sub == "set" && args.Length < 4) || (sub == "clear" && args.Length < 3) || (sub != "set" && sub != "clear")) + { + c.Say("Usage: rnpc." + _verbs["faction"].Usage); + return; + } + + string a = args[1], b = args[2]; + var next = _profileFile.Factions.Where(f => PairKey(f.A, f.B) != PairKey(a, b)).ToList(); + if (sub == "set") + next.Add(new FactionPair { A = a, B = b, Relation = args[3].ToLowerInvariant() }); + string error = ValidateFactions(next); + if (error != null) + { + c.Say(Regex.Replace(error, @"^factions\[\d+\]: ", "")); + return; + } + + _profileFile.Factions = next; + SaveProfiles(); + RevalidateProfiles(); + c.Say(sub == "set" ? $"{a} ↔ {b}: {args[3].ToLowerInvariant()}, both ways (D268)." : $"{a} ↔ {b}: back to neutral."); + } + + private void CmdProfiles(Caller c, string[] args) + { + if (_profiles.Count == 0 && _refused.Count == 0) + { + c.Say(_profileFile.Managed ? "This server has no profiles yet; its website manages them." : "This server has no profiles yet. rnpc.profile create makes one."); + return; + } + + c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:"); + foreach (KeyValuePair kv in _profiles.OrderBy(kv => kv.Key)) + { + Profile p = kv.Value; + c.Say($"{kv.Key} · {p.Role}{(p.IsSentry ? "" : " " + p.Movement.Mode)} · {p.Health:0} hp · kits {string.Join(", ", p.Kits.ToArray())}"); + } + + foreach (KeyValuePair kv in _refused.OrderBy(kv => kv.Key)) + c.Say($"{kv.Key} · refused: {kv.Value}"); + } + + // ---- tp, respawn, clear ---- + + private void CmdTp(Caller c, string[] args) + { + if (c.Player == null) + { + c.Say("Teleporting is for a player."); + return; + } + + PlacementState ps; + if (args.Length != 1 || !_placements.TryGetValue(args[0], out ps)) + { + c.Say(args.Length == 1 ? $"There is no placement '{args[0]}'." : "Usage: rnpc tp "); + return; + } + + c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f); + c.Say($"At {ps.Id}."); + } + + /// A placement's NPCs, despawned and spawned fresh now, without waiting for the delay. + private int RespawnNow(PlacementState ps) + { + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + foreach (Slot s in ps.Slots) + s.DiedAt = float.NegativeInfinity; + ps.LastError = null; + return ps.Slots.Length; + } + + private void CmdRespawn(Caller c, string[] args) + { + if (args.Length != 1) + { + c.Say("Usage: rnpc respawn "); + return; + } + + List which; + if (args[0] == "all") + which = _placements.Values.ToList(); + else + { + PlacementState ps; + if (!_placements.TryGetValue(args[0], out ps)) + { + c.Say($"There is no placement '{args[0]}'."); + return; + } + + which = new List { ps }; + } + + int n = which.Sum(RespawnNow); + TickPlacements(); + List waiting = which.Where(ps => ps.Waiting != null).Select(ps => $"{ps.Id} waits: {ps.Waiting}").ToList(); + c.Say($"Respawning {n} NPC(s) from {which.Count} placement(s)."); + c.Lines.AddRange(waiting); + } + + private void CmdClear(Caller c, string[] args) + { + if (args.Length != 1 || !(OwnerRule.IsMatch(args[0]) || args[0].StartsWith("placement:"))) + { + c.Say("Usage: rnpc clear , e.g. run:, plugin: or placement:"); + return; + } + + int n = Despawn(_npcs.Values.Where(x => x.Owner == args[0])); + c.Say(args[0].StartsWith("placement:") + ? $"Removed {n} NPC(s) of {args[0]}; the placement stays and respawns them after its delay (rnpc remove deletes it)." + : $"Removed {n} NPC(s) of {args[0]}."); + } + + // ---- path: recording a route point by point (D240) ---- + + private void CmdPath(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; + Recording rec; + _recordings.TryGetValue(c.Key, out rec); + + switch (sub) + { + case "record": + if (args.Length != 2 || !NameRule.IsMatch(args[1])) + { + c.Say("Usage: rnpc path record ; a name is 1–40 of a-z, 0-9, _ and -."); + return; + } + + _recordings[c.Key] = new Recording { Name = args[1] }; + c.Say($"Recording '{args[1]}'{(_routeFile.Routes.ContainsKey(args[1]) ? " (saving replaces the route of that name)" : "")}. " + + "Walk, and at each point type rnpc path point."); + return; + + case "point": + if (rec == null) + { + c.Say("Nothing is being recorded: rnpc path record first."); + return; + } + + Vector3 at; + if (c.Player != null && args.Length == 1) + at = c.Player.transform.position; + else + { + PlaceOptions o = null; + string error = args.Length == 2 ? ParseOptions(c, args, 1, out o) : "from the server console, give the point as at=x,y,z"; + if (error == null && (o == null || !o.At.HasValue)) error = "give the point as at=x,y,z"; + if (error != null) + { + c.Say(error); + return; + } + + at = o.At.Value; + } + + string off = OffMeshReason(at); + if (off != null) + { + c.Say($"Refused: {off}."); + return; + } + + NavMeshHit hit; + SampleNavmesh(at, Scatter, out hit); + if (rec.Points.Count > 0 && !Walkable(rec.Points[rec.Points.Count - 1], hit.position)) + { + c.Say($"Refused: an NPC cannot walk here from point {rec.Points.Count}; Rust's navmesh finds no path " + + "(a player can climb what an NPC cannot). Add a point between them, or choose another spot."); + return; + } + + rec.Points.Add(hit.position); + string gap = rec.Points.Count > 1 ? $", {Meters(rec.Points[rec.Points.Count - 2], hit.position)} from the last" : ""; + c.Say($"Point {rec.Points.Count} at ({hit.position.x:0}, {hit.position.y:0}, {hit.position.z:0}){gap}."); + return; + + case "undo": + if (rec == null || rec.Points.Count == 0) + { + c.Say("There is no point to undo."); + return; + } + + rec.Points.RemoveAt(rec.Points.Count - 1); + c.Say($"Dropped the last point; {rec.Points.Count} left."); + return; + + case "save": + if (rec == null) + { + c.Say("Nothing is being recorded."); + return; + } + + string how = args.Length > 1 ? args[1].ToLowerInvariant() : "loop"; + if (how != "loop" && how != "back") + { + c.Say("Usage: rnpc path save [loop|back]: loop returns from the last point to the first; back walks it back and forth."); + return; + } + + if (rec.Points.Count < 2) + { + c.Say($"A route needs at least two points; '{rec.Name}' has {rec.Points.Count}."); + return; + } + + if (how == "loop" && !Walkable(rec.Points[rec.Points.Count - 1], rec.Points[0])) + { + c.Say($"An NPC cannot walk from the last point back to the first, so '{rec.Name}' cannot loop. " + + "Add points that close the loop, or save it as back (back and forth)."); + return; + } + + var route = new Route { Loop = how == "loop", Points = rec.Points.Select(Pos.Of).ToList() }; + string refused = SetRoute(rec.Name, route); + if (refused != null) + { + c.Say("Not saved: " + refused); + return; + } + + _recordings.Remove(c.Key); + int users = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + rec.Name); + c.Say($"Saved '{rec.Name}': {route.Points.Count} points, {(route.Loop ? "loops" : "back and forth")}. " + + $"Place a patrol on it: rnpc place move=route:{rec.Name}{(users > 0 ? $" ({users} placement(s) already use it)" : "")}"); + return; + + case "cancel": + c.Say(_recordings.Remove(c.Key) ? $"Discarded '{rec.Name}'." : "Nothing is being recorded."); + return; + + case "list": + if (_routeFile.Routes.Count == 0) + c.Say("No routes yet: rnpc path record ."); + foreach (KeyValuePair kv in _routeFile.Routes.OrderBy(kv => kv.Key)) + { + int n = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + kv.Key) + + _placements.Values.Count(ps => ps.Data.Movement == null && _profiles.ContainsKey(ps.Data.Profile) && _profiles[ps.Data.Profile].Movement.Mode == "route:" + kv.Key); + string where = c.Player != null ? $" · first point {Meters(kv.Value.Points[0].V, c.Player.transform.position)}" : ""; + c.Say($"{kv.Key} · {kv.Value.Points.Count} points · {(kv.Value.Loop ? "loop" : "back and forth")} · {n} placement(s){where}"); + } + + if (rec != null) + c.Say($"Recording: '{rec.Name}', {rec.Points.Count} point(s), not saved."); + return; + + case "delete": + if (args.Length != 2) + { + c.Say("Usage: rnpc path delete "); + return; + } + + if (!RunicNpc_RemoveRoute(args[1])) + { + c.Say($"There is no route '{args[1]}'."); + return; + } + + TickPlacements(); + int waiting = _placements.Values.Count(ps => ps.Waiting != null && ps.Waiting.Contains($"route '{args[1]}'")); + c.Say($"Deleted '{args[1]}'.{(waiting > 0 ? $" {waiting} placement(s) on it now wait for a route of that name." : "")}"); + return; + + default: + c.Say("Usage: rnpc " + _verbs["path"].Usage); + return; + } + } + + // ---- profile: standalone edits from a console (D221) ---- + + private void CmdProfile(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; + if (sub == "list") + { + CmdProfiles(c, args); + return; + } + + if (sub == null || args.Length < 2) + { + c.Say("Usage: rnpc." + _verbs["profile"].Usage); + return; + } + + string name = args[1]; + Profile existing; + _profileFile.Profiles.TryGetValue(name, out existing); + if (sub == "show") + { + c.Say(existing == null ? $"There is no profile '{name}'." : JsonConvert.SerializeObject(existing, Formatting.Indented)); + if (existing != null && _refused.ContainsKey(name)) + c.Say($"It is refused: {_refused[name]}"); + return; + } + + if (_profileFile.Managed) + { + c.Say("This server's profiles are managed by its website (D221): edit them there."); + return; + } + + switch (sub) + { + case "create": + if (!NameRule.IsMatch(name)) + { + c.Say("A name is 1–40 of a-z, 0-9, _ and -."); + return; + } + + if (existing != null) + { + c.Say($"'{name}' exists: rnpc.profile set {name} changes it."); + return; + } + + Profile made = new Profile { Names = new List { "Scientist" } }; + if (args.Length > 2) + { + Profile from; + if (!args[2].StartsWith("from=") || !_profileFile.Profiles.TryGetValue(args[2].Substring(5), out from)) + { + c.Say(args[2].StartsWith("from=") ? $"There is no profile '{args[2].Substring(5)}'." : "Usage: rnpc.profile create [from=]"); + return; + } + + made = JObject.FromObject(from).ToObject(); + } + + _profileFile.Profiles[name] = made; + break; + + case "set": + if (existing == null) + { + c.Say($"There is no profile '{name}'."); + return; + } + + if (args.Length < 4) + { + c.Say("Usage: rnpc.profile set , e.g. health 200, ranges.sense 40, kits a,b, names Bob,Old Jim"); + return; + } + + string error = SetProfileField(name, existing, args[2], string.Join(" ", args.Skip(3).ToArray())); + if (error != null) + { + c.Say(error); + return; + } + + break; + + case "delete": + if (existing == null) + { + c.Say($"There is no profile '{name}'."); + return; + } + + _profileFile.Profiles.Remove(name); + break; + + default: + c.Say("Usage: rnpc." + _verbs["profile"].Usage); + return; + } + + SaveProfiles(); + RevalidateProfiles(); + TickPlacements(); + if (sub == "delete") + { + int waiting = _placements.Values.Count(ps => ps.Data.Profile == name); + c.Say($"Deleted '{name}'.{(waiting > 0 ? $" Its {waiting} placement(s) wait for a profile of that name (D237)." : "")}"); + } + else + c.Say(_refused.ContainsKey(name) ? $"Saved '{name}', but it cannot spawn yet: {_refused[name]}" : $"Saved '{name}'; it is in use."); + } + + /// One field of a profile, by its JSON path (health, ranges.sense, kits). + private string SetProfileField(string name, Profile profile, string field, string value) + { + JObject o = JObject.FromObject(profile); + + // Stage 5: a profile's own exceptions are keyed by faction (D254), so one is set, or removed + // with none, by name: relations.scientists hostile, relations.profile:warden none. + if (field.StartsWith("relations.")) + { + string key = field.Substring(10); + JObject relations = (JObject)o["relations"]; + if (value == "none" || value == "-") + relations.Remove(key); + else + relations[key] = value; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + if (field == "faction" && (value == "none" || value == "-")) + { + o["faction"] = null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + // Stage 6: the boss box and press E switch on with their defaults and off with none; their own + // fields are then set one at a time (boss.spawnLine …, use.mode …), and a list of objects as + // JSON: boss.phases [{"at":0.5,"line":"…"}]. + if ((field == "boss" || field == "use") && (value == "on" || value == "none" || value == "-")) + { + o[field] = value == "on" ? (o[field] is JObject ? o[field] : JObject.FromObject(field == "boss" ? (object)new BossBlock() : new UseBlock { Lines = new List { "Hello." } })) : null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + // D279: a profile made passive starts unhurtable; "hurtByPlayers on" changes that. + if (field == "role" && value == "passive" && (string)o["role"] != "passive") + o["hurtByPlayers"] = false; + + JToken target = o.SelectToken(field); + if (target == null || target is JObject) + return target == null + ? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}" + : $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}"; + + JToken replacement; + float f; + if (target is JArray && value.TrimStart().StartsWith("[")) + { + try + { + replacement = JArray.Parse(value); + } + catch (Exception e) + { + return $"'{field}' could not be read as JSON: {e.Message}"; + } + } + else if (target is JArray) + { + string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); + if (field == "healthThresholds") + { + var numbers = new JArray(); + foreach (string s in parts) + { + if (!TryFloat(s, out f)) return $"'{s}' is not a number"; + numbers.Add(f); + } + + replacement = numbers; + } + else + replacement = new JArray(parts.Cast().ToArray()); + } + else if (target.Type == JTokenType.Float || target.Type == JTokenType.Integer) + { + if (!TryFloat(value, out f)) return $"'{field}' is a number"; + replacement = new JValue(f); + } + else if (target.Type == JTokenType.Boolean) + { + string v = value.ToLowerInvariant(); + if (v != "true" && v != "false" && v != "on" && v != "off") return $"'{field}' is true or false"; + replacement = new JValue(v == "true" || v == "on"); + } + else + replacement = new JValue(value); + + target.Replace(replacement); + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + private static IEnumerable Flatten(JObject o, string prefix = "") + { + foreach (JProperty prop in o.Properties()) + { + if (prop.Value is JObject) + foreach (string inner in Flatten((JObject)prop.Value, prefix + prop.Name + ".")) + yield return inner; + else + yield return prefix + prop.Name; + } + } + + #endregion + + #region Console + [ConsoleCommand("rnpc.status")] private void CmdStatus(ConsoleSystem.Arg arg) { @@ -98,26 +6362,60 @@ namespace Oxide.Plugins var fired = new List(); var silent = new List(); - foreach (string hook in ExpectedHooks) { long count; _hookCounts.TryGetValue(hook, out count); - if (count > 0L) fired.Add($"{hook}={count}"); else silent.Add(hook); } - string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)"; - string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)"; + string nl = Environment.NewLine; + var sb = new StringBuilder(); + sb.Append($"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} fired={fired.Count} silent={silent.Count}").Append(nl); + sb.Append($"fired: {(fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)")}").Append(nl); + sb.Append($"silent: {(silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)")}").Append(nl); + sb.Append($"framework={Framework()} navmesh={(NavmeshReady() ? "ready" : "building")} " + + $"swap fields: npc={_npcFields?.Length ?? 0}/{SwapNpcFields.Length} brain={_brainFields?.Length ?? 0}/{SwapBrainFields.Length} " + + $"missing={(_fieldsMissing.Count > 0 ? string.Join(",", _fieldsMissing) : "-")} added={(_fieldsAdded.Count > 0 ? string.Join(",", _fieldsAdded) : "-")}").Append(nl); - arg.ReplyWith( - $"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} " + - $"fired={fired.Count} silent={silent.Count}" + Environment.NewLine + - $"fired: {firedText}" + Environment.NewLine + - $"silent: {silentText}"); + IEnumerable brains = _npcs.Values.Select(n => n.RnBrain).Where(b => b != null).ToList(); + sb.Append($"npcs={_npcs.Count} awake={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Awake)} " + + $"goingHome={brains.Count(b => b.Rest == RunicNpcBrain.RestState.GoingHome)} asleep={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Asleep)} " + + $"owners: {string.Join(" ", _npcs.Values.GroupBy(n => n.Owner).Select(g => $"{g.Key}={g.Count()}").ToArray())}").Append(nl); + sb.Append($"profiles={_profiles.Count} managed={_profileFile.Managed} refused: " + + $"{(_refused.Count > 0 ? string.Join("; ", _refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()) : "-")}").Append(nl); + sb.Append($"placements={_placements.Count} queue={_queue.Count} waiting: " + + $"{string.Join("; ", _placements.Values.Where(ps => ps.Waiting != null).Select(ps => ps.Id + ": " + ps.Waiting).DefaultIfEmpty("-").ToArray())}").Append(nl); + sb.Append($"routes={_routeFile.Routes.Count}").Append(nl); + double fightMs = RunicNpcBrain.FightTicks > 0 ? RunicNpcBrain.FightMs / RunicNpcBrain.FightTicks : 0.0; + double senseMs = SenseRuns > 0 ? SenseMs / SenseRuns : 0.0; + sb.Append($"stage 5: factions={_profileFile.Factions.Count} pairs, tracked scientists={_scientists.Count} animals={_animals.Count}, " + + $"engaged={_npcs.Values.Count(n => n.Engaged)} defenders={_defenders.Count} alarms={Alarms} defences={Defences}, " + + $"fight {fightMs:0.000} ms/think over {RunicNpcBrain.FightTicks}, sense {senseMs:0.000} ms over {SenseRuns}").Append(nl); + sb.Append($"sentries (turrets: always): {_sentryPatch}, answered {SentryPatch.Answered}; zonemanager={(ZoneManager != null ? "loaded" : "absent")}").Append(nl); + CapsConfig caps = _config.Caps; + sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl); + sb.Append(CostWarning(PlannedTotal())); + arg.ReplyWith(sb.ToString()); } + + /// Re-reads profiles and routes after a hand edit of their files (standalone, D221). + [ConsoleCommand("rnpc.reload")] + private void CmdReload(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + ReadProfileFile(); + _routeFile = ReadData(DataRoutes); + if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); + RevalidateProfiles(); + TickPlacements(); + arg.ReplyWith($"RunicNPC: {_profiles.Count} profile(s) in use, {_refused.Count} refused, {_routeFile.Routes.Count} route(s)."); + } + + #endregion } } diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 7d35559..5b6135e 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -69,7 +69,21 @@ const PLUGIN_TOML = path.join(ROOT, 'plugin.toml') * That half cannot be read statically; it is the reviewer's to check, which is * why the reason goes here where the reviewer will see it. */ -const ANSWERS_DELIBERATELY = Object.create(null) +const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), { + // Stage 5 (D259, D264): turrets: ignore. Answers false for RunicNPC's own NPCs whose profile says + // ignore, so no auto turret, flame turret or shotgun trap targets them; null for everything else. + CanBeTargeted: 'turrets: ignore (D259, D264), our own NPCs only', + // Stage 5 (D261, D271): TruePVE's and NextGenPVE's question. Answers only for one of ours facing a + // real player, with the profile's two PVE checkboxes; null for everything else. + CanEntityTakeDamage: 'PVE allowed both ways, or the profile opt-out (D261, D271), our own NPCs only', + // Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players + // hits a real player. Its other job, defending an ally or escort (D257, D269), never answers. + OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only', + // Stage 7 (D290, D300): answering with the corpse is how Rust's own ApplyLoot is skipped. It answers only for + // one of ours whose profile's corpse starts with its kit or with nothing; null for everything else, Rust's own + // scientists included. + OnCorpsePopulate: "a kit or empty corpse start skips Rust's scientist loot (D290, D300), our own NPCs only", +}) /** Plugins RunicNPC must require, whatever else it requires (D217). */ const REQUIRED_PLUGINS = ['Kits'] @@ -179,7 +193,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. diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index f86785f..9fc660d 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -100,13 +100,13 @@ test('returning null is not good enough — the signature is the rule', () => { // `return null` today is one edit away from `return true` tomorrow, and the // edit that breaks it looks harmless in a diff. A void method cannot be turned // into a veto without changing its signature, which is visible. - const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret) + const methods = ` private bool CanLootEntity(BasePlayer player, StorageContainer container) { return true; }` - const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) - assert.match(problems[0], /CanBeTargeted returns bool, not void/) + const problems = check(source({ expected: ['CanLootEntity'], methods }), toml()) + assert.match(problems[0], /CanLootEntity returns bool, not void/) }) test('a method that is not shaped like a hook is left alone', () => { @@ -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']) diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs new file mode 100644 index 0000000..d511902 --- /dev/null +++ b/tools/RunicNpcHarness.cs @@ -0,0 +1,2063 @@ +// Requires: Kits + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; +using UnityEngine.AI; + +namespace Oxide.Plugins +{ + /// + /// RunicNPC's stage 1 harness (docs/runicnpc/PLAN.md §9). NEVER SHIPPED: it lives in tools/, + /// is loaded on a rig by hand, and answers the six questions stage 2 is built on: + /// + /// + /// the subclass swap spawns, walks, fights and dies cleanly (rnh.swap); + /// enableSaving = false survives a hard kill and restart (rnh.save plant|check); + /// which ranges and scales hold on Rust's brain as it is (rnh.tune); + /// navmesh coverage at every monument and on a roof (rnh.nav); + /// what 1, 10 and 100 cost, idle and fighting (rnh.cost); + /// the name on the death screen, and what the bridge publishes (rnh.dummy). + /// + /// + /// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets + /// (rnh.npcfight), its cost (rnh.sense), turrets and traps (rnh.turrets, + /// D259), the kit items Rust's AI uses by itself (rnh.items, D260), and TruePVE or + /// NextGenPVE, whichever is loaded (rnh.pve, D261). + /// + /// Every result is a line starting [rnh] in the console and a record in the data file + /// RunicNpcHarness.json, read back over the panel. Nothing here is RunicNPC's design: + /// it is the measurement that design waits for. + /// + /// + /// It needs a hidden Kits kit named rnhrevolver (a revolver, pistol ammo and burlap): + /// add it to the rig's Kits/kits_data.json and reload Kits before loading this, and + /// restore the file afterwards. ready in the console says whether it was found. + /// + /// + /// + /// Its NPC is swapped the way stage 2's will be: the prefab's ScientistNPC and + /// ScientistBrain components are replaced, before the entity is spawned, by our own + /// subclasses carrying the prefab's serialised fields. Written from Rust's own classes; no + /// other plugin's code. + /// + /// + [Info("RunicNpcHarness", "Runic Gateway", "0.2.0")] + [Description("RunicNPC stage 1 and stage 5 measurements. A developer tool: never ship it.")] + internal class RunicNpcHarness : RustPlugin + { + [PluginReference] private Plugin Kits; + + private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab"; + private const string PlayerPrefab = "assets/prefabs/player/player.prefab"; + private const string Kit = "rnhrevolver"; + + /// The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000). + private const ulong DummyUserId = 11400000001UL; + + private static RunicNpcHarness _self; + private readonly Dictionary _results = new Dictionary(); + private readonly HashSet _spawned = new HashSet(); + private GameObject _meterHost; + private FrameMeter _meter; + + // ---- counters the hooks feed ---- + + private sealed class HitTally + { + public int Hits; + public float Damage; + public readonly Dictionary Weapons = new Dictionary(); + } + + private readonly HitTally _hitsByOurs = new HitTally(); + private readonly HitTally _hitsOnOurs = new HitTally(); + private readonly HitTally _hitsOnStandIns = new HitTally(); + + /// While set, stand-in players take no damage: the cost run's targets never die. + private bool _standInsInvulnerable; + + private static bool IsStandIn(BaseEntity e) => + e is BasePlayer p && !p.IsNpc && (ulong)p.userID >= DummyUserId && (ulong)p.userID < DummyUserId + 1000UL; + private readonly List _deaths = new List(); + private readonly List _corpses = new List(); + + // ---- lifecycle ---- + + private void Init() => _self = this; + + private void OnServerInitialized() + { + _meterHost = new GameObject("RunicNpcHarness.FrameMeter"); + _meter = _meterHost.AddComponent(); + foreach (BaseNetworkable n in BaseNetworkable.serverEntities) + Register(n as BaseEntity); + Log("ready", $"registry scientists={Scientists.Count} animals={Animals.Count} pveStandIn.isSteamId={PveUserId.IsSteamId()}"); + Log("ready", $"oxide-or-carbon={Framework()} fps.target={Application.targetFrameRate} " + + $"ai.budget.ms={AIThinkManager.framebudgetms} map={World.Size} seed={World.Seed} navmeshBuilt={NavmeshBuilt()} " + + $"kit.{Kit}={KitExists()}"); + } + + private void Unload() + { + foreach (BaseEntity e in _spawned.ToArray()) + if (e != null && !e.IsDestroyed) + e.Kill(); + _spawned.Clear(); + if (_meterHost != null) + UnityEngine.Object.Destroy(_meterHost); + Scientists.Clear(); + Animals.Clear(); + OursByTag.Clear(); + _self = null; + } + + private static string Framework() => + AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide"; + + // ---- output ---- + + private void Log(string test, string line) => Puts($"[rnh] {test}: {line}"); + + private void Record(string test, object value) + { + _results[test] = value; + Interface.Oxide.DataFileSystem.WriteObject(Name, _results); + } + + // ---- the swap ---- + + /// + /// Copies the fields Unity itself would copy between two instances of a component: every + /// instance field of 's type chain down to + /// that is public and not [NonSerialized], or private with [SerializeField]. That is the + /// prefab's authored data. Runtime state (delegates, caches, anything [NonSerialized]) is + /// left at the new component's own defaults, so nothing bound to the destroyed instance is + /// carried across. + /// + private static int CopyPrefabFields(Component from, Component to) + { + int copied = 0; + for (Type t = from.GetType(); t != null && t != typeof(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.IsNotSerialized) || f.IsDefined(typeof(SerializeField), false); + if (!serialised || typeof(Delegate).IsAssignableFrom(f.FieldType)) + continue; + f.SetValue(to, f.GetValue(from)); + copied++; + } + } + return copied; + } + + private sealed class SpawnReport + { + public RnhNpc Npc; + public int NpcFields; + public int BrainFields; + public double Ms; + public string Error; + public string Kit; + } + + /// Our NPC: the prefab's scientist, its components swapped before it spawns. + private SpawnReport SpawnOurs(Vector3 at, string name, Action tune = null, bool save = false) + { + var report = new SpawnReport(); + Stopwatch sw = Stopwatch.StartNew(); + + BaseEntity made = GameManager.server.CreateEntity(ScientistPrefab, at, Quaternion.identity, false); + ScientistNPC stock = made as ScientistNPC; + if (stock == null) + { + report.Error = "prefab did not make a ScientistNPC"; + if (made != null) UnityEngine.Object.Destroy(made.gameObject); + return report; + } + + ScientistBrain stockBrain = stock.GetComponent(); + GameObject go = stock.gameObject; + + RnhNpc npc = go.AddComponent(); + RnhBrain brain = go.AddComponent(); + report.NpcFields = CopyPrefabFields(stock, npc); + report.BrainFields = stockBrain != null ? CopyPrefabFields(stockBrain, brain) : -1; + + UnityEngine.Object.DestroyImmediate(stock, true); + if (stockBrain != null) + UnityEngine.Object.DestroyImmediate(stockBrain, true); + + npc.RnhName = name; + npc.enableSaving = save; + tune?.Invoke(brain); + + // CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this + // call, which GameManager.CreatePrefab makes when active is true. Without it the entity + // still spawns and thinks, but Unity never starts the brain: no design, no navigator, no state. + go.AwakeFromInstantiate(); + npc.Spawn(); + report.Ms = sw.Elapsed.TotalMilliseconds; + report.Npc = npc; + _spawned.Add(npc); + + report.Kit = Equip(npc); + return report; + } + + /// + /// Senses NPC players too, does not wait for a target to be "hostile", and checks line of + /// sight as it senses: Rust refreshes LOS every think only for targets that are NOT NPCs + /// (AIBrainSenses.UpdateKnownPlayersLOS), and HumanNPC.TickAttack never fires without it. + /// + private static void FightsScientists(RnhBrain b) + { + b.SenseTypes |= EntityType.BasePlayerNPC; + b.HostileTargetsOnly = false; + b.CheckLOS = true; + RnhNpc npc = b.GetComponent(); + if (npc != null) npc.RnhFightsNpcs = true; + } + + private ScientistNPC SpawnStock(Vector3 at) + { + ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, at) as ScientistNPC; + if (s == null) return null; + s.enableSaving = false; + s.Spawn(); + _spawned.Add(s); + return s; + } + + private string Equip(BasePlayer p) + { + p.inventory.Strip(); + object r = Kits?.Call("GiveKit", p, Kit); + (p as NPCPlayer)?.EquipWeapon(); + return r == null ? "null" : r.ToString(); + } + + private bool KitExists() => Kits != null && Kits.Call("IsKit", Kit) is bool b && b; + + /// The scientist subclass. Only what stage 1 has to prove it can override. + public class RnhNpc : ScientistNPC, IAISenses, IAIAttack + { + public int AttackTicks, AttackTicksLos; + public string LastAttackTarget = "-"; + + /// Counts what Rust's AttackTick event hands us: the last step before a shot. + public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS) + { + AttackTicks++; + if (targetIsLOS) AttackTicksLos++; + LastAttackTarget = target != null ? target.ShortPrefabName : "null"; + base.AttackTick(delta, target, targetIsLOS); + } + + public string RnhName = "Scientist"; + + /// + /// Rust's HumanNPC.IsTarget answers true only for non-NPC players, pets and scarecrows, + /// so a scientist never attacks another NPC. Re-implementing IAISenses is how ours can: + /// it targets stock scientists (not other RnhNpcs) when this is set. + /// + public bool RnhFightsNpcs; + + public new bool IsTarget(BaseEntity entity) => + base.IsTarget(entity) || (RnhFightsNpcs && entity is ScientistNPC && !(entity is RnhNpc)); + + public new bool IsThreat(BaseEntity entity) => IsTarget(entity); + + public new bool IsFriendly(BaseEntity entity) => !IsTarget(entity) && base.IsFriendly(entity); + public float RnhAimCone = -1f; + + public override string displayName => RnhName; + + public override float GetAimConeScale() => RnhAimCone >= 0f ? RnhAimCone : base.GetAimConeScale(); + + /// + /// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's + /// death screen, after BasePlayer's displayName. Ours puts the name back. + /// + public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) + { + base.AttackerInfo(info); + info.attackerName = displayName; + } + + // ---- stage 5: our own target, beside Rust's ---- + + /// This NPC's "profile" for the spike: allies share a tag. + public string RnhTag = ""; + + /// The factions it hunts: scientists, animals, ours:<tag>. Null: none. + public HashSet RnhHostile; + + /// + /// BaseProjectile.ServerUse drops every hit on an NPC from an NPC's gun unless the victim's + /// faction is Horror. When set, the target alone is made Horror for the length of each of + /// this NPC's shots, then put back, so nothing else changes for anyone else's bullets. + /// + public bool RnhShotFaction; + + /// When a turret or trap hurts it and it has no target, the turret becomes its target. + public bool RnhShootsBack; + + public BaseCombatEntity RnhNpcTarget; + public int ShotTests, ShotsMarked; + public string LastHurtBy = "-"; + + private BaseCombatEntity _marked; + private BaseCombatEntity.Faction _markedWas; + + /// + /// Marks the target unless a mark is already in place, and says whether it did. ShotTest + /// calls TriggerDown inside itself, so the two nest: an inner mark would save "Horror" as + /// the faction to put back and leave the target Horror for good, after which everyone's + /// bullets hit it, allies' included (the first 50-v-50 run showed exactly that). + /// + private bool Mark() + { + if (_marked != null) + return false; + BaseCombatEntity t = RnhNpcTarget; + if (!RnhShotFaction || t == null || t.IsDestroyed || !t.IsNpc) + return false; + _marked = t; + _markedWas = t.faction; + t.faction = BaseCombatEntity.Faction.Horror; + ShotsMarked++; + return true; + } + + private void Unmark(bool marked) + { + if (!marked) + return; + if (_marked != null && !_marked.IsDestroyed) + _marked.faction = _markedWas; + _marked = null; + } + + /// A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown. + public override bool ShotTest(float targetDist) + { + ShotTests++; + bool marked = Mark(); + try { return base.ShotTest(targetDist); } + finally { Unmark(marked); } + } + + public override void TriggerDown() + { + bool marked = Mark(); + try { base.TriggerDown(); } + finally { Unmark(marked); } + } + + public override void Hurt(HitInfo info) + { + if (info != null) + { + BaseEntity parent = info.Weapon != null ? info.Weapon.GetParentEntity() : null; + LastHurtBy = $"initiator={info.Initiator?.GetType().Name ?? "null"} weapon={info.Weapon?.ShortPrefabName ?? "null"} weaponParent={parent?.GetType().Name ?? "null"} type={info.damageTypes.GetMajorityDamageType()}"; + BaseCombatEntity turret = TurretOf(info.Initiator) ?? TurretOf(parent); + if (RnhShootsBack && turret != null && (RnhNpcTarget == null || RnhNpcTarget.IsDestroyed)) + RnhNpcTarget = turret; + } + base.Hurt(info); + } + + /// + /// The turret behind a hit. A flame turret's damage comes from its fireballs, whose + /// creator is the turret, or the fireball that spawned them: follow the chain. + /// + public static BaseCombatEntity TurretOf(BaseEntity e) + { + for (int i = 0; i < 6 && e is FireBall; i++) + e = e.creatorEntity == e ? null : e.creatorEntity; + return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; + } + } + + /// + /// The brain subclass. It adds no state of its own: stage 1 asks what Rust's own design + /// does with our values. It times its own thinking, which is what the cost warning needs + /// beside frame time, because Rust budgets human AI per frame (aithinkmanager.framebudgetms). + /// + public class RnhBrain : ScientistBrain + { + public static double ThinkMs; + public static long Thinks; + public float LastThinkAt = -1f; + public float IntervalSum; + public int IntervalCount; + + public override void Think(float delta) + { + if (LastThinkAt >= 0f) + { + IntervalSum += Time.time - LastThinkAt; + IntervalCount++; + } + LastThinkAt = Time.time; + long t0 = Stopwatch.GetTimestamp(); + base.Think(delta); + + // Harness-only stand-in for the combat state stage 5 will write: Rust's design never + // runs its AttackTick event for an NPC target, so when this NPC is set to fight NPCs + // and knows one, face it and hand Rust's own attack routine the target and its LOS. + RnhNpc npc = GetBaseEntity() as RnhNpc; + if (npc != null && npc.RnhFightsNpcs && Senses?.Memory != null) + { + BaseEntity target = Senses.GetNearestTarget(1f); + if (target != null) + { + Navigator?.SetFacingDirectionEntity(target); + npc.AttackTick(delta, target, Senses.Memory.IsLOS(target)); + } + } + + if (npc != null && Navigator != null && (npc.RnhHostile != null || npc.RnhNpcTarget != null)) + { + long o0 = Stopwatch.GetTimestamp(); + OwnThink(npc, delta); + OwnMs += (Stopwatch.GetTimestamp() - o0) * 1000.0 / Stopwatch.Frequency; + OwnThinks++; + } + ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency; + Thinks++; + } + + // ---- stage 5: the prototype of our own sensing and combat state ---- + + public const float OwnSenseRange = 40f; + public const float OwnLoseRange = 60f; + public const float OwnSenseEvery = 1f; + private const int LosBudget = 3; + private const int LosMask = 1218519041; + + public static double OwnMs, SenseMs; + public static long OwnThinks, SenseRuns, Scanned, LosTests, Engaged; + + private float _nextSense, _nextLos, _nextMove; + private bool _los; + + private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; + private static readonly float[] BestD2 = new float[LosBudget]; + + /// + /// After Rust's own think, so Rust's design still owns every fight with a player. With no + /// player to fight and a target of ours: face it (the states clear facing every think, so + /// it is set again after them; the navigator applies it on its own 0.1 s tick), close in + /// until it is in range and in sight, and hand Rust's own attack routine the target. + /// + private void OwnThink(RnhNpc npc, float delta) + { + float now = Time.time; + BaseCombatEntity t = npc.RnhNpcTarget; + if (t != null && (t.IsDestroyed || t.IsDead() || (t.transform.position - npc.transform.position).sqrMagnitude > OwnLoseRange * OwnLoseRange)) + t = npc.RnhNpcTarget = null; + + if (npc.RnhHostile != null && now >= _nextSense && RnhNpc.TurretOf(t) == null) + { + _nextSense = now + OwnSenseEvery; + long s0 = Stopwatch.GetTimestamp(); + BaseCombatEntity found = SenseHostile(npc); + SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; + SenseRuns++; + if (found != null) + t = npc.RnhNpcTarget = found; + } + + if (t == null || FightingAPlayer()) + return; + + Engaged++; + if (now >= _nextLos) + { + _nextLos = now + 0.5f; + _los = Los(npc, t); + LosTests++; + } + + Navigator.SetFacingDirectionEntity(t); + float dist = Vector3.Distance(npc.transform.position, t.transform.position); + if (!_los || dist > npc.EngagementRange() * 0.9f) + { + if (now >= _nextMove) + { + _nextMove = now + 1f; + Navigator.SetDestination(t.transform.position, BaseNavigator.NavigationSpeed.Fast); + } + } + else if (Navigator.Moving) + Navigator.Stop(); + + npc.AttackTick(delta, t, _los); + } + + private bool FightingAPlayer() + { + if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null) + return false; + foreach (BaseEntity e in Senses.Memory.Targets) + if (e is BasePlayer p && !p.IsNpc && p.IsAlive()) + return true; + return false; + } + + /// + /// Only the factions this NPC is hostile to, from the harness's registry rather than a + /// physics query; distance first (no physics), then line of sight on the nearest few only. + /// + private static BaseCombatEntity SenseHostile(RnhNpc npc) + { + for (int i = 0; i < LosBudget; i++) { Best[i] = null; BestD2[i] = float.MaxValue; } + Vector3 at = npc.transform.position; + float r2 = OwnSenseRange * OwnSenseRange; + foreach (string faction in npc.RnhHostile) + { + HashSet set = Registry(faction); + if (set == null) continue; + foreach (BaseCombatEntity e in set) + { + if (e == null || e.IsDestroyed || e == npc || e.IsDead()) continue; + Scanned++; + float d2 = (e.transform.position - at).sqrMagnitude; + if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) continue; + int k = LosBudget - 1; + while (k > 0 && BestD2[k - 1] > d2) { Best[k] = Best[k - 1]; BestD2[k] = BestD2[k - 1]; k--; } + Best[k] = e; BestD2[k] = d2; + } + } + for (int i = 0; i < LosBudget && Best[i] != null; i++) + { + LosTests++; + if (Los(npc, Best[i])) return Best[i]; + } + return null; + } + + /// + /// HumanNPC.CanSeeTarget answers true for anything that is not a player, without a ray, + /// so animals and turrets get a linecast that counts the target's own collider as seen. + /// + private static bool Los(RnhNpc npc, BaseCombatEntity t) + { + BasePlayer p = t as BasePlayer; + if (p != null) return npc.CanSeeTarget(p); + RaycastHit hit; + if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) + return true; + BaseEntity e = hit.GetEntity(); + return e != null && (e == t || e.EqualNetID(t) || e.HasParent() && e.GetParentEntity() == t); + } + } + + // ---- the stage 5 registry: who can be hunted, by faction ---- + + private static readonly HashSet Scientists = new HashSet(); + private static readonly HashSet Animals = new HashSet(); + private static readonly Dictionary> OursByTag = new Dictionary>(); + + private static HashSet Registry(string faction) + { + if (faction == "scientists") return Scientists; + if (faction == "animals") return Animals; + HashSet set; + return faction.StartsWith("ours:") && OursByTag.TryGetValue(faction.Substring(5), out set) ? set : null; + } + + private static void Register(BaseEntity e) + { + if (e is RnhNpc) return; + if (e is ScientistNPC s) Scientists.Add(s); + else if (e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2) Animals.Add((BaseCombatEntity)e); + } + + private static void Tag(RnhNpc npc, string tag) + { + npc.RnhTag = tag; + HashSet set; + if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet(); + set.Add(npc); + } + + private static void Unregister(BaseEntity e) + { + BaseCombatEntity c = e as BaseCombatEntity; + if (c == null) return; + Scientists.Remove(c); + Animals.Remove(c); + RnhNpc o = c as RnhNpc; + HashSet set; + if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o); + } + + // ---- frame meter ---- + + public class FrameMeter : MonoBehaviour + { + private readonly List _frames = new List(20000); + public bool Recording; + + public void Begin() + { + _frames.Clear(); + Recording = true; + } + + public List End() + { + Recording = false; + return new List(_frames); + } + + private void Update() + { + if (Recording) + _frames.Add(Time.unscaledDeltaTime * 1000f); + } + } + + private static Dictionary Summarise(List ms) + { + if (ms.Count == 0) + return new Dictionary { ["frames"] = 0 }; + ms.Sort(); + Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)]; + double mean = ms.Average(); + return new Dictionary + { + ["frames"] = ms.Count, + ["meanMs"] = Math.Round(mean, 2), + ["fps"] = Math.Round(1000.0 / mean, 1), + ["p50Ms"] = Math.Round(pct(0.50), 2), + ["p95Ms"] = Math.Round(pct(0.95), 2), + ["p99Ms"] = Math.Round(pct(0.99), 2), + ["maxMs"] = Math.Round(ms[ms.Count - 1], 2), + }; + } + + // ---- places ---- + + /// + /// A flat, dry spot on the navmesh at least m from every + /// monument, found by sampling the map from a fixed seed so a rerun lands in the same field. + /// + private bool FindField(float clear, int seed, out Vector3 at) + { + var rng = new System.Random(seed); + float half = World.Size / 2f - 300f; + List monuments = TerrainMeta.Path.Monuments; + + int wet = 0, steep = 0, nearMonument = 0, offMesh = 0, offAnyMesh = 0; + 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) { wet++; continue; } + if (TerrainMeta.HeightMap.GetSlope(p) > 15f) { steep++; continue; } + if (monuments.Any(m => Vector3.Distance(m.transform.position, p) < clear)) { nearMonument++; continue; } + NavMeshHit hit; + if (!OnNavmesh(p, 2f, out hit)) + { + offMesh++; + if (!NavMesh.SamplePosition(p, out hit, 2f, NavMesh.AllAreas)) offAnyMesh++; + continue; + } + at = hit.position; + return true; + } + _self?.Log("field", $"none: wet={wet} steep={steep} nearMonument={nearMonument} offRustNavmesh={offMesh} offUnityNavmesh={offAnyMesh} rustNavmeshBuilt={NavmeshBuilt()}"); + at = Vector3.zero; + return false; + } + + /// + /// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2 + /// RustNavMeshAgent, not Unity's NavMeshAgent, and Unity's NavMesh queries see none of + /// the open world. This is the helper Rust's navigator samples with, and it answers false + /// until the default navmesh is built (minutes after a map's first boot). + /// + private static bool OnNavmesh(Vector3 p, float radius, out NavMeshHit hit) => + Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); + + private static bool NavmeshBuilt() => + Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt(); + + private static Vector3 Ring(Vector3 centre, int i, int n, float r) => + centre + new Vector3(Mathf.Cos(i * 2f * Mathf.PI / n) * r, 0f, Mathf.Sin(i * 2f * Mathf.PI / n) * r); + + private static readonly FieldInfo StatesField = typeof(BaseAIBrain).GetField("states", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo SleepingField = typeof(BaseAIBrain).GetField("sleeping", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo DisabledField = typeof(BaseAIBrain).GetField("disabled", BindingFlags.Instance | BindingFlags.NonPublic); + + /// A brain's insides: what it takes for a scientist to have a state at all. + private static string Diag(HumanNPC n) + { + if (n == null || n.IsDestroyed) return "gone"; + ScientistBrain b = n.Brain; + if (b == null) return $"{n.GetType().Name} Brain=null comps={string.Join(",", n.GetComponents().Select(c => c.GetType().Name).ToArray())}"; + var states = StatesField?.GetValue(b) as System.Collections.IDictionary; + return $"{n.GetType().Name}/{b.GetType().Name} enabled={b.enabled} state={b.CurrentState?.StateType} states={(states == null ? "null" : states.Count.ToString())} " + + $"useDesign={b.UseAIDesign} design={(b.AIDesign != null)} designs={b.Designs?.Count} defaultSO={(b.DefaultDesignSO != null)} think={b.ThinkMode} " + + $"sleeping={SleepingField?.GetValue(b)} disabled={DisabledField?.GetValue(b)} nav={(b.Navigator != null)} agent={(n.NavAgent != null)} " + + $"onMesh={(n.NavAgent != null && n.NavAgent.isOnNavMesh)} pathFinder={(b.PathFinder != null)} senses={b.Senses?.Players?.Count} " + + $"targets={b.Senses?.Memory?.Targets?.Count} threats={b.Senses?.Memory?.Threats?.Count} " + + $"slot={(b.Events != null ? b.Events.Memory.Entity.Get(b.Events.CurrentInputMemorySlot)?.ShortPrefabName : "-")} " + + (n is RnhNpc r ? $"attackTicks={r.AttackTicks} withLos={r.AttackTicksLos} lastTarget={r.LastAttackTarget} " : "") + + $"pos={V(n.transform.position)}"; + } + + private static Vector3 Ground(Vector3 p) + { + p.y = TerrainMeta.HeightMap.GetHeight(p); + return p; + } + + private static string V(Vector3 p) => $"{p.x:0},{p.y:0.#},{p.z:0}"; + + // ---- hooks (all void: none of them may change what the game does) ---- + + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) + { + if (info == null) return; + string weapon = info.WeaponPrefab != null ? info.WeaponPrefab.ShortPrefabName : info.Weapon != null ? info.Weapon.ShortPrefabName : "none"; + TallyPair(victim, info, weapon); + if (IsStandIn(victim) && info.Initiator is RnhNpc) + { + _hitsOnStandIns.Hits++; + if (_standInsInvulnerable) info.damageTypes.ScaleAll(0f); + } + HitTally tally = info.Initiator is RnhNpc ? _hitsByOurs : victim is RnhNpc ? _hitsOnOurs : null; + if (tally == null) return; + tally.Hits++; + tally.Damage += info.damageTypes.Total(); + int n; + tally.Weapons.TryGetValue(weapon, out n); + tally.Weapons[weapon] = n + 1; + } + + private void OnEntityDeath(BaseCombatEntity victim, HitInfo info) + { + string label; + if (victim != null && _labels.TryGetValue(victim, out label) && !_diedAt.ContainsKey(label)) + _diedAt[label] = (float)Math.Round(Time.time - _scenarioStart, 1); + if (!(victim is RnhNpc) && !IsStandIn(victim)) return; + BasePlayer p = (BasePlayer)victim; + _deaths.Add($"{p.GetType().Name} '{p.displayName}' by {(info?.Initiator != null ? info.Initiator.GetType().Name + " '" + (info.Initiator as BasePlayer)?.displayName + "'" : "none")}"); + } + + private void OnEntityKill(BaseNetworkable entity) => Unregister(entity as BaseEntity); + + /// + /// TruePVE and NextGenPVE ask every plugin this before applying their rules (D261). The + /// harness counts the asks; in "allow" mode it allows any hit to or from one of ours, in + /// "deny" mode it refuses them. + /// + private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + _pveAsks++; + if (_pveMode == 0 || !(entity is RnhNpc || info?.Initiator is RnhNpc)) + return null; + return _pveMode == 1; + } + + private void OnEntitySpawned(BaseEntity entity) + { + Register(entity); + PlayerCorpse corpse = entity as PlayerCorpse; + if (corpse == null) return; + if (corpse.playerSteamID != DummyUserId && (corpse.playerName == null || !corpse.playerName.StartsWith("Rnh"))) return; + _corpses.Add($"{corpse.GetType().Name} playerName='{corpse.playerName}' steamId={corpse.playerSteamID}"); + } + + // ---- 1. the swap ---- + + [ConsoleCommand("rnh.swap")] + private void CmdSwap(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(250f, 101, out field)) { Log("swap", "FAIL no field found"); return; } + + _hitsByOurs.Hits = _hitsOnOurs.Hits = 0; + _hitsByOurs.Weapons.Clear(); _hitsOnOurs.Weapons.Clear(); + _deaths.Clear(); _corpses.Clear(); + + SpawnReport r = SpawnOurs(field, "RnhWarden", FightsScientists); + if (r.Npc == null) { Log("swap", "FAIL " + r.Error); return; } + RnhNpc npc = r.Npc; + Vector3 start = npc.transform.position; + + var rec = new Dictionary + { + ["at"] = V(start), + ["spawnMs"] = Math.Round(r.Ms, 2), + ["npcFieldsCopied"] = r.NpcFields, + ["brainFieldsCopied"] = r.BrainFields, + ["type"] = npc.GetType().Name, + ["brainType"] = npc.Brain != null ? npc.Brain.GetType().Name : "null", + ["isNpc"] = npc.IsNpc, + ["displayName"] = npc.displayName, + ["shortPrefab"] = npc.ShortPrefabName, + ["enableSaving"] = npc.enableSaving, + ["navigator"] = npc.Brain?.Navigator != null, + ["kitResult"] = r.Kit, + ["belt0"] = npc.inventory.containerBelt.GetSlot(0)?.info.shortname ?? "none", + ["held"] = npc.GetHeldEntity() != null ? npc.GetHeldEntity().ShortPrefabName : "none", + }; + Log("swap", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray())); + + var samples = new List(); + float maxMoved = 0f; + int t = 0; + ScientistNPC foe = null; + + timer.Repeat(5f, 18, () => + { + t += 5; + if (t == 30 && npc != null && !npc.IsDestroyed && !npc.IsDead()) + foe = SpawnStock(Ground(start + new Vector3(8f, 0f, 0f))); + if (npc == null || npc.IsDestroyed) { samples.Add($"+{t}s gone"); return; } + float moved = Vector3.Distance(start, npc.transform.position); + maxMoved = Mathf.Max(maxMoved, moved); + string s = $"+{t}s state={npc.Brain?.CurrentState?.StateType} moved={moved:0.0} hp={npc.health:0} " + + $"thinking={((npc.Brain as RnhBrain)?.LastThinkAt > Time.time - 2f)} foe={(foe != null && !foe.IsDestroyed ? foe.health.ToString("0") : "-")}"; + samples.Add(s); + Log("swap", s); + if (t == 5 || t == 35 || t == 60) + { + Log("swap", " ours " + Diag(npc)); + if (foe != null) Log("swap", " stock " + Diag(foe)); + } + }); + + timer.Once(92f, () => + { + bool alive = npc != null && !npc.IsDestroyed && !npc.IsDead(); + if (alive) + npc.Hurt(1000f, Rust.DamageType.Bullet, foe != null && !foe.IsDestroyed ? foe : null, false); + timer.Once(3f, () => + { + rec["samples"] = samples; + rec["walked"] = maxMoved > 3f; + rec["maxMovedM"] = Math.Round(maxMoved, 1); + rec["hitsByOurs"] = _hitsByOurs.Hits; + rec["hitsByOursWeapons"] = new Dictionary(_hitsByOurs.Weapons); + rec["hitsOnOurs"] = _hitsOnOurs.Hits; + rec["killedByHarness"] = alive; + rec["deaths"] = new List(_deaths); + rec["corpses"] = new List(_corpses); + rec["destroyedAfterDeath"] = npc == null || npc.IsDestroyed; + Record("swap", rec); + Log("swap", $"DONE walked={rec["walked"]} hitsByOurs={_hitsByOurs.Hits} weapons={string.Join(",", _hitsByOurs.Weapons.Select(kv => kv.Key + ":" + kv.Value).ToArray())} " + + $"hitsOnOurs={_hitsOnOurs.Hits} deaths={_deaths.Count} corpses={string.Join(" | ", _corpses.ToArray())} destroyed={rec["destroyedAfterDeath"]}"); + if (foe != null && !foe.IsDestroyed) foe.Kill(); + }); + }); + } + + // ---- 2. saving ---- + + private sealed class Planted + { + public ulong NetId; + public float X, Y, Z; + public bool Saved; + } + + [ConsoleCommand("rnh.save")] + private void CmdSave(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + string mode = arg.GetString(0); + + if (mode == "plant") + { + Vector3 field; + if (!FindField(250f, 202, out field)) { Log("save", "FAIL no field found"); return; } + var planted = new List(); + for (int i = 0; i < 6; i++) + { + // Five never saved, one saved: the control that proves the check can see a saved NPC. + bool saved = i == 5; + SpawnReport r = SpawnOurs(Ground(field + new Vector3(i * 6f, 0f, 0f)), "RnhSave" + i, null, saved); + if (r.Npc == null) continue; + _spawned.Remove(r.Npc); // Unload must not kill them: the restart is the test. + planted.Add(new Planted { NetId = r.Npc.net.ID.Value, Saved = saved }); + } + timer.Once(3f, () => + { + foreach (Planted p in planted) + { + BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); + if (e == null) continue; + p.X = e.transform.position.x; p.Y = e.transform.position.y; p.Z = e.transform.position.z; + } + Interface.Oxide.DataFileSystem.WriteObject(Name + "_planted", planted); + ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.save"); + Log("save", $"planted {planted.Count} ({planted.Count(p => p.Saved)} saved control) at {V(field)} and ran server.save; now hard-kill the server"); + }); + return; + } + + if (mode == "check") + { + List planted = Interface.Oxide.DataFileSystem.ReadObject>(Name + "_planted") ?? new List(); + int backUnsaved = 0, backSaved = 0; + var found = new List(); + foreach (Planted p in planted) + { + // By net id: Rust restores a saved entity under the id it had. (A distance match was + // tried first and matched the saved control from its neighbour 6 m away.) + BaseNetworkable hit = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); + BaseNetworkable byId = hit; + if (hit == null) continue; + if (p.Saved) backSaved++; else backUnsaved++; + found.Add($"{p.NetId} saved={p.Saved} -> {hit.GetType().Name} '{(hit as BasePlayer)?.displayName}' byId={byId != null}"); + } + var rec = new Dictionary + { + ["planted"] = planted.Count, + ["unsavedBack"] = backUnsaved, + ["savedControlBack"] = backSaved, + ["found"] = found, + ["pass"] = backUnsaved == 0 && backSaved == planted.Count(p => p.Saved), + }; + Record("save." + Framework(), rec); + Log("save", $"{(true.Equals(rec["pass"]) ? "PASS" : "FAIL")} unsaved back={backUnsaved}/{planted.Count(p => !p.Saved)} saved control back={backSaved}/{planted.Count(p => p.Saved)} :: {string.Join(" | ", found.ToArray())}"); + foreach (Planted p in planted) + { + BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); + if (e != null) e.Kill(); + } + return; + } + + Log("save", "usage: rnh.save plant|check"); + } + + // ---- 3. tuning ---- + + /// + /// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain + /// starts (the values Senses.Init copies) or AFTER it is running, and then observed. + /// + [ConsoleCommand("rnh.tune")] + private void CmdTune(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(250f, 303, out field)) { Log("tune", "FAIL no field found"); return; } + + var rec = new Dictionary(); + + // Health and name and aim cone: the NPC's own values. + SpawnReport h = SpawnOurs(field, "RnhTune", null); + RnhNpc n = h.Npc; + n.startHealth = 400f; + n.InitializeHealth(400f, 400f); + n.RnhAimCone = 0.5f; + rec["health.set400"] = $"{n.health:0}/{n.MaxHealth():0}"; + rec["name"] = n.displayName; + rec["aimCone.override0.5"] = n.GetAimConeScale(); + n.Kill(); + + // Sense range: a stock scientist 35 m away. Default SenseRange on the prefab vs 50 set before start vs after. + Vector3 foeAt = Ground(field + new Vector3(35f, 0f, 0f)); + ScientistNPC target = SpawnStock(foeAt); + target.Brain.SetEnabled(false); + + SpawnReport dflt = SpawnOurs(Ground(field + new Vector3(0f, 0f, 4f)), "RnhDefault", b => b.SenseTypes |= EntityType.BasePlayerNPC); + SpawnReport before = SpawnOurs(Ground(field + new Vector3(0f, 0f, -4f)), "RnhBefore", b => + { + b.SenseTypes |= EntityType.BasePlayerNPC; + b.SenseRange = 50f; + b.TargetLostRange = 60f; + }); + SpawnReport after = SpawnOurs(Ground(field + new Vector3(0f, 0f, -8f)), "RnhAfter", b => b.SenseTypes |= EntityType.BasePlayerNPC); + + rec["prefab.SenseRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseRange : -1f; + rec["prefab.TargetLostRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.TargetLostRange : -1f; + rec["prefab.AttackRangeMultiplier"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.AttackRangeMultiplier : -1f; + rec["prefab.VisionCone"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.VisionCone : -1f; + rec["prefab.HostileTargetsOnly"] = dflt.Npc.Brain != null && dflt.Npc.Brain.HostileTargetsOnly; + rec["prefab.CheckLOS"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckLOS; + rec["prefab.CheckVisionCone"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckVisionCone; + rec["prefab.ListenRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.ListenRange : -1f; + rec["prefab.MemoryDuration"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.MemoryDuration : -1f; + rec["prefab.SenseTypes"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseTypes.ToString() : "-"; + rec["prefab.MaxRoamDistanceFromHome"] = dflt.Npc.Brain?.Navigator != null ? dflt.Npc.Brain.Navigator.MaxRoamDistanceFromHome : -1f; + rec["prefab.aimConeScale"] = dflt.Npc.aimConeScale; + rec["prefab.startHealth"] = dflt.Npc.startHealth; + + timer.Once(3f, () => + { + if (after.Npc?.Brain != null) + { + after.Npc.Brain.SenseRange = 50f; + after.Npc.Brain.TargetLostRange = 60f; + } + }); + + timer.Once(10f, () => + { + Func sees = s => + s.Npc == null || s.Npc.IsDestroyed ? "gone" : + $"knows={s.Npc.Brain.Senses.Players.Contains(target)} state={s.Npc.Brain.CurrentState?.StateType}"; + rec["senseRange.default@35m"] = sees(dflt); + rec["senseRange.50.beforeStart@35m"] = sees(before); + rec["senseRange.50.afterStart@35m"] = sees(after); + + // Roam: where does a roamer go with no AI zone vs its home range? + rec["roam.aiZoneHere"] = AIInformationZone.GetForPoint(field, false) != null; + Record("tune", rec); + Log("tune", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray())); + foreach (SpawnReport s in new[] { dflt, before, after }) + if (s.Npc != null && !s.Npc.IsDestroyed) s.Npc.Kill(); + if (target != null && !target.IsDestroyed) target.Kill(); + }); + } + + /// Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each. + [ConsoleCommand("rnh.roam")] + private void CmdRoam(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(250f, 404, out field)) { Log("roam", "FAIL no field found"); return; } + AIInformationZone zone = AIInformationZone.zones.FirstOrDefault(z => z != null && z.GetComponentInParent() != null); + Vector3 monument = Vector3.zero; + NavMeshHit mh; + if (zone != null && OnNavmesh(zone.transform.position, 20f, out mh)) monument = mh.position; + + SpawnReport inField = SpawnOurs(field, "RnhField", null); + SpawnReport inZone = monument != Vector3.zero ? SpawnOurs(monument, "RnhZone", null) : null; + Vector3 f0 = field, z0 = monument; + float fMax = 0f, zMax = 0f; + var states = new List(); + timer.Repeat(5f, 12, () => + { + if (inField.Npc != null && !inField.Npc.IsDestroyed) fMax = Mathf.Max(fMax, Vector3.Distance(f0, inField.Npc.transform.position)); + if (inZone?.Npc != null && !inZone.Npc.IsDestroyed) zMax = Mathf.Max(zMax, Vector3.Distance(z0, inZone.Npc.transform.position)); + states.Add($"field={inField.Npc?.Brain?.CurrentState?.StateType} zone={inZone?.Npc?.Brain?.CurrentState?.StateType}"); + }); + timer.Once(62f, () => + { + var rec = new Dictionary + { + ["field.maxMovedM"] = Math.Round(fMax, 1), + ["zone"] = zone != null ? zone.GetComponentInParent()?.name : "none", + ["zone.maxMovedM"] = Math.Round(zMax, 1), + ["states"] = states, + }; + Record("roam", rec); + Log("roam", $"field moved {fMax:0.0} m, zone ({rec["zone"]}) moved {zMax:0.0} m; last {states.LastOrDefault()}"); + inField.Npc?.Kill(); + inZone?.Npc?.Kill(); + }); + } + + // ---- 4. navmesh ---- + + [ConsoleCommand("rnh.nav")] + private void CmdNav(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + int mask = LayerMask.GetMask("Terrain", "World", "Construction", "Default", "Deployed"); + var rows = new List(); + int points = 0, onMesh = 0, roofs = 0, roofsOnMesh = 0; + Stopwatch sw = Stopwatch.StartNew(); + long checkTicks = 0; + + foreach (MonumentInfo m in TerrainMeta.Path.Monuments) + { + if (m == null) continue; + Vector3 c = m.transform.position; + float r = Mathf.Clamp(Mathf.Min(m.Bounds.extents.x, m.Bounds.extents.z) * 0.5f, 5f, 40f); + int p = 0, on = 0, rf = 0, rfOn = 0; + for (int i = 0; i < 9; i++) + { + Vector3 probe = i == 0 ? c : Ring(c, i - 1, 8, r); + RaycastHit hit; + if (!Physics.Raycast(new Vector3(probe.x, 1000f, probe.z), Vector3.down, out hit, 1100f, mask)) continue; + p++; + bool roof = hit.point.y > TerrainMeta.HeightMap.GetHeight(hit.point) + 2.5f; + long t0 = Stopwatch.GetTimestamp(); + NavMeshHit nh; + bool ok = OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f; + checkTicks += Stopwatch.GetTimestamp() - t0; + if (ok) on++; + if (roof) { rf++; if (ok) rfOn++; } + } + points += p; onMesh += on; roofs += rf; roofsOnMesh += rfOn; + rows.Add(new Dictionary + { + ["monument"] = System.IO.Path.GetFileNameWithoutExtension(m.name), + ["points"] = p, ["onMesh"] = on, ["elevated"] = rf, ["elevatedOnMesh"] = rfOn, + }); + } + + // A player-built roof: a floor held 4 m up on the field, sampled on top, then removed. + Vector3 field; + string built = "no field"; + if (FindField(250f, 505, out field)) + { + BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + Vector3.up * 4f); + if (floor != null) + { + floor.enableSaving = false; + floor.Spawn(); + RaycastHit hit; + bool gotTop = Physics.Raycast(field + Vector3.up * 20f, Vector3.down, out hit, 30f, mask); + NavMeshHit nh; + bool ok = gotTop && OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f; + built = $"top={(gotTop ? hit.point.y - field.y : -1f):0.0}m onMesh={ok}"; + floor.Kill(); + } + } + + double checkUs = points > 0 ? checkTicks * 1e6 / Stopwatch.Frequency / points : 0; + var rec = new Dictionary + { + ["monuments"] = rows.Count, ["points"] = points, ["onMesh"] = onMesh, + ["elevated"] = roofs, ["elevatedOnMesh"] = roofsOnMesh, + ["builtFloor4m"] = built, + ["checkMicrosEach"] = Math.Round(checkUs, 1), + ["scanMs"] = sw.ElapsedMilliseconds, + ["rows"] = rows, + }; + Record("nav." + Framework(), rec); + Log("nav", $"{rows.Count} monuments, {onMesh}/{points} points on mesh, elevated {roofsOnMesh}/{roofs}, built floor {built}, check {checkUs:0.0} us each, scan {sw.ElapsedMilliseconds} ms"); + foreach (Dictionary row in rows.Cast>()) + Log("nav", $" {row["monument"]}: {row["onMesh"]}/{row["points"]} elevated {row["elevatedOnMesh"]}/{row["elevated"]}"); + } + + // ---- 5. cost ---- + + [ConsoleCommand("rnh.cost")] + private void CmdCost(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(400f, 606, out field)) { Log("cost", "FAIL no field found"); return; } + + float window = arg.GetFloat(0, 30f); + var phases = new List>(); + var ours = new List(); + var stock = new List(); + var standIns = new List(); + int stockInWorld = BaseNetworkable.serverEntities.OfType().Count(); + + Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)); + Func foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 45f + (i / 10) * 3f)); + + var queue = new Queue>(); + + // Each phase: run its setup (timed), then record frames for `window` seconds. + Action next = null; + next = () => + { + if (queue.Count == 0) + { + foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill(); + foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill(); + foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill(); + _standInsInvulnerable = false; + var rec = new Dictionary + { + ["framework"] = Framework(), + ["map"] = $"{World.Size}/{World.Seed}", + ["players"] = BasePlayer.activePlayerList.Count, + ["stockScientistsInWorld"] = stockInWorld, + ["aiBudgetMs"] = AIThinkManager.framebudgetms, + ["windowS"] = window, + ["phases"] = phases, + }; + Record("cost." + Framework(), rec); + Log("cost", "DONE"); + return; + } + KeyValuePair step = queue.Dequeue(); + Stopwatch sw = Stopwatch.StartNew(); + step.Value(); + double setupMs = sw.Elapsed.TotalMilliseconds; + RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0; + foreach (RnhNpc o in ours) + { + if (o?.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; } + if (o != null) { o.AttackTicks = 0; o.AttackTicksLos = 0; } + } + _hitsByOurs.Hits = 0; _hitsOnOurs.Hits = 0; _hitsOnStandIns.Hits = 0; + long gc0 = GC.GetTotalMemory(false); + _meter.Begin(); + timer.Once(window, () => + { + Dictionary f = Summarise(_meter.End()); + float isum = 0f; int icount = 0; + foreach (RnhNpc o in ours) if (o?.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; } + f["phase"] = step.Key; + f["setupMs"] = Math.Round(setupMs, 1); + f["ours"] = ours.Count(o => o != null && !o.IsDestroyed && !o.IsDead()); + f["stock"] = stock.Count(s => s != null && !s.IsDestroyed && !s.IsDead()); + f["thinkMsPerSecond"] = Math.Round(RnhBrain.ThinkMs / window, 2); + f["thinksPerSecond"] = Math.Round(RnhBrain.Thinks / window, 1); + f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0; + f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0; + f["aiQueue"] = AIThinkManager._processQueue.Count; + f["hitsByOurs"] = _hitsByOurs.Hits; + f["hitsOnOurs"] = _hitsOnOurs.Hits; + f["hitsOnStandIns"] = _hitsOnStandIns.Hits; + f["attackTicksWithLos"] = ours.Where(o => o != null && !o.IsDestroyed).Sum(o => o.AttackTicksLos); + f["gcDeltaMB"] = Math.Round((GC.GetTotalMemory(false) - gc0) / 1048576.0, 1); + phases.Add(f); + Log("cost", string.Join(" ", f.Select(kv => kv.Key + "=" + kv.Value).ToArray())); + next(); + }); + }; + + // Rust's default senses (players only) unless a phase asks for NPC fighting: sensing NPCs with + // line of sight is a cost of its own, measured in its own phase. + Action> spawnTo = (n, tune) => + { + for (int i = ours.Count; i < n; i++) + { + SpawnReport r = SpawnOurs(grid(i), "RnhCost" + i, tune); + if (r.Npc != null) ours.Add(r.Npc); + } + }; + + queue.Enqueue(new KeyValuePair("baseline", () => { })); + queue.Enqueue(new KeyValuePair("spawn1", () => spawnTo(1, null))); + queue.Enqueue(new KeyValuePair("spawn10", () => spawnTo(10, null))); + queue.Enqueue(new KeyValuePair("spawn100.idle", () => spawnTo(100, null))); + // The org lead's second load: real gunfire. Rust's AI only shoots non-NPC players, so the + // targets are 20 stand-ins (IsNpc false) in a row 12 m in front of the grid; the harness + // zeroes the damage they take, so none die and the bridge records no deaths. + queue.Enqueue(new KeyValuePair("fight100vStandIns", () => + { + _standInsInvulnerable = true; + for (int i = 1; i <= 20; i++) + { + BasePlayer d = SpawnDummy(Ground(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), i); + if (d != null) standIns.Add(d); + } + })); + // The org lead's first load: a fresh 100 set to fight NPCs (sensing NPCs, LOS checks, the + // harness attack stand-in) beside 100 stock scientists. Rust's design never fires at an NPC, + // so this measures NPCs in combat without gunfire. + queue.Enqueue(new KeyValuePair("fight100vScientists", () => + { + foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill(); + foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill(); + _standInsInvulnerable = false; + ours.Clear(); standIns.Clear(); + spawnTo(100, FightsScientists); + for (int i = 0; i < 100; i++) + { + ScientistNPC s = SpawnStock(foeGrid(i)); + if (s != null) stock.Add(s); + } + })); + queue.Enqueue(new KeyValuePair("after.clear", () => + { + foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill(); + foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill(); + ours.Clear(); stock.Clear(); + })); + + Log("cost", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}"); + next(); + } + + // ---- 6. the name on the death screen, and the bridge ---- + + private static readonly MethodInfo LifeStoryStartMethod = + typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + /// + /// A stand-in player: player.prefab with a made-up user id, so IsNpc is false + /// and the bridge reports it as it would a real player (PLAN.md §1.2 found that by + /// accident with HumanNPC). It never connects. Only the harness ever makes one. + /// + private BasePlayer SpawnDummy(Vector3 at, int index = 0) + { + BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer; + if (p == null) return null; + ulong id = DummyUserId + (ulong)index; + p.userID = id; + p.UserIDString = id.ToString(); + p.displayName = index == 0 ? "RnhDummy" : "RnhStandIn" + index; + p.enableSaving = false; + p.Spawn(); + p.EndSleeping(); + LifeStoryStartMethod?.Invoke(p, null); + _spawned.Add(p); + Equip(p); + return p; + } + + [ConsoleCommand("rnh.dummy")] + private void CmdDummy(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(250f, 707, out field)) { Log("dummy", "FAIL no field found"); return; } + _deaths.Clear(); _corpses.Clear(); + var rec = new Dictionary(); + + BasePlayer dummy = SpawnDummy(field); + if (dummy == null) { Log("dummy", "FAIL no dummy"); return; } + rec["dummy"] = $"isNpc={dummy.IsNpc} isBot={dummy.IsBot} sleeping={dummy.IsSleeping()} lifeStory={dummy.lifeStory != null} held={dummy.GetActiveItem()?.info.shortname}"; + Log("dummy", "spawned " + rec["dummy"]); + + // (a) The stand-in kills one of ours, with the kit's revolver as the weapon on the hit. + SpawnReport victim = SpawnOurs(Ground(field + new Vector3(6f, 0f, 0f)), "RnhWarden", null); + timer.Once(2f, () => + { + Item gunItem = dummy.inventory.containerBelt.GetSlot(0); + AttackEntity gun = gunItem?.GetHeldEntity() as AttackEntity; + var hit = new HitInfo(dummy, victim.Npc, Rust.DamageType.Bullet, 1000f) { Weapon = gun, WeaponPrefab = gun }; + victim.Npc.Hurt(hit); + rec["playerKillsOurs"] = $"weapon={(gun != null ? gun.ShortPrefabName : "none")} dead={victim.Npc == null || victim.Npc.IsDestroyed || victim.Npc.IsDead()}"; + Log("dummy", "(a) " + rec["playerKillsOurs"]); + }); + + // (b) One of ours kills the stand-in, fighting with its own kit. + timer.Once(6f, () => + { + SpawnReport killer = SpawnOurs(Ground(field + new Vector3(-14f, 0f, 0f)), "RnhWarden", b => b.HostileTargetsOnly = false); + timer.Once(1f, () => + { + if (killer.Npc?.Brain?.Senses?.Memory != null && dummy != null && !dummy.IsDestroyed) + killer.Npc.Brain.Senses.Memory.SetKnown(dummy, killer.Npc, null); + }); + int waited = 0; + timer.Repeat(2f, 45, () => + { + waited += 2; + if (rec.ContainsKey("deathScreen")) return; + if (dummy == null || dummy.IsDestroyed || dummy.IsDead()) + { + ProtoBuf.PlayerLifeStory story = dummy?.previousLifeStory ?? dummy?.lifeStory; + ProtoBuf.PlayerLifeStory.DeathInfo d = story?.deathInfo; + rec["deathScreen"] = d == null ? "no death info" : + $"attackerName='{d.attackerName}' attackerSteamID={d.attackerSteamID} inflictorName='{d.inflictorName}' hitBone='{d.hitBone}' distance={d.attackerDistance:0.0}"; + rec["ourKillerStillNamed"] = killer.Npc != null ? killer.Npc.displayName : "gone"; + rec["secondsToKill"] = waited; + rec["deaths"] = new List(_deaths); + rec["corpses"] = new List(_corpses); + Record("dummy." + Framework(), rec); + Log("dummy", "(b) " + rec["deathScreen"] + " after " + waited + " s; deaths: " + string.Join(" | ", _deaths.ToArray()) + "; corpses: " + string.Join(" | ", _corpses.ToArray())); + timer.Once(5f, () => + { + if (killer.Npc != null && !killer.Npc.IsDestroyed) killer.Npc.Kill(); + if (dummy != null && !dummy.IsDestroyed) dummy.Kill(); + foreach (PlayerCorpse c in BaseNetworkable.serverEntities.OfType().Where(c => c.playerSteamID == DummyUserId || c.playerName == "RnhWarden").ToArray()) + c.Kill(); + }); + } + else if (waited >= 90) + { + rec["deathScreen"] = $"FAIL the stand-in was not killed in 90 s (hp {dummy.health:0}, killer state {killer.Npc?.Brain?.CurrentState?.StateType})"; + Record("dummy." + Framework(), rec); + Log("dummy", (string)rec["deathScreen"]); + } + }); + }); + } + + // ==== stage 5 (PLAN.md §9, stage 5; D231, D253-D262) ==== + + /// + /// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players + /// (IsSteamId), so this one is in the Steam range, and far beyond any id Steam has issued. + /// + private const ulong PveUserId = 76570997960265728UL; + + /// 0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out). + private int _pveMode; + private int _pveAsks; + + /// Labelled entities: every hit to or from one is tallied by "attacker>victim". + private readonly Dictionary _labels = new Dictionary(); + private readonly Dictionary _pairs = new Dictionary(); + private readonly Dictionary _diedAt = new Dictionary(); + private readonly HashSet _invulnerable = new HashSet(); + private float _scenarioStart; + + private T Label(T e, string label) where T : BaseEntity + { + if (e == null) return null; + _labels[e] = label; + _spawned.Add(e); + return e; + } + + private void TallyPair(BaseCombatEntity victim, HitInfo info, string weapon) + { + string v = null, a = null; + if (victim != null) _labels.TryGetValue(victim, out v); + BaseEntity src = info.Initiator; + if (src != null) _labels.TryGetValue(src, out a); + if (a == null && info.Weapon != null) + { + BaseEntity parent = info.Weapon.GetParentEntity(); + if (parent != null) _labels.TryGetValue(parent, out a); + } + if (v == null && a == null) return; + + string key = (a ?? (src != null ? src.ShortPrefabName : "none")) + ">" + (v ?? victim.ShortPrefabName); + HitTally t; + if (!_pairs.TryGetValue(key, out t)) _pairs[key] = t = new HitTally(); + t.Hits++; + t.Damage += info.damageTypes.Total(); + int n; + t.Weapons.TryGetValue(weapon, out n); + t.Weapons[weapon] = n + 1; + + if (victim != null && _invulnerable.Contains(victim)) + info.damageTypes.ScaleAll(0f); + } + + private Dictionary Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary + { + ["hits"] = kv.Value.Hits, + ["damage"] = Math.Round(kv.Value.Damage, 1), + ["weapons"] = kv.Value.Weapons.ToDictionary(w => w.Key, w => (object)w.Value), + }); + + private sealed class Scenario + { + public string Name; + public float Seconds; + public Action Setup; + public Func Done; + public Func> Extra; + } + + /// + /// Runs scenarios one after another: set up, wait until done or out of time, record the hit + /// pairs, deaths and extras, then remove everything labelled and the corpses near the field. + /// + private void RunScenarios(string test, Vector3 field, List list, Dictionary header = null) + { + var results = new List>(); + int index = 0; + Action next = null; + next = () => + { + if (index >= list.Count) + { + var rec = header ?? new Dictionary(); + rec["framework"] = Framework(); + rec["scenarios"] = results; + Record(test + "." + Framework(), rec); + Log(test, "DONE"); + return; + } + Scenario sc = list[index++]; + _labels.Clear(); _pairs.Clear(); _diedAt.Clear(); _invulnerable.Clear(); + RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0; + _scenarioStart = Time.time; + string error = null; + try { sc.Setup(); } + catch (Exception e) { error = e.GetType().Name + ": " + e.Message; } + + float elapsed = 0f; + Timer poll = null; + poll = timer.Every(1f, () => + { + elapsed += 1f; + bool done = error != null; + if (!done) { try { done = sc.Done != null && sc.Done(); } catch { done = true; } } + if (!done && elapsed < sc.Seconds) return; + poll.Destroy(); + + var r = new Dictionary + { + ["scenario"] = sc.Name, + ["seconds"] = elapsed, + ["pairs"] = Pairs(), + ["diedAt"] = new Dictionary(_diedAt), + }; + if (error != null) r["error"] = error; + if (sc.Extra != null) + { + try { foreach (KeyValuePair kv in sc.Extra()) r[kv.Key] = kv.Value; } + catch (Exception e) { r["extraError"] = e.Message; } + } + results.Add(r); + Log(test, $"{sc.Name}: {JsonConvert.SerializeObject(r)}"); + + foreach (BaseEntity e in _labels.Keys.ToArray()) + if (e != null && !e.IsDestroyed) e.Kill(); + ClearLeftovers(field, 80f); + timer.Once(2f, next); + }); + }; + Log(test, $"field {V(field)}, {list.Count} scenarios"); + next(); + } + + /// Corpses and dropped backpacks near the field: a fight leaves both. + private static void ClearLeftovers(Vector3 field, float radius) + { + foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType() + .Where(e => (e is BaseCorpse || e is DroppedItemContainer || e is DroppedItem) && Vector3.Distance(e.transform.position, field) < radius) + .ToArray()) + e.Kill(); + } + + private RnhNpc Ours(Vector3 at, string label, string tag, bool shotFaction, params string[] hostile) + { + SpawnReport r = SpawnOurs(Ground(at), "Rnh" + label); + if (r.Npc == null) throw new Exception("our NPC did not spawn: " + r.Error); + Tag(r.Npc, tag); + r.Npc.RnhShotFaction = shotFaction; + if (hostile.Length > 0) r.Npc.RnhHostile = new HashSet(hostile); + return Label(r.Npc, label); + } + + private ScientistNPC Stock(Vector3 at, string label) => Label(SpawnStock(Ground(at)), label); + + private static string FindPrefab(string suffix) => + GameManifest.Current.entities.FirstOrDefault(p => p.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + + private BaseCombatEntity Animal(string suffix, Vector3 at, string label) + { + string path = FindPrefab(suffix); + if (path == null) throw new Exception("no prefab ends with " + suffix); + BaseCombatEntity a = GameManager.server.CreateEntity(path, Ground(at)) as BaseCombatEntity; + if (a == null) throw new Exception(path + " is not a combat entity"); + a.enableSaving = false; + a.Spawn(); + return Label(a, label); + } + + private static Dictionary Stats(RnhNpc o) => o == null || o.IsDestroyed + ? new Dictionary { ["gone"] = true } + : new Dictionary + { + ["health"] = Math.Round(o.health, 0), + ["target"] = o.RnhNpcTarget != null ? o.RnhNpcTarget.ShortPrefabName : "none", + ["targetHealth"] = o.RnhNpcTarget != null && !o.RnhNpcTarget.IsDestroyed + ? $"{o.RnhNpcTarget.health:0}/{o.RnhNpcTarget.MaxHealth():0} at {Vector3.Distance(o.transform.position, o.RnhNpcTarget.transform.position):0} m" + : "-", + ["shotTests"] = o.ShotTests, + ["shotsMarked"] = o.ShotsMarked, + ["attackTicks"] = o.AttackTicks, + ["attackTicksWithLos"] = o.AttackTicksLos, + ["state"] = o.Brain?.CurrentState?.StateType.ToString() ?? "none", + ["held"] = o.GetActiveItem()?.info.shortname ?? "none", + ["lastHurtBy"] = o.LastHurtBy, + }; + + private static bool Dead(BaseEntity e) => e == null || e.IsDestroyed || (e as BaseCombatEntity)?.IsDead() == true; + + // ---- 5.1 our sensing and combat state against an NPC target ---- + + [ConsoleCommand("rnh.npcfight")] + private void CmdNpcFight(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 f; + if (!FindField(250f, 505, out f)) { Log("npcfight", "FAIL no field found"); return; } + Vector3 z(float m) => f + new Vector3(0f, 0f, m); + float secs = arg.GetFloat(0, 60f); + + RnhNpc o = null, b = null, ally = null; + BaseCombatEntity foe = null; + Func> extra = () => new Dictionary + { + ["ours"] = Stats(o), + ["other"] = b != null ? Stats(b) : null, + ["ally"] = ally != null ? Stats(ally) : null, + ["foe"] = foe == null ? null : new Dictionary + { + ["type"] = foe.GetType().Name, ["dead"] = Dead(foe), ["health"] = foe.IsDestroyed ? 0 : Math.Round(foe.health, 0), + }, + ["engagedThinks"] = RnhBrain.Engaged, + ["senseRuns"] = RnhBrain.SenseRuns, + ["leftHorror"] = _labels.Keys.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror), + }; + Action reset = () => { o = b = ally = null; foe = null; }; + + var list = new List + { + // Rust's own filter, untouched: shots at a stock scientist, and what lands. + new Scenario { Name = "scientist.noFaction", Seconds = 30f, Extra = extra, + Setup = () => { reset(); o = Ours(f, "ours", "a", false, "scientists"); foe = Stock(z(15f), "stock"); } }, + new Scenario { Name = "scientist.horrorPerShot", Seconds = secs, Extra = extra, Done = () => Dead(foe), + Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); foe = Stock(z(15f), "stock"); } }, + new Scenario { Name = "ours.vs.ours", Seconds = secs, Extra = extra, Done = () => Dead(o) || Dead(b), + Setup = () => { reset(); o = Ours(f, "ours", "a", true, "ours:b"); b = Ours(z(15f), "other", "b", true, "ours:a"); } }, + new Scenario { Name = "animal.boar", Seconds = secs, Extra = extra, Done = () => Dead(foe), + Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/boar.prefab", z(15f), "boar"); } }, + new Scenario { Name = "animal.wolf2", Seconds = secs, Extra = extra, Done = () => Dead(foe) || Dead(o), + Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/wolf2.prefab", z(15f), "wolf"); } }, + // An ally of ours standing on the line of fire: the per-shot faction marks only the target. + new Scenario { Name = "crossfire.allyOnTheLine", Seconds = secs, Extra = extra, Done = () => Dead(foe), + Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); ally = Ours(z(8f), "ally", "a", false); foe = Stock(z(16f), "stock"); } }, + }; + string only = arg.GetString(1, ""); + RunScenarios("npcfight", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList()); + } + + // ---- 5.2 what our sensing costs ---- + + [ConsoleCommand("rnh.sense")] + private void CmdSense(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 field; + if (!FindField(400f, 606, out field)) { Log("sense", "FAIL no field found"); return; } + float window = arg.GetFloat(0, 30f); + var phases = new List>(); + var all = new List(); + Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)); + Func foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 30f + (i / 10) * 3f)); + Action killAll = () => { foreach (BaseEntity e in all) if (e != null && !e.IsDestroyed) e.Kill(); all.Clear(); _invulnerable.Clear(); }; + + Func spawn = (i, tag, foeSide, hostile) => + { + SpawnReport r = SpawnOurs(foeSide ? foeGrid(i) : grid(i), "RnhSense" + tag + i); + if (r.Npc == null) return null; + Tag(r.Npc, tag); + r.Npc.RnhShotFaction = true; + r.Npc.RnhHostile = new HashSet(hostile); + all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc); + return r.Npc; + }; + + var queue = new Queue>(); + queue.Enqueue(new KeyValuePair("baseline", () => { })); + queue.Enqueue(new KeyValuePair("ours100.hostileToScientists.noneNear", () => + { + for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" }); + })); + queue.Enqueue(new KeyValuePair("ours100.v.scientists100", () => + { + for (int i = 0; i < 100; i++) + { + ScientistNPC s = SpawnStock(foeGrid(i)); + if (s == null) continue; + all.Add(s); _labels[s] = "stock"; _invulnerable.Add(s); + } + })); + queue.Enqueue(new KeyValuePair("ours50.v.ours50", () => + { + killAll(); + for (int i = 0; i < 50; i++) spawn(i, "a", false, new[] { "ours:b" }); + for (int i = 0; i < 50; i++) spawn(i, "b", true, new[] { "ours:a" }); + })); + queue.Enqueue(new KeyValuePair("after.clear", killAll)); + + Action next = null; + next = () => + { + if (queue.Count == 0) + { + Record("sense." + Framework(), new Dictionary + { + ["framework"] = Framework(), ["map"] = $"{World.Size}/{World.Seed}", ["players"] = BasePlayer.activePlayerList.Count, + ["aiBudgetMs"] = AIThinkManager.framebudgetms, ["windowS"] = window, ["senseEveryS"] = RnhBrain.OwnSenseEvery, + ["senseRangeM"] = RnhBrain.OwnSenseRange, ["phases"] = phases, + }); + _labels.Clear(); + Log("sense", "DONE"); + return; + } + KeyValuePair step = queue.Dequeue(); + Stopwatch sw = Stopwatch.StartNew(); + step.Value(); + double setupMs = sw.Elapsed.TotalMilliseconds; + // Five seconds to settle (spawn, first senses), then measure. + timer.Once(5f, () => + { + RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0; + RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0; + _pairs.Clear(); + foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; } + _meter.Begin(); + timer.Once(window, () => + { + Dictionary f = Summarise(_meter.End()); + float isum = 0f; int icount = 0; + foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; } + f["phase"] = step.Key; + f["setupMs"] = Math.Round(setupMs, 1); + f["ours"] = all.Count(e => e is RnhNpc && !Dead(e)); + f["stock"] = all.Count(e => e is ScientistNPC && !(e is RnhNpc) && !Dead(e)); + f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0; + f["ownMsPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.OwnMs / RnhBrain.Thinks, 3) : 0; + f["senseRuns"] = RnhBrain.SenseRuns; + f["msPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round(RnhBrain.SenseMs / RnhBrain.SenseRuns, 4) : 0; + f["scannedPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round((double)RnhBrain.Scanned / RnhBrain.SenseRuns, 1) : 0; + f["losTests"] = RnhBrain.LosTests; + f["engagedThinks"] = RnhBrain.Engaged; + f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0; + f["aiQueue"] = AIThinkManager._processQueue.Count; + f["leftHorror"] = all.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); + f["pairs"] = Pairs(); + phases.Add(f); + Log("sense", JsonConvert.SerializeObject(f)); + next(); + }); + }); + }; + Log("sense", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}"); + next(); + } + + // ---- 5.3 turrets and traps (D259) ---- + + private BaseCombatEntity Turret(string kind, Vector3 at, Quaternion rot, List notes) + { + string suffix = kind == "auto" ? "/autoturret_deployed.prefab" : kind == "flame" ? "/flameturret.deployed.prefab" : "/guntrap.deployed.prefab"; + string path = FindPrefab(suffix); + if (path == null) throw new Exception("no prefab ends with " + suffix); + BaseCombatEntity t = GameManager.server.CreateEntity(path, at, rot) as BaseCombatEntity; + if (t == null) throw new Exception(path + " is not a combat entity"); + t.enableSaving = false; + t.Spawn(); + Label(t, "turret." + kind); + + AutoTurret auto = t as AutoTurret; + if (auto != null) + { + ItemManager.CreateByName("rifle.ak").MoveToContainer(auto.inventory, 0); + ItemManager.CreateByName("ammo.rifle", 256).MoveToContainer(auto.inventory, 1); + auto.UpdateAttachedWeapon(); + auto.UpdateTotalAmmo(); + auto.UpdateFromInput(10, 0); + timer.Once(4f, () => + { + if (auto.IsDestroyed) return; + notes.Add($"auto: powered={auto.IsPowered()} online={auto.IsOnline()} weapon={auto.GetAttachedWeapon()?.ShortPrefabName ?? "none"}"); + if (!auto.IsOnline()) { auto.SetIsOnline(true); notes.Add("auto: forced online"); } + }); + } + else if (t is FlameTurret flame) + ItemManager.CreateByName("lowgradefuel", 100).MoveToContainer(flame.inventory); + else if (t is GunTrap trap) + ItemManager.CreateByName("ammo.handmade.shell", 20).MoveToContainer(trap.inventory); + return t; + } + + [ConsoleCommand("rnh.turrets")] + private void CmdTurrets(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 f; + if (!FindField(250f, 808, out f)) { Log("turrets", "FAIL no field found"); return; } + float secs = arg.GetFloat(0, 30f); + var list = new List(); + + foreach (string kind in new[] { "auto", "flame", "trap" }) + { + foreach (string victim in new[] { "standIn", "stock", "ours" }) + { + string k = kind, v = victim; + BaseCombatEntity turret = null; + BaseEntity target = null; + var notes = new List(); + list.Add(new Scenario + { + Name = k + "." + v, + Seconds = secs, + Done = () => Dead(target) || Dead(turret), + Setup = () => + { + notes.Clear(); + float d = k == "auto" ? 10f : 3f; + Vector3 spot = Ground(f + new Vector3(0f, 0f, d)); + // Spawned outside the turret's trigger and moved in: a flame turret's trigger only + // notices what ENTERS it (a target spawned inside, standing still, was never burned). + Vector3 outside = Ground(f + new Vector3(0f, 0f, d + 12f)); + turret = Turret(k, Ground(f), Quaternion.LookRotation(new Vector3(0f, 0f, 1f)), notes); + if (v == "stock") + target = Stock(outside, "stock"); + else if (v == "standIn") + { + // The control: a stand-in player shows the turret is armed. It takes no damage. + target = Label(SpawnDummy(outside, 40), "standIn"); + _invulnerable.Add(target); + } + else + { + RnhNpc o = Ours(outside, "ours", "a", false); + o.RnhShootsBack = true; + target = o; + } + BaseEntity moving = target; + timer.Once(2f, () => + { + if (Dead(moving)) return; + BasePlayer p = moving as BasePlayer; + if (p is HumanNPC h && h.Brain?.Navigator?.Agent != null) + h.Brain.Navigator.Agent.Warp(spot); + p?.Teleport(spot); + notes.Add($"moved in: at {V(moving.transform.position)}, spot {V(spot)}"); + }); + }, + Extra = () => new Dictionary + { + ["turret"] = turret == null ? null : new Dictionary + { + ["type"] = turret.GetType().Name, ["dead"] = Dead(turret), + ["health"] = turret.IsDestroyed ? 0 : Math.Round(turret.health, 0), + }, + ["target"] = target is RnhNpc o ? Stats(o) : new Dictionary + { + ["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0), + }, + ["notes"] = new List(notes), + }, + }); + } + } + string only = arg.GetString(1, ""); + RunScenarios("turrets", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList()); + } + + // ---- 5.4 the kit items Rust's AI uses on its own (D260) ---- + + private static Dictionary Inventory(BasePlayer p) + { + var counts = new Dictionary(); + if (p == null || p.IsDestroyed || p.inventory == null) return counts; + foreach (ItemContainer c in new[] { p.inventory.containerBelt, p.inventory.containerMain }) + { + if (c == null) continue; + foreach (Item i in c.itemList) + { + object n; + counts[i.info.shortname] = (counts.TryGetValue(i.info.shortname, out n) ? (int)n : 0) + i.amount; + } + } + return counts; + } + + private static void Give(BasePlayer p, string shortname, int amount, ItemContainer into, int slot = -1) + { + Item item = ItemManager.CreateByName(shortname, amount); + if (item == null) return; + if (!item.MoveToContainer(into, slot)) item.Remove(); + } + + [ConsoleCommand("rnh.items")] + private void CmdItems(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 f; + if (!FindField(250f, 909, out f)) { Log("items", "FAIL no field found"); return; } + float secs = arg.GetFloat(0, 60f); + Vector3 z(float m) => f + new Vector3(0f, 0f, m); + + RnhNpc o = null; + BasePlayer standIn = null; + Dictionary before = null; + float lastHealth = 0f; + int heals = 0, scripted = 0; + float minHealth = float.MaxValue; + Timer pulse = null; + + Action addStandIn = (dist, index) => + { + standIn = Label(SpawnDummy(Ground(z(dist)), index), "standIn"); + _invulnerable.Add(standIn); + timer.Once(1f, () => + { + if (o?.Brain?.Senses?.Memory != null && standIn != null && !standIn.IsDestroyed) + o.Brain.Senses.Memory.SetKnown(standIn, o, null); + }); + }; + Action> spawn = kit => + { + o = Ours(f, "ours", "a", false); + kit(o); + o.EquipWeapon(); + before = Inventory(o); + lastHealth = o.health; minHealth = o.health; heals = 0; scripted = 0; + pulse?.Destroy(); + pulse = timer.Every(1f, () => + { + if (Dead(o)) return; + if (o.health > lastHealth + 5f) heals++; + lastHealth = o.health; + minHealth = Mathf.Min(minHealth, o.health); + }); + }; + Func> extra = () => + { + pulse?.Destroy(); + return new Dictionary + { + ["ours"] = Stats(o), + ["before"] = before, + ["after"] = Inventory(o), + ["healthRises"] = heals, + ["minHealth"] = Math.Round(minHealth, 0), + ["scriptedHurts"] = scripted, + ["canUseHealingItems"] = o?.Brain != null && o.Brain.CanUseHealingItems, + }; + }; + + var list = new List + { + // The kit's revolver plus every extra D260 names; the stand-in "shoots" ours every 4 s. + new Scenario { Name = "extras.healingOn", Seconds = secs, Extra = extra, + Setup = () => + { + spawn(n => + { + Give(n, "syringe.medical", 3, n.inventory.containerBelt, 1); + Give(n, "grenade.f1", 2, n.inventory.containerBelt, 2); + Give(n, "machete", 1, n.inventory.containerBelt, 3); + n.Brain.CanUseHealingItems = true; + }); + addStandIn(15f, 31); + timer.Repeat(4f, (int)(secs / 4f), () => + { + if (Dead(o) || Dead(standIn) || o.health < 45f) return; + AttackEntity gun = standIn.GetActiveItem()?.GetHeldEntity() as AttackEntity; + o.Hurt(new HitInfo(standIn, o, Rust.DamageType.Bullet, 20f) { Weapon = gun, WeaponPrefab = gun }); + scripted++; + }); + } }, + new Scenario { Name = "main.rocketLauncher", Seconds = Mathf.Min(secs, 40f), Extra = extra, + Setup = () => + { + spawn(n => + { + n.inventory.Strip(); + Give(n, "rocket.launcher", 1, n.inventory.containerBelt, 0); + Give(n, "ammo.rocket.basic", 6, n.inventory.containerMain); + }); + addStandIn(25f, 32); + } }, + new Scenario { Name = "main.flamethrower", Seconds = Mathf.Min(secs, 40f), Extra = extra, + Setup = () => + { + spawn(n => + { + n.inventory.Strip(); + Give(n, "flamethrower", 1, n.inventory.containerBelt, 0); + Give(n, "lowgradefuel", 300, n.inventory.containerMain); + }); + addStandIn(6f, 33); + } }, + }; + RunScenarios("items", f, list); + } + + // ---- 5.5 TruePVE and NextGenPVE (D261) ---- + + [ConsoleCommand("rnh.pve")] + private void CmdPve(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + Vector3 f; + if (!FindField(250f, 1001, out f)) { Log("pve", "FAIL no field found"); return; } + _labels.Clear(); _pairs.Clear(); _invulnerable.Clear(); + + Plugin pve = plugins.Find("TruePVE") ?? plugins.Find("NextGenPVE"); + string which = pve == null ? "none" : $"{pve.Name} {pve.Version}"; + + RnhNpc ours = Ours(f, "ours", "a", false); + ScientistNPC stock = Stock(f + new Vector3(4f, 0f, 0f), "stock"); + Func pvePlayer = (id, at, label) => + { + BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, Ground(at)) as BasePlayer; + if (p == null) return null; + p.userID = id; + p.UserIDString = id.ToString(); + p.displayName = "RnhPve" + label; + p.enableSaving = false; + p.Spawn(); + p.EndSleeping(); + LifeStoryStartMethod?.Invoke(p, null); + Label(p, label); + Equip(p); + return p; + }; + BasePlayer player = pvePlayer(PveUserId, f + new Vector3(-4f, 0f, 0f), "player"); + BasePlayer player2 = pvePlayer(PveUserId + 1UL, f + new Vector3(-4f, 0f, 4f), "player2"); + if (player == null || player2 == null) { Log("pve", "FAIL no player"); return; } + + timer.Once(3f, () => + { + var pairs = new List> + { + // The control: player against player is what a PVE plugin exists to stop. + new KeyValuePair(player2, player), + new KeyValuePair(player, ours), + new KeyValuePair(ours, player), + new KeyValuePair(player, stock), + new KeyValuePair(stock, player), + new KeyValuePair(ours, stock), + new KeyValuePair(stock, ours), + }; + var rows = new List>(); + foreach (int mode in new[] { 0, 1, 2 }) + { + _pveMode = mode; + foreach (KeyValuePair pr in pairs) + { + BaseCombatEntity a = pr.Key, v = pr.Value; + if (Dead(a) || Dead(v)) { rows.Add(new Dictionary { ["pair"] = _labels[a] + ">" + _labels[v], ["error"] = "dead" }); continue; } + v.health = v.MaxHealth(); + AttackEntity gun = (a as BasePlayer)?.GetActiveItem()?.GetHeldEntity() as AttackEntity; + int asks0 = _pveAsks; + float h0 = v.health; + v.Hurt(new HitInfo(a, v, Rust.DamageType.Bullet, 10f) { Weapon = gun, WeaponPrefab = gun }); + rows.Add(new Dictionary + { + ["mode"] = mode == 0 ? "observe" : mode == 1 ? "allow" : "deny", + ["pair"] = _labels[a] + ">" + _labels[v], + ["damage"] = Math.Round(h0 - v.health, 2), + ["asked"] = _pveAsks - asks0, + }); + } + } + _pveMode = 0; + var rec = new Dictionary { ["framework"] = Framework(), ["plugin"] = which, ["rows"] = rows }; + Record("pve." + (pve == null ? "none" : pve.Name) + "." + Framework(), rec); + Log("pve", JsonConvert.SerializeObject(rec)); + foreach (BaseEntity e in _labels.Keys.ToArray()) if (e != null && !e.IsDestroyed) e.Kill(); + _labels.Clear(); + ClearLeftovers(f, 40f); + }); + } + + // ---- housekeeping ---- + + [ConsoleCommand("rnh.clear")] + private void CmdClear(ConsoleSystem.Arg arg) + { + if (!arg.IsAdmin) return; + int n = 0; + foreach (BaseEntity e in _spawned.ToArray()) + if (e != null && !e.IsDestroyed) { e.Kill(); n++; } + _spawned.Clear(); + int loose = 0; + foreach (RnhNpc o in BaseNetworkable.serverEntities.OfType().ToArray()) { o.Kill(); loose++; } + Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs"); + } + } +} diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs new file mode 100644 index 0000000..a16a4a2 --- /dev/null +++ b/tools/RunicNpcTest.cs @@ -0,0 +1,3395 @@ +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 Oxide.Game.Rust.Cui; +using UnityEngine; +using UnityEngine.AI; + +namespace Oxide.Plugins +{ + /// + /// RunicNPC's test harness (docs/runicnpc/PLAN.md §9, stages 2 and 3). NEVER SHIPPED: it lives + /// in tools/ and is loaded on a rig by hand. It drives RunicNPC through its API, its hooks and its + /// console commands (the chat command through a stand-in player), and prints one line per + /// assertion: + /// + /// [rnt] <group>.<check>: PASS|FAIL <detail> + /// + /// Every line is also kept in data/RunicNpcTest.json, read back over the panel. + /// + /// + /// rnt.run <group> runs one group: api, hooks, move, + /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, + /// s5 (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets, + /// PVE and the kit's extras), or all for each in turn. + /// rnt.run place leaves two placements behind for the restart check, and + /// rnt.after checks, after a reload or restart, that every placement is back and nothing + /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and + /// rnt.clear removes everything the harness made. + /// + /// + /// + /// It needs the hidden Kits kit rnhrevolver (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 data/RunicNPC/ first and restore it afterwards. + /// + /// + /// + /// Targets and wake-ups are stand-ins, stage 1's fake players: player.prefab with + /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. + /// + /// + [Info("RunicNpcTest", "Runic Gateway", "0.7.3")] + [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] + internal class RunicNpcTest : RustPlugin + { + [PluginReference] private Plugin RunicNPC; + [PluginReference] private Plugin Kits; + + 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 _lines = new List(); + private readonly HashSet _standIns = new HashSet(); + private readonly List _built = new List(); + private Coroutine _running; + private int _pass, _fail; + + // What RunicNPC's hooks told us. + private readonly List _spawned = new List(); + private readonly List _died = new List(); + private readonly List _health = new List(); + private readonly List _despawned = new List(); + private readonly List _changed = new List(); + private readonly Dictionary _lastContributors = new Dictionary(); + 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(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("RunicNpc_Spawn", at, profile, owner, overrides); + + private List> List(string owner) => + Api>>("RunicNpc_List", owner) ?? new List>(); + + private Dictionary 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)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; + + /// The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.) + private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType()); + + /// A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field. + /// + /// An open field on the navmesh. With , the points every line / 2 m out to + /// ± along x, and 25 m past each, must be on it too: stage 5's five fights stand + /// that far apart. On a 6000 map the first field had room; on the staging drill's maps it is rare (D319). + /// + private static bool FindField(float clear, int seed, out Vector3 at, float line = 0f) + { + var rng = new System.Random(seed); + float half = World.Size / 2f - 300f; + // The line is rare on a small map: 5 samples in 20000 have it on the drill's 3500 map (D319). + for (int i = 0; i < (line > 0f ? 20000 : 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); + } + + for (int k = -2; k <= 2 && open && line > 0f; k++) + { + Vector3 q = hit.position + new Vector3(k * line / 2f, 0f, 0f); + Vector3 r = q + new Vector3(0f, 0f, 25f); + q.y = TerrainMeta.HeightMap.GetHeight(q); + r.y = TerrainMeta.HeightMap.GetHeight(r); + NavMeshHit h2; + open = SampleNavmesh(q, 3f, out h2) && SampleNavmesh(r, 3f, out h2); + } + + if (!open) continue; + at = hit.position; + return true; + } + + at = Vector3.zero; + return false; + } + + /// A monument with an AI zone, where Rust's own roam has move points to follow. + 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 contributors) + { + _died.Add($"{profile}/{owner}"); + _lastContributors.Clear(); + foreach (KeyValuePair 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 OnRunicNpcPlacementChanged(string id, string change, string previous) => + _changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}"); + + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) + { + // Stage 6's spike: the scientist a boss fights takes no damage, and every hit is tallied by weapon. + if (_spikeTarget != null && victim == _spikeTarget && info != null) + { + string w = info.Weapon?.GetItem()?.info.shortname ?? info.WeaponPrefab?.ShortPrefabName ?? "?"; + int n; + _spikeHits.TryGetValue(w, out n); + _spikeHits[w] = n + 1; + info.damageTypes.ScaleAll(0f); + return; + } + + 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 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"); + Stage5Profiles(all); + Stage6Profiles(all); + Stage7Profiles(all); + return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "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("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(); + } + + /// Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it. + [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}"); + } + + /// A probe: two profiles 15 m apart, described every two seconds for 30 s. + [ConsoleCommand("rnt.duel")] + private void CmdDuel(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red")); + BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue")); + int n = 0; + timer.Repeat(2f, 15, () => + { + n++; + foreach (BasePlayer p in new[] { x, y }) + { + Dictionary d = p == null || p.IsDestroyed ? null : Describe(p); + Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}"); + } + if (n == 15) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: duel started"); + } + + /// A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s. + [ConsoleCommand("rnt.kitprobe")] + private void CmdKitProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer flamer = Spawn(at, "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63); + int n = 0; + timer.Repeat(1f, 20, () => + { + n++; + ScientistNPC s = flamer as ScientistNPC; + Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem(); + FlameThrower tank = held?.GetHeldEntity() as FlameThrower; + if (n == 3 && tank != null) tank.ammo = 0; + var mem = s?.Brain?.Senses?.Memory; + Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: kit probe started"); + } + + /// + /// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a + /// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time. + /// + [ConsoleCommand("rnt.stockprobe")] + private void CmdStockProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + StandIn(f + new Vector3(0, 0, 60), 50); + ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60))); + BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71); + BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry"); + BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null; + List world = BaseNetworkable.serverEntities.OfType().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList(); + Dictionary from = world.ToDictionary(s => s, s => s.transform.position); + _hitsOnStandIns = 0; + int n = 0; + timer.Repeat(2f, 10, () => + { + n++; + string Line(ScientistNPC s, BasePlayer t) + { + if (Gone(s)) return "gone"; + var senses = s.Brain?.Senses; + return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}"; + } + Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}"); + if (n == 10) + { + var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList(); + Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + } + }); + arg.ReplyWith("rnt: stock probe started"); + } + + /// A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s. + [ConsoleCommand("rnt.monprobe")] + private void CmdMonProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 at; + string name; + if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; } + StandIn(at + new Vector3(0, 0, 100), 2); + BasePlayer mon = Spawn(at, "t_mon"); + AIInformationZone zone = AIInformationZone.GetForPoint(at, false); + List locals = BaseNetworkable.serverEntities.OfType() + .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList(); + Dictionary from = locals.ToDictionary(s => s, s => s.transform.position); + Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero; + Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}"); + int n = 0; + timer.Repeat(4f, 10, () => + { + n++; + ScientistNPC s = mon as ScientistNPC; + var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList(); + Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}"); + if (n == 10) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: monument probe started"); + } + + /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks. + [ConsoleCommand("rnt.list")] + private void CmdList(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string owner = arg.GetString(0, null); + var rows = List(owner); + arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)"); + } + + /// A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s. + [ConsoleCommand("rnt.rocketprobe")] + private void CmdRocketProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer r = Spawn(at, "t_kit"); + Give(r, "rocket.launcher", 1); + Give(r, "ammo.rocket.basic", 2); + ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0)); + int n = 0; + timer.Repeat(2f, 20, () => + { + n++; + Dictionary d = Gone(r) ? null : Describe(r); + string line = "-"; + if (!Gone(r) && !Gone(target)) + { + RaycastHit hh; + line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore) + ? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}" + : "clear"; + line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}"; + } + Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: rocket probe started"); + } + + /// A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does. + [ConsoleCommand("rnt.escortprobe")] + private void CmdEscortProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 c = OnMesh(f); + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + Api("RunicNpc_Escort", Id(escort), charge); + int n = 0; + timer.Repeat(2f, 30, () => + { + n++; + if (n == 3) Teleport(charge, c + new Vector3(0, 0, 35)); + if (n == 12 && !Gone(charge)) charge.Kill(); + Dictionary d = Gone(escort) ? null : Describe(escort); + Puts($"[esc {n}] state={d?["state"]} rest={d?["rest"]} escort={d?["escort"] ?? "null"} target={TargetOf(d)} engaged={d?["engaged"]} home={(d == null ? -1f : Flat(escort.transform.position, V(d["home"]))):0.0} ended={_escortEnded.Count}"); + if (n == 30) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: escort probe started"); + } + + [ConsoleCommand("rnt.clear")] + private void CmdClear(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + ClearWorld(); + Api("RunicNpc_DespawnOwner", Owner); + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" }) + Api("RunicNpc_RemovePlacement", id); + Api("RunicNpc_RemoveRoute", "t_loop"); + Api("RunicNpc_RemoveRoute", "t_rec"); + arg.ReplyWith("rnt: cleared"); + } + + // ---- cost: stage 9's performance check (D306) ---- + + /// + /// Stage 9's performance check (D306): what 100 of RunicNPC's own NPCs add to the median + /// server frame, idle and fighting, over this session's empty baseline. Stage 1 measured its + /// harness's stand-in NPC; this measures the NPC RunicNPC ships, brain, fight layer and all. + /// Same shape as stage 1's rnh.cost: a baseline, 100 awake but idle (a stand-in 85 m + /// away keeps them from sleeping and is beyond their senses), then 100 shooting 20 + /// invulnerable stand-ins 12 m in front of the grid. It replaces RunicNPC's profiles with + /// one, t_cost: back up data/RunicNPC/ first, and stop any site that pushes + /// profiles. rnt.cost [seconds=60] [idle [sentry|asleep]]: idle skips the fight, + /// sentry makes the 100 stand still, and asleep leaves out the keeper, so nobody is + /// within their sleep distance. Each run ends with a second empty baseline, and the bar is + /// measured against the lower of the two. + /// + [ConsoleCommand("rnt.cost")] + private void CmdCost(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Cost(arg.GetFloat(0, 60f), arg.GetString(1) == "idle", arg.GetString(2) == "sentry", arg.GetString(2) == "asleep")); + arg.ReplyWith("rnt: measuring cost"); + } + + /// The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar. + private const double IdleBudgetMs = 1.6, FightBudgetMs = 5.2; + + private IEnumerator Cost(float window, bool idleOnly = false, bool still = false, bool asleep = false) + { + _pass = _fail = 0; + Note($"cost on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, " + + $"players {BasePlayer.activePlayerList.Count}, {window:0} s a phase, ai budget {AIThinkManager.framebudgetms} ms"); + + Vector3 field; + if (!FindField(400f, 606, out field)) + { + Check("cost.field", false, "no open field on the navmesh"); + _running = null; + yield break; + } + + // `rnt.cost idle sentry`: the same NPCs standing still, to tell movement from thinking. + JObject prof = still ? Prof(role: "sentry", name: "TCost") : Prof(radius: 10f, name: "TCost"); + prof["ranges"] = JObject.FromObject(new { sense = 50f, loseTarget = 60f, chase = 40f, attack = 40f }); + Dictionary refused = Api>("RunicNpc_SetProfiles", new JObject { ["t_cost"] = prof }); + if (refused == null || refused.Count > 0) + { + Check("cost.profile", false, refused == null ? "no answer" : string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); + _running = null; + yield break; + } + + var meterHost = new GameObject("RunicNpcTest.FrameMeter"); + FrameMeter meter = meterHost.AddComponent(); + var medians = new Dictionary(); + var npcs = new List(); + + IEnumerator m = MeasurePhase(meter, window, "baseline", medians); + while (m.MoveNext()) yield return m.Current; + + // `rnt.cost idle asleep`: no keeper, so with nobody within 160 m they walk home and sleep (D235). + if (!asleep) + StandIn(OnMesh(field + new Vector3(13f, 0f, 110f)), 90); + for (int i = 0; i < 100; i++) + { + BasePlayer npc = Spawn(OnMesh(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)), "t_cost"); + if (npc != null) npcs.Add(npc); + if (i % 10 == 9) yield return null; + } + + Check("cost.spawned", npcs.Count == 100, $"{npcs.Count} of 100"); + yield return new WaitForSeconds(10f); + m = MeasurePhase(meter, window, "idle100", medians); + while (m.MoveNext()) yield return m.Current; + + // `rnt.cost idle` stops here: a quicker run for finding what idle NPCs cost. + if (!idleOnly) + { + _hitsOnStandIns = 0; + for (int i = 1; i <= 20; i++) + StandIn(OnMesh(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), 90 + i); + yield return new WaitForSeconds(10f); + int hitsBefore = _hitsOnStandIns; + m = MeasurePhase(meter, window, "fight100", medians); + while (m.MoveNext()) yield return m.Current; + Note($"cost.fight100 hits on stand-ins while measured: {_hitsOnStandIns - hitsBefore}"); + Check("cost.fighting", _hitsOnStandIns - hitsBefore > 0, $"{_hitsOnStandIns - hitsBefore} hits"); + } + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + + // A second empty window after everything is gone: one baseline at the start can carry a hitch + // (the first run on the Oxide rig did: a 2.4 s frame, and idle read below it). The bar is + // measured against the worse — the lower — of the two, so noise can only make it stricter. + yield return new WaitForSeconds(10f); + m = MeasurePhase(meter, window, "baseline.after", medians); + while (m.MoveNext()) yield return m.Current; + UnityEngine.Object.Destroy(meterHost); + + double baseline = Math.Min(medians["baseline"], medians["baseline.after"]); + double idle = medians["idle100"] - baseline; + double fight = idleOnly ? double.NaN : medians["fight100"] - baseline; + Note($"cost baselines {medians["baseline"]:0.00} and {medians["baseline.after"]:0.00} ms; the lower is the bar's"); + Check("cost.idle", idle <= IdleBudgetMs, $"{idle:+0.00;-0.00} ms median over the baseline (bar {IdleBudgetMs} ms)"); + if (!idleOnly) Check("cost.fight", fight <= FightBudgetMs, $"{fight:+0.00;-0.00} ms median over the baseline (bar {FightBudgetMs} ms)"); + Note($"done cost: {_pass} pass, {_fail} fail"); + _running = null; + } + + private IEnumerator MeasurePhase(FrameMeter meter, float window, string phase, Dictionary medians) + { + int queue0 = AIThinkManager._processQueue.Count; + meter.Begin(); + yield return new WaitForSeconds(window); + List ms = meter.End(); + if (ms.Count == 0) + { + Check("cost." + phase, false, "no frames"); + medians[phase] = double.NaN; + yield break; + } + + ms.Sort(); + Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)]; + double mean = ms.Average(); + medians[phase] = pct(0.5); + Note($"cost.{phase} frames={ms.Count} p50={pct(0.5):0.00} ms fps={1000.0 / mean:0.0} p95={pct(0.95):0.00} p99={pct(0.99):0.00} " + + $"max={ms[ms.Count - 1]:0.00} aiQueue={queue0}->{AIThinkManager._processQueue.Count}"); + } + + public class FrameMeter : MonoBehaviour + { + private readonly List _frames = new List(20000); + private bool _recording; + + public void Begin() + { + _frames.Clear(); + _recording = true; + } + + public List End() + { + _recording = false; + return new List(_frames); + } + + private void Update() + { + if (_recording) + _frames.Add(Time.unscaledDeltaTime * 1000f); + } + } + + 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 refused = SetTestProfiles(); + Check("setup.profiles", refused.Count == 3 && refused.ContainsKey("t_bad") && refused.ContainsKey("t_badfaction") && refused.ContainsKey("t_badturrets"), "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", "cmd", "api3", "s5", "s6", "s7", "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 == "cmd" ? Cmd(field) + : g == "api3" ? Api3(field) + : g == "floor" ? Floor(field) + : g == "place" ? Place(field) + : g == "s5" ? Stage5() + : g == "s6" ? Stage6() + : g == "s7" ? Stage7() + : null; + if (inner == null) + { + Check("setup.group", false, "no group " + g); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + 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("RunicNpc_ApiVersion") == 6, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + JObject profiles = Api("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("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("RunicNpc_IsRunicNpc", npc), ""); + Check("api.profileOf", Api("RunicNpc_ProfileOf", npc) == "t_roam", Api("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 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("RunicNpc_Despawn", ov.net.ID.Value) == 1, ""); + Check("api.despawnOwner", Api("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("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; + List locals = new List(); + var localsFrom = new Dictionary(); + if (FindMonument(out monument, out monumentName)) + { + StandIn(monument + new Vector3(0, 0, 100), 2); + mon = Spawn(monument, "t_mon"); + // The monument's own scientists: a monument roamer is meant to roam as they do. + locals = BaseNetworkable.serverEntities.OfType() + .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList(); + foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position; + } + + 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("RunicNpc_Routes")["t_loop"]).ToObject(); + var reached = new bool[loop.points.Count]; + var order = new List(); + + 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) + { + // Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no + // real player near (stand-ins do not seem to count): then ours standing too is the same behaviour. + int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f); + Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0), + $"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}"); + } + else + Check("move.monument.roams", false, "no monument with an AI zone"); + + Api("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 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) + { + // On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's + // first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times. + Vector3 sentryAt, standInAt; + if (!ClearLine(field, 12f, out sentryAt, out standInAt)) + { + sentryAt = field; + standInAt = field + new Vector3(12, 0, 0); + } + + BasePlayer sentry = Spawn(sentryAt, "t_sentry"); + yield return new WaitForSeconds(1f); + Vector3 spot = sentry.transform.position; + _hitsOnStandIns = 0; + StandIn(standInAt, 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 d = Describe(sentry); + Note("sentry state: " + (d != null ? d["state"] : "?")); + Api("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("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("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); + Check("place.set.group", Api("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); + yield return new WaitForSeconds(4f); + + List> each = List("placement:t_each"); + List> 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(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> 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 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 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("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("RunicNpc_RemovePlacement", "t_waitroute"); + + Check("place.remove", Api("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, ""); + Check("place.refuse.badRespawnMode", Api("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, ""); + + // Leave two for rnt.after, across a reload or a restart. + Api("RunicNpc_RemovePlacement", "t_each"); + Api("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each")); + Api("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each")); + Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after"); + } + + // ---- cmd: the stage 3 commands, through the server console and the chat path ---- + + private static string F(float f) => f.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); + + private static string At(Vector3 v) => $"at={F(v.x)},{F(v.y)},{F(v.z)}"; + + /// Runs a RunicNPC console command as the server console and returns its answer. + private string Rnpc(string command) + { + string reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command) ?? ""; + Note($"> {command} => {reply.Replace(Environment.NewLine, " | ")}"); + return reply; + } + + private JObject PlacementData(string id) + { + Dictionary row = Row(id); + return row != null ? (JObject)row["placement"] : null; + } + + private IEnumerator Cmd(Vector3 field) + { + Vector3 a = OnMesh(field + new Vector3(40, 0, -40)); + Vector3 b = OnMesh(field + new Vector3(40, 0, -10)); + Vector3 air = a + Vector3.up * 40f; + + // A stand-in 60-130 m from every spot keeps the NPCs awake (D235) without being sensed. + StandIn(OnMesh(field + new Vector3(60, 0, 90)), 21); + + Check("cmd.help", Rnpc("rnpc.help").Contains("rnpc.path record"), ""); + Check("cmd.status", Rnpc("rnpc.status").Contains("placements="), ""); + + // D242 options and D241 names. + string r = Rnpc($"rnpc.place t_roam {At(a)} count=2 respawn=45 mode=group move=wander radius=8"); + JObject p1 = PlacementData("t_roam-1"); + Check("cmd.place.named", r.Contains("Placed t_roam-1") && p1 != null, ""); + Check("cmd.place.options", p1 != null && (int)p1["count"] == 2 && (float)p1["respawn"] == 45f && (string)p1["respawnMode"] == "group" && + (string)p1["movement"]?["mode"] == "wander" && (float)p1["movement"]?["radius"] == 8f, "" + p1?.ToString(Formatting.None)); + Check("cmd.place.costWarning", r.Contains("NPC(s) on this server"), ""); + r = Rnpc($"rnpc.place t_roam {At(b)} respawn=20"); + Check("cmd.place.nextName", r.Contains("Placed t_roam-2") && PlacementData("t_roam-2") != null, ""); + JObject p2 = PlacementData("t_roam-2"); + Check("cmd.place.defaults", p2 != null && (int)p2["count"] == 1 && (string)p2["respawnMode"] == "each" && p2["movement"] == null, "" + p2?.ToString(Formatting.None)); + + // Refusals, each with its reason; none may leave a placement behind. + int before = (Api>>("RunicNpc_Placements") ?? new List>()).Count; + Check("cmd.refuse.unknownProfile", Rnpc($"rnpc.place nope {At(a)}").Contains("no profile"), ""); + Check("cmd.refuse.refusedProfile", Rnpc($"rnpc.place t_bad {At(a)}").Contains("refused"), ""); + Check("cmd.refuse.unknownOption", Rnpc($"rnpc.place t_roam {At(a)} speed=3").Contains("no option speed="), ""); + Check("cmd.refuse.notKeyValue", Rnpc($"rnpc.place t_roam {At(a)} 3").Contains("not key=value"), ""); + Check("cmd.refuse.count", Rnpc($"rnpc.place t_roam {At(a)} count=0").Contains("count="), ""); + Check("cmd.refuse.twice", Rnpc($"rnpc.place t_roam {At(a)} count=1 count=2").Contains("twice"), ""); + Check("cmd.refuse.mode", Rnpc($"rnpc.place t_roam {At(a)} mode=sometimes").Contains("each or group"), ""); + Check("cmd.refuse.missingRoute", Rnpc($"rnpc.place t_roam {At(a)} move=route:t_none").Contains("no route 't_none'"), ""); + Check("cmd.refuse.sentryMoves", Rnpc($"rnpc.place t_sentry {At(a)} move=wander").Contains("sentry"), ""); + Check("cmd.refuse.offMesh", Rnpc($"rnpc.place t_roam {At(air)}").Contains("m above the"), ""); + Check("cmd.refuse.noSpot", Rnpc("rnpc.place t_roam").Contains("at=x,y,z"), ""); + Check("cmd.refuse.leftNothing", (Api>>("RunicNpc_Placements") ?? new List>()).Count == before, ""); + Check("cmd.sentryOffMesh", Rnpc($"rnpc.place t_sentry {At(air)}").Contains("Placed t_sentry-1"), "a sentry may stand 40 m up"); + Rnpc("rnpc.remove t_sentry-1"); + Check("cmd.refuse.api.offMesh", Api("RunicNpc_SetPlacement", "t_air", Placement("t_roam", air, 1, 20f, "each")) != null, "the API refuses a roamer off the navmesh too"); + + yield return new WaitForSeconds(4f); + Check("cmd.place.spawned", List("placement:t_roam-1").Count == 2 && List("placement:t_roam-2").Count == 1, + $"t_roam-1 {List("placement:t_roam-1").Count}, t_roam-2 {List("placement:t_roam-2").Count}"); + + // Rename (D241): the NPC keeps living and changes owner with it. + ulong renamedNpc = List("placement:t_roam-2").Select(d => (ulong)d["netId"]).FirstOrDefault(); + r = Rnpc("rnpc.rename t_roam-2 t_gate"); + List> gate = List("placement:t_gate"); + Check("cmd.rename", r.Contains("Renamed") && PlacementData("t_gate") != null && PlacementData("t_roam-2") == null, ""); + Check("cmd.rename.keepsNpc", gate.Count == 1 && (ulong)gate[0]["netId"] == renamedNpc && (string)gate[0]["placement"] == "t_gate", ""); + Check("cmd.rename.refuseTaken", Rnpc("rnpc.rename t_gate t_roam-1").Contains("taken"), ""); + Check("cmd.rename.refuseBadName", Rnpc("rnpc.rename t_gate Bad!Name").Contains("1–40"), ""); + Check("cmd.place.nameReused", Rnpc($"rnpc.place t_roam {At(b)}").Contains("Placed t_roam-2"), "the freed number is taken again"); + Rnpc("rnpc.remove t_roam-2"); + + // Recording a route (D240), from the console with at=. + // The hill route a first run recorded (points 12-17 m up): the patrol never left its first point. + // Recorded again, to note what the reachability check now answers. + Rnpc("rnpc.path record t_hill"); + foreach (Vector3 h in new[] { new Vector3(70, 0, -40), new Vector3(90, 0, -40), new Vector3(90, 0, -20) }) + Rnpc($"rnpc.path point {At(OnMesh(field + h))}"); + Rnpc("rnpc.path cancel"); + + Check("cmd.path.pointFirst", Rnpc($"rnpc.path point {At(a)}").Contains("Nothing is being recorded"), ""); + Check("cmd.path.record", Rnpc("rnpc.path record t_rec").Contains("Recording 't_rec'"), ""); + Vector3[] pts = { OnMesh(field + new Vector3(-25, 0, -25)), OnMesh(field + new Vector3(-5, 0, -25)), OnMesh(field + new Vector3(-5, 0, -5)), OnMesh(field + new Vector3(-25, 0, -5)) }; + Note("route points y: " + string.Join(", ", pts.Select(v => v.y.ToString("0.0")).ToArray())); + Check("cmd.path.point1", Rnpc($"rnpc.path point {At(pts[0])}").Contains("Point 1"), ""); + Check("cmd.path.saveTooFew", Rnpc("rnpc.path save").Contains("at least two"), ""); + Check("cmd.path.pointOffMesh", Rnpc($"rnpc.path point {At(pts[0] + Vector3.up * 30f)}").Contains("Refused"), ""); + Rnpc($"rnpc.path point {At(pts[1])}"); + Rnpc($"rnpc.path point {At(pts[3])}"); + Check("cmd.path.undo", Rnpc("rnpc.path undo").Contains("2 left"), ""); + Rnpc($"rnpc.path point {At(pts[2])}"); + Check("cmd.path.saveBadHow", Rnpc("rnpc.path save sideways").Contains("loop|back"), ""); + r = Rnpc("rnpc.path save back"); + JObject routes = Api("RunicNpc_Routes"); + JToken rec = routes?["t_rec"]; + Check("cmd.path.saved", r.Contains("Saved 't_rec': 3 points") && rec != null && ((JArray)rec["points"]).Count == 3 && !(bool)rec["loop"], "" + rec?.ToString(Formatting.None)); + Check("cmd.path.list", Rnpc("rnpc.path list").Contains("t_rec · 3 points"), ""); + + // A patrol on it. + r = Rnpc($"rnpc.place t_roam {At(pts[0])} move=route:t_rec respawn=20"); + Check("cmd.patrol.placed", r.Contains("Placed t_roam-2"), ""); + for (int s = 0; s < 4; s++) + { + yield return new WaitForSeconds(3f); + List> seen = List("placement:t_roam-2"); + Note(seen.Count == 1 ? $"patrol +{3 * (s + 1)} s: {Flat(V(seen[0]["position"]), pts[0]):0.0} m from point 0, state={seen[0]["state"]} rest={seen[0]["rest"]}" : "patrol: " + seen.Count + " listed"); + } + List> patrol = List("placement:t_roam-2"); + Check("cmd.patrol.walks", patrol.Count == 1 && (string)patrol[0]["movement"] == "route:t_rec" && Flat(V(patrol[0]["position"]), pts[0]) > 3f, + patrol.Count == 1 ? $"{patrol[0]["movement"]}, {Flat(V(patrol[0]["position"]), pts[0]):0.0} m from the first point" : "not spawned"); + r = Rnpc("rnpc.path delete t_rec"); + yield return new WaitForSeconds(2f); + Dictionary row = Row("t_roam-2"); + Check("cmd.path.deleteWaits", r.Contains("1 placement(s) on it now wait") && row != null && ("" + row["waiting"]).Contains("t_rec") && List("placement:t_roam-2").Count == 0, + row != null ? "" + row["waiting"] : "no row"); + Rnpc("rnpc.remove t_roam-2"); + + // respawn, clear, remove. + HashSet old = new HashSet(List("placement:t_roam-1").Select(d => (ulong)d["netId"])); + r = Rnpc("rnpc.respawn t_roam-1"); + yield return new WaitForSeconds(3f); + List> fresh = List("placement:t_roam-1"); + Check("cmd.respawn", r.Contains("Respawning 2") && fresh.Count == 2 && fresh.All(d => !old.Contains((ulong)d["netId"])), $"{fresh.Count} alive, fresh={fresh.All(d => !old.Contains((ulong)d["netId"]))}"); + r = Rnpc("rnpc.clear placement:t_gate"); + Check("cmd.clear", r.Contains("Removed 1") && List("placement:t_gate").Count == 0 && PlacementData("t_gate") != null, "the NPC goes, the placement stays"); + Check("cmd.clear.refuseBadOwner", Rnpc("rnpc.clear nobody").Contains("Usage"), ""); + r = Rnpc("rnpc.remove t_gate"); + Check("cmd.remove", r.Contains("Removed t_gate") && PlacementData("t_gate") == null, ""); + Check("cmd.remove.unknown", Rnpc("rnpc.remove t_gate").Contains("no placement"), ""); + Check("cmd.near.console", Rnpc("rnpc.near").Contains("server console"), ""); + Check("cmd.info.console", Rnpc("rnpc.info").Contains("in game"), ""); + + // The chat path, as a stand-in player: nothing without a permission, then place where it stands. + BasePlayer admin = StandIn(b, 20); + string uid = admin.UserIDString; + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam" }); + Check("cmd.chat.needsPermission", PlacementData("t_roam-2") == null, ""); + permission.GrantUserPermission(uid, "runicnpc.place", null); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam", "count=1" }); + JObject chatPlaced = PlacementData("t_roam-2"); + // D325: a metre in front of the admin, not at their feet. + float hereAway = chatPlaced == null ? -1f : Flat(V(chatPlaced["position"].ToObject>()), admin.transform.position); + Check("cmd.chat.here", hereAway > 0.8f && hereAway < 1.2f, $"{hereAway:0.00} m from the admin; " + chatPlaced?.ToString(Formatting.None)); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "respawn", "t_roam-2" }); + Check("cmd.chat.adminNeedsAdmin", Row("t_roam-2") != null, "respawn is refused with runicnpc.place alone (no error)"); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "remove", "t_roam-2" }); + Check("cmd.chat.remove", PlacementData("t_roam-2") == null, ""); + permission.RevokeUserPermission(uid, "runicnpc.place"); + admin.Kill(); + _standIns.Remove(admin); + + // Standalone profile edits (D221): refused while managed, then allowed once the file says it is not. + Check("cmd.profile.managed", Rnpc("rnpc.profile create t_new").Contains("managed"), ""); + Check("cmd.profile.show", Rnpc("rnpc.profile show t_roam").Contains("\"names\""), ""); + JObject file = Api("RunicNpc_Profiles"); + file.Remove("refused"); + file["managed"] = false; + Interface.Oxide.DataFileSystem.WriteObject("RunicNPC/profiles", file); + Rnpc("rnpc.reload"); + Check("cmd.profile.create", Rnpc("rnpc.profile create t_new from=t_roam").Contains("in use"), ""); + Rnpc("rnpc.profile set t_new health 321"); + Rnpc("rnpc.profile set t_new names Old Jim,Bob"); + Rnpc("rnpc.profile set t_new ranges.sense 44"); + JToken made = Api("RunicNpc_Profiles")?["profiles"]?["t_new"]; + Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f && + made["names"].ToObject().SequenceEqual(new[] { "Old Jim", "Bob" }), "" + made?.ToString(Formatting.None)); + Check("cmd.profile.set.noField", Rnpc("rnpc.profile set t_new nope 1").Contains("no field"), ""); + Check("cmd.profile.set.object", Rnpc("rnpc.profile set t_new ranges 1").Contains("fields of its own"), ""); + Check("cmd.profile.set.refusedKit", Rnpc("rnpc.profile set t_new kits rnt_nokit").Contains("cannot spawn yet"), ""); + Check("cmd.profile.delete", Rnpc("rnpc.profile delete t_new").Contains("Deleted") && Api("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, ""); + SetTestProfiles(); + + IEnumerator floorTest = Floor(field); + while (floorTest.MoveNext()) yield return floorTest.Current; + } + + /// + /// D239: a roamer placed on a player-built floor is told so. The floor is then destroyed under + /// a live NPC (what happens to it is measured, not asserted), and after its death the placement + /// falls back to the nearest navmesh until the floor returns. + /// + // ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ---- + + private Dictionary Add(object placement) => + Api>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary { ["error"] = "no answer" }; + + private static string Said(Dictionary d) => + d.ContainsKey("error") ? "error: " + d["error"] : "id " + d["id"] + (d.ContainsKey("position") ? " at " + V(d["position"]) : ""); + + private IEnumerator Api3(Vector3 field) + { + _changed.Clear(); + Vector3 a = OnMesh(field + new Vector3(-40, 0, 40)); + StandIn(OnMesh(field + new Vector3(-40, 0, 140)), 31); + + // A map point: x and z only. RunicNPC grounds it and names it like rnpc place (D241, D246). + Dictionary r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 2, respawn = 30f, respawnMode = "group" }); + Check("api3.add.named", !r.ContainsKey("error") && (string)r["id"] == "t_roam-1", Said(r)); + Vector3 put = r.ContainsKey("position") ? V(r["position"]) : Vector3.zero; + float ground = TerrainMeta.HeightMap.GetHeight(a); + Check("api3.add.grounded", r.ContainsKey("position") && Mathf.Abs(put.y - ground) < 1.5f, $"y {put.y:0.00}, terrain {ground:0.00}"); + Check("api3.add.cost", r.ContainsKey("cost") && ((string)r["cost"]).Contains("NPC(s) on this server"), r.ContainsKey("cost") ? (string)r["cost"] : ""); + Check("api3.add.notBuilt", r.ContainsKey("built") && !(bool)r["built"], ""); + JObject data = PlacementData("t_roam-1"); + Check("api3.add.values", data != null && (int)data["count"] == 2 && (string)data["respawnMode"] == "group", "" + data?.ToString(Formatting.None)); + Check("api3.hook.set", _changed.Contains("set:t_roam-1"), string.Join(",", _changed.ToArray())); + + r = Add(new { profile = "t_roam", position = new { x = a.x + 6f, z = a.z } }); + Check("api3.add.nextName", !r.ContainsKey("error") && (string)r["id"] == "t_roam-2", Said(r)); + + // Refusals, each with the in-game reason; none leaves a placement behind. + int before = (Api>>("RunicNpc_Placements") ?? new List>()).Count; + float edge = World.Size / 2f + 50f; + r = Add(new { profile = "t_roam", position = new { x = edge, z = 0f } }); + Check("api3.refuse.offMap", r.ContainsKey("error") && ((string)r["error"]).Contains("off the map"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, y = a.y + 40f, z = a.z } }); + Check("api3.refuse.offMesh", r.ContainsKey("error") && ((string)r["error"]).Contains("m above the"), Said(r)); + r = Add(new { profile = "nope", position = new { x = a.x, z = a.z } }); + Check("api3.refuse.unknownProfile", r.ContainsKey("error") && ((string)r["error"]).Contains("no profile"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 0 }); + Check("api3.refuse.count", r.ContainsKey("error") && ((string)r["error"]).Contains("count"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, movement = new { mode = "route:t_none", radius = 0f } }); + Check("api3.refuse.missingRoute", r.ContainsKey("error") && ((string)r["error"]).Contains("no route 't_none'"), Said(r)); + Vector3 sea; + if (FindWater(out sea)) + { + r = Add(new { profile = "t_roam", position = new { x = sea.x, z = sea.z } }); + Check("api3.refuse.water", r.ContainsKey("error") && ((string)r["error"]).Contains("under water"), Said(r) + $" at {sea}"); + } + else + Note("api3.refuse.water skipped: no deep water found"); + Check("api3.refuse.leftNothing", (Api>>("RunicNpc_Placements") ?? new List>()).Count == before, ""); + + yield return new WaitForSeconds(4f); + Check("api3.spawned", List("placement:t_roam-1").Count == 2, "listed " + List("placement:t_roam-1").Count); + + // Rename and respawn, as rnpc rename and rnpc respawn. + _changed.Clear(); + string renamed = Api("RunicNpc_RenamePlacement", "t_roam-2", "t_site"); + Check("api3.rename", renamed == null && PlacementData("t_site") != null && PlacementData("t_roam-2") == null, renamed ?? ""); + Check("api3.hook.renamed", _changed.Contains("renamed:t_roam-2>t_site"), string.Join(",", _changed.ToArray())); + string taken = Api("RunicNpc_RenamePlacement", "t_site", "t_roam-1"); + Check("api3.rename.refuseTaken", taken != null && taken.Contains("taken"), taken ?? "null"); + ulong first = List("placement:t_roam-1").Select(d => (ulong)d["netId"]).FirstOrDefault(); + int respawned = Api("RunicNpc_RespawnPlacement", "t_roam-1"); + Check("api3.respawn", respawned == 2, "respawned " + respawned); + Check("api3.respawn.missing", Api("RunicNpc_RespawnPlacement", "t_nothing") == -1, ""); + yield return new WaitForSeconds(3f); + List> after = List("placement:t_roam-1"); + Check("api3.respawn.fresh", after.Count == 2 && after.All(d => (ulong)d["netId"] != first), $"listed {after.Count}"); + + // The hook for every change made in game too. + _changed.Clear(); + Rnpc($"rnpc.place t_roam {At(OnMesh(field + new Vector3(-30, 0, 40)))}"); + Rnpc("rnpc.remove t_roam-2"); + Check("api3.hook.inGame", _changed.Contains("set:t_roam-2") && _changed.Contains("removed:t_roam-2"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_site"); + Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_roam-1"); + } + + /// A point of open sea at least 5 m deep, for the under-water refusal. + private static bool FindWater(out Vector3 at) + { + var rng = new System.Random(7); + float half = World.Size / 2f - 20f; + for (int i = 0; i < 4000; i++) + { + var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half); + if (TerrainMeta.WaterMap.GetHeight(p) - TerrainMeta.HeightMap.GetHeight(p) > 5f) + { + at = p; + return true; + } + } + + at = Vector3.zero; + return false; + } + + private IEnumerator Floor(Vector3 field) + { + // Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never + // covered it there in 10 s. + Vector3 at = Vector3.zero; + foreach (Vector3 o in new[] { new Vector3(0, 0, 20), new Vector3(-20, 0, 20), new Vector3(20, 0, 0), new Vector3(0, 0, -20), new Vector3(-20, 0, -20) }) + { + Vector3 ground = OnMesh(field + o); + if (ground.y > TerrainMeta.WaterMap.GetHeight(ground) + 1f) + { + at = ground + Vector3.up * 4f; + break; + } + } + + BaseEntity floor = at == Vector3.zero ? null : BuildFloor(at); + if (floor == null) + { + Check("cmd.floor.built", false, "no floor"); + yield break; + } + + Vector3 top = floor.transform.position + Vector3.up * 0.1f; + float t0 = Time.realtimeSinceStartup; + NavMeshHit onTop; + bool covered = false; + while (!(covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f) && Time.realtimeSinceStartup - t0 < 10f) + yield return null; + Note($"floor at y={top.y:0.00} over terrain {TerrainMeta.HeightMap.GetHeight(top):0.00}, owner {floor.OwnerID}: " + + (covered ? $"covered after {Time.realtimeSinceStartup - t0:0.00} s" : "NOT covered within 10 s")); + + // A route leg an NPC cannot walk is refused: the floor has no stairs, so its top is on the + // navmesh but not reachable from the ground beside it. + Rnpc("rnpc.path record t_up"); + Rnpc($"rnpc.path point {At(OnMesh(top + new Vector3(8f, 0f, 0f)))}"); + Check("cmd.path.unreachableRefused", Rnpc($"rnpc.path point {At(top)}").Contains("cannot walk here"), "ground to the top of a floor with no stairs"); + Rnpc("rnpc.path cancel"); + + string r = Rnpc($"rnpc.place t_roam {At(top)} respawn=5 move=wander radius=2"); + string id = System.Text.RegularExpressions.Regex.Match(r, @"Placed (\S+):").Groups[1].Value; + Check("cmd.floor.warns", r.Contains("player-built structure"), id); + StandIn(OnMesh(field + new Vector3(60, 0, 90)), 22); + yield return new WaitForSeconds(3f); + List> on = List("placement:" + id); + Check("cmd.floor.standsOnIt", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f, on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}, floor {top.y:0.00}" : "not spawned"); + + // D239 under a live NPC: Rust leaves it standing in the air; RunicNPC puts it on the nearest + // navmesh within its 2 s check. + ulong liveId = on.Count == 1 ? (ulong)on[0]["netId"] : 0UL; + floor.Kill(); + _built.Remove(floor); + float downAfter = -1f; + for (int s = 1; s <= 10; s++) + { + yield return new WaitForSeconds(1f); + on = List("placement:" + id); + if (on.Count == 1 && downAfter < 0f && V(on[0]["position"]).y < top.y - 2f) + downAfter = s; + if (s == 1 || s == downAfter || s == 10) + Note(on.Count == 1 + ? $"floor gone +{s} s: y={V(on[0]["position"]).y:0.00} (floor was {top.y:0.00}) state={on[0]["state"]} health={Convert.ToSingle(on[0]["health"]):0} regrounded={on[0]["regrounded"]}" + : $"floor gone +{s} s: {on.Count} NPC(s) listed"); + } + + on = List("placement:" + id); + Check("cmd.floor.liveRegrounds", on.Count == 1 && (ulong)on[0]["netId"] == liveId && downAfter > 0f && downAfter <= 4f && Row(id)?["note"] != null, + downAfter > 0f ? $"on the ground {downAfter:0} s after the floor went, the same NPC; note: {Row(id)?["note"]}" : "still in the air after 10 s"); + + foreach (Dictionary d in List("placement:" + id)) + Kill((ulong)d["netId"]); + yield return new WaitForSeconds(9f); + on = List("placement:" + id); + Dictionary row = Row(id); + Check("cmd.floor.fallsBack", on.Count == 1 && V(on[0]["position"]).y < top.y - 2f && row != null && row["note"] != null, + on.Count == 1 ? $"respawned at y={V(on[0]["position"]).y:0.00}; note: {row?["note"]}" : $"{on.Count} alive; lastError={row?["lastError"]}"); + + // The floor is rebuilt: the next respawn is back on it, and the note clears. + floor = BuildFloor(at); + yield return new WaitForSeconds(1f); + Rnpc("rnpc.respawn " + id); + yield return new WaitForSeconds(3f); + on = List("placement:" + id); + row = Row(id); + Check("cmd.floor.returns", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f && row?["note"] == null, + on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}; note: {row?["note"] ?? "-"}" : "not spawned"); + Rnpc("rnpc.remove " + id); + Rnpc("rnpc.remove t_roam-1"); + } + + private BaseEntity BuildFloor(Vector3 at) + { + BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", at); + if (floor == null) return null; + 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.OwnerID = StandInId; + floor.Spawn(); + _built.Add(floor); + return floor; + } + + private static Vector3 OnMesh(Vector3 p) + { + p.y = TerrainMeta.HeightMap.GetHeight(p); + NavMeshHit hit; + return SampleNavmesh(p, 3f, out hit) ? hit.position : p; + } + + 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 Row(string id) => + (Api>>("RunicNpc_Placements") ?? new List>()).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)); + } + + /// + /// 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). + /// + private void After() + { + _pass = _fail = 0; + List> rows = Api>>("RunicNpc_Placements") ?? new List>(); + foreach (Dictionary 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(List(null).Select(d => (ulong)d["netId"])); + List world = BaseNetworkable.serverEntities.OfType().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 r in rows) + { + Vector3 at = V(((JObject)r["placement"])["position"].ToObject>()); + strays += BaseNetworkable.serverEntities.OfType().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"); + } + + // ==== stage 5: behaviour (docs/runicnpc/PLAN.md, D253–D272) ==== + + private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab"; + private const string Zone = "rnt_zone"; + + private readonly List _escortEnded = new List(); + + private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}"); + + /// The stage 5 test profiles, beside the others; three are refused on purpose. + private static void Stage5Profiles(JObject all) + { + JObject P(string name, Action edit) + { + JObject p = Prof(name: name); + edit(p); + return p; + } + + all["t_red"] = P("TRed", p => p["faction"] = "red"); + all["t_blue"] = P("TBlue", p => p["faction"] = "blue"); + all["t_hunter"] = P("THunter", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f }); + }); + all["t_leashed"] = P("TLeashed", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f }); + }); + all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary { ["profile:t_roam"] = "hostile" })); + all["t_guard"] = P("TGuard", p => p["role"] = "guard"); + all["t_kit"] = P("TKit", p => + { + p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true }); + // Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m. + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + }); + all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore"); + all["t_always"] = P("TAlways", p => p["turrets"] = "always"); + all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false); + all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false); + all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists"); + all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes"); + } + + private ScientistNPC SpawnScientist(Vector3 at) + { + ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC; + if (s == null) return null; + s.enableSaving = false; + s.Spawn(); + _built.Add(s); + return s; + } + + private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL; + + private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead(); + + private static bool Knows(BasePlayer npc, BaseEntity who) + { + ScientistNPC s = npc as ScientistNPC; + return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who); + } + + /// + /// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing + /// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens + /// next start from here: SetKnown asks the NPC's IsTarget, which is what they test. + /// + private static void Sense(BasePlayer npc, BasePlayer who) + { + ScientistNPC s = npc as ScientistNPC; + if (s?.Brain?.Senses?.Memory != null && who != null) + s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses); + } + + private static string TargetOf(Dictionary d) => d == null ? "?" : d["target"] as string ?? "none"; + + private static void Give(BasePlayer npc, string item, int amount) + { + Item made = ItemManager.CreateByName(item, amount); + if (made != null && !made.MoveToContainer(npc.inventory.containerMain)) + made.Remove(); + } + + private static int Count(BasePlayer npc, string item) => + Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount); + + private static List Items(BasePlayer npc) + { + var all = new List(); + npc.inventory.GetAllItems(all); + return all; + } + + private static void Teleport(BasePlayer npc, Vector3 to) + { + ScientistNPC s = npc as ScientistNPC; + NavMeshHit hit; + if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit)) + { + s.Brain.Navigator.Stop(); + s.Brain.Navigator.Agent.Warp(hit.position); + s.transform.position = hit.position; + } + } + + private static void MoveStandIn(BasePlayer p, Vector3 to) + { + p.transform.position = OnMesh(to); + p.SendNetworkUpdateImmediate(); + } + + /// A spot near with a clear line to a second spot m east: no rock between. + private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b) + { + for (int i = 0; i < 40; i++) + { + a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f)); + b = OnMesh(a + new Vector3(range, 0, 0)); + NavMeshHit h1, h2; + if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue; + if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore)) + return true; + } + + a = b = Vector3.zero; + return false; + } + + private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); + + private IEnumerator Stage5() + { + Vector3 f; + if (!FindField(300f, 2025, out f, 140f)) + { + Check("s5.setup.field", false, "no second open field on the navmesh"); + yield break; + } + + int horrorBefore = HorrorLeft(); + Note($"s5 field {f}, Horror entities before: {horrorBefore}"); + + // ---- the faction table (D254, D268) and the profile refusals ---- + string said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } })); + Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } })); + Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } })); + Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } })); + Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } })); + JArray table = Api("RunicNpc_Factions"); + Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None)); + JObject profiles = Api("RunicNpc_Profiles"); + Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]); + Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]); + + // A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed. + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- five fights at once, 70 m apart (D231, D255, D263, D268) ---- + Vector3 a = OnMesh(f + new Vector3(-140, 0, 0)); + Vector3 b = OnMesh(f + new Vector3(-70, 0, 0)); + Vector3 c = OnMesh(f + new Vector3(0, 0, 0)); + Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); + Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); + + BasePlayer red = Spawn(a, "t_red"); + BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); + BasePlayer hunter = Spawn(b, "t_hunter"); + ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); + BasePlayer neutral = Spawn(c, "t_roam"); + ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); + BasePlayer leashed = Spawn(d, "t_leashed"); + ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); + BasePlayer victim = Spawn(e, "t_roam"); + BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); + var fighters = new Dictionary + { + { "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral }, + { "bystander", bystander }, { "leashed", leashed }, { "far", far }, { "victim", victim }, { "bully", bully }, + }; + string[] unspawned = fighters.Where(kv => kv.Value == null).Select(kv => kv.Key).ToArray(); + Check("s5.fight.spawned", unspawned.Length == 0, unspawned.Length == 0 ? "" : $"not spawned: {string.Join(", ", unspawned)} (field {f}, map {World.Size})"); + if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break; + + float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health; + float redHp = red.health, blueHp = blue.health; + float factionsAt = -1f, scientistAt = -1f; + bool neutralFought = false, leashedFought = false, victimAnswered = false; + int marked = 0; + for (int t = 1; t <= 70; t++) + { + yield return new WaitForSeconds(1f); + if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; + if (scientistAt < 0f && Gone(prey)) scientistAt = t; + if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; + if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; + if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; + foreach (BasePlayer x in new[] { red, blue, hunter }) + { + Dictionary dx = Gone(x) ? null : Describe(x); + if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"])); + } + + if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break; + } + + float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health; + Check("s5.fight.factions", redNow < redHp && blueNow < blueHp, + $"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}"); + Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length"); + Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f), + scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s"); + Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)"); + Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)"); + Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ---- + BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51); + BasePlayer b0 = Spawn(a, "t_blue"); + BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue"); + BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue"); + BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue"); + BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam"); + yield return new WaitForSeconds(2f); + b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}"); + Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}"); + Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- allies (D257, D270): never targeted, and defended with what they own ---- + BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52); + // The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses + // only one of two stand-ins this close, so a second stand-in cannot be the control.) + BasePlayer guardian = Spawn(b, "t_roam"); + BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam"); + said = Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + Check("s5.ally.set", said == null, said ?? "allied to a player"); + said = Api("RunicNpc_Ally", Id(guardian), "clan", "424242"); + Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null"); + Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + yield return new WaitForSeconds(2f); + Sense(guardian, friend); + Sense(control, friend); + yield return new WaitForSeconds(1f); + Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}"); + Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}"); + Api("RunicNpc_Despawn", Id(control)); + BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54); + friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}"); + + BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8))); + if (foundation != null) + { + foundation.enableSaving = false; + foundation.OwnerID = (ulong)friend.userID; + if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true; + foundation.Spawn(); + _built.Add(foundation); + BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55); + ((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}"); + } + else + Check("s5.ally.defendsBuilding", false, "no foundation could be made"); + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- escort (D258, D269) ---- + _escortEnded.Clear(); + // A stock scientist as the charge: a stand-in has no connection, so it is never "on the server". + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry"); + said = Api("RunicNpc_Escort", Id(sentry), charge); + Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null"); + said = Api("RunicNpc_Escort", Id(escort), charge); + Check("s5.escort.set", said == null, said ?? "escorting"); + yield return new WaitForSeconds(2f); + Vector3 home = escort.transform.position; + // East, not north: north brings the escort within sense range of the keeper stand-in, and Rust's + // design then fights it instead of walking home. + Teleport(charge, c + new Vector3(35, 0, 0)); + float closest = float.MaxValue; + for (int t = 0; t < 20; t++) + { + yield return new WaitForSeconds(1f); + closest = Mathf.Min(closest, Flat(escort.transform.position, charge.transform.position)); + } + + Check("s5.escort.follows", closest < 8f, $"its charge moved 35 m; closest {closest:0.0} m in 20 s"); + Check("s5.escort.spared", !Knows(escort, charge), $"target={Knows(escort, charge)}"); + charge.Kill(); + for (int t = 0; t < 20 && _escortEnded.Count == 0; t++) + yield return new WaitForSeconds(1f); + Dictionary de = Describe(escort); + Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}"); + float back = float.MaxValue; + for (int t = 0; t < 45 && back > 4f; t++) + { + yield return new WaitForSeconds(1f); + back = Flat(escort.transform.position, V(Describe(escort)["home"])); + } + + Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the guard role ---- + BasePlayer guard = Spawn(d, "t_guard"); + yield return new WaitForSeconds(2f); + Vector3 post = V(Describe(guard)["home"]); + Teleport(guard, post + new Vector3(10, 0, 0)); + float off = float.MaxValue; + for (int t = 0; t < 25 && off > 2.5f; t++) + { + yield return new WaitForSeconds(1f); + off = Flat(guard.transform.position, post); + } + + Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it"); + // It stops within 2 m of its post; let it arrive before measuring how still it holds. + yield return new WaitForSeconds(4f); + Vector3 held = guard.transform.position; + float drift = 0f; + for (int t = 0; t < 12; t++) + { + yield return new WaitForSeconds(1f); + drift = Mathf.Max(drift, Flat(guard.transform.position, held)); + } + + Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)"); + Api("RunicNpc_DespawnOwner", Owner); + yield return new WaitForSeconds(1f); + + // ---- the tether (D272) ---- + Plugin zm = plugins.Find("ZoneManager"); + if (zm == null) + Note("s5.tether skipped: ZoneManager is not loaded"); + else + { + // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m. + Vector3 centre = OnMesh(c); + zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre); + BasePlayer tethered = Spawn(centre, "t_roam"); + said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone"); + Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null"); + said = Api("RunicNpc_Tether", Id(tethered), Zone); + Check("s5.tether.set", said == null, said ?? "held"); + float widest = 0f; + for (int t = 0; t < 30; t++) + { + yield return new WaitForSeconds(1f); + widest = Mathf.Max(widest, Flat(tethered.transform.position, centre)); + } + + Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s"); + Teleport(tethered, centre + new Vector3(25, 0, 0)); + float inside = float.MaxValue; + for (int t = 0; t < 25 && inside > 12f; t++) + { + yield return new WaitForSeconds(1f); + inside = Flat(tethered.transform.position, centre); + } + + Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre"); + string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" })); + Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null"); + Api("RunicNpc_RemovePlacement", "t_tether"); + zm.Call("EraseZone", Zone); + Api("RunicNpc_DespawnOwner", Owner); + } + + // ---- PVE (D261, D271) ---- + BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57); + BasePlayer unkill = Spawn(a, "t_unkill"); + BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless"); + BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam"); + object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f)); + object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f)); + object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}"); + float hp = unkill.health; + unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f)); + float plainHp = plain.health; + plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f)); + Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}"); + object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ---- + BasePlayer ignoring = Spawn(b, "t_ignore"); + BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam"); + object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null); + object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null); + Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + IEnumerator sentries = Sentries(); + while (sentries.MoveNext()) yield return sentries.Current; + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the kit's extras (D260, D265), each NPC 60 m from the next ---- + IEnumerator kit = KitExtras(f); + while (kit.MoveNext()) yield return kit.Current; + + int horrorAfter = HorrorLeft(); + Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after"); + Api("RunicNpc_SetFactions", new JArray()); + } + + /// + /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they + /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry: + /// one always, one default, and a stock scientist beside them. + /// + private IEnumerator Sentries() + { + List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList(); + if (turrets.Count == 0) + { + Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries"); + yield break; + } + + NPCAutoTurret turret = null; + Vector3 spot = Vector3.zero; + foreach (NPCAutoTurret t in turrets) + { + Vector3 ahead = t.transform.position + t.transform.forward * 12f; + NavMeshHit hit, start; + // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the + // water left the Carbon run's NPC unspawned). + if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f && + SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start)) + { + turret = t; + spot = hit.position; + break; + } + } + + if (turret == null) + { + Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries"); + yield break; + } + + Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map"); + BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58); + Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized; + // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike). + Vector3 outside = turret.transform.position + turret.transform.forward * 45f; + BasePlayer always = Spawn(OnMesh(outside), "t_always"); + BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam"); + ScientistNPC stock = SpawnScientist(outside - side * 4f); + yield return new WaitForSeconds(1f); + Teleport(always, spot); + Teleport(plain, spot + side * 4f); + Teleport(stock, spot - side * 4f); + float alwaysHp = always != null ? always.health : 0f; + var targeted = new HashSet(); + bool alwaysTargeted = false; + int stockTargets = 0; + for (int t = 0; t < 120; t++) + { + yield return new WaitForSeconds(0.25f); + foreach (NPCAutoTurret any in turrets) + { + BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null; + if (target == null) continue; + targeted.Add(target.GetType().Name); + if (target == always) alwaysTargeted = true; + if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++; + } + } + + if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}"); + Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}"); + Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp"); + Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)"); + Api("RunicNpc_DespawnOwner", Owner); + } + + private IEnumerator KitExtras(Vector3 f) + { + Vector3[] at = new Vector3[6]; + for (int i = 0; i < 6; i++) + at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60)); + + // Heal in a lull, one syringe used up; and a profile that did not opt in never heals. + BasePlayer healer = Spawn(at[0], "t_kit"); + BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam"); + Give(healer, "syringe.medical", 2); + Give(plain, "syringe.medical", 2); + yield return new WaitForSeconds(1f); + healer.health = healer.MaxHealth() * 0.3f; + plain.health = plain.MaxHealth() * 0.3f; + float plainHp = plain.health; + + // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range. + BasePlayer grenadier = Spawn(at[1], "t_kit"); + Give(grenadier, "grenade.f1", 2); + BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60); + Vector3 rAt, rTo; + if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); } + BasePlayer rocketeer = Spawn(rAt, "t_kit"); + Give(rocketeer, "rocket.launcher", 1); + Give(rocketeer, "ammo.rocket.basic", 2); + ScientistNPC rTarget = SpawnScientist(rTo); + BasePlayer brawler = Spawn(at[3], "t_kit"); + Give(brawler, "machete", 1); + BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62); + BasePlayer flamer = Spawn(at[4], "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up). + foreach (Item it in Items(flamer)) + if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0; + BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63); + Sense(flamer, near); + + bool flameHeld = false; + for (int t = 0; t < 45; t++) + { + yield return new WaitForSeconds(1f); + if (t % 5 == 0) + { + if (!Gone(flamer)) Sense(flamer, near); + if (!Gone(grenadier)) Sense(grenadier, gTarget); + if (!Gone(brawler)) Sense(brawler, mTarget); + } + Item held = Gone(flamer) ? null : flamer.GetActiveItem(); + if (held == null || held.info.shortname != "flamethrower") continue; + // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265). + FlameThrower tank = held.GetHeldEntity() as FlameThrower; + if (!flameHeld && tank != null) + { + tank.ammo = 0; + ((NPCPlayer)flamer).ShotTest(4f); + } + + flameHeld = true; + } + + Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary; + int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1; + + Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}"); + Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}"); + Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}"); + Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}"); + Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}"); + Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}"); + Api("RunicNpc_DespawnOwner", Owner); + } + + // ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ---- + + private readonly List _bossEvents = new List(); + private readonly List _used = new List(); + private readonly Dictionary _bossContributors = new Dictionary(); + + private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) => _bossEvents.Add("spawned:" + profile); + + private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) => _bossEvents.Add($"phase{phase}@{at}"); + + private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + _bossEvents.Add("died:" + profile); + _bossContributors.Clear(); + foreach (KeyValuePair kv in contributors) _bossContributors[kv.Key] = kv.Value; + } + + private void OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) => _used.Add(profile + "/" + player.displayName); + + private static void Stage6Profiles(JObject all) + { + JObject boss = Prof(name: "TBoss"); + boss["health"] = 1000f; + boss["damageTaken"] = JObject.FromObject(new { head = 1f, body = 1f, legs = 1f }); + boss["boss"] = JObject.Parse(@"{ + ""barDistance"": 100, ""announce"": ""chat"", + ""spawnLine"": ""{name} rises (rnt)"", ""deathLine"": ""{name} fell to {killer} (rnt)"", + ""phases"": [ + { ""at"": 0.75, ""damageDealt"": 2, ""line"": ""phase one (rnt)"" }, + { ""at"": 0.5, ""adds"": { ""profile"": ""t_roam"", ""count"": 2 }, ""line"": ""adds (rnt)"" }, + { ""at"": 0.25, ""kit"": ""rnhrevolver"", ""speed"": 1.5, ""aimCone"": 4, + ""ranges"": { ""sense"": 50, ""loseTarget"": 60, ""chase"": 50, ""attack"": 40 } } + ]}"); + all["t_boss"] = boss; + + JObject passive = Prof(role: "passive", name: "TPassive"); + passive["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); + passive["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Hello {player} (rnt)" } }); + all["t_passive"] = passive; + + JObject window = Prof(role: "passive", name: "TWindow"); + window["use"] = JObject.FromObject(new { mode = "window", title = "Notice", text = "Hello {player} (rnt)" }); + all["t_passwin"] = window; + + JObject hurt = Prof(role: "passive", name: "THurt"); + hurt["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); + hurt["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Ow (rnt)" } }); + hurt["hurtByPlayers"] = true; + all["t_passhurt"] = hurt; + } + + /// A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay). + private static void Look(BasePlayer p, BasePlayer at) + { + Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized; + p.eyes.rotation = Quaternion.LookRotation(dir); + } + + /// E, just pressed, through RunicNPC's own OnPlayerInput. + private void PressE(BasePlayer p) + { + var input = new InputState(); + input.current.buttons = (int)BUTTON.USE; + input.previous.buttons = 0; + RunicNPC?.Call("OnPlayerInput", p, input); + } + + /// A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some). + private static void HurtTo(BasePlayer npc, BasePlayer by, float fraction) + { + for (int i = 0; i < 200 && !Gone(npc) && npc.health / npc.MaxHealth() >= fraction; i++) + npc.Hurt(new HitInfo(by, npc, Rust.DamageType.Bullet, 40f)); + } + + private IEnumerator Stage6() + { + Vector3 f; + if (!FindField(300f, 2026, out f)) + { + Check("s6.setup.field", false, "no third open field on the navmesh"); + yield break; + } + + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- refusals, through a spawn's overrides (ValidateProfile on the effective profile) ---- + Func refused = (profile, json) => Spawn(f, profile, JObject.Parse(json)) == null; + Check("s6.refuse.passiveBoss", refused("t_passive", "{\"boss\":{}}"), "a passive NPC cannot be a boss"); + Check("s6.refuse.useOnRoamer", refused("t_roam", "{\"use\":{\"mode\":\"chat\",\"lines\":[\"hi\"]}}"), "only a passive NPC answers E"); + Check("s6.refuse.standRoamer", refused("t_roam", "{\"movement\":{\"mode\":\"stand\",\"radius\":1}}"), "stand is a passive NPC's"); + Check("s6.refuse.useMode", refused("t_passive", "{\"use\":{\"mode\":\"shout\"}}"), "use.mode chat or window"); + Check("s6.refuse.chatNoLines", refused("t_passive", "{\"use\":{\"lines\":[]}}"), "a chat answer has lines"); + Check("s6.refuse.phaseAt", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":1.5}]}}"), "at between 0 and 1"); + Check("s6.refuse.phaseTwice", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5},{\"at\":0.5}]}}"), "two phases at one fraction"); + Check("s6.refuse.addsCount", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_roam\",\"count\":0}}]}}"), "adds 1 to 20"); + Check("s6.refuse.addsBoss", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_boss\",\"count\":1}}]}}"), "a boss does not summon bosses"); + Check("s6.refuse.phaseKit", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"kit\":\"rnt_nokit\"}]}}"), "a phase's kit must exist"); + Check("s6.refuse.speed", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"speed\":9}]}}"), "speed at most 5"); + Check("s6.refuse.announce", refused("t_boss", "{\"boss\":{\"announce\":\"shout\"}}"), "chat or popup"); + Check("s6.refuse.barDistance", refused("t_boss", "{\"boss\":{\"barDistance\":0}}"), "above 0"); + Api("RunicNpc_DespawnOwner", Owner); + + // ---- passive (D278, D279, D288) ---- + Vector3 a = OnMesh(f + new Vector3(-40, 0, 0)); + _used.Clear(); + int hitsBefore = _hitsOnStandIns; + BasePlayer pas = Spawn(a, "t_passive"); + BasePlayer talker = StandIn(OnMesh(a + new Vector3(2f, 0, 0)), 61); + yield return new WaitForSeconds(1.5f); + Dictionary dp = Describe(pas); + Check("s6.passive.spawned", pas != null && dp != null && (string)dp["role"] == "passive", dp != null ? "role " + dp["role"] : "not spawned"); + if (pas == null) yield break; + + Sense(pas, talker); + yield return new WaitForSeconds(6f); + Check("s6.passive.neverFights", _hitsOnStandIns == hitsBefore && !Knows(pas, talker), + $"told about a player 2 m off: hits {_hitsOnStandIns - hitsBefore}, still knows it as a target: {Knows(pas, talker)}"); + Check("s6.passive.stands", Flat(pas.transform.position, a) < 1.5f, $"{Flat(pas.transform.position, a):0.0} m from its spot after 7.5 s"); + + float hp = pas.health; + pas.Hurt(new HitInfo(talker, pas, Rust.DamageType.Bullet, 50f)); + Check("s6.passive.unhurtByDefault", pas.health >= hp - 0.01f, $"health {hp:0} → {pas.health:0} after a player's 50 (D279: off by default)"); + + Look(talker, pas); + PressE(talker); + Check("s6.passive.pressE", _used.Count == 1 && _used[0] == "t_passive/RntStandIn61", string.Join(", ", _used.ToArray())); + PressE(talker); + Check("s6.passive.pressOncePerSecond", _used.Count == 1, $"{_used.Count} answer(s) to two presses at once"); + talker.eyes.rotation = Quaternion.LookRotation(Vector3.back); + yield return new WaitForSeconds(1.2f); + PressE(talker); + Check("s6.passive.lookingAway", _used.Count == 1, $"E while looking away: {_used.Count} answer(s)"); + + BasePlayer win = Spawn(OnMesh(a + new Vector3(0, 0, 10f)), "t_passwin"); + BasePlayer reader = StandIn(OnMesh(a + new Vector3(2f, 0, 10f)), 62); + yield return new WaitForSeconds(1f); + if (win != null && reader != null) + { + Look(reader, win); + PressE(reader); + } + + Check("s6.passive.window", _used.Contains("t_passwin/RntStandIn62"), string.Join(", ", _used.ToArray())); + Dictionary dw = Describe(win); + Check("s6.passive.wanders", dw != null && (string)dw["movement"] == "wander", dw != null ? "movement " + dw["movement"] : "not spawned"); + + BasePlayer ph = Spawn(OnMesh(a + new Vector3(0, 0, -10f)), "t_passhurt"); + BasePlayer hitter = StandIn(OnMesh(a + new Vector3(2f, 0, -10f)), 63); + yield return new WaitForSeconds(1f); + if (ph != null && hitter != null) + { + hitsBefore = _hitsOnStandIns; + float before = ph.health; + ph.Hurt(new HitInfo(hitter, ph, Rust.DamageType.Bullet, 30f)); + float after = ph.health; + yield return new WaitForSeconds(5f); + Check("s6.passive.hurtable", after < before, $"hurtByPlayers on: health {before:0} → {after:0}"); + Check("s6.passive.neverFightsBack", _hitsOnStandIns == hitsBefore && !Knows(ph, hitter) && TargetOf(Describe(ph)) == "none", + $"hit by a player: shots back {_hitsOnStandIns - hitsBefore}, knows it {Knows(ph, hitter)}"); + } + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- the boss (D273–D277, D286, D287) ---- + _bossEvents.Clear(); + _bossContributors.Clear(); + Vector3 b = OnMesh(f + new Vector3(40, 0, 0)); + BasePlayer shooter = StandIn(OnMesh(b + new Vector3(0, 0, 30f)), 64); + BasePlayer boss = Spawn(b, "t_boss"); + yield return new WaitForSeconds(1.5f); + Check("s6.boss.spawned", boss != null && _bossEvents.Contains("spawned:t_boss"), string.Join(", ", _bossEvents.ToArray())); + if (boss == null) yield break; + Dictionary db = Describe(boss); + Check("s6.boss.listed", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 0, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); + + ScientistNPC sci = boss as ScientistNPC; + float baseSpeed = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; + float scaleBefore = ((NPCPlayer)boss).damageScale; + + // Down to 70%: phase one (damage ×2, a line). + HurtTo(boss, shooter, 0.7f); + yield return null; + Check("s6.boss.phase1", _bossEvents.Contains("phase1@0.75") && Mathf.Approximately(((NPCPlayer)boss).damageScale, 2f), + $"health {boss.health:0}/1000, events {string.Join(", ", _bossEvents.ToArray())}, damageScale {scaleBefore} → {((NPCPlayer)boss).damageScale}"); + + // Down to 45%: phase two (two adds of t_roam, one per frame, owned as the boss is). + int before2 = List(Owner).Count; + HurtTo(boss, shooter, 0.45f); + for (int i = 0; i < 10; i++) yield return null; + var adds = List(Owner).Where(d => (string)d["profile"] == "t_roam").ToList(); + Check("s6.boss.phase2.adds", _bossEvents.Contains("phase2@0.5") && adds.Count == 2 && adds.All(d => Flat(V(d["position"]), boss.transform.position) < 10f), + $"{adds.Count} add(s) owned by {Owner}, {string.Join(", ", adds.Select(d => Flat(V(d["position"]), boss.transform.position).ToString("0.0") + " m").ToArray())} from the boss; listed {before2} → {List(Owner).Count}"); + + // Down to 20%: phase three (kit swap, speed ×1.5, aim and ranges). It is first given another + // weapon to hold, so the swap back to the kit's revolver can be seen. + boss.inventory.Strip(); + Give(boss, "pistol.semiauto", 1); + ((HumanNPC)boss).EquipWeapon(); + string heldBefore = boss.GetActiveItem()?.info.shortname ?? "none"; + HurtTo(boss, shooter, 0.2f); + yield return null; + float speedNow = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; + string heldAfter = boss.GetActiveItem()?.info.shortname ?? "none"; + Check("s6.boss.phase3", _bossEvents.Contains("phase3@0.25") && heldAfter == "pistol.revolver" && Mathf.Approximately(speedNow, baseSpeed * 1.5f) && + Mathf.Approximately(((HumanNPC)boss).aimConeScale, 4f), + $"held {heldBefore} → {heldAfter}, speed {baseSpeed:0.0} → {speedNow:0.0}, aimConeScale {((HumanNPC)boss).aimConeScale}, senseRange {sci?.Brain?.SenseRange}"); + Check("s6.boss.phase3.ranges", sci?.Brain != null && Mathf.Approximately(sci.Brain.SenseRange, 50f) && Mathf.Approximately(sci.Brain.TargetLostRange, 60f), + $"senseRange {sci?.Brain?.SenseRange}, targetLostRange {sci?.Brain?.TargetLostRange}"); + db = Describe(boss); + Check("s6.boss.listedPhase", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 3, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); + + // A phase changes the boss's own copy of its profile, never the shared one. + BasePlayer second = Spawn(OnMesh(b + new Vector3(8, 0, 0)), "t_boss"); + yield return new WaitForSeconds(0.5f); + Check("s6.boss.ownProfile", second != null && Mathf.Approximately(((NPCPlayer)second).damageScale, 1f) && Mathf.Approximately(((HumanNPC)second).aimConeScale, 2f), + second != null ? $"a second t_boss: damageScale {((NPCPlayer)second).damageScale}, aimConeScale {((HumanNPC)second).aimConeScale}" : "not spawned"); + if (second != null) Api("RunicNpc_Despawn", Id(second)); + + // The kill: the death hook with every player who hurt it; the adds fight on (D287). + ulong bossId = Id(boss); + boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f)); + yield return new WaitForSeconds(1f); + Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f, + $"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}"); + int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam"); + Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)"); + // An event's teardown despawns what its step placed, by net id: the dead boss's id takes its adds with it. + int removed = Api("RunicNpc_Despawn", bossId); + int addsAfter = List(Owner).Count(d => (string)d["profile"] == "t_roam"); + Check("s6.boss.despawnTakesAdds", removed == 2 && addsAfter == 0, $"RunicNpc_Despawn(the dead boss's id) removed {removed}; adds left {addsAfter}"); + Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), ""); + + // ---- the examples (D280, D284, D289) ---- + Plugin kits = plugins.Find("Kits"); + string[] exampleKits = { "rnpc_raider", "rnpc_campguard", "rnpc_sniper", "rnpc_juggernaut" }; + string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray(); + Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing)); + JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); + Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "kits.runicnpc"), + "each needs kits.runicnpc, which nobody is granted"); + JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state"); + Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None)); + // D329: once Kits knows the examples, no reload is owed (a state from before D329 has no list at all). + JArray owed = state?["kitsReloadOwed"] as JArray; + Check("s6.examples.reloadSettled", owed == null || owed.Count == 0, "kitsReloadOwed: " + (owed?.ToString(Formatting.None) ?? "absent")); + JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; + int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count; + Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); + // D324: every item is written at this Rust's full durability for it, 0 where it has none. + var worn = new List(); + foreach (string k in exampleKits) + foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" }) + foreach (JToken it in (kitFile?["_kits"]?[k]?[list] as JArray) ?? new JArray()) + { + ItemDefinition def = ItemManager.FindItemDefinition((string)it["Shortname"]); + float want = def != null && def.condition.enabled ? def.condition.max : 0f; + if ((float)it["Condition"] != want || (float)it["MaxCondition"] != want) + worn.Add($"{k}/{it["Shortname"]} {it["Condition"]}/{it["MaxCondition"]} (want {want})"); + } + Check("s6.examples.fullCondition", worn.Count == 0, worn.Count == 0 ? "every item at its full durability" : string.Join("; ", worn.ToArray())); + } + + // ---- stage 7: loot (D290–D301) ---- + + private static JObject LootTable(string start, string crate = null, float? corpseSeconds = null, float hack = 900f) => + JObject.FromObject(new + { + start, + always = new object[] + { + new { item = "sewingkit", min = 10, max = 20, chance = 1f }, + new { item = "tarp", min = 1, max = 1, chance = 0.5f } + }, + pool = new + { + pick = 1, + nothing = 50f, + rows = new object[] + { + new { item = "propanetank", min = 5, max = 5, weight = 25f }, + new { item = "semibody", min = 1, max = 2, weight = 25f } + } + }, + crate, + hackSeconds = hack, + corpseSeconds + }); + + private static void Stage7Profiles(JObject all) + { + JObject P(string name, JObject loot) + { + JObject p = Prof(name: name); + p["loot"] = loot; + return p; + } + + all["t_loot"] = P("TLoot", LootTable("rust")); + all["t_loot100"] = P("TLoot100", LootTable("none")); + all["t_lootkit"] = P("TLootKit", JObject.FromObject(new { start = "kit" })); + all["t_lootnone"] = P("TLootNone", JObject.FromObject(new { start = "none", always = new object[] { new { item = "scrap", min = 7, max = 7, chance = 1f } } })); + all["t_lootcrate"] = P("TLootCrate", LootTable("rust", "military")); + all["t_lootlocked"] = P("TLootLocked", LootTable("rust", "locked", null, 10f)); + all["t_lootgone"] = P("TLootGone", LootTable("rust", null, 0f)); + all["t_lootfive"] = P("TLootFive", LootTable("rust", null, 5f)); + } + + private static int CountIn(LootableCorpse c, string item) => + c == null || c.containers == null ? 0 : c.containers.Sum(x => x.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount)); + + private static int CountIn(ItemContainer c, string item) => c == null ? 0 : c.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount); + + /// Kills n NPCs of a profile in a row, then finds each one's corpse where it died. + private IEnumerator KillMany(Vector3 at, string profile, int n, JObject overrides, List corpses, List spots) + { + BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 25)), 81); + var npcs = new List(); + for (int i = 0; i < n; i++) + { + BasePlayer npc = null; + Vector3 spot = at + new Vector3((i % 10) * 4f, 0, (i / 10) * 4f); + for (int tryAt = 0; tryAt < 6 && npc == null; tryAt++) + npc = Spawn(OnMesh(spot + new Vector3(tryAt * 1.5f, 0, -tryAt * 1.5f)), profile, overrides); + if (npc != null) npcs.Add(npc); + if (i % 5 == 4) yield return null; + } + + yield return new WaitForSeconds(1f); + foreach (BasePlayer npc in npcs) + { + if (Gone(npc)) continue; + spots.Add(npc.transform.position); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + } + + yield return new WaitForSeconds(1f); + var taken = new HashSet(); + foreach (Vector3 s in spots) + { + var near = new List(); + Vis.Entities(s, 2f, near); + NPCPlayerCorpse c = near.Where(x => !taken.Contains(x)).OrderBy(x => (x.transform.position - s).sqrMagnitude).FirstOrDefault(); + if (c != null) taken.Add(c); + corpses.Add(c); + } + } + + private IEnumerator Stage7() + { + Vector3 f; + if (!FindField(300f, 2028, out f)) + { + Check("s7.setup.field", false, "no open field on the navmesh"); + yield break; + } + + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- refusals, through a spawn's overrides ---- + Func refused = json => Spawn(f, "t_loot", JObject.Parse(json)) == null; + Check("s7.refuse.start", refused("{\"loot\":{\"start\":\"some\"}}"), "start rust, kit or none"); + Check("s7.refuse.item", refused("{\"loot\":{\"always\":[{\"item\":\"nope.item\",\"chance\":1}]}}"), "an item this Rust has"); + Check("s7.refuse.chance", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"chance\":0}]}}"), "chance above 0"); + Check("s7.refuse.minMax", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"min\":5,\"max\":2}]}}"), "max at least min"); + Check("s7.refuse.pick", refused("{\"loot\":{\"pool\":{\"pick\":3}}}"), "pick no more than the pool's rows"); + Check("s7.refuse.crate", refused("{\"loot\":{\"crate\":\"barrel\"}}"), "wooden, military, elite or locked"); + Check("s7.refuse.slots", refused("{\"loot\":{\"crate\":\"wooden\",\"always\":[{\"item\":\"scrap\"},{\"item\":\"rope\"},{\"item\":\"gears\"},{\"item\":\"cloth\"},{\"item\":\"wood\"},{\"item\":\"stones\"}],\"pool\":{\"pick\":1}}}"), "6 always + 1 picked > a wooden box's 6 slots"); + Check("s7.refuse.hack", refused("{\"loot\":{\"hackSeconds\":0}}"), "hackSeconds 1 to 86400"); + Api("RunicNpc_DespawnOwner", Owner); + + // ---- 100 kills against the table (D291) ---- + var corpses = new List(); + var spots = new List(); + for (int batch = 0; batch < 5; batch++) + { + IEnumerator k = KillMany(OnMesh(f + new Vector3(-60, 0, -40 + batch * 10)), "t_loot100", 20, null, corpses, new List()); + while (k.MoveNext()) yield return k.Current; + } + + var found = corpses.Where(c => c != null).ToList(); + int scrap = found.Count(c => CountIn(c, "sewingkit") >= 10 && CountIn(c, "sewingkit") <= 20); + int rope = found.Count(c => CountIn(c, "tarp") > 0); + int metal = found.Count(c => CountIn(c, "propanetank") == 5); + int gears = found.Count(c => CountIn(c, "semibody") > 0); + int both = found.Count(c => CountIn(c, "propanetank") > 0 && CountIn(c, "semibody") > 0); + int rustLoot = found.Count(c => c.containers[0].itemList.Any(i => i.info.shortname != "sewingkit" && i.info.shortname != "tarp" && i.info.shortname != "propanetank" && i.info.shortname != "semibody")); + Check("s7.table.corpses", corpses.Count >= 60 && found.Count >= corpses.Count - 2, $"{corpses.Count} of 100 spawned and killed, {found.Count} corpses found"); + Check("s7.table.always", scrap == found.Count, $"sewing kits 10-20 (chance 1): in {scrap}/{found.Count}"); + Check("s7.table.chance", Math.Abs((double)rope / Math.Max(1, found.Count) - 0.5) <= Math.Sqrt(0.25 / Math.Max(1, found.Count)) * 3.5, $"tarp (chance 0.5): in {rope}/{found.Count}"); + // Each row 25%: within 3.5 standard deviations for this many kills. + double sd = Math.Sqrt(0.25 * 0.75 / Math.Max(1, found.Count)) * 3.5; + Func near25 = x => Math.Abs((double)x / Math.Max(1, found.Count) - 0.25) <= sd; + Check("s7.table.poolWeights", near25(metal) && near25(gears), + $"pool pick 1 (nothing 50, propane 25, semi body 25): propane in {metal}, semi body in {gears}, of {found.Count}"); + Check("s7.table.poolCap", both == 0, $"both pool rows in one corpse: {both} (pick 1 is the cap)"); + Check("s7.table.onlyTable", rustLoot == 0, $"start none: nothing but the table in {found.Count - rustLoot}/{found.Count}"); + Api("RunicNpc_DespawnOwner", Owner); + foreach (NPCPlayerCorpse c in found) if (c != null && !c.IsDestroyed) c.Kill(); + + // ---- the starts (D290, D300) ---- + var one = new List(); + var at = new List(); + IEnumerator k2 = KillMany(OnMesh(f + new Vector3(20, 0, -40)), "t_lootkit", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse kit = one.FirstOrDefault(); + var dropped = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, dropped); + Note("s7.start.kit dropped near it: " + string.Join(", ", dropped.Select(d => d.item != null ? d.item.info.shortname + "×" + d.item.amount : "?").ToArray())); + Check("s7.start.kit", kit != null && CountIn(kit, "pistol.revolver") == 1 && CountIn(kit, "ammo.pistol") > 0 && kit.containers.Length > 1 && kit.containers[1].itemList.Count > 0, + $"kit start: {CorpseContents(kit)}"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(30, 0, -40)), "t_lootnone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse none = one.FirstOrDefault(); + Check("s7.start.none", none != null && none.containers.Sum(x => x.itemList.Count) == 1 && CountIn(none, "scrap") == 7, + $"nothing, then the table's 7 scrap, clothes off too (D300): {CorpseContents(none)}"); + + // ---- crates (D292, D297, D301) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(40, 0, -40)), "t_lootcrate", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + var crates = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + LootContainer mil = crates.FirstOrDefault(x => x.ShortPrefabName == "crate_normal"); + Check("s7.crate.military", mil != null && CountIn(mil.inventory, "sewingkit") >= 10 && CountIn(one.FirstOrDefault(), "sewingkit") == 0, + mil != null ? $"a military crate beside it: {Contents(mil.inventory)}; corpse sewing kits {CountIn(one.FirstOrDefault(), "sewingkit")}" : "no crate"); + if (mil != null) _built.Add(mil); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(50, 0, -40)), "t_lootlocked", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + crates.Clear(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + HackableLockedCrate locked = crates.OfType().FirstOrDefault(); + if (locked != null) + { + _built.Add(locked); + Check("s7.crate.lockedNotStarted", !locked.IsBeingHacked() && !locked.IsFullyHacked(), "it waits for a player to start the hack"); + locked.StartHacking(); // as RPC_Hack does for a player + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < 25 && !locked.IsFullyHacked()) + yield return new WaitForSeconds(0.5f); + Check("s7.crate.lockedTimer", locked.IsFullyHacked() && sw.Elapsed.TotalSeconds < 15, $"hackSeconds 10: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, holding {Contents(locked.inventory)}"); + } + else Check("s7.crate.lockedNotStarted", false, "no locked crate"); + + // ---- the corpse's time (D294) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(60, 0, -40)), "t_lootgone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + Check("s7.corpse.none", one.FirstOrDefault() == null, "corpseSeconds 0: no corpse a second later"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(35, 0, -30)), "t_lootfive", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse five = one.FirstOrDefault(); + bool fiveFound = !ReferenceEquals(five, null) && !five.IsDestroyed; + if (five == null) + { + var near10 = new List(); + if (at.Count > 0) Vis.Entities(at[0], 10f, near10); + Note($"s7.corpse.seconds: spots {at.Count}, corpses within 10 m: " + string.Join(", ", near10.Select(x => x.ShortPrefabName + " " + Vector3.Distance(x.transform.position, at[0]).ToString("0.0") + " m").ToArray())); + } + yield return new WaitForSeconds(5f); + // A destroyed Unity object compares equal to null, so whether one was found is kept apart. + Check("s7.corpse.seconds", fiveFound && (ReferenceEquals(five, null) || five.IsDestroyed), fiveFound ? $"corpseSeconds 5: found a second after death, gone 6 s after" : "no corpse at all"); + + // ---- an event's switch (D296) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(80, 0, -40)), "t_loot", 1, JObject.Parse("{\"loot\":{\"dropTable\":false}}"), one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse off = one.FirstOrDefault(); + Check("s7.dropTable.off", off != null && CountIn(off, "sewingkit") == 0, $"dropTable false: no table: {CorpseContents(off)}"); + Check("s7.start.rust", off != null && off.containers[0].itemList.Count > 0 && off.containers[1].itemList.Count > 0, $"start rust: Rust's scientist loot and the clothes it wore: {CorpseContents(off)}"); + } + + // ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ---- + + private int _corpsePopulated; + private NPCPlayerCorpse _lastCorpse; + + /// Counts the framework hook for an NPC corpse being filled, for our NPC (stage 7 spike). + private void OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse) + { + if (npc == null || npc.GetType().Name != "RunicNpcPlayer") return; + _corpsePopulated++; + _lastCorpse = corpse; + } + + private static string Contents(ItemContainer c) => + c == null ? "-" : string.Join(", ", c.itemList.Select(i => $"{i.info.shortname}×{i.amount}").ToArray()); + + private static string CorpseContents(LootableCorpse c) => + c == null || c.containers == null ? "-" : string.Join(" | ", c.containers.Select(Contents).ToArray()); + + /// rnt.spike7 [part,...]: corpse, crates, locked, removal, or all. + [ConsoleCommand("rnt.spike7")] + private void CmdSpike7(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string part = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Spike7(part)); + arg.ReplyWith($"rnt: spike7 {part}"); + } + + private IEnumerator Spike7(string part) + { + _pass = _fail = 0; + Note($"spike7 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}"); + SetTestProfiles(); + Vector3 f; + if (!FindField(300f, 2027, out f)) + { + Check("s7.setup.field", false, "no open field on the navmesh"); + yield break; + } + + string[] parts = part == "all" ? new[] { "corpse", "crates", "locked", "removal" } : part.Split(','); + foreach (string p in parts) + { + StandIn(f + new Vector3(0, 0, 60), 50); + IEnumerator inner = p == "corpse" ? CorpseSpike(f) + : p == "crates" ? CratesSpike(f) + : p == "locked" ? LockedSpike(f) + : p == "removal" ? RemovalSpike(f) + : null; + if (inner == null) { Check("s7.setup.part", false, "no part " + p); continue; } + while (inner.MoveNext()) yield return inner.Current; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + yield return new WaitForSeconds(1f); + } + + Note($"done spike7 {part}: {_pass} pass, {_fail} fail"); + _running = null; + } + + /// Kills one t_roam as a stand-in and returns its corpse (found near where it died). + private IEnumerator KillForCorpse(Vector3 at, Action before, Action got) + { + BasePlayer npc = Spawn(at, "t_roam"); + yield return new WaitForSeconds(1f); + if (npc == null) { got(null, 0); yield break; } + before?.Invoke(npc); + BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 20)), 80); + _lastCorpse = null; + Vector3 where = npc.transform.position; + var sw = System.Diagnostics.Stopwatch.StartNew(); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + double ms = sw.Elapsed.TotalMilliseconds; + yield return new WaitForSeconds(1f); + NPCPlayerCorpse corpse = _lastCorpse; + if (corpse == null) + { + var near = new List(); + Vis.Entities(where, 5f, near); + corpse = near.FirstOrDefault(); + } + + got(corpse, ms); + } + + private IEnumerator CorpseSpike(Vector3 f) + { + var applyLoot = typeof(HumanNPC).GetMethod("ApplyLoot", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var createCorpse = typeof(NPCPlayer).GetMethod("CreateCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var copyField = typeof(NPCPlayer).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? typeof(HumanNPC).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var copyProp = copyField == null ? typeof(NPCPlayer).GetProperty("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null; + Note($"s7.corpse ApplyLoot: {(applyLoot == null ? "none" : (applyLoot.IsVirtual ? "virtual" : "not virtual") + " " + (applyLoot.IsFamily ? "protected" : applyLoot.IsPublic ? "public" : "private"))}; " + + $"CreateCorpse virtual: {createCorpse?.IsVirtual}; CopyInventoryToCorpse: {(copyField != null ? "field" : copyProp != null ? "property, settable " + (copyProp.GetSetMethod(true) != null) + ", virtual get " + copyProp.GetGetMethod(true)?.IsVirtual : "none")}"); + Check("s7.corpse.applyLootOverridable", applyLoot != null && applyLoot.IsVirtual, "a subclass can fill its own corpse"); + + // 1. As today: Rust's scientist loot. + NPCPlayerCorpse c1 = null; + double ms1 = 0; + _corpsePopulated = 0; + IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-40, 0, 0)), null, (c, ms) => { c1 = c; ms1 = ms; }); + while (k.MoveNext()) yield return k.Current; + Check("s7.corpse.hook", _corpsePopulated > 0, $"OnCorpsePopulate raised {_corpsePopulated} time(s) for our NPC"); + Check("s7.corpse.default", c1 != null, $"Rust's scientist loot: {CorpseContents(c1)}; removal in {(c1 != null ? c1.GetRemovalTime() : -1):0} s; death took {ms1:0.0} ms"); + + // 2. The kit: Rust copies the inventory when CopyInventoryToCorpse is true. + NPCPlayerCorpse c2 = null; + k = KillForCorpse(OnMesh(f + new Vector3(-30, 0, 0)), npc => + { + if (copyField != null) copyField.SetValue(npc, true); + else if (copyProp != null && copyProp.GetSetMethod(true) != null) copyProp.SetValue(npc, true, null); + }, (c, ms) => c2 = c); + while (k.MoveNext()) yield return k.Current; + bool kit = c2 != null && c2.containers != null && c2.containers.Any(x => x.itemList.Any(i => i.info.shortname == "pistol.revolver")); + Check("s7.corpse.kit", kit, $"with CopyInventoryToCorpse on: {CorpseContents(c2)}"); + + // 3. Nothing, then a table added: emptied in the hook's place, then filled. + NPCPlayerCorpse c3 = null; + k = KillForCorpse(OnMesh(f + new Vector3(-20, 0, 0)), null, (c, ms) => c3 = c); + while (k.MoveNext()) yield return k.Current; + if (c3 != null) + { + foreach (ItemContainer cont in c3.containers) cont.Clear(); + ItemManager.DoRemoves(); + Item scrap = ItemManager.CreateByName("scrap", 150); + bool moved = scrap != null && scrap.MoveToContainer(c3.containers[0]); + Check("s7.corpse.emptyThenFill", moved && c3.containers[0].itemList.Count == 1, $"emptied, then the table's scrap: {CorpseContents(c3)}"); + } + else Check("s7.corpse.emptyThenFill", false, "no corpse"); + } + + private static readonly string[][] Crates = + { + new[] { "wooden box", "assets/bundled/prefabs/radtown/crate_normal_2.prefab" }, + new[] { "military crate", "assets/bundled/prefabs/radtown/crate_normal.prefab" }, + new[] { "elite crate", "assets/bundled/prefabs/radtown/crate_elite.prefab" }, + new[] { "locked crate", "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab" }, + }; + + private IEnumerator CratesSpike(Vector3 f) + { + int i = 0; + foreach (string[] c in Crates) + { + Vector3 at = OnMesh(f + new Vector3(-40 + i * 6, 0, 10)); + i++; + BaseEntity made = GameManager.server.CreateEntity(c[1], at); + LootContainer box = made as LootContainer; + if (box == null) + { + Check("s7.crate." + c[0].Replace(" ", ""), false, made == null ? "no entity from " + c[1] : made.GetType().Name + " is not a LootContainer"); + if (made != null) made.Kill(); + continue; + } + + // Before it spawns: no loot of Rust's, never refilled. + box.initialLootSpawn = false; + box.minSecondsBetweenRefresh = -1f; + box.maxSecondsBetweenRefresh = -1f; + box.enableSaving = false; + box.Spawn(); + _built.Add(box); + yield return new WaitForSeconds(0.5f); + int before = box.inventory?.itemList.Count ?? -1; + Item scrap = ItemManager.CreateByName("scrap", 200); + bool moved = scrap != null && scrap.MoveToContainer(box.inventory); + yield return new WaitForSeconds(1f); + Check("s7.crate." + c[0].Replace(" ", ""), before == 0 && moved && box.inventory.itemList.Count == 1, + $"{box.GetType().Name} '{box.ShortPrefabName}': {before} item(s) of Rust's at spawn, then {Contents(box.inventory)}; capacity {box.inventory?.capacity}, refreshes {box.shouldRefreshContents}, destroyOnEmpty {box.destroyOnEmpty}"); + } + } + + private static readonly FieldInfo HackSecondsField = typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + private IEnumerator LockedSpike(Vector3 f) + { + Note($"s7.locked server-wide: requiredHackSeconds {HackableLockedCrate.requiredHackSeconds}, decaySeconds {HackableLockedCrate.decaySeconds}; hackSeconds field {(HackSecondsField != null ? "found" : "MISSING")}"); + foreach (float want in new[] { 10f, 20f }) + { + Vector3 at = OnMesh(f + new Vector3(20 + want, 0, 10)); + var crate = GameManager.server.CreateEntity(Crates[3][1], at) as HackableLockedCrate; + if (crate == null || HackSecondsField == null) { Check("s7.locked." + want, false, "no crate or no field"); yield break; } + crate.initialLootSpawn = false; + crate.enableSaving = false; + crate.Spawn(); + _built.Add(crate); + // A per-crate timer: start its count where `want` seconds remain of the server-wide total. + HackSecondsField.SetValue(crate, HackableLockedCrate.requiredHackSeconds - want); + crate.StartHacking(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < want + 15 && !crate.IsFullyHacked()) + yield return new WaitForSeconds(0.5f); + Check("s7.locked." + want, crate.IsFullyHacked() && Mathf.Abs((float)sw.Elapsed.TotalSeconds - want) < 3f, + $"asked {want} s: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, lootable {crate.IsFullyHacked()}"); + } + + // Longer than Rust's own: the count starts below zero. Read where it stands after 3 s. + var slow = GameManager.server.CreateEntity(Crates[3][1], OnMesh(f + new Vector3(60, 0, 10))) as HackableLockedCrate; + if (slow != null) + { + slow.initialLootSpawn = false; + slow.enableSaving = false; + slow.Spawn(); + _built.Add(slow); + HackSecondsField.SetValue(slow, HackableLockedCrate.requiredHackSeconds - 1800f); + slow.StartHacking(); + yield return new WaitForSeconds(3f); + float now = (float)HackSecondsField.GetValue(slow); + Check("s7.locked.longer", !slow.IsFullyHacked() && now < 0f, $"asked 1800 s: count at {now:0} after 3 s (counts up to {HackableLockedCrate.requiredHackSeconds}); the client is sent this count, so its timer shows from a negative start"); + } + } + + private IEnumerator RemovalSpike(Vector3 f) + { + NPCPlayerCorpse c = null; + IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-10, 0, 0)), null, (x, ms) => c = x); + while (k.MoveNext()) yield return k.Current; + if (c == null) { Check("s7.removal.timed", false, "no corpse"); yield break; } + float rust = c.GetRemovalTime(); + c.ResetRemovalTime(5f); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < 15 && c != null && !c.IsDestroyed) + yield return new WaitForSeconds(0.5f); + Check("s7.removal.timed", c == null || c.IsDestroyed, $"Rust's own time {rust:0} s; set to 5 s: gone after {sw.Elapsed.TotalSeconds:0.0} s"); + + NPCPlayerCorpse c2 = null; + k = KillForCorpse(OnMesh(f + new Vector3(0, 0, 0)), null, (x, ms) => c2 = x); + while (k.MoveNext()) yield return k.Current; + if (c2 != null) c2.Kill(); + yield return null; + Check("s7.removal.none", c2 == null || c2.IsDestroyed, "a corpse killed at once is gone"); + } + + // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- + + private const string SwapKit = "rnt6smg"; + private const string CooldownKit = "rnt6cd"; + private BaseCombatEntity _spikeTarget; + private readonly Dictionary _spikeHits = new Dictionary(); + + private static bool IsCarbon() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")); + + private Plugin K => plugins.Find("Kits"); + + private int SpikeHits(string weapon) + { + int n; + return _spikeHits.TryGetValue(weapon, out n) ? n : 0; + } + + private static string Held(BasePlayer npc) => Gone(npc) ? "-" : npc.GetActiveItem()?.info.shortname ?? "none"; + + /// + /// rnt.spike6 [part,...]: stage 6's measurements, one part or all in order: + /// use, bar, kits, swap, adds, unkits. swap needs the + /// kit kits writes; unkits takes both spike kits out of Kits' file again. + /// + [ConsoleCommand("rnt.spike6")] + private void CmdSpike6(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string part = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Spike6(part)); + arg.ReplyWith($"rnt: spike6 {part}"); + } + + private IEnumerator Spike6(string part) + { + _pass = _fail = 0; + Note($"spike6 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, Kits {K?.Version}"); + SetTestProfiles(); + Vector3 f; + if (!FindField(300f, 2025, out f)) + { + Check("s6.setup.field", false, "no open field on the navmesh"); + yield break; + } + + string[] parts = part == "all" ? new[] { "use", "bar", "kits", "swap", "adds", "unkits" } : part.Split(','); + foreach (string p in parts) + { + StandIn(f + new Vector3(0, 0, 60), 50); + IEnumerator inner = p == "use" ? UseSpike(f) + : p == "bar" ? BarSpike(f) + : p == "kits" ? KitsSpike(f) + : p == "swap" ? SwapSpike(f) + : p == "adds" ? AddsSpike(f) + : p == "unkits" ? UnkitsSpike() + : null; + if (inner == null) + { + Check("s6.setup.part", false, "no part " + p); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + _spikeTarget = null; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + yield return new WaitForSeconds(1f); + } + + Note($"done spike6 {part}: {_pass} pass, {_fail} fail"); + _running = null; + } + + // ---- press E (D278) ---- + + private IEnumerator UseSpike(Vector3 f) + { + Vector3 at = OnMesh(f + new Vector3(30, 0, -60)); + BasePlayer npc = Spawn(at, "t_sentry"); + yield return new WaitForSeconds(1.5f); + Check("s6.use.spawned", !Gone(npc), npc != null ? npc.GetType().Name : "null"); + if (Gone(npc)) yield break; + + string cols = string.Join("; ", npc.GetComponentsInChildren(true) + .Select(c => $"{c.GetType().Name} '{c.name}' layer={LayerMask.LayerToName(c.gameObject.layer)} enabled={c.enabled} trigger={c.isTrigger}").ToArray()); + Note($"s6.use npc layer={LayerMask.LayerToName(npc.gameObject.layer)} colliders: {cols}"); + + int playerMask = LayerMask.GetMask("Player (Server)"); + int broadMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + BasePlayer p = StandIn(at + new Vector3(2f, 0, 0), 61); + int hitsPlayerMask = 0, hitsBroad = 0, rays = 0; + foreach (float dist in new[] { 1.5f, 2.5f, 3.5f }) + { + MoveStandIn(p, at + new Vector3(dist, 0, 0)); + yield return new WaitForSeconds(0.5f); + if (Gone(npc)) break; + foreach (KeyValuePair aim in new Dictionary + { + ["head"] = npc.eyes.position, ["chest"] = npc.CenterPoint(), ["legs"] = npc.transform.position + Vector3.up * 0.4f + }) + { + Vector3 eye = p.eyes.position; + Vector3 dir = (aim.Value - eye).normalized; + RaycastHit h1, h2; + bool a = Physics.Raycast(eye, dir, out h1, 5f, playerMask, QueryTriggerInteraction.Ignore) && h1.GetEntity() == npc; + bool b = Physics.Raycast(eye, dir, out h2, 5f, broadMask, QueryTriggerInteraction.Ignore) && h2.GetEntity() == npc; + rays++; + if (a) hitsPlayerMask++; + if (b) hitsBroad++; + Note($"s6.use ray {dist} m at {aim.Key}: player-mask {(a ? $"HIT '{h1.collider.name}' at {h1.distance:0.00}" : "miss " + (h1.collider != null ? h1.collider.name : "-"))}, broad {(b ? $"HIT at {h2.distance:0.00}" : "miss " + (h2.collider != null ? $"{h2.collider.name}/{h2.GetEntity()?.ShortPrefabName}" : "-"))}"); + } + } + + Check("s6.use.ray", hitsBroad == rays, $"a ray from a player's eyes reaches our NPC: {hitsBroad}/{rays} with the world in the mask, {hitsPlayerMask}/{rays} on Player (Server) alone"); + + // What OnPlayerInput costs per player tick when E is not pressed, and one use ray when it is. + var input = new InputState(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + int pressed = 0; + for (int i = 0; i < 1000000; i++) + if (input.WasJustPressed(BUTTON.USE)) pressed++; + double perTickNs = sw.Elapsed.TotalMilliseconds * 1e6 / 1000000; + sw.Restart(); + int found = 0; + for (int i = 0; i < 10000; i++) + { + RaycastHit h; + if (Physics.Raycast(p.eyes.position, (npc.CenterPoint() - p.eyes.position).normalized, out h, 3f, broadMask, QueryTriggerInteraction.Ignore)) found++; + } + + double perRayUs = sw.Elapsed.TotalMilliseconds * 1000 / 10000; + Check("s6.use.cost", perTickNs < 1000 && perRayUs < 100, $"OnPlayerInput's not-pressed test {perTickNs:0.0} ns a tick; one 3 m use ray {perRayUs:0.00} µs ({found} hit)"); + } + + // ---- the health bar (D274) ---- + + private IEnumerator BarSpike(Vector3 f) + { + Type el = typeof(CuiElement); + MemberInfo upd = (MemberInfo)el.GetProperty("Update") ?? el.GetField("Update"); + Note("s6.bar CuiElement: " + string.Join(", ", el.GetProperties().Select(x => x.Name).Concat(el.GetFields().Select(x => x.Name)).ToArray())); + Check("s6.bar.updateMember", upd != null, upd != null ? $"CuiElement.{upd.Name} ({upd.MemberType})" : "no Update: every redraw is destroy + add"); + + Func> build = (frac, text, fillOnly) => + { + var c = new CuiElementContainer(); + if (!fillOnly) + c.Add(new CuiPanel { Image = { Color = "0 0 0 0.6" }, RectTransform = { AnchorMin = "0.35 0.9", AnchorMax = "0.65 0.93" } }, "Hud", "rnpc.bar"); + c.Add(new CuiPanel { Image = { Color = "0.75 0.1 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{frac:0.###} 1" } }, "rnpc.bar", "rnpc.bar.fill"); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 12, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, "rnpc.bar", "rnpc.bar.text"); + if (fillOnly && upd != null) + foreach (CuiElement e in c) + { + if (upd is PropertyInfo) ((PropertyInfo)upd).SetValue(e, true, null); + else ((FieldInfo)upd).SetValue(e, true); + } + + return c; + }; + + string full = CuiHelper.ToJson(build(0.73f, "Juggernaut 1825 / 2500", false)); + string fill = CuiHelper.ToJson(build(0.72f, "Juggernaut 1800 / 2500", true)); + Note("s6.bar full json: " + full); + Note("s6.bar update json: " + fill); + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", false)); + double fullUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + sw.Restart(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", true)); + double fillUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + Check("s6.bar.build", fullUs < 500 && fillUs < 500, + $"whole bar {full.Length} B in {fullUs:0.0} µs; update {fill.Length} B in {fillUs:0.0} µs, contains update={fill.Contains("\"update\"")}"); + + // Who sees it: 40 players scattered over 150 m, the ones within 100 m found by one sphere query. + var rng = new System.Random(6); + var crowd = new List(); + for (int i = 0; i < 40; i++) + { + float a = (float)(rng.NextDouble() * Math.PI * 2), r = (float)(rng.NextDouble() * 150); + BasePlayer s = StandIn(OnMesh(f + new Vector3(Mathf.Cos(a) * r, 0, Mathf.Sin(a) * r)), 70 + i); + if (s != null) crowd.Add(s); + } + + yield return new WaitForSeconds(1f); + int within = crowd.Count(s => Vector3.Distance(s.transform.position, f) <= 100f); + var found = new List(); + sw.Restart(); + for (int i = 0; i < 1000; i++) + { + found.Clear(); + Vis.Entities(f, 100f, found, LayerMask.GetMask("Player (Server)"), QueryTriggerInteraction.Ignore); + } + + double visUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + int seen = found.Count(s => crowd.Contains(s)); + sw.Restart(); + int byLoop = 0; + for (int i = 0; i < 1000; i++) + { + byLoop = 0; + foreach (BasePlayer s in crowd) + if ((s.transform.position - f).sqrMagnitude <= 100f * 100f) byLoop++; + } + + double loopUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + Check("s6.bar.viewers", seen == within && byLoop == within, + $"{within} of 40 within 100 m: sphere query found {seen} in {visUs:0.0} µs, a distance loop over the list {byLoop} in {loopUs:0.0} µs"); + bool sent = CuiHelper.AddUi(crowd[0], full); + Note($"s6.bar AddUi to a player with no connection returns {sent} (sending is not measured here: no client)"); + } + + // ---- Kits' data file (D280) ---- + + private static JObject KitItem(string shortname, int amount, int position, float condition = 0f) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject SpikeKit(string name, int cooldown, int maxUses, JObject[] main, JObject[] wear, JObject[] belt) => + new JObject + { + ["Name"] = name, ["Description"] = "RunicNPC stage 6 spike (remove after)", ["RequiredPermission"] = "", + ["MaximumUses"] = maxUses, ["RequiredAuth"] = 0, ["Cooldown"] = cooldown, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private JObject KitsFile() => Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); + + private double _reloadSeconds; + private bool _reloaded; + + /// Reloads Kits the way an operator would at the console (the bridge's way on Carbon, W3) and waits for the new instance. + private IEnumerator ReloadKits() + { + Plugin old = K, oldRunic = plugins.Find("RunicNPC"); + int npcsBefore = List(null).Count; + var sw = System.Diagnostics.Stopwatch.StartNew(); + ConsoleSystem.Run(ConsoleSystem.Option.Server, IsCarbon() ? "c.reload" : "oxide.reload", "Kits"); + _reloaded = false; + double kitsAt = -1, runicAt = -1; + while (sw.Elapsed.TotalSeconds < 60) + { + yield return new WaitForSeconds(0.25f); + Plugin now = K, runic = plugins.Find("RunicNPC"); + if (kitsAt < 0 && now != null && now != old && now.IsLoaded) kitsAt = sw.Elapsed.TotalSeconds; + // RunicNPC requires Kits (// Requires: Kits): both frameworks unload it with Kits and load it again. + if (runicAt < 0 && runic != null && runic != oldRunic && runic.IsLoaded) runicAt = sw.Elapsed.TotalSeconds; + // Carbon unloads it only after a fresh compile of RunicNPC; give it 20 s, then call it not reloaded. + if (kitsAt >= 0 && (runicAt >= 0 || sw.Elapsed.TotalSeconds > kitsAt + 20)) break; + } + + _reloaded = kitsAt >= 0; + _reloadSeconds = sw.Elapsed.TotalSeconds; + Note($"s6.kits reload: Kits back at {kitsAt:0.0} s, RunicNPC {(oldRunic == plugins.Find("RunicNPC") ? "NOT reloaded" : $"reloaded with it, back at {runicAt:0.0} s")}; RunicNPC's NPCs {npcsBefore} before, {List(null).Count} after"); + } + + /// Every player's uses of every kit, as Kits says now, for the ids in its player file. + private Dictionary Usage() + { + var usage = new Dictionary(); + JObject players = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"] as JObject; + if (players == null) return usage; + foreach (JProperty pl in players.Properties().Where(x => ulong.Parse(x.Name) < StandInId - 1000UL || ulong.Parse(x.Name) > StandInId + 1000UL)) + foreach (JProperty kit in ((pl.Value as JObject)?["_usageData"] as JObject ?? new JObject()).Properties()) + usage[pl.Name + "/" + kit.Name] = Convert.ToInt32(K?.Call("GetPlayerKitUses", ulong.Parse(pl.Name), kit.Name) ?? -1); + return usage; + } + + private IEnumerator KitsSpike(Vector3 f) + { + Check("s6.kits.loaded", K != null && K.IsLoaded, $"Kits {K?.Version}"); + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + Check("s6.kits.read", kits != null, kits != null ? $"{kits.Count} kit(s): {string.Join(", ", kits.Properties().Select(x => x.Name).ToArray())}" : "no _kits"); + if (kits == null) yield break; + int before = kits.Count; + Dictionary realBefore = Usage(); + // Two NPCs a plugin spawned (as an event's are), to see what a Kits reload does to them. + Spawn(OnMesh(f + new Vector3(-60, 0, 30)), "t_roam"); + Spawn(OnMesh(f + new Vector3(-60, 0, 36)), "t_roam"); + yield return new WaitForSeconds(1f); + + // Round 1: a kit with a cooldown and a use limit, so a claim leaves usage behind. + kits[CooldownKit] = SpikeKit(CooldownKit, 3600, 5, new[] { KitItem("bandage", 1, 0) }, new JObject[0], new JObject[0]); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + Check("s6.kits.notLive", !(K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit)), "Kits does not see a kit written to its file until it reloads"); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload1", _reloaded, $"{(IsCarbon() ? "c.reload" : "oxide.reload")} Kits: new instance in {_reloadSeconds:0.0} s"); + Check("s6.kits.added", K != null && K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit), ""); + + BasePlayer claimer = StandIn(OnMesh(new Vector3(0, 0, 0)), 98); + ulong cid = (ulong)claimer.userID; + // An earlier run's claim is in Kits' player file (saved when it unloaded): clear it first. + K?.Call("SetPlayerKitUses", cid, CooldownKit, 0); + K?.Call("SetPlayerKitCooldown", cid, CooldownKit, 0.0); + object claimed = K?.Call("TryClaimKit", claimer, CooldownKit, false); + int uses = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cd = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.claim", claimed is bool && (bool)claimed && uses == 1 && cd > 3500, $"a player claims it: {claimed ?? "null (no such hook)"}, uses {uses}, cooldown {cd:0} s — in Kits' memory, not yet saved"); + JObject onDisk = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"]?[cid.ToString()] as JObject; + Note($"s6.kits the claim on disk before the reload: {(onDisk != null ? "yes" : "no")}"); + + // Round 2: the swap's kit, written while a player's usage is only in memory. + file = KitsFile(); + kits = (JObject)file["_kits"]; + kits[SwapKit] = SpikeKit(SwapKit, 0, 0, new[] { KitItem("ammo.pistol", 120, 0) }, new[] { KitItem("hazmatsuit", 1, 0) }, new[] { KitItem("smg.mp5", 1, 0, 100f) }); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload2", _reloaded, $"second reload: new instance in {_reloadSeconds:0.0} s"); + var names = new List(); + K?.Call("GetKitNames", names); + Check("s6.kits.all", names.Count == before + 2 && names.Contains(SwapKit) && names.Contains(CooldownKit), $"{names.Count} kit(s) now ({before} before): {string.Join(", ", names.ToArray())}"); + int usesAfter = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cdAfter = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.usageKept", usesAfter == uses && cdAfter > cd - _reloadSeconds - 5 && cdAfter <= cd, $"the claim survived the reload: uses {uses}→{usesAfter}, cooldown {cd:0}→{cdAfter:0} s"); + Dictionary realAfter = Usage(); + Check("s6.kits.realUsageKept", realBefore.All(kv => realAfter.ContainsKey(kv.Key) && realAfter[kv.Key] == kv.Value), + realBefore.Count == 0 ? "no real players on file" : string.Join(", ", realBefore.Select(kv => $"{kv.Key} {kv.Value}→{(realAfter.ContainsKey(kv.Key) ? realAfter[kv.Key] : -1)}").ToArray())); + JObject fileAfter = KitsFile(); + Check("s6.kits.fileKept", fileAfter?["_kits"]?[SwapKit] != null && fileAfter?["_kits"]?[CooldownKit] != null, "Kits did not rewrite its file on load"); + Check("s6.kits.ref", Kits != null && Kits == K, "this plugin's [PluginReference] Kits follows the reload"); + } + + private IEnumerator UnkitsSpike() + { + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + if (kits == null) yield break; + kits.Remove(SwapKit); + kits.Remove(CooldownKit); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + bool gone = !(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit)); + Check("s6.unkits", _reloaded && gone, $"spike kits removed, reload {_reloadSeconds:0.0} s"); + } + + // ---- a phase's kit swap mid-fight (D275) ---- + + private IEnumerator SwapSpike(Vector3 f) + { + if (!(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit))) + { + Check("s6.swap.kit", false, "no " + SwapKit + ": run the kits part first"); + yield break; + } + + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(-30, 0, -60)), 15f, out at, out there)) + { + Check("s6.swap.spot", false, "no clear 15 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + if (boss == null || sci == null) + { + Check("s6.swap.spawned", false, $"boss {(boss != null ? "up" : "null")}, scientist {(sci != null ? "up" : "null")} at {at}; profiles refused: {Api("RunicNpc_Profiles")?["refused"]?.ToString(Formatting.None)}"); + yield break; + } + + _spikeTarget = sci; + _spikeHits.Clear(); + int t; + for (t = 1; t <= 30; t++) + { + yield return new WaitForSeconds(1f); + if (SpikeHits("pistol.revolver") >= 3 && TargetOf(Describe(boss)) != "none") break; + } + + string target = TargetOf(Describe(boss)); + Check("s6.swap.fighting", SpikeHits("pistol.revolver") >= 3, $"revolver hits {SpikeHits("pistol.revolver")} by {t} s, target {target}, hits {JsonConvert.SerializeObject(_spikeHits)}"); + if (Gone(boss) || Gone(sci)) yield break; + + // The swap, as a phase would make it: strip, give, equip — one frame. + object given = null; + string split = SwapTimed(boss, SwapKit, out given); + Check("s6.swap.given", !(given is string) && Held(boss) == "smg.mp5", $"given={given ?? "null"}, held {Held(boss)}, {split}"); + int rev = SpikeHits("pistol.revolver"); + bool lost = false; + for (t = 1; t <= 20; t++) + { + yield return new WaitForSeconds(1f); + if (TargetOf(Describe(boss)) == "none") lost = true; + if (SpikeHits("smg.mp5") >= 5) break; + } + + Check("s6.swap.keepsTarget", !lost, $"target before {target}, after {TargetOf(Describe(boss))}, lost for a second: {lost}"); + Check("s6.swap.newWeapon", SpikeHits("smg.mp5") > 0 && SpikeHits("pistol.revolver") == rev, $"MP5 hits {SpikeHits("smg.mp5")} in {t} s, revolver hits after the swap {SpikeHits("pistol.revolver") - rev}"); + + // And back, WITHOUT EquipWeapon: does Rust's brain pick the new weapon up by itself? + boss.inventory.Strip(); + K.Call("GiveKit", boss, Kit); + string heldNow = Held(boss); + int mp5 = SpikeHits("smg.mp5"), revBefore = SpikeHits("pistol.revolver"); + string heldLater = heldNow; + for (t = 1; t <= 15; t++) + { + yield return new WaitForSeconds(1f); + heldLater = Held(boss); + if (SpikeHits("pistol.revolver") > revBefore) break; + } + + Note($"s6.swap without EquipWeapon: held {heldNow} at once, {heldLater} after {t} s, revolver hits {SpikeHits("pistol.revolver") - revBefore}, MP5 hits {SpikeHits("smg.mp5") - mp5}"); + + // The same swap twice more, warm: is the first one's cost Kits' first call, or every swap's? + for (int i = 2; i <= 3 && !Gone(boss); i++) + { + Note($"s6.swap #{i} warm: {SwapTimed(boss, i == 2 ? SwapKit : Kit, out given)}, held {Held(boss)}"); + yield return new WaitForSeconds(2f); + } + } + + /// Strip, Kits' GiveKit, EquipWeapon, each timed: "strip a ms, give b ms, equip c ms, total d ms". + private string SwapTimed(BasePlayer npc, string kit, out object given) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + npc.inventory.Strip(); + double strip = sw.Elapsed.TotalMilliseconds; + given = K.Call("GiveKit", npc, kit); + double give = sw.Elapsed.TotalMilliseconds; + ((HumanNPC)npc).EquipWeapon(); + double all = sw.Elapsed.TotalMilliseconds; + return $"strip {strip:0.00} ms, give {give - strip:0.00} ms, equip {all - give:0.00} ms, total {all:0.00} ms"; + } + + // ---- summoned adds (D275) ---- + + private IEnumerator AddsSpike(Vector3 f) + { + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(60, 0, -40)), 20f, out at, out there)) + { + Check("s6.adds.spot", false, "no clear 20 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + _spikeTarget = sci; + _spikeHits.Clear(); + for (int w = 0; w < 20 && TargetOf(Describe(boss)) == "none"; w++) + yield return new WaitForSeconds(1f); + Check("s6.adds.bossFighting", TargetOf(Describe(boss)) != "none", "target " + TargetOf(Describe(boss))); + + foreach (int count in new[] { 4, 10 }) + { + var adds = new List(); + int offMesh = 0; + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < count; i++) + { + float a = i * Mathf.PI * 2f / count; + Vector3 ring = boss.transform.position + new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)) * 6f; + NavMeshHit hit; + if (!SampleNavmesh(ring, 3f, out hit)) + { + offMesh++; + continue; + } + + BasePlayer add = Spawn(hit.position, "t_hunter"); + if (add != null) adds.Add(add); + } + + double ms = sw.Elapsed.TotalMilliseconds; + yield return new WaitForSeconds(2f); + int onMesh = adds.Count(x => { NavMeshHit h; return !Gone(x) && SampleNavmesh(x.transform.position, 0.5f, out h); }); + int fighting = 0; + for (int w = 0; w < 20; w++) + { + fighting = adds.Count(x => !Gone(x) && TargetOf(Describe(x)) != "none"); + if (fighting == adds.Count) break; + yield return new WaitForSeconds(1f); + } + + Check($"s6.adds.{count}", adds.Count == count && onMesh == adds.Count && fighting == adds.Count, + $"{adds.Count}/{count} placed in {ms:0.0} ms ({ms / Math.Max(1, count):0.00} ms each, one frame), {offMesh} ring points off the navmesh, {onMesh} on the mesh after 2 s, {fighting} fighting the boss's target"); + foreach (BasePlayer x in adds) + if (!Gone(x)) Api("RunicNpc_Despawn", Id(x)); + yield return new WaitForSeconds(1f); + } + } + + /// A route, as RunicNpc_Routes returns it. + private sealed class Route + { + public List

points = new List

(); + public bool loop; + } + + private sealed class P + { + public float x, y, z; + public Vector3 V => new Vector3(x, y, z); + } + } +} diff --git a/tools/drill/drill.js b/tools/drill/drill.js new file mode 100644 index 0000000..d7bf19e --- /dev/null +++ b/tools/drill/drill.js @@ -0,0 +1,591 @@ +// The staging drill (docs/runicnpc/PLAN.md stage 9c, D307-D309, D317, D318). Never shipped. +// +// .gitea/workflows/staging-drill.yml runs it daily, and it does something only in the week before the +// monthly forced wipe (D318): +// +// node tools/drill/drill.js window [--today YYYY-MM-DD] in the drill week? +// tools/drill/static.sh the static half +// node tools/drill/drill.js gate --state --work

a build not checked yet? +// node tools/drill/drill.js live --work [--branch staging] the live half, on rust-staging +// node tools/drill/drill.js report --state --work one issue per build; the state +// +// A build is the manifest of Rust's Linux depot, as Steam names it to static.sh. +// +// Environment: PANEL_KEY (or PANEL_KEY_FILE, a file with a `user: ptlc_…` line) is the drill's own +// Pterodactyl client key; PANEL_URL the panel. The live half finds its servers by name: DRILL_RIG +// (rust-staging) and the two 6000-map rigs, of which it stops DRILL_STOPPABLE (rust-carbon) when both +// run, because the node has no memory for three (D309). It leaves every rig as it found it. +// `report` needs GITEA_TOKEN, and GITHUB_SERVER_URL + GITHUB_REPOSITORY as Gitea Actions sets them. +// +// The key is never printed, and neither is the server's console start line (it carries the RCON +// password): only lines this script picks are ever written out. + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..', '..') +const PANEL = (process.env.PANEL_URL || 'http://192.168.0.12').replace(/\/+$/, '') +const RIG = process.env.DRILL_RIG || 'rust-staging' +const BIG = (process.env.DRILL_BIG_RIGS || 'rust-oxide,rust-carbon').split(',') +const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon' +const KITS_URL = 'https://umod.org/plugins/Kits.cs' +const WAIT_DAYS = 3 // D317: how long a build waits for Oxide to catch up + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const now = () => new Date().toISOString() +const log = (...a) => console.log(`[drill ${now().slice(11, 19)}]`, ...a) + +function args() { + const out = { _: [] } + const argv = process.argv.slice(2) + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const k = argv[i].slice(2) + out[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true + } else out._.push(argv[i]) + } + return out +} + +const readJson = (f, fallback) => { + try { + return JSON.parse(fs.readFileSync(f, 'utf8')) + } catch { + return fallback + } +} + +function output(name, value) { + if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) +} + +// ---------------------------------------------------------------- the panel + +function panelKey() { + if (process.env.PANEL_KEY) return process.env.PANEL_KEY.trim() + const file = process.env.PANEL_KEY_FILE + if (!file) throw new Error('PANEL_KEY (or PANEL_KEY_FILE) is not set') + const line = fs.readFileSync(file, 'utf8').split(/\r?\n/).find((l) => l.startsWith('user:')) + if (!line) throw new Error(`${file} has no "user:" line`) + return line.slice(5).trim() +} + +function panel() { + const key = panelKey() + const headers = (type = 'application/json') => ({ Authorization: 'Bearer ' + key, Accept: 'application/json', 'Content-Type': type }) + const api = async (method, p, body, type) => { + const r = await fetch(`${PANEL}/api/client${p}`, { method, headers: headers(type), body }) + const text = await r.text() + if (!r.ok) throw new Error(`${method} ${p.split('?')[0]}: ${r.status} ${text.slice(0, 200)}`) + return text + } + + const p = { + async servers() { + const d = JSON.parse(await api('GET', '/?per_page=100')) + return Object.fromEntries(d.data.map((s) => [s.attributes.name, s.attributes.identifier])) + }, + async state(id) { + return JSON.parse(await api('GET', `/servers/${id}/resources`)).attributes.current_state + }, + power: (id, signal) => api('POST', `/servers/${id}/power`, JSON.stringify({ signal })), + command: (id, command) => api('POST', `/servers/${id}/command`, JSON.stringify({ command })), + read: (id, file) => api('GET', `/servers/${id}/files/contents?file=${encodeURIComponent(file)}`), + async readOr(id, file, fallback = null) { + try { + return await p.read(id, file) + } catch { + return fallback + } + }, + write: (id, file, content) => api('POST', `/servers/${id}/files/write?file=${encodeURIComponent(file)}`, content, 'text/plain'), + async rm(id, file) { + const dir = file.slice(0, file.lastIndexOf('/')) || '/' + try { + await api('POST', `/servers/${id}/files/delete`, JSON.stringify({ root: dir, files: [file.slice(file.lastIndexOf('/') + 1)] })) + } catch { + // already gone + } + }, + + /** Sends one console command and returns the console lines that follow, for `secs` seconds. */ + async console(id, command, secs = 6) { + const { data } = JSON.parse(await api('GET', `/servers/${id}/websocket`)) + const strip = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + return new Promise((resolve) => { + const lines = [] + let sent = false + const ws = new WebSocket(data.socket, { headers: { Origin: PANEL } }) + const done = () => { + try { + ws.close() + } catch {} + resolve(lines) + } + ws.onopen = () => ws.send(JSON.stringify({ event: 'auth', args: [data.token] })) + ws.onmessage = (m) => { + const msg = JSON.parse(m.data) + if (msg.event === 'auth success' && !sent) { + sent = true + // The panel replays recent history on connect; let it pass, then send. + setTimeout(() => { + lines.length = 0 + ws.send(JSON.stringify({ event: 'send command', args: [command] })) + }, 800) + setTimeout(done, secs * 1000) + } else if (msg.event === 'console output' && sent) { + lines.push(...strip(String(msg.args[0])).split('\n')) + } + } + ws.onerror = () => done() + }) + }, + + async waitState(id, want, timeoutMs) { + const end = Date.now() + timeoutMs + while (Date.now() < end) { + if ((await p.state(id)) === want) return true + await sleep(5000) + } + return false + }, + + /** Stops a server: `stop`, then `kill` if it has not stopped in time. */ + async stop(id, graceMs = 180000) { + if ((await p.state(id)) === 'offline') return + await p.power(id, 'stop') + if (!(await p.waitState(id, 'offline', graceMs))) { + await p.power(id, 'kill') + await p.waitState(id, 'offline', 60000) + } + }, + } + return p +} + +// ---------------------------------------------------------------- window: the week before a forced wipe + +// Rust changes on the monthly forced wipe, the first Thursday (D318). The drill runs only in the seven +// days before it, which leaves a week to fix what it finds; outside them the job stops at once. +function nextWipe(today) { + const firstThursday = (y, m) => { + const d = new Date(Date.UTC(y, m, 1)) + d.setUTCDate(1 + ((4 - d.getUTCDay() + 7) % 7)) + return d + } + const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + const thisMonth = firstThursday(today.getUTCFullYear(), today.getUTCMonth()) + return thisMonth.getTime() > t ? thisMonth : firstThursday(today.getUTCFullYear(), today.getUTCMonth() + 1) +} + +function windowDays(today = new Date()) { + const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + return Math.round((nextWipe(today).getTime() - t) / 86400000) +} + +async function window_(a) { + const days = windowDays(a.today ? new Date(a.today) : new Date()) + const wipe = nextWipe(a.today ? new Date(a.today) : new Date()).toISOString().slice(0, 10) + const open = days >= 1 && days <= 7 + const verdict = open ? 'in the drill week' : a.force ? 'outside the drill week, but run by hand: going ahead' : 'outside the drill week, nothing to do' + log(`the next forced wipe is ${wipe}, in ${days} day(s): ${verdict}`) + output('open', open || a.force ? 'true' : 'false') +} + +// ---------------------------------------------------------------- gate: is there anything to check? + +// A build is Steam's manifest of the Linux depot, which static.sh reads from Steam itself. +async function gate(a) { + const state = readJson(a.state, {}) + const stat = readJson(path.join(path.resolve(a.work || 'drill-work'), 'static.json'), {}) + const checked = state.checked && state.checked.manifest + const isNew = !stat.manifest || stat.manifest !== checked + log(`Rust ${stat.branch || 'staging'} is manifest ${stat.manifest || '?'} (${stat.manifestDate || '?'}); last checked ${checked || 'none'}`) + output('run', isNew || a.force ? 'true' : 'false') + if (!isNew && !a.force) log('already checked: nothing to do') +} + +// ---------------------------------------------------------------- live: the harness on rust-staging + +async function live(a) { + const work = path.resolve(a.work || 'drill-work') + const branch = a.branch || 'staging' + const result = { branch, at: now(), ran: false, findings: [], notes: [] } + const save = () => fs.writeFileSync(path.join(work, 'live.json'), JSON.stringify(result, null, 2)) + fs.mkdirSync(work, { recursive: true }) + + const stat = readJson(path.join(work, 'static.json'), null) + if (!stat || stat.managed !== 'ok') { + result.skipped = 'the static half did not run' + save() + return log(result.skipped) + } + if (stat.oxideBehind !== 0) { + result.skipped = `Oxide's ${branch} build is ${stat.oxideBehind} member(s) behind Rust's, so an Oxide server cannot boot on it` + save() + return log(result.skipped) + } + + const p = panel() + const ids = await p.servers() + const rig = ids[RIG] + if (!rig) throw new Error(`no server named ${RIG} on the panel`) + + let restart = null + const finding = (f) => { + result.findings.push(f) + log('FINDING', f) + } + + try { + // Room for it (D309): with both 6000-map rigs running the node's memory is full. + const running = [] + for (const name of BIG) if (ids[name] && (await p.state(ids[name])) === 'running') running.push(name) + if (running.length === BIG.length && ids[STOPPABLE]) { + log(`both ${BIG.join(' and ')} are running: saving and stopping ${STOPPABLE}`) + await p.command(ids[STOPPABLE], 'server.save').catch(() => {}) + await sleep(15000) + await p.stop(ids[STOPPABLE]) + restart = STOPPABLE + result.notes.push(`${STOPPABLE} was stopped for the drill and started again afterwards`) + } + + await p.stop(rig, 60000) + + // What runs: this commit's plugin and harness, and the startup that installs the branch. + await p.write(rig, '/staging-drill.sh', fs.readFileSync(path.join(__dirname, 'rig-start.sh'), 'utf8').replace(/\r\n/g, '\n')) + await p.write(rig, '/staging-drill.branch', branch + '\n') + await p.write(rig, '/oxide/plugins/RunicNPC.cs', fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8')) + const kits = await fetch(KITS_URL) + if (!kits.ok) throw new Error(`Kits from uMod: ${kits.status}`) + await p.write(rig, '/oxide/plugins/Kits.cs', await kits.text()) + await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') + await p.rm(rig, '/oxide/data/RunicNpcTest.json') + await p.rm(rig, '/staging-drill.json') + + log(`starting ${RIG} on ${branch}`) + const started = Date.now() + await p.power(rig, 'start') + const up = await waitRunning(p, rig, 45 * 60000) + result.installed = readJsonText(await p.readOr(rig, '/staging-drill.json', null)) + if (!up) { + finding(`The server did not start on Rust ${branch} ${result.installed?.buildid || ''} with Oxide's ${branch} build.`) + result.consoleTail = safeTail(await p.readOr(rig, '/latest.log', ''), 30) + return + } + result.bootMinutes = Math.round((Date.now() - started) / 6000) / 10 + log(`running after ${result.bootMinutes} min:`, JSON.stringify(result.installed)) + + // RunicNPC loaded, and the navmesh ready. + const status = await waitStatus(p, rig, 30 * 60000) + if (!status) { + finding('RunicNPC did not load: `rnpc.status` never answered.') + result.oxideErrors = await oxideErrors(p, rig) + return + } + result.status = status + log('rnpc.status:', status.find((l) => l.includes('navmesh='))) + const fieldsLine = status.find((l) => l.includes('swap fields:')) || '' + const missing = /missing=(\S+)/.exec(fieldsLine) + if (missing && missing[1] !== '-') finding(`The swap's fields are missing on this build: ${missing[1]}`) + if (!fieldsLine.includes('navmesh=ready')) { + finding('The navmesh was not ready in 30 minutes.') + return + } + + // The harness needs the hidden Kits kit rnhrevolver (tools/RunicNpcTest.cs). + await ensureKit(p, rig) + + await p.write(rig, '/oxide/plugins/RunicNpcTest.cs', fs.readFileSync(path.join(ROOT, 'tools', 'RunicNpcTest.cs'), 'utf8')) + let accepted = false + for (let i = 0; i < 18 && !accepted; i++) { + await sleep(10000) + accepted = (await p.console(rig, 'rnt.run all', 5)).some((l) => l.includes('rnt: running all')) + } + if (!accepted) { + finding('The test harness did not load (`rnt.run all` was not answered).') + result.oxideErrors = await oxideErrors(p, rig) + return + } + result.ran = true + log('rnt.run all: running') + + const lines = await waitHarness(p, rig, 45 * 60000) + result.harness = summarise(lines) + if (!result.harness.done) finding(`The harness did not finish in 45 minutes (${result.harness.pass} pass, ${result.harness.fail} fail so far).`) + for (const f of result.harness.failed) finding(`Harness: ${f}`) + log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`) + result.oxideErrors = await oxideErrors(p, rig) + if (result.oxideErrors.length) finding(`RunicNPC logged ${result.oxideErrors.length} error line(s) in Oxide's log during the run.`) + await p.command(rig, 'rnt.clear').catch(() => {}) + } catch (e) { + finding(`The live half stopped: ${e.message}`) + } finally { + try { + await p.stop(rig) + await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') + } catch (e) { + result.notes.push(`${RIG} could not be stopped cleanly: ${e.message}`) + } + if (restart) { + try { + await p.power(ids[restart], 'start') + log(`started ${restart} again`) + } catch (e) { + result.findings.push(`${restart} was stopped for the drill and could NOT be started again: ${e.message}`) + } + } + save() + } +} + +async function waitRunning(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let seenStarting = false + while (Date.now() < end) { + const s = await p.state(id) + if (s === 'running') return true + if (s === 'starting') seenStarting = true + // Wings restarts a crashed server a few times; offline after starting is a crash it gave up on. + if (s === 'offline' && seenStarting) return false + await sleep(15000) + } + return false +} + +async function waitStatus(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let last = null + while (Date.now() < end) { + const lines = (await p.console(id, 'rnpc.status', 6)).filter((l) => l.trim()) + const at = lines.findIndex((l) => l.startsWith('RunicNPC ')) + if (at >= 0) { + last = lines.slice(at, at + 14) + if (last.some((l) => l.includes('navmesh=ready'))) return last + } + await sleep(30000) + } + return last +} + +async function ensureKit(p, id) { + const file = '/oxide/data/Kits/kits_data.json' + let data = null + for (let i = 0; i < 12 && !data; i++) { + data = readJsonText(await p.readOr(id, file, null)) + if (!data) await sleep(5000) + } + if (!data) throw new Error(`Kits wrote no ${file}`) + data._kits = data._kits || {} + if (data._kits.rnhrevolver) return + const item = (Shortname, Amount, Position, Condition = 0) => ({ Shortname, Skin: 0, Amount, Condition, MaxCondition: Condition, Ammo: 0, Position, Frequency: -1 }) + data._kits.rnhrevolver = { + Name: 'rnhrevolver', + Description: 'RunicNPC test harness: revolver + burlap (staging drill)', + RequiredPermission: '', + MaximumUses: 0, + RequiredAuth: 0, + Cooldown: 0, + Cost: 0, + IsHidden: true, + CopyPasteFile: '', + KitImage: '', + MainItems: [item('ammo.pistol', 120, 0)], + WearItems: [item('burlap.shirt', 1, 0), item('burlap.trousers', 1, 1), item('hat.boonie', 1, 2)], + BeltItems: [item('pistol.revolver', 1, 0, 100)], + } + await p.write(id, file, JSON.stringify(data, null, 2)) + await p.command(id, 'oxide.reload Kits') + await sleep(10000) + log('added the rnhrevolver kit and reloaded Kits') +} + +function readJsonText(t) { + try { + return t ? JSON.parse(t) : null + } catch { + return null + } +} + +async function waitHarness(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let lines = [] + while (Date.now() < end) { + await sleep(30000) + lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines + if (lines.some((l) => /^# done all:/.test(l))) break + } + return lines +} + +function summarise(lines) { + const failed = lines.filter((l) => / FAIL( |$)/.test(l)) + const passed = lines.filter((l) => / PASS( |$)/.test(l)) + const done = lines.find((l) => /^# done all:/.test(l)) + return { done: !!done, summary: done || null, pass: passed.length, fail: failed.length, failed } +} + +/** RunicNPC's and the harness's error lines from today's Oxide log. */ +async function oxideErrors(p, id) { + const day = now().slice(0, 10) + const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || '' + return text + .split('\n') + .filter((l) => /RunicNPC|RunicNpcTest/.test(l) && /\[Error\]|Exception|Failed|error CS/.test(l)) + .slice(-40) +} + +/** The console's last lines, without the startup line (it carries the RCON password). */ +function safeTail(text, n) { + return (text || '') + .split('\n') + .filter((l) => !/rcon\.password|\+rcon/i.test(l)) + .slice(-n) +} + +// ---------------------------------------------------------------- report: one issue per build + +async function report(a) { + const work = path.resolve(a.work || 'drill-work') + const statePath = a.state + const state = readJson(statePath, {}) + const stat = readJson(path.join(work, 'static.json'), null) + const liv = readJson(path.join(work, 'live.json'), null) + const branch = (stat && stat.branch) || 'staging' + const manifest = (stat && stat.manifest) || '' + const buildid = (liv && liv.installed && liv.installed.buildid) || '' + // How a build is named: Steam's build id when the rig installed it, and always the manifest's date. + const build = `${buildid ? `build ${buildid}, ` : ''}manifest ${manifest || '?'} of ${(stat && stat.manifestDate) || '?'}` + const read = (f, n = 80) => { + try { + return fs.readFileSync(path.join(work, f), 'utf8').replace(/\r/g, '').split('\n').slice(0, n).join('\n').trim() + } catch { + return '' + } + } + + const findings = [] + const sections = [] + let outcome + let markChecked = true + + if (!stat || stat.managed !== 'ok' || stat.fields === 'error' || stat.compile === 'error') { + outcome = 'could not run' + markChecked = false + findings.push(`The static half could not run: ${(stat && stat.note) || 'no static.json'}`) + if (stat && stat.fields === 'error') sections.push(['tools/fieldlist', read('fields.diff')]) + } else if (stat.oxideBehind !== 0) { + // A wait from an earlier drill week (or a run by hand outside one) starts over. + const current = state.waiting && state.waiting.manifest === manifest && Date.now() - Date.parse(state.waiting.since) < 7 * 86400000 + const waiting = current ? state.waiting : { manifest, since: now() } + const days = (Date.now() - Date.parse(waiting.since)) / 86400000 + if (days < WAIT_DAYS) { + outcome = `waiting for Oxide (${stat.oxideBehind} behind, day ${Math.floor(days) + 1} of ${WAIT_DAYS})` + if (branch === 'staging') { + state.waiting = { ...waiting, behind: stat.oxideBehind, lastSeen: now() } + markChecked = false + } + } else { + outcome = 'Oxide has not caught up' + findings.push( + `Oxide's staging build is still ${stat.oxideBehind} member(s) behind Rust's staging build after ${WAIT_DAYS} days, ` + + 'so an Oxide server cannot boot on it and the live half did not run. The static half ran against Oxide\'s older build.' + ) + sections.push(['What Oxide lacks (first 20)', read('oxide-behind.txt', 21)]) + } + } + + if (!outcome || outcome === 'Oxide has not caught up') { + if (stat.fields === 'differs') { + findings.push("The swap's field list differs from the one the plugin carries (D232): regenerate it with tools/fieldlist and check what changed.") + sections.push(['Field list (carried → staging)', read('fields.diff', 120)]) + } + if (stat.compile === 'errors') { + findings.push("RunicNPC does not compile against this build with Oxide's staging build.") + sections.push(['Compiler output', read('compile.txt', 60)]) + } + if (!liv && stat.oxideBehind === 0) findings.push('The live half did not run: see the job log (is PTERODACTYL_DRILL_KEY set?).') + if (liv && !liv.skipped) { + findings.push(...liv.findings) + if (liv.consoleTail && liv.consoleTail.length) sections.push(['Console, last lines', liv.consoleTail.join('\n')]) + if (liv.oxideErrors && liv.oxideErrors.length) sections.push(["Oxide's log (RunicNPC lines)", liv.oxideErrors.join('\n')]) + if (liv.status) sections.push(['rnpc.status', liv.status.join('\n')]) + } + if (!outcome) outcome = findings.length ? 'fail' : 'pass' + if (branch === 'staging' && markChecked) delete state.waiting + } + + const installed = liv && liv.installed ? `Rust ${liv.installed.branch} ${liv.installed.buildid}, Oxide build of ${liv.installed.oxideModified}` : '' + const lines = [ + `Static: Oxide behind ${stat ? stat.oxideBehind : '?'}, fields ${stat ? stat.fields : '?'}, compile ${stat ? stat.compile : '?'}.`, + liv ? (liv.skipped ? `Live: skipped (${liv.skipped}).` : `Live: ${liv.harness ? `${liv.harness.pass} pass, ${liv.harness.fail} fail` : 'did not reach the harness'}${installed ? `, on ${installed}` : ''}${liv.bootMinutes ? `, booted in ${liv.bootMinutes} min` : ''}.`) : 'Live: did not run.', + ] + log(`${build} on ${branch}: ${outcome}`) + lines.forEach((l) => log(l)) + findings.forEach((f) => log('-', f)) + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Staging drill: ${build}: ${outcome}\n\n${lines.join('\n\n')}\n\n${findings.map((f) => `- ${f}`).join('\n')}\n`) + } + + // A rehearsal on another branch reports here and changes nothing. + if (branch !== 'staging') return log('not the staging branch: no issue, no state') + + let issue = null + if (findings.length) { + const body = [ + `The staging drill (docs/runicnpc/PLAN.md stage 9c) checked Rust's staging branch (**${build}**) on ${now().slice(0, 10)}: **${outcome}**.`, + '', + ...findings.map((f) => `- ${f}`), + '', + ...lines, + ...sections.flatMap(([title, text]) => (text ? ['', `### ${title}`, '', '```', text, '```'] : [])), + '', + process.env.GITHUB_RUN_NUMBER ? `Run: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_NUMBER}` : '', + ].join('\n') + issue = await fileIssue(`Staging drill: Rust staging manifest ${manifest} (${(stat && stat.manifestDate) || '?'})`, body) + } + + if (markChecked) state.checked = { manifest, buildid, at: now(), outcome, issue, oxideBehind: stat ? stat.oxideBehind : null } + state.lastRun = { at: now(), manifest, buildid, outcome, issue } + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n') + output('outcome', outcome) +} + +async function fileIssue(title, body) { + const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}` + const headers = { Authorization: 'token ' + process.env.GITEA_TOKEN, 'Content-Type': 'application/json', Accept: 'application/json' } + const call = async (method, p, data) => { + const r = await fetch(base + p, { method, headers, body: data ? JSON.stringify(data) : undefined }) + if (!r.ok) throw new Error(`${method} ${p}: ${r.status} ${(await r.text()).slice(0, 200)}`) + return r.json() + } + + const open = await call('GET', `/issues?state=open&type=issues&labels=staging-drill&limit=50`) + const same = open.find((i) => i.title === title) + if (same) { + await call('POST', `/issues/${same.number}/comments`, { body }) + log(`commented on #${same.number}`) + return same.number + } + const labels = await call('GET', '/labels?limit=100') + let label = labels.find((l) => l.name === 'staging-drill') + if (!label) label = await call('POST', '/labels', { name: 'staging-drill', color: '#d93f0b', description: "Rust's staging branch breaks RunicNPC (the daily staging drill)" }) + const created = await call('POST', '/issues', { title, body, labels: [label.id] }) + log(`opened #${created.number}`) + return created.number +} + +// ---------------------------------------------------------------- main + +const a = args() +const commands = { window: window_, gate, live, report } +const command = commands[a._[0]] +if (!command) { + console.error('usage: node tools/drill/drill.js window|gate|live|report [--state f] [--work dir] [--branch b] [--force]') + process.exit(2) +} +command(a).catch((e) => { + console.error(e.message || e) + process.exit(1) +}) diff --git a/tools/drill/rig-start.sh b/tools/drill/rig-start.sh new file mode 100644 index 0000000..c0cd013 --- /dev/null +++ b/tools/drill/rig-start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# The staging drill rig's startup (docs/runicnpc/PLAN.md D307). Never shipped. +# +# rust-staging runs the published egg in its vanilla mode. That image's entrypoint always runs +# `app_update 258550` before the startup, and the egg cannot be told otherwise, so the server's +# startup is this script in front of the egg's own command line: +# +# bash ./staging-drill.sh ./RustDedicated -batchmode … +# +# It moves the install to a Steam branch, lays Oxide's build for that branch over it, writes down what +# it installed (staging-drill.json), and becomes the server. The branch is `staging` unless the file +# staging-drill.branch names another (`public` rehearses the drill on a pair known to match). +# tools/drill/drill.js writes this file to the rig as /staging-drill.sh before every start, so the +# copy in git is the one that runs. +# +# Steam keeps the branch: the entrypoint's own app_update, which names none, then answers "already +# up to date" and leaves a staging install on staging (seen 2026-10-06). + +cd /home/container || exit 1 + +branch=staging +if [ -s staging-drill.branch ]; then branch=$(tr -dc 'a-z0-9-' < staging-drill.branch); fi +if [ "$branch" = public ]; then + OXIDE_URL=https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip +else + OXIDE_URL=https://downloads.oxidemod.com/artifacts/Oxide.Rust/$branch/Oxide.Rust-linux.zip +fi + +# The branch Steam has INSTALLED: MountedConfig's key (UserConfig's is only the one asked for). The +# public branch has no key. +mounted() { + sed -n '/"MountedConfig"/,/}/s/^[[:space:]]*"BetaKey"[[:space:]]*"\([^"]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1 +} + +# A branch switch downloads the whole server (5.4 GB), and one can fail ("UpdateResult" 13 on +# 2026-10-06, the next attempt fine). Rust on one branch with Oxide's build for another dies at boot, +# so the server is not started on a pair that does not match. +want=$branch +[ "$want" = public ] && want="" +for attempt in 1 2; do + echo "[staging-drill] Rust: app_update 258550 -beta $branch (attempt $attempt)" + ./steamcmd/steamcmd.sh +force_install_dir /home/container +login anonymous +app_update 258550 -beta "$branch" +quit + installed=$(mounted) + [ "${installed:-public}" = "${want:-public}" ] && break +done +if [ "${installed:-public}" != "${want:-public}" ]; then + echo "[staging-drill] Steam installed '${installed:-public}', not '$branch': not starting" + exit 1 +fi + +# Oxide's build carries its own patched Assembly-CSharp.dll, so it goes on AFTER Rust. +echo "[staging-drill] Oxide: $OXIDE_URL" +oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1) +if curl -sSL --fail -o oxide-drill.zip "$OXIDE_URL" && unzip -o -q oxide-drill.zip; then + oxide_ok=true +else + oxide_ok=false + echo "[staging-drill] Oxide's $branch build could not be installed" +fi +rm -f oxide-drill.zip + +buildid=$(sed -n 's/^[[:space:]]*"buildid"[[:space:]]*"\([0-9]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1) +printf '{"buildid":"%s","branch":"%s","oxideModified":"%s","oxideInstalled":%s,"at":"%s"}\n' \ + "$buildid" "${installed:-public}" "$oxide_modified" "$oxide_ok" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > staging-drill.json +echo "[staging-drill] $(cat staging-drill.json)" +if [ "$oxide_ok" != true ]; then exit 1; fi + +exec "$@" diff --git a/tools/drill/static.sh b/tools/drill/static.sh new file mode 100644 index 0000000..5cf7d47 --- /dev/null +++ b/tools/drill/static.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# The staging drill's static half (docs/runicnpc/PLAN.md D308). Never shipped. +# +# tools/drill/static.sh (WORK=drill-work BRANCH=staging by default) +# +# Needs no server. It downloads only RustDedicated_Data/Managed/*.dll from a Steam branch, and +# Oxide's build for that branch, and: +# +# 1. asks whether Oxide's build has caught up with Rust's (tools/fieldlist --oxide-behind). Until it +# has, an Oxide server on that branch dies at boot, so the drill waits for it (D317); +# 2. regenerates the swap's field list (tools/fieldlist, D232) from Rust's own Assembly-CSharp.dll +# and compares it with the one plugin/RunicNPC.cs carries; +# 3. compiles plugin/RunicNPC.cs against those assemblies with Oxide's laid over them, as an Oxide +# server would. Oxide's patched Assembly-CSharp.dll is needed: the plugin uses members Rust +# ships non-public (IAISenses, for one). +# +# It writes $WORK/static.json and, beside it, oxide-behind.txt, fields.diff and compile.txt. In +# static.json, oxideBehind is a count (-1: not measured), fields "same"/"differs", compile +# "ok"/"errors", and either is "error" when its step could not run. The script exits 0 whenever it +# ran; what it found is in static.json. DEPOTDOWNLOADER and DOTNET point at other copies (on +# Windows, DEPOTDOWNLOADER=…/DepotDownloader.exe from Git Bash). + +set -uo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +WORK=${WORK:-$ROOT/drill-work} +BRANCH=${BRANCH:-staging} +if [ "$BRANCH" = public ]; then + OXIDE_URL=${OXIDE_URL:-https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip} +else + OXIDE_URL=${OXIDE_URL:-https://downloads.oxidemod.com/artifacts/Oxide.Rust/$BRANCH/Oxide.Rust-linux.zip} +fi +DD_URL=https://github.com/SteamRE/DepotDownloader/releases/download/DepotDownloader_3.4.0/DepotDownloader-linux-x64.zip +DOTNET=${DOTNET:-dotnet} + +mkdir -p "$WORK" +rm -rf "$WORK/depot" "$WORK/oxide" "$WORK/refs" "$WORK/fields" +rm -f "$WORK"/static.json "$WORK"/oxide-behind.txt "$WORK"/fields.diff "$WORK"/compile.txt + +managed=error +behind=-1 +fields=error +compile=error +note="" +oxide_modified="" +manifest="" +manifest_date="" + +finish() { + printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s"}\n' \ + "$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" > "$WORK/static.json" + cat "$WORK/static.json" + exit 0 +} + +# Git Bash does not translate paths inside a response file; elsewhere this is the path as it is. +native() { if command -v cygpath > /dev/null; then cygpath -m "$1"; else printf '%s' "$1"; fi; } + +# ---- Rust's managed assemblies, from Steam (no login: the dedicated server is anonymous) ---- + +DD=${DEPOTDOWNLOADER:-} +if [ -z "$DD" ]; then + curl -sSL --fail -o "$WORK/dd.zip" "$DD_URL" && unzip -oq "$WORK/dd.zip" -d "$WORK/dd" && chmod +x "$WORK/dd/DepotDownloader" \ + || { note="DepotDownloader could not be fetched"; finish; } + DD=$WORK/dd/DepotDownloader +fi + +printf 'regex:^RustDedicated_Data/Managed/[^/]+\\.dll$\n' > "$WORK/files.txt" +"$DD" -app 258550 -depot 258552 -branch "$BRANCH" -filelist "$WORK/files.txt" -dir "$WORK/depot" -os linux \ + > "$WORK/depot.txt" 2>&1 +MANAGED=$WORK/depot/RustDedicated_Data/Managed +# Which build this is, from Steam itself: the Linux depot's manifest and its date. (api.steamcmd.net +# answered a build id a day stale on 2026-10-06, so the drill does not ask it.) +manifest=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\1/p' | head -1) +manifest_date=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\2/p' | head -1) +if [ ! -f "$MANAGED/Assembly-CSharp.dll" ]; then + note="no Assembly-CSharp.dll from Steam's $BRANCH branch: $(tail -3 "$WORK/depot.txt" | tr '\n"' ' ')" + finish +fi +managed=ok + +# ---- Oxide's build for the branch ---- + +oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1) +if ! { curl -sSL --fail -o "$WORK/oxide.zip" "$OXIDE_URL" && unzip -oq "$WORK/oxide.zip" -d "$WORK/oxide"; }; then + note="Oxide's $BRANCH build could not be downloaded" + finish +fi +OXIDE_MANAGED=$WORK/oxide/RustDedicated_Data/Managed + +# The tool is built once, here, so a build failure is not mistaken for anything it reports. +if ! "$DOTNET" build "$ROOT/tools/fieldlist" -c Release -o "$WORK/fieldlist" > "$WORK/fieldlist-build.txt" 2>&1; then + note="tools/fieldlist did not build: $(grep -m1 'error' "$WORK/fieldlist-build.txt" | tr '"' ' ')" + finish +fi +FIELDLIST=("$DOTNET" "$WORK/fieldlist/FieldList.dll") + +# ---- 1. Has Oxide caught up? ---- + +if "${FIELDLIST[@]}" --oxide-behind "$MANAGED" "$OXIDE_MANAGED/Assembly-CSharp.dll" > "$WORK/oxide-behind.txt" 2>&1; then + behind=$(sed -n 's/^behind \([0-9]*\)$/\1/p' "$WORK/oxide-behind.txt" | head -1) + behind=${behind:--1} +fi + +# ---- 2. The swap's field list (D232) ---- + +mkdir -p "$WORK/fields" +cp "$ROOT/plugin/RunicNPC.cs" "$WORK/fields/RunicNPC.cs" +block() { sed -n '/\/\/ /,/\/\/ <\/fieldlist>/p' "$1" | grep -v "Generated by tools/fieldlist" | tr -d '\r'; } +if "${FIELDLIST[@]}" "$MANAGED" "$WORK/fields/RunicNPC.cs" > "$WORK/fields/run.txt" 2>&1; then + if diff -u --label carried <(block "$ROOT/plugin/RunicNPC.cs") --label "$BRANCH" <(block "$WORK/fields/RunicNPC.cs") > "$WORK/fields.diff"; then + fields=same + else + fields=differs + fi +else + cp "$WORK/fields/run.txt" "$WORK/fields.diff" +fi + +# ---- 3. Compile against the branch with Oxide's build laid over ---- + +mkdir -p "$WORK/refs" +cp "$MANAGED"/*.dll "$WORK/refs/" +cp "$OXIDE_MANAGED"/*.dll "$WORK/refs/" +# Oxide.References.dll bundles its own older copy of Newtonsoft.Json, which Oxide's compiler never +# references; with it, every JObject is ambiguous. +rm -f "$WORK/refs/Oxide.References.dll" + +DOTNET_DIR=$(dirname "$(readlink -f "$(command -v "$DOTNET")")") +CSC=$(ls "$DOTNET_DIR"/sdk/*/Roslyn/bincore/csc.dll 2>/dev/null | tail -1) +if [ -z "$CSC" ]; then + note="no csc.dll under $DOTNET_DIR/sdk" + finish +fi + +# A response file: some 250 references outgrow a Windows command line. +for f in "$WORK/refs"/*.dll; do printf '"-r:%s"\n' "$(native "$f")"; done > "$WORK/refs.rsp" +if "$DOTNET" "$CSC" -nologo -noconfig -nostdlib -target:library -nowarn:1701,1702 -warn:0 \ + -out:"$WORK/RunicNPC.dll" "@$WORK/refs.rsp" "$ROOT/plugin/RunicNPC.cs" > "$WORK/compile.txt" 2>&1; then + compile=ok +else + compile=errors +fi + +finish diff --git a/tools/fieldlist/FieldList.csproj b/tools/fieldlist/FieldList.csproj new file mode 100644 index 0000000..71d72d1 --- /dev/null +++ b/tools/fieldlist/FieldList.csproj @@ -0,0 +1,18 @@ + + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/tools/fieldlist/Program.cs b/tools/fieldlist/Program.cs new file mode 100644 index 0000000..de8dd63 --- /dev/null +++ b/tools/fieldlist/Program.cs @@ -0,0 +1,139 @@ +// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232). +// +// node tools/managed.js carbon (once per Rust update) +// dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs] +// +// 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. + +// +// The staging drill (D308) also asks it whether Oxide's build has caught up with Rust's: +// +// dotnet run --project tools/fieldlist -- --oxide-behind +// +// Oxide ships its own patched Assembly-CSharp.dll, which replaces Rust's. Built from an older Rust, +// it lacks what Rust has added since, and the server dies at boot ("The referenced script +// (ItemModFoodVisual) on this Behaviour is missing!"). Oxide's patcher only ever adds, so every +// type and member Rust's own assembly declares, compiler-generated ones aside, must be in Oxide's. +// It prints "behind " and the first of them; 0 means caught up. Measured 2026-10-06: public Rust +// and public Oxide, 0; staging Rust 25766353 and Oxide's staging build of 2026-10-05, 482. + +using System.Reflection; +using System.Text; + +if (args.Length == 3 && args[0] == "--oxide-behind") + return OxideBehind(args[1], args[2]); + +if (args.Length < 1) +{ + Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs]"); + Console.Error.WriteLine(" dotnet run --project tools/fieldlist -- --oxide-behind "); + 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 Fields(string typeName) +{ + Type type = rust.GetType(typeName, throwOnError: true)!; + var names = new List(); + 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 npc = Fields("ScientistNPC"); +List brain = Fields("ScientistBrain"); + +Guid mvid = rust.ManifestModule.ModuleVersionId; + +const string Open = " // "; +const string Close = " // "; + +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 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; + +static int OxideBehind(string managed, string oxideAssembly) +{ + HashSet Declared(string assembly) + { + IEnumerable others = Directory.GetFiles(managed, "*.dll").Where(f => Path.GetFileName(f) != "Assembly-CSharp.dll"); + using var context = new MetadataLoadContext(new PathAssemblyResolver(others.Append(assembly)), "mscorlib"); + var names = new HashSet(); + foreach (Type t in context.LoadFromAssemblyPath(assembly).GetTypes()) + { + names.Add(t.FullName!); + foreach (MemberInfo m in t.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + names.Add($"{t.FullName}::{m.Name}"); + } + // Compiler-generated names (lambdas, closures, iterators) are renumbered by every build. + names.RemoveWhere(n => n.Contains('<')); + return names; + } + + List missing = Declared(Path.Combine(managed, "Assembly-CSharp.dll")).Except(Declared(oxideAssembly)).OrderBy(n => n).ToList(); + Console.WriteLine($"behind {missing.Count}"); + foreach (string n in missing.Take(20)) + Console.WriteLine(" " + n); + return 0; +} diff --git a/tools/managed.js b/tools/managed.js new file mode 100644 index 0000000..ab490eb --- /dev/null +++ b/tools/managed.js @@ -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 +// +// 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 ') + 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) +}) diff --git a/tools/rigs.example.json b/tools/rigs.example.json index 5837e15..0018fa3 100644 --- a/tools/rigs.example.json +++ b/tools/rigs.example.json @@ -3,6 +3,7 @@ "tokenFile": "/path/to/pterodactyl_api_token", "rigs": { "oxide": "00000000", - "carbon": "00000000" + "carbon": "00000000", + "staging": "00000000" } }