58 Commits

Author SHA1 Message Date
53e32a0ba4 Merge pull request 'chore(release): RunicNPC cutover for the 2026-11-05 wipe, Rust 547616323393184824' (#29) from edge into main
All checks were successful
Release plugin / release (push) Successful in 28s
Wipe-day cutover / cutover (push) Successful in 12s
Staging drill / drill (push) Successful in 24s
2026-11-06 14:13:36 +00:00
27ff34e689 Merge pull request 'ci(cutover): main's cutover.yml is edge's again, so the cutover PR merges (D397)' (#30) from ci/cutover-yml-match-edge into main
All checks were successful
Release plugin / release (push) Successful in 8s
Wipe-day cutover / cutover (push) Successful in 32m49s
Reviewed-on: #30
2026-11-06 13:26:05 +00:00
8a9e8fa4b1 ci(cutover): main's cutover.yml is edge's again, so the cutover PR merges (D397)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 21s
runicnpc-rust#28 changed edge's copy of cutover.yml (gate 1 reads Steam; the
staging static half is gone). main's copy was added by its own commit (#26), so
the two now conflict (add/add) and the cutover PR #29 cannot merge, though all
its gates passed. This makes main's copy byte-for-byte edge's. It changes no
commit on edge, so the gates kept for edge 761a9b67 stand and the next hourly
run only re-checks the PR and merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 06:59:53 -06:00
761a9b6733 Merge pull request 'fix(cutover): gate 1 asks Steam when public was set (D397)' (#28) from fix/cutover-public-gate into edge
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 41s
Reviewed-on: #28
2026-11-06 10:44:09 +00:00
e5548472da fix(cutover): gate 1 asks Steam when public was set, not the manifest's date or staging (D332)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 29s
The 2026-11-05 weekend never cut over: gate 1 blocked every hourly run. Both of
its checks rested on assumptions the wipe disproved:

- The wipe build went live Thursday evening, but its depot manifest is dated
  11-04: the date DepotDownloader prints is when Facepunch built it.
- Public never names staging's manifest. Facepunch promotes a hidden `release`
  branch ("Release, before public"), and staging had moved on before the wipe.

Gate 1 now reads Steam's app info (steamcmd +app_info_print 258550, kept by
static.sh as appinfo.txt with BRANCH=public): branches.public.timeupdated must be
on the wipe day or later, and the Linux depot manifest Steam names for public
must be the one the static half downloaded. The staging static half is gone from
edge's cutover.yml; --staging-work is still accepted, since main's copy passes it
until the cutover merge carries the new wiring over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 04:37:50 -06:00
c57456264d Merge pull request 'ci(cutover): the wipe-day cutover's trigger on main (D332)' (#26) from ci/cutover-trigger into main
Some checks failed
Release plugin / release (push) Successful in 10s
Staging drill / drill (push) Successful in 9s
Wipe-day cutover / cutover (push) Failing after 2m46s
Reviewed-on: #26
2026-11-02 15:39:01 +00:00
fa606e5a01 Merge pull request 'ci(cutover): the wipe-day edge → main cutover, once five gates pass (D332)' (#25) from feat/auto-cutover into edge
Reviewed-on: #25
2026-11-02 15:38:25 +00:00
567b7671ad ci(cutover): the wipe-day cutover's trigger on main (D332)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s
Gitea fires a schedule only from the default branch, so the cutover's
workflow file reaches main once, alone. It is the same file as on edge
(the trigger and the step wiring); every step checks out edge and runs
edge's scripts, so nothing else of edge comes with it: the field list stays
on edge until the cutover merges it. A ci: commit cuts no release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 07:09:11 -06:00
29226ab450 ci(cutover): no Closes listed twice, room for the worst case, the checkout pinned (D332)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
From the second review:
- A PR whose merge commit main already has is not listed again. edge's tip at
  a cutover is often that PR's merge commit, and its merged_at can be a moment
  after the cut-off's committer date, so its issues would have had the next
  release's comment too.
- timeout-minutes 240: the worst case is about 200, and a timeout must never
  cut off the step that starts rust-carbon again.
- A test pins the checkout: edge, except on a dry dispatch that names code_ref.

Dry run (CI run 54, before these): every gate refused for the right reason.
Public Rust is still the 22 October build, which lacks the three fields edge
carries for November, and gates 1 to 4 each saw it; the panel key wrote to,
restarted and read rust-carbon; the live half ran 266 pass, 0 fail; nothing
was opened, edited, commented or merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 07:08:47 -06:00
512efa77cb ci(cutover): the wipe-day edge → main cutover, once five gates pass (D332)
A workflow that merges edge into main on the forced-wipe weekend with no
approval: hourly from Thursday 17:00 UTC to Sunday 23:00 UTC, through five
gates in order, then a merge commit made with RUNICNPC_TOKEN (that step only)
so release.yml cuts the release.

1. Rust's public build changed: dated the wipe day and naming the same depot
   manifest as staging (the staging build was promoted to live).
2. Oxide's public release caught up, RunicNPC compiles, the swap's fields match.
3. A Carbon boot on public Rust: rust-carbon proves the public manifest is
   installed, and rnpc.status says framework carbon, navmesh ready,
   missing=- added=-.
4. The drill's live half on public Rust with edge's plugin and harness.
5. PR checks, run fresh on the cutover PR's head just before the merge.

Gates are kept in drill.json for one (wipe, public manifest, edge commit);
any change runs them again. A failed Carbon or live attempt cools down for
3 hours. The merge is pinned to the commit the gates ran on. The cutover PR
lists every `Closes #N` from the PRs merged into edge since the last cutover,
so those issues close on the merge; afterwards each gets the release version.
One comment a day while a gate blocks; at the deadline it hands back.

The copy on main is only the trigger and step wiring; every step runs edge's
code (a dry dispatch may name another branch, code_ref). Dispatches are dry
unless dry_run is exactly false. rust-carbon is started again if the job left
it stopped. drill.js only gains exports; its report already kept other keys,
now tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 06:15:16 -06:00
1374891251 Merge pull request 'fix(kit): kit extras found by item definition; stage 5 pairs in sight (#20, #22)' (#24) from fix/kit-extras-belt-only into edge
Reviewed-on: #24
2026-11-02 11:46:19 +00:00
df2a258f64 fix(drill): stage 5's pairs stand in each other's sight; say how the leashed NPC came to fight (#20, #22)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 31s
s5.fight.shootBack failed on every staging drill run because, on the drill's
3500 map (seed 1234), a cliff rock (cliff_hills_large_e_arid) stands between
the fifth fight's victim and the bully 15 m north of it: the bully never saw
the victim, so never shot it. In the open the victim answers within a second.
Each fight's second NPC now stands where each one's eyes see the other's
centre, both ways, as RunicNPC's own line of sight runs: north first, then the
other seven ways round, with a note when it moves.

s5.fight.leash failed once (run 49) and did not come back in three tries. The
first moment the leashed NPC has a target, the harness now notes the target,
its health, and how far it and the scientist are from its home, so the next
failing run says whether it sensed, was shot, or heard an alarm.

Harness 0.7.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 04:24:15 -06:00
5c3a360b70 fix(kit): find a kit's extra by its item definition when it has no held entity (#20)
Rust's November 2026 build (staging 26344826) adds the server variable
ItemModEntity.playerOnlyEntityBeltOnly, on by default: an item Rust marks
player-only, the medical syringe among them, has a held entity only while it
is in the belt. A kit puts its syringes in the main inventory, so the heal
(D260) found no item whose held entity is a MedicalTool and never healed
(s5.kit.heal on the staging drill: heals 0, health 75).

FindHeld now asks the item definition's entity prefab when an item has no
held entity, for every extra (heal, melee, flamethrower, grenade, rocket),
and HasFuel reads the flamethrower's fuel type the same way. Holding the item
moves it to the belt, where Rust makes its entity. A heal that cannot hold the
item (a full belt) is now no heal, not a heal of an item never held (D331).

On rust-staging: s5.kit.heal PASS, heals 2, syringes left 0, health 145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 04:24:14 -06:00
a43e5c299f Merge pull request 'fix(drill): staging 26341433's swap fields, a harness that finishes, a drill rig that starts fresh (#20, #22)' (#23) from fix/staging-drill-20 into edge
Reviewed-on: #23
2026-11-01 08:18:20 +00:00
cf3997bbce fix(drill): place spots found up to 15 m from the navmesh; count only this run's Oxide errors (#22)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 1m28s
Run 49: place.set.each was refused "4.4 m from the navmesh", because
OnMesh searched 3 m and returned the unsnapped point; Place now searches
15 m. The drill reported run 48's four error lines again: oxideErrors
read the whole day's Oxide log, and now reads only what was added after
the drill started the rig.

Refs #20, #22

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-01 02:15:18 -06:00
4fb935d9dd fix(drill): lay the harness's kit down before boot instead of reloading Kits (#22)
Run 48 deleted Kits' file for a fresh start, and the harness's
rnhrevolver kit, added after boot with `oxide.reload Kits` through the
panel's command endpoint, never reached Kits: no reload or compile job
followed, and every test profile was refused for want of the kit.

The drill now replaces kits_data.json with one holding only that kit.
Kits reads it at boot; RunicNPC's first load adds its example kits
beside it (it skips kits already there) and settles its own Kits
reload (D329). The add-after-boot stays as a fallback, now through the
console's websocket, and is a finding when Kits does not load again.

Refs #20, #22

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-01 01:50:19 -06:00
54fb1ece67 ci(pr-checks): run the staging drill's tests (#20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-01 01:35:45 -06:00
bad19a668d fix(drill): staging 26341433's swap fields; a failed group no longer ends the harness (#20)
The staging drill's run 47 failed on Rust staging 26341433:

- Rust added BasePlayer.timeSinceAiming, BaseEntity.ServerPreProcessed and
  BaseNetworkable.ignoreNetworkLod to the swap's field list (D232): 55 -> 58.
  On edge now; main at the forced wipe of 2026-11-05.
- Harness 0.7.4: place.set.each was refused and the refusal was thrown
  away; place.spawned failed; reading each[0] of an empty list then killed
  the coroutine, so the drill waited 45 minutes for a run that had stopped
  after 10. Place puts its spots on the navmesh, the set checks carry the
  refusal, a failed place.spawned ends the group, and any group that throws
  is a <group>.exception FAIL line and the run goes on.
- drill.js: rust-staging boots from empty Kits and RunicNPC data, so the
  write-once example kits match this Rust (s6.examples.fullCondition); the
  wait ends after 5 minutes without a new harness line; latest.log's tail
  goes into the issue when the harness did not finish. drill.test.js
  covers the stall logic.

Refs #20, #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-01 01:30:10 -06:00
27bafedb03 Merge pull request 'feat!: RunicNPC v1.0.0, edge → main (runicnpc 9e step 3, merge 1 of 3)' (#19) from edge into main
All checks were successful
Release plugin / release (push) Successful in 21s
Staging drill / drill (push) Successful in 6m11s
Reviewed-on: #19
2026-10-09 07:19:59 +00:00
2d36028125 Merge pull request 'feat!: RunicNPC 1.0.0, the release module-rust requires (runicnpc 9e)' (#18) from chore/v1.0.0 into edge
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
Reviewed-on: #18
2026-10-09 07:19:31 +00:00
89df052d7d feat!: RunicNPC 1.0.0, the release module-rust requires (runicnpc 9e)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -2m1s
Stage 9's release (D311, D313). Every stage before it landed as `feat:`,
so the release engine, which reads non-merge subjects since v0.1.0, would
cut v0.2.0 at the cutover. This commit's `!` makes it v1.0.0, as the plan
and the README say (the org lead's choice, 2026-10-09).

The README's status line now says v1.0.0, and its Releases section says
how the number was reached. No code changes.

BREAKING CHANGE: 1.0.0 is the release a Runic Gateway server requires.
module-rust refuses every Place NPCs step on a server without RunicNPC
(D310), and the bridge declares API 6 as its floor. Nothing in the API
itself changes shape here: it stays version 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 02:14:13 -05:00
667c77e32c Merge pull request 'fix(examples): a Kits reload owed for the example kits survives an interrupted load (D329)' (#17) from fix/kits-reload-owed into edge
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -2m2s
Reviewed-on: #17
2026-10-09 07:09:37 +00:00
f9210e3a4f fix(examples): a Kits reload owed for the example kits survives an interrupted load (D329)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m52s
The first load writes the example kits into Kits' file and asked for a Kits
reload on a one-second timer. A timer dies with its plugin, so a second load of
RunicNPC inside that second (the 9d session's, on Carbon) lost the reload, and
examplesWritten was already set: the examples stayed refused ("no kit
rnpc_juggernaut") until Kits was reloaded by hand.

The kits written are now owed a reload in data/RunicNPC/state.json
(kitsReloadOwed). SettleKitsReload, on every load and when Kits comes back,
clears the debt once Kits knows every kit owed, and otherwise asks for the
reload again, at most three times, then warns with the command to run.

Oxide never showed it: loading RunicNPC recompiles and reloads Kits there
(// Requires: Kits). Carbon does not.

Walked on both rigs (Rust 25797215, Oxide 2.0.7815, Carbon 2.0.262): a first
install landing on a running server, the interrupted state, and a debt for a
kit that never exists (three reloads, the warning, no loop). The harness gains
s6.examples.reloadSettled (0.7.3; compiled on the Carbon rig).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 02:07:14 -05:00
00be8d8d74 Merge pull request 'fix: example NPC outfits at full durability; /rnpc here a metre in front (D323–D325)' (#16) from fix/example-kit-outfits into edge
Reviewed-on: #16
2026-10-09 06:09:15 +00:00
246ad17c60 feat(commands): rnpc here stands the NPC a metre in front, facing you (D325)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
At the admin's feet it spawned inside them, looking away (9d player walk). It now
stands 1 m ahead on the ground, turned to face the admin. If something is in the way
at chest height, there is no ground within 2 m below, or a walker would have no
navmesh, it falls back to the admin's own spot and the reply says so.

The harness's cmd.chat.here now checks the metre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 00:11:41 -05:00
6b15d68071 fix(examples): each example NPC its own outfit, every item at full durability (D323, D324)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The 9d player walk found the example kits looked alike and shipped broken armour.

- D323: the raider wears scrap armour, the camp guard metal, the sniper hide, the
  Juggernaut heavy plate. Weapons are unchanged.
- D324: Kits sets an item's condition straight from its file, and the writer put 0 on
  armour (heavy plate 0 of 1000) and a flat 100 on weapons (an M249's maximum is 500).
  Each item is now written at this Rust's own maximum for it, read from the item
  definition; 0 where the item has none.
- The harness checks it: s6.examples.fullCondition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-08 23:53:01 -05:00
2b28575218 Merge pull request 'feat(drill): the staging drill, the week before each forced wipe (stage 9c, D307–D309, D317–D319)' (#15) from feat/stage-9c into edge
Reviewed-on: #15
2026-10-07 06:44:50 +00:00
d7ba935302 fix(drill): a wait from an earlier week starts over; say when a run by hand goes ahead
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 01:32:22 -05:00
c25f73c525 feat(drill): the staging drill, the week before each forced wipe (stage 9c, D307-D309, D317-D319)
A Gitea job that checks each new Rust staging build in the seven days before the
monthly forced wipe:

- tools/drill/static.sh: staging's managed assemblies (DepotDownloader) and
  Oxide's staging build; whether Oxide has caught up (fieldlist --oxide-behind),
  the swap's field list compared, and the plugin compiled.
- tools/drill/drill.js: window (D318), gate (one check per depot manifest),
  live (rust-staging runs rnt.run all; rust-carbon is stopped and restarted when
  both 6000-map rigs run, D309) and report (one issue per build, state on the
  drill branch; a build waits up to three days for Oxide, D317).
- tools/drill/rig-start.sh: the rig's startup; installs the branch and Oxide's
  build for it, and refuses to start a mismatched pair.
- RunicNpcTest 0.7.2: stage 5's five fights look for a field with room for their
  280 m line (D319), and the spawn check names what did not spawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 01:29:45 -05:00
9b316c6266 Merge pull request 'feat: stage 9b hardening — cost warning, update surface, rnt.cost (D306, D315)' (#14) from feat/stage-9 into edge
Reviewed-on: #14
2026-10-07 02:42:36 +00:00
b5e7ecc4ed feat: the cost warning says what stage 9 measured (D306)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
Stage 9 measured the released NPC on both frameworks (6000 map): about 3 ms of
median frame per 100 idle while a player keeps them awake, 0.5 ms once they
sleep with nobody near, and 9 ms per 100 fighting, with each NPC reacting about
every 3 s under Rust's shared AI budget. The warning now says idle twice
(awake and asleep) and uses those figures, the worst of the two rigs rounded up.
The awake-idle overhead over the bare swap is runicnpc-rust#13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 21:33:49 -05:00
6a384d0a28 docs, test: the update surface in the README, and rnt.cost (stage 9, D306)
- README: status is stage 9; the installer and egg install it now; API 6; the
  swap reports 55/55 npc fields; COMMANDS.md and INSTALL.md are linked; a new
  "update surface" table lists every Rust type and member the swap and the
  brain depend on, where, and what catches a change (compile, the field list,
  or only a running server). The staging drill checks exactly that list.
- tools/RunicNpcTest.cs 0.7.0: rnt.cost measures 100 of RunicNPC's own NPCs
  against the same session's empty server, idle (awake) and fighting 20
  invulnerable stand-ins, with a second empty baseline at the end and the bar
  taken against the lower. `idle` skips the fight, `sentry` makes them stand
  still, `asleep` leaves out the keeper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 17:33:28 -05:00
d5c9f04f2f Merge pull request 'feat: API 6, loot tables, crates and the corpse's start and time (stage 7)' (#12) from feat/stage-7 into edge
Reviewed-on: #12
2026-10-06 07:16:32 +00:00
c6401182c3 Merge pull request 'test(harness): stage 7 spike, rnt.spike7 (D299)' (#11) from spike/stage-7 into edge
Reviewed-on: #11
2026-10-06 07:16:06 +00:00
f9ebc5f25b feat: API 6, loot tables, crates and the corpse's start and time (stage 7)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
A profile's `loot` block (D290-D301):

- the corpse's start: Rust's scientist loot (the default), the kit it
  carried, or nothing at all, clothes included (D290, D300);
- a table of rows that always roll, each with its chance and amount,
  and a pool that draws at most `pick` rows by weight, with a "nothing"
  weight, so a kill never pays out more than the cap (D291);
- the table into Rust's wooden box, military crate, elite crate or
  hackable locked crate instead of the corpse, with Rust's loot and
  refresh off; it stays until emptied (D292, D301);
- a locked crate's hack time, through its starting count (D297);
- the corpse's time: Rust's own, seconds, or none (D294);
- `dropTable`, for an event step that switches the table off (D296).

The corpse is filled in OnCorpsePopulate, which answers with the corpse
for a kit or empty start so Rust's ApplyLoot is skipped: ApplyLoot is
protected on Oxide and public on Carbon, so no one override fits both.
A kit start takes the items as the NPC dies, because Rust has emptied
its inventory by the time the corpse is filled.

The harness's s7 group (25 checks): refusals, about 100 kills read back
against the always-rows, the chance, the pool's weights and its cap,
each start, both crates and the locked crate's timer, the corpse's time
and the event switch. 25/25 on both rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 22:35:42 -05:00
9a00919614 test(harness): stage 7 spike, rnt.spike7 (D299)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 7s
The corpse (Rust's loot, OnCorpsePopulate, whether ApplyLoot and
CopyInventoryToCorpse can be overridden, emptying and filling), Rust's
four crates with their loot off, a locked crate's per-crate timer
through its starting count, and removing a corpse. 13 of 14 on both
rigs; the kit start cannot be switched from outside the NPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 19:51:22 -05:00
d068635b45 Merge pull request 'feat: API 5, bosses, passive NPCs and the example profiles (stage 6)' (#10) from feat/stage-6 into edge
Reviewed-on: #10
2026-10-06 00:45:55 +00:00
3be2c54260 fix: an event's teardown removes a boss's adds (D287)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m33s
The walk found an event run's boss adds outliving the run: teardown
removes what a step placed by net id, and the adds RunicNPC spawned for
a boss's phase are in no step's ledger. RunicNPC now keeps each boss's
live adds by the boss's net id, after the boss dies too, and
RunicNpc_Despawn(<boss net id>) removes them with it, which is the call
the bridge's teardown makes. An add leaves the set when it dies.

Walked on the Oxide rig: event 36, run 65 placed the boss, its phase
summoned two adds owned by run:65, the boss was killed, the event
advanced on rust.boss.killed, and the run's cleanup removed both adds.
New harness check s6.boss.despawnTakesAdds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 13:06:19 -05:00
cf7f9cd38d test(harness): stage 6 group, and two checks fixed after the Rust update
The s6 group (41 checks): profile refusals, the passive role (never
fights, unhurtable by default, press E through OnPlayerInput once a
second, a window, wandering), the boss (three phases: damage, adds,
kit swap, speed, aim and ranges; its own copy of the profile; the death
hook's contributors; adds that fight on) and the example kits.

After Rust 2634.289.1 (buildid 25681086) rebuilt the navmesh,
sentry.shoots's first spot had a rock between the sentry and its
stand-in: it now uses a clear line, as the stage 5 tests do (28 hits).
move.monument.roams: the military tunnel's own 30 scientists no longer
move with no real player near, so ours standing too is the same
behaviour, and the check says both.

Two probes for that: rnt.stockprobe and rnt.monprobe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 12:48:09 -05:00
1e927f6a66 fix: the example kits' permission is kits.runicnpc (stage 6)
Kits registers a kit's RequiredPermission, and Oxide warns on every Kits
load that a permission a plugin registers must start with that plugin's
name: "Missing plugin name prefix 'kits' for permission
'runicnpc.examplekits'". The org lead chose Kits' prefix. Nobody is
granted it, so no player can claim an example kit by name; RunicNPC's
GiveKit call skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 12:14:51 -05:00
3972bccf34 feat: API 5, bosses, passive NPCs and the example profiles (stage 6)
The boss box on any role but passive (D273): a health bar for players
within its distance and everyone who hurt it, redrawn in place at most
four times a second (D274); phases at health fractions that change its
own copy of the profile (damage, aim, speed, ranges), swap its kit,
summon adds one per frame and say a line (D275); spawn and death lines
to everyone, phase lines to players near it (D277, D286); adds fight on
after it dies (D287). Hooks OnRunicNpcBossSpawned, _BossPhase and
_BossDied.

The passive role (D278, D279, D288): never fights (GetBestTarget and
AttackTick refuse, since Rust's design picks targets from every sensed
player), cannot be hurt unless its profile says so, moves as its profile
says including the new `stand`, and answers E through OnPlayerInput
with a chat line or a window. Hook OnRunicNpcUsed.

The four example profiles and their kits, written into Kits' data file
on the first load only (D280, D284, D289), each kit needing a
permission nobody holds. Kits' return re-checks the profiles.

The swap's field list regenerated for Rust 2634.289.1: two new fields,
eleven now [NonSerialized].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 10:47:00 -05:00
5274da1e69 Merge pull request 'test(harness): stage 6 spike, rnt.spike6 (D282)' (#9) from spike/stage-6 into edge
Reviewed-on: #9
2026-10-05 14:26:30 +00:00
c8db94ece4 test(harness): stage 6 spike, rnt.spike6 (D282)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
RunicNpcTest 0.6.0 gets stage 6's spike, run on both rigs against
RunicNPC API 4: press E (a use ray from a player's eyes to our NPC,
and what OnPlayerInput's not-pressed test costs), the health bar
(CuiElement.Update, the bar's JSON and what finding its viewers
costs), Kits' data file (a kit written to it, two reloads, a claim
held only in memory, real players' usage), a kit swap mid-fight
(strip, GiveKit, EquipWeapon, timed; and without EquipWeapon), and
summoned adds (4 and 10 on a ring around a fighting NPC).

The Kits reload notes whether RunicNPC was reloaded with it and what
became of its NPCs: its "// Requires: Kits" takes it down on both
frameworks.

Results: 26 of 26 on each rig (docs/runicnpc/PLAN.md stage 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 06:23:34 -05:00
cf8c661087 Merge pull request 'feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5)' (#8) from feat/stage-5 into edge
Reviewed-on: #8
2026-10-05 08:38:32 +00:00
900ac4e992 test(harness): the escort test walks its charge away from the keeper; rnt.escortprobe (stage 5)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m37s
Run alone on each rig, s5 is 52/52 on Oxide and on Carbon, and rnt.run all
was otherwise green on both (stages 2-4 unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 09:16:50 -05:00
4ea50c4fd2 fix: escort end, line of sight and rockets; test(harness): stage 5 group (stage 5)
What the rigs' stage 5 runs found in the plugin:

- an escort whose charge was destroyed was never released: Unity's ==
  says a destroyed entity is null, so CheckEscort returned before it
  looked. It now tests the reference itself (D269);
- our own fight's line of sight no longer uses Rust's CanSeeTarget for an
  NPC target: its rays are unbounded, so on open ground they run past the
  target into the terrain behind it. A bounded linecast to the target's
  centre, which counts the target's own collider as seen;
- a rocket's line of sight: Rust's sensed one for a real player, ours for
  an NPC or an animal, which are never in Rust's player memory.

tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction
table and its refusals, faction fights, the leash, shooting back, the alarm,
allies spared and defended (players and buildings), escort, the guard,
the tether, PVE, turrets (`ignore` through the hook, `always` on the map's
own sentries with the org lead's condition: no stock scientist or bandit
guard ever targeted), and the kit's extras used up. Also `rnt.duel`,
`rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 08:39:33 -05:00
21d3c477dc feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5)
Stage 5's behaviour (docs runicnpc/PLAN.md, D253-D272):

- profiles gain faction, relations (exceptions), alertRadius, turrets,
  hurtByPlayers, hurtsPlayers and kitUse, and the guard role; the faction
  table rides in profiles.json, one row per pair, both ways (D254, D268);
- our own sensing and fight beside Rust's design: only the factions a
  profile hunts, within its leash of its anchor (D263), the target made
  Horror for the length of our own shot, and Rust's attack routine;
- shooting back at whoever hurt it (D268), the alert radius (D256), clan,
  team or player allies that are never targeted and are defended with
  what they own (D257), escort as an order that walks home when its
  charge is gone (D269), and a placement's tether through ZoneManager
  (D272);
- turrets: ignore through CanBeTargeted, and always through a Harmony
  patch on the safe-zone sentries for our always NPCs only (D267);
- the kit's extras, each opted into and used up: heal, melee,
  flamethrower, grenades and rockets (D260, D265);
- TruePVE/NextGenPVE answered both ways unless a profile turns a direction
  off, and the opt-out enforced by RunicNPC itself (D261, D271);
- API 4: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether and the
  OnRunicNpcEscortEnded hook; rnpc follow, rnpc.faction and tether=.

Compiled and loaded on both rigs (Oxide and Carbon); the sentry patch
applies on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:20:59 -05:00
340f1dc8f5 Merge pull request 'test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253)' (#7) from spike/stage-5 into edge
Reviewed-on: #7
2026-10-01 08:17:26 +00:00
9424dca6d1 test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m28s
RunicNpcHarness 0.2.0 adds the measurements stage 5 opens with:

- rnh.npcfight: our own sensing and combat state against a stock
  scientist, another of ours, a boar and a wolf, and with an ally on
  the line of fire. Rust's BaseProjectile.ServerUse drops every hit on
  an NPC from an NPC's gun unless the victim's faction is Horror; the
  prototype marks only its current target Horror for the length of
  each of its own shots.
- rnh.sense: what that sensing costs, 100 against 100 scientists and
  50 against 50 of ours.
- rnh.turrets: auto turret, flame turret and shotgun trap against a
  stand-in player, a stock scientist and ours (D259).
- rnh.items: which kit items Rust's AI uses by itself (D260).
- rnh.pve: what TruePVE or NextGenPVE does with our NPC, and with an
  answer to CanEntityTakeDamage (D261).

Never shipped: a developer tool in tools/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 00:23:47 -05:00
bf2940b819 Merge pull request 'feat: API 3, placements from a website (stage 4, D249)' (#6) from feat/stage-4-api3 into edge
Reviewed-on: #6
2026-09-30 19:52:55 +00:00
e2c0db2a5d ci(release): ask the installer to recompose the Rust bundle (stage 4, D224)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
RunicNPC is the Rust bundle's third artefact from stage 4, so its release
ends as Rust-Plugins' does: a dispatch of the installer's bundle workflow,
warned rather than failed when the token cannot reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:52:36 -05:00
fe20ca41af feat: API 3, placements from a website (stage 4, D249)
RunicNpc_AddPlacement creates a placement and names it as /rnpc place
does (D241, D246). A position without y is a map point: it is put on the
ground there (terrain and rock, never a building or a tree, D245), then
checked against the navmesh like an in-game placement. It answers the id,
the grounded position, whether the spot is player-built, and the cost
warning, or the in-game refusal sentence.

RunicNpc_RenamePlacement and RunicNpc_RespawnPlacement do what rnpc
rename and rnpc respawn do. OnRunicNpcPlacementChanged(id, change,
previous) is raised on every set, remove and rename, from the API or in
game, so the bridge can tell the site at once.

rnpc place and here now share CreatePlacement with the API, so both give
the same refusals. The test harness gains an api3 group; all 157 checks
pass on the Oxide and Carbon rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:01:30 -05:00
9c366ab5b5 Merge pull request 'feat: stage 3, the /rnpc commands in game' (#5) from feat/stage-3-in-game into edge
Reviewed-on: #5
2026-09-30 08:20:17 +00:00
0a57d783ca feat: stage 3, the /rnpc commands in game
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 7s
The chat command /rnpc and the same verbs as rnpc.<verb> in a console
(PLAN.md §5), behind runicnpc.place and runicnpc.admin:

- place / here with key=value options in any order (D242); placements
  made in game are named <profile>-<n> and renamable (D241).
- remove, rename, near, info (the NPC you look at), profiles, tp,
  respawn, clear, and rnpc.profile create|show|set|delete for a
  standalone server (refused while the site manages profiles, D221).
- path record / point / undo / save [loop|back] / cancel / list /
  delete (D240). A point an NPC cannot walk to from the last one is
  refused, as is a loop that cannot close: Rust's path query says so.
- A roamer may stand on a player-built floor, with a warning (D239).
  If the floor is destroyed under a live NPC, Rust leaves it standing
  in the air, so the brain puts it on the nearest navmesh within 2 s;
  a placement whose spot is off the mesh respawns on the nearest.
- The API's SetPlacement now refuses a roamer off the navmesh, as the
  command does; placements report a note while they fall back.
- Pos.V and Profile.IsSentry no longer leak into the JSON files.

The harness gains the cmd and floor groups. Both rigs: 134/134 in one
rnt.run all, rnpc reload 5/5, server restart 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 03:17:44 -05:00
15120ef80d Merge pull request 'feat: stage 2, the NPC and its API (API 2)' (#4) from feat/stage-2-npc-api into edge
Reviewed-on: #4
2026-09-30 06:52:37 +00:00
f8084955e8 feat: stage 2, the NPC and its API (API 2)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and
ScientistBrain swapped for ours, copying a fixed field list generated from
Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in
data/RunicNPC/profiles.json in the approved shape (D238); placements persist
with each/group respawn (D236) and wait for a missing profile or route
(D237); roamers wander, follow Rust's monument paths, or walk a route (D233,
D234), with our own chase where Rust's needs an AI zone; sentries hold their
spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait
for the navmesh and are spread over frames; caps are off by default and the
cost warning is shown instead (D227). The whole PLAN.md section 4 API and its
hooks are in, documented in docs/runicnpc/API.md.

tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both
rigs, and after a plugin reload and a server restart. checkPlugin no longer
counts an override (RunicNpcPlayer.OnDied) as a hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 01:31:04 -05:00
914047f1b0 Merge pull request 'chore(tools): the stage 1 harness, RunicNpcHarness.cs' (#3) from feat/stage-1-harness into edge
Reviewed-on: #3
2026-09-30 04:26:33 +00:00
18c90961ba chore(tools): the stage 1 harness, RunicNpcHarness.cs
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
A developer plugin, never shipped, that measured the six questions stage 2
depends on (docs/runicnpc/PLAN.md §9, stage 1) on both rigs: the subclass
swap, saving across a hard kill, what Rust's brain honours, navmesh coverage,
the cost of 1/10/100 idle and fighting, and the death screen and bridge frames.

It spawns stand-in players (player.prefab, a made-up user id, IsNpc false) as
targets, at the org lead's suggestion. It needs a hidden Kits kit, rnhrevolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 23:04:46 -05:00
22 changed files with 15468 additions and 56 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Shell scripts run on Linux (the CI runner, the staging rig): a CRLF checkout breaks them.
*.sh text eol=lf

View File

@@ -0,0 +1,258 @@
# The wipe-day cutover (docs/runicnpc/PLAN.md stage 9c, D332): `edge` merges into `main` by itself.
#
# The org lead approves no merge on a wipe day. Rust changes on the monthly forced wipe, the first
# Thursday (D318), and RunicNPC must be proven on the public build before it is released. This job runs
# hourly from 17:00 UTC on that Thursday until Sunday 23:00 UTC and merges `edge` into `main` once five
# gates have passed, cheapest first (the decisions are pure functions in tools/drill/cutover.js, tested
# by cutover.test.js, which PR Checks runs):
#
# 1. public Steam set Rust's public branch to its build on the wipe day or later (steamcmd's app info,
# branches.public.timeupdated, which the static half keeps as appinfo.txt) AND the Linux depot
# manifest Steam names for public is the one the static half downloaded. (The first design used
# the manifest's date and staging's manifest; neither works, see tools/drill/cutover.js.)
# 2. oxide Oxide's public build has caught up (0 members behind), the swap's field list is unchanged
# and RunicNPC compiles.
# 3. carbon `rust-carbon` restarts on public Rust with edge's RunicNPC.cs: its appmanifest names the
# public manifest, and rnpc.status says framework=carbon, navmesh=ready, missing=- added=-.
# The rig is left as it was found (running stays running).
# 4. live the drill's live half (drill.js live --branch public) on `rust-staging`: every harness check
# passes, no finding.
# 5. checks `plugin-checks` is green on the cutover PR's head commit, which must be the commit the gates
# ran on. Never kept: it runs fresh, right before the merge.
#
# Each passed gate is kept in drill.json (the `cutover` key, on the `drill` branch) for that wipe, that
# public manifest and that `edge` commit; a change of any of them starts the gates again. A failed carbon or
# live attempt is not repeated for three hours for the same manifest and commit. As soon as gate 1 passes it
# opens ONE edge -> main PR (or adopts the open one: its title is never changed and only the block between
# <!-- cutover:start --> and <!-- cutover:end --> of its body is written) whose block carries a `Closes #N`
# line for every issue fixed by the PRs merged into `edge` since the last cutover (merging into `main`, the
# default branch, is what closes them). When a gate blocks it comments on that PR once a day; at Sunday
# 23:00 UTC it stops with a last comment and the org lead merges by hand, as before. The merge sends the
# head commit it checked, so Gitea refuses it if the PR moved. After the merge it waits for release.yml's
# release and comments the version on each issue that closed.
#
# Sequencing inside the job: `gates` runs 1-3 and stops in front of 4, saying live=true; the live half is
# its own step (drill.js live); `gates --after-live` records 4 and runs 5. The state is pushed to the
# `drill` branch after the first `gates` too, so a long live half that runs into the job timeout does not
# lose gate 3. A last always-run step starts `rust-carbon` again if it was running when the job began and is
# offline now (that server only, power `start` only).
#
# THE MAIN / EDGE CONTRACT. The copy of this file on `main` is the trigger and the step wiring (Gitea fires
# a schedule only from the default branch). Every step checks out `edge` and runs edge's scripts, plugin
# and harness: the gates and the decisions come from edge. The step ids and the output names (open, run,
# live, merge, pr, sha, merged) are a contract between the two: cutover.test.js reads this file and fails
# when one is used here but not written by tools/drill/cutover.js, so change both on edge and carry the
# new wiring to `main` in the same release. Because edge's code runs with RUNICNPC_TOKEN in the merge step,
# PR review into `edge` is the real gate: nothing reaches `edge` unreviewed.
#
# It shares the staging drill's concurrency group, so it never runs while the daily drill has `rust-staging`.
#
# Try it: Actions -> Wipe-day cutover -> Run workflow. A dispatch is a DRY RUN unless `dry_run` is
# explicitly false (anything else, or nothing, is dry): every gate runs and reports in the job summary (it
# does restart rust-carbon and use rust-staging); no PR is opened or edited, nothing is commented or merged,
# no state is written. `force` goes outside the wipe weekend.
#
# Secrets (Settings -> Actions -> Secrets, the repository's or the organisation's):
# PTERODACTYL_DRILL_KEY — the panel client key made for the drill (D309). Never printed.
# REGISTRY_USER / REGISTRY_TOKEN — as the drill: push the `drill` branch, open and edit the PR, comment.
# RUNICNPC_TOKEN — a personal access token locked to this repository, used by the merge step ALONE: a
# push made with the runner's own token would not start release.yml.
name: Wipe-day cutover
on:
schedule:
# Thursday from 17:00 UTC, then every hour of Friday, Saturday and Sunday. (Not one line with a day of
# the month as well: cron ORs the two.) The window step ends the runs that fall outside the weekend.
- cron: '11 17-23 * * 4'
- cron: '11 * * * 5,6,0'
workflow_dispatch:
inputs:
dry_run:
description: 'Run every gate and report; open, edit, comment and merge nothing (dry unless exactly false)'
type: boolean
default: true
force:
description: 'Work outside the wipe weekend'
type: boolean
default: false
code_ref:
description: 'Branch whose scripts and plugin a DRY run uses, to rehearse a change before it reaches edge (a real run always uses edge)'
type: string
default: 'edge'
concurrency:
group: staging-drill
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/runicnpc-rust
jobs:
cutover:
runs-on: ubuntu-latest
# Carbon's restart and navmesh, the live half (about an hour), the PR checks: about 100 minutes, 200 at the
# worst. Room for that, so a timeout never cuts off the step that starts rust-carbon again.
timeout-minutes: 240
env:
WORK: ${{ github.workspace }}/cutover-work
# cutover.js treats a dispatch as dry unless this is exactly 'false'.
DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
steps:
# edge, always, except a dry dispatch naming another branch (code_ref). A real run on another branch could
# not merge anyway: the merge is pinned to the commit the gates ran on, which must be edge's head.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' && github.event.inputs.code_ref || 'edge' }}
fetch-depth: 0
# The commit counts and the cut-off for the Closes list are read from git.
- name: main and the tags
run: git fetch origin main edge --tags
# 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 wipe weekend?
id: window
run: node tools/drill/cutover.js window ${{ github.event.inputs.force == 'true' && '--force' || '' }}
- name: Anything to merge?
id: pending
if: ${{ steps.window.outputs.open == 'true' }}
run: node tools/drill/cutover.js pending ${{ (github.event.inputs.force == 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false')) && '--force' || '' }}
# Whether rust-carbon is running now: gate 3 restarts it and the last step puts it back if needed.
- name: Note rust-carbon
if: ${{ steps.pending.outputs.run == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/cutover.js rig-note --work "$WORK"
# tools/fieldlist and the compiler (Roslyn's csc.dll ships in the SDK).
- name: .NET SDK
if: ${{ steps.pending.outputs.run == '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 (what the cutover has passed)
if: ${{ steps.pending.outputs.run == '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
# With BRANCH=public it also keeps Steam's app info (appinfo.txt) for gate 1.
- name: Static half (Rust's public build)
if: ${{ steps.pending.outputs.run == 'true' }}
env:
BRANCH: public
LC_ALL: C
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'
run: bash tools/drill/static.sh
- name: Gates 1-3 (public build, Oxide, Carbon)
id: gates
if: ${{ steps.pending.outputs.run == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: >-
node tools/drill/cutover.js gates --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}
- name: Keep the gates passed so far on the drill branch
if: ${{ always() && steps.pending.outputs.run == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
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 drill.json
if git diff --cached --quiet; then echo "no change"; exit 0; fi
node -e "const c=require('./drill.json').cutover||{};console.log('chore(drill): cutover '+(c.wipe||'?')+' '+(c.manifest||'?')+' [skip ci]')" > ../cutover-msg.txt
git commit -q -F ../cutover-msg.txt
git push origin drill
- name: Gate 4, the drill again on live Rust (rust-staging)
id: live
if: ${{ steps.gates.outputs.live == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/drill.js live --work "$WORK" --branch public
- name: Gates 4-5 (the live half, PR checks)
id: gates2
if: ${{ always() && steps.gates.outputs.live == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: >-
node tools/drill/cutover.js gates --after-live --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}
# The only step that holds RUNICNPC_TOKEN. It merges the commit the gates ran on, or nothing.
- name: Merge edge into main
id: merge
if: ${{ (steps.gates2.outputs.merge == 'true' || steps.gates.outputs.merge == 'true') && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
env:
MERGE_TOKEN: ${{ secrets.RUNICNPC_TOKEN }}
run: node tools/drill/cutover.js merge --pr ${{ steps.gates2.outputs.pr || steps.gates.outputs.pr }} --sha ${{ steps.gates2.outputs.sha || steps.gates.outputs.sha }} --state drill-state/drill.json
- name: Record the merge and the release
if: ${{ steps.merge.outputs.merged == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: node tools/drill/cutover.js after --state drill-state/drill.json --pr ${{ steps.gates2.outputs.pr || steps.gates.outputs.pr }}
- name: Keep the result on the drill branch
if: ${{ always() && steps.pending.outputs.run == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
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 drill.json
if git diff --cached --quiet; then echo "no change"; exit 0; fi
node -e "const c=require('./drill.json').cutover||{};console.log('chore(drill): cutover '+(c.wipe||'?')+' '+(c.manifest||'?')+' [skip ci]')" > ../cutover-msg.txt
git commit -q -F ../cutover-msg.txt
git push origin drill
# Last: gate 3 and the live half stop and start rust-carbon; if it was running when the job began
# and is offline now, start it. That server only, power `start` only.
- name: Leave rust-carbon as found
if: ${{ always() && steps.pending.outputs.run == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/cutover.js rig-restore --work "$WORK"

View File

@@ -57,3 +57,12 @@ jobs:
# not portable across the Node versions this project runs on. # not portable across the Node versions this project runs on.
- name: Test the checker itself - name: Test the checker itself
run: node --test scripts/checkPlugin.test.js run: node --test scripts/checkPlugin.test.js
# The staging drill's pure parts (tools/drill/drill.js): its stall limit and fresh-data list.
- name: Test the staging drill
run: node --test tools/drill/drill.test.js
# The wipe-day cutover's pure parts (tools/drill/cutover.js, D332): the weekend, the five gates'
# verdicts, the issues the PR closes and what the hourly run does next.
- name: Test the wipe-day cutover
run: node --test tools/drill/cutover.test.js

View File

@@ -38,9 +38,12 @@
# sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs # 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. # 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 # 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third
# makes it a third artefact of the Rust bundle (D224). That stage adds the # artefact (D224): the installer's compose job carries the latest RunicNPC
# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose. # 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 # 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 # `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 # Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or
# the organisation's): # the organisation's):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the # 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. # REGISTRY_USER — the Gitea username that token belongs to.
name: Release plugin name: Release plugin
@@ -71,6 +76,7 @@ concurrency:
env: env:
GITEA_HOST: gitea.whitlocktech.com GITEA_HOST: gitea.whitlocktech.com
INSTALLER_REPO: RunicGateway/installer
REPO: RunicGateway/runicnpc-rust REPO: RunicGateway/runicnpc-rust
ARTIFACT: runicnpc ARTIFACT: runicnpc
PLUGIN: plugin/RunicNPC.cs PLUGIN: plugin/RunicNPC.cs
@@ -474,3 +480,27 @@ jobs:
fi fi
echo " uploaded ${f}" echo " uploaded ${f}"
done 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

View File

@@ -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

136
README.md
View File

@@ -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 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). [`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 > **Status: v1.0.0, stage 9's release.** The NPC, its profiles, placements and routes; factions,
> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. > 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 ## Requirements
@@ -21,8 +24,8 @@ website authors its NPC profiles and events use its NPCs. The plan of record, st
## Installing it ## Installing it
On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) 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 and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other
stage 4). Until then, and on any other server: server:
1. Download `runicnpc-<version>.tar.gz` and `SHA256SUMS` from this repository's 1. Download `runicnpc-<version>.tar.gz` and `SHA256SUMS` from this repository's
[releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the [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 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. renamed it, because neither framework reports a hook that matches nothing.
## In game
`/rnpc` in chat, and the same verbs as `rnpc.<verb>` in a console (F1, the server console or RCON).
`/rnpc` alone lists the ones you may use. Grant `runicnpc.place` to place and manage NPCs, and
`runicnpc.admin` for everything (it includes `runicnpc.place`); the server console has both.
```
/rnpc place bandit count=3 respawn=300 mode=group move=route:gate
> Placed bandit-1: 3 × 'bandit' (roamer, route:gate), respawn 300 s, group.
/rnpc path record gate then walk, and at each point: /rnpc path point
/rnpc path save loop (or back, to walk it back and forth)
/rnpc near · /rnpc info · /rnpc rename bandit-1 gateguards · /rnpc remove
```
`place` uses the spot you look at, `here` the spot you stand on; the server console gives
`at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement
answers with what the server's NPCs cost. On a standalone server, profiles are made from a console
with `rnpc.profile create|set|delete`. 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 ## For other plugins
Every call is prefixed `RunicNpc_` and reached through `Call`: 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<int>("RunicNpc_ApiVersion") ?? 0; int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
``` ```
Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be The API is version 6, documented in
documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md):
changes shape, not on every release. 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 ## Repository layout
@@ -64,13 +92,103 @@ changes shape, not on every release.
| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `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. | | `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). | | `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=58/58 brain=32/32
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it:
```bash
node tools/managed.js carbon <dir> # the Carbon rig's RustDedicated_Data/Managed
dotnet run --project tools/fieldlist -- <dir> # rewrites the block in plugin/RunicNPC.cs
```
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which
fields Rust itself serialises.
## 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` (58 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`. It starts the server from fresh Kits and RunicNPC data
(RunicNPC's files are deleted and Kits' file holds only the harness's kit, so the example kits are written
afresh) and gives up on the harness after
5 minutes with no new output (45 minutes at most), putting the tail of the server's log in the issue. 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 ## Releases
Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a 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 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 ## Contributing

View File

@@ -22,8 +22,18 @@
# a RunicNPC too old for the bridge it is pairing with. Declaring it here is what # 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. # lets a bundle check the pair BEFORE an operator installs it.
# #
# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
api = 1 # 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 ───────────────────────────────────────────────────────── # ── 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 # `doctor`, which reports a missing one rather than installing it. The PR check
# holds this list and the plugin's `// Requires:` lines equal. # holds this list and the plugin's `// Requires:` lines equal.
# #
# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a # ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6). A placement
# profile that asks for one on a server without it is refused when it is saved. # or an event step that asks for one on a server without it is refused (D272).
requires_plugins = ["Kits"] requires_plugins = ["Kits"]

File diff suppressed because it is too large Load Diff

View File

@@ -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 * 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. * 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). */ /** Plugins RunicNPC must require, whatever else it requires (D217). */
const REQUIRED_PLUGINS = ['Kits'] const REQUIRED_PLUGINS = ['Kits']
@@ -179,7 +193,11 @@ function check(source, toml) {
} }
const methods = readMethods(source) 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)) const hookNames = new Set(hooks.map((x) => x.name))
// 1. Every hook the plugin implements is one `rnpc.status` can report on. // 1. Every hook the plugin implements is one `rnpc.status` can report on.

View File

@@ -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 // `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 // 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. // 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; return true;
}` }`
const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) const problems = check(source({ expected: ['CanLootEntity'], methods }), toml())
assert.match(problems[0], /CanBeTargeted returns bool, not void/) assert.match(problems[0], /CanLootEntity returns bool, not void/)
}) })
test('a method that is not shaped like a hook is left alone', () => { 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')) 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 ──────────────────────────────────────────────────────────────── // ── The API ────────────────────────────────────────────────────────────────
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { 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 // Raise these floors as the plugin grows; they are what stops a regex that
// matches nothing from passing every case above. // 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('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') 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) 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_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.ok(readExpectedHooks(real).length >= 1)
assert.deepEqual(readRequires(real), ['Kits']) assert.deepEqual(readRequires(real), ['Kits'])

2063
tools/RunicNpcHarness.cs Normal file

File diff suppressed because it is too large Load Diff

3461
tools/RunicNpcTest.cs Normal file

File diff suppressed because it is too large Load Diff

885
tools/drill/cutover.js Normal file
View File

@@ -0,0 +1,885 @@
// The wipe-day cutover (docs/runicnpc/PLAN.md stage 9c, D332). Never shipped.
//
// .gitea/workflows/cutover.yml runs it hourly over the forced-wipe weekend. It merges `edge` into `main`
// once Rust's wipe update is live and RunicNPC is proven on it, so nobody approves a merge on a wipe day.
//
// node tools/drill/cutover.js window [--force] [--now ISO] in the wipe weekend?
// node tools/drill/cutover.js pending [--force] does edge have commits main lacks?
// (tools/drill/static.sh with BRANCH=public) the static half + Steam's app info
// node tools/drill/cutover.js rig-note --work <dir> is rust-carbon running now?
// node tools/drill/cutover.js gates --state <drill.json> --work <dir> [--dry-run] [--force] [--after-live]
// (--staging-work is still accepted and ignored: main's wiring passes it)
// node tools/drill/drill.js live --work <dir> --branch public the live half (only when `gates` says)
// node tools/drill/cutover.js merge --pr <n> --sha <sha> [--state f] MERGE_TOKEN only
// node tools/drill/cutover.js after --state <drill.json> --pr <n> record it, comment the release
// node tools/drill/cutover.js rig-restore --work <dir> start rust-carbon if the job stopped it
//
// The five gates, cheapest first; it merges only when all pass (the later ones need the earlier ones):
// public Steam set Rust's public branch to its build on the wipe day or later (branches.public.timeupdated
// in steamcmd's app info, which static.sh keeps as appinfo.txt), AND the Linux depot manifest Steam
// names for public is the one the static half downloaded. Not the depot manifest's date: that is
// when Facepunch BUILT it (the 2026-11-05 wipe build was dated 11-04). Not staging's manifest:
// public never shares one with staging (Facepunch promotes a hidden `release` branch, and staging
// has moved on by then). Both were the first design and blocked the whole 2026-11-05 weekend.
// oxide Oxide caught up, the swap's field list is unchanged, RunicNPC compiles (static.json).
// carbon rust-carbon restarts on public Rust with edge's RunicNPC.cs. Its appmanifest must name the public
// manifest (it really updated); then rnpc.status says framework=carbon, navmesh=ready, missing=-
// added=- (D232: only Carbon's unmodified assembly makes `added` mean anything).
// live drill.js live --branch public on rust-staging: every harness check passes, no finding.
// checks the cutover PR's combined status on its head commit is success and has a `plugin-checks`
// context, AND the PR head is the commit the gates ran on.
//
// The gates are tied to the code: drill.json keeps `edgeSha` (the commit this job checked out and ran the
// plugin and harness from) and the manifest; a change of either, or of the wipe, clears every gate. `checks`
// is never kept: it runs fresh, right before the merge, and the merge is sent `head_commit_id` so Gitea refuses
// it when the PR head moved. The state is written after EVERY gate result, before anything touches the PR.
//
// A carbon or live attempt that fails is not repeated for the same manifest and commit for COOL_DOWN_HOURS
// (3): the rigs are not restarted every hour. A new manifest or a new edge commit retries at once.
//
// How the gates are sequenced across the workflow's steps. `gates` is run twice at most:
// 1. `gates` runs public, oxide, carbon in this job and stops in front of live, saying
// `live=true`. A gate that fails ends the run (retried next hour, a cooling one
// after the cool-down); a gate that passed is kept in drill.json, never re-run.
// 2. `drill.js live` is its own workflow step; its output is a file (live.json).
// 3. `gates --after-live` reads live.json (the live gate), then runs checks. Both say `merge=true pr=<n>
// sha=<edge sha>` when everything has passed.
// When live already passed (an earlier hour), step 1 goes on to checks by itself and the other two steps
// are skipped. The cutover PR is opened (or its marked block updated) as soon as the public gate has passed;
// an existing open edge to main PR is adopted: its title is never changed and only the block between
// <!-- cutover:start --> and <!-- cutover:end --> of its body is written (appended when absent).
//
// A dry run (--dry-run, or any workflow_dispatch whose dry_run input is not exactly 'false') runs every gate
// it can instead of stopping at the first failure, ignores what an earlier real run kept, and creates, edits,
// comments and merges nothing and writes no state. It does use the rigs: carbon restarts rust-carbon and live
// uses rust-staging. The table goes to the job summary.
//
// Every gate check below returns `true`, or a string saying why not.
//
// Environment: GITEA_TOKEN (the workflow's REGISTRY_TOKEN: read, open and edit the PR, comment) with
// GITHUB_SERVER_URL + GITHUB_REPOSITORY as Gitea Actions sets them; PANEL_KEY / PANEL_URL as drill.js;
// MERGE_TOKEN (RUNICNPC_TOKEN) in the `merge` step only; GITHUB_EVENT_NAME + DRY_RUN_INPUT for the dry-run
// default. No token or key is ever printed.
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
const ROOT = path.resolve(__dirname, '..', '..')
const CARBON_RIG = process.env.CUTOVER_CARBON_RIG || 'rust-carbon'
const CARBON_PLUGIN = '/carbon/plugins/RunicNPC.cs'
const ACF = '/steamapps/appmanifest_258550.acf'
const LINUX_DEPOT = '258552'
const GATES = ['public', 'oxide', 'carbon', 'live', 'checks']
const PERSISTED = GATES.slice(0, 4) // checks is never kept
const COOLING = ['carbon', 'live'] // the gates that cost a rig restart
const COOL_DOWN_HOURS = 3
const GATE_LABELS = {
public: "1. Steam set Rust's public branch on the wipe day or later",
oxide: '2. Oxide has caught up; RunicNPC compiles',
carbon: '3. A Carbon boot on public Rust',
live: '4. The drill again, on live Rust',
checks: '5. PR checks (plugin-checks) on the head commit',
}
const BLOCK_START = '<!-- cutover:start -->'
const BLOCK_END = '<!-- cutover:end -->'
const H = 3600000
const CHECKS_WAIT_MS = 15 * 60000
const RELEASE_WAIT_MS = 30 * 60000
// ---------------------------------------------------------------- pure parts (cutover.test.js)
const day = (d) => d.toISOString().slice(0, 10)
/** A reason in a PR body or a comment must not hold a closing keyword Gitea would act on: `#123` is broken up. */
const neutralise = (text) => String(text == null ? '' : text).replace(/#(\d)/g, '#&#8203;$1')
function firstThursday(y, m) {
const d = new Date(Date.UTC(y, m, 1))
d.setUTCDate(1 + ((4 - d.getUTCDay() + 7) % 7))
return d
}
/**
* The forced wipe of `now`'s month (its first Thursday, D318) and the weekend that follows. `open` is the
* span the cutover works in: from the wipe Thursday 17:00 UTC to Sunday 23:00 UTC (`deadline`). `final` is
* the hour after it, in which the hourly run still starts once more so it can give up out loud. (nextWipe
* in drill.js cannot do this: on the Thursday itself it answers next month.)
*/
function wipeWeekend(now) {
const wipe = firstThursday(now.getUTCFullYear(), now.getUTCMonth())
const t = now.getTime()
const start = wipe.getTime() + 17 * H
const deadline = new Date(wipe.getTime() + (3 * 24 + 23) * H)
return { open: t >= start && t < deadline.getTime(), final: t >= deadline.getTime() && t < deadline.getTime() + H, wipe, deadline }
}
/**
* One branch of Rust's dedicated server (app 258550) from steamcmd's `+app_info_print 258550` output (the
* appinfo.txt static.sh keeps; steamcmd's own lines come before the `"258550"` block and are skipped):
* `timeupdated`, when Steam set the branch to its build (Unix seconds); its `buildid`; and the manifest Steam
* names for the Linux depot on that branch (`gid`, or the older bare-string form). null when there is no
* 258550 block or no such branch.
*/
function steamBranch(text, branch = 'public', depot = LINUX_DEPOT) {
const s = String(text || '')
const at = s.search(/^"258550"[ \t]*\r?$/m)
if (at < 0) return null
const app = parseVdf(s.slice(at))['258550']
const depots = app && typeof app === 'object' ? app.depots : null
const b = depots && depots.branches && depots.branches[branch]
if (!b || typeof b !== 'object') return null
const m = depots[depot] && depots[depot].manifests && depots[depot].manifests[branch]
return {
buildid: b.buildid || null,
timeupdated: b.timeupdated === undefined ? NaN : Number(b.timeupdated),
manifest: (m && typeof m === 'object' ? m.gid : m) || null,
}
}
const iso = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
/**
* Gate public: Steam set the public branch on the wipe day (a Date, or YYYY-MM-DD) or later, UTC, and the
* Linux depot manifest it names for public is the one the static half downloaded (`stat.manifest`): the two
* are read a minute apart, so a build pushed in between blocks one hour rather than tests the wrong build.
*/
function publicGate(stat, steam, wipe) {
const w = wipe instanceof Date ? wipe.getTime() : Date.parse(wipe)
if (!Number.isFinite(w)) return `no usable wipe date (${wipe})`
if (!steam) return `Steam's app info for Rust could not be read (${(stat && stat.steamNote) || 'no appinfo.txt'})`
const t = steam.timeupdated * 1000
if (!Number.isFinite(t) || t <= 0) return "Steam's app info gives no time for Rust's public branch"
if (t < w) return `Steam last set Rust's public branch (build ${steam.buildid || '?'}) at ${iso(t)}, before the wipe (${day(new Date(w))} 00:00 UTC)`
if (!steam.manifest) return `Steam's app info names no ${LINUX_DEPOT} manifest for public`
if (steam.manifest !== (stat && stat.manifest)) {
return `Steam names manifest ${steam.manifest} for public, but the static half downloaded ${(stat && stat.manifest) || 'none'}: the next hour tries again`
}
return true
}
/** Gate oxide: true, or why an Oxide server (or the plugin's swap) cannot be trusted on this build. */
function oxideReady(stat) {
const s = stat || {}
if (s.managed !== 'ok') return `the static half could not fetch Rust's public build (${s.note || 'no static.json'})`
if (s.oxideBehind === -1 || s.oxideBehind === undefined) return "Oxide's public build could not be compared with Rust's"
if (s.oxideBehind !== 0) return `Oxide's public build is ${s.oxideBehind} member(s) behind`
if (s.compile !== 'ok') return `RunicNPC does not compile against public Rust with Oxide's build (compile ${s.compile})`
if (s.fields === 'differs') return "the swap's field list differs from public Rust's"
if (s.fields !== 'same') return `the swap's field list could not be checked (fields ${s.fields})`
return true
}
/** Valve's text KeyValues (an appmanifest .acf) as nested objects. */
function parseVdf(text) {
const root = {}
const stack = [root]
let key = null
for (const m of String(text || '').matchAll(/"((?:[^"\\]|\\.)*)"|([{}])/g)) {
const top = stack[stack.length - 1]
if (m[2] === '{') {
const o = {}
top[key] = o
stack.push(o)
key = null
} else if (m[2] === '}') {
if (stack.length > 1) stack.pop()
key = null
} else if (key === null) key = m[1]
else {
top[key] = m[1]
key = null
}
}
return root
}
/** The manifest an appmanifest_258550.acf records for a depot (InstalledDepots, "258552", "manifest"), or null. */
function acfDepotManifest(text, depot = LINUX_DEPOT) {
const state = parseVdf(text).AppState
const entry = state && state.InstalledDepots && state.InstalledDepots[depot]
return (entry && typeof entry === 'object' && entry.manifest) || null
}
/** Gate carbon, first half: the rig's installed Linux depot is the public manifest. */
function carbonBuild(acfText, publicManifest) {
const have = acfDepotManifest(acfText)
if (!have) return `the ${LINUX_DEPOT} manifest could not be read from ${ACF} on ${CARBON_RIG}`
if (have !== publicManifest) return `${CARBON_RIG} still runs manifest ${have} (public is ${publicManifest})`
return true
}
/** Gate carbon, second half: the `rnpc.status` console lines (an array, or the text) of a Carbon server. */
function carbonOk(statusLines) {
const lines = Array.isArray(statusLines) ? statusLines : String(statusLines || '').split('\n')
const line = lines.find((l) => /framework=/.test(l))
if (!line) return 'rnpc.status never answered on Carbon'
const get = (k) => (new RegExp(`${k}=(\\S+)`).exec(line) || [])[1]
const framework = get('framework')
if (framework !== 'carbon') return `rnpc.status says framework=${framework}, not carbon`
if (get('navmesh') !== 'ready') return `the navmesh was not ready on Carbon (navmesh=${get('navmesh')})`
const missing = get('missing')
const added = get('added')
if (missing === undefined || added === undefined) return 'rnpc.status carried no swap-fields report'
if (missing !== '-') return `the swap's fields are missing on Carbon: ${missing}`
if (added !== '-') return `public Rust's Carbon assembly has fields the swap does not know: ${added}`
return true
}
/** Gate live: live.json as drill.js live wrote it. */
function liveOk(live) {
if (!live) return 'the live half wrote no live.json'
const first = (live.findings && live.findings[0]) || null
if (live.branch && live.branch !== 'public') return `live.json is from the ${live.branch} branch, not public`
if (!live.ran) return live.skipped || first || 'the live half did not run the harness'
if (!live.harness || !live.harness.done) return first || 'the harness did not finish'
if (live.harness.fail > 0) return `a harness check failed: ${(live.harness.failed && live.harness.failed[0]) || `${live.harness.fail} fail`}`
if (!(live.harness.pass > 0)) return 'the harness passed no check at all'
if (first) return first
return true
}
/** Gate checks: where the cutover PR's combined commit status stands. { state: success|pending|failed, reason } */
function checkState(combined) {
const statuses = (combined && combined.statuses) || []
const plugin = statuses.filter((s) => String(s.context || '').includes('plugin-checks'))
const bad = statuses.find((s) => ['failure', 'error'].includes(s.status || s.state))
if (bad) return { state: 'failed', reason: `${bad.context} is ${bad.status || bad.state}` }
if (!plugin.length) return { state: 'pending', reason: 'no plugin-checks status has been reported yet' }
if (combined.state !== 'success') return { state: 'pending', reason: `the PR's combined status is ${combined.state}` }
return { state: 'success', reason: '' }
}
/** Issue numbers a PR body closes: a Gitea closing keyword directly before `#N`. Sorted, unique. */
function collectCloses(prBodies) {
const found = new Set()
const re = /(?<![\w-])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi
for (const body of prBodies) for (const m of String(body || '').matchAll(re)) found.add(Number(m[1]))
return [...found].sort((a, b) => a - b)
}
/** The cutover PR's description (the block). `gates` is [{ name, status, note }]. */
function cutoverBody({ wipe, manifest, buildDate, closes = [], gates = [] }) {
return [
'The automated wipe-day cutover (D332, `docs/runicnpc/PLAN.md` stage 9c): `edge` merges into `main` once Rust\'s ' +
'forced-wipe update is live and RunicNPC is proven on it, so no merge needs approving on a wipe day. ' +
'The workflow merges this PR by itself when every gate below has passed; `release.yml` then cuts the release.',
'',
`Wipe: **${wipe}**. Rust's public build: manifest **${manifest}**, dated ${buildDate || '?'}.`,
'',
'| Gate | State | Detail |',
'| --- | --- | --- |',
...gates.map((g) => `| ${g.name} | ${g.status} | ${neutralise(g.note || '').replace(/\|/g, '/').replace(/\r?\n/g, ' ')} |`),
'',
closes.length ? 'Issues fixed by the PRs merged into `edge` since the last cutover:' : 'No issue is closed by this release.',
...closes.map((n) => `Closes #${n}`),
'',
'Opened by the runicnpc-rust cutover workflow (D332).',
].join('\n')
}
/** `existing` with the cutover block set to `content`: replaced between the markers, or appended when absent. */
function withBlock(existing, content) {
const block = `${BLOCK_START}\n${content}\n${BLOCK_END}`
const text = String(existing || '')
const a = text.indexOf(BLOCK_START)
const b = text.indexOf(BLOCK_END, a + 1)
if (a >= 0 && b > a) return text.slice(0, a) + block + text.slice(b + BLOCK_END.length)
return text.trim() ? `${text.replace(/\s+$/, '')}\n\n${block}` : block
}
const nextGate = (gates) => PERSISTED.find((g) => !(gates || {})[g])
/** A gate passed: the state with it kept (and its failure forgotten). */
function pass(state, gate, now) {
const { [gate]: _gone, ...failed } = state.failed || {}
return { ...state, gates: { ...state.gates, [gate]: { at: now.toISOString() } }, failed }
}
/** A cooling gate failed: when, and why. */
function fail(state, gate, reason, now) {
return { ...state, failed: { ...state.failed, [gate]: { at: now.toISOString(), reason: String(reason) } } }
}
/** What goes into drill.json: never `checks`. */
function persistable(state, pr) {
const { checks: _checks, ...gates } = state.gates || {}
return { ...state, gates, pr: pr ? pr.number : null }
}
/**
* What the hourly run does next, as data. `state` is the `cutover` object kept in drill.json:
* { wipe: 'YYYY-MM-DD', manifest, edgeSha, gates: { public, oxide, carbon, live: { at } }, failed: { carbon, live: { at, reason } },
* pr, lastComment: 'YYYY-MM-DD', merged, gaveUp, mergeError }
* `obs`: { manifest, edgeSha, ahead (commits edge has that main lacks), openPr (number or null), forced,
* checksPassed (this run, never kept), blocked: { gate, reason } }.
* Returns { state, action, gate, reason, retryAt, write, comment, merge }: `action` is nothing | giveUp | run |
* blocked | cooling | merge; `write`, `comment` and `merge` say whether the CLI may persist the state, comment,
* merge (a dry run: none).
*/
function decide(state, obs, now, { dryRun = false } = {}) {
const w = wipeWeekend(now)
const wipe = day(w.wipe)
let s = state || {}
if (s.wipe !== wipe) s = { wipe, manifest: obs.manifest, edgeSha: obs.edgeSha, gates: {}, failed: {} } // a new wipe starts over
else if ((obs.manifest && s.manifest !== obs.manifest) || (obs.edgeSha && s.edgeSha !== obs.edgeSha)) {
// A new build, or new code: every gate again, and the failures that cooled them are forgotten.
const { mergeError: _e, ...rest } = s
s = { ...rest, manifest: obs.manifest || s.manifest, edgeSha: obs.edgeSha || s.edgeSha, gates: {}, failed: {} }
}
const { checks: _checks, ...gates } = s.gates || {}
s = { ...s, gates, failed: s.failed || {}, pr: obs.openPr || null } // the PR is kept while it is open
const out = { state: s, write: !dryRun, comment: false, merge: false }
// One comment a day, none before the build is known to have changed (a quiet Thursday morning), none without a PR.
const hold = (action, gate, reason, retryAt) => {
const due = !dryRun && !!s.pr && !!s.gates.public && s.lastComment !== day(now)
return { ...out, state: due ? { ...s, lastComment: day(now) } : s, action, gate, reason, retryAt, comment: due }
}
if (s.merged) return { ...out, action: 'nothing', reason: 'already merged' }
if (obs.ahead === 0 && !dryRun) return { ...out, action: 'nothing', reason: 'edge has no commits main lacks' }
if (now.getTime() >= w.deadline.getTime() && !obs.forced) {
if (s.gaveUp) return { ...out, action: 'nothing', reason: 'gave up already' }
return { ...out, state: { ...s, gaveUp: true }, action: 'giveUp', gate: nextGate(s.gates), comment: !dryRun && !!s.pr }
}
if (obs.blocked) {
const retryAt = COOLING.includes(obs.blocked.gate) ? new Date(now.getTime() + COOL_DOWN_HOURS * H) : null
return hold('blocked', obs.blocked.gate, obs.blocked.reason, retryAt)
}
const gate = nextGate(s.gates)
if (!gate) return obs.checksPassed ? { ...out, action: 'merge', merge: !dryRun } : { ...out, action: 'run', gate: 'checks' }
const failed = COOLING.includes(gate) && s.failed[gate]
if (failed && now.getTime() - Date.parse(failed.at) < COOL_DOWN_HOURS * H) {
return hold('cooling', gate, failed.reason, new Date(Date.parse(failed.at) + COOL_DOWN_HOURS * H))
}
return { ...out, action: 'run', gate }
}
/** The comment for a decision that asks for one. */
function commentText(d, state = {}) {
const label = GATE_LABELS[d.gate] || d.gate
if (d.action === 'giveUp') {
const head = 'The wipe-day cutover (D332) stops here: the window closed (Sunday 23:00 UTC).'
const tail = 'Merge `edge` into `main` by hand when it is ready.'
if (state.mergeError) return `${head} Every gate passed; the merge failed: ${neutralise(state.mergeError)}. ${tail}`
if (!d.gate) return `${head} Every gate passed, but the PR checks and the merge did not complete. ${tail}`
return `${head} Gate "${label}" had not passed. ${tail}`
}
const when = d.retryAt ? ` It will not try this gate again before ${d.retryAt.toISOString().slice(0, 16).replace('T', ' ')} UTC.` : ' It tries again every hour until Sunday 23:00 UTC.'
return `The wipe-day cutover (D332) is waiting at gate "${label}": ${neutralise(d.reason)}.${when} This is the day's only comment.`
}
/** Whether a dispatch is a dry run: unless its dry_run input is exactly 'false' it is. (A schedule never is.) */
function isDry(a, env) {
return !!a['dry-run'] || (env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.DRY_RUN_INPUT !== 'false')
}
// ---------------------------------------------------------------- plumbing
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const log = (...a) => console.log(`[cutover ${new Date().toISOString().slice(11, 19)}]`, ...a)
/** What the commands reach outside themselves for; cutover.test.js passes fakes. */
function realDeps() {
return {
env: process.env,
now: () => new Date(),
sleep,
fetch: (u, o) => fetch(u, o),
git: (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8' }).trim(),
panel: () => require('./drill.js').panel(),
}
}
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
}
}
const writeJson = (f, data) => fs.writeFileSync(f, JSON.stringify(data, null, 2) + '\n')
const output = (env, name, value) => {
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`)
}
const summary = (env, md) => {
if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, md + '\n')
}
function aheadCount(a, deps) {
if (a.ahead !== undefined && a.ahead !== true) return Number(a.ahead)
return Number(deps.git('rev-list', '--count', 'origin/main..origin/edge'))
}
/** Gitea's REST API for this repo (the idiom of drill.js fileIssue). The token is never printed. */
function gitea(token, deps) {
if (!token) throw new Error('no Gitea token in the environment')
const base = `${deps.env.GITHUB_SERVER_URL}/api/v1/repos/${deps.env.GITHUB_REPOSITORY}`
const headers = { Authorization: 'token ' + token, 'Content-Type': 'application/json', Accept: 'application/json' }
const call = async (method, p, data) => {
const r = await deps.fetch(base + p, { method, headers, body: data ? JSON.stringify(data) : undefined })
const text = await r.text()
if (!r.ok) throw new Error(`${method} ${p}: ${r.status} ${text.slice(0, 200)}`)
return text ? JSON.parse(text) : null
}
return {
call,
async openPr() {
return (await call('GET', '/pulls?state=open&limit=50')).find((p) => p.head.ref === 'edge' && p.base.ref === 'main') || null
},
}
}
/**
* The `Closes #N` numbers of the PRs merged into edge since the last cutover: after the committer date of
* `git merge-base origin/main origin/edge`, the newest edge commit already in main.
*/
async function closesSinceCutOff(g, deps) {
let since = 0
try {
since = Date.parse(deps.git('log', '-1', '--format=%cI', deps.git('merge-base', 'origin/main', 'origin/edge'))) || 0
} catch {}
// A PR whose merge commit main already has shipped in an earlier cutover, even when its merged_at is a moment
// after the cut-off's committer date (edge's tip is often that very merge commit).
const shipped = (sha) => {
if (!sha) return false
try {
return /\borigin\/main\b/.test(deps.git('branch', '-r', '--contains', sha, '--list', 'origin/main'))
} catch {
return false
}
}
const bodies = []
for (let page = 1; page <= 10; page++) {
const prs = await g.call('GET', `/pulls?state=closed&limit=50&page=${page}`)
for (const pr of prs) {
if (pr.merged && pr.base && pr.base.ref === 'edge' && Date.parse(pr.merged_at) > since && !shipped(pr.merge_commit_sha)) bodies.push(pr.body)
}
if (prs.length < 50) break
}
return collectCloses(bodies)
}
// ---------------------------------------------------------------- window and pending
function window_(a, deps = realDeps()) {
const now = a.now ? new Date(a.now) : deps.now()
const w = wipeWeekend(now)
const open = w.open || w.final || !!a.force
const verdict = w.open ? 'in the wipe weekend' : w.final ? 'the hour after it (the last run, to give up out loud)' : a.force ? 'outside the wipe weekend, but run by hand: going ahead' : 'outside the wipe weekend, nothing to do'
log(`the wipe is ${day(w.wipe)}, the cutover works until ${w.deadline.toISOString().slice(0, 16)}Z: ${verdict} (open=${open})`)
output(deps.env, 'open', open ? 'true' : 'false')
}
function pending(a, deps = realDeps()) {
const ahead = aheadCount(a, deps)
const run = ahead > 0 || !!a.force
log(`edge is ${ahead} commit(s) ahead of main: ${run ? 'going ahead' : 'nothing to merge'}`)
output(deps.env, 'run', run ? 'true' : 'false')
}
// ---------------------------------------------------------------- the rig
async function carbonGate(ctx) {
const { waitRunning } = require('./drill.js')
const p = ctx.deps.panel()
const id = (await p.servers())[CARBON_RIG]
if (!id) return `no server named ${CARBON_RIG} on the panel`
await p.write(id, CARBON_PLUGIN, fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8'))
const was = await p.state(id)
log(`${CARBON_RIG} is ${was}: ${was === 'offline' ? 'starting' : 'restarting'} it (its egg updates Rust and Carbon)`)
await p.power(id, was === 'offline' ? 'start' : 'restart')
// `running` right after the signal is the old process: wait for it to leave first.
for (let i = 0; i < 60 && was !== 'offline' && (await p.state(id)) === 'running'; i++) await ctx.deps.sleep(5000)
if (!(await waitRunning(p, id, 45 * 60000))) return `${CARBON_RIG} did not come up in 45 minutes, or crashed on start`
// It must have updated to the public build, or everything after proves nothing.
const built = carbonBuild(await p.readOr(id, ACF, null), ctx.stat.manifest)
if (built !== true) return built
const lines = await carbonStatus(p, id, 30 * 60000, ctx.deps)
if (lines) log('rnpc.status:', lines.find((l) => l.includes('navmesh=')))
return carbonOk(lines || [])
}
/** `rnpc.status` over the console until the navmesh is ready; the last answer if it never is. */
async function carbonStatus(p, id, timeoutMs, deps) {
const end = Date.now() + timeoutMs
let last = null
while (Date.now() < end) {
try {
const lines = (await p.console(id, 'rnpc.status', 6)).filter((l) => l.trim())
const at = lines.findIndex((l) => l.includes('RunicNPC ') && l.includes('api='))
if (at >= 0) {
last = lines.slice(at, at + 14)
if (last.some((l) => l.includes('navmesh=ready'))) return last
}
} catch {
// the panel's console is not up yet
}
await deps.sleep(30000)
}
return last
}
/** Writes whether rust-carbon is running now, to put it back if the job leaves it stopped. */
async function rigNote(a, deps = realDeps()) {
const work = path.resolve(a.work || 'cutover-work')
const p = deps.panel()
const id = (await p.servers())[CARBON_RIG]
const state = id ? await p.state(id) : null
fs.mkdirSync(work, { recursive: true })
writeJson(path.join(work, 'rig-carbon.json'), { name: CARBON_RIG, state })
log(`${CARBON_RIG} is ${state || 'not on the panel'}`)
}
/** Starts rust-carbon (power `start` on it alone) when it was running at the start of the job and is offline now. */
async function rigRestore(a, deps = realDeps()) {
const rec = readJson(path.join(path.resolve(a.work || 'cutover-work'), 'rig-carbon.json'), null)
if (!rec || rec.state !== 'running') return log(`${CARBON_RIG} was not running at the start: nothing to put back`)
const p = deps.panel()
const id = (await p.servers())[CARBON_RIG]
if (!id) return log(`${CARBON_RIG} is not on the panel`)
const now = await p.state(id)
if (now !== 'offline') return log(`${CARBON_RIG} is ${now}: left as it is`)
await p.power(id, 'start')
log(`${CARBON_RIG} was running at the start and is offline: started it`)
}
// ---------------------------------------------------------------- the gates
async function checksGate(ctx) {
const wait = !ctx.dryRun
let pr = ctx.pr
if (!pr) return ctx.dryRun ? 'no cutover PR is open (a dry run opens none)' : 'no cutover PR is open'
const end = Date.now() + CHECKS_WAIT_MS
for (;;) {
pr = (await ctx.g.call('GET', `/pulls/${pr.number}`)) || pr
if (pr.head.sha !== ctx.edgeSha) return `the PR's head ${String(pr.head.sha).slice(0, 8)} is not the commit the gates ran on (${String(ctx.edgeSha).slice(0, 8)})`
const v = checkState(await ctx.g.call('GET', `/commits/${pr.head.sha}/status`))
if (v.state === 'success') return true
if (v.state === 'failed') return v.reason
if (!wait || Date.now() >= end) return `${v.reason} (waited ${wait ? 15 : 0} minutes)`
await ctx.deps.sleep(30000)
}
}
const RUNNERS = {
public: async (c) => publicGate(c.stat, c.steam, c.wipe),
oxide: async (c) => oxideReady(c.stat),
carbon: async (c) => carbonGate(c),
live: async (c) => liveOk(readJson(path.join(c.work, 'live.json'), null)),
checks: async (c) => checksGate(c),
}
/** Runs one gate; whatever it throws is its reason, so a flaky API call blocks an hour, not the state. */
async function runGate(gate, ctx) {
log(`gate ${gate}...`)
let r
try {
r = await RUNNERS[gate](ctx)
} catch (e) {
r = `the gate stopped: ${e.message}`
}
log(`gate ${gate}: ${r === true ? 'passed' : `NOT passed: ${r}`}`)
return r
}
// ---------------------------------------------------------------- the PR
function gateRows(state, rows) {
return GATES.map((g) => {
const done = state.gates[g]
const row = rows[g]
if (done) return { name: GATE_LABELS[g], status: 'passed', note: done.at.slice(0, 16).replace('T', ' ') + ' UTC' }
return { name: GATE_LABELS[g], status: row ? row.status : 'not yet', note: row ? row.detail : '' }
})
}
/**
* Opens the edge to main PR, or brings the marked block of the open one's body up to date. A PR it did not
* open keeps its title and the rest of its body. Not in a dry run, and never with a blank manifest.
*/
async function syncPr(ctx) {
if (ctx.dryRun) return ctx.pr
if (!ctx.stat.manifest) {
log('no manifest: the PR is left alone')
return ctx.pr
}
if (!ctx.closes) ctx.closes = await closesSinceCutOff(ctx.g, ctx.deps)
const content = cutoverBody({ wipe: ctx.wipeDate, manifest: ctx.stat.manifest, buildDate: ctx.stat.manifestDate, closes: ctx.closes, gates: gateRows(ctx.state, ctx.rows) })
if (!ctx.pr) {
const title = `chore(release): RunicNPC cutover for the ${ctx.wipeDate} wipe, Rust ${ctx.stat.manifest}`
ctx.pr = await ctx.g.call('POST', '/pulls', { head: 'edge', base: 'main', title, body: withBlock('', content) })
log(`opened PR #${ctx.pr.number}`)
} else {
const body = withBlock(ctx.pr.body, content)
if (body !== ctx.pr.body) {
ctx.pr = { ...ctx.pr, ...((await ctx.g.call('PATCH', `/pulls/${ctx.pr.number}`, { body })) || {}) }
log(`updated PR #${ctx.pr.number}`)
}
}
ctx.state = { ...ctx.state, pr: ctx.pr.number }
return ctx.pr
}
// ---------------------------------------------------------------- gates (the command)
async function gates(a, deps = realDeps()) {
const env = deps.env
const dryRun = isDry(a, env)
const afterLive = !!a['after-live']
const work = path.resolve(a.work || 'cutover-work')
const file = readJson(a.state, {})
const now = deps.now()
const w = wipeWeekend(now)
const stat = readJson(path.join(work, 'static.json'), {})
const appinfo = path.join(work, 'appinfo.txt')
const steam = fs.existsSync(appinfo) ? steamBranch(fs.readFileSync(appinfo, 'utf8')) : null
const g = gitea(env.GITEA_TOKEN, deps)
const prev = (afterLive && readJson(path.join(work, 'cutover-run.json'), null)) || { rows: {} }
const pr = await g.openPr()
const edgeSha = a.edge || deps.git('rev-parse', 'HEAD')
const obs = { manifest: stat.manifest || '', edgeSha, ahead: aheadCount(a, deps), openPr: pr && pr.number, forced: !!a.force && !(w.open || w.final), checksPassed: !!(dryRun && prev.checks) }
// A dry run starts from nothing: it is there to try every gate, not to trust an earlier hour's.
let d = decide(dryRun ? undefined : file.cutover, obs, now, { dryRun })
const ctx = { g, deps, stat, steam, work, now, dryRun, pr, edgeSha, wipe: w.wipe, wipeDate: day(w.wipe), state: d.state, rows: prev.rows, closes: null }
if (dryRun && prev.gates) ctx.state = { ...ctx.state, gates: prev.gates } // what the first half of this dry run passed
else for (const k of Object.keys(ctx.state.gates)) ctx.rows[k] = { status: 'passed earlier', detail: ctx.state.gates[k].at }
const note = (gate, r) => (ctx.rows[gate] = r === true ? { status: 'passed', detail: '' } : { status: 'failed', detail: r })
// The state goes to the file after every gate result, before the PR is touched.
const persist = () => {
if (dryRun) return
file.cutover = persistable(ctx.state, ctx.pr)
writeJson(a.state, file)
}
const safely = async (what, fn) => {
try {
return await fn()
} catch (e) {
log(`${what} failed (the gates are kept): ${e.message}`)
}
}
let blocked = null
let deferred = false
if (d.action === 'nothing') log(`nothing to do: ${d.reason}`)
else if (d.action === 'giveUp') log(`the cutover window closed with ${d.gate || 'every gate'} not done`)
else if (dryRun) {
// Every gate it can; live waits for its own step.
for (const gate of GATES) {
if (gate === 'live' && !afterLive) {
deferred = true
ctx.rows.live = { status: 'runs next', detail: 'drill.js live --branch public' }
} else if ((gate === 'live') === afterLive || (afterLive && gate === 'checks' && !prev.checks)) {
const r = await runGate(gate, ctx)
note(gate, r)
if (r === true) {
if (gate === 'checks') obs.checksPassed = true
else ctx.state = pass(ctx.state, gate, now)
} else if (!blocked) blocked = { gate, reason: r }
}
}
} else {
while (d.action === 'run') {
if (d.gate === 'live' && !afterLive) {
deferred = true
break
}
const r = await runGate(d.gate, ctx)
note(d.gate, r)
if (r !== true) {
blocked = { gate: d.gate, reason: r }
if (COOLING.includes(d.gate)) ctx.state = fail(ctx.state, d.gate, r, now)
persist()
break
}
if (d.gate === 'checks') obs.checksPassed = true
else {
ctx.state = pass(ctx.state, d.gate, now)
persist()
}
if (d.gate === 'public') {
await safely('opening the PR', () => syncPr(ctx))
obs.openPr = ctx.pr ? ctx.pr.number : null
}
d = decide(ctx.state, obs, now, { dryRun })
}
}
// What the run ends in.
if (d.action !== 'nothing' && d.action !== 'giveUp') {
obs.openPr = ctx.pr ? ctx.pr.number : null
d = decide(ctx.state, blocked ? { ...obs, blocked } : obs, now, { dryRun })
}
if (!dryRun && ctx.state.gates.public && d.action !== 'giveUp' && !ctx.state.merged) await safely('updating the PR', () => syncPr(ctx))
const end = d.action
log(`${end}${d.gate ? ` (${d.gate})` : ''}${d.reason ? `: ${d.reason}` : ''}`)
let finalState = d.state
if (d.comment && ctx.pr) {
const done = await safely('commenting', async () => {
await g.call('POST', `/issues/${ctx.pr.number}/comments`, { body: commentText(d, d.state) })
return true
})
if (done) log(`commented on PR #${ctx.pr.number}`)
else finalState = { ...finalState, lastComment: ctx.state.lastComment } // not said, so it may be said next hour
}
ctx.state = finalState
persist()
fs.mkdirSync(work, { recursive: true })
writeJson(path.join(work, 'cutover-run.json'), { rows: ctx.rows, gates: ctx.state.gates, checks: !!obs.checksPassed })
output(env, 'live', deferred ? 'true' : 'false')
output(env, 'merge', d.action === 'merge' && d.merge ? 'true' : 'false')
output(env, 'sha', edgeSha)
if (ctx.pr) output(env, 'pr', ctx.pr.number)
if (!deferred) {
const rows = GATES.map((gate) => `| ${GATE_LABELS[gate]} | ${(ctx.rows[gate] || { status: 'not reached' }).status} | ${neutralise((ctx.rows[gate] || {}).detail || '').replace(/\|/g, '/').replace(/\r?\n/g, ' ')} |`)
summary(
env,
[
`## Wipe-day cutover${dryRun ? ' (dry run: nothing created, edited, commented or merged)' : ''}: ${end}`,
'',
`Wipe ${ctx.wipeDate}; Rust public manifest ${stat.manifest || '?'} of ${stat.manifestDate || '?'}; edge ${edgeSha.slice(0, 8)} is ${obs.ahead} commit(s) ahead of main.`,
'',
'| Gate | State | Detail |',
'| --- | --- | --- |',
...rows,
d.action === 'merge' ? `\nEvery gate passed${dryRun ? ': a real run would merge now' : ''}.` : '',
].join('\n')
)
}
return d
}
// ---------------------------------------------------------------- merge and after
/** Records why the merge failed where the next hour (and a last comment) can read it. */
function recordMergeError(stateFile, message) {
if (!stateFile || stateFile === true) return
const file = readJson(stateFile, {})
file.cutover = { ...(file.cutover || {}), mergeError: String(message).slice(0, 300) }
writeJson(stateFile, file)
}
async function merge(a, deps = realDeps()) {
const g = gitea(deps.env.MERGE_TOKEN, deps)
if (!a.pr || a.pr === true) throw new Error('--pr <number> is required')
if (!a.sha || a.sha === true) throw new Error('--sha <the commit the gates ran on> is required')
try {
const before = await g.call('GET', `/pulls/${a.pr}`)
if (before.head.sha !== a.sha) throw new Error(`PR #${a.pr}'s head ${String(before.head.sha).slice(0, 8)} is not the commit the gates ran on (${a.sha.slice(0, 8)})`)
// Not the squash: release.yml reads the feat/fix subjects of the commits that arrive. Never delete edge.
// head_commit_id: Gitea refuses the merge when the head moved since the gates.
await g.call('POST', `/pulls/${a.pr}/merge`, { Do: 'merge', delete_branch_after_merge: false, head_commit_id: a.sha })
const pr = await g.call('GET', `/pulls/${a.pr}`)
if (!pr.merged) throw new Error(`PR #${a.pr} is not merged after the merge call`)
} catch (e) {
recordMergeError(a.state, e.message)
throw e
}
log(`merged PR #${a.pr}`)
output(deps.env, 'merged', 'true')
}
async function after(a, deps = realDeps()) {
const g = gitea(deps.env.GITEA_TOKEN, deps)
const pr = await g.call('GET', `/pulls/${a.pr}`)
if (!pr.merged) return log(`PR #${a.pr} is not merged: nothing to record`)
const file = readJson(a.state, {})
const { mergeError: _e, ...kept } = file.cutover || {}
file.cutover = { ...kept, merged: true, pr: pr.number }
writeJson(a.state, file)
// release.yml cuts the release as the push to main arrives.
const mergedAt = Date.parse(pr.merged_at)
let release = null
for (const end = deps.now().getTime() + RELEASE_WAIT_MS; !release && deps.now().getTime() < end; ) {
const list = await g.call('GET', '/releases?draft=false&pre-release=false&limit=5')
release = (list || []).find((r) => Date.parse(r.created_at) > mergedAt) || null
if (!release) await deps.sleep(60000)
}
if (!release) return log('no release appeared in 30 minutes: no issue is commented')
log(`released as ${release.tag_name}`)
output(deps.env, 'release', release.tag_name)
const text = `Released in ${release.tag_name} by the wipe cutover (D332).`
for (const n of collectCloses([pr.body])) {
const issue = await g.call('GET', `/issues/${n}`)
if (issue.state !== 'closed') {
log(`#${n} is not closed: no comment`)
continue
}
if ((await g.call('GET', `/issues/${n}/comments`)).some((c) => c.body === text)) continue
await g.call('POST', `/issues/${n}/comments`, { body: text })
log(`commented on #${n}`)
}
}
// ---------------------------------------------------------------- main
module.exports = {
wipeWeekend,
steamBranch,
publicGate,
oxideReady,
parseVdf,
acfDepotManifest,
carbonBuild,
carbonOk,
liveOk,
checkState,
collectCloses,
cutoverBody,
withBlock,
decide,
commentText,
neutralise,
isDry,
pass,
fail,
persistable,
nextGate,
gates,
merge,
after,
rigNote,
rigRestore,
checksGate,
closesSinceCutOff,
gitea,
GATES,
COOL_DOWN_HOURS,
BLOCK_START,
BLOCK_END,
}
if (require.main === module) {
const a = args()
const commands = { window: window_, pending, gates, merge, after, 'rig-note': rigNote, 'rig-restore': rigRestore }
const command = commands[a._[0]]
if (!command) {
console.error('usage: node tools/drill/cutover.js window|pending|gates|merge|after|rig-note|rig-restore [--state f] [--work dir] [--pr n] [--sha s] [--dry-run] [--force] [--after-live]')
process.exit(2)
}
Promise.resolve(command(a)).catch((e) => {
console.error(e.message || e)
process.exit(1)
})
}

862
tools/drill/cutover.test.js Normal file
View File

@@ -0,0 +1,862 @@
// The wipe-day cutover (docs/runicnpc/PLAN.md D332): when it works, what each gate accepts, which issues the
// PR closes, what the hourly run does next, and the commands against a fake Gitea and a fake panel.
// node --test tools/drill/cutover.test.js
const test = require('node:test')
const assert = require('node:assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const c = require('./cutover.js')
const { wipeWeekend, steamBranch, publicGate, oxideReady, carbonOk, carbonBuild, acfDepotManifest, liveOk, checkState, collectCloses, cutoverBody, withBlock, decide, commentText, neutralise, isDry, pass, fail, persistable, nextGate, GATES, COOL_DOWN_HOURS } = c
const at = (s) => new Date(s)
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'cutover-test-'))
// ---- wipeWeekend: November 2026 has its wipe on Thursday the 5th, so the weekend ends Sunday the 8th.
test('the weekend opens at 17:00 UTC on the wipe Thursday and not a minute before', () => {
assert.equal(wipeWeekend(at('2026-11-05T16:59:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).open, true)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).wipe.toISOString(), '2026-11-05T00:00:00.000Z')
})
test('it closes at Sunday 23:00 UTC, with one more hour to give up in', () => {
assert.equal(wipeWeekend(at('2026-11-08T22:59:00Z')).open, true)
const late = wipeWeekend(at('2026-11-08T23:00:00Z'))
assert.equal(late.open, false)
assert.equal(late.final, true)
assert.equal(late.deadline.toISOString(), '2026-11-08T23:00:00.000Z')
assert.equal(wipeWeekend(at('2026-11-08T23:59:00Z')).final, true)
assert.equal(wipeWeekend(at('2026-11-09T00:00:00Z')).final, false)
assert.equal(wipeWeekend(at('2026-11-05T17:00:00Z')).final, false)
})
test('a Thursday that is not the first of the month is closed, as is the morning of the wipe', () => {
assert.equal(wipeWeekend(at('2026-11-12T18:00:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-05T09:00:00Z')).open, false)
assert.equal(wipeWeekend(at('2026-11-06T12:00:00Z')).open, true)
})
test('a month whose first Thursday is the 7th ends its weekend on the 10th', () => {
const w = wipeWeekend(at('2026-05-09T12:00:00Z'))
assert.equal(w.wipe.toISOString(), '2026-05-07T00:00:00.000Z')
assert.equal(w.deadline.toISOString(), '2026-05-10T23:00:00.000Z')
assert.equal(w.open, true)
assert.equal(wipeWeekend(at('2026-05-10T23:00:00Z')).open, false)
})
test('when the 1st is the Thursday, the weekend is the 1st to the 4th', () => {
const w = wipeWeekend(at('2026-01-02T00:00:00Z'))
assert.equal(w.wipe.toISOString(), '2026-01-01T00:00:00.000Z')
assert.equal(w.deadline.toISOString(), '2026-01-04T23:00:00.000Z')
assert.equal(w.open, true)
assert.equal(wipeWeekend(at('2026-01-01T16:59:00Z')).open, false)
})
// ---- Steam's app info (steamcmd +app_info_print 258550), cut down from the real one of 2026-11-06
const APPINFO = `Steam Console Client (c) Valve Corporation - version 1792617903
-- type 'quit' to exit --
Loading Steam API...OK
Connecting anonymously to Steam Public...OK
Waiting for client config...OK
Waiting for user info...OK
AppID : 258550, change number : 40663829/40663829, last change : Fri Nov 6 04:32:39 2026
"258550"
{
\t"common"
\t{
\t\t"name"\t\t"Rust Dedicated Server"
\t}
\t"depots"
\t{
\t\t"258552"
\t\t{
\t\t\t"config"
\t\t\t{
\t\t\t\t"oslist"\t\t"linux"
\t\t\t}
\t\t\t"manifests"
\t\t\t{
\t\t\t\t"public"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"547616323393184824"
\t\t\t\t\t"size"\t\t"769819750"
\t\t\t\t}
\t\t\t\t"last-month"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"1632958440323253420"
\t\t\t\t}
\t\t\t\t"staging"
\t\t\t\t{
\t\t\t\t\t"gid"\t\t"4006809933087453123"
\t\t\t\t}
\t\t\t}
\t\t}
\t\t"branches"
\t\t{
\t\t\t"public"
\t\t\t{
\t\t\t\t"buildid"\t\t"26479619"
\t\t\t\t"timeupdated"\t\t"1793959612"
\t\t\t\t"timebuildupdated"\t\t"1793954045"
\t\t\t}
\t\t\t"release"
\t\t\t{
\t\t\t\t"buildid"\t\t"26479619"
\t\t\t\t"description"\t\t"Release, before public"
\t\t\t\t"timeupdated"\t\t"1793954045"
\t\t\t}
\t\t\t"staging"
\t\t\t{
\t\t\t\t"buildid"\t\t"26480085"
\t\t\t\t"timeupdated"\t\t"1793955490"
\t\t\t}
\t\t}
\t\t"privatebranches"\t\t"1"
\t}
}
Unloading Steam API...OK
`
test("steamBranch reads a branch's set time, build id and Linux manifest, past steamcmd's own lines", () => {
assert.deepEqual(steamBranch(APPINFO), { buildid: '26479619', timeupdated: 1793959612, manifest: '547616323393184824' })
assert.deepEqual(steamBranch(APPINFO, 'staging'), { buildid: '26480085', timeupdated: 1793955490, manifest: '4006809933087453123' })
assert.deepEqual(steamBranch(APPINFO.replace(/\n/g, '\r\n')), { buildid: '26479619', timeupdated: 1793959612, manifest: '547616323393184824' })
// The older form names the manifest as a bare string.
assert.equal(steamBranch(APPINFO.replace(/"public"\n\t\t\t\t\{\n\t\t\t\t\t"gid"\t\t"(\d+)"\n\t\t\t\t\t"size"\t\t"\d+"\n\t\t\t\t\}/, '"public"\t\t"$1"')).manifest, '547616323393184824')
assert.equal(steamBranch(APPINFO, 'nope'), null)
assert.equal(steamBranch('Loading Steam API...FAILED'), null)
assert.equal(steamBranch(''), null)
assert.equal(steamBranch(undefined), null)
})
// ---- the gates
test('publicGate: Steam set public on the wipe day or later, and names the manifest the static half has', () => {
const wipe = at('2026-11-05T00:00:00Z')
const pub = { manifest: '547616323393184824', manifestDate: '11/06/2026 08:33:44' }
assert.equal(publicGate(pub, steamBranch(APPINFO), wipe), true)
// The 2026-11-05 case: the wipe build went live that evening, but its depot manifest was dated 11-04.
const thu = { buildid: '26427999', timeupdated: Date.UTC(2026, 10, 5, 18, 30) / 1000, manifest: '3647035647797862067' }
assert.equal(publicGate({ manifest: '3647035647797862067', manifestDate: '11/04/2026 12:57:41' }, thu, wipe), true)
assert.equal(publicGate(pub, { ...steamBranch(APPINFO), timeupdated: Date.UTC(2026, 10, 5) / 1000 }, wipe), true)
// Before the wipe: October's build still live.
const oct = { buildid: '26139947', timeupdated: Date.UTC(2026, 9, 22, 17, 40) / 1000, manifest: '1632958440323253420' }
assert.match(publicGate({ manifest: '1632958440323253420' }, oct, wipe), /at 2026-10-22 17:40 UTC, before the wipe \(2026-11-05 00:00 UTC\)/)
assert.match(publicGate(pub, { ...oct, timeupdated: Date.UTC(2026, 10, 4, 23, 59, 59) / 1000, manifest: pub.manifest }, wipe), /before the wipe/)
// Steam and DepotDownloader disagree (a push between the two reads): block this hour.
assert.match(publicGate({ manifest: '8150' }, steamBranch(APPINFO), wipe), /Steam names manifest 547616323393184824 for public, but the static half downloaded 8150/)
assert.match(publicGate({}, steamBranch(APPINFO), wipe), /downloaded none/)
assert.match(publicGate(pub, { ...steamBranch(APPINFO), manifest: null }, wipe), /names no 258552 manifest/)
assert.match(publicGate(pub, { ...steamBranch(APPINFO), timeupdated: NaN }, wipe), /gives no time/)
assert.match(publicGate({ ...pub, steamNote: 'steamcmd could not be fetched' }, null, wipe), /could not be read \(steamcmd could not be fetched\)/)
assert.match(publicGate(pub, null, wipe), /could not be read \(no appinfo.txt\)/)
assert.match(publicGate(pub, steamBranch(APPINFO), 'garbage'), /no usable wipe date/)
assert.equal(publicGate(pub, steamBranch(APPINFO), '2026-11-05'), true)
})
test('oxideReady: true, or the reason', () => {
const ok = { managed: 'ok', oxideBehind: 0, compile: 'ok', fields: 'same' }
assert.equal(oxideReady(ok), true)
assert.match(oxideReady({ ...ok, oxideBehind: 12 }), /Oxide's public build is 12 member\(s\) behind/)
assert.match(oxideReady({ ...ok, fields: 'differs' }), /field list differs from public Rust's/)
assert.match(oxideReady({ ...ok, compile: 'errors' }), /does not compile/)
assert.match(oxideReady({ ...ok, managed: 'error', note: 'DepotDownloader could not be fetched' }), /could not fetch Rust's public build \(DepotDownloader/)
assert.match(oxideReady({ ...ok, oxideBehind: -1 }), /could not be compared/)
assert.match(oxideReady({ ...ok, fields: 'error' }), /could not be checked/)
assert.match(oxideReady(undefined), /could not fetch/)
})
const ACF = `"AppState"
{
\t"appid"\t\t"258550"
\t"buildid"\t\t"22222222"
\t"InstalledDepots"
\t{
\t\t"258551"
\t\t{
\t\t\t"manifest"\t\t"111"
\t\t\t"size"\t\t"1"
\t\t}
\t\t"258552"
\t\t{
\t\t\t"manifest"\t\t"8140123456789"
\t\t\t"size"\t\t"9000000000"
\t\t}
\t}
\t"SharedDepots"
\t{
\t\t"228984"\t\t"228990"
\t}
}
`
test('the appmanifest: the Linux depot\'s manifest, and null when it is not there', () => {
assert.equal(acfDepotManifest(ACF), '8140123456789')
assert.equal(acfDepotManifest(ACF, '258551'), '111')
assert.equal(acfDepotManifest(ACF, '999'), null)
assert.equal(acfDepotManifest(''), null)
assert.equal(acfDepotManifest(undefined), null)
assert.equal(acfDepotManifest('"AppState" { "InstalledDepots" { "258552" "oops" } }'), null)
})
test('carbonBuild: the rig must run the public manifest', () => {
assert.equal(carbonBuild(ACF, '8140123456789'), true)
assert.match(carbonBuild(ACF, '9999'), /rust-carbon still runs manifest 8140123456789 \(public is 9999\)/)
assert.match(carbonBuild(null, '9999'), /could not be read/)
})
const STATUS = 'framework=carbon navmesh=ready swap fields: npc=58/58 brain=32/32 missing=- added=-'
test('carbonOk: a Carbon server with the navmesh ready and nothing missing or added', () => {
assert.equal(carbonOk(['RunicNPC 0.0.0 api=1 hooks=40 fired=3 silent=0', 'fired: x', STATUS, 'npcs=0']), true)
assert.equal(carbonOk(STATUS), true)
})
test('carbonOk: each way it can fail says why', () => {
assert.match(carbonOk([STATUS.replace('added=-', 'added=BasePlayer.x')]), /does not know: BasePlayer\.x/)
assert.match(carbonOk([STATUS.replace('missing=-', 'missing=BaseNpc.y,Brain.z')]), /missing on Carbon: BaseNpc\.y,Brain\.z/)
assert.match(carbonOk([STATUS.replace('framework=carbon', 'framework=oxide')]), /framework=oxide, not carbon/)
assert.match(carbonOk([STATUS.replace('navmesh=ready', 'navmesh=building')]), /navmesh=building/)
assert.match(carbonOk([]), /never answered/)
assert.match(carbonOk(['RunicNPC 0.0.0 api=1']), /never answered/)
})
test('liveOk: ran, harness done, some checks passed, no failed check and no finding', () => {
const good = { branch: 'public', ran: true, findings: [], harness: { done: true, pass: 266, fail: 0, failed: [] } }
assert.equal(liveOk(good), true)
assert.match(liveOk(null), /no live\.json/)
assert.match(liveOk({ ...good, ran: false, findings: ['The server did not start on Rust public 1'] }), /did not start/)
assert.match(liveOk({ ...good, ran: false, skipped: "Oxide's public build is 3 member(s) behind" }), /3 member/)
assert.match(liveOk({ ...good, harness: { done: false, pass: 5, fail: 0, failed: [] }, findings: ['The harness stopped after x'] }), /harness stopped after x/)
assert.match(liveOk({ ...good, harness: { done: true, pass: 5, fail: 1, failed: ['place.set: FAIL off the navmesh'] } }), /place\.set: FAIL off the navmesh/)
assert.match(liveOk({ ...good, findings: ['RunicNPC logged 2 error line(s) in Oxide\'s log during the run.'] }), /logged 2 error/)
assert.match(liveOk({ ...good, branch: 'staging' }), /staging branch/)
assert.match(liveOk({ ...good, harness: { done: true, pass: 0, fail: 0, failed: [] } }), /passed no check/)
})
test('checkState: success needs a plugin-checks context; a failure or a wait is said', () => {
const s = (context, status) => ({ context, status })
assert.equal(checkState({ state: 'success', statuses: [s('PR Checks / plugin-checks (pull_request)', 'success')] }).state, 'success')
assert.equal(checkState({ state: 'success', statuses: [] }).state, 'pending')
assert.equal(checkState({ state: 'success', statuses: [s('other', 'success')] }).state, 'pending')
assert.equal(checkState({ state: 'pending', statuses: [s('PR Checks / plugin-checks (pull_request)', 'pending')] }).state, 'pending')
const failed = checkState({ state: 'failure', statuses: [s('PR Checks / plugin-checks (pull_request)', 'failure')] })
assert.equal(failed.state, 'failed')
assert.match(failed.reason, /plugin-checks.*failure/)
})
// ---- what the PR closes, and what is written into it
test('collectCloses reads closing keywords before #N, in any case', () => {
assert.deepEqual(collectCloses(['Closes #20', 'fixes #3', 'RESOLVED #9', 'Fixed #12.', 'close #4', 'Resolves: #8']), [3, 4, 8, 9, 12, 20])
})
test('collectCloses takes several on a line, and dedupes and sorts', () => {
assert.deepEqual(collectCloses(['Closes #22, closes #20 and fixes #5', 'Closes #20\nCloses #5', 'closes #100']), [5, 20, 22, 100])
})
test('collectCloses ignores references, bare numbers, prose and other repositories', () => {
assert.deepEqual(collectCloses(['Refs #21', 'see #21', '#21', 'This closes the loop on #21', 'prefix #3', 'unfixes #4', 'Closes RunicGateway/docs#9', '']), [])
assert.deepEqual(collectCloses([undefined, null]), [])
})
test('cutoverBody names the wipe and the build, lists a Closes line per issue, and ends with the disclosure', () => {
const body = cutoverBody({
wipe: '2026-11-05',
manifest: '8140',
buildDate: '11/5/2026 11:48:58',
closes: [20, 22],
gates: [
{ name: '1. Rust public', status: 'passed', note: '2026-11-05 17:11 UTC' },
{ name: '2. Oxide', status: 'failed', note: 'a | b' },
],
})
assert.match(body, /D332/)
assert.match(body, /manifest \*\*8140\*\*, dated 11\/5\/2026 11:48:58/)
assert.match(body, /^Closes #20$/m)
assert.match(body, /^Closes #22$/m)
assert.match(body, /\| 2\. Oxide \| failed \| a \/ b \|/)
assert.ok(body.endsWith('Opened by the runicnpc-rust cutover workflow (D332).'))
assert.match(cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [] }), /No issue is closed/)
})
test('a reason can never close an issue: #123 is broken up in the body and in comments', () => {
assert.equal(neutralise('fixes #123 and #4'), 'fixes #&#8203;123 and #&#8203;4')
const body = cutoverBody({ wipe: 'w', manifest: 'm', closes: [], gates: [{ name: 'g', status: 'failed', note: 'Closes #5: fixes #123' }] })
assert.deepEqual(collectCloses([body]), [])
const text = commentText({ action: 'blocked', gate: 'oxide', reason: 'resolves #7' })
assert.deepEqual(collectCloses([text]), [])
// The deliberate lines still close.
assert.deepEqual(collectCloses([cutoverBody({ wipe: 'w', manifest: 'm', closes: [9], gates: [] })]), [9])
})
test('withBlock appends the marked block, replaces only what is between the markers, and is stable', () => {
const once = withBlock('A human wrote this.', 'one')
assert.equal(once, 'A human wrote this.\n\n<!-- cutover:start -->\none\n<!-- cutover:end -->')
const twice = withBlock(once + '\n\nAnd this after.', 'two')
assert.equal(twice, 'A human wrote this.\n\n<!-- cutover:start -->\ntwo\n<!-- cutover:end -->\n\nAnd this after.')
assert.equal(withBlock(twice, 'two'), twice)
assert.equal(withBlock('', 'x'), '<!-- cutover:start -->\nx\n<!-- cutover:end -->')
})
// ---- decide
const THU = at('2026-11-05T18:11:00Z')
const obs = (more = {}) => ({ manifest: '8140', edgeSha: 'sha1', ahead: 3, openPr: null, ...more })
const fresh = () => decide(undefined, obs(), THU)
const passed = (state, ...names) => names.reduce((s, g) => pass(s, g, THU), state)
test('a first run starts at the public gate and asks to be written', () => {
const d = fresh()
assert.equal(d.action, 'run')
assert.equal(d.gate, 'public')
assert.equal(d.write, true)
assert.equal(d.state.wipe, '2026-11-05')
assert.equal(d.state.manifest, '8140')
assert.equal(d.state.edgeSha, 'sha1')
})
test('the gates run in order, checks comes last and is never kept, and then it merges', () => {
assert.deepEqual(GATES, ['public', 'oxide', 'carbon', 'live', 'checks'])
let s = fresh().state
for (const g of GATES.slice(0, 4)) {
const d = decide(s, obs(), THU)
assert.equal(d.gate, g)
s = pass(d.state, g, THU)
}
const checks = decide(s, obs(), THU)
assert.equal(checks.action, 'run')
assert.equal(checks.gate, 'checks')
const done = decide(s, obs({ checksPassed: true }), THU)
assert.equal(done.action, 'merge')
assert.equal(done.merge, true)
assert.equal(nextGate({ public: {}, carbon: {} }), 'oxide')
// A checks entry found in a stored state is dropped.
const old = decide({ ...s, gates: { ...s.gates, checks: { at: 'x' } } }, obs(), THU)
assert.equal(old.state.gates.checks, undefined)
assert.equal(old.gate, 'checks')
assert.equal(persistable({ ...s, gates: { ...s.gates, checks: {} } }, { number: 4 }).gates.checks, undefined)
assert.equal(persistable(s, { number: 4 }).pr, 4)
})
test('a passed gate is never run again for the same build and the same code', () => {
const s = passed(fresh().state, 'public', 'oxide', 'carbon')
const d = decide(s, obs(), at('2026-11-05T19:11:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.gate, 'live')
assert.ok(d.state.gates.carbon)
})
test('a new public manifest starts the gates again, keeping the open PR', () => {
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon'), pr: 9 }
const d = decide(s, obs({ manifest: '8141', openPr: 9 }), at('2026-11-06T10:11:00Z'))
assert.equal(d.gate, 'public')
assert.deepEqual(d.state.gates, {})
assert.equal(d.state.manifest, '8141')
assert.equal(d.state.pr, 9)
assert.equal(decide(s, obs({ manifest: '8141', openPr: null }), at('2026-11-06T10:11:00Z')).state.pr, null)
})
test('a new edge commit clears every gate and every cooled failure, so nothing proven on old code counts', () => {
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'x', THU)
const d = decide({ ...s, mergeError: 'old' }, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:11:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.gate, 'public')
assert.deepEqual(d.state.gates, {})
assert.deepEqual(d.state.failed, {})
assert.equal(d.state.edgeSha, 'sha2')
assert.equal(d.state.mergeError, undefined)
// A state written before edgeSha was kept counts as changed code.
const legacy = { wipe: '2026-11-05', manifest: '8140', gates: { public: { at: 'x' } } }
assert.equal(decide(legacy, obs(), THU).gate, 'public')
})
test('a new wipe forgets everything, comments and a give-up included', () => {
const s = { ...passed(fresh().state, 'public'), lastComment: '2026-11-05', gaveUp: true }
const d = decide(s, obs(), at('2026-12-03T18:00:00Z'))
assert.equal(d.action, 'run')
assert.equal(d.state.wipe, '2026-12-03')
assert.deepEqual(d.state.gates, {})
assert.equal(d.state.lastComment, undefined)
assert.equal(d.state.gaveUp, undefined)
})
test('nothing to do when edge has no commit main lacks, or when it already merged', () => {
const d = decide(undefined, obs({ ahead: 0 }), THU)
assert.equal(d.action, 'nothing')
assert.match(d.reason, /no commits/)
assert.equal(decide({ ...fresh().state, merged: true }, obs(), THU).action, 'nothing')
})
test('the cool-down: a failed carbon or live is not tried again for three hours, a new commit retries at once', () => {
assert.equal(COOL_DOWN_HOURS, 3)
const base = passed(fresh().state, 'public', 'oxide')
const failedCarbon = fail(base, 'carbon', 'did not come up', THU) // failed at 18:11
const at59 = decide(failedCarbon, obs(), at('2026-11-05T21:10:00Z')) // 2 h 59 min later
assert.equal(at59.action, 'cooling')
assert.equal(at59.gate, 'carbon')
assert.equal(at59.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
assert.match(at59.reason, /did not come up/)
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).action, 'run') // 3 h 00
assert.equal(decide(failedCarbon, obs(), at('2026-11-05T21:11:00Z')).gate, 'carbon')
assert.equal(decide(failedCarbon, obs({ edgeSha: 'sha2' }), at('2026-11-05T19:00:00Z')).gate, 'public')
assert.equal(decide(failedCarbon, obs({ manifest: '8141' }), at('2026-11-05T19:00:00Z')).gate, 'public')
// The same rule for live; public and oxide are cheap and never cool.
const failedLive = fail(passed(base, 'carbon'), 'live', 'a check failed', THU)
assert.equal(decide(failedLive, obs(), at('2026-11-05T20:00:00Z')).action, 'cooling')
assert.equal(decide(failedLive, obs(), at('2026-11-05T21:11:00Z')).gate, 'live')
assert.equal(decide(fail(fresh().state, 'oxide', 'x', THU), obs(), at('2026-11-05T18:30:00Z')).gate, 'public')
// A pass forgets the failure.
assert.equal(pass(failedCarbon, 'carbon', THU).failed.carbon, undefined)
})
test('a cooling gate comments once a day and says when it will try again', () => {
const s = fail(passed(fresh().state, 'public', 'oxide'), 'carbon', 'did not come up', THU)
const d = decide(s, obs({ openPr: 9 }), at('2026-11-05T19:11:00Z'))
assert.equal(d.comment, true)
assert.match(commentText(d, d.state), /not try this gate again before 2026-11-05 21:11 UTC/)
assert.match(commentText(d, d.state), /3\. A Carbon boot/)
assert.equal(decide(d.state, obs({ openPr: 9 }), at('2026-11-05T20:11:00Z')).comment, false)
})
test('a blocked gate comments once a day, and only after the public gate passed', () => {
const blocked = { gate: 'oxide', reason: "Oxide's public build is 12 member(s) behind" }
const s = passed(fresh().state, 'public')
const first = decide(s, obs({ openPr: 9, blocked }), THU)
assert.equal(first.action, 'blocked')
assert.equal(first.comment, true)
assert.equal(first.state.lastComment, '2026-11-05')
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-05T19:11:00Z')).comment, false)
assert.equal(decide(first.state, obs({ openPr: 9, blocked }), at('2026-11-06T09:11:00Z')).comment, true)
assert.equal(decide(fresh().state, obs({ openPr: 9, blocked: { gate: 'public', reason: 'old' } }), THU).comment, false)
assert.equal(decide(s, obs({ openPr: null, blocked }), THU).comment, false)
// A blocked carbon says when it will be tried again.
const carbon = decide(s, obs({ openPr: 9, blocked: { gate: 'carbon', reason: 'x' } }), THU)
assert.equal(carbon.retryAt.toISOString(), '2026-11-05T21:11:00.000Z')
})
test('after the deadline it gives up once, with a last comment', () => {
const s = passed(fresh().state, 'public', 'oxide')
const late = at('2026-11-08T23:11:00Z')
const d = decide(s, obs({ openPr: 9 }), late)
assert.equal(d.action, 'giveUp')
assert.equal(d.gate, 'carbon')
assert.equal(d.comment, true)
assert.equal(d.state.gaveUp, true)
assert.match(commentText(d, d.state), /Gate "3\. A Carbon boot on public Rust" had not passed/)
const again = decide(d.state, obs({ openPr: 9 }), at('2026-11-08T23:40:00Z'))
assert.equal(again.action, 'nothing')
assert.equal(again.comment, false)
assert.equal(decide(s, obs({ openPr: null }), late).comment, false)
assert.equal(decide(s, obs({ openPr: 9, forced: true }), late).action, 'run')
})
test('giving up with every gate passed and the merge failed says so, never gate "undefined"', () => {
const s = { ...passed(fresh().state, 'public', 'oxide', 'carbon', 'live'), mergeError: 'POST /pulls/5/merge: 405 not mergeable' }
const d = decide(s, obs({ openPr: 5 }), at('2026-11-08T23:11:00Z'))
assert.equal(d.action, 'giveUp')
assert.equal(d.gate, undefined)
const text = commentText(d, d.state)
assert.match(text, /Every gate passed; the merge failed: POST \/pulls\/5\/merge: 405 not mergeable/)
assert.ok(!text.includes('undefined'))
assert.ok(!commentText(d, { ...d.state, mergeError: undefined }).includes('undefined'))
})
test('a dry run never asks to write, comment or merge', () => {
const dry = { dryRun: true }
assert.equal(decide(undefined, obs(), THU, dry).write, false)
const s = passed(fresh().state, 'public')
const b = decide(s, obs({ openPr: 9, blocked: { gate: 'oxide', reason: 'x' } }), THU, dry)
assert.equal(b.comment, false)
assert.equal(b.write, false)
assert.equal(b.state.lastComment, undefined)
const m = decide(passed(fresh().state, ...GATES.slice(0, 4)), obs({ checksPassed: true }), THU, dry)
assert.equal(m.action, 'merge')
assert.equal(m.merge, false)
const g = decide(s, obs({ openPr: 9 }), at('2026-11-08T23:11:00Z'), dry)
assert.equal(g.action, 'giveUp')
assert.equal(g.comment, false)
assert.equal(decide(undefined, obs({ ahead: 0 }), THU, dry).action, 'run')
})
test('a dispatch is dry unless its dry_run input is exactly false', () => {
const dispatch = (input) => ({ GITHUB_EVENT_NAME: 'workflow_dispatch', DRY_RUN_INPUT: input })
assert.equal(isDry({}, dispatch(undefined)), true)
assert.equal(isDry({}, dispatch('')), true)
assert.equal(isDry({}, dispatch('true')), true)
assert.equal(isDry({}, dispatch('maybe')), true)
assert.equal(isDry({}, dispatch('false')), false)
assert.equal(isDry({}, { GITHUB_EVENT_NAME: 'schedule' }), false)
assert.equal(isDry({ 'dry-run': true }, { GITHUB_EVENT_NAME: 'schedule' }), true)
})
// ---- the commands, against a fake Gitea and a fake panel
const GIT = (...a) => ({ 'rev-parse': 'sha1', 'rev-list': '3', 'merge-base': 'mb', log: '2026-11-01T00:00:00+00:00' }[a[0]] || '')
/** deps with a fake fetch: handler(method, path, body) -> { status, json }; every call is recorded. */
function fakeDeps({ now = '2026-11-05T18:11:00Z', handler, git = GIT, panel, env = {} }) {
const deps = {
calls: [],
at: now,
env: { GITHUB_SERVER_URL: 'http://gitea.test', GITHUB_REPOSITORY: 'o/r', GITEA_TOKEN: 'reg-token', MERGE_TOKEN: 'merge-token', ...env },
now: () => new Date(deps.at),
sleep: async () => {},
git: (...a) => git(...a),
panel,
fetch: async (url, o) => {
const p = url.replace('http://gitea.test/api/v1/repos/o/r', '')
const body = o.body ? JSON.parse(o.body) : undefined
deps.calls.push({ method: o.method, p, body, auth: o.headers.Authorization })
const r = handler(o.method, p, body) || {}
const status = r.status || 200
return { ok: status < 400, status, text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)) }
},
}
return deps
}
const STAT = { branch: 'public', manifest: '8140', manifestDate: '11/5/2026 11:48:58', managed: 'ok', oxideBehind: 0, fields: 'same', compile: 'ok' }
// Steam set public (2026-11-06 10:06 UTC) to the manifest STAT has.
const APPINFO_8140 = APPINFO.replace('"547616323393184824"', '"8140"')
function rig(stat = STAT, appinfo = APPINFO_8140) {
const dir = tmp()
fs.mkdirSync(path.join(dir, 'work'))
fs.writeFileSync(path.join(dir, 'work', 'static.json'), JSON.stringify(stat))
if (appinfo !== null) fs.writeFileSync(path.join(dir, 'work', 'appinfo.txt'), appinfo)
const state = path.join(dir, 'drill.json')
fs.writeFileSync(state, '{}')
// main's cutover.yml still passes --staging-work; it is ignored.
const a = { state, work: path.join(dir, 'work'), 'staging-work': path.join(dir, 'work-staging') }
return { dir, a, state, read: () => JSON.parse(fs.readFileSync(state, 'utf8')) }
}
/** A Gitea with one edge to main PR that exists once POSTed. */
function fakeGitea() {
const g = { pr: null, comments: [] }
g.handler = (method, p, body) => {
if (method === 'GET' && p.startsWith('/pulls?state=open')) return { json: g.pr ? [g.pr] : [] }
if (method === 'GET' && p.startsWith('/pulls?state=closed')) return { json: [] }
if (method === 'POST' && p === '/pulls') {
if (g.failCreate) return { status: 500, json: { message: 'boom' } }
g.pr = { number: 5, head: { ref: 'edge', sha: 'sha1' }, base: { ref: 'main' }, ...body, head_: undefined }
g.pr.head = { ref: 'edge', sha: 'sha1' }
g.pr.base = { ref: 'main' }
return { json: g.pr }
}
if (method === 'PATCH' && p === '/pulls/5') {
g.pr = { ...g.pr, ...body }
return { json: g.pr }
}
if (method === 'POST' && p === '/issues/5/comments') {
g.comments.push(body.body)
return { json: {} }
}
return { json: [] }
}
return g
}
test('gates in a dry run creates, edits, comments and writes nothing', async () => {
const r = rig()
const g = fakeGitea()
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
const d = await c.gates({ ...r.a, 'dry-run': true }, deps)
assert.ok(deps.calls.length > 0)
assert.ok(deps.calls.every((x) => x.method === 'GET'), JSON.stringify(deps.calls.filter((x) => x.method !== 'GET')))
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
assert.equal(d.comment, false)
assert.equal(g.pr, null)
assert.equal(g.comments.length, 0)
// It ran past the failed carbon gate to try the others.
const run = JSON.parse(fs.readFileSync(path.join(r.dir, 'work', 'cutover-run.json'), 'utf8'))
assert.equal(run.rows.public.status, 'passed')
assert.equal(run.rows.oxide.status, 'passed')
assert.equal(run.rows.carbon.status, 'failed')
assert.equal(run.rows.checks.status, 'failed')
})
test('a workflow_dispatch without dry_run=false is dry even without the flag', async () => {
const r = rig()
const g = fakeGitea()
const deps = fakeDeps({ handler: g.handler, env: { GITHUB_EVENT_NAME: 'workflow_dispatch' }, panel: () => ({ servers: async () => { throw new Error('down') } }) })
await c.gates(r.a, deps)
assert.ok(deps.calls.every((x) => x.method === 'GET'))
assert.equal(fs.readFileSync(r.state, 'utf8'), '{}')
})
test('a real run keeps each gate, opens the PR, comments once, and cools a failed carbon for three hours', async () => {
const r = rig()
const g = fakeGitea()
let panelCalls = 0
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { panelCalls++; throw new Error('panel down') } }) })
await c.gates(r.a, deps) // 18:11
let s = r.read().cutover
assert.equal(s.edgeSha, 'sha1')
assert.deepEqual(Object.keys(s.gates).sort(), ['oxide', 'public'])
assert.match(s.failed.carbon.reason, /panel down/)
assert.equal(s.pr, 5)
assert.equal(s.lastComment, '2026-11-05')
assert.equal(panelCalls, 1)
assert.ok(g.pr.title.startsWith('chore(release): RunicNPC cutover for the 2026-11-05 wipe, Rust 8140'))
assert.ok(g.pr.body.includes('<!-- cutover:start -->'))
assert.equal(g.comments.length, 1)
assert.match(g.comments[0], /not try this gate again before 2026-11-05 21:11 UTC/)
assert.ok(deps.calls.filter((x) => x.method !== 'GET').every((x) => x.auth === 'token reg-token'))
deps.at = '2026-11-05T19:11:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 1, 'cooling: the rig is not touched')
deps.at = '2026-11-05T21:10:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 1, '2 h 59 min')
assert.equal(g.comments.length, 1, 'one comment a day')
deps.at = '2026-11-05T21:11:00Z'
await c.gates(r.a, deps)
assert.equal(panelCalls, 2, '3 h 00 min: it tries again')
// New code: everything again, at once.
deps.git = (...a) => (a[0] === 'rev-parse' ? 'sha2' : GIT(...a))
deps.at = '2026-11-05T21:30:00Z'
await c.gates(r.a, deps)
s = r.read().cutover
assert.equal(panelCalls, 3)
assert.equal(s.edgeSha, 'sha2')
assert.ok(s.gates.public && s.gates.oxide)
})
test('a PR call that fails never loses a gate that passed', async () => {
const r = rig()
const g = fakeGitea()
g.failCreate = true
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { throw new Error('panel down') } }) })
await c.gates(r.a, deps)
const s = r.read().cutover
assert.ok(s.gates.public)
assert.ok(s.gates.oxide)
assert.equal(s.pr, null)
})
for (const [why, appinfo] of [
['Steam names another manifest for public', APPINFO],
['Steam last set public before the wipe', APPINFO_8140.replace('"1793959612"', '"1792690223"')],
['steamcmd left no appinfo.txt', null],
]) {
test(`gate 1 blocks, and nothing else runs, when ${why}`, async () => {
const r = rig(STAT, appinfo)
const g = fakeGitea()
let panelCalls = 0
const deps = fakeDeps({ handler: g.handler, panel: () => ({ servers: async () => { panelCalls++; throw new Error('x') } }) })
await c.gates(r.a, deps)
const s = r.read().cutover
assert.deepEqual(s.gates, {})
assert.equal(panelCalls, 0)
assert.equal(g.pr, null)
})
}
test('checksGate refuses a PR whose head is not the commit the gates ran on', async () => {
const deps = fakeDeps({ handler: (m, p) => (p === '/pulls/3' ? { json: { number: 3, head: { sha: 'zzzzzzzz1' } } } : { json: {} }) })
const ctx = { g: c.gitea('t', deps), deps, dryRun: false, edgeSha: 'aaaaaaaa1', pr: { number: 3 } }
assert.match(await c.checksGate(ctx), /head zzzzzzzz is not the commit the gates ran on \(aaaaaaaa\)/)
assert.ok(!deps.calls.some((x) => x.p.includes('/status')))
})
test('merge sends Do=merge, keeps edge, names the head commit, and verifies it merged', async () => {
let merged = false
const deps = fakeDeps({
handler: (method, p, body) => {
if (method === 'POST' && p === '/pulls/7/merge') {
merged = true
return {}
}
if (method === 'GET' && p === '/pulls/7') return { json: { number: 7, head: { sha: 'abc123' }, merged } }
return {}
},
})
await c.merge({ pr: '7', sha: 'abc123' }, deps)
const post = deps.calls.find((x) => x.method === 'POST')
assert.deepEqual(post.body, { Do: 'merge', delete_branch_after_merge: false, head_commit_id: 'abc123' })
assert.equal(post.auth, 'token merge-token')
assert.equal(deps.calls[deps.calls.length - 1].method, 'GET')
})
test('merge refuses a moved head, and a merge that did not happen, and records why', async () => {
const r = rig()
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05' } }))
const moved = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'other' }, merged: false } } : {}) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123', state: r.state }, moved), /not the commit the gates ran on/)
assert.ok(!moved.calls.some((x) => x.method === 'POST'))
assert.match(r.read().cutover.mergeError, /not the commit the gates ran on/)
assert.equal(r.read().cutover.wipe, '2026-11-05')
const unmerged = fakeDeps({ handler: (m, p) => (p === '/pulls/7' ? { json: { head: { sha: 'abc123' }, merged: false } } : {}) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, unmerged), /not merged after the merge call/)
const refused = fakeDeps({ handler: (m, p) => (m === 'POST' ? { status: 409, json: { message: 'head changed' } } : { json: { head: { sha: 'abc123' } } }) })
await assert.rejects(c.merge({ pr: '7', sha: 'abc123' }, refused), /409/)
await assert.rejects(c.merge({ pr: '7' }, refused), /--sha/)
})
test('after records the merge, comments the release on each closed issue once, and is safe to repeat', async () => {
const r = rig()
fs.writeFileSync(r.state, JSON.stringify({ cutover: { wipe: '2026-11-05', mergeError: 'old', gates: {} } }))
const posted = {}
const deps = fakeDeps({
handler: (method, p, body) => {
if (p === '/pulls/5') return { json: { number: 5, merged: true, merged_at: '2026-11-05T20:00:00Z', body: 'Closes #20\nCloses #22' } }
if (p.startsWith('/releases')) return { json: [{ tag_name: 'v1.2.0', created_at: '2026-11-05T20:05:00Z' }, { tag_name: 'v1.1.0', created_at: '2026-10-01T00:00:00Z' }] }
if (p === '/issues/20') return { json: { state: 'closed' } }
if (p === '/issues/22') return { json: { state: 'open' } }
if (method === 'GET' && p === '/issues/20/comments') return { json: (posted[20] || []).map((b) => ({ body: b })) }
if (method === 'POST' && p === '/issues/20/comments') {
posted[20] = [...(posted[20] || []), body.body]
return {}
}
return { json: [] }
},
})
await c.after({ state: r.state, pr: '5' }, deps)
await c.after({ state: r.state, pr: '5' }, deps)
assert.deepEqual(posted[20], ['Released in v1.2.0 by the wipe cutover (D332).'])
assert.equal(deps.calls.filter((x) => x.method === 'POST').length, 1)
assert.ok(!deps.calls.some((x) => x.p.startsWith('/issues/22/comments')))
assert.equal(r.read().cutover.merged, true)
assert.equal(r.read().cutover.mergeError, undefined)
})
test('the Closes list is the PRs merged into edge after the newest edge commit already in main', async () => {
const prs = [
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-20T00:00:00Z', body: 'Closes #1' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-25T00:00:00Z', body: 'Closes #2, fixes #6' },
{ merged: true, base: { ref: 'main' }, merged_at: '2026-10-26T00:00:00Z', body: 'Closes #3' },
{ merged: false, base: { ref: 'edge' }, merged_at: null, body: 'Closes #4' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-10-30T00:00:00Z', body: 'Refs #5' },
]
const asked = []
const deps = fakeDeps({
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
git: (...a) => {
asked.push(a.join(' '))
if (a[0] === 'merge-base') return 'deadbeef'
if (a[0] === 'log') return '2026-10-22T09:00:00+00:00'
return ''
},
})
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [2, 6])
assert.ok(asked.includes('merge-base origin/main origin/edge'))
assert.ok(asked.includes('log -1 --format=%cI deadbeef'))
})
test('a PR whose merge commit main already has is not listed again, though merged a moment after the cut-off', async () => {
const prs = [
// edge's tip at the last cutover: its merged_at is a second after the merge commit's committer date.
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-05T19:00:01Z', merge_commit_sha: 'shipped1', body: 'Closes #20' },
{ merged: true, base: { ref: 'edge' }, merged_at: '2026-11-20T00:00:00Z', merge_commit_sha: 'new1', body: 'Closes #30' },
]
const deps = fakeDeps({
handler: (m, p) => (p.startsWith('/pulls?state=closed') ? { json: prs } : {}),
git: (...a) => {
if (a[0] === 'merge-base') return 'shipped1'
if (a[0] === 'log') return '2026-11-05T19:00:00+00:00'
if (a[0] === 'branch') return a[3] === 'shipped1' ? ' origin/main' : ''
return ''
},
})
assert.deepEqual(await c.closesSinceCutOff(c.gitea('t', deps), deps), [30])
})
test('rust-carbon is started again only when it was running at the start and is offline now, and nothing else is touched', async () => {
const dir = tmp()
const powered = []
const mkPanel = (state) => () => ({
servers: async () => ({ 'rust-carbon': 'abc', 'rust-oxide': 'def' }),
state: async () => state,
power: async (id, signal) => powered.push([id, signal]),
})
await c.rigNote({ work: dir }, { panel: mkPanel('running') })
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'rig-carbon.json'), 'utf8')), { name: 'rust-carbon', state: 'running' })
await c.rigRestore({ work: dir }, { panel: mkPanel('running') })
await c.rigRestore({ work: dir }, { panel: mkPanel('starting') })
assert.deepEqual(powered, [])
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
assert.deepEqual(powered, [['abc', 'start']])
// It was off at the start: left off.
await c.rigNote({ work: dir }, { panel: mkPanel('offline') })
await c.rigRestore({ work: dir }, { panel: mkPanel('offline') })
assert.equal(powered.length, 1)
})
// ---- the workflow and the scripts agree
const yml = fs.readFileSync(path.join(__dirname, '..', '..', '.gitea', 'workflows', 'cutover.yml'), 'utf8')
const src = fs.readFileSync(path.join(__dirname, 'cutover.js'), 'utf8')
function stepsOf(text) {
return text
.split(/\n {6}- (?=name:|uses:)/)
.slice(1)
.map((b) => ({ id: (/^\s+id: (\w+)/m.exec(b) || [])[1], command: (/cutover\.js (\S+)/.exec(b) || /drill\.js (\S+)/.exec(b) || [])[1], script: /drill\.js/.test(b) && !/cutover\.js/.test(b) ? 'drill' : 'cutover', text: b }))
}
const FUNCTIONS = { window: 'window_', pending: 'pending', gates: 'gates', merge: 'merge', after: 'after' }
function written(command) {
const m = new RegExp(`\\n(?:async )?function ${FUNCTIONS[command]}\\(`).exec(src)
assert.ok(m, `cutover.js has no function for ${command}`)
const body = src.slice(m.index, src.indexOf('\n}\n', m.index))
return new Set([...body.matchAll(/output\([^,]+,\s*'(\w+)'/g)].map((x) => x[1]))
}
test('every steps.<id>.outputs.<name> the workflow reads is written by the command that step runs', () => {
const steps = stepsOf(yml)
const used = [...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].map((m) => [m[1], m[2]])
assert.ok(used.length >= 8)
for (const [id, name] of used) {
const step = steps.find((s) => s.id === id)
assert.ok(step, `steps.${id} is not a step of cutover.yml`)
assert.equal(step.script, 'cutover', `steps.${id}.outputs.${name}: ${id} runs drill.js, which writes no output the workflow reads`)
assert.ok(written(step.command).has(name), `cutover.js ${step.command} never writes output ${name} (read as steps.${id}.outputs.${name})`)
}
})
test('and the outputs the logic depends on are all read by the workflow', () => {
const steps = stepsOf(yml)
const needed = { window: ['open'], pending: ['run'], gates: ['live', 'merge', 'pr', 'sha'], merge: ['merged'] }
for (const [command, names] of Object.entries(needed)) {
const ids = steps.filter((s) => s.script === 'cutover' && s.command === command && s.id).map((s) => s.id)
assert.ok(ids.length > 0, `no step with an id runs cutover.js ${command}`)
const read = new Set([...yml.matchAll(/steps\.(\w+)\.outputs\.(\w+)/g)].filter((m) => ids.includes(m[1])).map((m) => m[2]))
for (const n of names) {
assert.ok(read.has(n), `cutover.yml never reads ${n} from the ${command} step`)
assert.ok(written(command).has(n), `cutover.js ${command} no longer writes ${n}`)
}
}
})
test('the code checked out is edge, except on a dry dispatch that names code_ref', () => {
const refs = yml.split('\n').filter((l) => /^\s*ref:/.test(l)).map((l) => l.trim())
assert.deepEqual(refs, [
"ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' && github.event.inputs.code_ref || 'edge' }}",
])
})
test('RUNICNPC_TOKEN is in the merge step alone, and a dispatch is dry unless dry_run is exactly false', () => {
const uses = yml.split('\n').filter((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN'))
assert.equal(uses.length, 1)
const merge = stepsOf(yml).find((s) => s.text.split('\n').some((l) => !l.trim().startsWith('#') && l.includes('RUNICNPC_TOKEN')))
assert.equal(merge.id, 'merge')
assert.match(merge.text, /MERGE_TOKEN: \$\{\{ secrets\.RUNICNPC_TOKEN \}\}/)
const code = yml.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n')
assert.ok(!/dry_run\s*[=!]=\s*'true'/.test(code), 'dry_run must be compared with false, never true')
assert.ok((code.match(/dry_run != 'false'/g) || []).length >= 6)
for (const key of ['LC_ALL: C', "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'"]) assert.equal(code.split(key).length - 1, 1, `${key} on the static half`)
assert.ok(!/BRANCH: staging/.test(code), 'gate 1 reads Steam, not a staging static half')
assert.match(code, /timeout-minutes: 240/)
})

668
tools/drill/drill.js Normal file
View File

@@ -0,0 +1,668 @@
// 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 <drill.json> --work <dir> a build not checked yet?
// node tools/drill/drill.js live --work <dir> [--branch staging] the live half, on rust-staging
// node tools/drill/drill.js report --state <drill.json> --work <dir> 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 HARNESS_CAP_MS = 45 * 60000 // the whole harness run
const HARNESS_STALL_MS = 5 * 60000 // no new line in its file for this long: it has stopped (issue #20)
// What a fresh server would not have (issue #20). RunicNPC writes its four example kits once, into
// Kits' own file, and never again, so a file kept from an older build keeps that build's kits; the drill
// starts from none of it. Files, never directories: a directory the panel's file API creates is not
// writable by the game (docs/runicnpc/PLAN.md §1.5), and the game makes these itself on its first boot.
// Kits' file is not deleted but replaced by one holding only the harness's kit (KITS_FILE, harnessKits):
// Kits reads it at boot and RunicNPC adds its examples beside it, so no reload is needed for it (#22).
const KITS_FILE = '/oxide/data/Kits/kits_data.json'
const FRESH_DATA = [
'/oxide/data/RunicNPC/profiles.json',
'/oxide/data/RunicNPC/placements.json',
'/oxide/data/RunicNPC/routes.json',
'/oxide/data/RunicNPC/state.json',
]
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')
for (const f of FRESH_DATA) await p.rm(rig, f) // already gone is fine: rm swallows it
await p.write(rig, KITS_FILE, JSON.stringify(harnessKits(), null, 2))
const logMark = await oxideLogMark(p, rig)
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, logMark)
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, finding)
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, logMark)
return
}
result.ran = true
log('rnt.run all: running')
const { lines, ended } = await waitHarness(p, rig, HARNESS_CAP_MS, HARNESS_STALL_MS)
result.harness = summarise(lines)
if (!result.harness.done) {
finding(unfinishedFinding(result.harness, lines, ended))
// What the server said while it stalled (an exception in the harness's coroutine lands here, not in its file).
result.consoleTail = safeTail(await p.readOr(rig, '/latest.log', ''), 40)
}
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, logMark)
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
}
/** Kits' data file holding only the harness's hidden kit, which the drill lays down before the start. */
function harnessKits() {
return { _kits: { rnhrevolver: harnessKit() } }
}
/**
* The harness's kit is laid down before the start (harnessKits), so it is normally there. If it is not
* (RunicNPC rewrote the file without it), it is added and Kits reloaded through the console's websocket:
* the panel's fire-and-forget command left no reload in run 48 (#22), so the reply is logged.
*/
async function ensureKit(p, id, finding) {
const file = KITS_FILE
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) {
log('the rnhrevolver kit is in Kits\' file, laid down before the start')
return
}
data._kits.rnhrevolver = harnessKit()
await p.write(id, file, JSON.stringify(data, null, 2))
const reply = await p.console(id, 'oxide.reload Kits', 15)
const loaded = reply.some((l) => /Loaded plugin Kits/.test(l))
log(`added the rnhrevolver kit and reloaded Kits: ${loaded ? 'Kits loaded again' : `no "Loaded plugin Kits" in 15 s (${reply.length} console line(s))`}`)
if (!loaded) finding('Kits did not reload after the drill added the harness\'s rnhrevolver kit; the harness\'s profiles may be refused.')
}
function harnessKit() {
const item = (Shortname, Amount, Position, Condition = 0) => ({ Shortname, Skin: 0, Amount, Condition, MaxCondition: Condition, Ammo: 0, Position, Frequency: -1 })
return {
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)],
}
}
function readJsonText(t) {
try {
return t ? JSON.parse(t) : null
} catch {
return null
}
}
/**
* Where the harness stands, given its lines now and what was last seen of them ({ count, at }): done
* (`# done all:`), stalled (no new line for stallMs: it died without saying so, as in issue #20), or running.
*/
function harnessProgress(lines, seen, nowMs, stallMs) {
if (lines.some((l) => /^# done all:/.test(l))) return { state: 'done', seen }
if (lines.length > seen.count) return { state: 'running', seen: { count: lines.length, at: nowMs } }
if (nowMs - seen.at >= stallMs) return { state: 'stalled', seen }
return { state: 'running', seen }
}
/** Waits for the harness; `ended` is done, stalled or capped (timeoutMs passed). */
async function waitHarness(p, id, timeoutMs, stallMs) {
const start = Date.now()
let lines = []
let seen = { count: 0, at: start }
while (Date.now() < start + timeoutMs) {
await sleep(30000)
lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines
const v = harnessProgress(lines, seen, Date.now(), stallMs)
seen = v.seen
if (v.state !== 'running') return { lines, ended: v.state }
}
return { lines, ended: 'capped' }
}
/** The finding for a harness that never wrote `# done all:`. */
function unfinishedFinding(h, lines, ended) {
const so = `${h.pass} pass, ${h.fail} fail so far`
if (ended !== 'stalled') return `The harness did not finish in ${HARNESS_CAP_MS / 60000} minutes (${so}).`
const last = (lines[lines.length - 1] || 'no line at all').replace(/`/g, "'").slice(0, 200)
return `The harness stopped after \`${last}\` (no output for ${HARNESS_STALL_MS / 60000} minutes; ${so}).`
}
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. */
/** Where Oxide's log of today ends now, so oxideErrors reads only what a run adds (run 49 counted run 48's). */
async function oxideLogMark(p, id) {
const day = now().slice(0, 10)
return { day, length: ((await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || '').length }
}
async function oxideErrors(p, id, since = null) {
const day = now().slice(0, 10)
const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || ''
return text
.slice(since && since.day === day ? since.length : 0)
.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
// The pure parts, for drill.test.js; run as a script, it is the command line. panel and waitRunning serve
// cutover.js (D332), and report is exported so a test can show it keeps the state keys it does not own.
module.exports = { harnessProgress, unfinishedFinding, FRESH_DATA, KITS_FILE, harnessKits, HARNESS_STALL_MS, panel, waitRunning, report }
if (require.main === module) {
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)
})
}

87
tools/drill/drill.test.js Normal file
View File

@@ -0,0 +1,87 @@
// The drill's pure parts: how it decides the harness has stopped (issue #20), and what it starts clean.
// node --test tools/drill/drill.test.js
const test = require('node:test')
const assert = require('node:assert')
const { harnessProgress, unfinishedFinding, FRESH_DATA, KITS_FILE, harnessKits, HARNESS_STALL_MS } = require('./drill.js')
const MIN = 60000
const T0 = 1000000
test('a harness that keeps writing is running, and its clock restarts on each new line', () => {
let v = harnessProgress(['# run all'], { count: 0, at: T0 }, T0 + 30000, HARNESS_STALL_MS)
assert.equal(v.state, 'running')
assert.deepEqual(v.seen, { count: 1, at: T0 + 30000 })
// Four silent minutes, then a line: still running, and the clock restarts.
v = harnessProgress(['# run all', 'a: PASS '], v.seen, T0 + 30000 + 4 * MIN, HARNESS_STALL_MS)
assert.equal(v.state, 'running')
assert.equal(v.seen.at, T0 + 30000 + 4 * MIN)
})
test('no new line for five minutes is a stall; four minutes and a bit is not', () => {
const seen = { count: 2, at: T0 }
const lines = ['# run all', 'a: PASS ']
assert.equal(harnessProgress(lines, seen, T0 + 5 * MIN - 1, HARNESS_STALL_MS).state, 'running')
assert.equal(harnessProgress(lines, seen, T0 + 5 * MIN, HARNESS_STALL_MS).state, 'stalled')
})
test('a harness that never wrote a line stalls from when the wait began', () => {
assert.equal(harnessProgress([], { count: 0, at: T0 }, T0 + 5 * MIN, HARNESS_STALL_MS).state, 'stalled')
})
test('done wins, even when the file has been still for a long time', () => {
const lines = ['# run all', '# done all: 10 pass, 1 fail']
assert.equal(harnessProgress(lines, { count: 2, at: T0 }, T0 + 30 * MIN, HARNESS_STALL_MS).state, 'done')
})
test('the stall finding names the last line and the counts', () => {
const lines = ['# run all', 'place.set.each: FAIL position: off the navmesh', 'place.spawned: FAIL each 0, group 3']
const f = unfinishedFinding({ pass: 40, fail: 2 }, lines, 'stalled')
assert.equal(f, 'The harness stopped after `place.spawned: FAIL each 0, group 3` (no output for 5 minutes; 40 pass, 2 fail so far).')
})
test('a backtick in the last line cannot break the issue body, and a long line is cut', () => {
const f = unfinishedFinding({ pass: 0, fail: 0 }, ['x`y' + 'z'.repeat(500)], 'stalled')
assert.equal(f.split('`').length, 3)
assert.ok(f.length < 330)
assert.match(unfinishedFinding({ pass: 0, fail: 0 }, [], 'stalled'), /stopped after `no line at all`/)
})
test('a capped wait keeps the 45-minute message', () => {
assert.equal(unfinishedFinding({ pass: 3, fail: 1 }, ['# run all'], 'capped'), 'The harness did not finish in 45 minutes (3 pass, 1 fail so far).')
})
test('the clean start deletes files only: every path ends in .json and none is a directory', () => {
assert.ok(FRESH_DATA.includes('/oxide/data/RunicNPC/state.json'))
for (const f of FRESH_DATA) assert.match(f, /^\/oxide\/data\/RunicNPC\/[a-z_]+\.json$/)
})
test("Kits' file is replaced, not deleted, by one holding only the harness's kit (#22)", () => {
assert.ok(!FRESH_DATA.includes(KITS_FILE))
assert.equal(KITS_FILE, '/oxide/data/Kits/kits_data.json')
const kits = harnessKits()._kits
assert.deepEqual(Object.keys(kits), ['rnhrevolver'])
assert.equal(kits.rnhrevolver.Name, 'rnhrevolver')
assert.equal(kits.rnhrevolver.IsHidden, true)
assert.deepEqual(kits.rnhrevolver.BeltItems.map((i) => i.Shortname), ['pistol.revolver'])
})
test('report keeps the state keys it does not own (the cutover, D332) and writes its own', async () => {
const fs = require('fs')
const os = require('os')
const path = require('path')
const { report } = require('./drill.js')
delete process.env.GITHUB_STEP_SUMMARY
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drill-report-'))
const cutover = { wipe: '2026-11-05', manifest: '1', gates: { public: { at: '2026-11-05T17:11:00.000Z' } }, pr: 7 }
fs.writeFileSync(path.join(dir, 'drill.json'), JSON.stringify({ cutover }))
const stat = { branch: 'staging', manifest: '42', manifestDate: '10/31/2026 11:48:58', managed: 'ok', oxideBehind: 0, fields: 'same', compile: 'ok' }
fs.writeFileSync(path.join(dir, 'static.json'), JSON.stringify(stat))
fs.writeFileSync(path.join(dir, 'live.json'), JSON.stringify({ branch: 'staging', ran: true, findings: [], harness: { done: true, pass: 3, fail: 0, failed: [] } }))
await report({ state: path.join(dir, 'drill.json'), work: dir })
const after = JSON.parse(fs.readFileSync(path.join(dir, 'drill.json'), 'utf8'))
assert.deepEqual(after.cutover, cutover)
assert.equal(after.checked.manifest, '42')
assert.equal(after.lastRun.outcome, 'pass')
fs.rmSync(dir, { recursive: true, force: true })
})

68
tools/drill/rig-start.sh Normal file
View File

@@ -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 "$@"

177
tools/drill/static.sh Normal file
View File

@@ -0,0 +1,177 @@
#!/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 "$WORK"/appinfo.txt
managed=error
behind=-1
fields=error
compile=error
note=""
oxide_modified=""
manifest=""
manifest_date=""
steam_note=""
finish() {
printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s","steamNote":"%s"}\n' \
"$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" "$steam_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; }
# ---- When Steam set the branch to its build (BRANCH=public only: the cutover's gate 1) ----
# The depot manifest's date below is when Facepunch BUILT the build, not when it went live (the
# 2026-11-05 wipe build was dated 11-04). Steam's app info says when the branch was SET to it
# (depots.branches.<branch>.timeupdated) and which Linux depot manifest that is. steamcmd's whole
# output goes to appinfo.txt; tools/drill/cutover.js reads it. A failure only leaves steamNote set,
# and gate 1 blocks. STEAMCMD points at another steamcmd (steamcmd.sh, or steamcmd.exe on Windows).
steam_info() {
local sc=${STEAMCMD:-} i
if [ -z "$sc" ]; then
mkdir -p "$WORK/steamcmd"
curl -sSL --fail https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar -xz -C "$WORK/steamcmd" \
|| { steam_note="steamcmd could not be fetched"; return; }
sc=$WORK/steamcmd/steamcmd.sh
fi
# steamcmd updates itself on its first start and then may print nothing, so it gets three tries.
# It is 32-bit: a bare runner image lacks its libgcc, which is installed after the first miss.
for i in 1 2 3; do
"$sc" +login anonymous +app_info_update 1 +app_info_print 258550 +quit > "$WORK/appinfo.txt" 2>&1
grep -q '^"258550"' "$WORK/appinfo.txt" && return
if [ "$i" = 1 ] && command -v apt-get > /dev/null && [ "$(id -u)" = 0 ]; then
{ apt-get update -qq && apt-get install -y -qq lib32gcc-s1; } > "$WORK/steamcmd-deps.txt" 2>&1
fi
done
steam_note="steamcmd printed no app info: $(tail -2 "$WORK/appinfo.txt" | tr '\n"\\' ' ')"
}
if [ "$BRANCH" = public ]; then
steam_info
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>/,/\/\/ <\/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

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Generates the swap's field list (docs/runicnpc/PLAN.md D232) from Rust's unmodified
Assembly-CSharp.dll. A developer tool: never shipped, not built by CI. See Program.cs.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="9.0.0" />
</ItemGroup>
</Project>

139
tools/fieldlist/Program.cs Normal file
View File

@@ -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 <dir> (once per Rust update)
// dotnet run --project tools/fieldlist -- <dir> [plugin/RunicNPC.cs]
//
// <dir> must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields
// public, so its Assembly-CSharp.dll no longer says what Rust itself serialises.
//
// The rule is stage 1's, which is Unity's own for a component's authored data: every instance
// field from the component's type down to (not including) MonoBehaviour that is public and not
// [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to
// the unmodified assembly, it picks the same fields on both frameworks. The result replaces the
// block between the two marker lines in the plugin, and the plugin resolves each name at load.
//
// The staging drill (D308) also asks it whether Oxide's build has caught up with Rust's:
//
// dotnet run --project tools/fieldlist -- --oxide-behind <managed dir> <Oxide's Assembly-CSharp.dll>
//
// 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 <n>" 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 -- <managed dir> [plugin/RunicNPC.cs]");
Console.Error.WriteLine(" dotnet run --project tools/fieldlist -- --oxide-behind <managed dir> <Oxide's Assembly-CSharp.dll>");
return 2;
}
string managed = args[0];
string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs");
string[] dlls = Directory.GetFiles(managed, "*.dll");
var resolver = new PathAssemblyResolver(dlls);
using var context = new MetadataLoadContext(resolver, "mscorlib");
Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll"));
List<string> Fields(string typeName)
{
Type type = rust.GetType(typeName, throwOnError: true)!;
var names = new List<string>();
for (Type? t = type; t != null && t.FullName != "UnityEngine.MonoBehaviour"; t = t.BaseType)
{
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (f.IsInitOnly || f.IsLiteral)
continue;
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) ||
f.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField");
if (!serialised || IsDelegate(f.FieldType))
continue;
names.Add($"{t.Name}.{f.Name}");
}
}
return names;
}
static bool IsDelegate(Type t)
{
for (Type? b = t; b != null; b = b.BaseType)
if (b.FullName == "System.Delegate")
return true;
return false;
}
List<string> npc = Fields("ScientistNPC");
List<string> brain = Fields("ScientistBrain");
Guid mvid = rust.ManifestModule.ModuleVersionId;
const string Open = " // <fieldlist>";
const string Close = " // </fieldlist>";
var block = new StringBuilder();
block.AppendLine(Open);
block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}.");
block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232).");
Append(block, "SwapNpcFields", npc);
Append(block, "SwapBrainFields", brain);
block.Append(Close);
static void Append(StringBuilder sb, string name, List<string> fields)
{
sb.AppendLine($" private static readonly string[] {name} =");
sb.AppendLine(" {");
foreach (string f in fields)
sb.AppendLine($" \"{f}\",");
sb.AppendLine(" };");
}
string text = File.ReadAllText(plugin);
string nl = text.Contains("\r\n") ? "\r\n" : "\n";
int start = text.IndexOf(Open, StringComparison.Ordinal);
int end = text.IndexOf(Close, StringComparison.Ordinal);
if (start < 0 || end < start)
{
Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found.");
return 1;
}
string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl);
File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]);
Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid}).");
return 0;
static int OxideBehind(string managed, string oxideAssembly)
{
HashSet<string> Declared(string assembly)
{
IEnumerable<string> 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<string>();
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<string> 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;
}

49
tools/managed.js Normal file
View File

@@ -0,0 +1,49 @@
// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory,
// for tools/fieldlist, which reads Rust's own field declarations from them (D232).
//
// node tools/managed.js <rig> <out dir>
//
// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public,
// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the
// assembly as Facepunch shipped it.
const fs = require('fs')
const path = require('path')
const { load, serverId, unmsys } = require('./panel')
const config = load()
const [rig, out] = process.argv.slice(2).map(unmsys)
if (!rig || !out) {
console.error('usage: node tools/managed.js <rig> <out dir>')
process.exit(2)
}
const base = `${config.panel}/api/client/servers/${serverId(config, rig)}`
const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' }
const dir = '/RustDedicated_Data/Managed'
async function main() {
const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers })
if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`)
const files = (await list.json()).data
.map((f) => f.attributes)
.filter((f) => f.is_file && f.name.endsWith('.dll'))
fs.mkdirSync(out, { recursive: true })
let bytes = 0
for (const f of files) {
const target = path.join(out, f.name)
if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue
const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers })
if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`)
const body = await fetch((await link.json()).attributes.url)
fs.writeFileSync(target, Buffer.from(await body.arrayBuffer()))
bytes += f.size
}
console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`)
}
main().catch((e) => {
console.error(e.message || e)
process.exit(1)
})

View File

@@ -3,6 +3,7 @@
"tokenFile": "/path/to/pterodactyl_api_token", "tokenFile": "/path/to/pterodactyl_api_token",
"rigs": { "rigs": {
"oxide": "00000000", "oxide": "00000000",
"carbon": "00000000" "carbon": "00000000",
"staging": "00000000"
} }
} }