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,480 @@
package dnfbridge
import (
"context"
"fmt"
"strings"
"time"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfsocketemblem "longheng.io/server/internal/modules/dnf/socketemblem"
)
// 本文件实现 GM 发放时的 **附魔宝珠** 与 **时装/装备徽章**。
//
// 为什么不能只写 Extra
// 徽章不是普通的 Extra 键值:它走 socketemblem 的
// data[0]=已开孔数 + data[1..]=各孔徽章 ID 的向量,而且镶嵌时
// currentApplyEquipmentEmblems 会**从背包消耗**对应徽章行。
// 所以发放带徽章的装备必须复用那套投影逻辑,不能自己拼 key。
//
// 附魔宝珠相对简单:权威写法是 inventory.setEnchantFields ——
// Extra["value_a"] / ["enchant_card_id"] = 宝珠 item_id
// Extra["byte_12"] / ["enchant_upgrade_count"] = 升级次数
// 其中 value_a 会被 currentItemListEntryFromStack 写进 raw[0x0E:0x12]。
const (
gmEmblemListMain byte = currentSocketListMain
gmEmblemListAvatar byte = currentSocketListAvatar
gmEmblemMaxSockets = 4
)
// GMEmblemRequest 是请求里的一枚徽章。
//
// SocketIndex 省略(<0)时按出现顺序自动分配 0,1,2,…;
// EmblemSlot 省略(<0)时按 EmblemItemID 在主背包里找第一行并消耗 1 个。
type GMEmblemRequest struct {
SocketIndex *int `json:"socket_index,omitempty"`
EmblemItemID uint32 `json:"emblem_item_id"`
EmblemSlot *int `json:"emblem_slot,omitempty"`
}
// GMItemWithExtrasRequest 对应 POST /local/dnf-gm/item/grant-with-emblems。
type GMItemWithExtrasRequest struct {
CharacterID string `json:"character_id"`
ItemID uint32 `json:"item_id"`
// 附魔宝珠:直接落到目标装备上(不消耗背包里的宝珠行)。
EnchantBeadItemID uint32 `json:"enchant_bead_item_id,omitempty"`
EnchantUpgrade *int `json:"enchant_upgrade_count,omitempty"`
// 徽章
Emblems []GMEmblemRequest `json:"emblems,omitempty"`
// 已开孔数;0 表示按徽章数量自动开孔(上限由装备部位决定)。
OpenSockets *int `json:"open_sockets,omitempty"`
}
// GMItemWithExtrasResult 描述带附魔/徽章发放的结果。
type GMItemWithExtrasResult struct {
CharacterID string `json:"character_id"`
ItemID uint32 `json:"item_id"`
ListType byte `json:"list_type"`
Slot int `json:"slot"`
EntryKey string `json:"entry_key"`
ItemName string `json:"item_name,omitempty"`
IsAvatar bool `json:"is_avatar"`
EnchantCardID int64 `json:"enchant_card_id,omitempty"`
OpenSockets int `json:"open_sockets"`
Sockets []int64 `json:"sockets,omitempty"`
ConsumedEmblems []string `json:"consumed_emblems,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// GMGrantItemWithEmblems 发放一件带附魔宝珠与徽章的装备 / 时装。
//
// 流程(同一事务内):
// 1. 构造道具行,写入附魔字段(Extra + 由 RawEntry 序列化承载 0x0E 宝珠 ID)
// 2. 放入背包(沿用副本拾取那套容器/槽位规则)
// 3. 若给了徽章:先把徽章行放进背包(以便被消耗),再按 socketemblem 的投影
// 逻辑开孔并镶嵌,最后写回目标行的 socket 向量
func (s *Service) GMGrantItemWithEmblems(
ctx context.Context,
request GMItemWithExtrasRequest,
) (GMItemWithExtrasResult, error) {
result := GMItemWithExtrasResult{}
if s == nil {
return result, ErrGMItemGrantUnavailable
}
if ctx == nil {
ctx = context.Background()
}
characterID := strings.TrimSpace(request.CharacterID)
if characterID == "" {
return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid)
}
if request.ItemID == 0 {
return result, fmt.Errorf("%w: item_id is required", ErrGMRequestInvalid)
}
result.CharacterID = characterID
result.ItemID = request.ItemID
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return result, fmt.Errorf("%w: %v", ErrGMItemGrantUnavailable, err)
}
definition, err := catalog.ResolveItem(request.ItemID)
if err != nil {
return result, fmt.Errorf("%w: item=%d resolved=false", ErrGMItemNotFound, request.ItemID)
}
result.ItemName = definition.Name
// 目标必须是装备/时装:附魔与徽章只对这两类有意义。
rule, ruleOK := currentEquipmentPlacementRuleForPVFType(definition.EquipmentType)
isAvatar := ruleOK && rule.class == currentEquipmentPlacementClassAvatar
if definition.Kind != dungeonDropItemEquipment {
return result, fmt.Errorf("%w: item=%d is not equipment; enchant/emblems require equipment",
ErrGMRequestInvalid, request.ItemID)
}
result.IsAvatar = isAvatar
if request.OpenSockets != nil && (*request.OpenSockets < 0 || *request.OpenSockets > gmEmblemMaxSockets) {
return result, fmt.Errorf("%w: open_sockets must be 0..%d", ErrGMRequestInvalid, gmEmblemMaxSockets)
}
// 附魔宝珠校验:必须是已解析的道具。
var enchantCardID int64
if request.EnchantBeadItemID != 0 {
if _, beadErr := catalog.ResolveItem(request.EnchantBeadItemID); beadErr != nil {
return result, fmt.Errorf("%w: enchant bead item=%d resolved=false",
ErrGMItemNotFound, request.EnchantBeadItemID)
}
enchantCardID = int64(request.EnchantBeadItemID)
}
upgradeCount := 0
if request.EnchantUpgrade != nil {
if *request.EnchantUpgrade < 0 || *request.EnchantUpgrade > 255 {
return result, fmt.Errorf("%w: enchant_upgrade_count must be 0..255", ErrGMRequestInvalid)
}
upgradeCount = *request.EnchantUpgrade
}
stack, buildErr := s.buildGMGrantItemStack(ctx, catalog, definition, 1, gmGrantItemOptions{})
if buildErr != nil {
return result, buildErr
}
if enchantCardID != 0 {
// 与 inventory.setEnchantFields 写同样的键集合。
stack.Extra["value_a"] = fmt.Sprintf("%d", enchantCardID)
stack.Extra["enchant_card_id"] = fmt.Sprintf("%d", enchantCardID)
stack.Extra["byte_12"] = fmt.Sprintf("%d", upgradeCount)
stack.Extra["enchant_upgrade_count"] = fmt.Sprintf("%d", upgradeCount)
result.EnchantCardID = enchantCardID
}
// 徽章参数先规范化,任何非法输入都在动库之前拒绝。
emblems, autoSocketCount, normErr := gmNormalizeEmblems(request.Emblems)
if normErr != nil {
return result, normErr
}
openSockets := autoSocketCount
if request.OpenSockets != nil && *request.OpenSockets > openSockets {
openSockets = *request.OpenSockets
}
placement := currentDungeonPickupPlacement(definition, 1)
placement.BuildNew = func(int16) (dnfrepo.ItemStack, error) {
return gmCloneStack(stack), nil
}
placement.NormalizeExisting = nil
repositories, available := s.repositoryGroup()
if !available || repositories.CharacterItems == nil {
return result, ErrGMRepositoryUnavailable
}
owner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
return result, fmt.Errorf("%w: %v", ErrGMItemGrantUnavailable, err)
}
grant, err := owner.GrantPickupItem(ctx, dnfdungeon.PickupItemCommand{
CharacterID: characterID,
Placement: placement,
UpdatedAt: time.Now().UTC(),
})
if err != nil {
return result, err
}
result.ListType = placement.ListType
result.Slot = int(grant.Slot)
result.EntryKey = fmt.Sprintf("%d:%d", placement.ListType, grant.Slot)
result.OpenSockets = openSockets
// 没有徽章时也要把附魔序列化进原始行 —— **必须放在发放之后**。
// 构建阶段的 raw 会被落库过程冲掉(实测:只在 BuildNew 里刷新时
// raw[0x0E:0x12] 仍是 0),而徽章路径之所以正确,正是因为它是在目标行
// 已经落位之后才调用 currentRefreshStackRawEntry。
if len(emblems) == 0 {
if err := s.gmRefreshGrantedItemRaw(ctx, repositories, grant.Slot, placement.ListType, characterID); err != nil {
return result, err
}
if inventory, found, loadErr := repositories.Inventory.Load(ctx, characterID); loadErr == nil && found {
if final, present := inventory.Slots[result.EntryKey]; present {
result.Extra = final.Extra
}
}
return result, nil
}
return s.gmAttachEmblemsToGrantedItem(ctx, repositories, catalog, rule, request.ItemID,
placement.ListType, int16(grant.Slot), openSockets, emblems, result)
}
// gmRefreshGrantedItemRaw 在目标行已经落位之后,用 Extra 重新序列化它的原始行。
//
// 顺序很关键:必须"先落库、后刷新",否则刷新出来的 raw 会被落库路径覆盖回旧值。
func (s *Service) gmRefreshGrantedItemRaw(
ctx context.Context,
repositories dnfrepo.Group,
slot uint16,
listType byte,
characterID string,
) error {
if repositories.CharacterItems == nil {
return ErrGMRepositoryUnavailable
}
key := fmt.Sprintf("%d:%d", listType, slot)
return repositories.CharacterItems.WithinCharacterItems(ctx, characterID, func(
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
record, found, loadErr := inventories.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found {
return fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID)
}
stack, present := record.Slots[key]
if !present {
return fmt.Errorf("%w: entry_key=%s", ErrGMItemRowNotFound, key)
}
record = dnfrepo.CloneInventory(record)
currentRefreshStackRawEntry(&stack, listType, int16(slot))
record.Slots[key] = stack
record.UpdatedAt = time.Now().UTC()
return dnfrepo.SaveInventoryFields(ctx, inventories, record, dnfrepo.InventoryFieldSlots)
})
}
// gmNormalizeEmblems 校验并补全徽章参数,返回"至少需要开几孔"。
func gmNormalizeEmblems(values []GMEmblemRequest) ([]currentEmblemApplyRequest, int, error) {
if len(values) == 0 {
return nil, 0, nil
}
if len(values) > gmEmblemMaxSockets {
return nil, 0, fmt.Errorf("%w: at most %d emblems", ErrGMRequestInvalid, gmEmblemMaxSockets)
}
out := make([]currentEmblemApplyRequest, 0, len(values))
seenSocket := map[int]bool{}
nextIndex := 0
for _, value := range values {
if value.EmblemItemID == 0 {
return nil, 0, fmt.Errorf("%w: emblem_item_id is required", ErrGMRequestInvalid)
}
index := nextIndex
if value.SocketIndex != nil {
index = *value.SocketIndex
}
if index < 0 || index >= gmEmblemMaxSockets {
return nil, 0, fmt.Errorf("%w: socket_index must be 0..%d", ErrGMRequestInvalid, gmEmblemMaxSockets-1)
}
if seenSocket[index] {
return nil, 0, fmt.Errorf("%w: socket_index %d repeated", ErrGMRequestInvalid, index)
}
seenSocket[index] = true
if index >= nextIndex {
nextIndex = index + 1
}
slot := int16(-1)
if value.EmblemSlot != nil {
if *value.EmblemSlot < 0 {
return nil, 0, fmt.Errorf("%w: emblem_slot must not be negative", ErrGMRequestInvalid)
}
slot = int16(*value.EmblemSlot)
}
out = append(out, currentEmblemApplyRequest{
EmblemSlot: slot,
EmblemItemID: int64(value.EmblemItemID),
SocketIndex: byte(index),
})
}
return out, nextIndex, nil
}
// gmAttachEmblemsToGrantedItem 在同一事务里给刚放好的装备开孔并镶嵌徽章。
//
// 徽章行会被临时放进同一个背包记录里,交给 currentApplyEquipmentEmblems 消耗 ——
// 那条路径是游戏内镶嵌用的同一份实现,因此不会写出客户端读不了的向量。
func (s *Service) gmAttachEmblemsToGrantedItem(
ctx context.Context,
repositories dnfrepo.Group,
catalog *pvfDungeonDropCatalog,
rule currentEquipmentPlacementRule,
targetItemID uint32,
listType byte,
slot int16,
openSockets int,
emblems []currentEmblemApplyRequest,
result GMItemWithExtrasResult,
) (GMItemWithExtrasResult, error) {
owner, err := dnfsocketemblem.NewOwner(repositories)
if err != nil {
return result, fmt.Errorf("%w: socket emblem owner: %v", ErrGMItemGrantUnavailable, err)
}
targetKey := fmt.Sprintf("%d:%d", listType, slot)
attach := func(assets *dnfsocketemblem.Assets) (dnfsocketemblem.Changes, error) {
inventory := assets.Inventory
if inventory == nil {
return dnfsocketemblem.Changes{}, errCurrentSocketInventoryMissing
}
target, ok := inventory.Slots[targetKey]
if !ok || target.ItemID != int64(targetItemID) {
return dnfsocketemblem.Changes{}, fmt.Errorf("%w: target %s", errCurrentSocketTargetMissing, targetKey)
}
// 1) 把徽章行临时放进背包(原地消耗,不再清理;
// 镶嵌成功后它们就"消耗掉了",与游戏内一致)。
resolved, materialiseErr := gmMaterialiseEmblems(inventory, emblems)
if materialiseErr != nil {
return dnfsocketemblem.Changes{}, materialiseErr
}
// 2) 打开所需孔位。
isAvatar := rule.class == currentEquipmentPlacementClassAvatar
if isAvatar {
data := currentAvatarSocketData(target.Extra)
want := openSockets
if want <= 0 {
want = len(resolved)
}
for i := 0; i < want && i < currentAvatarSocketCount; i++ {
if data[i] == 0 && i >= int(data[0]) {
// 空孔记 0xFF 占位,交给应用阶段写入真实徽章 ID。
data[i] = 0xFF
}
}
data[0] = byte(want)
if applyErr := currentApplyAvatarEmblems(catalog, inventory, &data, resolved); applyErr != nil {
return dnfsocketemblem.Changes{}, applyErr
}
currentApplyAvatarSocketDataToStack(&target, listType, slot, data)
} else {
dataBytes := currentEquipmentEmblemData(target.Extra, target.RawEntry)
maxOpen := currentEquipmentSocketOpenCount(rule)
want := openSockets
if want <= 0 {
want = len(resolved)
}
if want > maxOpen {
want = maxOpen
}
currentEnsureEquipmentEmblemSocketsOpen(&dataBytes, maxOpen, want)
if applyErr := currentApplyEquipmentEmblems(catalog, rule, inventory, &dataBytes, resolved); applyErr != nil {
return dnfsocketemblem.Changes{}, applyErr
}
currentApplyEquipmentEmblemDataToStack(&target, listType, slot, dataBytes, currentEquipmentJewelSocketType(rule))
}
inventory.Slots[targetKey] = target
return dnfsocketemblem.Changes{Inventory: true}, nil
}
if err := owner.AttachEquipmentEmblems(ctx, dnfsocketemblem.Command{
CharacterID: result.CharacterID,
Project: attach,
}); err != nil {
return result, err
}
// 回读最终行,把实际写入的附魔/徽章信息返回给工具端。
inventory, found, loadErr := repositories.Inventory.Load(ctx, result.CharacterID)
if loadErr == nil && found {
if final, present := inventory.Slots[targetKey]; present {
result.Extra = final.Extra
if text := strings.TrimSpace(final.Extra["enchant_card_id"]); text != "" {
var parsed int64
if _, scanErr := fmt.Sscanf(text, "%d", &parsed); scanErr == nil {
result.EnchantCardID = parsed
}
}
if isAvatarRule(rule) {
data := currentAvatarSocketData(final.Extra)
result.OpenSockets = int(data[0])
for i := 1; i < len(data) && i <= result.OpenSockets; i++ {
if data[i] != 0 && data[i] != 0xFF {
result.Sockets = append(result.Sockets, int64(data[i]))
}
}
} else {
data := currentEquipmentEmblemData(final.Extra, final.RawEntry)
result.OpenSockets = int(data[0])
for i := 1; i < len(data) && i <= result.OpenSockets; i++ {
if data[i] != 0 && data[i] != 0xFF {
result.Sockets = append(result.Sockets, int64(data[i]))
}
}
}
}
}
return result, nil
}
func isAvatarRule(rule currentEquipmentPlacementRule) bool {
return rule.class == currentEquipmentPlacementClassAvatar
}
// gmMaterialiseEmblems 把请求里的徽章落到背包行,返回带真实槽位的申请列表。
//
// 已经给了 EmblemSlot 就直接用;否则找同 item_id 的第一行消耗 1 个。
// 找不到时**临时插入**一行(工具端通常还没把徽章发给角色)。
func gmMaterialiseEmblems(
inventory *dnfrepo.InventoryRecord,
emblems []currentEmblemApplyRequest,
) ([]currentEmblemApplyRequest, error) {
if inventory.Slots == nil {
inventory.Slots = map[string]dnfrepo.ItemStack{}
}
out := make([]currentEmblemApplyRequest, 0, len(emblems))
// 临时槽从主背包末段往前找,避免占用正常装备/消耗品段。
nextTempSlot := int16(350)
for _, emblem := range emblems {
slot := emblem.EmblemSlot
if slot >= 0 {
if existing, ok := inventory.Slots[fmt.Sprintf("%d:%d", gmEmblemListMain, slot)]; ok &&
existing.ItemID == emblem.EmblemItemID {
emblem.EmblemSlot = slot
out = append(out, emblem)
continue
}
}
// 找同 id 的现有行。
foundSlot := int16(-1)
for key, candidate := range inventory.Slots {
var keyList byte
var keySlot int16
if _, scanErr := fmt.Sscanf(key, "%d:%d", &keyList, &keySlot); scanErr != nil {
continue
}
if keyList != gmEmblemListMain || candidate.ItemID != emblem.EmblemItemID || candidate.Count <= 0 {
continue
}
if foundSlot < 0 || keySlot < foundSlot {
foundSlot = keySlot
}
}
if foundSlot < 0 {
// 临时插入一行,让镶嵌阶段能"消耗"掉它。
for {
key := fmt.Sprintf("%d:%d", gmEmblemListMain, nextTempSlot)
if _, taken := inventory.Slots[key]; !taken {
inventory.Slots[key] = dnfrepo.ItemStack{
ItemID: emblem.EmblemItemID,
Count: 1,
Extra: map[string]string{
"source": "gm_tool_emblem_materialise",
"item_kind": "stackable",
},
}
foundSlot = nextTempSlot
nextTempSlot--
break
}
nextTempSlot--
if nextTempSlot < 300 {
return nil, fmt.Errorf("%w: no temporary slot for emblem %d",
ErrGMRequestInvalid, emblem.EmblemItemID)
}
}
}
emblem.EmblemSlot = foundSlot
out = append(out, emblem)
}
return out, nil
}