package dnfbridge import ( "context" "fmt" "strings" "time" dnfcharacterdata "longheng.io/server/internal/modules/dnf/characterdata" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // 本文件实现 /local/character-clone:把一个已有角色的完整持久化状态复制成 // 同账号下的一个新角色(新的 character_id、新的角色名、空闲槽位)。 // // 契约来自 GmTool90CN 的 CharacterCloneAdminClient: // // GET → {"available":bool,"mode":string} // POST → {"source_character_id","character_id","account_id","slot","name", // "settings_count","permission_count","inventory_copied","equipment_copied", // "skill_copied","quest_copied","pet_copied"} // // 只做"复制",不改源角色。子记录的 Clone* 与 character_id 改绑由 // characterdata.normalizeCreation 统一完成,本文件不需要手工改子记录 ID。 // GMCharacterCloneCapability 对应 GET /local/character-clone。 type GMCharacterCloneCapability struct { Available bool `json:"available"` Mode string `json:"mode"` } // GMCharacterCloneResult 对应 POST /local/character-clone。 type GMCharacterCloneResult struct { SourceCharacterID uint16 `json:"source_character_id"` CharacterID uint16 `json:"character_id"` AccountID string `json:"account_id"` Slot int `json:"slot"` Name string `json:"name"` SettingsCount int `json:"settings_count"` PermissionCount int `json:"permission_count"` InventoryCopied bool `json:"inventory_copied"` EquipmentCopied bool `json:"equipment_copied"` SkillCopied bool `json:"skill_copied"` QuestCopied bool `json:"quest_copied"` PetCopied bool `json:"pet_copied"` } // GMCharacterCloneRequest 是工具端 POST 体的服务端视图。 type GMCharacterCloneRequest struct { SourceCharacterID string Name string Confirmation string } const ( gmCharacterCloneMode = "current_exe_character_clone" gmCharacterCloneConfirmation = "CLONE_CHARACTER" ) // GMCharacterCloneCapabilityOf 报告本服务端是否具备角色克隆能力。 func (s *Service) GMCharacterCloneCapabilityOf() GMCharacterCloneCapability { if s == nil { return GMCharacterCloneCapability{Mode: gmCharacterCloneMode} } repositories, available := s.repositoryGroup() if !available || repositories.Character == nil || repositories.CharacterCreate == nil { return GMCharacterCloneCapability{Mode: gmCharacterCloneMode} } return GMCharacterCloneCapability{Available: true, Mode: gmCharacterCloneMode} } // GMCloneCharacter 执行一次角色克隆。 // // 全过程在同一把 characterCreateMu 下进行,与原生创建角色互斥, // 避免并发请求抢到同一个 character_id 或同一个槽位。 func (s *Service) GMCloneCharacter(ctx context.Context, request GMCharacterCloneRequest) (GMCharacterCloneResult, error) { result := GMCharacterCloneResult{} if s == nil { return result, ErrGMRepositoryUnavailable } if ctx == nil { ctx = context.Background() } sourceID, err := parseGMCharacterID(request.SourceCharacterID) if err != nil { return result, err } result.SourceCharacterID = sourceID name := strings.TrimSpace(request.Name) if name == "" { return result, fmt.Errorf("%w: character name is required", ErrGMRequestInvalid) } if err := dnfcharacterdata.ValidateCharacterName(name); err != nil { return result, fmt.Errorf("%w: %v", ErrGMRequestInvalid, err) } // 破坏性操作必须带显式确认串,避免误触。 if strings.TrimSpace(request.Confirmation) != gmCharacterCloneConfirmation { return result, fmt.Errorf("%w: confirmation must be %s", ErrGMRequestInvalid, gmCharacterCloneConfirmation) } ctx, cancel := context.WithTimeout(ctx, createWriteTimeout) defer cancel() s.characterCreateMu.Lock() defer s.characterCreateMu.Unlock() repositories, available := s.repositoryGroup() if !available || repositories.Character == nil || repositories.CharacterCreate == nil { return result, ErrGMRepositoryUnavailable } sourceIDText := fmt.Sprintf("%d", sourceID) source, found, err := repositories.Character.Load(ctx, sourceIDText) if err != nil { return result, err } if !found { return result, fmt.Errorf("%w: source character not found", ErrGMRequestInvalid) } result.AccountID = source.AccountID existing, slotLimit, err := s.listCharactersWithLimit(ctx, repositories, source.AccountID) if err != nil { return result, err } if len(existing) >= slotLimit { return result, fmt.Errorf("%w: account character slots are full", ErrGMRequestInvalid) } duplicated, err := s.characterNameExists(ctx, repositories, existing, name) if err != nil { return result, err } if duplicated { return result, fmt.Errorf("%w: character name already exists", ErrGMRequestInvalid) } newID, err := s.nextCharacterID(ctx, repositories, existing) if err != nil { return result, err } if newID <= 0 || newID > upperMaxCharacter { return result, fmt.Errorf("%w: character id %d exceeds current protocol", ErrGMRequestInvalid, newID) } slot := nextCharacterSlot(existing, slotLimit) if slot < 0 { return result, fmt.Errorf("%w: account character slots are full", ErrGMRequestInvalid) } newIDText := fmt.Sprintf("%d", newID) now := time.Now().UTC() // 角色本体:保留源角色的职业/等级/属性/外观名单,只换身份与位置。 clone := dnfrepo.CloneCharacter(source) clone.CharacterID = newIDText clone.Name = name clone.Slot = slot clone.CreatedAt = now clone.UpdatedAt = now // 新角色从城镇起步,不继承源角色的副本内位置。 clone.Location = dnfrepo.CharacterLocation{} creation := dnfcharacterdata.Creation{Character: clone} // 子记录:读取源角色记录后**必须立即改绑到新角色 ID**。 // // characterdata.normalizeCreation 里的 normalizeChildID 只做"校验 + 填空": // 子记录 ID 非空且与目标角色不同时直接报 ErrChildCharacterMismatch,它不会 // 替调用方改绑。所以改绑必须在读取处完成。 if record, ok, loadErr := gmCloneLoadChild(ctx, repositories.Inventory, sourceIDText, newIDText, func(r *dnfrepo.InventoryRecord) { r.CharacterID = newIDText }); loadErr != nil { return result, loadErr } else if ok { creation.Inventory = record } if record, ok, loadErr := gmCloneLoadChild(ctx, repositories.Equipment, sourceIDText, newIDText, func(r *dnfrepo.EquipmentRecord) { r.CharacterID = newIDText }); loadErr != nil { return result, loadErr } else if ok { creation.Equipment = record } if record, ok, loadErr := gmCloneLoadChild(ctx, repositories.Skill, sourceIDText, newIDText, func(r *dnfrepo.SkillRecord) { r.CharacterID = newIDText }); loadErr != nil { return result, loadErr } else if ok { creation.Skill = record } if record, ok, loadErr := gmCloneLoadChild(ctx, repositories.Quest, sourceIDText, newIDText, func(r *dnfrepo.QuestRecord) { r.CharacterID = newIDText }); loadErr != nil { return result, loadErr } else if ok { creation.Quest = record } if record, ok, loadErr := gmCloneLoadChild(ctx, repositories.Pet, sourceIDText, newIDText, func(r *dnfrepo.PetRecord) { r.CharacterID = newIDText }); loadErr != nil { return result, loadErr } else if ok { creation.Pet = record } // 设置记录(快捷键、容器状态、选项快照等)由各子系统用自己的 scope 约定写入, // 仓储层并没有"角色设置总 scope"这样的公开函数。克隆时**不复制设置**: // 复制过来的 scope 仍指向源角色,等于造出一批无主记录;让新角色按原生流程 // 首次进入时生成自己的默认设置更安全。工具端只展示数量,这里如实报 0。 // 一次性落库:复用原生创建角色的事务边界,避免半成品角色出现在选择器里。 if err := dnfcharacterdata.NewCreator(repositories).Create(ctx, creation); err != nil { return result, fmt.Errorf("persist cloned character: %w", err) } result.CharacterID = uint16(newID) result.Slot = slot result.Name = name result.InventoryCopied = creation.Inventory != nil result.EquipmentCopied = creation.Equipment != nil result.SkillCopied = creation.Skill != nil result.QuestCopied = creation.Quest != nil result.PetCopied = creation.Pet != nil result.SettingsCount = len(creation.Settings) // 副本权限按账号维度记录,不随角色复制;仅统计可用性供工具端展示。 if repositories.DungeonPermission != nil { if _, permFound, permErr := repositories.DungeonPermission.Load(ctx, source.AccountID); permErr == nil && permFound { result.PermissionCount = 1 } } return result, nil } // gmCloneLoadChild 读取一个源角色的子记录,并把它的 CharacterID 改绑到新角色。 // // repo 为 nil 时报错;源记录不存在时 ok=false。 // 改绑通过 setID 回调完成,避免在泛型里对任意记录类型做字段访问。 func gmCloneLoadChild[T any]( ctx context.Context, repo interface { Load(context.Context, string) (T, bool, error) }, sourceCharacterID string, targetCharacterID string, setID func(*T), ) (*T, bool, error) { if repo == nil { return nil, false, fmt.Errorf("%w: child repository unavailable", ErrGMRepositoryUnavailable) } record, found, err := repo.Load(ctx, sourceCharacterID) if err != nil { return nil, false, err } if !found { return nil, false, nil } setID(&record) return &record, true, nil }