7 Commits

Author SHA1 Message Date
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
6 changed files with 291 additions and 53 deletions

View File

@@ -57,3 +57,7 @@ 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

View File

@@ -98,7 +98,7 @@ The API version moves when a call or a raised hook changes shape, not on every r
## After a Rust update: the swap's field list ## 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), Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232),
generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=55/55 brain=32/32 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 missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it: the list was made. To regenerate it:
@@ -121,7 +121,7 @@ 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 | | Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by |
|---|---|---|---| |---|---|---|---|
| `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (55 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list | | `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 | | `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 | | `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 | | `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 |
@@ -153,8 +153,11 @@ the week. A run by hand always goes ahead.
plugin against them. plugin against them.
- **Live, once per build** (`tools/drill/drill.js live`): the `rust-staging` server (Oxide, a 3500 map) - **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 installs the same pair at start (`tools/drill/rig-start.sh`), loads RunicNPC, Kits and
`tools/RunicNpcTest.cs`, and runs `rnt.run all`. When both 6000-map rigs are running, `rust-carbon` is `tools/RunicNpcTest.cs`, and runs `rnt.run all`. It starts the server from fresh Kits and RunicNPC data
stopped for it and started again. (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`. - **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. The last build checked is kept on the `drill` branch.

View File

@@ -947,6 +947,7 @@ namespace Oxide.Plugins
"BasePlayer.debugCommandedNpcs", "BasePlayer.debugCommandedNpcs",
"BasePlayer.GestureViewModel", "BasePlayer.GestureViewModel",
"BasePlayer.lastPlayerVisibility", "BasePlayer.lastPlayerVisibility",
"BasePlayer.timeSinceAiming",
"BasePlayer.eyeHistory", "BasePlayer.eyeHistory",
"BasePlayer.tickHistory", "BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect", "BasePlayer.fallDamageEffect",
@@ -974,12 +975,14 @@ namespace Oxide.Plugins
"BaseEntity.model", "BaseEntity.model",
"BaseEntity.flags", "BaseEntity.flags",
"BaseEntity.HasBrain", "BaseEntity.HasBrain",
"BaseEntity.ServerPreProcessed",
"BaseNetworkable.postNetworkUpdateComponents", "BaseNetworkable.postNetworkUpdateComponents",
"BaseNetworkable.prefabID", "BaseNetworkable.prefabID",
"BaseNetworkable.globalBroadcast", "BaseNetworkable.globalBroadcast",
"BaseNetworkable.globalNetworkBehavior", "BaseNetworkable.globalNetworkBehavior",
"BaseNetworkable.globalBuildingBlock", "BaseNetworkable.globalBuildingBlock",
"BaseNetworkable.networkRange", "BaseNetworkable.networkRange",
"BaseNetworkable.ignoreNetworkLod",
}; };
private static readonly string[] SwapBrainFields = private static readonly string[] SwapBrainFields =
{ {
@@ -2781,13 +2784,29 @@ namespace Oxide.Plugins
if (container == null) if (container == null)
continue; continue;
foreach (Item item in container.itemList) foreach (Item item in container.itemList)
if (item != null && item.GetHeldEntity() is T) if (item != null && HeldAs<T>(item) != null)
return item; return item;
} }
return null; return null;
} }
/// <summary>
/// The item's held entity as a <typeparamref name="T"/>, or, when it has none, its definition's
/// entity prefab's. Since Rust's November 2026 build a "player-only" item (a syringe) has a held
/// entity only in the belt (<c>ItemModEntity.playerOnlyEntityBeltOnly</c>), and an extra waits in
/// the main inventory until <see cref="Hold"/> moves it (issue #20).
/// </summary>
private static T HeldAs<T>(Item item) where T : HeldEntity
{
BaseEntity held = item.GetHeldEntity();
if (held != null)
return held as T;
ItemModEntity mod = item.info.GetComponent<ItemModEntity>();
GameObject prefab = mod != null && mod.entityPrefab != null && mod.entityPrefab.isValid ? mod.entityPrefab.Get() : null;
return prefab != null ? prefab.GetComponent<T>() : null;
}
/// <summary>Puts an item in its hands; only the belt can be held, so it is moved there first.</summary> /// <summary>Puts an item in its hands; only the belt can be held, so it is moved there first.</summary>
private static bool Hold(RunicNpcPlayer npc, Item item) private static bool Hold(RunicNpcPlayer npc, Item item)
{ {
@@ -2911,7 +2930,8 @@ namespace Oxide.Plugins
if (amount <= 0f) if (amount <= 0f)
return false; return false;
Hold(npc, item); if (!Hold(npc, item))
return false; // a full belt: nowhere to hold it.
npc.SignalBroadcast(BaseEntity.Signal.Attack); npc.SignalBroadcast(BaseEntity.Signal.Attack);
item.UseItem(1); item.UseItem(1);
npc.Heal(amount); npc.Heal(amount);
@@ -3008,8 +3028,10 @@ namespace Oxide.Plugins
private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) private static bool HasFuel(RunicNpcPlayer npc, Item flameItem)
{ {
FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; // Off the belt it may have no held entity, so no tank (HeldAs): only the kit's fuel counts then.
return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); FlameThrower held = flameItem.GetHeldEntity() as FlameThrower;
FlameThrower f = HeldAs<FlameThrower>(flameItem);
return f != null && ((held != null && held.ammo > 0) || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null));
} }
/// <summary> /// <summary>

View File

@@ -45,7 +45,7 @@ namespace Oxide.Plugins
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage. /// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para> /// </para>
/// </summary> /// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.7.3")] [Info("RunicNpcTest", "Runic Gateway", "0.7.5")]
[Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin internal class RunicNpcTest : RustPlugin
{ {
@@ -753,7 +753,25 @@ namespace Oxide.Plugins
continue; continue;
} }
while (inner.MoveNext()) yield return inner.Current; // A group that throws is a FAIL line, not the end of the run: yield cannot sit inside a try, so
// each step is taken inside one and the value is yielded outside it.
while (true)
{
object step;
try
{
if (!inner.MoveNext()) break;
step = inner.Current;
}
catch (Exception e)
{
Check($"{g}.exception", false, $"{e.GetType().Name}: {e.Message} at {(e.StackTrace ?? "").Split('\n').FirstOrDefault()?.Trim()}");
break;
}
yield return step;
}
Api<int>("RunicNpc_DespawnOwner", Owner); Api<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld(); ClearWorld();
yield return new WaitForSeconds(1f); yield return new WaitForSeconds(1f);
@@ -1095,16 +1113,28 @@ namespace Oxide.Plugins
private IEnumerator Place(Vector3 field) private IEnumerator Place(Vector3 field)
{ {
Vector3 a = field + new Vector3(-40, 0, 0); // A roamer's spot must be on the navmesh when it is set (D219), so both spots are put there; the
Vector3 b = field + new Vector3(-40, 0, 30); // wider search because a fixed offset from the field can land a few metres off it (run 49: 4.4 m).
StandIn(field + new Vector3(-40, 0, 110), 8); Vector3 a = OnMesh(field + new Vector3(-40, 0, 0), 15f);
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); Vector3 b = OnMesh(field + new Vector3(-40, 0, 30), 15f);
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); StandIn(OnMesh(field + new Vector3(-40, 0, 110)), 8);
string eachRefusal = Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each"));
Check("place.set.each", eachRefusal == null, eachRefusal ?? "");
string groupRefusal = Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group"));
Check("place.set.group", groupRefusal == null, groupRefusal ?? "");
yield return new WaitForSeconds(4f); yield return new WaitForSeconds(4f);
List<Dictionary<string, object>> each = List("placement:t_each"); List<Dictionary<string, object>> each = List("placement:t_each");
List<Dictionary<string, object>> group = List("placement:t_group"); List<Dictionary<string, object>> group = List("placement:t_group");
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}"); Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
if (each.Count != 3 || group.Count != 3)
{
// Everything below needs both sets alive. Take back what was set and end the group, so the run goes on.
Api<bool>("RunicNpc_RemovePlacement", "t_each");
Api<bool>("RunicNpc_RemovePlacement", "t_group");
Note("place: the placements did not spawn, so the rest of the group is skipped");
yield break;
}
// each: kill one; the other two stay, and it alone returns after ~20 s. // each: kill one; the other two stay, and it alone returns after ~20 s.
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"])); var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));
@@ -1540,11 +1570,11 @@ namespace Oxide.Plugins
return floor; return floor;
} }
private static Vector3 OnMesh(Vector3 p) private static Vector3 OnMesh(Vector3 p, float radius = 3f)
{ {
p.y = TerrainMeta.HeightMap.GetHeight(p); p.y = TerrainMeta.HeightMap.GetHeight(p);
NavMeshHit hit; NavMeshHit hit;
return SampleNavmesh(p, 3f, out hit) ? hit.position : p; return SampleNavmesh(p, radius, out hit) ? hit.position : p;
} }
private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) => private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) =>
@@ -1723,6 +1753,31 @@ namespace Oxide.Plugins
return false; return false;
} }
/// <summary>
/// Where a stage 5 fight's second NPC stands: <paramref name="dist"/> m from the first, on the navmesh,
/// with a clear line between their eyes. North first, as the fights were laid out; then the other seven
/// ways round. On the drill's 3500 map a cliff rock stood between the victim and the bully north of it,
/// so neither ever saw the other and s5.fight.shootBack failed on every run (issue #20).
/// </summary>
private Vector3 Partner(Vector3 at, float dist)
{
for (int k = 0; k < 8; k++)
{
float angle = k * Mathf.PI / 4f;
Vector3 p = OnMesh(at + new Vector3(Mathf.Sin(angle), 0f, Mathf.Cos(angle)) * dist);
NavMeshHit hit;
if (!SampleNavmesh(p, 1f, out hit)) continue;
// Each one's eyes to the other's centre, both ways, as RunicNPC's own line of sight runs.
if (Physics.Linecast(at + Vector3.up * 1.6f, p + Vector3.up * 0.9f, 1218519041, QueryTriggerInteraction.Ignore) ||
Physics.Linecast(p + Vector3.up * 1.6f, at + Vector3.up * 0.9f, 1218519041, QueryTriggerInteraction.Ignore)) continue;
if (k > 0) Note($"s5: the partner {dist:0} m from {at} stands {k * 45}° round from north (no clear line north)");
return p;
}
Note($"s5: no clear line {dist:0} m round {at}; the partner stands north as before");
return OnMesh(at + new Vector3(0, 0, dist));
}
private int HorrorLeft() => BaseNetworkable.serverEntities.OfType<BaseCombatEntity>().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); private int HorrorLeft() => BaseNetworkable.serverEntities.OfType<BaseCombatEntity>().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror);
private IEnumerator Stage5() private IEnumerator Stage5()
@@ -1763,16 +1818,18 @@ namespace Oxide.Plugins
Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); Vector3 d = OnMesh(f + new Vector3(70, 0, 0));
Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); Vector3 e = OnMesh(f + new Vector3(140, 0, 0));
// Each fight's second NPC where the two can see each other (issue #20), found before anyone stands there.
Vector3 aTo = Partner(a, 15f), bTo = Partner(b, 15f), cTo = Partner(c, 15f), dTo = Partner(d, 25f), eTo = Partner(e, 15f);
BasePlayer red = Spawn(a, "t_red"); BasePlayer red = Spawn(a, "t_red");
BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); BasePlayer blue = Spawn(aTo, "t_blue");
BasePlayer hunter = Spawn(b, "t_hunter"); BasePlayer hunter = Spawn(b, "t_hunter");
ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); ScientistNPC prey = SpawnScientist(bTo);
BasePlayer neutral = Spawn(c, "t_roam"); BasePlayer neutral = Spawn(c, "t_roam");
ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); ScientistNPC bystander = SpawnScientist(cTo);
BasePlayer leashed = Spawn(d, "t_leashed"); BasePlayer leashed = Spawn(d, "t_leashed");
ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); ScientistNPC far = SpawnScientist(dTo);
BasePlayer victim = Spawn(e, "t_roam"); BasePlayer victim = Spawn(e, "t_roam");
BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); BasePlayer bully = Spawn(eTo, "t_bully");
var fighters = new Dictionary<string, BaseEntity> var fighters = new Dictionary<string, BaseEntity>
{ {
{ "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral }, { "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral },
@@ -1793,7 +1850,16 @@ namespace Oxide.Plugins
if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t;
if (scientistAt < 0f && Gone(prey)) scientistAt = t; if (scientistAt < 0f && Gone(prey)) scientistAt = t;
if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true;
if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; if (!leashedFought && !Gone(leashed) && TargetOf(Describe(leashed)) != "none")
{
leashedFought = true;
// Run 49 failed here once and it has not been seen since: say how it came to fight. A target its
// own sense found is within its chase (8 m) of home; a hurt one was shot; otherwise an ally's alarm.
Dictionary<string, object> dl = Describe(leashed);
Vector3 leashHome = V(dl["home"]);
Note($"s5.fight.leash: fighting at {t} s, target {dl["target"]}, health {Convert.ToSingle(dl["health"]):0}/{Convert.ToSingle(dl["maxHealth"]):0}, " +
$"{Flat(V(dl["position"]), leashHome):0.0} m from home; the scientist {(Gone(far) ? "gone" : $"{Flat(far.transform.position, leashHome):0.0} m from its home, {far.health:0} hp")}");
}
if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true;
foreach (BasePlayer x in new[] { red, blue, hunter }) foreach (BasePlayer x in new[] { red, blue, hunter })
{ {

View File

@@ -30,6 +30,22 @@ const BIG = (process.env.DRILL_BIG_RIGS || 'rust-oxide,rust-carbon').split(',')
const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon' const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon'
const KITS_URL = 'https://umod.org/plugins/Kits.cs' 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 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 sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const now = () => new Date().toISOString() const now = () => new Date().toISOString()
@@ -262,7 +278,10 @@ async function live(a) {
await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs')
await p.rm(rig, '/oxide/data/RunicNpcTest.json') await p.rm(rig, '/oxide/data/RunicNpcTest.json')
await p.rm(rig, '/staging-drill.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}`) log(`starting ${RIG} on ${branch}`)
const started = Date.now() const started = Date.now()
await p.power(rig, 'start') await p.power(rig, 'start')
@@ -280,7 +299,7 @@ async function live(a) {
const status = await waitStatus(p, rig, 30 * 60000) const status = await waitStatus(p, rig, 30 * 60000)
if (!status) { if (!status) {
finding('RunicNPC did not load: `rnpc.status` never answered.') finding('RunicNPC did not load: `rnpc.status` never answered.')
result.oxideErrors = await oxideErrors(p, rig) result.oxideErrors = await oxideErrors(p, rig, logMark)
return return
} }
result.status = status result.status = status
@@ -294,7 +313,7 @@ async function live(a) {
} }
// The harness needs the hidden Kits kit rnhrevolver (tools/RunicNpcTest.cs). // The harness needs the hidden Kits kit rnhrevolver (tools/RunicNpcTest.cs).
await ensureKit(p, rig) await ensureKit(p, rig, finding)
await p.write(rig, '/oxide/plugins/RunicNpcTest.cs', fs.readFileSync(path.join(ROOT, 'tools', 'RunicNpcTest.cs'), 'utf8')) await p.write(rig, '/oxide/plugins/RunicNpcTest.cs', fs.readFileSync(path.join(ROOT, 'tools', 'RunicNpcTest.cs'), 'utf8'))
let accepted = false let accepted = false
@@ -304,18 +323,22 @@ async function live(a) {
} }
if (!accepted) { if (!accepted) {
finding('The test harness did not load (`rnt.run all` was not answered).') finding('The test harness did not load (`rnt.run all` was not answered).')
result.oxideErrors = await oxideErrors(p, rig) result.oxideErrors = await oxideErrors(p, rig, logMark)
return return
} }
result.ran = true result.ran = true
log('rnt.run all: running') log('rnt.run all: running')
const lines = await waitHarness(p, rig, 45 * 60000) const { lines, ended } = await waitHarness(p, rig, HARNESS_CAP_MS, HARNESS_STALL_MS)
result.harness = summarise(lines) result.harness = summarise(lines)
if (!result.harness.done) finding(`The harness did not finish in 45 minutes (${result.harness.pass} pass, ${result.harness.fail} fail so far).`) 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}`) for (const f of result.harness.failed) finding(`Harness: ${f}`)
log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`) log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`)
result.oxideErrors = await oxideErrors(p, rig) 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.`) 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(() => {}) await p.command(rig, 'rnt.clear').catch(() => {})
} catch (e) { } catch (e) {
@@ -368,8 +391,18 @@ async function waitStatus(p, id, timeoutMs) {
return last return last
} }
async function ensureKit(p, id) { /** Kits' data file holding only the harness's hidden kit, which the drill lays down before the start. */
const file = '/oxide/data/Kits/kits_data.json' 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 let data = null
for (let i = 0; i < 12 && !data; i++) { for (let i = 0; i < 12 && !data; i++) {
data = readJsonText(await p.readOr(id, file, null)) data = readJsonText(await p.readOr(id, file, null))
@@ -377,9 +410,21 @@ async function ensureKit(p, id) {
} }
if (!data) throw new Error(`Kits wrote no ${file}`) if (!data) throw new Error(`Kits wrote no ${file}`)
data._kits = data._kits || {} data._kits = data._kits || {}
if (data._kits.rnhrevolver) return 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 }) const item = (Shortname, Amount, Position, Condition = 0) => ({ Shortname, Skin: 0, Amount, Condition, MaxCondition: Condition, Ammo: 0, Position, Frequency: -1 })
data._kits.rnhrevolver = { return {
Name: 'rnhrevolver', Name: 'rnhrevolver',
Description: 'RunicNPC test harness: revolver + burlap (staging drill)', Description: 'RunicNPC test harness: revolver + burlap (staging drill)',
RequiredPermission: '', RequiredPermission: '',
@@ -394,10 +439,6 @@ async function ensureKit(p, id) {
WearItems: [item('burlap.shirt', 1, 0), item('burlap.trousers', 1, 1), item('hat.boonie', 1, 2)], WearItems: [item('burlap.shirt', 1, 0), item('burlap.trousers', 1, 1), item('hat.boonie', 1, 2)],
BeltItems: [item('pistol.revolver', 1, 0, 100)], BeltItems: [item('pistol.revolver', 1, 0, 100)],
} }
await p.write(id, file, JSON.stringify(data, null, 2))
await p.command(id, 'oxide.reload Kits')
await sleep(10000)
log('added the rnhrevolver kit and reloaded Kits')
} }
function readJsonText(t) { function readJsonText(t) {
@@ -408,15 +449,38 @@ function readJsonText(t) {
} }
} }
async function waitHarness(p, id, timeoutMs) { /**
const end = Date.now() + timeoutMs * 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 lines = []
while (Date.now() < end) { let seen = { count: 0, at: start }
while (Date.now() < start + timeoutMs) {
await sleep(30000) await sleep(30000)
lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines
if (lines.some((l) => /^# done all:/.test(l))) break const v = harnessProgress(lines, seen, Date.now(), stallMs)
seen = v.seen
if (v.state !== 'running') return { lines, ended: v.state }
} }
return lines 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) { function summarise(lines) {
@@ -427,10 +491,17 @@ function summarise(lines) {
} }
/** RunicNPC's and the harness's error lines from today's Oxide log. */ /** RunicNPC's and the harness's error lines from today's Oxide log. */
async function oxideErrors(p, id) { /** 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 day = now().slice(0, 10)
const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || '' const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || ''
return text return text
.slice(since && since.day === day ? since.length : 0)
.split('\n') .split('\n')
.filter((l) => /RunicNPC|RunicNpcTest/.test(l) && /\[Error\]|Exception|Failed|error CS/.test(l)) .filter((l) => /RunicNPC|RunicNpcTest/.test(l) && /\[Error\]|Exception|Failed|error CS/.test(l))
.slice(-40) .slice(-40)
@@ -578,14 +649,19 @@ async function fileIssue(title, body) {
// ---------------------------------------------------------------- main // ---------------------------------------------------------------- main
const a = args() // The pure parts, for drill.test.js; run as a script, it is the command line.
const commands = { window: window_, gate, live, report } module.exports = { harnessProgress, unfinishedFinding, FRESH_DATA, KITS_FILE, harnessKits, HARNESS_STALL_MS }
const command = commands[a._[0]]
if (!command) { if (require.main === module) {
console.error('usage: node tools/drill/drill.js window|gate|live|report [--state f] [--work dir] [--branch b] [--force]') const a = args()
process.exit(2) 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)
})
} }
command(a).catch((e) => {
console.error(e.message || e)
process.exit(1)
})

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

@@ -0,0 +1,67 @@
// 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'])
})