DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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