From f9ebc5f25baf05b52bdfaf24532844a54d722d31 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 22:35:42 -0500 Subject: [PATCH] feat: API 6, loot tables, crates and the corpse's start and time (stage 7) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 10 +- plugin/RunicNPC.cs | 316 ++++++++++++++++++++++++++++++++++++++++- scripts/checkPlugin.js | 4 + tools/RunicNpcTest.cs | 221 +++++++++++++++++++++++++++- 4 files changed, 543 insertions(+), 8 deletions(-) diff --git a/plugin.toml b/plugin.toml index 40bda9e..4fe8cc0 100644 --- a/plugin.toml +++ b/plugin.toml @@ -28,10 +28,12 @@ # 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. -# Current: 5 — 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, documented in docs/runicnpc/API.md. -api = 5 +# 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 ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 12a3701..f01c51e 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -59,7 +59,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 5; + private const int ApiVersion = 6; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -78,7 +78,8 @@ namespace Oxide.Plugins "CanEntityTakeDamage", "OnPluginLoaded", "OnPlayerInput", - "OnPlayerDisconnected" + "OnPlayerDisconnected", + "OnCorpsePopulate" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -242,6 +243,11 @@ namespace Oxide.Plugins /// What pressing E on it does (D278); a passive profile's only, and required there. [JsonProperty("use")] public UseBlock Use; + // ---- stage 7 (D290–D301) ---- + + /// Its loot (§7): null keeps Rust's scientist loot and nothing more, as before stage 7. + [JsonProperty("loot")] public LootBlock Loot; + [JsonIgnore] public bool IsSentry => Role == "sentry"; [JsonIgnore] public bool IsGuard => Role == "guard"; @@ -304,6 +310,56 @@ namespace Oxide.Plugins [JsonProperty("count")] public int Count = 1; } + /// + /// §7 (D290–D301): what the corpse starts with, a table rolled on top of it (rows that always roll and a + /// pool that picks at most N), an optional crate the table goes into instead, and the corpse's time. + /// + public sealed class LootBlock + { + /// D290: rust (Rust's scientist loot, the default), kit (what it carried), or none (D300: truly empty). + [JsonProperty("start")] public string Start = "rust"; + + /// D291: each row rolls on its own, with its chance. + [JsonProperty("always")] public List Always = new List(); + + /// D291: at most pick rows, drawn by weight; nothing is the weight of drawing none. + [JsonProperty("pool")] public LootPool Pool = new LootPool(); + + /// D292: null (the table goes in the corpse), wooden, military, elite or locked. + [JsonProperty("crate", NullValueHandling = NullValueHandling.Include)] public string Crate; + + /// D297: a locked crate's hack time, in seconds; Rust's own is 900. + [JsonProperty("hackSeconds")] public float HackSeconds = 900f; + + /// D294: null keeps Rust's time (600 s), 0 removes the corpse at once, otherwise seconds. + [JsonProperty("corpseSeconds", NullValueHandling = NullValueHandling.Include)] public float? CorpseSeconds; + + /// D296: off for an event's NPCs when its step says so; the start and the corpse's time still apply. + [JsonProperty("dropTable")] public bool DropTable = true; + } + + public sealed class LootRow + { + [JsonProperty("item")] public string Item; + [JsonProperty("min")] public int Min = 1; + [JsonProperty("max")] public int Max = 1; + + /// For an always-row: its chance, above 0 and at most 1. + [JsonProperty("chance")] public float Chance = 1f; + + /// For a pool row: its weight, above 0. + [JsonProperty("weight")] public float Weight = 1f; + + [JsonProperty("skin")] public ulong Skin; + } + + public sealed class LootPool + { + [JsonProperty("pick")] public int Pick; + [JsonProperty("nothing")] public float Nothing; + [JsonProperty("rows")] public List Rows = new List(); + } + /// D278: a chat line picked at random from lines, or a window with a title, text and Close. public sealed class UseBlock { @@ -569,6 +625,72 @@ namespace Oxide.Plugins if (boss != null) return boss; } + if (p.Loot != null) + { + string loot = ValidateLoot(p.Loot); + if (loot != null) return loot; + } + + return null; + } + + private const int MaxLootRows = 30; + + /// The crates a table may go into (D292), by name: Rust's prefab and its slots (stage 7 spike). + private static readonly Dictionary> LootCrates = new Dictionary> + { + ["wooden"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal_2.prefab", 6), + ["military"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal.prefab", 6), + ["elite"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_elite.prefab", 12), + ["locked"] = new KeyValuePair("assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", 24) + }; + + /// An NPC corpse's main container (its inventory's main container, which Rust gives the corpse). + private const int CorpseSlots = 24; + + private static string ValidateLootRow(LootRow r, string at, bool pool) + { + if (r == null) return $"{at}: it is empty"; + if (string.IsNullOrEmpty(r.Item) || ItemManager.FindItemDefinition(r.Item) == null) return $"{at}.item: '{r.Item}' is not an item this Rust has"; + if (r.Min < 1 || r.Max < r.Min) return $"{at}: min at least 1, and max at least min"; + if (!pool && !(r.Chance > 0f && r.Chance <= 1f)) return $"{at}.chance: above 0 and at most 1"; + if (pool && !(r.Weight > 0f)) return $"{at}.weight: above 0"; + return null; + } + + private static string ValidateLoot(LootBlock l) + { + if (l.Start != "rust" && l.Start != "kit" && l.Start != "none") return $"loot.start: '{l.Start}' is not rust, kit or none"; + if (l.Always == null) return "loot.always: a list, even an empty one"; + if (l.Pool == null || l.Pool.Rows == null) return "loot.pool: an object with rows, even an empty list"; + if (l.Always.Count > MaxLootRows || l.Pool.Rows.Count > MaxLootRows) return $"loot: at most {MaxLootRows} rows in always and in the pool"; + for (int i = 0; i < l.Always.Count; i++) + { + string e = ValidateLootRow(l.Always[i], $"loot.always[{i}]", false); + if (e != null) return e; + } + + for (int i = 0; i < l.Pool.Rows.Count; i++) + { + string e = ValidateLootRow(l.Pool.Rows[i], $"loot.pool.rows[{i}]", true); + if (e != null) return e; + } + + if (l.Pool.Pick < 0 || l.Pool.Pick > l.Pool.Rows.Count) return "loot.pool.pick: 0 to the number of pool rows"; + if (l.Pool.Nothing < 0f) return "loot.pool.nothing: 0 or more"; + + int slots = CorpseSlots; + if (l.Crate != null) + { + KeyValuePair crate; + if (!LootCrates.TryGetValue(l.Crate, out crate)) return $"loot.crate: '{l.Crate}' is not wooden, military, elite or locked"; + slots = crate.Value; + } + + if (l.Always.Count + l.Pool.Pick > slots) + return $"loot: {l.Always.Count} always-rows and {l.Pool.Pick} picked need {l.Always.Count + l.Pool.Pick} slots, and {(l.Crate != null ? "the " + l.Crate + " crate" : "the corpse")} has {slots}"; + if (!(l.HackSeconds >= 1f && l.HackSeconds <= 86400f)) return "loot.hackSeconds: 1 to 86400"; + if (l.CorpseSeconds.HasValue && !(l.CorpseSeconds.Value >= 0f)) return "loot.corpseSeconds: null (Rust's own time), 0 (none) or more"; return null; } @@ -1272,8 +1394,35 @@ namespace Oxide.Plugins Credit(_pendingAttacker, _healthBefore); Died = true; _self?.HandleDied(this, info); + + // D290: a kit start. Rust empties the inventory before it fills the corpse, so what it carried is + // taken out now and handed to the corpse in OnCorpsePopulate; anything not handed over is removed. + if (Profile?.Loot != null && Profile.Loot.Start == "kit") + { + KitCarried = new List(); + foreach (ItemContainer from in new[] { inventory.containerBelt, inventory.containerMain }) + foreach (Item item in from.itemList.ToList()) + { + item.RemoveFromContainer(); + KitCarried.Add(item); + } + List taken = KitCarried; + _self?.NextTick(() => + { + if (KitCarried != taken) + return; + foreach (Item item in taken) + if (item.parent == null) + item.Remove(); + KitCarried = null; + }); + } + base.OnDied(info); } + + /// D290: a kit start's items, between its death and its corpse being filled. + public List KitCarried; } /// @@ -4127,6 +4276,169 @@ namespace Oxide.Plugins #endregion + #region Loot (stage 7: D290–D301) + + private static readonly FieldInfo HackSecondsField = + typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + /// What the last corpses and crates got, for rnpc.status and the harness. + public static long LootRolls, LootCrateDrops, LootItems; + + /// + /// One of ours' corpse is about to be filled. Rust has just emptied its containers (all but the clothing) + /// and will next call its own ApplyLoot (Rust's scientist loot) unless this hook answers with the + /// corpse (stage 7 build). A subclass cannot do it itself: ApplyLoot is protected on Oxide and + /// public on Carbon, so no one override compiles on both (stage 7 spike). Here the corpse's start is made + /// (D290, D300), the table rolled into it or into a crate (D291, D292), and the corpse's time set (D294). + /// It answers with the corpse only for one of ours whose start is kit or none, to skip + /// Rust's loot; null for everything else. + /// + private object OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse) + { + MarkHook("OnCorpsePopulate"); + RunicNpcPlayer ours = npc as RunicNpcPlayer; + LootBlock l = ours?.Profile?.Loot; + if (l == null || corpse == null || corpse.containers == null || corpse.containers.Length == 0) + return null; + + // D290, D300: the start. + if (l.Start == "none" || l.Start == "kit") + { + // Container 1 is the clothing it wore: a kit start keeps it, "none" empties it too (D300). + for (int i = 0; i < corpse.containers.Length; i++) + if (l.Start == "none" || i != 1) + corpse.containers[i].Clear(); + ItemManager.DoRemoves(); + if (l.Start == "kit") + { + // What it carried, taken out of its inventory as it died (OnDied): by the time Rust fills the + // corpse it has emptied the NPC's inventory (stage 7 build). + if (ours.KitCarried != null) + { + foreach (Item item in ours.KitCarried) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + ours.KitCarried = null; + } + } + } + + // D291, D292, D296: the table. + if (l.DropTable) + { + List rolled = RollLoot(l); + LootRolls++; + if (rolled.Count > 0) + { + if (l.Crate != null) + DropCrate(l, corpse.transform.position, rolled); + else + foreach (Item item in rolled) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + } + } + + // D294: the corpse's time. Zero removes it once Rust has finished making it. + if (l.CorpseSeconds.HasValue) + { + if (l.CorpseSeconds.Value <= 0f) + NextTick(() => + { + if (corpse != null && !corpse.IsDestroyed) + corpse.Kill(); + }); + else + corpse.ResetRemovalTime(l.CorpseSeconds.Value); + } + + // A kit or empty start skips Rust's own loot; a "rust" start lets Rust add it after the table. + return l.Start == "kit" || l.Start == "none" ? corpse : null; + } + + /// + /// D291: every always-row rolls its own chance; the pool draws up to pick times by weight without + /// putting a row back, and a draw of "nothing" uses up a draw. So the pool never pays out more than + /// pick rows, whatever the luck. + /// + private static List RollLoot(LootBlock l) + { + var items = new List(); + foreach (LootRow r in l.Always) + if (UnityEngine.Random.value <= r.Chance) + AddRolled(items, r); + + var left = new List(l.Pool.Rows); + for (int draw = 0; draw < l.Pool.Pick && left.Count > 0; draw++) + { + float total = l.Pool.Nothing + left.Sum(r => r.Weight); + float x = UnityEngine.Random.value * total; + if (x < l.Pool.Nothing) + continue; + x -= l.Pool.Nothing; + for (int i = 0; i < left.Count; i++) + { + if (x < left[i].Weight || i == left.Count - 1) + { + AddRolled(items, left[i]); + left.RemoveAt(i); + break; + } + + x -= left[i].Weight; + } + } + + LootItems += items.Count; + return items; + } + + private static void AddRolled(List items, LootRow r) + { + Item item = ItemManager.CreateByName(r.Item, UnityEngine.Random.Range(r.Min, r.Max + 1), r.Skin); + if (item != null) + items.Add(item); + } + + /// + /// D292, D297, D301: one of Rust's crates beside the corpse, with Rust's own loot and refresh switched off + /// before it spawns (stage 7 spike), holding the table. It stays until emptied, as Rust's crates do; a + /// locked crate keeps Rust's own decay. Its hack time is its starting count, which Rust never resets. + /// + private void DropCrate(LootBlock l, Vector3 at, List items) + { + KeyValuePair kind = LootCrates[l.Crate]; + Vector3 spot = at + new Vector3(1.2f, 0f, 0f); + spot.y = TerrainMeta.HeightMap.GetHeight(spot); + RaycastHit hit; + if (Physics.Raycast(spot + Vector3.up * 3f, Vector3.down, out hit, 6f, LayerMask.GetMask("Terrain", "World", "Construction"), QueryTriggerInteraction.Ignore)) + spot = hit.point; + LootContainer crate = GameManager.server.CreateEntity(kind.Key, spot, Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f)) as LootContainer; + if (crate == null) + { + PrintWarning($"The {l.Crate} crate could not be made; its loot is dropped where the NPC died."); + foreach (Item item in items) + item.Drop(at + Vector3.up, Vector3.zero); + return; + } + + crate.initialLootSpawn = false; + crate.minSecondsBetweenRefresh = -1f; + crate.maxSecondsBetweenRefresh = -1f; + crate.enableSaving = false; + crate.Spawn(); + foreach (Item item in items) + if (!item.MoveToContainer(crate.inventory)) + item.Drop(spot + Vector3.up, Vector3.zero); + + HackableLockedCrate locked = crate as HackableLockedCrate; + if (locked != null && HackSecondsField != null) + HackSecondsField.SetValue(locked, HackableLockedCrate.requiredHackSeconds - l.HackSeconds); + LootCrateDrops++; + } + + #endregion + #region Lifecycle and hooks private void Init() diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 9678467..5b6135e 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -79,6 +79,10 @@ const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), { // 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). */ diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index d1ce723..9abaec9 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -294,6 +294,7 @@ namespace Oxide.Plugins all["t_each"] = Prof(name: "TEach"); Stage5Profiles(all); Stage6Profiles(all); + Stage7Profiles(all); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; } @@ -559,7 +560,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "s7", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -573,6 +574,7 @@ namespace Oxide.Plugins : g == "place" ? Place(field) : g == "s5" ? Stage5() : g == "s6" ? Stage6() + : g == "s7" ? Stage7() : null; if (inner == null) { @@ -594,7 +596,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 6, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -2246,6 +2248,221 @@ 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: loot (D290–D301) ---- + + private static JObject LootTable(string start, string crate = null, float? corpseSeconds = null, float hack = 900f) => + JObject.FromObject(new + { + start, + always = new object[] + { + new { item = "sewingkit", min = 10, max = 20, chance = 1f }, + new { item = "tarp", min = 1, max = 1, chance = 0.5f } + }, + pool = new + { + pick = 1, + nothing = 50f, + rows = new object[] + { + new { item = "propanetank", min = 5, max = 5, weight = 25f }, + new { item = "semibody", min = 1, max = 2, weight = 25f } + } + }, + crate, + hackSeconds = hack, + corpseSeconds + }); + + private static void Stage7Profiles(JObject all) + { + JObject P(string name, JObject loot) + { + JObject p = Prof(name: name); + p["loot"] = loot; + return p; + } + + all["t_loot"] = P("TLoot", LootTable("rust")); + all["t_loot100"] = P("TLoot100", LootTable("none")); + all["t_lootkit"] = P("TLootKit", JObject.FromObject(new { start = "kit" })); + all["t_lootnone"] = P("TLootNone", JObject.FromObject(new { start = "none", always = new object[] { new { item = "scrap", min = 7, max = 7, chance = 1f } } })); + all["t_lootcrate"] = P("TLootCrate", LootTable("rust", "military")); + all["t_lootlocked"] = P("TLootLocked", LootTable("rust", "locked", null, 10f)); + all["t_lootgone"] = P("TLootGone", LootTable("rust", null, 0f)); + all["t_lootfive"] = P("TLootFive", LootTable("rust", null, 5f)); + } + + private static int CountIn(LootableCorpse c, string item) => + c == null || c.containers == null ? 0 : c.containers.Sum(x => x.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount)); + + private static int CountIn(ItemContainer c, string item) => c == null ? 0 : c.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount); + + /// Kills n NPCs of a profile in a row, then finds each one's corpse where it died. + private IEnumerator KillMany(Vector3 at, string profile, int n, JObject overrides, List corpses, List spots) + { + BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 25)), 81); + var npcs = new List(); + for (int i = 0; i < n; i++) + { + BasePlayer npc = null; + Vector3 spot = at + new Vector3((i % 10) * 4f, 0, (i / 10) * 4f); + for (int tryAt = 0; tryAt < 6 && npc == null; tryAt++) + npc = Spawn(OnMesh(spot + new Vector3(tryAt * 1.5f, 0, -tryAt * 1.5f)), profile, overrides); + if (npc != null) npcs.Add(npc); + if (i % 5 == 4) yield return null; + } + + yield return new WaitForSeconds(1f); + foreach (BasePlayer npc in npcs) + { + if (Gone(npc)) continue; + spots.Add(npc.transform.position); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + } + + yield return new WaitForSeconds(1f); + var taken = new HashSet(); + foreach (Vector3 s in spots) + { + var near = new List(); + Vis.Entities(s, 2f, near); + NPCPlayerCorpse c = near.Where(x => !taken.Contains(x)).OrderBy(x => (x.transform.position - s).sqrMagnitude).FirstOrDefault(); + if (c != null) taken.Add(c); + corpses.Add(c); + } + } + + private IEnumerator Stage7() + { + Vector3 f; + if (!FindField(300f, 2028, out f)) + { + Check("s7.setup.field", false, "no open field on the navmesh"); + yield break; + } + + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- refusals, through a spawn's overrides ---- + Func refused = json => Spawn(f, "t_loot", JObject.Parse(json)) == null; + Check("s7.refuse.start", refused("{\"loot\":{\"start\":\"some\"}}"), "start rust, kit or none"); + Check("s7.refuse.item", refused("{\"loot\":{\"always\":[{\"item\":\"nope.item\",\"chance\":1}]}}"), "an item this Rust has"); + Check("s7.refuse.chance", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"chance\":0}]}}"), "chance above 0"); + Check("s7.refuse.minMax", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"min\":5,\"max\":2}]}}"), "max at least min"); + Check("s7.refuse.pick", refused("{\"loot\":{\"pool\":{\"pick\":3}}}"), "pick no more than the pool's rows"); + Check("s7.refuse.crate", refused("{\"loot\":{\"crate\":\"barrel\"}}"), "wooden, military, elite or locked"); + Check("s7.refuse.slots", refused("{\"loot\":{\"crate\":\"wooden\",\"always\":[{\"item\":\"scrap\"},{\"item\":\"rope\"},{\"item\":\"gears\"},{\"item\":\"cloth\"},{\"item\":\"wood\"},{\"item\":\"stones\"}],\"pool\":{\"pick\":1}}}"), "6 always + 1 picked > a wooden box's 6 slots"); + Check("s7.refuse.hack", refused("{\"loot\":{\"hackSeconds\":0}}"), "hackSeconds 1 to 86400"); + Api("RunicNpc_DespawnOwner", Owner); + + // ---- 100 kills against the table (D291) ---- + var corpses = new List(); + var spots = new List(); + for (int batch = 0; batch < 5; batch++) + { + IEnumerator k = KillMany(OnMesh(f + new Vector3(-60, 0, -40 + batch * 10)), "t_loot100", 20, null, corpses, new List()); + while (k.MoveNext()) yield return k.Current; + } + + var found = corpses.Where(c => c != null).ToList(); + int scrap = found.Count(c => CountIn(c, "sewingkit") >= 10 && CountIn(c, "sewingkit") <= 20); + int rope = found.Count(c => CountIn(c, "tarp") > 0); + int metal = found.Count(c => CountIn(c, "propanetank") == 5); + int gears = found.Count(c => CountIn(c, "semibody") > 0); + int both = found.Count(c => CountIn(c, "propanetank") > 0 && CountIn(c, "semibody") > 0); + int rustLoot = found.Count(c => c.containers[0].itemList.Any(i => i.info.shortname != "sewingkit" && i.info.shortname != "tarp" && i.info.shortname != "propanetank" && i.info.shortname != "semibody")); + Check("s7.table.corpses", corpses.Count >= 60 && found.Count >= corpses.Count - 2, $"{corpses.Count} of 100 spawned and killed, {found.Count} corpses found"); + Check("s7.table.always", scrap == found.Count, $"sewing kits 10-20 (chance 1): in {scrap}/{found.Count}"); + Check("s7.table.chance", Math.Abs((double)rope / Math.Max(1, found.Count) - 0.5) <= Math.Sqrt(0.25 / Math.Max(1, found.Count)) * 3.5, $"tarp (chance 0.5): in {rope}/{found.Count}"); + // Each row 25%: within 3.5 standard deviations for this many kills. + double sd = Math.Sqrt(0.25 * 0.75 / Math.Max(1, found.Count)) * 3.5; + Func near25 = x => Math.Abs((double)x / Math.Max(1, found.Count) - 0.25) <= sd; + Check("s7.table.poolWeights", near25(metal) && near25(gears), + $"pool pick 1 (nothing 50, propane 25, semi body 25): propane in {metal}, semi body in {gears}, of {found.Count}"); + Check("s7.table.poolCap", both == 0, $"both pool rows in one corpse: {both} (pick 1 is the cap)"); + Check("s7.table.onlyTable", rustLoot == 0, $"start none: nothing but the table in {found.Count - rustLoot}/{found.Count}"); + Api("RunicNpc_DespawnOwner", Owner); + foreach (NPCPlayerCorpse c in found) if (c != null && !c.IsDestroyed) c.Kill(); + + // ---- the starts (D290, D300) ---- + var one = new List(); + var at = new List(); + IEnumerator k2 = KillMany(OnMesh(f + new Vector3(20, 0, -40)), "t_lootkit", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse kit = one.FirstOrDefault(); + var dropped = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, dropped); + Note("s7.start.kit dropped near it: " + string.Join(", ", dropped.Select(d => d.item != null ? d.item.info.shortname + "×" + d.item.amount : "?").ToArray())); + Check("s7.start.kit", kit != null && CountIn(kit, "pistol.revolver") == 1 && CountIn(kit, "ammo.pistol") > 0 && kit.containers.Length > 1 && kit.containers[1].itemList.Count > 0, + $"kit start: {CorpseContents(kit)}"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(30, 0, -40)), "t_lootnone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse none = one.FirstOrDefault(); + Check("s7.start.none", none != null && none.containers.Sum(x => x.itemList.Count) == 1 && CountIn(none, "scrap") == 7, + $"nothing, then the table's 7 scrap, clothes off too (D300): {CorpseContents(none)}"); + + // ---- crates (D292, D297, D301) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(40, 0, -40)), "t_lootcrate", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + var crates = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + LootContainer mil = crates.FirstOrDefault(x => x.ShortPrefabName == "crate_normal"); + Check("s7.crate.military", mil != null && CountIn(mil.inventory, "sewingkit") >= 10 && CountIn(one.FirstOrDefault(), "sewingkit") == 0, + mil != null ? $"a military crate beside it: {Contents(mil.inventory)}; corpse sewing kits {CountIn(one.FirstOrDefault(), "sewingkit")}" : "no crate"); + if (mil != null) _built.Add(mil); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(50, 0, -40)), "t_lootlocked", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + crates.Clear(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + HackableLockedCrate locked = crates.OfType().FirstOrDefault(); + if (locked != null) + { + _built.Add(locked); + Check("s7.crate.lockedNotStarted", !locked.IsBeingHacked() && !locked.IsFullyHacked(), "it waits for a player to start the hack"); + locked.StartHacking(); // as RPC_Hack does for a player + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < 25 && !locked.IsFullyHacked()) + yield return new WaitForSeconds(0.5f); + Check("s7.crate.lockedTimer", locked.IsFullyHacked() && sw.Elapsed.TotalSeconds < 15, $"hackSeconds 10: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, holding {Contents(locked.inventory)}"); + } + else Check("s7.crate.lockedNotStarted", false, "no locked crate"); + + // ---- the corpse's time (D294) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(60, 0, -40)), "t_lootgone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + Check("s7.corpse.none", one.FirstOrDefault() == null, "corpseSeconds 0: no corpse a second later"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(35, 0, -30)), "t_lootfive", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse five = one.FirstOrDefault(); + bool fiveFound = !ReferenceEquals(five, null) && !five.IsDestroyed; + if (five == null) + { + var near10 = new List(); + if (at.Count > 0) Vis.Entities(at[0], 10f, near10); + Note($"s7.corpse.seconds: spots {at.Count}, corpses within 10 m: " + string.Join(", ", near10.Select(x => x.ShortPrefabName + " " + Vector3.Distance(x.transform.position, at[0]).ToString("0.0") + " m").ToArray())); + } + yield return new WaitForSeconds(5f); + // A destroyed Unity object compares equal to null, so whether one was found is kept apart. + Check("s7.corpse.seconds", fiveFound && (ReferenceEquals(five, null) || five.IsDestroyed), fiveFound ? $"corpseSeconds 5: found a second after death, gone 6 s after" : "no corpse at all"); + + // ---- an event's switch (D296) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(80, 0, -40)), "t_loot", 1, JObject.Parse("{\"loot\":{\"dropTable\":false}}"), one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse off = one.FirstOrDefault(); + Check("s7.dropTable.off", off != null && CountIn(off, "sewingkit") == 0, $"dropTable false: no table: {CorpseContents(off)}"); + Check("s7.start.rust", off != null && off.containers[0].itemList.Count > 0 && off.containers[1].itemList.Count > 0, $"start rust: Rust's scientist loot and the clothes it wore: {CorpseContents(off)}"); + } + // ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ---- private int _corpsePopulated; -- 2.49.1