feat: API 3, placements from a website (stage 4, D249)

RunicNpc_AddPlacement creates a placement and names it as /rnpc place
does (D241, D246). A position without y is a map point: it is put on the
ground there (terrain and rock, never a building or a tree, D245), then
checked against the navmesh like an in-game placement. It answers the id,
the grounded position, whether the spot is player-built, and the cost
warning, or the in-game refusal sentence.

RunicNpc_RenamePlacement and RunicNpc_RespawnPlacement do what rnpc
rename and rnpc respawn do. OnRunicNpcPlacementChanged(id, change,
previous) is raised on every set, remove and rename, from the API or in
game, so the bridge can tell the site at once.

rnpc place and here now share CreatePlacement with the API, so both give
the same refusals. The test harness gains an api3 group; all 157 checks
pass on the Oxide and Carbon rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 04:01:30 -05:00
parent 9c366ab5b5
commit fe20ca41af
4 changed files with 264 additions and 44 deletions

View File

@@ -58,7 +58,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 = 2;
private const int ApiVersion = 3;
/// <summary>
/// Every hook this plugin implements. <c>rnpc.status</c> reports which have fired, because
@@ -1964,6 +1964,7 @@ namespace Oxide.Plugins
_placementsDirty = true;
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
TickPlacements();
PlacementChanged(id, "set");
return null;
}
@@ -1976,9 +1977,96 @@ namespace Oxide.Plugins
_placementFile.Placements.Remove(id);
_placementsDirty = true;
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
PlacementChanged(id, "removed");
return true;
}
/// <summary>
/// API 3 (D249): creates a placement and names it as <c>rnpc place</c> does (D241, D246). A
/// position without <c>y</c> is a point on a map: it is put on the ground there (terrain and
/// rock, never a building, D245) and then checked against the navmesh like any other.
/// Answers <c>{id, position, built, cost}</c>, or <c>{error}</c>.
/// </summary>
private Dictionary<string, object> RunicNpc_AddPlacement(JObject placement)
{
Placement data;
try
{
data = placement?.ToObject<Placement>();
}
catch (Exception e)
{
return new Dictionary<string, object> { ["error"] = "the placement could not be read: " + e.Message };
}
if (data != null && data.Position != null && placement["position"]?["y"] == null)
{
Vector3 ground;
string off = GroundAt(data.Position.X, data.Position.Z, out ground);
if (off != null)
return new Dictionary<string, object> { ["error"] = off };
data.Position = Pos.Of(ground);
}
string id;
bool built;
string error = CreatePlacement(data, out id, out built);
if (error != null)
{
PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}");
return new Dictionary<string, object> { ["error"] = error };
}
return new Dictionary<string, object>
{
["id"] = id,
["position"] = new Dictionary<string, object> { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z },
["built"] = built,
["cost"] = CostWarning(PlannedTotal())
};
}
/// <summary>API 3 (D249): renames a placement, as <c>rnpc rename</c> does. Null on success.</summary>
private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to);
/// <summary>API 3 (D249): respawns a placement's NPCs now, as <c>rnpc respawn</c>. How many, or -1 if there is no such placement.</summary>
private int RunicNpc_RespawnPlacement(string id)
{
PlacementState ps;
if (id == null || !_placements.TryGetValue(id, out ps))
return -1;
int n = RespawnNow(ps);
TickPlacements();
return n;
}
/// <summary>
/// The ground under a point of the map: terrain or rock, never anything built or a tree (D245:
/// a roof is an in-game placement). Null with the spot, or the reason there is none.
/// </summary>
private static string GroundAt(float x, float z, out Vector3 ground)
{
ground = Vector3.zero;
float half = World.Size / 2f;
if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half)
return $"({x:0}, {z:0}) is off the map";
RaycastHit hit;
if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore))
ground = hit.point;
else
ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z);
if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f)
return $"({x:0}, {z:0}) is under water";
return null;
}
/// <summary>
/// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in
/// game alike. <c>change</c> is <c>set</c>, <c>removed</c> or <c>renamed</c> (with <c>previous</c>).
/// </summary>
private static void PlacementChanged(string id, string change, string previous = null) =>
Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous);
private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes);
/// <summary>Adds or replaces a route (D234). Every point must be on Rust's navmesh.</summary>
@@ -2044,6 +2132,9 @@ namespace Oxide.Plugins
private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree");
private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed");
/// <summary>What a map point is put down on (D245): terrain and rock, never a building or a tree.</summary>
private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World");
/// <summary>Who ran a command, and where the answer goes.</summary>
private sealed class Caller
{
@@ -2365,6 +2456,50 @@ namespace Oxide.Plugins
}
}
/// <summary>
/// Checks, names and sets a NEW placement: <c>rnpc place</c>, <c>rnpc here</c> and
/// <c>RunicNpc_AddPlacement</c> alike, so a placement made from a website is named and refused
/// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show.
/// </summary>
private string CreatePlacement(Placement data, out string id, out bool built)
{
id = null;
built = false;
Profile p;
if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p))
{
string name = data?.Profile;
return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.";
}
string error = ValidatePlacement(data);
if (error != null)
return "Not placed: " + error;
string kind, route;
ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route);
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
return $"There is no route '{route}'. Record it first: rnpc path record {route}";
if (!NavmeshReady())
return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly.";
if (!p.IsSentry)
{
string off = OffMeshReason(data.Position.V);
if (off != null)
return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219).";
built = IsPlayerBuilt(data.Position.V);
}
string next = NextPlacementId(data.Profile);
error = SetPlacement(next, data);
if (error != null)
return "Not placed: " + error;
id = next;
return null;
}
private void CmdPlace(Caller c, string[] args, bool here)
{
if (args.Length < 1)
@@ -2437,33 +2572,6 @@ namespace Oxide.Plugins
}
}
string kind, route;
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
{
c.Say($"There is no route '{route}'. Record it first: rnpc path record {route}");
return;
}
if (!NavmeshReady())
{
c.Say("The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly.");
return;
}
bool built = false;
if (!p.IsSentry)
{
string off = OffMeshReason(spot);
if (off != null)
{
c.Say($"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219).");
return;
}
built = IsPlayerBuilt(spot);
}
var data = new Placement
{
Profile = name,
@@ -2475,14 +2583,17 @@ namespace Oxide.Plugins
Movement = movement
};
string id = NextPlacementId(name);
error = SetPlacement(id, data);
string id;
bool built;
error = CreatePlacement(data, out id, out built);
if (error != null)
{
c.Say("Not placed: " + error);
c.Say(error);
return;
}
string kind, route;
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
Movement m = movement ?? p.Movement;
c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " +
$"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} <name>.");
@@ -2574,6 +2685,7 @@ namespace Oxide.Plugins
_placementsDirty = true;
Puts($"Placement '{from}' renamed to '{to}'.");
PlacementChanged(to, "renamed", from);
return null;
}