docs, test: the update surface in the README, and rnt.cost (stage 9, D306)
- README: status is stage 9; the installer and egg install it now; API 6; the swap reports 55/55 npc fields; COMMANDS.md and INSTALL.md are linked; a new "update surface" table lists every Rust type and member the swap and the brain depend on, where, and what catches a change (compile, the field list, or only a running server). The staging drill checks exactly that list. - tools/RunicNpcTest.cs 0.7.0: rnt.cost measures 100 of RunicNPC's own NPCs against the same session's empty server, idle (awake) and fighting 20 invulnerable stand-ins, with a second empty baseline at the end and the bar taken against the lower. `idle` skips the fight, `sentry` makes them stand still, `asleep` leaves out the keeper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -45,8 +45,8 @@ namespace Oxide.Plugins
|
||||
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Info("RunicNpcTest", "Runic Gateway", "0.6.0")]
|
||||
[Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")]
|
||||
[Info("RunicNpcTest", "Runic Gateway", "0.7.0")]
|
||||
[Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")]
|
||||
internal class RunicNpcTest : RustPlugin
|
||||
{
|
||||
[PluginReference] private Plugin RunicNPC;
|
||||
@@ -543,6 +543,161 @@ namespace Oxide.Plugins
|
||||
arg.ReplyWith("rnt: cleared");
|
||||
}
|
||||
|
||||
// ---- cost: stage 9's performance check (D306) ----
|
||||
|
||||
/// <summary>
|
||||
/// Stage 9's performance check (D306): what 100 of RunicNPC's own NPCs add to the median
|
||||
/// server frame, idle and fighting, over this session's empty baseline. Stage 1 measured its
|
||||
/// harness's stand-in NPC; this measures the NPC RunicNPC ships, brain, fight layer and all.
|
||||
/// Same shape as stage 1's <c>rnh.cost</c>: a baseline, 100 awake but idle (a stand-in 85 m
|
||||
/// away keeps them from sleeping and is beyond their senses), then 100 shooting 20
|
||||
/// invulnerable stand-ins 12 m in front of the grid. It replaces RunicNPC's profiles with
|
||||
/// one, <c>t_cost</c>: back up <c>data/RunicNPC/</c> first, and stop any site that pushes
|
||||
/// profiles. <c>rnt.cost [seconds=60] [idle [sentry|asleep]]</c>: <c>idle</c> skips the fight,
|
||||
/// <c>sentry</c> makes the 100 stand still, and <c>asleep</c> leaves out the keeper, so nobody is
|
||||
/// within their sleep distance. Each run ends with a second empty baseline, and the bar is
|
||||
/// measured against the lower of the two.
|
||||
/// </summary>
|
||||
[ConsoleCommand("rnt.cost")]
|
||||
private void CmdCost(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
|
||||
_running = ServerMgr.Instance.StartCoroutine(Cost(arg.GetFloat(0, 60f), arg.GetString(1) == "idle", arg.GetString(2) == "sentry", arg.GetString(2) == "asleep"));
|
||||
arg.ReplyWith("rnt: measuring cost");
|
||||
}
|
||||
|
||||
/// <summary>The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar.</summary>
|
||||
private const double IdleBudgetMs = 1.6, FightBudgetMs = 5.2;
|
||||
|
||||
private IEnumerator Cost(float window, bool idleOnly = false, bool still = false, bool asleep = false)
|
||||
{
|
||||
_pass = _fail = 0;
|
||||
Note($"cost on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, " +
|
||||
$"players {BasePlayer.activePlayerList.Count}, {window:0} s a phase, ai budget {AIThinkManager.framebudgetms} ms");
|
||||
|
||||
Vector3 field;
|
||||
if (!FindField(400f, 606, out field))
|
||||
{
|
||||
Check("cost.field", false, "no open field on the navmesh");
|
||||
_running = null;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// `rnt.cost <s> idle sentry`: the same NPCs standing still, to tell movement from thinking.
|
||||
JObject prof = still ? Prof(role: "sentry", name: "TCost") : Prof(radius: 10f, name: "TCost");
|
||||
prof["ranges"] = JObject.FromObject(new { sense = 50f, loseTarget = 60f, chase = 40f, attack = 40f });
|
||||
Dictionary<string, string> refused = Api<Dictionary<string, string>>("RunicNpc_SetProfiles", new JObject { ["t_cost"] = prof });
|
||||
if (refused == null || refused.Count > 0)
|
||||
{
|
||||
Check("cost.profile", false, refused == null ? "no answer" : string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
|
||||
_running = null;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var meterHost = new GameObject("RunicNpcTest.FrameMeter");
|
||||
FrameMeter meter = meterHost.AddComponent<FrameMeter>();
|
||||
var medians = new Dictionary<string, double>();
|
||||
var npcs = new List<BasePlayer>();
|
||||
|
||||
IEnumerator m = MeasurePhase(meter, window, "baseline", medians);
|
||||
while (m.MoveNext()) yield return m.Current;
|
||||
|
||||
// `rnt.cost <s> idle asleep`: no keeper, so with nobody within 160 m they walk home and sleep (D235).
|
||||
if (!asleep)
|
||||
StandIn(OnMesh(field + new Vector3(13f, 0f, 110f)), 90);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
BasePlayer npc = Spawn(OnMesh(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)), "t_cost");
|
||||
if (npc != null) npcs.Add(npc);
|
||||
if (i % 10 == 9) yield return null;
|
||||
}
|
||||
|
||||
Check("cost.spawned", npcs.Count == 100, $"{npcs.Count} of 100");
|
||||
yield return new WaitForSeconds(10f);
|
||||
m = MeasurePhase(meter, window, "idle100", medians);
|
||||
while (m.MoveNext()) yield return m.Current;
|
||||
|
||||
// `rnt.cost <s> idle` stops here: a quicker run for finding what idle NPCs cost.
|
||||
if (!idleOnly)
|
||||
{
|
||||
_hitsOnStandIns = 0;
|
||||
for (int i = 1; i <= 20; i++)
|
||||
StandIn(OnMesh(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), 90 + i);
|
||||
yield return new WaitForSeconds(10f);
|
||||
int hitsBefore = _hitsOnStandIns;
|
||||
m = MeasurePhase(meter, window, "fight100", medians);
|
||||
while (m.MoveNext()) yield return m.Current;
|
||||
Note($"cost.fight100 hits on stand-ins while measured: {_hitsOnStandIns - hitsBefore}");
|
||||
Check("cost.fighting", _hitsOnStandIns - hitsBefore > 0, $"{_hitsOnStandIns - hitsBefore} hits");
|
||||
}
|
||||
|
||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||||
ClearWorld();
|
||||
|
||||
// A second empty window after everything is gone: one baseline at the start can carry a hitch
|
||||
// (the first run on the Oxide rig did: a 2.4 s frame, and idle read below it). The bar is
|
||||
// measured against the worse — the lower — of the two, so noise can only make it stricter.
|
||||
yield return new WaitForSeconds(10f);
|
||||
m = MeasurePhase(meter, window, "baseline.after", medians);
|
||||
while (m.MoveNext()) yield return m.Current;
|
||||
UnityEngine.Object.Destroy(meterHost);
|
||||
|
||||
double baseline = Math.Min(medians["baseline"], medians["baseline.after"]);
|
||||
double idle = medians["idle100"] - baseline;
|
||||
double fight = idleOnly ? double.NaN : medians["fight100"] - baseline;
|
||||
Note($"cost baselines {medians["baseline"]:0.00} and {medians["baseline.after"]:0.00} ms; the lower is the bar's");
|
||||
Check("cost.idle", idle <= IdleBudgetMs, $"{idle:+0.00;-0.00} ms median over the baseline (bar {IdleBudgetMs} ms)");
|
||||
if (!idleOnly) Check("cost.fight", fight <= FightBudgetMs, $"{fight:+0.00;-0.00} ms median over the baseline (bar {FightBudgetMs} ms)");
|
||||
Note($"done cost: {_pass} pass, {_fail} fail");
|
||||
_running = null;
|
||||
}
|
||||
|
||||
private IEnumerator MeasurePhase(FrameMeter meter, float window, string phase, Dictionary<string, double> medians)
|
||||
{
|
||||
int queue0 = AIThinkManager._processQueue.Count;
|
||||
meter.Begin();
|
||||
yield return new WaitForSeconds(window);
|
||||
List<float> ms = meter.End();
|
||||
if (ms.Count == 0)
|
||||
{
|
||||
Check("cost." + phase, false, "no frames");
|
||||
medians[phase] = double.NaN;
|
||||
yield break;
|
||||
}
|
||||
|
||||
ms.Sort();
|
||||
Func<double, double> pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
|
||||
double mean = ms.Average();
|
||||
medians[phase] = pct(0.5);
|
||||
Note($"cost.{phase} frames={ms.Count} p50={pct(0.5):0.00} ms fps={1000.0 / mean:0.0} p95={pct(0.95):0.00} p99={pct(0.99):0.00} " +
|
||||
$"max={ms[ms.Count - 1]:0.00} aiQueue={queue0}->{AIThinkManager._processQueue.Count}");
|
||||
}
|
||||
|
||||
public class FrameMeter : MonoBehaviour
|
||||
{
|
||||
private readonly List<float> _frames = new List<float>(20000);
|
||||
private bool _recording;
|
||||
|
||||
public void Begin()
|
||||
{
|
||||
_frames.Clear();
|
||||
_recording = true;
|
||||
}
|
||||
|
||||
public List<float> End()
|
||||
{
|
||||
_recording = false;
|
||||
return new List<float>(_frames);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_recording)
|
||||
_frames.Add(Time.unscaledDeltaTime * 1000f);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator Run(string group)
|
||||
{
|
||||
_pass = _fail = 0;
|
||||
|
||||
Reference in New Issue
Block a user