服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
450 lines
16 KiB
Go
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()})
|
|
}
|