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