~Fiona's Script Library~
commando-collar.lsl
| 31.61 KB
// Copyright 2025 Fiona Sweet
// Free to use for any purpose - if you modify put your name ("don't blame me!")
//
// RLV commander. May be worn with collar. Allows wearer to give ownership to another AV
// AV can perform RLV commands. Also registers with remote web server and allows others to
// manipulate AV using RLV.
// fun times!
// ---------- Settings ----------
string BASE_PATH = "Shapes"; // Create Folder "Shapes" (case sensitive) in #RLV folder
// create two folders inside "Shapes" - "Shape Normal" and "Shape Short"
// put the included "Shape Normal" and "Shape Short" in each folder (1 per folder)
integer PEECHANNEL = 126; //get from peepee hud
integer maxpee = 100; //timer on peepee
integer ispeeing = FALSE;
integer REQUIRE_NEARBY = TRUE; // require controller within ~20m when issuing commands
float SCAN_RANGE = 30.0; // how far to look for candidates
integer CONFIG_CHANNEL = 23; // wearer-only config channel
string DESC_TAG = "CTRL="; // prefix in object description for persistence
string SERVER_BASE = "https://preg.jemcity.com/rlvaware"; // change me
string SHARED_SECRET = "LONGRANDOMSTRING"; // used only at register
float POLL_MAX = 20.0; // polling interval 5/.25
float polliterations = 20.0;
string ANIM_KNEEL = "kneel"; // name of the animation in this prim’s inventory
string ANIM_MASTURBATE = "masturbate";
string gLastAnim = "";
integer gHavePerms = 0;
//hitch
float HITCH_SCAN_RADIUS = 20.0; // how far to look for an anchor
float LEASH_LENGTH = 2.0; // max distance allowed
float LEASH_SLACK = 1.0; // extra slack before yank
float CHECK_EVERY = 0.25; // seconds between checks
integer DRAW_PARTICLES = TRUE; // set FALSE to disable the visible leash
// ---------- State ----------
key gOwner;
string gAvatarName;
integer gIsRegistered = FALSE;
integer gConsent = FALSE; // set TRUE after server confirms consent
string gToken = "";
string CONTROLLER = "00000000-0000-0000-0000-000000000000";
integer gListenLocal;
integer gListenConfig;
integer gListenDialog;
integer gDialogChan;
//hitch state
key gHitchObj = NULL_KEY;
vector gHitchPos = ZERO_VECTOR;
integer gLeashed = FALSE;
integer gHadTimer = FALSE;
// track HTTP requests
key gReqRegister;
key gReqPoll;
key gReqAck;
key gReqUnsub;
list gNames = []; // parallel arrays for dialog paging
list gKeys = [];
integer gPage = 0;
integer gPageSize = 9; // up to 12 buttons; reserve 3 for More/Refresh/Cancel
// ---------- Helpers ----------
wearShape(string shapeFolder) {
// Attach the folder contents without removing other worn items.
// For system body parts like Shape, the new one replaces the old automatically.
// Requires RLVa enabled in the viewer.
llOwnerSay("@attachover:" + BASE_PATH + "/" + shapeFolder + "=force");
}
integer playAnim(string animName)
{
if (!gHavePerms) return 0;
if (llGetInventoryType(animName) != INVENTORY_ANIMATION)
{
llOwnerSay("Animation missing from HUD inventory: " + animName);
return 0;
}
if (gLastAnim != "" && gLastAnim != animName)
{
llStopAnimation(gLastAnim);
}
llStartAnimation(animName);
gLastAnim = animName;
return 1;
}
integer stopCurrent()
{
if (!gHavePerms) return 0;
if (gLastAnim != "")
{
llStopAnimation(gLastAnim);
gLastAnim = "";
return 1;
}
return 0;
}
doKneel()
{
playAnim(ANIM_KNEEL);
}
doMasturbate()
{
playAnim(ANIM_MASTURBATE);
}
stopMasturbating()
{
stopCurrent();
}
stopKneeling()
{
stopCurrent();
}
runAlias(string msg)
{
/* controller commands */
if ((msg == "command off") || (msg == "commandoff") || (msg == "restore") || (msg == "reset"))
{
//shut up off
sendRLV("@sendim=y");
sendRLV("@recvim=y");
sendRLV("@viewnote=y");
sendRLV("@recvchat=y");
sendRLV("@sendchat=y");
sendRLV("@detach=y");
//confuse off
sendRLV("@shownames=y");
sendRLV("@shownametags=y");
//maphide off
sendRLV("@showloc=y");
//blind off
sendRLV("@setenv_daytime:1=force");
sendRLV("@setenv_ambienti=y");
sendRLV("@setenv_scenegamma=y");
sendRLV("@setenv_sunmooncolori=y");
sendRLV("@setenv_bluedensityi=y");
sendRLV("@setenv_bluehorizoni=y");
sendRLV("@setenv_cloudcolori=y");
sendRLV("@setenv_cloudcoverage=y");
sendRLV("@camdistmax:1=y");
//cuffs off
sendRLV("@detachall:cuffs=force");
stopMasturbating();
stopKneeling();
wearShape("Shape Normal");
return;
}
if (msg == "tall")
{
wearShape("Shape Normal");
}
if (msg == "short")
{
wearShape("Shape Short");
}
if (msg == "pee")
{
llSay(PEECHANNEL, "PEE");
ispeeing = TRUE;
maxpee = 100;
llSetTimerEvent(CHECK_EVERY);
}
if ((msg == "shutup") || (msg == "shut up"))
{
sendRLV("@sendim=n");
sendRLV("@detach=n");
sendRLV("@recvim=n");
sendRLV("@viewnote=n");
sendRLV("@recvchat=n");
sendRLV("@setdebug_renderresolutiondivisor:1=force");
return;
}
if ((msg == "shutupoff") || (msg == "shutup off") || (msg == "shut up off") || (msg == "speak"))
{
sendRLV("@sendim=y");
sendRLV("@recvim=y");
sendRLV("@viewnote=y");
sendRLV("@recvchat=y");
sendRLV("@sendchat=y");
sendRLV("@detach=y");
return;
}
if (msg == "confuse")
{
sendRLV("@shownames=n");
sendRLV("@shownametags=n");
return;
}
if ((msg == "confuseoff") || (msg == "confuse off"))
{
sendRLV("@shownames=y");
sendRLV("@shownametags=y");
return;
}
if (msg == "maphide")
{
sendRLV("@showloc=n");
return;
}
if ((msg == "maphideoff") || (msg == "maphide off"))
{
sendRLV("@showloc=y");
return;
}
if (msg == "blind")
{
sendRLV("@setenv_daytime:-1=force");
sendRLV("@setenv_ambienti:0=force");
sendRLV("@setenv_scenegamma:0=force");
sendRLV("@setenv_sunmooncolori:0=force");
sendRLV("@setenv_bluedensityi:0=force");
sendRLV("@setenv_bluehorizoni:0=force");
sendRLV("@setenv_cloudcolori:0=force");
sendRLV("@setenv_cloudcoverage:0=force");
sendRLV("@camdistmax:1=n");
return;
}
if ((msg == "blindoff") || (msg == "blind off"))
{
sendRLV("@setenv_daytime:1=force");
sendRLV("@setenv_ambienti=y");
sendRLV("@setenv_scenegamma=y");
sendRLV("@setenv_sunmooncolor=y");
sendRLV("@setenv_bluedensityi=y");
sendRLV("@setenv_bluehorizoni=y");
sendRLV("@setenv_cloudcolori=y");
sendRLV("@setenv_cloudcoverage=y");
sendRLV("@camdistmax:1=y");
return;
}
if (msg == "cuffs")
{
sendRLV("@attachall:cuffs=force");
return;
}
if ((msg == "cuffs off") || (msg == "cuffsoff"))
{
sendRLV("@detachall:cuffs=force");
return;
}
if (msg == "hitch")
{
startHitch();
return;
}
if ((msg == "unhitch")||(msg == "hitchoff")||(msg == "hitch off"))
{
stopHitch();
return;
}
if (msg == "strangle")
{
key owner = llGetOwner();
string name = llKey2Name(owner);
llSay(0,"*"+name+" chokes and gasps, struggling to breath*");
osCauseDamage(llGetOwner(), 20.0);
}
if (msg == "masturbate")
{
key owner = llGetOwner();
string name = llKey2Name(owner);
llSay(0,"*"+name+" touches herself and rubs one out*");
doMasturbate();
}
if (msg == "kneel")
{
key owner = llGetOwner();
string name = llKey2Name(owner);
llSay(0,"*"+name+" obeys and kneels to Mistress*");
doKneel();
}
if (msg == "stand")
{
stopMasturbating();
stopKneeling();
}
// inside listen(channel,name,id,msg) when channel==0 and controller checks passed:
string low = llToLower(msg);
if (llGetSubString(low, 0, 2) == "go ") {
handleGo(llGetSubString(msg, 3, -1)); // pass everything after "go "
return;
}
}
string nowUnixStr() { return (string)llGetUnixTime(); }
// ---- SHA-256 helpers ----
string sha256(string s) { return llSHA256String(s); }
string signRegister(string uuid, string ts) { return sha256(uuid + "|" + ts + "|" + SHARED_SECRET); }
string signPoll(string uuid, string ts, string token) { return sha256(uuid + "|" + ts + "|" + token); }
string signAck(string uuid, string ids, string ts, string token) { return sha256(uuid + "|" + ids + "|" + ts + "|" + token); }
string signUnsub(string uuid, string ts, string token) { return sha256(uuid + "|" + ts + "|" + token); }
// Execute one command from server
executeCommand(string id, string type, string payload) {
//type is rlv or alias
//payload is command
//we don't want arbitrary rlv commands at this time just aliases
if (type=="alias")
{
runAlias(payload);
}
}
// ---- HTTP calls ----
registerSelf() {
string ts = (string)llGetUnixTime();
string body = llList2Json(JSON_OBJECT, [
"uuid", (string)gOwner,
"name", gAvatarName,
"ts", ts,
"sig", signRegister((string)gOwner, ts)
]);
// POST JSON
gReqRegister = llHTTPRequest(
SERVER_BASE + "/register.php",
[ HTTP_METHOD, "POST",
HTTP_MIMETYPE, "application/json" ], // <-- not "Content-Type"
body
);
}
pollServer() {
//
if (!gIsRegistered || gToken == "") return;
string ts = (string)llGetUnixTime();
string url = SERVER_BASE + "/poll.php?uuid=" + (string)gOwner + "&ts=" + ts + "&sig=" + signPoll((string)gOwner, ts, gToken);
// GET (no body)
gReqPoll = llHTTPRequest(
url,
[ HTTP_METHOD, "GET" ],
"" // body must be a string; use ""
);
}
ackCommands(string csvIds) {
if (csvIds == "" || gToken == "" || !gConsent) return;
string ts = (string)llGetUnixTime();
string body = llList2Json(JSON_OBJECT, [
"uuid", (string)gOwner,
"ids", csvIds,
"ts", ts,
"sig", signAck((string)gOwner, csvIds, ts, gToken)
]);
// POST JSON (ack)
gReqAck = llHTTPRequest(
SERVER_BASE + "/ack.php",
[ HTTP_METHOD, "POST",
HTTP_MIMETYPE, "application/json" ],
body
);
}
// wearer-triggered revoke
unsubscribeRemote() {
if (gToken == "") { llOwnerSay("[Remote] Not registered."); return; }
string ts = (string)llGetUnixTime();
string body = llList2Json(JSON_OBJECT, [
"uuid", (string)gOwner,
"ts", ts,
"sig", signUnsub((string)gOwner, ts, gToken)
]);
// POST JSON (unsubscribe)
gReqUnsub = llHTTPRequest(
SERVER_BASE + "/unsubscribe.php",
[ HTTP_METHOD, "POST",
HTTP_MIMETYPE, "application/json" ],
body
);
}
integer startsWith(string s, string prefix) { return llSubStringIndex(llToLower(s), llToLower(prefix)) == 0; }
// only replace literal spaces with %20 (won't double-encode existing %XX)
string encodeSpacesOnly(string s) {
return llDumpList2String(llParseStringKeepNulls(s, [" "], []), "%20");
}
// Normalize a hop:// URL so the region segment has %20 instead of spaces
string normalizeHop(string hop) {
// hop format: hop://host:port/Region Name[/x/y/z]
string rest = llGetSubString(hop, 6, -1); // strip "hop://"
list segs = llParseStringKeepNulls(rest, ["/"], []);
integer n = llGetListLength(segs);
if (n >= 2) {
string hostport = llList2String(segs, 0);
string region = llList2String(segs, 1);
integer x = (n > 2) ? (integer)llList2String(segs, 2) : 128;
integer y = (n > 3) ? (integer)llList2String(segs, 3) : 128;
integer z = (n > 4) ? (integer)llList2String(segs, 4) : 25;
// only encode spaces in region segment
region = encodeSpacesOnly(region);
return "hop://" + hostport + "/" + region + "/" +
(string)x + "/" + (string)y + "/" + (string)z;
}
// If it's malformed, just return what we got
return hop;
}
string urlEncodeSpaces(string s) { return llDumpList2String(llParseStringKeepNulls(s, [" "], []), "%20"); }
integer hasSlashCoords(string s) { return (llSubStringIndex(s, "/") != -1); }
list parseSlashCoords(string s) {
// returns [region, x, y, z] (z defaults 25 if missing)
list parts = llParseStringKeepNulls(s, ["/"], []);
integer n = llGetListLength(parts);
string region = llList2String(parts, 0);
integer x = (n > 1) ? (integer)llList2String(parts, 1) : 128;
integer y = (n > 2) ? (integer)llList2String(parts, 2) : 128;
integer z = (n > 3) ? (integer)llList2String(parts, 3) : 25;
return [region, x, y, z];
}
string trimAll(string s) { return llStringTrim(s, STRING_TRIM); }
// Main handler: controller typed "go ..."
handleGo(string arg) {
arg = llStringTrim(arg, STRING_TRIM);
if (arg == "") return;
// CASE A: full hop URL already
if (startsWith(arg, "hop://")) {
string hop = normalizeHop(arg);
// Try direct RLVa teleport to hop://
llOwnerSay("@tpto:" + hop + "=force");
// Clickable backup (in case viewer doesn't accept hop in @tpto)
llOwnerSay("HG link (backup): " + hop);
return;
}
// CASE B: hypergrid in host:port:Region Name[/x/y/z]
integer c1 = llSubStringIndex(arg, ":");
integer c2 = (c1 != -1) ? llSubStringIndex(llGetSubString(arg, c1 + 1, -1), ":") : -1;
if (c1 != -1 && c2 != -1) {
string host = llGetSubString(arg, 0, c1 - 1);
string after1 = llGetSubString(arg, c1 + 1, -1);
integer ppos = llSubStringIndex(after1, ":");
string port = llGetSubString(after1, 0, ppos - 1);
string rest = llGetSubString(after1, ppos + 1, -1); // "Region[/x/y/z]"
list parts = llParseStringKeepNulls(rest, ["/"], []);
string region = llList2String(parts, 0);
integer x = (llGetListLength(parts) > 1) ? (integer)llList2String(parts, 1) : 128;
integer y = (llGetListLength(parts) > 2) ? (integer)llList2String(parts, 2) : 128;
integer z = (llGetListLength(parts) > 3) ? (integer)llList2String(parts, 3) : 25;
string hop = "hop://" + host + ":" + port + "/" + encodeSpacesOnly(region)
+ "/" + (string)x + "/" + (string)y + "/" + (string)z;
llOwnerSay("@tpto:" + hop + "=force");
llOwnerSay("HG link (backup): " + hop);
return;
}
// CASE C: local region "Region Name[/x/y/z]"
list l = llParseStringKeepNulls(arg, ["/"], []);
string region = llList2String(l, 0);
integer x = (llGetListLength(l) > 1) ? (integer)llList2String(l, 1) : 128;
integer y = (llGetListLength(l) > 2) ? (integer)llList2String(l, 2) : 128;
integer z = (llGetListLength(l) > 3) ? (integer)llList2String(l, 3) : 25;
llOwnerSay("@tpto:" + region + "/" + (string)x + "/" + (string)y + "/" + (string)z + "=force");
}
integer isUUID(string s) { return (llStringLength(s) >= 36); }
integer isController(key speaker) { return ((string)speaker == CONTROLLER); }
integer controllerInRange(key speaker) {
if (!REQUIRE_NEARBY) return TRUE;
list d = llGetObjectDetails(speaker, [OBJECT_POS]);
if (llGetListLength(d) == 0) return TRUE; // fail-open if unavailable
vector pos = llList2Vector(d, 0);
vector mine = llGetRootPosition();
return (llVecDist(pos, mine) <= 20.0);
}
string trim(string s) {
while (llStringLength(s) > 0 && llGetSubString(s, 0, 0) == " ")
s = llGetSubString(s, 1, -1);
while (llStringLength(s) > 0 && llGetSubString(s, -1, -1) == " ")
s = llGetSubString(s, 0, -2);
return s;
}
sendRLV(string msg) {
string m = trim(msg);
// strip optional leading "rlv"
if (llToLower(llGetSubString(m, 0, 2)) == "rlv") {
list parts = llParseStringKeepNulls(m, [" "], []);
if (llGetListLength(parts) >= 2) m = llDumpList2String(llList2List(parts, 1, -1), " ");
else m = "";
m = trim(m);
}
if (llGetSubString(m, 0, 0) != "@") return; // only @ commands
llOwnerSay(m);
}
saveController(string uuid) {
CONTROLLER = uuid;
string desc = llGetObjectDesc();
integer idx = llSubStringIndex(desc, DESC_TAG);
if (idx != -1) {
integer end = idx + llStringLength(DESC_TAG) + 36;
if (end > llStringLength(desc)) end = llStringLength(desc);
desc = llGetSubString(desc, 0, idx - 1) + DESC_TAG + uuid + llGetSubString(desc, end, -1);
} else {
if (llStringLength(desc) > 0) desc += " ";
desc += DESC_TAG + uuid;
}
llSetObjectDesc(desc);
llOwnerSay("[RLV Relay] Controller set to " + uuid + " and saved.");
}
loadControllerFromDesc() {
string desc = llGetObjectDesc();
integer idx = llSubStringIndex(desc, DESC_TAG);
if (idx != -1) {
string maybe = llGetSubString(desc, idx + llStringLength(DESC_TAG), idx + llStringLength(DESC_TAG) + 35);
if (isUUID(maybe)) CONTROLLER = maybe;
}
}
startListening() {
if (gListenLocal) llListenRemove(gListenLocal);
if (gListenConfig) llListenRemove(gListenConfig);
if (gListenDialog) llListenRemove(gListenDialog);
gListenLocal = llListen(0, "", NULL_KEY, "");
gListenConfig = llListen(CONFIG_CHANNEL, "", llGetOwner(), "");
gDialogChan = (integer)(-1 - llRound(llFrand(1000000000.0)));
gListenDialog = llListen(gDialogChan, "", llGetOwner(), "");
}
showStatus() {
llOwnerSay("[RLV Relay] Status — Controller: " + CONTROLLER +
" | Nearby required: " + (REQUIRE_NEARBY ? "yes" : "no") +
" | Menu: touch or /" + (string)CONFIG_CHANNEL + " menu");
}
// ---- Listing / Dialog (llGetAgentList, filtered by distance) ----
buildNearbyList() {
gNames = [];
gKeys = [];
gPage = 0;
list agents = llGetAgentList(AGENT_LIST_REGION, []); // region-wide
key owner = llGetOwner();
integer len = llGetListLength(agents);
integer i = 0;
vector mine = llGetRootPosition();
while (i < len) {
key k = llList2Key(agents, i);
if (k != owner) {
list d = llGetObjectDetails(k, [OBJECT_POS]);
if (llGetListLength(d) >= 1) {
vector pos = llList2Vector(d, 0);
if (llVecDist(pos, mine) <= SCAN_RANGE) {
// unique by key
if (llListFindList(gKeys, [k]) == -1) {
string nm = llKey2Name(k);
if (nm == "") nm = "(unknown)";
string shortKey = llGetSubString((string)k, -4, -1);
gKeys += [k];
gNames += [nm + "…" + shortKey];
}
}
}
}
i = i + 1;
}
}
showPage() {
integer total = llGetListLength(gNames);
integer pages = (total + gPageSize - 1) / gPageSize;
if (pages < 1) pages = 1;
if (gPage >= pages) gPage = pages - 1;
integer start = gPage * gPageSize;
integer end = start + gPageSize - 1;
if (end >= total) end = total - 1;
list buttons = [];
integer i = start;
while (i <= end && i >= 0) {
buttons += [llList2String(gNames, i)];
i = i + 1;
}
if (pages > 1) buttons += ["More"];
buttons += ["Refresh", "Cancel"];
string prompt = "Pick a controller (" + (string)total + " nearby)\n";
prompt += "Page " + (string)(gPage + 1) + "/" + (string)pages + "\n";
prompt += "Tap a name to set as controller.";
llDialog(llGetOwner(), prompt, buttons, gDialogChan);
}
openMenu() {
buildNearbyList();
showPage();
}
//hitch helpers
// --- particles (visual leash) ---
leashParticlesOn(key target)
{
if (!DRAW_PARTICLES) return;
list ps = [
PSYS_SRC_PATTERN, PSYS_SRC_PATTERN_DROP,
PSYS_PART_START_COLOR, <1,1,1>,
PSYS_PART_END_COLOR, <1,1,1>,
PSYS_PART_START_ALPHA, 1.0,
PSYS_PART_END_ALPHA, 1.0,
PSYS_PART_START_SCALE, <0.02,0.02,0.0>,
PSYS_PART_END_SCALE, <0.02,0.02,0.0>,
PSYS_SRC_MAX_AGE, 0.0,
PSYS_PART_MAX_AGE, 0.2,
PSYS_SRC_BURST_RATE, 0.01,
PSYS_SRC_BURST_PART_COUNT, 1,
PSYS_SRC_TARGET_KEY, target,
PSYS_SRC_BURST_SPEED_MIN, 0.0,
PSYS_SRC_BURST_SPEED_MAX, 0.0,
PSYS_PART_FLAGS, PSYS_PART_TARGET_POS_MASK|PSYS_PART_INTERP_COLOR_MASK|PSYS_PART_INTERP_SCALE_MASK
];
llParticleSystem(ps);
}
leashParticlesOff(){ llParticleSystem([]); }
// --- small helpers ---
vector clampToRadius(vector from, vector to, float r)
{
vector d = to - from;
float dist = llVecMag(d);
if (dist <= r) return to;
return from + (d / dist) * r;
}
// multi-direction sweep to find nearest object via llCastRay
key nearestObjectKey = NULL_KEY;
float nearestObjectDist = 999999.0;
vector nearestObjectPos = ZERO_VECTOR;
// Return the first hit object key along a ray, or NULL_KEY
key castForObject(vector from, vector to)
{
// Only objects (reject avatars), 1 hit max, fastest path
list hits = llCastRay(
from, to,
[ RC_REJECT_TYPES, RC_REJECT_AGENTS,
RC_MAX_HITS, 1 ]
);
if (llGetListLength(hits) >= 1) return (key)llList2String(hits, 0);
return NULL_KEY;
}
// Get object world position safely
vector getObjPos(key obj)
{
list d = llGetObjectDetails(obj, [OBJECT_POS]);
if (llGetListLength(d) > 0) return llList2Vector(d, 0);
return ZERO_VECTOR;
}
key findNearestObject(vector origin, float radius)
{
nearestObjectKey = NULL_KEY; nearestObjectDist = 999999.0; nearestObjectPos = ZERO_VECTOR;
integer steps = 16; // 16 spokes around you
integer i = 0;
while (i < steps)
{
float ang = (TWO_PI / steps) * i;
vector dir = <llCos(ang), llSin(ang), 0.0>;
key k = castForObject(origin, origin + dir * radius);
if (k && k != llGetKey()) // ignore self
{
vector p = getObjPos(k);
if (p != ZERO_VECTOR)
{
float d = llVecDist(origin, p);
if (d < nearestObjectDist) { nearestObjectDist = d; nearestObjectKey = k; nearestObjectPos = p; }
}
}
i += 1;
}
// one straight-down ray (often hits floor/ground/fixture)
key kd = castForObject(origin, origin + <0,0,-radius>);
if (kd && kd != llGetKey())
{
vector pd = getObjPos(kd);
if (pd != ZERO_VECTOR)
{
float dd = llVecDist(origin, pd);
if (dd < nearestObjectDist) { nearestObjectDist = dd; nearestObjectKey = kd; nearestObjectPos = pd; }
}
}
return nearestObjectKey;
}
// --- start/stop leash ---
startHitch()
{
vector me = llGetRootPosition();
key k = findNearestObject(me, HITCH_SCAN_RADIUS);
if (!k)
{
llOwnerSay("[Hitch] No object found within " + (string)HITCH_SCAN_RADIUS + " m.");
return;
}
gHitchObj = k;
gHitchPos = nearestObjectPos; // snapshot; object might move, you can refresh if desired
gLeashed = TRUE;
leashParticlesOn(gHitchObj);
if (!gHadTimer) { gHadTimer = TRUE; llSetTimerEvent(CHECK_EVERY); }
llOwnerSay("[Hitch] Leashed to object " + (string)gHitchObj + " at " + (string)gHitchPos + ".");
}
stopHitch()
{
gLeashed = FALSE;
gHitchObj = NULL_KEY;
gHitchPos = ZERO_VECTOR;
leashParticlesOff();
// keep timer if you already use it for other features
if (gHadTimer) { gHadTimer = FALSE; }
llOwnerSay("[Hitch] Released.");
}
// --- rule: keep within leash length, yank back with RLVa tpto (grid-local) ---
leashEnforce()
{
if (!gLeashed || !gHitchObj) return;
// if target moved (optional): refresh pos
list d = llGetObjectDetails(gHitchObj, [OBJECT_POS]);
if (llGetListLength(d) > 0) gHitchPos = llList2Vector(d, 0);
vector me = llGetRootPosition();
float dist = llVecDist(me, gHitchPos);
if (dist > (LEASH_LENGTH + LEASH_SLACK))
{
// yank to the boundary point
vector target = clampToRadius(gHitchPos, me, LEASH_LENGTH);
// convert to region coords string
string region = llGetRegionName();
string tp = region + "/" + (string)((integer)target.x) + "/" + (string)((integer)target.y) + "/" + (string)((integer)target.z);
// RLVa remote TP (within current grid)
llOwnerSay("@tpto:" + tp + "=force"); // viewer handles region->global conversion
// (RLVa @tpto syntax documented here) // ref: Catznip RLVa list
}
}
// ---------- Events ----------
default
{
state_entry()
{
llRequestPermissions(llGetOwner(), PERMISSION_TRIGGER_ANIMATION);
loadControllerFromDesc();
startListening();
gOwner = llGetOwner();
gAvatarName = llKey2Name(gOwner);
gIsRegistered = FALSE; gToken = ""; gConsent = FALSE;
registerSelf();
llSetTimerEvent(CHECK_EVERY);
llOwnerSay("[Remote] Type '/"+(string)CONFIG_CHANNEL+" unsubscribe' to revoke internet control.");
llOwnerSay("[RLV Relay] Ready. Controller: " + CONTROLLER +
" | Nearby required: " + (REQUIRE_NEARBY ? "yes" : "no") +
" | Config /" + (string)CONFIG_CHANNEL + " | Touch for menu");
}
attach(key id)
{
if (id) {
loadControllerFromDesc();
startListening();
llOwnerSay("[RLV Relay] Attached.");
llRequestPermissions(id, PERMISSION_TRIGGER_ANIMATION);
gOwner = id;
gAvatarName = llKey2Name(gOwner);
gIsRegistered = FALSE; gToken = ""; gConsent = FALSE;
registerSelf();
llSetTimerEvent(CHECK_EVERY);
} else {
if (gListenLocal) llListenRemove(gListenLocal);
if (gListenConfig) llListenRemove(gListenConfig);
if (gListenDialog) llListenRemove(gListenDialog);
llSetTimerEvent(0.0);
}
}
on_rez(integer sp)
{
loadControllerFromDesc();
startListening();
gOwner = llGetOwner();
gAvatarName = llKey2Name(gOwner);
gIsRegistered = FALSE; gToken = ""; gConsent = FALSE;
registerSelf();
llSetTimerEvent(CHECK_EVERY);
}
run_time_permissions(integer perm)
{
gHavePerms = (perm & PERMISSION_TRIGGER_ANIMATION);
if (!gHavePerms)
{
llOwnerSay("I need animation permission. Touch again after granting.");
}
}
touch_start(integer n)
{
if (llDetectedKey(0) == llGetOwner()) openMenu();
}
timer()
{
if (ispeeing)
{
maxpee--;
if (maxpee<1)
{
llSay(PEECHANNEL, "STOP");
ispeeing = FALSE;
}
} else {
polliterations--;
if (polliterations<1)
{
polliterations = POLL_MAX;
if (gIsRegistered && gToken != "") {
pollServer(); // will no-op or handle errors as you already do
}
}
}
leashEnforce();
}
listen(integer channel, string name, key id, string msg)
{
if (channel == 0) {
if (!isController(id)) return;
if (!controllerInRange(id)) return;
runAlias(msg);
return;
}
if (channel == CONFIG_CHANNEL && id == llGetOwner())
{
list parts = llParseString2List(llToLower(msg), [" "], []);
integer cnt = llGetListLength(parts);
if (cnt > 0) {
string cmd = llList2String(parts, 0);
if (cmd == "unsubscribe")
{
unsubscribeRemote(); // stops on server; client stops polling below on response
return;
}
if (cmd == "menu" || cmd == "scan") { openMenu(); return; }
if (cmd == "nearby" && cnt >= 2) {
string v = llList2String(parts, 1);
if (v == "on" || v == "yes") REQUIRE_NEARBY = TRUE;
else if (v == "off" || v == "no") REQUIRE_NEARBY = FALSE;
llOwnerSay("[RLV Relay] Nearby requirement: " + (REQUIRE_NEARBY ? "on" : "off"));
return;
}
if (cmd == "controller" && cnt >= 2) {
string newCtrl = llList2String(llParseString2List(msg, [" "], []), 1);
if (isUUID(newCtrl)) saveController(newCtrl);
else llOwnerSay("[RLV Relay] That doesn't look like a UUID.");
return;
}
if (cmd == "status") { showStatus(); return; }
}
llOwnerSay("[RLV Relay] Commands: /" + (string)CONFIG_CHANNEL +
" menu|scan | nearby on|off | controller <uuid> | status");
return;
}
if (channel == gDialogChan && id == llGetOwner())
{
if (msg == "Cancel") return;
if (msg == "Refresh") { openMenu(); return; }
if (msg == "More") { gPage = gPage + 1; showPage(); return; }
integer idx = llListFindList(gNames, [msg]);
if (idx != -1) {
key chosen = (key)llList2String(gKeys, idx);
if (chosen) {
saveController((string)chosen);
llInstantMessage(chosen, "/me set you as controller for their RLV relay.");
showStatus();
}
} else {
openMenu(); // list changed between scan & click
}
}
}
http_response(key req, integer status, list meta, string body)
{
if (req == gReqRegister) {
if (status == 200) {
string tok = llJsonGetValue(body, ["token"]);
string consent_url = llJsonGetValue(body, ["consent_url"]);
integer consent = (integer)llJsonGetValue(body, ["consent"]);
if (tok != JSON_INVALID && tok != "") {
gToken = tok; gIsRegistered = TRUE; gConsent = consent;
if (!gConsent && consent_url != JSON_INVALID && consent_url != "") {
llOwnerSay("[Remote] Please approve remote control in your browser.");
llLoadURL(llGetOwner(), "Approve remote control", consent_url);
} else if (gConsent) {
llOwnerSay("[Remote] Consent already granted. Remote control active.");
}
} else {
llOwnerSay("[Remote] Register failed: " + body);
}
} else {
llOwnerSay("[Remote] Register HTTP " + (string)status);
}
return;
}
if (req == gReqPoll) {
if (status == 200) {
// consent is ON (or the server doesn’t require it)
if (!gConsent) llOwnerSay("[Remote] Consent confirmed. Remote control active.");
gConsent = TRUE;
if (body != "[]") {
list ids = [];
integer i = 0;
while (TRUE) {
string cid = llJsonGetValue(body,[i,"id"]); if (cid == JSON_INVALID) { jump done; }
string ctype = llJsonGetValue(body,[i,"type"]);
string pay = llJsonGetValue(body,[i,"payload"]);
executeCommand(cid, ctype, pay);
ids += [cid]; i++;
}
@done;
if (llGetListLength(ids) > 0) ackCommands(llDumpList2String(ids, ","));
}
return;
}
if (status == 403) {
// consent OFF (not granted or revoked)
if (gConsent) llOwnerSay("[Remote] Consent revoked/not granted. Polling will keep checking.");
gConsent = FALSE;
return;
}
// other errors: keep trying later
return;
}
if (req == gReqAck) {
return;
}
if (req == gReqUnsub) {
if (status == 200) {
gConsent = FALSE;
llOwnerSay("[Remote] You are unsubscribed. Remote control is OFF.");
} else {
llOwnerSay("[Remote] Unsubscribe failed (HTTP " + (string)status + ").");
}
return;
}
}
}