Files
90CNServer/server/internal/app/dnf90/gm_robot.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

450 lines
16 KiB
Go

package dnf90
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
"longheng.io/server/internal/services/dnfrobot"
)
const (
gmRobotsPath = "/local/dnf-gm/robots"
gmRobotStatusPath = "/local/dnf-gm/robots/status"
gmRobotBatchPath = "/local/dnf-gm/robots/batch"
gmRobotOperationsPath = "/local/dnf-gm/robots/operations"
gmRobotProfilePath = "/local/dnf-gm/robots/profile"
gmRobotProvisionPath = "/local/dnf-gm/robots/provision"
gmRobotProvisionBatchPath = "/local/dnf-gm/robots/provision/batch"
gmRobotStockPath = "/local/dnf-gm/robots/stock"
gmRobotAutomationPath = "/local/dnf-gm/robots/automation"
gmRobotStartPath = "/local/dnf-gm/robots/start"
gmRobotStopPath = "/local/dnf-gm/robots/stop"
gmRobotMovePath = "/local/dnf-gm/robots/move"
gmRobotChatPath = "/local/dnf-gm/robots/chat"
gmRobotStoreOpenPath = "/local/dnf-gm/robots/store/open"
gmRobotStoreClosePath = "/local/dnf-gm/robots/store/close"
)
const gmRobotMaximumProvisionBatch = 64
type gmRobotProvisionBatchRequest struct {
Count int `json:"count"`
StartIndex int `json:"start_index"`
AccountPrefix string `json:"account_prefix"`
NamePrefix string `json:"name_prefix"`
ProfilePrefix string `json:"profile_prefix"`
ChannelID int `json:"channel_id"`
AutoStart bool `json:"auto_start"`
Job int `json:"job"`
GrowType int `json:"grow_type"`
Level int `json:"level"`
Village int `json:"village"`
Area int `json:"area"`
X int `json:"x"`
Y int `json:"y"`
}
type gmRobotProvisionBatchRow struct {
Index int `json:"index"`
ProfileID string `json:"profile_id"`
AccountID string `json:"account_id"`
Name string `json:"name"`
CharacterID uint16 `json:"character_id,omitempty"`
Configured bool `json:"configured"`
Running bool `json:"running"`
Error string `json:"error,omitempty"`
}
type gmRobotProvisionBatchResult struct {
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Rows []gmRobotProvisionBatchRow `json:"rows"`
}
func registerDNFRobotGMRoutes(env *appkit.Env, bridge *dnfbridge.Service, manager *dnfrobot.Manager) {
if env == nil || env.AdminMux == nil || env.AdminAuth == nil || bridge == nil || manager == nil {
return
}
auth := func(handler http.HandlerFunc) http.HandlerFunc { return env.AdminAuth("dnf-gm", handler) }
env.AdminMux.HandleFunc(gmRobotsPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.List()})
}))
env.AdminMux.HandleFunc(gmRobotStatusPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Status()})
}))
env.AdminMux.HandleFunc(gmRobotBatchPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfrobot.BatchRequest
if !decodeGMRequest(w, r, &request, 64<<10) {
return
}
result, err := manager.Batch(r.Context(), request)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot batch action completed", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotOperationsPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Operations()})
}))
env.AdminMux.HandleFunc(gmRobotProvisionPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMRobotCharacterProvisionRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMProvisionRobotCharacter(r.Context(), request)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot character provisioned", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotProvisionBatchPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request gmRobotProvisionBatchRequest
if !decodeGMRequest(w, r, &request, 32<<10) {
return
}
request, err := normalizeGMRobotProvisionBatchRequest(request)
if err != nil {
writeGMRobotError(w, err)
return
}
operation := manager.BeginOperation("provision", fmt.Sprintf("%s count=%d start=%d", request.ProfilePrefix, request.Count, request.StartIndex))
result := provisionGMRobotBatch(r.Context(), bridge, manager, request)
manager.CompleteOperation(operation.ID, fmt.Sprintf("requested=%d succeeded=%d failed=%d", result.Requested, result.Succeeded, result.Failed), nil)
writeGMJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": fmt.Sprintf("robot batch provision completed: %d succeeded, %d failed", result.Succeeded, result.Failed),
"data": result,
})
}))
env.AdminMux.HandleFunc(gmRobotStockPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Items []dnfbridge.GMRobotStockItem `json:"items"`
}
if !decodeGMRequest(w, r, &request, 32<<10) {
return
}
snapshot, err := manager.Snapshot(request.ID)
if err != nil {
writeGMRobotError(w, err)
return
}
operation := manager.BeginOperation("stock", snapshot.Profile.ID)
result, err := bridge.GMStockRobotCharacter(r.Context(), dnfbridge.GMRobotStockRequest{
AccountID: snapshot.Profile.AccountID, CharacterID: snapshot.Profile.CharacterID, Items: request.Items,
})
if err != nil {
manager.CompleteOperation(operation.ID, "", err)
writeGMRobotError(w, err)
return
}
manager.CompleteOperation(operation.ID, fmt.Sprintf("items=%d slots=%d", len(result.Items), len(result.Slots)), nil)
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private-store stock committed", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotAutomationPath, auth(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Automation()})
case http.MethodPost:
var policy dnfrobotdomain.AutomationPolicy
if !decodeGMRequest(w, r, &policy, 64<<10) {
return
}
result, err := manager.UpdateAutomation(r.Context(), policy)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot automation saved", "data": result})
default:
gmMethodNotAllowed(w, "GET, POST")
}
}))
env.AdminMux.HandleFunc(gmRobotProfilePath, auth(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var profile dnfrobotdomain.Profile
if !decodeGMRequest(w, r, &profile, 16<<10) {
return
}
result, err := manager.Upsert(r.Context(), profile)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot profile saved", "data": result})
case http.MethodDelete:
if err := manager.Delete(r.Context(), strings.TrimSpace(r.URL.Query().Get("id"))); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot profile deleted"})
default:
gmMethodNotAllowed(w, "POST, DELETE")
}
}))
env.AdminMux.HandleFunc(gmRobotStartPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
result, err := manager.StartProfile(id)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot start requested", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotStopPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
result, err := manager.StopProfile(id)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot stopped", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotMovePath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Village uint8 `json:"village"`
Area uint8 `json:"area"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
MoveType uint8 `json:"move_type"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.Move(request.ID, request.Village, request.Area, request.X, request.Y, request.MoveType); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot move packet sent"})
}))
env.AdminMux.HandleFunc(gmRobotChatPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Mode uint8 `json:"mode"`
Message string `json:"message"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.Chat(request.ID, request.Mode, request.Message); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot chat packet sent"})
}))
env.AdminMux.HandleFunc(gmRobotStoreOpenPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Title string `json:"title"`
Maximum int `json:"maximum_rows"`
UnitPrice uint32 `json:"unit_price"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.OpenPrivateStore(r.Context(), request.ID, request.Title, request.Maximum, request.UnitPrice); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private store opening requested"})
}))
env.AdminMux.HandleFunc(gmRobotStoreClosePath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
if err := manager.ClosePrivateStore(id); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private store close requested"})
}))
}
func normalizeGMRobotProvisionBatchRequest(request gmRobotProvisionBatchRequest) (gmRobotProvisionBatchRequest, error) {
request.AccountPrefix = strings.TrimSpace(request.AccountPrefix)
request.NamePrefix = strings.TrimSpace(request.NamePrefix)
request.ProfilePrefix = strings.TrimSpace(request.ProfilePrefix)
if request.Count < 1 || request.Count > gmRobotMaximumProvisionBatch || request.StartIndex < 0 || request.StartIndex > 999999 ||
request.AccountPrefix == "" || request.NamePrefix == "" || request.ProfilePrefix == "" || request.ChannelID < 1 || request.ChannelID > 65535 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: invalid robot provision batch", dnfbridge.ErrGMRequestInvalid)
}
if request.StartIndex == 0 {
request.StartIndex = 1
}
last := request.StartIndex + request.Count - 1
if last > 999999 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: robot provision batch index exceeds 999999", dnfbridge.ErrGMRequestInvalid)
}
accountID := fmt.Sprintf("%s-%03d", request.AccountPrefix, last)
profileID := fmt.Sprintf("%s-%03d", request.ProfilePrefix, last)
if len(accountID) > 96 || len(profileID) > 64 || strings.IndexByte(accountID, 0) >= 0 || strings.IndexByte(profileID, 0) >= 0 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: generated robot account/profile id is too long", dnfbridge.ErrGMRequestInvalid)
}
return request, nil
}
func provisionGMRobotBatch(ctx context.Context, bridge *dnfbridge.Service, manager *dnfrobot.Manager, request gmRobotProvisionBatchRequest) gmRobotProvisionBatchResult {
result := gmRobotProvisionBatchResult{Requested: request.Count, Rows: make([]gmRobotProvisionBatchRow, 0, request.Count)}
for offset := 0; offset < request.Count; offset++ {
index := request.StartIndex + offset
row := gmRobotProvisionBatchRow{
Index: index,
ProfileID: fmt.Sprintf("%s-%03d", request.ProfilePrefix, index),
AccountID: fmt.Sprintf("%s-%03d", request.AccountPrefix, index),
Name: fmt.Sprintf("%s%d", request.NamePrefix, index),
}
if ctx != nil && ctx.Err() != nil {
row.Error = ctx.Err().Error()
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
provisioned, err := bridge.GMProvisionRobotCharacter(ctx, dnfbridge.GMRobotCharacterProvisionRequest{
AccountID: row.AccountID,
Name: row.Name,
Job: request.Job,
GrowType: request.GrowType,
Level: request.Level,
Village: request.Village,
Area: request.Area,
X: request.X,
Y: request.Y,
})
if err != nil {
row.Error = err.Error()
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
row.CharacterID = provisioned.CharacterID
runtime, err := manager.Upsert(ctx, dnfrobotdomain.Profile{
ID: row.ProfileID,
AccountID: row.AccountID,
CharacterID: provisioned.CharacterID,
ChannelID: request.ChannelID,
AutoStart: request.AutoStart,
Village: uint8(request.Village),
Area: uint8(request.Area),
X: uint16(request.X),
Y: uint16(request.Y),
})
if err != nil {
row.Error = fmt.Sprintf("character created but profile save failed: %v", err)
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
row.Configured = true
row.Running = runtime.Running
result.Succeeded++
result.Rows = append(result.Rows, row)
}
return result
}
func decodeRobotID(w http.ResponseWriter, r *http.Request) (string, bool) {
var request struct {
ID string `json:"id"`
}
if !decodeGMRequest(w, r, &request, 4096) {
return "", false
}
request.ID = strings.TrimSpace(request.ID)
if request.ID == "" {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "robot profile id is required"})
return "", false
}
return request.ID, true
}
func writeGMRobotError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, dnfbridge.ErrGMRequestInvalid):
status = http.StatusBadRequest
case errors.Is(err, dnfbridge.ErrGMRepositoryUnavailable):
status = http.StatusServiceUnavailable
case errors.Is(err, dnfrobotdomain.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, dnfrobot.ErrProfileNotFound):
status = http.StatusNotFound
case errors.Is(err, dnfrobotdomain.ErrUnavailable), errors.Is(err, dnfrobot.ErrManagerStopped):
status = http.StatusServiceUnavailable
case errors.Is(err, dnfrobot.ErrNotReady):
status = http.StatusConflict
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
status = http.StatusGatewayTimeout
}
writeGMJSON(w, status, map[string]any{"ok": false, "error": err.Error()})
}