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";