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