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

265 lines
13 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"reflect"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestDecodeCurrentCustomAbilitySetRequest(t *testing.T) {
body := []byte{1, 107, 3, 11, 0, 12, 0, 9, 0}
request, err := decodeCurrentCustomAbilitySetRequest(body)
if err != nil || !bytes.Equal(request.options, []byte{107}) || !reflect.DeepEqual(request.slots, []int16{11, 12, 9}) {
t.Fatalf("request=%+v err=%v", request, err)
}
for _, invalid := range [][]byte{
nil,
{0, 3, 11, 0, 12, 0, 9, 0},
{1, 107, 2, 11, 0, 12, 0},
{1, 107, 3, 11, 0, 12, 0},
{1, 107, 3, 11, 0, 12, 0, 9, 0, 0},
{2, 107, 107, 3, 11, 0, 12, 0, 9, 0},
{1, 107, 3, 11, 0, 11, 0, 9, 0},
{1, 107, 3, 11, 0, 12, 0, 0, 0x80},
} {
if _, err := decodeCurrentCustomAbilitySetRequest(invalid); !errors.Is(err, errCurrentCustomAbilitySetInvalid) {
t.Fatalf("invalid body=%x err=%v", invalid, err)
}
}
}
func currentCustomAbilitySetUnitCatalog() *currentCustomAbilitySetCatalog {
return &currentCustomAbilitySetCatalog{
partSetIndex: 1,
minParts: 3,
maxParts: 6,
maxOptions: 4,
maxPoints: map[byte]map[byte]int{
0: {3: 105, 4: 175, 5: 280, 6: 420},
1: {3: 120, 4: 200, 5: 320, 6: 480},
2: {3: 135, 4: 225, 5: 360, 6: 540},
3: {3: 150, 4: 250, 5: 400, 6: 600},
},
materialID: 10161537,
materialRate: 2.20,
options: map[byte]currentCustomAbilitySetOptionRule{
1: {points: 50, group: 1, minParts: 4},
2: {points: 60, group: 1, minParts: 4},
34: {points: 50, group: 4, minParts: 3},
56: {points: 104, group: 3, minParts: 3},
100: {points: 50, group: 2, minParts: 3},
122: {points: 10, group: 5, minParts: 3},
152: {points: 10, group: 6, minParts: 3},
252: {points: 150, group: 7, minParts: 6},
},
}
}
func currentCustomAbilityIndividualUnitCatalog() *currentCustomAbilityCatalog {
return &currentCustomAbilityCatalog{optionIDs: map[byte]struct{}{4: {}, 12: {}}}
}
func TestCurrentCustomAbilitySetEvaluateUsesAverageGradeAndRecursivePoints(t *testing.T) {
catalog := currentCustomAbilitySetUnitCatalog()
grade, spent, material, err := catalog.evaluate([]byte{122, 152}, []byte{0, 1, 2})
if err != nil || grade != 1 {
t.Fatalf("grade=%d spent=%d material=%d err=%v", grade, spent, material, err)
}
first := 10 * (120 + 1000) / (10 + 1000)
second := 10 * (120 - first + 1000) / (10 + 1000)
wantSpent := first + second
if spent != wantSpent || material != int64(float64(wantSpent)*2.20) {
t.Fatalf("spent=%d material=%d want_spent=%d want_material=%d", spent, material, wantSpent, int64(float64(wantSpent)*2.20))
}
for _, test := range []struct {
options []byte
grades []byte
}{
{options: []byte{1}, grades: []byte{0, 0, 0}},
{options: []byte{252}, grades: []byte{3, 3, 3, 3, 3}},
{options: []byte{122, 122}, grades: []byte{0, 0, 0}},
{options: []byte{1, 2}, grades: []byte{0, 0, 0, 0}},
} {
if _, _, _, err := catalog.evaluate(test.options, test.grades); !errors.Is(err, errCurrentCustomAbilitySetInvalid) {
t.Fatalf("options=%v grades=%v err=%v", test.options, test.grades, err)
}
}
}
func TestValidateCurrentCustomAbilitySetProfilesRejectsDuplicatePartAndMixedFullArmor(t *testing.T) {
base := []currentCustomAbilityItemProfile{
{applicable: true, optionCount: 2, equipmentSlot: 12},
{applicable: true, optionCount: 2, equipmentSlot: 14, hasSubType: true, subType: 1},
{applicable: true, optionCount: 2, equipmentSlot: 15, hasSubType: true, subType: 1},
}
if err := validateCurrentCustomAbilitySetProfiles(base); err != nil {
t.Fatal(err)
}
duplicate := append([]currentCustomAbilityItemProfile(nil), base...)
duplicate[2].equipmentSlot = 14
if err := validateCurrentCustomAbilitySetProfiles(duplicate); !errors.Is(err, errCurrentCustomAbilitySetInvalid) {
t.Fatalf("duplicate part err=%v", err)
}
mixedArmor := []currentCustomAbilityItemProfile{
{applicable: true, optionCount: 2, equipmentSlot: 14, hasSubType: true, subType: 1},
{applicable: true, optionCount: 2, equipmentSlot: 15, hasSubType: true, subType: 1},
{applicable: true, optionCount: 2, equipmentSlot: 16, hasSubType: true, subType: 1},
{applicable: true, optionCount: 2, equipmentSlot: 17, hasSubType: true, subType: 2},
{applicable: true, optionCount: 2, equipmentSlot: 18, hasSubType: true, subType: 1},
}
if err := validateCurrentCustomAbilitySetProfiles(mixedArmor); !errors.Is(err, errCurrentCustomAbilitySetInvalid) {
t.Fatalf("mixed armor err=%v", err)
}
}
func TestCommitCurrentCustomAbilitySetConsumesAcrossStacksClearsOldMembersAndPersistsSnapshot(t *testing.T) {
repositories := memory.NewMemoryGroup()
catalog := currentCustomAbilitySetUnitCatalog()
makeRaw := func(grade, setRecord, category byte) []byte {
raw := make([]byte, currentItemListEntryWireSize)
raw[currentCustomAbilityOption0Offset] = 4
raw[currentCustomAbilityOption1Offset] = 12
raw[currentCustomAbilityRawBlockStart+2] = category
raw[currentCustomAbilityGradeOffset] = grade
raw[currentCustomAbilitySetRecordOffset] = setRecord
return raw
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "4", Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 1, RawEntry: makeRaw(0, 0, 0x31)},
"0:11": {ItemID: 1002, Count: 1, RawEntry: makeRaw(1, 0, 0x32)},
"0:12": {ItemID: 1003, Count: 1, RawEntry: makeRaw(2, 0, 0x33)},
"0:20": {ItemID: 1004, Count: 1, RawEntry: makeRaw(0, 1, 0x34), Extra: map[string]string{currentCustomAbilitySetOptionsExtra: "34"}},
"0:50": {ItemID: 10161537, Count: 10},
"0:51": {ItemID: 10161537, Count: 20},
}}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{CharacterID: "4", Entries: map[string]dnfrepo.EquipmentEntry{
"17": {SlotIndex: 17, ItemID: 1005, RawEntry: makeRaw(0, 1, 0x35), Extra: map[string]string{currentCustomAbilitySetOptionsExtra: "34"}},
}}); err != nil {
t.Fatal(err)
}
profiles := map[uint32]currentCustomAbilityItemProfile{
1001: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 12},
1002: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 14, hasSubType: true, subType: 1},
1003: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 15, hasSubType: true, subType: 1},
1004: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 16, hasSubType: true, subType: 1},
1005: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 17, hasSubType: true, subType: 1},
}
resolve := func(itemID uint32) (currentCustomAbilityItemProfile, error) { return profiles[itemID], nil }
request := currentCustomAbilitySetRequest{options: []byte{122}, slots: []int16{11, 12, 9}}
result, err := commitCurrentCustomAbilitySet(context.Background(), repositories, "4", request, currentCustomAbilityIndividualUnitCatalog(), catalog, resolve)
if err != nil {
t.Fatal(err)
}
if result.snapshot.grade != 1 || result.snapshot.requiredParts != 3 || result.snapshot.options[0] != 122 || result.materialCost != 24 || result.materialRemaining != 6 || !result.equipmentChanged {
t.Fatalf("result=%+v", result)
}
wantRefreshSlots := []int16{9, 11, 12, 20, 50, 51}
gotRefreshSlots := make([]int16, len(result.refreshes))
for index, refresh := range result.refreshes {
gotRefreshSlots[index] = refresh.SlotIndex
}
if !reflect.DeepEqual(gotRefreshSlots, wantRefreshSlots) {
t.Fatalf("refresh slots=%v want=%v", gotRefreshSlots, wantRefreshSlots)
}
inventory, _, _ := repositories.Inventory.Load(context.Background(), "4")
for slot, category := range map[string]byte{"0:9": 0x31, "0:11": 0x32, "0:12": 0x33} {
stack := inventory.Slots[slot]
if stack.RawEntry[currentCustomAbilitySetRecordOffset] != 1 || stack.RawEntry[currentCustomAbilityRawBlockStart+2] != category || stack.Extra[currentCustomAbilitySetOptionsExtra] != "122" || stack.Extra[currentCustomAbilitySetRequiredPartsExtra] != "3" || stack.Extra[currentCustomAbilitySetGradeExtra] != "1" {
t.Fatalf("selected slot=%s raw_tail=%x extra=%v", slot, stack.RawEntry[currentCustomAbilityRawBlockStart:currentCustomAbilityRawBlockEnd], stack.Extra)
}
}
if inventory.Slots["0:20"].RawEntry[currentCustomAbilitySetRecordOffset] != 0 || inventory.Slots["0:20"].Extra[currentCustomAbilitySetOptionsExtra] != "" || inventory.Slots["0:51"].Count != 6 {
t.Fatalf("inventory after commit=%+v", inventory.Slots)
}
equipment, _, _ := repositories.Equipment.Load(context.Background(), "4")
if equipment.Entries["17"].RawEntry[currentCustomAbilitySetRecordOffset] != 0 || equipment.Entries["17"].Extra[currentCustomAbilitySetOptionsExtra] != "" {
t.Fatalf("old equipped member=%+v", equipment.Entries["17"])
}
snapshot, found, err := loadCurrentCustomAbilitySetSnapshot(context.Background(), repositories, "4")
if err != nil || !found || snapshot != result.snapshot {
t.Fatalf("restored snapshot=%+v found=%t err=%v", snapshot, found, err)
}
}
func TestCommitCurrentCustomAbilitySetMaterialFailureRollsBack(t *testing.T) {
repositories := memory.NewMemoryGroup()
makeRaw := func(grade byte) []byte {
raw := make([]byte, currentItemListEntryWireSize)
raw[currentCustomAbilityOption0Offset] = 4
raw[currentCustomAbilityOption1Offset] = 12
raw[currentCustomAbilityGradeOffset] = grade
return raw
}
seed := dnfrepo.InventoryRecord{CharacterID: "4", Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 1, RawEntry: makeRaw(0)},
"0:11": {ItemID: 1002, Count: 1, RawEntry: makeRaw(1)},
"0:12": {ItemID: 1003, Count: 1, RawEntry: makeRaw(2)},
"0:50": {ItemID: 10161537, Count: 21},
}}
if err := repositories.Inventory.Save(context.Background(), seed); err != nil {
t.Fatal(err)
}
profiles := map[uint32]currentCustomAbilityItemProfile{
1001: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 12},
1002: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 14},
1003: {applicable: true, optionCount: 2, partSetIndex: 1, equipmentSlot: 15},
}
_, err := commitCurrentCustomAbilitySet(context.Background(), repositories, "4", currentCustomAbilitySetRequest{options: []byte{122}, slots: []int16{9, 11, 12}}, currentCustomAbilityIndividualUnitCatalog(), currentCustomAbilitySetUnitCatalog(), func(itemID uint32) (currentCustomAbilityItemProfile, error) { return profiles[itemID], nil })
if !errors.Is(err, errCurrentCustomAbilitySetMaterial) {
t.Fatalf("commit error=%v", err)
}
stored, _, _ := repositories.Inventory.Load(context.Background(), "4")
if stored.Slots["0:50"].Count != 21 || stored.Slots["0:9"].RawEntry[currentCustomAbilitySetRecordOffset] != 0 {
t.Fatalf("failed commit mutated inventory=%+v", stored.Slots)
}
if got := buildCurrentCustomAbilitySetFailureBody(err); !bytes.Equal(got, []byte{0, 8}) {
t.Fatalf("material failure body=%x", got)
}
}
func TestBuildCurrentCustomAbilitySetNotificationBody(t *testing.T) {
snapshot := currentCustomAbilitySetSnapshot{recordID: 1, requiredParts: 3, options: [5]byte{107}, grade: 2, partSetIndex: 1}
body := buildCurrentCustomAbilitySetNotificationBody(0x12345678, snapshot)
want := []byte{0x78, 0x56, 0x34, 0x12, 1, 3, 107, 0, 0, 0, 0, 2}
if !bytes.Equal(body, want) {
t.Fatalf("notification=%x want=%x", body, want)
}
if binary.LittleEndian.Uint32(body[:4]) != 0x12345678 {
t.Fatalf("actor id=%x", body[:4])
}
}
func TestCurrentCustomAbilitySetRealPVF(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify current part-set PVF")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := parseCurrentCustomAbilitySetCatalog(archive)
if err != nil {
t.Fatal(err)
}
if catalog.partSetIndex != 1 || catalog.minParts != 3 || catalog.maxParts != 6 || catalog.maxOptions != 4 || catalog.materialID != 10161537 || catalog.materialRate != 2.20 || len(catalog.options) != 252 {
t.Fatalf("catalog index=%d parts=%d..%d options=%d material=%d rate=%v rules=%d", catalog.partSetIndex, catalog.minParts, catalog.maxParts, catalog.maxOptions, catalog.materialID, catalog.materialRate, len(catalog.options))
}
if catalog.options[1].group != 1 || catalog.options[1].minParts != 4 || catalog.options[107].group != 2 || catalog.options[252].group != 7 || catalog.options[252].minParts != 6 {
t.Fatalf("rules option1=%+v option107=%+v option252=%+v", catalog.options[1], catalog.options[107], catalog.options[252])
}
if catalog.maxPoints[0][3] != 105 || catalog.maxPoints[3][6] != 600 {
t.Fatalf("max points=%v", catalog.maxPoints)
}
}