diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs
index 1c3972a..d1ce723 100644
--- a/tools/RunicNpcTest.cs
+++ b/tools/RunicNpcTest.cs
@@ -2246,6 +2246,241 @@ namespace Oxide.Plugins
Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)");
}
+ // ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ----
+
+ private int _corpsePopulated;
+ private NPCPlayerCorpse _lastCorpse;
+
+ /// Counts the framework hook for an NPC corpse being filled, for our NPC (stage 7 spike).
+ private void OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse)
+ {
+ if (npc == null || npc.GetType().Name != "RunicNpcPlayer") return;
+ _corpsePopulated++;
+ _lastCorpse = corpse;
+ }
+
+ private static string Contents(ItemContainer c) =>
+ c == null ? "-" : string.Join(", ", c.itemList.Select(i => $"{i.info.shortname}×{i.amount}").ToArray());
+
+ private static string CorpseContents(LootableCorpse c) =>
+ c == null || c.containers == null ? "-" : string.Join(" | ", c.containers.Select(Contents).ToArray());
+
+ /// rnt.spike7 [part,...]: corpse, crates, locked, removal, or all.
+ [ConsoleCommand("rnt.spike7")]
+ private void CmdSpike7(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ string part = arg.GetString(0, "all");
+ if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
+ _running = ServerMgr.Instance.StartCoroutine(Spike7(part));
+ arg.ReplyWith($"rnt: spike7 {part}");
+ }
+
+ private IEnumerator Spike7(string part)
+ {
+ _pass = _fail = 0;
+ Note($"spike7 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
+ SetTestProfiles();
+ Vector3 f;
+ if (!FindField(300f, 2027, out f))
+ {
+ Check("s7.setup.field", false, "no open field on the navmesh");
+ yield break;
+ }
+
+ string[] parts = part == "all" ? new[] { "corpse", "crates", "locked", "removal" } : part.Split(',');
+ foreach (string p in parts)
+ {
+ StandIn(f + new Vector3(0, 0, 60), 50);
+ IEnumerator inner = p == "corpse" ? CorpseSpike(f)
+ : p == "crates" ? CratesSpike(f)
+ : p == "locked" ? LockedSpike(f)
+ : p == "removal" ? RemovalSpike(f)
+ : null;
+ if (inner == null) { Check("s7.setup.part", false, "no part " + p); continue; }
+ while (inner.MoveNext()) yield return inner.Current;
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ yield return new WaitForSeconds(1f);
+ }
+
+ Note($"done spike7 {part}: {_pass} pass, {_fail} fail");
+ _running = null;
+ }
+
+ /// Kills one t_roam as a stand-in and returns its corpse (found near where it died).
+ private IEnumerator KillForCorpse(Vector3 at, Action before, Action got)
+ {
+ BasePlayer npc = Spawn(at, "t_roam");
+ yield return new WaitForSeconds(1f);
+ if (npc == null) { got(null, 0); yield break; }
+ before?.Invoke(npc);
+ BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 20)), 80);
+ _lastCorpse = null;
+ Vector3 where = npc.transform.position;
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
+ double ms = sw.Elapsed.TotalMilliseconds;
+ yield return new WaitForSeconds(1f);
+ NPCPlayerCorpse corpse = _lastCorpse;
+ if (corpse == null)
+ {
+ var near = new List();
+ Vis.Entities(where, 5f, near);
+ corpse = near.FirstOrDefault();
+ }
+
+ got(corpse, ms);
+ }
+
+ private IEnumerator CorpseSpike(Vector3 f)
+ {
+ var applyLoot = typeof(HumanNPC).GetMethod("ApplyLoot", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ var createCorpse = typeof(NPCPlayer).GetMethod("CreateCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ var copyField = typeof(NPCPlayer).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
+ ?? typeof(HumanNPC).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ var copyProp = copyField == null ? typeof(NPCPlayer).GetProperty("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null;
+ Note($"s7.corpse ApplyLoot: {(applyLoot == null ? "none" : (applyLoot.IsVirtual ? "virtual" : "not virtual") + " " + (applyLoot.IsFamily ? "protected" : applyLoot.IsPublic ? "public" : "private"))}; " +
+ $"CreateCorpse virtual: {createCorpse?.IsVirtual}; CopyInventoryToCorpse: {(copyField != null ? "field" : copyProp != null ? "property, settable " + (copyProp.GetSetMethod(true) != null) + ", virtual get " + copyProp.GetGetMethod(true)?.IsVirtual : "none")}");
+ Check("s7.corpse.applyLootOverridable", applyLoot != null && applyLoot.IsVirtual, "a subclass can fill its own corpse");
+
+ // 1. As today: Rust's scientist loot.
+ NPCPlayerCorpse c1 = null;
+ double ms1 = 0;
+ _corpsePopulated = 0;
+ IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-40, 0, 0)), null, (c, ms) => { c1 = c; ms1 = ms; });
+ while (k.MoveNext()) yield return k.Current;
+ Check("s7.corpse.hook", _corpsePopulated > 0, $"OnCorpsePopulate raised {_corpsePopulated} time(s) for our NPC");
+ Check("s7.corpse.default", c1 != null, $"Rust's scientist loot: {CorpseContents(c1)}; removal in {(c1 != null ? c1.GetRemovalTime() : -1):0} s; death took {ms1:0.0} ms");
+
+ // 2. The kit: Rust copies the inventory when CopyInventoryToCorpse is true.
+ NPCPlayerCorpse c2 = null;
+ k = KillForCorpse(OnMesh(f + new Vector3(-30, 0, 0)), npc =>
+ {
+ if (copyField != null) copyField.SetValue(npc, true);
+ else if (copyProp != null && copyProp.GetSetMethod(true) != null) copyProp.SetValue(npc, true, null);
+ }, (c, ms) => c2 = c);
+ while (k.MoveNext()) yield return k.Current;
+ bool kit = c2 != null && c2.containers != null && c2.containers.Any(x => x.itemList.Any(i => i.info.shortname == "pistol.revolver"));
+ Check("s7.corpse.kit", kit, $"with CopyInventoryToCorpse on: {CorpseContents(c2)}");
+
+ // 3. Nothing, then a table added: emptied in the hook's place, then filled.
+ NPCPlayerCorpse c3 = null;
+ k = KillForCorpse(OnMesh(f + new Vector3(-20, 0, 0)), null, (c, ms) => c3 = c);
+ while (k.MoveNext()) yield return k.Current;
+ if (c3 != null)
+ {
+ foreach (ItemContainer cont in c3.containers) cont.Clear();
+ ItemManager.DoRemoves();
+ Item scrap = ItemManager.CreateByName("scrap", 150);
+ bool moved = scrap != null && scrap.MoveToContainer(c3.containers[0]);
+ Check("s7.corpse.emptyThenFill", moved && c3.containers[0].itemList.Count == 1, $"emptied, then the table's scrap: {CorpseContents(c3)}");
+ }
+ else Check("s7.corpse.emptyThenFill", false, "no corpse");
+ }
+
+ private static readonly string[][] Crates =
+ {
+ new[] { "wooden box", "assets/bundled/prefabs/radtown/crate_normal_2.prefab" },
+ new[] { "military crate", "assets/bundled/prefabs/radtown/crate_normal.prefab" },
+ new[] { "elite crate", "assets/bundled/prefabs/radtown/crate_elite.prefab" },
+ new[] { "locked crate", "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab" },
+ };
+
+ private IEnumerator CratesSpike(Vector3 f)
+ {
+ int i = 0;
+ foreach (string[] c in Crates)
+ {
+ Vector3 at = OnMesh(f + new Vector3(-40 + i * 6, 0, 10));
+ i++;
+ BaseEntity made = GameManager.server.CreateEntity(c[1], at);
+ LootContainer box = made as LootContainer;
+ if (box == null)
+ {
+ Check("s7.crate." + c[0].Replace(" ", ""), false, made == null ? "no entity from " + c[1] : made.GetType().Name + " is not a LootContainer");
+ if (made != null) made.Kill();
+ continue;
+ }
+
+ // Before it spawns: no loot of Rust's, never refilled.
+ box.initialLootSpawn = false;
+ box.minSecondsBetweenRefresh = -1f;
+ box.maxSecondsBetweenRefresh = -1f;
+ box.enableSaving = false;
+ box.Spawn();
+ _built.Add(box);
+ yield return new WaitForSeconds(0.5f);
+ int before = box.inventory?.itemList.Count ?? -1;
+ Item scrap = ItemManager.CreateByName("scrap", 200);
+ bool moved = scrap != null && scrap.MoveToContainer(box.inventory);
+ yield return new WaitForSeconds(1f);
+ Check("s7.crate." + c[0].Replace(" ", ""), before == 0 && moved && box.inventory.itemList.Count == 1,
+ $"{box.GetType().Name} '{box.ShortPrefabName}': {before} item(s) of Rust's at spawn, then {Contents(box.inventory)}; capacity {box.inventory?.capacity}, refreshes {box.shouldRefreshContents}, destroyOnEmpty {box.destroyOnEmpty}");
+ }
+ }
+
+ private static readonly FieldInfo HackSecondsField = typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+
+ private IEnumerator LockedSpike(Vector3 f)
+ {
+ Note($"s7.locked server-wide: requiredHackSeconds {HackableLockedCrate.requiredHackSeconds}, decaySeconds {HackableLockedCrate.decaySeconds}; hackSeconds field {(HackSecondsField != null ? "found" : "MISSING")}");
+ foreach (float want in new[] { 10f, 20f })
+ {
+ Vector3 at = OnMesh(f + new Vector3(20 + want, 0, 10));
+ var crate = GameManager.server.CreateEntity(Crates[3][1], at) as HackableLockedCrate;
+ if (crate == null || HackSecondsField == null) { Check("s7.locked." + want, false, "no crate or no field"); yield break; }
+ crate.initialLootSpawn = false;
+ crate.enableSaving = false;
+ crate.Spawn();
+ _built.Add(crate);
+ // A per-crate timer: start its count where `want` seconds remain of the server-wide total.
+ HackSecondsField.SetValue(crate, HackableLockedCrate.requiredHackSeconds - want);
+ crate.StartHacking();
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (sw.Elapsed.TotalSeconds < want + 15 && !crate.IsFullyHacked())
+ yield return new WaitForSeconds(0.5f);
+ Check("s7.locked." + want, crate.IsFullyHacked() && Mathf.Abs((float)sw.Elapsed.TotalSeconds - want) < 3f,
+ $"asked {want} s: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, lootable {crate.IsFullyHacked()}");
+ }
+
+ // Longer than Rust's own: the count starts below zero. Read where it stands after 3 s.
+ var slow = GameManager.server.CreateEntity(Crates[3][1], OnMesh(f + new Vector3(60, 0, 10))) as HackableLockedCrate;
+ if (slow != null)
+ {
+ slow.initialLootSpawn = false;
+ slow.enableSaving = false;
+ slow.Spawn();
+ _built.Add(slow);
+ HackSecondsField.SetValue(slow, HackableLockedCrate.requiredHackSeconds - 1800f);
+ slow.StartHacking();
+ yield return new WaitForSeconds(3f);
+ float now = (float)HackSecondsField.GetValue(slow);
+ Check("s7.locked.longer", !slow.IsFullyHacked() && now < 0f, $"asked 1800 s: count at {now:0} after 3 s (counts up to {HackableLockedCrate.requiredHackSeconds}); the client is sent this count, so its timer shows from a negative start");
+ }
+ }
+
+ private IEnumerator RemovalSpike(Vector3 f)
+ {
+ NPCPlayerCorpse c = null;
+ IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-10, 0, 0)), null, (x, ms) => c = x);
+ while (k.MoveNext()) yield return k.Current;
+ if (c == null) { Check("s7.removal.timed", false, "no corpse"); yield break; }
+ float rust = c.GetRemovalTime();
+ c.ResetRemovalTime(5f);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (sw.Elapsed.TotalSeconds < 15 && c != null && !c.IsDestroyed)
+ yield return new WaitForSeconds(0.5f);
+ Check("s7.removal.timed", c == null || c.IsDestroyed, $"Rust's own time {rust:0} s; set to 5 s: gone after {sw.Elapsed.TotalSeconds:0.0} s");
+
+ NPCPlayerCorpse c2 = null;
+ k = KillForCorpse(OnMesh(f + new Vector3(0, 0, 0)), null, (x, ms) => c2 = x);
+ while (k.MoveNext()) yield return k.Current;
+ if (c2 != null) c2.Kill();
+ yield return null;
+ Check("s7.removal.none", c2 == null || c2.IsDestroyed, "a corpse killed at once is gone");
+ }
+
// ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ----
private const string SwapKit = "rnt6smg";