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 }