feat: API 6, loot tables, crates and the corpse's start and time (stage 7)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -294,6 +294,7 @@ namespace Oxide.Plugins
|
||||
all["t_each"] = Prof(name: "TEach");
|
||||
Stage5Profiles(all);
|
||||
Stage6Profiles(all);
|
||||
Stage7Profiles(all);
|
||||
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "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<int>("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 6, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
JObject profiles = Api<JObject>("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);
|
||||
|
||||
/// <summary>Kills n NPCs of a profile in a row, then finds each one's corpse where it died.</summary>
|
||||
private IEnumerator KillMany(Vector3 at, string profile, int n, JObject overrides, List<NPCPlayerCorpse> corpses, List<Vector3> spots)
|
||||
{
|
||||
BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 25)), 81);
|
||||
var npcs = new List<BasePlayer>();
|
||||
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<NPCPlayerCorpse>();
|
||||
foreach (Vector3 s in spots)
|
||||
{
|
||||
var near = new List<NPCPlayerCorpse>();
|
||||
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<string, bool> 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<int>("RunicNpc_DespawnOwner", Owner);
|
||||
|
||||
// ---- 100 kills against the table (D291) ----
|
||||
var corpses = new List<NPCPlayerCorpse>();
|
||||
var spots = new List<Vector3>();
|
||||
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<Vector3>());
|
||||
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<int, bool> 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<int>("RunicNpc_DespawnOwner", Owner);
|
||||
foreach (NPCPlayerCorpse c in found) if (c != null && !c.IsDestroyed) c.Kill();
|
||||
|
||||
// ---- the starts (D290, D300) ----
|
||||
var one = new List<NPCPlayerCorpse>();
|
||||
var at = new List<Vector3>();
|
||||
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<DroppedItem>();
|
||||
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<LootContainer>();
|
||||
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<HackableLockedCrate>().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<BaseCorpse>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user