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

481 lines
17 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"
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
}