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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user