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:
@@ -59,7 +59,7 @@ namespace Oxide.Plugins
|
||||
/// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private const int ApiVersion = 5;
|
||||
private const int ApiVersion = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Every hook this plugin implements. <c>rnpc.status</c> reports which have fired, because
|
||||
@@ -78,7 +78,8 @@ namespace Oxide.Plugins
|
||||
"CanEntityTakeDamage",
|
||||
"OnPluginLoaded",
|
||||
"OnPlayerInput",
|
||||
"OnPlayerDisconnected"
|
||||
"OnPlayerDisconnected",
|
||||
"OnCorpsePopulate"
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
|
||||
@@ -242,6 +243,11 @@ namespace Oxide.Plugins
|
||||
/// <summary>What pressing E on it does (D278); a passive profile's only, and required there.</summary>
|
||||
[JsonProperty("use")] public UseBlock Use;
|
||||
|
||||
// ---- stage 7 (D290–D301) ----
|
||||
|
||||
/// <summary>Its loot (§7): null keeps Rust's scientist loot and nothing more, as before stage 7.</summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §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.
|
||||
/// </summary>
|
||||
public sealed class LootBlock
|
||||
{
|
||||
/// <summary>D290: <c>rust</c> (Rust's scientist loot, the default), <c>kit</c> (what it carried), or <c>none</c> (D300: truly empty).</summary>
|
||||
[JsonProperty("start")] public string Start = "rust";
|
||||
|
||||
/// <summary>D291: each row rolls on its own, with its <c>chance</c>.</summary>
|
||||
[JsonProperty("always")] public List<LootRow> Always = new List<LootRow>();
|
||||
|
||||
/// <summary>D291: at most <c>pick</c> rows, drawn by weight; <c>nothing</c> is the weight of drawing none.</summary>
|
||||
[JsonProperty("pool")] public LootPool Pool = new LootPool();
|
||||
|
||||
/// <summary>D292: null (the table goes in the corpse), <c>wooden</c>, <c>military</c>, <c>elite</c> or <c>locked</c>.</summary>
|
||||
[JsonProperty("crate", NullValueHandling = NullValueHandling.Include)] public string Crate;
|
||||
|
||||
/// <summary>D297: a locked crate's hack time, in seconds; Rust's own is 900.</summary>
|
||||
[JsonProperty("hackSeconds")] public float HackSeconds = 900f;
|
||||
|
||||
/// <summary>D294: null keeps Rust's time (600 s), 0 removes the corpse at once, otherwise seconds.</summary>
|
||||
[JsonProperty("corpseSeconds", NullValueHandling = NullValueHandling.Include)] public float? CorpseSeconds;
|
||||
|
||||
/// <summary>D296: off for an event's NPCs when its step says so; the start and the corpse's time still apply.</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>For an always-row: its chance, above 0 and at most 1.</summary>
|
||||
[JsonProperty("chance")] public float Chance = 1f;
|
||||
|
||||
/// <summary>For a pool row: its weight, above 0.</summary>
|
||||
[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<LootRow> Rows = new List<LootRow>();
|
||||
}
|
||||
|
||||
/// <summary>D278: a chat line picked at random from <c>lines</c>, or a window with a title, text and Close.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The crates a table may go into (D292), by name: Rust's prefab and its slots (stage 7 spike).</summary>
|
||||
private static readonly Dictionary<string, KeyValuePair<string, int>> LootCrates = new Dictionary<string, KeyValuePair<string, int>>
|
||||
{
|
||||
["wooden"] = new KeyValuePair<string, int>("assets/bundled/prefabs/radtown/crate_normal_2.prefab", 6),
|
||||
["military"] = new KeyValuePair<string, int>("assets/bundled/prefabs/radtown/crate_normal.prefab", 6),
|
||||
["elite"] = new KeyValuePair<string, int>("assets/bundled/prefabs/radtown/crate_elite.prefab", 12),
|
||||
["locked"] = new KeyValuePair<string, int>("assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", 24)
|
||||
};
|
||||
|
||||
/// <summary>An NPC corpse's main container (its inventory's main container, which Rust gives the corpse).</summary>
|
||||
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<string, int> 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<Item>();
|
||||
foreach (ItemContainer from in new[] { inventory.containerBelt, inventory.containerMain })
|
||||
foreach (Item item in from.itemList.ToList())
|
||||
{
|
||||
item.RemoveFromContainer();
|
||||
KitCarried.Add(item);
|
||||
}
|
||||
List<Item> taken = KitCarried;
|
||||
_self?.NextTick(() =>
|
||||
{
|
||||
if (KitCarried != taken)
|
||||
return;
|
||||
foreach (Item item in taken)
|
||||
if (item.parent == null)
|
||||
item.Remove();
|
||||
KitCarried = null;
|
||||
});
|
||||
}
|
||||
|
||||
base.OnDied(info);
|
||||
}
|
||||
|
||||
/// <summary>D290: a kit start's items, between its death and its corpse being filled.</summary>
|
||||
public List<Item> KitCarried;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
|
||||
/// <summary>What the last corpses and crates got, for <c>rnpc.status</c> and the harness.</summary>
|
||||
public static long LootRolls, LootCrateDrops, LootItems;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ApplyLoot</c> (Rust's scientist loot) unless this hook answers with the
|
||||
/// corpse (stage 7 build). A subclass cannot do it itself: <c>ApplyLoot</c> 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 <c>kit</c> or <c>none</c>, to skip
|
||||
/// Rust's loot; null for everything else.
|
||||
/// </summary>
|
||||
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<Item> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D291: every always-row rolls its own chance; the pool draws up to <c>pick</c> 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
|
||||
/// <c>pick</c> rows, whatever the luck.
|
||||
/// </summary>
|
||||
private static List<Item> RollLoot(LootBlock l)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
foreach (LootRow r in l.Always)
|
||||
if (UnityEngine.Random.value <= r.Chance)
|
||||
AddRolled(items, r);
|
||||
|
||||
var left = new List<LootRow>(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<Item> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void DropCrate(LootBlock l, Vector3 at, List<Item> items)
|
||||
{
|
||||
KeyValuePair<string, int> 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()
|
||||
|
||||
Reference in New Issue
Block a user