2026-10-29 03:12:00 +07:00
package web
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"sync"
"time"
"ananta-ps/internal/config"
"ananta-ps/internal/gameserver"
)
type AdminServer struct {
cfg * config . Config
gameServer * gameserver . Server
httpSrv * http . Server
cancelFunc context . CancelFunc
mu sync . RWMutex
}
func NewAdmin ( cfg * config . Config , gameServer * gameserver . Server ) * AdminServer {
return & AdminServer {
cfg : cfg ,
gameServer : gameServer ,
}
}
func ( a * AdminServer ) Start () error {
if ! a . cfg . Admin . Enabled {
return nil
}
ctx , cancel := context . WithCancel ( context . Background ())
a . cancelFunc = cancel
mux := http . NewServeMux ()
mux . HandleFunc ( "/" , a . handleDashboard )
mux . HandleFunc ( "/api/teleport" , a . handleTeleport )
2026-10-29 03:53:15 +07:00
mux . HandleFunc ( "/api/command" , a . handleCommand )
2026-10-29 03:12:00 +07:00
mux . HandleFunc ( "/api/sessions" , a . handleSessions )
addr := fmt . Sprintf ( "%s:%d" , a . cfg . Network . BindHost , a . cfg . Admin . Port )
a . httpSrv = & http . Server {
Addr : addr ,
Handler : mux ,
}
go func () {
log . Printf ( "[ADMIN-GM] Admin Web Panel running at http://%s:%d/" , a . cfg . Network . AdvertisedHost , a . cfg . Admin . Port )
if err := a . httpSrv . ListenAndServe (); err != nil && err != http . ErrServerClosed {
log . Printf ( "[ADMIN-GM] Web server error: %v" , err )
}
}()
_ = ctx
return nil
}
func ( a * AdminServer ) Stop () error {
if a . cancelFunc != nil {
a . cancelFunc ()
}
if a . httpSrv != nil {
ctx , cancel := context . WithTimeout ( context . Background (), 2 * time . Second )
defer cancel ()
_ = a . httpSrv . Shutdown ( ctx )
}
return nil
}
func ( a * AdminServer ) handleDashboard ( w http . ResponseWriter , r * http . Request ) {
tmpl := `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ananta GM Admin Dashboard</title>
<style>
:root {
--bg: #0f111a;
--card: #171b26;
--border: #2b3147;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #1f6feb;
--success: #238636;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 24px;
}
.container { max-width: 1200px; margin: 0 auto; }
header { margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
h1 { font-size: 24px; font-weight: 600; color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.card h2 { font-size: 18px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.form-group { margin-bottom: 12px; }
label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
input, select, button {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 14px;
}
button {
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
border: none;
margin-top: 8px;
}
button:hover { background: var(--accent-hover); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; background: var(--success); color: #fff; }
.stat-val { font-size: 20px; font-weight: bold; margin-top: 4px; }
.coord-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Ananta GM Admin Dashboard (Go Native)</h1>
<p style="color: var(--text-muted); font-size: 13px;">Project Mugen CBT 4229938 Sandbox Controller</p>
</div>
<div>
<span class="badge">ONLINE</span>
</div>
</header>
<div class="grid">
<!-- Server Status -->
<div class="card">
<h2>Server Status</h2>
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: var(--text-muted);">Active Sessions</div>
<div class="stat-val"> {{ .ActiveSessions }} </div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: var(--text-muted);">Player UID / Account</div>
<div style="font-size: 14px; margin-top: 4px;"> {{ .PlayerPID }} ( {{ .AccountId }} )</div>
</div>
<div>
<div style="font-size: 13px; color: var(--text-muted);">Active Character Template</div>
<div style="font-size: 14px; margin-top: 4px;"> {{ .InitialSpirit }} </div>
</div>
</div>
<!-- Teleportation -->
<div class="card">
<h2>Instant Teleportation</h2>
<form id="teleportForm">
<div class="form-group">
<label>Presets</label>
<select id="teleportPreset" onchange="applyPreset()">
<option value="3142.67,0.0,2522.78,0.0">Downtown Core (Spawn)</option>
<option value="3500.0,10.0,2200.0,90.0">Subway Central Station</option>
<option value="2800.0,5.0,2900.0,180.0">Harbor / Waterfront Pier</option>
<option value="4100.0,50.0,1500.0,-45.0">Highrise Skybridge</option>
<option value="1500.0,0.0,1800.0,0.0">Suburban District</option>
</select>
</div>
<div class="form-group">
<label>Coordinates (X, Y, Z)</label>
<div class="coord-grid">
<input type="number" step="0.1" id="posX" value="3142.7">
<input type="number" step="0.1" id="posY" value="0.0">
<input type="number" step="0.1" id="posZ" value="2522.8">
</div>
</div>
<div class="form-group">
<label>Facing (Yaw)</label>
<input type="number" step="0.1" id="posFacing" value="0.0">
</div>
<button type="button" onclick="submitTeleport()">Teleport In-Game</button>
</form>
<div id="teleportResult" style="margin-top: 8px; font-size: 13px;"></div>
</div>
<!-- Character & Free-Roam Controls -->
<div class="card">
<h2>Free-Roam & Abilities</h2>
<div class="form-group">
<label>Playable Character</label>
<select id="charSelect">
<option value="15020967">辰宿 (Chensu - Default)</option>
<option value="15020968">塔菲 (Taffy)</option>
<option value="15020969">莉莉 (Lily)</option>
<option value="15020970">阿兰 (Alan)</option>
<option value="15020971">黛西 (Daisy)</option>
</select>
</div>
<button type="button" onclick="alert('Character switch registered. In game press T or switch team!')">Prepare Character</button>
<div style="margin-top: 16px;">
<button type="button" style="background: #238636;" onclick="alert('Diving ability unlocked natively for all characters!')">Diving Full Unlock (3D Swim)</button>
</div>
</div>
</div>
</div>
<script>
function applyPreset() {
const parts = document.getElementById("teleportPreset").value.split(",");
document.getElementById("posX").value = parts[0];
document.getElementById("posY").value = parts[1];
document.getElementById("posZ").value = parts[2];
document.getElementById("posFacing").value = parts[3];
}
async function submitTeleport() {
const x = parseFloat(document.getElementById("posX").value);
const y = parseFloat(document.getElementById("posY").value);
const z = parseFloat(document.getElementById("posZ").value);
const facing = parseFloat(document.getElementById("posFacing").value);
const res = await fetch("/api/teleport", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({x, y, z, facing})
});
const data = await res.json();
const el = document.getElementById("teleportResult");
if (data.ok) {
el.innerText = "✓ Teleported successfully to (" + x + ", " + y + ", " + z + ")";
el.style.color = "#3fb950";
} else {
el.innerText = "✗ Error: " + (data.msg || "no active game session");
el.style.color = "#f85149";
}
}
</script>
</body>
</html>`
sessions := a . gameServer . GetActiveSessions ()
data := map [ string ] interface {}{
"ActiveSessions" : len ( sessions ),
"PlayerPID" : a . cfg . Player . Pid ,
"AccountId" : a . cfg . Player . AccountId ,
"InitialSpirit" : a . cfg . Player . InitialSpiritTemplateId ,
}
t := template . Must ( template . New ( "dashboard" ). Parse ( tmpl ))
w . Header (). Set ( "Content-Type" , "text/html; charset=utf-8" )
_ = t . Execute ( w , data )
}
func ( a * AdminServer ) handleTeleport ( w http . ResponseWriter , r * http . Request ) {
if r . Method != "POST" {
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
var req struct {
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Facing float32 `json:"facing"`
}
if err := json . NewDecoder ( r . Body ). Decode ( & req ); err != nil {
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : false , "msg" : err . Error ()})
return
}
sessions := a . gameServer . GetActiveSessions ()
if len ( sessions ) == 0 {
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : false , "msg" : "No active client connected in game" })
return
}
for _ , sess := range sessions {
_ = sess . Teleport ( req . X , req . Y , req . Z , req . Facing )
}
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : true , "count" : len ( sessions )})
}
func ( a * AdminServer ) handleSessions ( w http . ResponseWriter , r * http . Request ) {
sessions := a . gameServer . GetActiveSessions ()
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{
"count" : len ( sessions ),
"host" : a . cfg . Network . AdvertisedHost ,
"port" : a . cfg . Network . GamePort ,
})
}
2026-10-29 03:53:15 +07:00
func ( a * AdminServer ) handleCommand ( w http . ResponseWriter , r * http . Request ) {
if r . Method != http . MethodPost {
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
var req struct {
Command string `json:"command"`
}
if err := json . NewDecoder ( r . Body ). Decode ( & req ); err != nil {
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : false , "msg" : err . Error ()})
return
}
bridge := a . gameServer . GetAdminBridge ()
if bridge == nil {
w . Header (). Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : false , "msg" : "Admin bridge not initialized" })
return
}
res , err := bridge . ExecuteCommand ( req . Command )
w . Header (). Set ( "Content-Type" , "application/json" )
if err != nil {
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : false , "msg" : err . Error ()})
return
}
json . NewEncoder ( w ). Encode ( map [ string ] interface {}{ "ok" : true , "result" : res })
}