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

@@ -0,0 +1,269 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// 本文件实现 30CN GM 工具(GmTool90CN)需要的三个 admin 能力:
//
// GET/POST /local/character-clone 角色克隆(见 gm_tool_clone.go)
// GET/POST /local/dungeon-control 副本状态查询 / 安全回城
// POST /local/quest-resync 任务状态落库后的在线投影刷新
//
// 契约来自 GmTool90CN 的 QuestRuntimeResyncClient / DungeonAdminClient,
// JSON 字段名必须逐一对齐,否则工具端会把结果判成失败。
var (
// ErrGMDungeonControlUnavailable 表示该角色当前没有可控制的在线副本会话。
ErrGMDungeonControlUnavailable = errors.New("gm dungeon control unavailable")
// ErrGMDungeonReturnNotAllowed 表示当前副本阶段不允许安全回城。
ErrGMDungeonReturnNotAllowed = errors.New("gm dungeon return to town not allowed")
)
// GMQuestResyncResult 对应 /local/quest-resync 的响应体。
type GMQuestResyncResult struct {
CharacterID uint16 `json:"character_id"`
Online bool `json:"online"`
Refreshed bool `json:"refreshed"`
Reason string `json:"reason,omitempty"`
}
// GMQuestResync 把角色已落库的任务状态重新投影给在线客户端。
//
// 工具端语义是 best-effort:离线角色返回 online=false 不算错误,
// 只有"在线但没刷新成功"才会被工具标记为问题。
func (s *Service) GMQuestResync(ctx context.Context, characterID uint16) (GMQuestResyncResult, error) {
result := GMQuestResyncResult{CharacterID: characterID}
if s == nil || characterID == 0 {
return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid)
}
if ctx == nil {
ctx = context.Background()
}
session, online := s.onlineGameSession(characterID)
result.Online = online
if !online {
result.Reason = "character_offline"
return result, nil
}
// 复用礼包/任务写路径已经证明过的"落库后重投影"出口:它依次重发
// active quest(op574)、completed clear-quest list(op356) 与 acceptable 列表,
// 与客户端状态机同序。
if err := s.callGameSession(ctx, session, "gm_admin_quest_resync", func() error {
if session.selectedCharacterID != characterID {
return errors.New("selected character changed before quest projection refresh")
}
return s.sendGMPersistedQuestPostActionState(session, "gm_admin_quest_resync")
}); err != nil {
return result, err
}
result.Refreshed = true
return result, nil
}
// GMDungeonControlState 是工具端 DungeonAdminState 的服务端来源。
type GMDungeonControlState struct {
CharacterID uint16 `json:"character_id"`
Online bool `json:"online"`
InDungeon bool `json:"in_dungeon"`
SessionGeneration uint64 `json:"session_generation"`
DungeonID int64 `json:"dungeon_id"`
DungeonPath string `json:"dungeon_path"`
MazeIndex int `json:"maze_index"`
RunStatus string `json:"run_status"`
CurrentRoom string `json:"current_room"`
MapID int64 `json:"map_id"`
BossRoom bool `json:"boss_room"`
RoomCleared bool `json:"room_cleared"`
VisitedRoomCount int `json:"visited_room_count"`
ClearedRoomCount int `json:"cleared_room_count"`
ExpectedHostileCount int `json:"expected_hostile_count"`
BlockingHostileCount int `json:"blocking_hostile_count"`
DefeatedObjectCount int `json:"defeated_object_count"`
SettlementPhase string `json:"settlement_phase"`
TownReturnPending bool `json:"town_return_pending"`
TownReturnOp24Sent bool `json:"town_return_op24_sent"`
ClearMapPersisted bool `json:"clear_map_persisted"`
CardRewardCommitted bool `json:"card_reward_committed"`
CanReturnToTown bool `json:"can_return_to_town"`
}
// GMDungeonControlSnapshot 对应 /local/dungeon-control 的 GET 响应体。
type GMDungeonControlSnapshot struct {
Available bool `json:"available"`
Mode string `json:"mode"`
SafeActions []string `json:"safe_actions"`
BlockedActions map[string]string `json:"blocked_actions"`
State GMDungeonControlState `json:"state"`
}
// GMDungeonReturnResult 对应 /local/dungeon-control POST 的响应体。
type GMDungeonReturnResult struct {
CharacterID uint16 `json:"character_id"`
Initiated bool `json:"initiated"`
AlreadyPending bool `json:"already_pending"`
Before GMDungeonControlState `json:"before"`
After GMDungeonControlState `json:"after"`
}
const gmDungeonSafeActionReturnToTown = "return_to_town"
// GMDungeonControlStateOf 组装一个角色的副本控制快照。
//
// controller 侧只做只读观测;离线或不在副本时返回对应的零值状态并把
// return_to_town 列进 blocked_actions,而不是报错。
func (s *Service) GMDungeonControlStateOf(ctx context.Context, characterID uint16) GMDungeonControlSnapshot {
snapshot := GMDungeonControlSnapshot{
Available: true,
Mode: "current_exe_dungeon_control",
SafeActions: []string{},
BlockedActions: map[string]string{},
State: GMDungeonControlState{CharacterID: characterID},
}
if s == nil || characterID == 0 {
snapshot.BlockedActions[gmDungeonSafeActionReturnToTown] = "character_id_required"
return snapshot
}
session, online := s.onlineGameSession(characterID)
state := GMDungeonControlState{CharacterID: characterID, Online: online}
if !online {
state.RunStatus = gmDungeonRunStatus(state)
snapshot.State = state
snapshot.BlockedActions[gmDungeonSafeActionReturnToTown] = "character_offline"
return snapshot
}
state.SessionGeneration = session.characterGeneration
scene, inDungeon := debugDungeonSceneSnapshot(session)
if !inDungeon {
state.RunStatus = gmDungeonRunStatus(state)
snapshot.State = state
snapshot.BlockedActions[gmDungeonSafeActionReturnToTown] = "not_in_dungeon"
return snapshot
}
state.InDungeon = true
state.DungeonID = scene.dungeonID
state.MapID = scene.mapID
state.CurrentRoom = fmt.Sprintf("%d:%d", scene.x, scene.y)
state.RoomCleared = scene.cleared
state.DefeatedObjectCount = debugDefeatedHostileCount(session)
if announced := debugAnnouncedHostileKeys(session); len(announced) > 0 {
state.BlockingHostileCount = len(announced)
}
state.ExpectedHostileCount = state.BlockingHostileCount + state.DefeatedObjectCount
// 运行期细节只在持锁下读,避免与事件循环并发读写同一 runtime。
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime != nil {
state.MazeIndex = runtime.MazeIndex
state.DungeonPath = strings.TrimSpace(runtime.Dungeon.Path)
state.SettlementPhase = runtime.settlementPhase.String()
state.TownReturnPending = runtime.townReturnPending
state.TownReturnOp24Sent = runtime.townReturnOp24Sent
state.ClearMapPersisted = runtime.clearMapCompletionPhaseAPersisted
state.CardRewardCommitted = runtime.settlementCardRewardCommitted
if runtime.Rooms != nil {
state.VisitedRoomCount = len(runtime.Rooms)
}
}
session.dungeon.mu.Unlock()
state.RunStatus = gmDungeonRunStatus(state)
// CanReturnToTown 与工具端"安全动作"一致:已在副本且还没有 pending 回城。
state.CanReturnToTown = !state.TownReturnPending
if state.CanReturnToTown {
snapshot.SafeActions = append(snapshot.SafeActions, gmDungeonSafeActionReturnToTown)
} else {
snapshot.BlockedActions[gmDungeonSafeActionReturnToTown] = "town_return_already_pending"
}
snapshot.State = state
return snapshot
}
// GMDungeonReturnToTown 请求把当前副本角色安全送回城镇。
//
// 复用运行时已有的"中断型回城"出口(死亡回城/遗忘之地计时器用的是同一条),
// 因此不需要发明新的包序列。已 pending 时返回 already_pending=true 且不重复触发。
func (s *Service) GMDungeonReturnToTown(ctx context.Context, characterID uint16) (GMDungeonReturnResult, error) {
result := GMDungeonReturnResult{CharacterID: characterID}
if s == nil || characterID == 0 {
return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid)
}
if ctx == nil {
ctx = context.Background()
}
session, online := s.onlineGameSession(characterID)
if !online {
return result, fmt.Errorf("%w: character_offline", ErrGMDungeonControlUnavailable)
}
before := s.GMDungeonControlStateOf(ctx, characterID)
result.Before = before.State
if !before.State.InDungeon {
return result, fmt.Errorf("%w: not_in_dungeon", ErrGMDungeonControlUnavailable)
}
if before.State.TownReturnPending {
result.AlreadyPending = true
result.After = before.State
return result, nil
}
// 回城过渡行必须按角色仓储里的城镇位置重建,等价于客户端自己发起的回城。
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, characterID)
if err != nil {
return result, err
}
if err := s.callGameSession(ctx, session, "gm_admin_dungeon_return_to_town", func() error {
if session.selectedCharacterID != characterID {
return errors.New("selected character changed before dungeon return")
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
session.dungeon.mu.Unlock()
if runtime == nil || runtime.Session == nil {
return fmt.Errorf("%w: runtime_generation_replaced", ErrGMDungeonControlUnavailable)
}
return s.sendCurrentActiveDungeonReturnToTown(
session,
runtime,
transition,
uint16(dnfenum.CmdPacketDieCharacter),
"gm_admin_dungeon_control_return_to_town",
)
}); err != nil {
return result, err
}
result.Initiated = true
result.After = s.GMDungeonControlStateOf(ctx, characterID).State
return result, nil
}
func gmDungeonRunStatus(state GMDungeonControlState) string {
switch {
case !state.Online:
return "offline"
case !state.InDungeon:
return "in_town"
case state.SettlementPhase != "" && state.SettlementPhase != "none":
return "settlement:" + state.SettlementPhase
case state.RoomCleared:
return "room_cleared"
default:
return "in_progress"
}
}
// parseGMCharacterID 解析工具端传来的 uint16 角色 ID。
func parseGMCharacterID(raw string) (uint16, error) {
value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 16)
if err != nil || value == 0 {
return 0, fmt.Errorf("%w: character_id must be a uint16", ErrGMRequestInvalid)
}
return uint16(value), nil
}