~Fiona's Script Library~

← Zurück zur Bibliothek

commando-collar.lsl | 31.61 KB
Herunterladen
// 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;
        }
    }
    
}