352 lines
11 KiB
Go
352 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net"
|
|
"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)
|
|
mux.HandleFunc("/api/command", a.handleCommand)
|
|
mux.HandleFunc("/api/sessions", a.handleSessions)
|
|
|
|
host := a.cfg.Admin.Host
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
addr := net.JoinHostPort(host, fmt.Sprint(a.cfg.Admin.Port))
|
|
a.httpSrv = &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
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.Serve(ln); 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,
|
|
})
|
|
}
|
|
|
|
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})
|
|
}
|