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