Files
90CNServer/server/internal/app/dnf90/gm_tool_compat.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

473 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnf90
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
)
// 本文件注册 30CN GM 工具(GmTool90CN)需要的三个 /local admin 能力。
//
// 契约由工具端三个客户端类固定:
//
// QuestRuntimeResyncClient POST /local/quest-resync
// DungeonAdminClient GET|POST /local/dungeon-control
// CharacterCloneAdminClient GET|POST /local/character-clone
//
// 工具端会对所有 mutating 方法无条件带 X-Admin-Confirm: "METHOD path",
// 所以这里只校验 token,不额外要求 confirm 头。
const (
gmQuestResyncPath = "/local/quest-resync"
gmDungeonControlPath = "/local/dungeon-control"
gmCharacterClonePath = "/local/character-clone"
gmItemGrantPath = "/local/dnf-gm/item/grant"
gmItemGrantOptsPath = "/local/dnf-gm/item/grant-options"
gmItemEmblemsPath = "/local/dnf-gm/item/grant-with-emblems"
gmItemDeletePath = "/local/dnf-gm/item/delete"
gmInventoryClearPath = "/local/dnf-gm/inventory/clear"
gmPetUpdatePath = "/local/dnf-gm/pet/update"
gmReviveCoinPath = "/local/dnf-gm/character/revive-coin"
gmAccountCeraPath = "/local/dnf-gm/account/cera"
gmEquipmentMovePath = "/local/equipment-move"
gmLoadoutEquipmentPath = "/local/dnf-gm/loadout/equipment"
gmLoadoutApplyPath = "/local/dnf-gm/loadout/apply"
gmCompanionExpPath = "/local/dnf-gm/companion-render-experiment"
gmAuctionListingsPath = "/local/dnf-gm/auction/listings"
gmAuctionListPath = "/local/dnf-gm/auction/list"
gmAuctionCancelPath = "/local/dnf-gm/auction/cancel"
gmInventoryListPath = "/local/dnf-gm/inventory/list"
gmToolAdminTimeout = 12 * time.Second
gmToolAdminBodyLimit = 64 << 10
gmDungeonReturnAction = "return_to_town"
)
func registerDNFGMToolCompatRoutes(env *appkit.Env, bridge *dnfbridge.Service) {
if env == nil || bridge == nil || env.AdminMux == nil || env.AdminAuth == nil ||
!strings.EqualFold(env.Core().Config.Service.Environment, "local") {
return
}
env.AdminMux.HandleFunc(gmQuestResyncPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
var body struct {
CharacterID uint16 `json:"character_id"`
}
if err := decodeGMNullableJSON(r, &body); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
result, err := bridge.GMQuestResync(ctx, body.CharacterID)
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{
"character_id": result.CharacterID,
"online": result.Online,
"refreshed": result.Refreshed,
"reason": result.Reason,
})
}))
env.AdminMux.HandleFunc(gmDungeonControlPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
characterID, err := parseGMDungeonCharacterID(r.URL.Query().Get("character_id"))
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
snapshot := bridge.GMDungeonControlStateOf(ctx, characterID)
writeGMJSON(w, http.StatusOK, map[string]any{
"available": snapshot.Available,
"mode": snapshot.Mode,
"safe_actions": snapshot.SafeActions,
"blocked_actions": snapshot.BlockedActions,
"state": snapshot.State,
})
case http.MethodPost:
var body struct {
CharacterID uint16 `json:"character_id"`
Action string `json:"action"`
Confirmation string `json:"confirmation"`
}
if err := decodeGMNullableJSON(r, &body); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if strings.TrimSpace(body.Action) != gmDungeonReturnAction {
writeGMJSON(w, http.StatusBadRequest, map[string]any{
"ok": false,
"error": "unsupported action: " + body.Action,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
result, err := bridge.GMDungeonReturnToTown(ctx, body.CharacterID)
if err != nil {
writeGMJSON(w, http.StatusConflict, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{
"character_id": result.CharacterID,
"initiated": result.Initiated,
"already_pending": result.AlreadyPending,
"before": result.Before,
"after": result.After,
})
default:
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
}
}))
env.AdminMux.HandleFunc(gmCharacterClonePath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
capability := bridge.GMCharacterCloneCapabilityOf()
writeGMJSON(w, http.StatusOK, map[string]any{
"available": capability.Available,
"mode": capability.Mode,
})
case http.MethodPost:
var body struct {
SourceCharacterID uint16 `json:"source_character_id"`
Name string `json:"name"`
Confirmation string `json:"confirmation"`
}
if err := decodeGMNullableJSON(r, &body); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
result, err := bridge.GMCloneCharacter(ctx, dnfbridge.GMCharacterCloneRequest{
SourceCharacterID: strconv.FormatUint(uint64(body.SourceCharacterID), 10),
Name: body.Name,
Confirmation: body.Confirmation,
})
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{
"source_character_id": result.SourceCharacterID,
"character_id": result.CharacterID,
"account_id": result.AccountID,
"slot": result.Slot,
"name": result.Name,
"settings_count": result.SettingsCount,
"permission_count": result.PermissionCount,
"inventory_copied": result.InventoryCopied,
"equipment_copied": result.EquipmentCopied,
"skill_copied": result.SkillCopied,
"quest_copied": result.QuestCopied,
"pet_copied": result.PetCopied,
})
default:
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
}
}))
// ── 道具发放 / 删除 ────────────────────────────────────────────────────
gmToolPost(env, bridge, gmItemGrantPath,
func(ctx context.Context, body dnfbridge.GMItemGrantRequest) (any, error) {
return bridge.GMGrantItem(ctx, body)
})
gmToolPost(env, bridge, gmItemDeletePath,
func(ctx context.Context, body dnfbridge.GMItemDeleteRequestV2) (any, error) {
return bridge.GMDeleteItemV2(ctx, body)
})
gmToolPost(env, bridge, gmItemGrantOptsPath,
func(ctx context.Context, body dnfbridge.GMItemGrantOptionsRequest) (any, error) {
return bridge.GMGrantItemWithOptions(ctx, body)
})
gmToolPost(env, bridge, gmInventoryClearPath,
func(ctx context.Context, body dnfbridge.GMInventoryClearRequest) (any, error) {
return bridge.GMClearInventoryForTool(ctx, body)
})
gmToolPost(env, bridge, gmPetUpdatePath,
func(ctx context.Context, body dnfbridge.GMPetUpdateRequest) (any, error) {
return bridge.GMUpdatePet(ctx, body)
})
gmToolPost(env, bridge, gmItemEmblemsPath,
func(ctx context.Context, body dnfbridge.GMItemWithExtrasRequest) (any, error) {
return bridge.GMGrantItemWithEmblems(ctx, body)
})
// ── 复活币钱包 ────────────────────────────────────────────────────────
gmToolPost(env, bridge, gmReviveCoinPath,
func(ctx context.Context, body dnfbridge.GMReviveCoinRequest) (any, error) {
return bridge.GMAdjustReviveCoin(ctx, body)
})
// ── 账号点券 / 代币券 ─────────────────────────────────────────────────
gmToolPost(env, bridge, gmAccountCeraPath,
func(ctx context.Context, body dnfbridge.GMAccountCeraRequest) (any, error) {
return bridge.GMAdjustAccountCera(ctx, body)
})
// ── 任务状态(走服务端权威操作)────────────────────────────────────────
// 为什么不直接改 SQLite 仓储:accept 需要接取资格校验、complete 需要正确
// 应用转职转换(职业/成长类型/技能),这两件事只有服务端自己的任务模块
// 能做对。角色在线时服务端会明确拒绝。
gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/accept",
func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) {
questID, err := parseGMQuestID(pathValue("quest_id"))
if err != nil {
return nil, err
}
return bridge.GMAcceptQuest(ctx, body.CharacterID, questID)
})
gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/complete",
func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) {
questID, err := parseGMQuestID(pathValue("quest_id"))
if err != nil {
return nil, err
}
return bridge.GMForceCompleteQuest(ctx, body.CharacterID, questID)
})
gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/give-up",
func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) {
questID, err := parseGMQuestID(pathValue("quest_id"))
if err != nil {
return nil, err
}
return bridge.GMGiveUpQuest(ctx, body.CharacterID, questID)
})
gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/uncomplete",
func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) {
questID, err := parseGMQuestID(pathValue("quest_id"))
if err != nil {
return nil, err
}
return bridge.GMUncompleteQuest(ctx, body.CharacterID, questID)
})
// ── 装备穿脱(GM 工具的装备管理页)────────────────────────────────────
// 走后端 equip 模块的 Move,与游戏内 op19 同一条路径,所以部位匹配、
// 职业/成长类型门槛、装扮外观投影、双武器副手限制都自动生效。
gmToolPost(env, bridge, gmEquipmentMovePath,
func(ctx context.Context, body dnfbridge.GMEquipmentMoveRequest) (any, error) {
return bridge.GMEquipmentMove(ctx, body)
})
// Loadout snapshot/apply for the external hotkey tool.
env.AdminMux.HandleFunc(gmLoadoutEquipmentPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
snapshot, err := bridge.GMLoadoutSnapshot(ctx, r.URL.Query().Get("character_id"))
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": snapshot})
}))
gmToolPost(env, bridge, gmLoadoutApplyPath,
func(ctx context.Context, body struct {
CharacterID string `json:"character_id"`
Slots []dnfbridge.GMLoadoutSlot `json:"slots"`
}) (any, error) {
return bridge.GMLoadoutApply(ctx, body.CharacterID, body.Slots)
})
// ── 支援兵渲染可行性实验 ───────────────────────────────────────────────
// 把另一个角色当成远端 actor 下发到目标角色当前场景,观察客户端是否渲染。
// 这是决定「支援兵能否纯服务端实现」的判据,不是最终功能。
gmToolPostPath(env, "POST /local/dnf-gm/character/{character_id}/companion-render-experiment",
func(ctx context.Context, pathValue func(string) string, body struct {
CompanionCharacterID uint16 `json:"companion_character_id"`
WithBinding *bool `json:"with_binding,omitempty"`
OwnerChannel *int `json:"owner_channel,omitempty"`
}) (any, error) {
withBinding := true
if body.WithBinding != nil {
withBinding = *body.WithBinding
}
// owner_channel 省略时用频道 ID;现场实测该值与本地 actor 相同,
// 客户端会走 "重建本地角色" 分支,所以要能手工换一个值再试。
ownerChannel := -1
if body.OwnerChannel != nil {
ownerChannel = *body.OwnerChannel
}
err := bridge.GMCompanionRenderExperimentForCharacter(
ctx, pathValue("character_id"), body.CompanionCharacterID, withBinding, ownerChannel)
if err != nil {
return nil, err
}
return map[string]any{
"sent": true,
"companion_character": body.CompanionCharacterID,
"with_binding": withBinding,
"owner_channel": ownerChannel,
"note": "包已发出;是否渲染请看客户端画面",
}, nil
})
// ── 拍卖行(GM 工具的上/下架)────────────────────────────────────────
// 服务端拍卖行没有面向工具的 GM 能力,这里直接调用 dnfbridge 的
// GMAuction* 方法(内部走 dnfauction.Owner 的纯数据层 Register/Cancel/
// 遍历 escrow,不依赖玩家 session),供服务端在线时上架/下架。
env.AdminMux.HandleFunc(gmAuctionListingsPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
result, err := bridge.GMAuctionListings(ctx)
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
}))
gmToolPost(env, bridge, gmAuctionListPath,
func(ctx context.Context, body dnfbridge.GMAuctionListRequest) (any, error) {
return bridge.GMAuctionList(ctx, body)
})
gmToolPost(env, bridge, gmAuctionCancelPath,
func(ctx context.Context, body dnfbridge.GMAuctionCancelRequest) (any, error) {
return bridge.GMAuctionCancel(ctx, body)
})
// ── 背包管理(列全部道具 + 删除,GM 工具背包页)─────────────────────
env.AdminMux.HandleFunc(gmInventoryListPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
result, err := bridge.GMInventoryList(ctx, r.URL.Query().Get("character_id"))
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
}))
}
// parseGMQuestID 解析任务路由里的 quest_id(正 int64)。
func parseGMQuestID(raw string) (int64, error) {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value <= 0 {
return 0, fmt.Errorf("%w: quest_id must be a positive integer", dnfbridge.ErrGMRequestInvalid)
}
return value, nil
}
// gmToolPost 是这几条新增写接口的公共外壳:只接受 POST、解析 body、
// 统一把成功/失败包成 {"ok":bool,...}。
func gmToolPost[T any](
env *appkit.Env,
bridge *dnfbridge.Service,
path string,
handle func(ctx context.Context, body T) (any, error),
) {
env.AdminMux.HandleFunc(path, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
var body T
if err := decodeGMNullableJSON(r, &body); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
data, err := handle(ctx, body)
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}))
}
// gmToolPostPath 与 gmToolPost 相同,但接受完整路由模板(含 {quest_id} 这类通配),
// 并把路径参数交给 handle。GM 工具的任务操作需要 quest_id 在 URL 里。
func gmToolPostPath[T any](
env *appkit.Env,
pattern string,
handle func(ctx context.Context, pathValue func(string) string, body T) (any, error),
) {
env.AdminMux.HandleFunc(pattern, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
var body T
if err := decodeGMNullableJSON(r, &body); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout)
defer cancel()
data, err := handle(ctx, r.PathValue, body)
if err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
}))
}
// gmQuestAdminBody 是任务操作请求体:只带 character_id,quest_id 在路径里。
type gmQuestAdminBody struct {
CharacterID string `json:"character_id"`
}
// decodeGMNullableJSON 解析一个可选请求体;空体不算错误(工具端 probe 会发空 POST)。
func decodeGMNullableJSON(r *http.Request, target any) error {
if r == nil || r.Body == nil {
return nil
}
decoder := json.NewDecoder(io.LimitReader(r.Body, gmToolAdminBodyLimit))
if err := decoder.Decode(target); err != nil {
if err == io.EOF {
return nil
}
return err
}
return nil
}
func parseGMDungeonCharacterID(raw string) (uint16, error) {
value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 16)
if err != nil || value == 0 {
return 0, errors.New("character_id must be a uint16")
}
return uint16(value), nil
}