test(harness): stage 7 spike, rnt.spike7 (D299) #11

Merged
whitlocktech merged 1 commits from spike/stage-7 into edge 2026-10-06 07:16:08 +00:00

View File

@@ -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;
/// <summary>Counts the framework hook for an NPC corpse being filled, for our NPC (stage 7 spike).</summary>
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());
/// <summary><c>rnt.spike7 [part,...]</c>: <c>corpse</c>, <c>crates</c>, <c>locked</c>, <c>removal</c>, or <c>all</c>.</summary>
[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<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld();
yield return new WaitForSeconds(1f);
}
Note($"done spike7 {part}: {_pass} pass, {_fail} fail");
_running = null;
}
/// <summary>Kills one t_roam as a stand-in and returns its corpse (found near where it died).</summary>
private IEnumerator KillForCorpse(Vector3 at, Action<BasePlayer> before, Action<NPCPlayerCorpse, double> 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<NPCPlayerCorpse>();
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";