DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+472
View File
@@ -0,0 +1,472 @@
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
}