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

517 lines
21 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentCustomAbilitySetNotifyOpcode uint16 = 1278
currentCustomAbilitySetErrorGeneric byte = 1
currentCustomAbilitySetErrorMaterial byte = 8
currentCustomAbilitySetOptionLimit = 4
currentCustomAbilitySetNotificationSize = 12
currentCustomAbilitySetOptionsExtra = "custom_ability_set_options"
currentCustomAbilitySetRequiredPartsExtra = "custom_ability_set_required_parts"
currentCustomAbilitySetGradeExtra = "custom_ability_set_grade"
currentCustomAbilitySetPartIndexExtra = "custom_ability_set_part_index"
)
var (
errCurrentCustomAbilitySetInvalid = errors.New("current custom ability set request is invalid")
errCurrentCustomAbilitySetMaterial = errors.New("current custom ability set material is insufficient")
)
type currentCustomAbilitySetRequest struct {
options []byte
slots []int16
}
type currentCustomAbilitySetSnapshot struct {
recordID byte
requiredParts byte
options [5]byte
grade byte
partSetIndex byte
}
type currentCustomAbilitySetResult struct {
snapshot currentCustomAbilitySetSnapshot
materialID uint32
materialCost int64
materialRemaining int64
spentPoints int
refreshes []alignedcmd.ItemSlotRefresh
equipmentChanged bool
}
func decodeCurrentCustomAbilitySetRequest(body []byte) (currentCustomAbilitySetRequest, error) {
if len(body) < 1 {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
offset := 0
optionCount := int(body[offset])
offset++
if optionCount < 1 || optionCount > currentCustomAbilitySetOptionLimit || len(body) < offset+optionCount+1 {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
request := currentCustomAbilitySetRequest{options: append([]byte(nil), body[offset:offset+optionCount]...)}
offset += optionCount
equipmentCount := int(body[offset])
offset++
if equipmentCount < 3 || equipmentCount > 6 || len(body) != offset+equipmentCount*2 {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
seenOptions := make(map[byte]struct{}, optionCount)
for _, optionID := range request.options {
if optionID == 0 {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
if _, duplicate := seenOptions[optionID]; duplicate {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
seenOptions[optionID] = struct{}{}
}
seenSlots := make(map[int16]struct{}, equipmentCount)
request.slots = make([]int16, 0, equipmentCount)
for index := 0; index < equipmentCount; index++ {
rawSlot := binary.LittleEndian.Uint16(body[offset : offset+2])
offset += 2
if rawSlot > math.MaxInt16 {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
slot := int16(rawSlot)
if _, duplicate := seenSlots[slot]; duplicate {
return currentCustomAbilitySetRequest{}, errCurrentCustomAbilitySetInvalid
}
seenSlots[slot] = struct{}{}
request.slots = append(request.slots, slot)
}
return request, nil
}
func buildCurrentCustomAbilitySetFailureBody(err error) []byte {
code := currentCustomAbilitySetErrorGeneric
if errors.Is(err, errCurrentCustomAbilitySetMaterial) {
code = currentCustomAbilitySetErrorMaterial
}
return []byte{0, code}
}
func buildCurrentCustomAbilitySetSuccessBody() []byte { return []byte{1} }
func buildCurrentCustomAbilitySetNotificationBody(actorID uint32, snapshot currentCustomAbilitySetSnapshot) []byte {
body := make([]byte, currentCustomAbilitySetNotificationSize)
binary.LittleEndian.PutUint32(body[0:4], actorID)
body[4] = snapshot.recordID
body[5] = snapshot.requiredParts
copy(body[6:11], snapshot.options[:])
body[11] = snapshot.grade
return body
}
func currentCustomAbilitySetOptionsText(options []byte) string {
values := make([]string, 0, len(options))
for _, optionID := range options {
if optionID == 0 {
break
}
values = append(values, strconv.Itoa(int(optionID)))
}
return strings.Join(values, ",")
}
func cloneCurrentCustomAbilityExtra(extra map[string]string) map[string]string {
clone := make(map[string]string, len(extra)+5)
for key, value := range extra {
clone[key] = value
}
return clone
}
func applyCurrentCustomAbilitySetMembership(raw []byte, extra map[string]string, recordID byte, snapshot *currentCustomAbilitySetSnapshot) ([]byte, map[string]string, error) {
if len(raw) != currentItemListEntryWireSize {
return nil, nil, errCurrentCustomAbilitySetInvalid
}
raw = append([]byte(nil), raw...)
raw[currentCustomAbilitySetRecordOffset] = recordID
extra = cloneCurrentCustomAbilityExtra(extra)
if recordID == 0 || snapshot == nil {
delete(extra, currentCustomAbilitySetOptionsExtra)
delete(extra, currentCustomAbilitySetRequiredPartsExtra)
delete(extra, currentCustomAbilitySetGradeExtra)
delete(extra, currentCustomAbilitySetPartIndexExtra)
} else {
extra[currentCustomAbilitySetOptionsExtra] = currentCustomAbilitySetOptionsText(snapshot.options[:])
extra[currentCustomAbilitySetRequiredPartsExtra] = strconv.Itoa(int(snapshot.requiredParts))
extra[currentCustomAbilitySetGradeExtra] = strconv.Itoa(int(snapshot.grade))
extra[currentCustomAbilitySetPartIndexExtra] = strconv.Itoa(int(snapshot.partSetIndex))
}
extra["custom_ability_set_record"] = strconv.Itoa(int(recordID))
tailHex := fmt.Sprintf("%x", raw[currentCustomAbilityRawBlockStart:currentCustomAbilityRawBlockEnd])
extra["tail_data_72"] = tailHex
for _, key := range []string{"mode1_raw_e_hex", "mode1_state_188_192_raw_hex"} {
if _, exists := extra[key]; exists {
extra[key] = tailHex
}
}
return raw, extra, nil
}
func validateCurrentCustomAbilitySetProfiles(profiles []currentCustomAbilityItemProfile) error {
occupied := make(map[int16]struct{}, len(profiles))
armorProfiles := make([]currentCustomAbilityItemProfile, 0, 5)
for _, profile := range profiles {
if !profile.applicable || profile.optionCount != 2 || profile.equipmentSlot < 0 {
return errCurrentCustomAbilitySetInvalid
}
if _, duplicate := occupied[profile.equipmentSlot]; duplicate {
return errCurrentCustomAbilitySetInvalid
}
occupied[profile.equipmentSlot] = struct{}{}
if profile.equipmentSlot >= 14 && profile.equipmentSlot <= 18 {
armorProfiles = append(armorProfiles, profile)
}
}
if len(armorProfiles) == 5 {
material := armorProfiles[0].subType
if !armorProfiles[0].hasSubType {
return errCurrentCustomAbilitySetInvalid
}
for _, profile := range armorProfiles[1:] {
if !profile.hasSubType || profile.subType != material {
return errCurrentCustomAbilitySetInvalid
}
}
}
return nil
}
func commitCurrentCustomAbilitySet(ctx context.Context, repositories dnfrepo.Group, characterID string, request currentCustomAbilitySetRequest, individualCatalog *currentCustomAbilityCatalog, catalog *currentCustomAbilitySetCatalog, resolve currentCustomAbilityProfileResolver) (currentCustomAbilitySetResult, error) {
if repositories.CharacterItems == nil || individualCatalog == nil || catalog == nil || resolve == nil || len(request.options) < 1 || len(request.options) > int(catalog.maxOptions) || len(request.slots) < int(catalog.minParts) || len(request.slots) > int(catalog.maxParts) {
return currentCustomAbilitySetResult{}, errCurrentCustomAbilitySetInvalid
}
var result currentCustomAbilitySetResult
err := repositories.CharacterItems.WithinCharacterItems(ctx, characterID, func(inventoryRepo dnfrepo.InventoryRepository, equipmentRepo dnfrepo.EquipmentRepository) error {
inventory, found, err := inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return errCurrentCustomAbilitySetInvalid
}
inventory = dnfrepo.CloneInventory(inventory)
equipment, equipmentFound, err := equipmentRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !equipmentFound {
equipment = dnfrepo.EquipmentRecord{CharacterID: characterID}
} else {
equipment = dnfrepo.CloneEquipment(equipment)
}
profiles := make([]currentCustomAbilityItemProfile, 0, len(request.slots))
grades := make([]byte, 0, len(request.slots))
selectedKeys := make(map[string]struct{}, len(request.slots))
for _, slot := range request.slots {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack, found := inventory.Slots[key]
if !found || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 || stack.Count != 1 || len(stack.RawEntry) != currentItemListEntryWireSize {
return errCurrentCustomAbilitySetInvalid
}
profile, err := resolve(uint32(stack.ItemID))
if err != nil {
return err
}
if !profile.applicable || profile.partSetIndex != catalog.partSetIndex {
return errCurrentCustomAbilitySetInvalid
}
state, valid := currentCustomAbilityStateFromRaw(stack.RawEntry, individualCatalog)
if !valid {
return errCurrentCustomAbilitySetInvalid
}
profiles = append(profiles, profile)
grades = append(grades, state.grade)
selectedKeys[key] = struct{}{}
}
if err := validateCurrentCustomAbilitySetProfiles(profiles); err != nil {
return err
}
setGrade, spentPoints, materialCost, err := catalog.evaluate(request.options, grades)
if err != nil {
return err
}
materialSlots := currentCompoundSlotsForItem(inventory.Slots, int64(catalog.materialID))
available := int64(0)
for _, slot := range materialSlots {
available += inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)].Count
}
if available < materialCost {
return errCurrentCustomAbilitySetMaterial
}
var snapshot currentCustomAbilitySetSnapshot
snapshot.recordID = catalog.partSetIndex
snapshot.partSetIndex = catalog.partSetIndex
snapshot.requiredParts = byte(len(request.slots))
snapshot.grade = setGrade
copy(snapshot.options[:], request.options)
refreshSet := make(map[int16]struct{})
equipmentChanged := false
for key, stack := range inventory.Slots {
if stack.Count != 1 || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 || len(stack.RawEntry) != currentItemListEntryWireSize || stack.RawEntry[currentCustomAbilitySetRecordOffset] != catalog.partSetIndex {
continue
}
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != dnfrepo.MainInventoryListType {
continue
}
profile, profileErr := resolve(uint32(stack.ItemID))
if profileErr != nil {
return profileErr
}
if !profile.applicable || profile.partSetIndex != catalog.partSetIndex {
continue
}
stack.RawEntry, stack.Extra, err = applyCurrentCustomAbilitySetMembership(stack.RawEntry, stack.Extra, 0, nil)
if err != nil {
return err
}
inventory.Slots[key] = stack
refreshSet[slot] = struct{}{}
}
for key, equipped := range equipment.Entries {
if equipped.ItemID <= 0 || equipped.ItemID > math.MaxUint32 || len(equipped.RawEntry) != currentItemListEntryWireSize || equipped.RawEntry[currentCustomAbilitySetRecordOffset] != catalog.partSetIndex {
continue
}
profile, profileErr := resolve(uint32(equipped.ItemID))
if profileErr != nil {
return profileErr
}
if !profile.applicable || profile.partSetIndex != catalog.partSetIndex {
continue
}
equipped.RawEntry, equipped.Extra, err = applyCurrentCustomAbilitySetMembership(equipped.RawEntry, equipped.Extra, 0, nil)
if err != nil {
return err
}
equipment.Entries[key] = equipped
equipmentChanged = true
}
for key := range selectedKeys {
stack := inventory.Slots[key]
stack.RawEntry, stack.Extra, err = applyCurrentCustomAbilitySetMembership(stack.RawEntry, stack.Extra, catalog.partSetIndex, &snapshot)
if err != nil {
return err
}
inventory.Slots[key] = stack
_, slot, _ := parseSceneInventorySlotKey(key)
refreshSet[slot] = struct{}{}
}
left := materialCost
for _, slot := range materialSlots {
if left == 0 {
break
}
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack := inventory.Slots[key]
take := stack.Count
if take > left {
take = left
}
stack.Count -= take
left -= take
if stack.Count == 0 {
delete(inventory.Slots, key)
} else {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
}
refreshSet[slot] = struct{}{}
}
inventory.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if equipmentChanged {
equipment.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveEquipmentFields(ctx, equipmentRepo, equipment, dnfrepo.EquipmentFieldEntries); err != nil {
return err
}
}
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(refreshSet))
for slot := range refreshSet {
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{ListType: dnfrepo.MainInventoryListType, SlotIndex: slot})
}
sort.Slice(refreshes, func(i, j int) bool { return refreshes[i].SlotIndex < refreshes[j].SlotIndex })
result = currentCustomAbilitySetResult{snapshot: snapshot, materialID: catalog.materialID, materialCost: materialCost, materialRemaining: available - materialCost, spentPoints: spentPoints, refreshes: refreshes, equipmentChanged: equipmentChanged}
return nil
})
return result, err
}
func parseCurrentCustomAbilitySetSnapshot(extra map[string]string, recordID byte) (currentCustomAbilitySetSnapshot, bool) {
if recordID == 0 || extra == nil {
return currentCustomAbilitySetSnapshot{}, false
}
parts := strings.Split(strings.TrimSpace(extra[currentCustomAbilitySetOptionsExtra]), ",")
if len(parts) < 1 || len(parts) > 5 {
return currentCustomAbilitySetSnapshot{}, false
}
var snapshot currentCustomAbilitySetSnapshot
snapshot.recordID = recordID
seen := make(map[byte]struct{}, len(parts))
for index, value := range parts {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 8)
if err != nil || parsed == 0 {
return currentCustomAbilitySetSnapshot{}, false
}
optionID := byte(parsed)
if _, duplicate := seen[optionID]; duplicate {
return currentCustomAbilitySetSnapshot{}, false
}
seen[optionID] = struct{}{}
snapshot.options[index] = optionID
}
required, requiredErr := strconv.ParseUint(strings.TrimSpace(extra[currentCustomAbilitySetRequiredPartsExtra]), 10, 8)
grade, gradeErr := strconv.ParseUint(strings.TrimSpace(extra[currentCustomAbilitySetGradeExtra]), 10, 8)
partIndex, partErr := strconv.ParseUint(strings.TrimSpace(extra[currentCustomAbilitySetPartIndexExtra]), 10, 8)
if requiredErr != nil || required < 3 || required > 6 || gradeErr != nil || grade > 3 || partErr != nil || partIndex == 0 || byte(partIndex) != recordID {
return currentCustomAbilitySetSnapshot{}, false
}
snapshot.requiredParts = byte(required)
snapshot.grade = byte(grade)
snapshot.partSetIndex = byte(partIndex)
return snapshot, true
}
func loadCurrentCustomAbilitySetSnapshot(ctx context.Context, repositories dnfrepo.Group, characterID string) (currentCustomAbilitySetSnapshot, bool, error) {
if repositories.Inventory == nil || repositories.Equipment == nil {
return currentCustomAbilitySetSnapshot{}, false, errors.New("custom ability set repositories unavailable")
}
inventory, _, err := repositories.Inventory.Load(ctx, characterID)
if err != nil {
return currentCustomAbilitySetSnapshot{}, false, err
}
equipment, _, err := repositories.Equipment.Load(ctx, characterID)
if err != nil {
return currentCustomAbilitySetSnapshot{}, false, err
}
counts := make(map[currentCustomAbilitySetSnapshot]int)
consider := func(raw []byte, extra map[string]string) {
if len(raw) != currentItemListEntryWireSize {
return
}
snapshot, valid := parseCurrentCustomAbilitySetSnapshot(extra, raw[currentCustomAbilitySetRecordOffset])
if !valid {
return
}
counts[snapshot]++
}
for _, stack := range inventory.Slots {
if stack.Count == 1 {
consider(stack.RawEntry, stack.Extra)
}
}
for _, equipped := range equipment.Entries {
consider(equipped.RawEntry, equipped.Extra)
}
for snapshot, memberCount := range counts {
if memberCount >= int(snapshot.requiredParts) {
return snapshot, true, nil
}
}
return currentCustomAbilitySetSnapshot{}, false, nil
}
func (s *Service) sendCurrentCustomAbilitySetNotification(session *gameSession, snapshot currentCustomAbilitySetSnapshot, source string) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
body := buildCurrentCustomAbilitySetNotificationBody(uint32(session.selectedCharacterID), snapshot)
s.logPacketEvent("game-upper-custom-ability-set-notification-send", "conn_id", session.connID, "source", source, "char_id", session.selectedCharacterID, "msg_id", currentCustomAbilitySetNotifyOpcode, "classification", 0, "record_id", snapshot.recordID, "required_parts", snapshot.requiredParts, "options", currentCustomAbilitySetOptionsText(snapshot.options[:]), "grade", snapshot.grade, "body_len", len(body), "evidence", "current_exe_sub_1D6D760_registered_at_0x1D7F958_wire_0x04FE")
return s.sendGameUpperRawClass(session, currentCustomAbilitySetNotifyOpcode, body, 0)
}
func (s *Service) handleCurrentCustomAbilitySet(session *gameSession, body []byte) error {
request, err := decodeCurrentCustomAbilitySetRequest(body)
if err != nil || session == nil || session.selectedCharacterID == 0 {
if err == nil {
err = errCurrentCustomAbilitySetInvalid
}
s.logGameEvent(session, "game-custom-ability-set-rejected", "reason", err, "body_len", len(body))
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketCustomAbilitySetEquipOption), buildCurrentCustomAbilitySetFailureBody(err), dnfproto.DefaultChannelClassification)
}
s.initialEquipmentMu.Lock()
archive, archiveErr := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if archiveErr != nil {
return archiveErr
}
catalog, err := parseCurrentCustomAbilitySetCatalog(archive)
if err != nil {
return err
}
individualCatalog, err := parseCurrentCustomAbilityCatalog(archive)
if err != nil {
return err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Inventory == nil || repositories.Equipment == nil || repositories.CharacterItems == nil {
return errors.New("custom ability set repositories unavailable")
}
profileCache := make(map[uint32]currentCustomAbilityItemProfile)
resolve := func(itemID uint32) (currentCustomAbilityItemProfile, error) {
if profile, found := profileCache[itemID]; found {
return profile, nil
}
profile, resolveErr := s.resolveCurrentCustomAbilityItemProfile(itemID)
if resolveErr == nil {
profileCache[itemID] = profile
}
return profile, resolveErr
}
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
result, err := commitCurrentCustomAbilitySet(ctx, repositories, characterID, request, individualCatalog, catalog, resolve)
if err != nil {
s.logGameEvent(session, "game-custom-ability-set-rejected", "options", currentCustomAbilitySetOptionsText(request.options), "slots", fmt.Sprint(request.slots), "reason", err, "body_len", len(body))
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketCustomAbilitySetEquipOption), buildCurrentCustomAbilitySetFailureBody(err), dnfproto.DefaultChannelClassification)
}
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "custom_ability_set_commit", result.refreshes); err != nil {
return err
}
if result.equipmentChanged {
if err := s.sendSelectedCurrentEquipmentSlotItemUpdates(session, "custom_ability_set_clear_previous_equipped_members"); err != nil {
return err
}
}
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketCustomAbilitySetEquipOption), buildCurrentCustomAbilitySetSuccessBody(), dnfproto.DefaultChannelClassification); err != nil {
return err
}
s.logGameEvent(session, "game-custom-ability-set-committed", "options", currentCustomAbilitySetOptionsText(request.options), "slots", fmt.Sprint(request.slots), "set_grade", result.snapshot.grade, "spent_points", result.spentPoints, "material_item_id", result.materialID, "material_cost", result.materialCost, "material_remaining", result.materialRemaining, "sequence", "op14_list0_then_optional_op14_list3_then_class1_op1341_ack_then_class0_wire1278")
return s.sendCurrentCustomAbilitySetNotification(session, result.snapshot, "custom_ability_set_commit")
}