Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

253 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}