2026-10-21 20:40:13 +07:00
package sdkserver
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
const clientUnstuckRequestFile = "scripts/client_unstuck_request.lua"
type clientUnstuckStatus struct {
RequestID string `json:"request_id"`
State string `json:"state"`
Error string `json:"error,omitempty"`
QueuedAt int64 `json:"queued_at"`
CompletedAt int64 `json:"completed_at,omitempty"`
}
var clientUnstuckState struct {
sync . Mutex
status clientUnstuckStatus
}
func queueClientUnstuck () ( clientUnstuckStatus , error ) {
clientUnstuckState . Lock ()
defer clientUnstuckState . Unlock ()
var nonce [ 16 ] byte
if _ , err := rand . Read ( nonce [:]); err != nil {
return clientUnstuckStatus {}, err
}
id := hex . EncodeToString ( nonce [:])
// Only a generated hexadecimal nonce is interpolated. No user-supplied
// command, path, endpoint or Lua source can enter this heartbeat script.
script := fmt . Sprintf ( clientUnstuckLua , id )
if err := os . MkdirAll ( filepath . Dir ( clientUnstuckRequestFile ), 0700 ); err != nil {
return clientUnstuckStatus {}, err
}
file , err := os . CreateTemp ( filepath . Dir ( clientUnstuckRequestFile ), ".client-unstuck-*.tmp" )
if err != nil {
return clientUnstuckStatus {}, err
}
defer os . Remove ( file . Name ())
if _ , err = file . WriteString ( script ); err != nil {
file . Close ()
return clientUnstuckStatus {}, err
}
if err = file . Close (); err != nil {
return clientUnstuckStatus {}, err
}
if err = os . Rename ( file . Name (), clientUnstuckRequestFile ); err != nil {
return clientUnstuckStatus {}, err
}
status := clientUnstuckStatus { RequestID : id , State : "queued" , QueuedAt : time . Now (). UnixMilli ()}
clientUnstuckState . status = status
Log ( "[UNSTUCK] UI recovery queued request=" + id )
return status , nil
}
func apiClientUnstuck ( w http . ResponseWriter , r * http . Request ) {
switch r . Method {
case http . MethodGet :
clientUnstuckState . Lock ()
status := clientUnstuckState . status
clientUnstuckState . Unlock ()
writeJSON ( w , status )
case http . MethodPost :
status , err := queueClientUnstuck ()
if err != nil {
http . Error ( w , "cannot queue UI recovery" , http . StatusInternalServerError )
return
}
w . Header (). Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusAccepted )
writeJSON ( w , status )
default :
http . Error ( w , "method not allowed" , http . StatusMethodNotAllowed )
}
}
func apiClientUnstuckResult ( w http . ResponseWriter , r * http . Request ) {
if r . Method != http . MethodPost {
http . Error ( w , "method not allowed" , http . StatusMethodNotAllowed )
return
}
var result struct {
RequestID string `json:"request_id"`
Success bool `json:"success"`
Error string `json:"error"`
}
decoder := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 8192 ))
decoder . DisallowUnknownFields ()
if err := decoder . Decode ( & result ); err != nil {
http . Error ( w , "invalid recovery result" , http . StatusBadRequest )
return
}
clientUnstuckState . Lock ()
defer clientUnstuckState . Unlock ()
if result . RequestID == "" || result . RequestID != clientUnstuckState . status . RequestID {
http . Error ( w , "recovery request is no longer current" , http . StatusConflict )
return
}
clientUnstuckState . status . State = "failed"
if result . Success {
clientUnstuckState . status . State = "completed"
}
clientUnstuckState . status . Error = result . Error
clientUnstuckState . status . CompletedAt = time . Now (). UnixMilli ()
2026-10-28 18:54:43 +07:00
// Queueing and acknowledgement share the lock, so removing this owned
// request cannot delete a newer recovery. A leftover script still checks
// the live nonce before touching UI, including after an SDK restart.
if err := os . Remove ( clientUnstuckRequestFile ); err != nil && ! os . IsNotExist ( err ) {
Log ( "[UNSTUCK] cannot remove completed request: " + err . Error ())
}
2026-10-21 20:40:13 +07:00
Log ( "[UNSTUCK] UI recovery " + clientUnstuckState . status . State + " request=" + result . RequestID )
writeJSON ( w , clientUnstuckState . status )
}
// Named UIManager API observed on the current Client. Clear only network UI
// waits: never close a Battle, change a scene, fabricate a successful gameplay
// response, erase a run, or reset the Client's loading/phase flags.
const clientUnstuckLua = `do
local id = %q
2026-10-28 18:54:43 +07:00
local function tick()
local url=_G.__GAC_SRTOOL_LIVE_URL
if type(url)~='string' or url=='' then return end
local s=_G.__GAC_UNSTUCK_WORK
if not s or s.id~=id then
if s and s.request then pcall(function()s.request:Dispose()end) end
s={id=id,phase='check',next=0};_G.__GAC_UNSTUCK_WORK=s
end
if s.phase=='done' or s.phase=='stale' then return end
local now=CS.UnityEngine.Time.unscaledTime
if s.request then
if not s.request.isDone then return end
local request=s.request;s.request=nil
local ok,err=pcall(function()
local code=request.responseCode
if code<200 or code>=300 then error('recovery HTTP '..tostring(code)) end
if s.phase=='check' then
local status=CS.Newtonsoft.Json.Linq.JObject.Parse(request.downloadHandler.text)
local request_id=status:get_Item('request_id')
local phase=status:get_Item('state')
if not request_id or tostring(request_id)~=id or not phase or tostring(phase)~='queued' then
s.phase='stale';return
end
-- Only the current queued SDK request authorizes a UI recovery. Old
-- generated files are inert after reconnect or SDK process restart.
local success,message=pcall(function()
local ui=CS.RPG.Client.GlobalVars.s_UIManager
if not ui then error('Client UIManager is unavailable') end
ui:ClearWaitNetPacketFullScreenBlock()
end)
s.body='{"request_id":"'..id..'","success":'..tostring(success)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(message or ''))..'}'
s.phase='ack'
CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(success)..' '..tostring(message or ''))
else
s.phase='done';_G.__GAC_UNSTUCK_LAST=id
end
end)
pcall(function()request:Dispose()end)
if not ok then
s.next=now+5
if s.error~=tostring(err) then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err));s.error=tostring(err) end
return
end
end
if s.phase=='done' or s.phase=='stale' or now<s.next then return end
local req
if s.phase=='check' then
req=CS.UnityEngine.Networking.UnityWebRequest.Get(url..'/api/client/unstuck')
else
req=CS.UnityEngine.Networking.UnityWebRequest(url..'/api/client/unstuck/result','POST')
req.uploadHandler=CS.UnityEngine.Networking.UploadHandlerRaw(CS.System.Text.Encoding.UTF8:GetBytes(s.body))
2026-10-21 20:40:13 +07:00
req.downloadHandler=CS.UnityEngine.Networking.DownloadHandlerBuffer()
2026-10-28 18:54:43 +07:00
req:SetRequestHeader('Content-Type','application/json')
end
req.timeout=10;s.request=req;req:SendWebRequest()
2026-10-21 20:40:13 +07:00
end
2026-10-28 18:54:43 +07:00
local ok,err=pcall(tick)
if not ok then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err)) end
2026-10-21 20:40:13 +07:00
end
`