服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
265 lines
13 KiB
Go
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 ¤tCustomAbilitySetCatalog{
|
|
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 ¤tCustomAbilityCatalog{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)
|
|
}
|
|
}
|