Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be2c54260 | |||
| cf7f9cd38d | |||
| 1e927f6a66 | |||
| 3972bccf34 |
@@ -26,9 +26,12 @@
|
||||
# 2 was stage 2's whole API, PLAN.md §4.
|
||||
# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
|
||||
# OnRunicNpcPlacementChanged hook.
|
||||
# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5
|
||||
# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md.
|
||||
api = 4
|
||||
# 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
|
||||
|
||||
# ── Framework floors ─────────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -293,6 +293,7 @@ namespace Oxide.Plugins
|
||||
if (withEach)
|
||||
all["t_each"] = Prof(name: "TEach");
|
||||
Stage5Profiles(all);
|
||||
Stage6Profiles(all);
|
||||
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
|
||||
}
|
||||
|
||||
@@ -392,6 +393,74 @@ namespace Oxide.Plugins
|
||||
arg.ReplyWith("rnt: kit probe started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a
|
||||
/// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time.
|
||||
/// </summary>
|
||||
[ConsoleCommand("rnt.stockprobe")]
|
||||
private void CmdStockProbe(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
Vector3 f;
|
||||
if (!FindField(300f, 2025, out f)) return;
|
||||
StandIn(f + new Vector3(0, 0, 60), 50);
|
||||
ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60)));
|
||||
BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71);
|
||||
BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry");
|
||||
BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null;
|
||||
List<ScientistNPC> world = BaseNetworkable.serverEntities.OfType<ScientistNPC>().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList();
|
||||
Dictionary<ScientistNPC, Vector3> from = world.ToDictionary(s => s, s => s.transform.position);
|
||||
_hitsOnStandIns = 0;
|
||||
int n = 0;
|
||||
timer.Repeat(2f, 10, () =>
|
||||
{
|
||||
n++;
|
||||
string Line(ScientistNPC s, BasePlayer t)
|
||||
{
|
||||
if (Gone(s)) return "gone";
|
||||
var senses = s.Brain?.Senses;
|
||||
return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}";
|
||||
}
|
||||
Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}");
|
||||
if (n == 10)
|
||||
{
|
||||
var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList();
|
||||
Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}");
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
ClearWorld();
|
||||
}
|
||||
});
|
||||
arg.ReplyWith("rnt: stock probe started");
|
||||
}
|
||||
|
||||
/// <summary>A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s.</summary>
|
||||
[ConsoleCommand("rnt.monprobe")]
|
||||
private void CmdMonProbe(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
Vector3 at;
|
||||
string name;
|
||||
if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; }
|
||||
StandIn(at + new Vector3(0, 0, 100), 2);
|
||||
BasePlayer mon = Spawn(at, "t_mon");
|
||||
AIInformationZone zone = AIInformationZone.GetForPoint(at, false);
|
||||
List<ScientistNPC> locals = BaseNetworkable.serverEntities.OfType<ScientistNPC>()
|
||||
.Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList();
|
||||
Dictionary<ScientistNPC, Vector3> from = locals.ToDictionary(s => s, s => s.transform.position);
|
||||
Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero;
|
||||
Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}");
|
||||
int n = 0;
|
||||
timer.Repeat(4f, 10, () =>
|
||||
{
|
||||
n++;
|
||||
ScientistNPC s = mon as ScientistNPC;
|
||||
var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList();
|
||||
Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}");
|
||||
if (n == 10) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
|
||||
});
|
||||
arg.ReplyWith("rnt: monument probe started");
|
||||
}
|
||||
|
||||
/// <summary>Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.</summary>
|
||||
[ConsoleCommand("rnt.list")]
|
||||
private void CmdList(ConsoleSystem.Arg arg)
|
||||
@@ -490,7 +559,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", "place" } : new[] { group };
|
||||
string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "place" } : new[] { group };
|
||||
foreach (string g in groups)
|
||||
{
|
||||
IEnumerator inner = g == "api" ? Api(field)
|
||||
@@ -503,6 +572,7 @@ namespace Oxide.Plugins
|
||||
: g == "floor" ? Floor(field)
|
||||
: g == "place" ? Place(field)
|
||||
: g == "s5" ? Stage5()
|
||||
: g == "s6" ? Stage6()
|
||||
: null;
|
||||
if (inner == null)
|
||||
{
|
||||
@@ -524,7 +594,7 @@ namespace Oxide.Plugins
|
||||
|
||||
private IEnumerator Api(Vector3 field)
|
||||
{
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 5, "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"]);
|
||||
@@ -613,10 +683,16 @@ namespace Oxide.Plugins
|
||||
Vector3 monument;
|
||||
string monumentName;
|
||||
BasePlayer mon = null;
|
||||
List<ScientistNPC> locals = new List<ScientistNPC>();
|
||||
var localsFrom = new Dictionary<ScientistNPC, Vector3>();
|
||||
if (FindMonument(out monument, out monumentName))
|
||||
{
|
||||
StandIn(monument + new Vector3(0, 0, 100), 2);
|
||||
mon = Spawn(monument, "t_mon");
|
||||
// The monument's own scientists: a monument roamer is meant to roam as they do.
|
||||
locals = BaseNetworkable.serverEntities.OfType<ScientistNPC>()
|
||||
.Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList();
|
||||
foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position;
|
||||
}
|
||||
|
||||
Vector3 home = wander.transform.position;
|
||||
@@ -656,7 +732,13 @@ namespace Oxide.Plugins
|
||||
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
|
||||
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
|
||||
if (mon != null)
|
||||
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
|
||||
{
|
||||
// Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no
|
||||
// real player near (stand-ins do not seem to count): then ours standing too is the same behaviour.
|
||||
int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f);
|
||||
Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0),
|
||||
$"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}");
|
||||
}
|
||||
else
|
||||
Check("move.monument.roams", false, "no monument with an AI zone");
|
||||
|
||||
@@ -687,11 +769,20 @@ namespace Oxide.Plugins
|
||||
|
||||
private IEnumerator Sentry(Vector3 field)
|
||||
{
|
||||
BasePlayer sentry = Spawn(field, "t_sentry");
|
||||
// On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's
|
||||
// first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times.
|
||||
Vector3 sentryAt, standInAt;
|
||||
if (!ClearLine(field, 12f, out sentryAt, out standInAt))
|
||||
{
|
||||
sentryAt = field;
|
||||
standInAt = field + new Vector3(12, 0, 0);
|
||||
}
|
||||
|
||||
BasePlayer sentry = Spawn(sentryAt, "t_sentry");
|
||||
yield return new WaitForSeconds(1f);
|
||||
Vector3 spot = sentry.transform.position;
|
||||
_hitsOnStandIns = 0;
|
||||
StandIn(spot + new Vector3(12, 0, 0), 4);
|
||||
StandIn(standInAt, 4);
|
||||
float moved = 0f;
|
||||
for (int t = 0; t < 40; t++)
|
||||
{
|
||||
@@ -1906,6 +1997,255 @@ namespace Oxide.Plugins
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
}
|
||||
|
||||
// ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ----
|
||||
|
||||
private readonly List<string> _bossEvents = new List<string>();
|
||||
private readonly List<string> _used = new List<string>();
|
||||
private readonly Dictionary<ulong, float> _bossContributors = new Dictionary<ulong, float>();
|
||||
|
||||
private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) => _bossEvents.Add("spawned:" + profile);
|
||||
|
||||
private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) => _bossEvents.Add($"phase{phase}@{at}");
|
||||
|
||||
private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
|
||||
{
|
||||
_bossEvents.Add("died:" + profile);
|
||||
_bossContributors.Clear();
|
||||
foreach (KeyValuePair<ulong, float> kv in contributors) _bossContributors[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
private void OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) => _used.Add(profile + "/" + player.displayName);
|
||||
|
||||
private static void Stage6Profiles(JObject all)
|
||||
{
|
||||
JObject boss = Prof(name: "TBoss");
|
||||
boss["health"] = 1000f;
|
||||
boss["damageTaken"] = JObject.FromObject(new { head = 1f, body = 1f, legs = 1f });
|
||||
boss["boss"] = JObject.Parse(@"{
|
||||
""barDistance"": 100, ""announce"": ""chat"",
|
||||
""spawnLine"": ""{name} rises (rnt)"", ""deathLine"": ""{name} fell to {killer} (rnt)"",
|
||||
""phases"": [
|
||||
{ ""at"": 0.75, ""damageDealt"": 2, ""line"": ""phase one (rnt)"" },
|
||||
{ ""at"": 0.5, ""adds"": { ""profile"": ""t_roam"", ""count"": 2 }, ""line"": ""adds (rnt)"" },
|
||||
{ ""at"": 0.25, ""kit"": ""rnhrevolver"", ""speed"": 1.5, ""aimCone"": 4,
|
||||
""ranges"": { ""sense"": 50, ""loseTarget"": 60, ""chase"": 50, ""attack"": 40 } }
|
||||
]}");
|
||||
all["t_boss"] = boss;
|
||||
|
||||
JObject passive = Prof(role: "passive", name: "TPassive");
|
||||
passive["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f });
|
||||
passive["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Hello {player} (rnt)" } });
|
||||
all["t_passive"] = passive;
|
||||
|
||||
JObject window = Prof(role: "passive", name: "TWindow");
|
||||
window["use"] = JObject.FromObject(new { mode = "window", title = "Notice", text = "Hello {player} (rnt)" });
|
||||
all["t_passwin"] = window;
|
||||
|
||||
JObject hurt = Prof(role: "passive", name: "THurt");
|
||||
hurt["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f });
|
||||
hurt["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Ow (rnt)" } });
|
||||
hurt["hurtByPlayers"] = true;
|
||||
all["t_passhurt"] = hurt;
|
||||
}
|
||||
|
||||
/// <summary>A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay).</summary>
|
||||
private static void Look(BasePlayer p, BasePlayer at)
|
||||
{
|
||||
Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized;
|
||||
p.eyes.rotation = Quaternion.LookRotation(dir);
|
||||
}
|
||||
|
||||
/// <summary>E, just pressed, through RunicNPC's own OnPlayerInput.</summary>
|
||||
private void PressE(BasePlayer p)
|
||||
{
|
||||
var input = new InputState();
|
||||
input.current.buttons = (int)BUTTON.USE;
|
||||
input.previous.buttons = 0;
|
||||
RunicNPC?.Call("OnPlayerInput", p, input);
|
||||
}
|
||||
|
||||
/// <summary>A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some).</summary>
|
||||
private static void HurtTo(BasePlayer npc, BasePlayer by, float fraction)
|
||||
{
|
||||
for (int i = 0; i < 200 && !Gone(npc) && npc.health / npc.MaxHealth() >= fraction; i++)
|
||||
npc.Hurt(new HitInfo(by, npc, Rust.DamageType.Bullet, 40f));
|
||||
}
|
||||
|
||||
private IEnumerator Stage6()
|
||||
{
|
||||
Vector3 f;
|
||||
if (!FindField(300f, 2026, out f))
|
||||
{
|
||||
Check("s6.setup.field", false, "no third open field on the navmesh");
|
||||
yield break;
|
||||
}
|
||||
|
||||
StandIn(f + new Vector3(0, 0, 60), 50);
|
||||
|
||||
// ---- refusals, through a spawn's overrides (ValidateProfile on the effective profile) ----
|
||||
Func<string, string, bool> refused = (profile, json) => Spawn(f, profile, JObject.Parse(json)) == null;
|
||||
Check("s6.refuse.passiveBoss", refused("t_passive", "{\"boss\":{}}"), "a passive NPC cannot be a boss");
|
||||
Check("s6.refuse.useOnRoamer", refused("t_roam", "{\"use\":{\"mode\":\"chat\",\"lines\":[\"hi\"]}}"), "only a passive NPC answers E");
|
||||
Check("s6.refuse.standRoamer", refused("t_roam", "{\"movement\":{\"mode\":\"stand\",\"radius\":1}}"), "stand is a passive NPC's");
|
||||
Check("s6.refuse.useMode", refused("t_passive", "{\"use\":{\"mode\":\"shout\"}}"), "use.mode chat or window");
|
||||
Check("s6.refuse.chatNoLines", refused("t_passive", "{\"use\":{\"lines\":[]}}"), "a chat answer has lines");
|
||||
Check("s6.refuse.phaseAt", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":1.5}]}}"), "at between 0 and 1");
|
||||
Check("s6.refuse.phaseTwice", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5},{\"at\":0.5}]}}"), "two phases at one fraction");
|
||||
Check("s6.refuse.addsCount", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_roam\",\"count\":0}}]}}"), "adds 1 to 20");
|
||||
Check("s6.refuse.addsBoss", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_boss\",\"count\":1}}]}}"), "a boss does not summon bosses");
|
||||
Check("s6.refuse.phaseKit", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"kit\":\"rnt_nokit\"}]}}"), "a phase's kit must exist");
|
||||
Check("s6.refuse.speed", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"speed\":9}]}}"), "speed at most 5");
|
||||
Check("s6.refuse.announce", refused("t_boss", "{\"boss\":{\"announce\":\"shout\"}}"), "chat or popup");
|
||||
Check("s6.refuse.barDistance", refused("t_boss", "{\"boss\":{\"barDistance\":0}}"), "above 0");
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
|
||||
// ---- passive (D278, D279, D288) ----
|
||||
Vector3 a = OnMesh(f + new Vector3(-40, 0, 0));
|
||||
_used.Clear();
|
||||
int hitsBefore = _hitsOnStandIns;
|
||||
BasePlayer pas = Spawn(a, "t_passive");
|
||||
BasePlayer talker = StandIn(OnMesh(a + new Vector3(2f, 0, 0)), 61);
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
Dictionary<string, object> dp = Describe(pas);
|
||||
Check("s6.passive.spawned", pas != null && dp != null && (string)dp["role"] == "passive", dp != null ? "role " + dp["role"] : "not spawned");
|
||||
if (pas == null) yield break;
|
||||
|
||||
Sense(pas, talker);
|
||||
yield return new WaitForSeconds(6f);
|
||||
Check("s6.passive.neverFights", _hitsOnStandIns == hitsBefore && !Knows(pas, talker),
|
||||
$"told about a player 2 m off: hits {_hitsOnStandIns - hitsBefore}, still knows it as a target: {Knows(pas, talker)}");
|
||||
Check("s6.passive.stands", Flat(pas.transform.position, a) < 1.5f, $"{Flat(pas.transform.position, a):0.0} m from its spot after 7.5 s");
|
||||
|
||||
float hp = pas.health;
|
||||
pas.Hurt(new HitInfo(talker, pas, Rust.DamageType.Bullet, 50f));
|
||||
Check("s6.passive.unhurtByDefault", pas.health >= hp - 0.01f, $"health {hp:0} → {pas.health:0} after a player's 50 (D279: off by default)");
|
||||
|
||||
Look(talker, pas);
|
||||
PressE(talker);
|
||||
Check("s6.passive.pressE", _used.Count == 1 && _used[0] == "t_passive/RntStandIn61", string.Join(", ", _used.ToArray()));
|
||||
PressE(talker);
|
||||
Check("s6.passive.pressOncePerSecond", _used.Count == 1, $"{_used.Count} answer(s) to two presses at once");
|
||||
talker.eyes.rotation = Quaternion.LookRotation(Vector3.back);
|
||||
yield return new WaitForSeconds(1.2f);
|
||||
PressE(talker);
|
||||
Check("s6.passive.lookingAway", _used.Count == 1, $"E while looking away: {_used.Count} answer(s)");
|
||||
|
||||
BasePlayer win = Spawn(OnMesh(a + new Vector3(0, 0, 10f)), "t_passwin");
|
||||
BasePlayer reader = StandIn(OnMesh(a + new Vector3(2f, 0, 10f)), 62);
|
||||
yield return new WaitForSeconds(1f);
|
||||
if (win != null && reader != null)
|
||||
{
|
||||
Look(reader, win);
|
||||
PressE(reader);
|
||||
}
|
||||
|
||||
Check("s6.passive.window", _used.Contains("t_passwin/RntStandIn62"), string.Join(", ", _used.ToArray()));
|
||||
Dictionary<string, object> dw = Describe(win);
|
||||
Check("s6.passive.wanders", dw != null && (string)dw["movement"] == "wander", dw != null ? "movement " + dw["movement"] : "not spawned");
|
||||
|
||||
BasePlayer ph = Spawn(OnMesh(a + new Vector3(0, 0, -10f)), "t_passhurt");
|
||||
BasePlayer hitter = StandIn(OnMesh(a + new Vector3(2f, 0, -10f)), 63);
|
||||
yield return new WaitForSeconds(1f);
|
||||
if (ph != null && hitter != null)
|
||||
{
|
||||
hitsBefore = _hitsOnStandIns;
|
||||
float before = ph.health;
|
||||
ph.Hurt(new HitInfo(hitter, ph, Rust.DamageType.Bullet, 30f));
|
||||
float after = ph.health;
|
||||
yield return new WaitForSeconds(5f);
|
||||
Check("s6.passive.hurtable", after < before, $"hurtByPlayers on: health {before:0} → {after:0}");
|
||||
Check("s6.passive.neverFightsBack", _hitsOnStandIns == hitsBefore && !Knows(ph, hitter) && TargetOf(Describe(ph)) == "none",
|
||||
$"hit by a player: shots back {_hitsOnStandIns - hitsBefore}, knows it {Knows(ph, hitter)}");
|
||||
}
|
||||
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
ClearWorld();
|
||||
StandIn(f + new Vector3(0, 0, 60), 50);
|
||||
|
||||
// ---- the boss (D273–D277, D286, D287) ----
|
||||
_bossEvents.Clear();
|
||||
_bossContributors.Clear();
|
||||
Vector3 b = OnMesh(f + new Vector3(40, 0, 0));
|
||||
BasePlayer shooter = StandIn(OnMesh(b + new Vector3(0, 0, 30f)), 64);
|
||||
BasePlayer boss = Spawn(b, "t_boss");
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
Check("s6.boss.spawned", boss != null && _bossEvents.Contains("spawned:t_boss"), string.Join(", ", _bossEvents.ToArray()));
|
||||
if (boss == null) yield break;
|
||||
Dictionary<string, object> db = Describe(boss);
|
||||
Check("s6.boss.listed", db?["boss"] is Dictionary<string, object> && Convert.ToInt32(((Dictionary<string, object>)db["boss"])["phase"]) == 0, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-");
|
||||
|
||||
ScientistNPC sci = boss as ScientistNPC;
|
||||
float baseSpeed = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f;
|
||||
float scaleBefore = ((NPCPlayer)boss).damageScale;
|
||||
|
||||
// Down to 70%: phase one (damage ×2, a line).
|
||||
HurtTo(boss, shooter, 0.7f);
|
||||
yield return null;
|
||||
Check("s6.boss.phase1", _bossEvents.Contains("phase1@0.75") && Mathf.Approximately(((NPCPlayer)boss).damageScale, 2f),
|
||||
$"health {boss.health:0}/1000, events {string.Join(", ", _bossEvents.ToArray())}, damageScale {scaleBefore} → {((NPCPlayer)boss).damageScale}");
|
||||
|
||||
// Down to 45%: phase two (two adds of t_roam, one per frame, owned as the boss is).
|
||||
int before2 = List(Owner).Count;
|
||||
HurtTo(boss, shooter, 0.45f);
|
||||
for (int i = 0; i < 10; i++) yield return null;
|
||||
var adds = List(Owner).Where(d => (string)d["profile"] == "t_roam").ToList();
|
||||
Check("s6.boss.phase2.adds", _bossEvents.Contains("phase2@0.5") && adds.Count == 2 && adds.All(d => Flat(V(d["position"]), boss.transform.position) < 10f),
|
||||
$"{adds.Count} add(s) owned by {Owner}, {string.Join(", ", adds.Select(d => Flat(V(d["position"]), boss.transform.position).ToString("0.0") + " m").ToArray())} from the boss; listed {before2} → {List(Owner).Count}");
|
||||
|
||||
// Down to 20%: phase three (kit swap, speed ×1.5, aim and ranges). It is first given another
|
||||
// weapon to hold, so the swap back to the kit's revolver can be seen.
|
||||
boss.inventory.Strip();
|
||||
Give(boss, "pistol.semiauto", 1);
|
||||
((HumanNPC)boss).EquipWeapon();
|
||||
string heldBefore = boss.GetActiveItem()?.info.shortname ?? "none";
|
||||
HurtTo(boss, shooter, 0.2f);
|
||||
yield return null;
|
||||
float speedNow = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f;
|
||||
string heldAfter = boss.GetActiveItem()?.info.shortname ?? "none";
|
||||
Check("s6.boss.phase3", _bossEvents.Contains("phase3@0.25") && heldAfter == "pistol.revolver" && Mathf.Approximately(speedNow, baseSpeed * 1.5f) &&
|
||||
Mathf.Approximately(((HumanNPC)boss).aimConeScale, 4f),
|
||||
$"held {heldBefore} → {heldAfter}, speed {baseSpeed:0.0} → {speedNow:0.0}, aimConeScale {((HumanNPC)boss).aimConeScale}, senseRange {sci?.Brain?.SenseRange}");
|
||||
Check("s6.boss.phase3.ranges", sci?.Brain != null && Mathf.Approximately(sci.Brain.SenseRange, 50f) && Mathf.Approximately(sci.Brain.TargetLostRange, 60f),
|
||||
$"senseRange {sci?.Brain?.SenseRange}, targetLostRange {sci?.Brain?.TargetLostRange}");
|
||||
db = Describe(boss);
|
||||
Check("s6.boss.listedPhase", db?["boss"] is Dictionary<string, object> && Convert.ToInt32(((Dictionary<string, object>)db["boss"])["phase"]) == 3, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-");
|
||||
|
||||
// A phase changes the boss's own copy of its profile, never the shared one.
|
||||
BasePlayer second = Spawn(OnMesh(b + new Vector3(8, 0, 0)), "t_boss");
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Check("s6.boss.ownProfile", second != null && Mathf.Approximately(((NPCPlayer)second).damageScale, 1f) && Mathf.Approximately(((HumanNPC)second).aimConeScale, 2f),
|
||||
second != null ? $"a second t_boss: damageScale {((NPCPlayer)second).damageScale}, aimConeScale {((HumanNPC)second).aimConeScale}" : "not spawned");
|
||||
if (second != null) Api<int>("RunicNpc_Despawn", Id(second));
|
||||
|
||||
// The kill: the death hook with every player who hurt it; the adds fight on (D287).
|
||||
ulong bossId = Id(boss);
|
||||
boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f));
|
||||
yield return new WaitForSeconds(1f);
|
||||
Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f,
|
||||
$"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}");
|
||||
int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam");
|
||||
Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)");
|
||||
// An event's teardown despawns what its step placed, by net id: the dead boss's id takes its adds with it.
|
||||
int removed = Api<int>("RunicNpc_Despawn", bossId);
|
||||
int addsAfter = List(Owner).Count(d => (string)d["profile"] == "t_roam");
|
||||
Check("s6.boss.despawnTakesAdds", removed == 2 && addsAfter == 0, $"RunicNpc_Despawn(the dead boss's id) removed {removed}; adds left {addsAfter}");
|
||||
Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), "");
|
||||
|
||||
// ---- the examples (D280, D284, D289) ----
|
||||
Plugin kits = plugins.Find("Kits");
|
||||
string[] exampleKits = { "rnpc_raider", "rnpc_campguard", "rnpc_sniper", "rnpc_juggernaut" };
|
||||
string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray();
|
||||
Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing));
|
||||
JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject<JObject>("Kits/kits_data");
|
||||
Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "kits.runicnpc"),
|
||||
"each needs kits.runicnpc, which nobody is granted");
|
||||
JObject state = Interface.Oxide.DataFileSystem.ReadObject<JObject>("RunicNPC/state");
|
||||
Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None));
|
||||
JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject;
|
||||
int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count;
|
||||
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 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ----
|
||||
|
||||
private const string SwapKit = "rnt6smg";
|
||||
|
||||
Reference in New Issue
Block a user