Files
90CNServer/server/internal/services/dnfbridge/gm_tool_bridge.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

270 lines
10 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 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
}