服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
176 lines
7.0 KiB
Go
176 lines
7.0 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/jobmap"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestCurrentAvatarFittingRoomProtocolRoundTripShape(t *testing.T) {
|
|
body := make([]byte, currentAvatarFittingRoomRequestSize)
|
|
for index := 0; index < currentAvatarFittingRoomPartCount; index++ {
|
|
offset := currentAvatarFittingRoomPrefixSize + index*currentAvatarFittingRoomRecordSize
|
|
for byteIndex := 0; byteIndex < 8; byteIndex++ {
|
|
body[offset+byteIndex] = 0xff
|
|
}
|
|
body[offset+8] = byte(index)
|
|
}
|
|
request, err := parseCurrentAvatarFittingRoomRequest(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := [currentAvatarFittingRoomPartCount]bool{true, false, true}
|
|
response := buildCurrentAvatarFittingRoomSuccessBody(request, changed)
|
|
if len(response) != 1+8*13 || response[0] != 1 || response[1+9] != 1 || response[1+13+9] != 0 || response[1+2*13+9] != 1 {
|
|
t.Fatalf("response=%x", response)
|
|
}
|
|
}
|
|
|
|
func TestRealPVFAvatarFittingRoomConfigAndTicket(t *testing.T) {
|
|
pvfPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Skipf("runtime Script.pvf unavailable: %v", err)
|
|
}
|
|
config, err := loadCurrentAvatarFittingRoomConfig(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if config.TicketItemID != 490702492 || config.GaugeThreshold != 20 || config.GaugeRewardID != 490702493 || config.GaugeRewardCount != 1 || config.NormalRate != 90 || config.RareRate != 10 || len(config.Sets) != 10 {
|
|
t.Fatalf("config=%+v", config)
|
|
}
|
|
rare, normal := 0, 0
|
|
for _, set := range config.Sets {
|
|
if set.Rare {
|
|
rare++
|
|
} else {
|
|
normal++
|
|
}
|
|
}
|
|
if rare != 2 || normal != 8 {
|
|
t.Fatalf("sets rare=%d normal=%d", rare, normal)
|
|
}
|
|
catalog, err := newPVFDungeonDropCatalog(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{pvfItemCatalog: catalog, automaticRobotAvatarConfig: &config}
|
|
for job := 0; job <= jobmap.MaxID; job++ {
|
|
set, setErr := service.automaticRobotAvatarSet(byte(job), uint64(job+1))
|
|
if setErr != nil {
|
|
t.Fatalf("automatic robot avatar job=%d: %v", job, setErr)
|
|
}
|
|
supported := false
|
|
for _, candidate := range config.Sets {
|
|
if _, ok := candidate.Jobs[byte(job)]; ok {
|
|
supported = true
|
|
break
|
|
}
|
|
}
|
|
if !supported {
|
|
for index, itemID := range set {
|
|
if itemID != 0 {
|
|
t.Fatalf("unsupported automatic robot avatar job=%d part=%d item=%d", job, index, itemID)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
for index, itemID := range set {
|
|
if itemID == 0 {
|
|
t.Fatalf("automatic robot avatar job=%d part=%d is empty", job, index)
|
|
}
|
|
}
|
|
}
|
|
definition, err := catalog.ResolveItem(config.TicketItemID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
isTicket, err := currentPVFAvatarFittingRoomTicket(archive, definition)
|
|
if err != nil || !isTicket {
|
|
t.Fatalf("ticket definition=%+v isTicket=%t err=%v", definition, isTicket, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentAvatarFittingRoomCommitUsesOneAtomicEightPartTransaction(t *testing.T) {
|
|
ctx := context.Background()
|
|
source := avatarCompoundPVFTestSource{}
|
|
var config strings.Builder
|
|
config.WriteString("[ticket index]\n490702492\n[vip gauge]\n20 490702493 1\n[rate]\n100 0\n[fitting room avatar]\n0 `[swordman]`\n")
|
|
definitions := make(map[uint32]dungeonDropItemDefinition)
|
|
equipmentEntries := make(map[string]dnfrepo.EquipmentEntry)
|
|
request := currentAvatarFittingRoomRequest{}
|
|
for index, part := range currentAvatarFittingRoomPartNames {
|
|
currentID, outputID := uint32(1000+index), uint32(2000+index)
|
|
fmt.Fprintf(&config, "`%s` %d\n", part, outputID)
|
|
currentPath, outputPath := fmt.Sprintf("equipment/current_%d.equ", index), fmt.Sprintf("equipment/output_%d.equ", index)
|
|
equipmentType := fmt.Sprintf("[%s avatar]", part)
|
|
if part == "neck" {
|
|
equipmentType = "[breast avatar]"
|
|
}
|
|
if part == "belt" {
|
|
equipmentType = "[waist avatar]"
|
|
}
|
|
source[currentPath] = fmt.Sprintf("[equipment type] `%s`\n", equipmentType)
|
|
source[outputPath] = fmt.Sprintf("[equipment type] `%s`\n", equipmentType)
|
|
definitions[currentID] = dungeonDropItemDefinition{ItemID: currentID, Kind: dungeonDropItemEquipment, PVFPath: currentPath, EquipmentType: equipmentType}
|
|
definitions[outputID] = dungeonDropItemDefinition{ItemID: outputID, Kind: dungeonDropItemEquipment, PVFPath: outputPath, EquipmentType: equipmentType}
|
|
raw := make([]byte, 119)
|
|
binary.LittleEndian.PutUint32(raw[2:6], currentID)
|
|
slot := currentAvatarFittingRoomEquipmentSlots[index]
|
|
equipmentEntries[fmt.Sprintf("%d", slot)] = dnfrepo.EquipmentEntry{SlotIndex: slot, ItemID: int64(currentID), RawEntry: raw}
|
|
request.Records[index].ObjectIndex = int32(3000 + index)
|
|
request.Records[index].CurrentItemID = int32(currentID)
|
|
request.Records[index].PartCode = byte(index)
|
|
}
|
|
config.WriteString("[/fitting room avatar]\n")
|
|
source[currentAvatarFittingRoomPVFPath] = config.String()
|
|
definitions[490702493] = dungeonDropItemDefinition{ItemID: 490702493, Kind: dungeonDropItemStackable, PVFPath: "stackable/reward.stk", StackableType: "[etc]", StackLimit: 1000, SlotStart: 9, SlotEnd: 64}
|
|
catalog := &pvfDungeonDropCatalog{source: source, itemCache: definitions, itemRefs: make(map[uint32]dungeonDropItemReference)}
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Job: "0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ticketRaw := make([]byte, 119)
|
|
binary.LittleEndian.PutUint32(ticketRaw[6:10], 8)
|
|
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{
|
|
"0:10": {ItemID: 490702492, Count: 8, RawEntry: ticketRaw},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19", Entries: equipmentEntries}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Settings.Save(ctx, dnfrepo.SettingsRecord{Scope: "character:19:avatar_fitting_room", Values: map[string]string{"used_parts_total": "19"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{options: options{accountPrefix: defaultAccountPrefix}, repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
|
|
session := &gameSession{accountID: "dnf:1", selectedCharacterID: 19}
|
|
result, err := service.commitCurrentAvatarFittingRoom(ctx, session, catalog, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Domain.TicketConsumed != 8 || result.Domain.RewardGranted != 1 || result.Domain.GaugeAfter != 27 {
|
|
t.Fatalf("result=%+v", result)
|
|
}
|
|
for index, changed := range result.Changed {
|
|
if !changed {
|
|
t.Fatalf("part=%d not changed", index)
|
|
}
|
|
}
|
|
equipment, _, _ := repositories.Equipment.Load(ctx, "19")
|
|
for index, slot := range currentAvatarFittingRoomEquipmentSlots {
|
|
entry := equipment.Entries[fmt.Sprintf("%d", slot)]
|
|
if entry.ItemID != int64(2000+index) {
|
|
t.Fatalf("slot=%d entry=%+v", slot, entry)
|
|
}
|
|
}
|
|
}
|