Reference module

The module the site ships, in full. It writes the character state to a file every few seconds and reads commands from another file. The upload script sends the files, so the game client makes no web requests itself.

Download the zip MIT licence. Fork it for your server.

README.txt

Voidwatch module and upload scripts                                          MIT licence

1. Close the game client.
2. Copy the "voidwatch" folder into the modules folder of your client: the folder that holds the
   other modules (the ones named game_... and client_...).
3. Set up the upload script once, by double-clicking:
     Windows:  setup-windows.cmd
     macOS:    setup-mac.command   (the first time: right-click it, pick Open, then Open again)
     Linux:    sh setup-linux.sh
   It runs in the background and starts at every login from then on. A client that runs through Wine or
   CrossOver uses the macOS or Linux setup.
4. Start the client and log in. A game message shows a code and a link. Open the link to try the
   dashboard as a guest, or type the code in Settings, Clients on the website.

If the game message says the setup did not find the client, drop the folder it shows onto the setup file
(Windows) or drag it into the setup window (macOS), or run: sh setup-linux.sh "<folder>".

To remove the upload script: remove-windows.cmd, remove-mac.command or sh remove-linux.sh.

Adapting the module to a server: https://voidwatch.xyz/developers/new-server

voidwatch/voidwatch.otmod

Module
  name: voidwatch
  description: Writes the character state for the Voidwatch upload script and runs the commands it brings back
  author: Voidwatch
  website: https://voidwatch.xyz/developers
  version: 0.3.0
  sandboxed: true
  autoload: true
  autoload-priority: 1000
  scripts: [ voidwatch ]
  @onLoad: Voidwatch.init()
  @onUnload: Voidwatch.terminate()

voidwatch/voidwatch.lua

-- Voidwatch reference module, feed schema vw.status/1. MIT licence.
--
-- The client makes no web requests. The module writes files into <write dir>/voidwatch/outbox and reads what the
-- upload script leaves in <write dir>/voidwatch/inbox:
--   outbox/status.json          the character state: every 2 s while someone watches, every 30 s otherwise
--   outbox/capture.png          a screenshot, when the last reply asked for one
--   outbox/pair-request.json    who wants a pairing code (only until the script has a token)
--   outbox/results.json         how the commands went
--   inbox/reply.json            the last answer to status: statusEvery, captureEvery, commands, settings
--   inbox/pair.json             the pairing code and link to show the player
--   inbox/commands.json         commands to run
-- Server-specific data (routes, supplies, tasks) comes from adapters: see /developers/new-server.

Voidwatch = { adapters = {}, actions = {} }

local MODULE = 'voidwatch-feed/0.3.0'
local ROOT = '/voidwatch'
local LOOP_MS = 1000                                    -- how often the module looks at the reply; writing a file is cheap
local SERVER = ''                                       -- set it, or let an adapter return server()

local statusEvent, lastShot, lastStatus, lastCommands, shownCode = nil, 0, 0, '', nil

local function write(path, text) pcall(g_resources.writeFileContents, ROOT .. path, text) end
local function read(path)
  if not g_resources.fileExists(ROOT .. path) then return nil end
  local ok, text = pcall(g_resources.readFileContents, ROOT .. path)
  return ok and text or nil
end
local function readJson(path)
  local text = read(path)
  if not text then return nil end
  local ok, value = pcall(json.decode, text)
  return ok and value or nil
end

local function say(text)
  if modules.game_textmessage and modules.game_textmessage.displayGameMessage then
    modules.game_textmessage.displayGameMessage('[Voidwatch] ' .. text)
  end
  g_logger.info('[Voidwatch] ' .. text)
end

-- asks every adapter for a field; the first one that answers wins
local function ask(field, ...)
  for _, a in ipairs(Voidwatch.adapters) do
    if a[field] then
      local ok, value = pcall(a[field], ...)
      if ok and value ~= nil then return value end
    end
  end
  return nil
end

local function state()
  local p = g_game.getLocalPlayer()
  if not p then return nil end
  local pos = p:getPosition()
  local skulls = {}
  for _, c in ipairs(g_map.getSpectators(pos, false)) do
    if c:isPlayer() and c ~= p and c:getSkull() ~= SkullNone then
      local colours = { [SkullWhite] = 'white', [SkullRed] = 'red', [SkullBlack] = 'black', [SkullYellow] = 'yellow',
                        [SkullGreen] = 'green', [SkullOrange] = 'orange' }
      if colours[c:getSkull()] then table.insert(skulls, { name = c:getName(), skull = colours[c:getSkull()] }) end
    end
  end
  return {
    schema = 'vw.status/1', module = MODULE, online = g_game.isOnline(),
    character = { name = p:getName(), vocation = ask('vocation'), level = p:getLevel(), exp = p:getExperience() },
    pos = { x = pos.x, y = pos.y, z = pos.z },
    cap = math.floor(p:getFreeCapacity()), stamina = p:getStamina(),
    expPerHour = ask('expPerHour'), route = ask('route'), routeOn = ask('routeOn'), label = ask('label'),
    routes = ask('routes') or {}, supplies = ask('supplies') or {}, tasks = ask('tasks') or {}, loot = ask('loot') or {},
    lootHours = ask('lootHours'), skulls = skulls, blessed = ask('blessed'), redemptionLeft = ask('redemptionLeft'),
  }
end

local function pairIfNeeded(s)
  local pair = readJson('/inbox/pair.json')
  if pair and pair.paired then return end
  write('/outbox/pair-request.json', json.encode({ character = s.character.name, server = ask('server') or SERVER, module = MODULE }))
  if pair and pair.code and pair.code ~= shownCode then
    shownCode = pair.code
    say('pairing code ' .. pair.code .. ', or open ' .. (pair.url or 'the website'))
  end
end

local function runCommands()
  local text = read('/inbox/commands.json')
  if not text or text == lastCommands then return end
  lastCommands = text
  local ok, list = pcall(json.decode, text)
  if not ok or type(list) ~= 'table' then return end
  local results = {}
  for _, c in ipairs(list) do
    local fn = Voidwatch.actions[c.action] or ask('action', c.action)
    local done, message = false, 'not supported on this client'
    if fn then
      local okRun, res, msg = pcall(fn, c.args or {})
      done, message = okRun and res ~= false, okRun and msg or tostring(res)
    end
    table.insert(results, { id = c.id, ok = done, message = message })
  end
  write('/outbox/results.json', json.encode(results))
end

local function tick()
  statusEvent = scheduleEvent(tick, LOOP_MS)
  if not g_game.isOnline() then return end
  local reply = readJson('/inbox/reply.json') or {}
  if g_clock.millis() - lastStatus >= (reply.statusEvery or 30) * 1000 or reply.statusEvery == nil then
    local s = state()
    if not s then return end
    lastStatus = g_clock.millis()
    pairIfNeeded(s)
    write('/outbox/status.json', json.encode(s))
  end
  local every = (reply.captureEvery or 60) * 1000
  if g_clock.millis() - lastShot >= every then
    lastShot = g_clock.millis()
    g_app.doScreenshot(ROOT .. '/outbox/capture.png')
  end
  runCommands()
end

-- actions every client can run without an adapter
Voidwatch.actions.capture = function() lastShot = 0; return true end
Voidwatch.actions.logout_in_pz = function()
  local p = g_game.getLocalPlayer()
  if p and p:isInProtectionZone() then g_game.safeLogout(); return true end
  return false, 'not in a protection zone'
end

function Voidwatch.init()
  g_resources.makeDir(ROOT)
  g_resources.makeDir(ROOT .. '/outbox')
  g_resources.makeDir(ROOT .. '/inbox')
  statusEvent = scheduleEvent(tick, 1000)
  -- the setup finds clients in the usual places; for any other place it needs this folder once
  scheduleEvent(function()
    local pair = readJson('/inbox/pair.json')
    if not pair then say('run the Voidwatch setup once. If it does not find this client, give it this folder: ' .. g_resources.getWriteDir() .. 'voidwatch') end
  end, 8000)
end

function Voidwatch.terminate()
  if statusEvent then removeEvent(statusEvent) end
  statusEvent = nil
end

setup-windows.cmd

@echo off
rem Voidwatch setup for Windows: copies the upload script, starts it now and at every login. No admin rights needed.
rem Drop the folder the game message showed onto this file to add a client that is not in the usual place.
setlocal
set "DEST=%LOCALAPPDATA%\Voidwatch"
rem the upload script ran as "Emberwatch" before the rename; stop that one too
powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='powershell.exe'\" | Where-Object { $_.CommandLine -like '*Emberwatch\upload.ps1*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
del "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Emberwatch upload.lnk" 2>nul
rmdir /S /Q "%LOCALAPPDATA%\Emberwatch" 2>nul
mkdir "%DEST%" 2>nul
copy /Y "%~dp0upload\upload.ps1" "%DEST%\upload.ps1" >nul
if not "%~1"=="" echo %~1>>"%DEST%\folders.txt"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$s = (New-Object -ComObject WScript.Shell).CreateShortcut([Environment]::GetFolderPath('Startup') + '\Voidwatch upload.lnk'); $s.TargetPath = 'powershell.exe'; $s.Arguments = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"' + $env:LOCALAPPDATA + '\Voidwatch\upload.ps1\"'; $s.WindowStyle = 7; $s.Save()"
start "" powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%DEST%\upload.ps1"
echo.
echo Voidwatch is set up. It runs in the background now and starts with Windows from now on.
echo Start the game and log in: a game message shows a code and a link.
echo.
pause

setup-mac.command

#!/bin/sh
# Voidwatch setup for macOS, also for clients in CrossOver or Wine: copies the upload script, starts it now and at
# every login. Double-click it. The first time, macOS may ask: right-click the file, pick Open, then Open again.
set -eu
DEST="$HOME/Library/Application Support/Voidwatch"
PLIST="$HOME/Library/LaunchAgents/xyz.voidwatch.upload.plist"
# the upload script ran as "Emberwatch" before the rename; stop that one too
launchctl unload "$HOME/Library/LaunchAgents/online.funyo.emberwatch.plist" 2>/dev/null || true
rm -f "$HOME/Library/LaunchAgents/online.funyo.emberwatch.plist"
pkill -f "Emberwatch/upload.sh" 2>/dev/null || true
rm -rf "$HOME/Library/Application Support/Emberwatch"
mkdir -p "$DEST" "$HOME/Library/LaunchAgents"
cp "$(dirname "$0")/upload/upload.sh" "$DEST/upload.sh"
echo "Drag the folder the game message showed into this window and press Enter,"
printf "or just press Enter to look in the usual places: "
read -r folder || folder=""
folder=$(printf '%s' "$folder" | sed "s/^'//; s/'$//; s/\\\\ / /g")
[ -n "$folder" ] && printf '%s\n' "$folder" >> "$DEST/folders.txt"
cat > "$PLIST" <<PL
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>xyz.voidwatch.upload</string>
  <key>ProgramArguments</key><array><string>/bin/sh</string><string>$DEST/upload.sh</string></array>
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
</dict></plist>
PL
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST"
echo
echo "Voidwatch is set up. It runs in the background now and starts with your Mac from now on."
echo "Start the game and log in: a game message shows a code and a link. You can close this window."

setup-linux.sh

#!/bin/sh
# Voidwatch setup for Linux, also for clients in Wine: copies the upload script and runs it as a user service, now and
# at every login. Run: sh setup-linux.sh [the folder the game message showed]
set -eu
DEST="${XDG_DATA_HOME:-$HOME/.local/share}/voidwatch"
UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/voidwatch.service"
# the upload script ran as "emberwatch" before the rename; stop that one too
systemctl --user disable --now emberwatch.service 2>/dev/null || true
rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/emberwatch.service"
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/emberwatch"
mkdir -p "$DEST" "$(dirname "$UNIT")"
cp "$(dirname "$0")/upload/upload.sh" "$DEST/upload.sh"
[ $# -gt 0 ] && printf '%s\n' "$1" >> "$DEST/folders.txt"
cat > "$UNIT" <<U
[Unit]
Description=Voidwatch upload script
[Service]
ExecStart=/bin/sh $DEST/upload.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
U
systemctl --user daemon-reload
systemctl --user enable --now voidwatch.service
echo "Voidwatch is set up and starts at every login. Start the game and log in: a game message shows a code and a link."

upload/upload.ps1

# Voidwatch upload script for Windows. It sends what the module writes and brings back the replies. MIT licence.
#   powershell -ExecutionPolicy Bypass -File upload.ps1 "<the voidwatch folder in the client's write directory>"
# The module shows the exact folder in a game message.
param([string]$Dir = '')
$Home_ = Join-Path $env:LOCALAPPDATA 'Voidwatch'

# Without a folder: watch every client on this computer. The usual places are searched every 30 s, plus the folders
# listed in folders.txt next to this script (setup adds the one you drop on it). Each client gets its own hidden worker.
if (-not $Dir) {
  New-Item -ItemType Directory -Force -Path $Home_ | Out-Null
  while ($true) {
    $found = @(Get-ChildItem (Join-Path $env:APPDATA 'OTClientV8') -Directory -ErrorAction SilentlyContinue |
      ForEach-Object { Join-Path $_.FullName 'voidwatch' } | Where-Object { Test-Path (Join-Path $_ 'outbox') })
    $list = Join-Path $Home_ 'folders.txt'
    if (Test-Path $list) { $found += Get-Content $list | Where-Object { $_ -and (Test-Path $_) } }
    foreach ($d in ($found | Sort-Object -Unique)) {
      $pidFile = Join-Path $d '.pid'
      $alive = $false
      if (Test-Path $pidFile) { $alive = [bool](Get-Process -Id ([int](Get-Content $pidFile)) -ErrorAction SilentlyContinue) }
      if (-not $alive) {
        $p = Start-Process powershell -WindowStyle Hidden -PassThru -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, $d)
        Set-Content -Path $pidFile -Value $p.Id
      }
    }
    Start-Sleep -Seconds 30
  }
}
$Api = if ($env:VOIDWATCH_API) { $env:VOIDWATCH_API } else { 'https://voidwatch.xyz/api/v1' }
$Out = Join-Path $Dir 'outbox'; $In = Join-Path $Dir 'inbox'; $TokenFile = Join-Path $Dir '.token'
New-Item -ItemType Directory -Force -Path $In | Out-Null
$lastCapture = [datetime]::MinValue; $lastMinimap = [datetime]::MinValue
$lastStatus = [datetime]::MinValue; $minimapCheck = [datetime]::MinValue
$statusEvery = 30; $minimapOn = $true

function Headers { @{ Authorization = 'Bearer ' + (Get-Content $TokenFile -Raw).Trim() } }

function Send-Status {
  $reply = Invoke-RestMethod -Method Post -Uri "$Api/status" -Headers (Headers) -ContentType 'application/json' -InFile "$Out\status.json" -TimeoutSec 10
  $reply | ConvertTo-Json -Depth 5 | Set-Content "$In\reply.json"
  '{"paired":true}' | Set-Content "$In\pair.json"
  $script:statusEvery = [int]$reply.statusEvery
  $script:minimapOn = $reply.settings.minimap -ne $false
  $script:lastStatus = Get-Date
  if ($reply.commands -gt 0) {
    Invoke-RestMethod -Uri "$Api/commands" -Headers (Headers) -TimeoutSec 10 | ConvertTo-Json -Depth 5 -AsArray | Set-Content "$In\commands.json"
  }
}

while ($true) {
  try {
    if (-not (Test-Path $TokenFile)) {
      if (Test-Path "$Out\pair-request.json") {
        $r = Invoke-RestMethod -Method Post -Uri "$Api/pair" -ContentType 'application/json' -InFile "$Out\pair-request.json" -TimeoutSec 10
        Set-Content -Path $TokenFile -Value $r.token -NoNewline
        @{ code = $r.code; url = $r.url; expiresAt = $r.expiresAt } | ConvertTo-Json | Set-Content "$In\pair.json"
      }
      Start-Sleep -Seconds 1
      continue
    }
    if ((Test-Path "$Out\status.json") -and ((Get-Date) - $lastStatus).TotalSeconds -ge $statusEvery) { Send-Status }
    $shot = Get-Item "$Out\capture.png" -ErrorAction SilentlyContinue
    if ($shot -and $shot.LastWriteTime -ne $lastCapture) {
      Invoke-RestMethod -Method Post -Uri "$Api/capture" -Headers (Headers) -ContentType 'image/png' -InFile $shot.FullName -TimeoutSec 20 | Out-Null
      $lastCapture = $shot.LastWriteTime
    }
    if (Test-Path "$Out\results.json") {
      foreach ($r in (Get-Content "$Out\results.json" -Raw | ConvertFrom-Json)) {
        $body = @{ ok = $r.ok; message = $r.message } | ConvertTo-Json
        Invoke-RestMethod -Method Post -Uri "$Api/commands/$($r.id)/result" -Headers (Headers) -ContentType 'application/json' -Body $body -TimeoutSec 10 | Out-Null
      }
      Remove-Item "$Out\results.json"
    }
    # the client's own minimap, one folder up; it changes slowly, so look at it once a minute
    if ($minimapOn -and ((Get-Date) - $minimapCheck).TotalSeconds -ge 60) {
      $minimapCheck = Get-Date
      $map = Get-ChildItem (Join-Path $Dir '..') -Filter 'minimap*.otmm' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
      if ($map -and $map.LastWriteTime -ne $lastMinimap) {
        Invoke-RestMethod -Method Post -Uri "$Api/minimap" -Headers (Headers) -ContentType 'application/octet-stream' -InFile $map.FullName -TimeoutSec 60 | Out-Null
        $lastMinimap = $map.LastWriteTime
      }
    }
    if ($statusEvery -le 2) {
      Start-Sleep -Seconds 1
    } else {
      # wait until someone opens the character, an alert fires or a command arrives; returns at once when that happens
      $w = Invoke-RestMethod -Uri "$Api/wait?timeout=25" -Headers (Headers) -TimeoutSec 30
      if ($w.wake) { $lastStatus = [datetime]::MinValue }
    }
  } catch { Write-Host (Get-Date -Format 'HH:mm:ss') $_.Exception.Message; Start-Sleep -Seconds 2 }
}

upload/upload.sh

#!/bin/sh
# Voidwatch upload script for macOS and Linux (also for clients that run through Wine or CrossOver).
# It sends what the module writes and brings back the replies. MIT licence.
#   sh upload.sh "<the voidwatch folder in the client's write directory>"
# The module shows the exact folder in a game message.
set -u
API=${VOIDWATCH_API:-https://voidwatch.xyz/api/v1}
HOME_DIR="$HOME/Library/Application Support/Voidwatch"
[ -d "$HOME_DIR" ] || HOME_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/voidwatch"

# Without a folder: watch every client on this computer. The usual places are searched every 30 s, plus the folders
# listed in folders.txt next to this script (setup adds the one you drag in). Each client gets its own worker.
if [ $# -eq 0 ]; then
  mkdir -p "$HOME_DIR"
  while :; do
    for d in "$HOME/Library/Application Support/OTClientV8"/*/voidwatch "$HOME/.otclientv8"/*/voidwatch "$HOME/.local/share/otclientv8"/*/voidwatch \
             "$HOME/Library/Application Support/CrossOver/Bottles"/*/drive_c/users/*/AppData/Roaming/OTClientV8/*/voidwatch \
             "$HOME/.wine/drive_c/users"/*/AppData/Roaming/OTClientV8/*/voidwatch; do
      [ -d "$d/outbox" ] && printf '%s\n' "$d"
    done > "$HOME_DIR/.found" 2>/dev/null
    [ -f "$HOME_DIR/folders.txt" ] && cat "$HOME_DIR/folders.txt" >> "$HOME_DIR/.found"
    while IFS= read -r d; do
      [ -d "$d" ] || continue
      pid=$(cat "$d/.pid" 2>/dev/null || echo 0)
      kill -0 "$pid" 2>/dev/null || { sh "$0" "$d" >/dev/null 2>&1 & echo $! > "$d/.pid"; }
    done < "$HOME_DIR/.found"
    sleep 30
  done
fi
DIR=$1
OUT="$DIR/outbox" IN="$DIR/inbox" TOKEN_FILE="$DIR/.token"
[ -f "$TOKEN_FILE" ] && chmod 600 "$TOKEN_FILE"
mkdir -p "$IN"
last_capture=0 last_status=0 last_minimap=0 minimap_check=0 status_every=30

auth() { printf 'Authorization: Bearer %s' "$(cat "$TOKEN_FILE")"; }
post_json() { curl -fsS -m 10 -X POST -H "Content-Type: application/json" -H "$(auth)" --data-binary "@$2" "$API$1"; }
field() { printf '%s' "$1" | sed -n "s/.*\"$2\":\([0-9a-z]*\).*/\1/p"; }
mtime() { stat -f %m "$1" 2>/dev/null || stat -c %Y "$1"; }

send_status() {
  reply=$(post_json /status "$OUT/status.json") || return
  printf '%s' "$reply" > "$IN/reply.json"
  printf '{"paired":true}' > "$IN/pair.json"
  status_every=$(field "$reply" statusEvery); status_every=${status_every:-30}
  last_status=$(date +%s)
  case "$reply" in *'"commands":0'*) ;; *)
    curl -fsS -m 10 -H "$(auth)" "$API/commands" > "$IN/commands.json.tmp" && mv "$IN/commands.json.tmp" "$IN/commands.json" ;;
  esac
  case "$reply" in *'"minimap":false'*) minimap_on=0 ;; *) minimap_on=1 ;; esac
}

while :; do
  if [ ! -s "$TOKEN_FILE" ]; then
    if [ -f "$OUT/pair-request.json" ]; then
      reply=$(curl -fsS -m 10 -X POST -H "Content-Type: application/json" --data-binary "@$OUT/pair-request.json" "$API/pair") && {
        (umask 077; printf '%s' "$reply" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' > "$TOKEN_FILE")
        printf '%s' "$reply" | sed 's/"token":"[^"]*",\{0,1\}//' > "$IN/pair.json"
      }
    fi
    sleep 1
    continue
  fi
  now=$(date +%s)
  if [ -f "$OUT/status.json" ] && [ $((now - last_status)) -ge "$status_every" ]; then send_status; fi
  if [ -f "$OUT/capture.png" ]; then
    m=$(mtime "$OUT/capture.png")
    if [ "$m" != "$last_capture" ]; then
      curl -fsS -m 20 -X POST -H "Content-Type: image/png" -H "$(auth)" --data-binary "@$OUT/capture.png" "$API/capture" >/dev/null && last_capture=$m
    fi
  fi
  if [ -f "$OUT/results.json" ]; then
    # one result per command: {"id":1,"ok":true,"message":"..."}
    sed 's/},{/}\n{/g; s/^\[//; s/\]$//' "$OUT/results.json" | while IFS= read -r r; do
      id=$(printf '%s' "$r" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
      [ -n "$id" ] && printf '%s' "$r" > "$OUT/.result" && post_json "/commands/$id/result" "$OUT/.result" >/dev/null
    done
    rm -f "$OUT/results.json" "$OUT/.result"
  fi
  # the client's own minimap, one folder up; it changes slowly, so look at it once a minute
  if [ "${minimap_on:-1}" = 1 ] && [ $((now - minimap_check)) -ge 60 ]; then
    minimap_check=$now
    map="" best=0
    for f in "$DIR"/../minimap*.otmm; do
      [ -f "$f" ] || continue
      m=$(mtime "$f"); if [ "$m" -gt "$best" ]; then map=$f best=$m; fi
    done
    if [ -n "$map" ] && [ "$best" != "$last_minimap" ]; then
      curl -fsS -m 60 -X POST -H "Content-Type: application/octet-stream" -H "$(auth)" --data-binary "@$map" "$API/minimap" >/dev/null \
        && last_minimap=$(mtime "$map")
    fi
  fi
  if [ "$status_every" -le 2 ]; then
    sleep 1
  else
    # wait until someone opens the character, an alert fires or a command arrives; returns at once when that happens
    reply=$(curl -fsS -m 30 -H "$(auth)" "$API/wait?timeout=25") && case "$reply" in *'"wake":true'*) last_status=0 ;; esac
  fi
done

remove-windows.cmd

@echo off
rem Removes the Voidwatch upload script from Windows: stops it, removes it from the startup items, deletes its files.
powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='powershell.exe'\" | Where-Object { $_.CommandLine -like '*Voidwatch\upload.ps1*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
rem the upload script ran as "Emberwatch" before the rename; stop that one too
powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='powershell.exe'\" | Where-Object { $_.CommandLine -like '*Emberwatch\upload.ps1*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
del "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Emberwatch upload.lnk" 2>nul
rmdir /S /Q "%LOCALAPPDATA%\Emberwatch" 2>nul
del "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Voidwatch upload.lnk" 2>nul
rmdir /S /Q "%LOCALAPPDATA%\Voidwatch" 2>nul
echo Voidwatch upload script removed. The module in your game client stays until you delete its folder.
pause

remove-mac.command

#!/bin/sh
# Removes the Voidwatch upload script from macOS: stops it, removes the login item, deletes its files.
# the upload script ran as "Emberwatch" before the rename; stop that one too
launchctl unload "$HOME/Library/LaunchAgents/online.funyo.emberwatch.plist" 2>/dev/null
rm -f "$HOME/Library/LaunchAgents/online.funyo.emberwatch.plist"
pkill -f "Emberwatch/upload.sh" 2>/dev/null
rm -rf "$HOME/Library/Application Support/Emberwatch"
PLIST="$HOME/Library/LaunchAgents/xyz.voidwatch.upload.plist"
launchctl unload "$PLIST" 2>/dev/null
rm -f "$PLIST"
pkill -f "Voidwatch/upload.sh" 2>/dev/null
rm -rf "$HOME/Library/Application Support/Voidwatch"
echo "Voidwatch upload script removed. The module in your game client stays until you delete its folder."

remove-linux.sh

#!/bin/sh
# Removes the Voidwatch upload script from Linux: stops the user service and deletes its files.
# the upload script ran as "emberwatch" before the rename; stop that one too
systemctl --user disable --now emberwatch.service 2>/dev/null
rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/emberwatch.service"
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/emberwatch"
systemctl --user disable --now voidwatch.service 2>/dev/null
rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/voidwatch.service"
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/voidwatch"
echo "Voidwatch upload script removed. The module in your game client stays until you delete its folder."