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