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

255 lines
11 KiB
Go

package dnfbridge
import (
"context"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
dnfavatarcompound "longheng.io/server/internal/modules/dnf/avatarcompound"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
type avatarCompoundPVFTestSource map[string]string
func (source avatarCompoundPVFTestSource) ReadText(relativePath string) (string, error) {
for path, text := range source {
if strings.EqualFold(path, relativePath) {
return text, nil
}
}
return "", fmt.Errorf("missing %s", relativePath)
}
func TestCurrentAvatarCompoundParsesObservedNoPackRequest(t *testing.T) {
body, err := hex.DecodeString("7500150015ecb406160015ecb40675ebb40602000000")
if err != nil {
t.Fatal(err)
}
request, err := parseCurrentAvatarCompoundRequest(body)
if err != nil {
t.Fatal(err)
}
if request.ConsumeSlot != 117 || request.Slot1 != 21 || request.ExpectedItemID1 != 112520213 ||
request.Slot2 != 22 || request.ExpectedItemID2 != 112520213 || request.RequestedItemID != 112520053 ||
request.AbilityNo != 2 || request.MaterialClass != 0 {
t.Fatalf("request=%+v", request)
}
}
func TestCurrentAvatarCompoundSuccessBodyMatchesIDAClass1Op99(t *testing.T) {
request := currentAvatarCompoundRequest{ConsumeSlot: 117, Slot1: 21, Slot2: 22, AbilityNo: 2}
body := buildCurrentAvatarCompoundSuccessBody(request, currentAvatarCompoundResult{
NewSlots: []int16{21},
NewItemIDs: []uint32{112520053},
})
wantHex := "0103011500010000000116000100000000750001000000" +
"150075ebb4060000000002001e000000" + strings.Repeat("00", 30) + "04000000" + strings.Repeat("00", 4) +
"ffff000000000000000002001e000000" + strings.Repeat("00", 30) + "04000000" + strings.Repeat("00", 4)
if got := hex.EncodeToString(body); got != wantHex {
t.Fatalf("body=%s\nwant=%s", got, wantHex)
}
if got := hex.EncodeToString(currentAvatarCompoundFailureBody()); got != "001600" {
t.Fatalf("failure=%s", got)
}
}
func TestCurrentAvatarCompoundPVFReadsCurrentMaterialsAndRates(t *testing.T) {
source := newAvatarCompoundPVFTestSource(950, 325)
config, err := loadCurrentAvatarCompoundConfig(source, 0)
if err != nil {
t.Fatal(err)
}
if config.NormalMaterialID != 21 || config.UpperMaterialID != 899 || config.MasterMaterialID != 490702265 {
t.Fatalf("materials=(%d,%d,%d)", config.NormalMaterialID, config.UpperMaterialID, config.MasterMaterialID)
}
part, found := currentAvatarCompoundRequestedPart(config, 2004)
if !found || part != "coat" {
t.Fatalf("part=%q found=%t", part, found)
}
for _, test := range []struct {
name string
grade1 int64
grade2 int64
materialID uint32
want int64
}{
{name: "ordinary", grade1: 1, grade2: 1, materialID: 21, want: 950},
{name: "mixed-golden", grade1: 1, grade2: 2, materialID: 899, want: 1312},
{name: "upper-master", grade1: 2, grade2: 2, materialID: 490702265, want: 1675},
} {
t.Run(test.name, func(t *testing.T) {
got, rateErr := currentAvatarCompoundRareRate(config, "coat", test.grade1, test.grade2, test.materialID)
if rateErr != nil || got != test.want {
t.Fatalf("rate=%d err=%v want=%d", got, rateErr, test.want)
}
})
}
}
func TestRealPVFObservedAvatarCompoundRequestIsFullyResolvable(t *testing.T) {
archive, err := platformpvf.Open(realLukeRewardPVFPath())
if err != nil {
t.Skipf("runtime Script.pvf unavailable: %v", err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
const sourceItemID uint32 = 112520213
const requestedItemID uint32 = 112520053
sourceDefinition, sourceDocument, err := currentAvatarCompoundDefinition(catalog, sourceItemID)
if err != nil {
t.Fatalf("source item: %v", err)
}
requestedDefinition, requestedDocument, err := currentAvatarCompoundDefinition(catalog, requestedItemID)
if err != nil {
t.Fatalf("requested item: %v", err)
}
sourcePart, sourcePartOK := currentAvatarCompoundDocumentPart(sourceDocument)
requestedPart, requestedPartOK := currentAvatarCompoundDocumentPart(requestedDocument)
if !sourcePartOK || !requestedPartOK || sourcePart != requestedPart || sourceDefinition.Grade < 0 || requestedDefinition.Grade < 0 {
t.Fatalf("source=(%s,%d) requested=(%s,%d)", sourcePart, sourceDefinition.Grade, requestedPart, requestedDefinition.Grade)
}
matchedJob := -1
for job := 0; job < 16; job++ {
config, loadErr := loadCurrentAvatarCompoundConfig(archive, byte(job))
if loadErr != nil {
t.Fatalf("job=%d config: %v", job, loadErr)
}
if part, found := currentAvatarCompoundRequestedPart(config, requestedItemID); found && part == requestedPart {
matchedJob = job
for _, materialID := range []uint32{config.NormalMaterialID, config.UpperMaterialID, config.MasterMaterialID} {
definition, resolveErr := catalog.ResolveItem(materialID)
if resolveErr != nil || definition.Kind != dungeonDropItemStackable {
t.Fatalf("job=%d material=%d definition=%+v err=%v", job, materialID, definition, resolveErr)
}
}
break
}
}
if matchedJob < 0 {
t.Fatalf("requested item=%d part=%s is absent from all current compound-avatar job pools", requestedItemID, requestedPart)
}
t.Logf("observed request resolves through job=%d part=%s", matchedJob, requestedPart)
}
func TestCurrentAvatarCompoundCommitsThreeConsumesAndOneOutput(t *testing.T) {
ctx := context.Background()
source := newAvatarCompoundPVFTestSource(0, 0)
source["equipment/source1.equ"] = "[equipment type] `[coat avatar]`\n[grade] 1\n"
source["equipment/source2.equ"] = "[equipment type] `[coat avatar]`\n[grade] 2\n"
source["equipment/output.equ"] = "[equipment type] `[coat avatar]`\n[grade] 2\n"
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Job: "0"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{
"0:117": {ItemID: 21, Count: 2},
"1:21": {ItemID: 3001, Count: 1},
"1:22": {ItemID: 3002, Count: 1},
}}); 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}
catalog := &pvfDungeonDropCatalog{
source: source,
itemRefs: map[uint32]dungeonDropItemReference{
21: {kind: dungeonDropItemStackable, path: "tool.stk"},
3001: {kind: dungeonDropItemEquipment, path: "source1.equ"},
3002: {kind: dungeonDropItemEquipment, path: "source2.equ"},
2004: {kind: dungeonDropItemEquipment, path: "output.equ"},
},
itemCache: map[uint32]dungeonDropItemDefinition{
21: {ItemID: 21, Kind: dungeonDropItemStackable, PVFPath: "stackable/tool.stk", StackableType: "[material]"},
3001: {ItemID: 3001, Kind: dungeonDropItemEquipment, PVFPath: "equipment/source1.equ", EquipmentType: "[coat avatar]", Grade: 1},
3002: {ItemID: 3002, Kind: dungeonDropItemEquipment, PVFPath: "equipment/source2.equ", EquipmentType: "[coat avatar]", Grade: 2},
2004: {ItemID: 2004, Kind: dungeonDropItemEquipment, PVFPath: "equipment/output.equ", EquipmentType: "[coat avatar]", Grade: 2},
},
}
request := currentAvatarCompoundRequest{
ConsumeSlot: 117, Slot1: 21, ExpectedItemID1: 3001, Slot2: 22, ExpectedItemID2: 3002,
RequestedItemID: 2004, AbilityNo: 7,
}
result, err := service.commitCurrentAvatarCompound(ctx, session, catalog, request)
if err != nil {
t.Fatal(err)
}
if len(result.NewItemIDs) != 1 || result.NewItemIDs[0] != 2004 || len(result.NewSlots) != 1 || result.NewSlots[0] != 21 || result.MaterialRemain != 1 {
t.Fatalf("result=%+v", result)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if inventory.Slots["0:117"].Count != 1 {
t.Fatalf("tool=%+v", inventory.Slots["0:117"])
}
if _, found := inventory.Slots["1:22"]; found {
t.Fatalf("second source remains: %+v", inventory.Slots["1:22"])
}
output := inventory.Slots["1:21"]
if output.ItemID != 2004 || output.Count != 1 || output.Extra["avatar_ability_no"] != "7" || len(output.RawEntry) != currentItemListEntryWireSize {
t.Fatalf("output=%+v", output)
}
}
func TestCurrentAvatarCompoundStaleSourceRollsBackEverything(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Job: "0"}); err != nil {
t.Fatal(err)
}
original := dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{
"0:117": {ItemID: 21, Count: 2}, "1:21": {ItemID: 9999, Count: 1}, "1:22": {ItemID: 3002, Count: 1},
}}
if err := repositories.Inventory.Save(ctx, original); err != nil {
t.Fatal(err)
}
owner, err := dnfavatarcompound.NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
err = owner.Compound(ctx, dnfavatarcompound.Command{AccountID: "dnf:1", CharacterID: "19", Project: func(assets *dnfavatarcompound.Assets) error {
if !currentAvatarCompoundSourceStackValid(assets.Inventory.Slots["1:21"], true, 3001) {
return errCurrentAvatarCompoundSourceInvalid
}
delete(assets.Inventory.Slots, "0:117")
return nil
}})
if !errors.Is(err, errCurrentAvatarCompoundSourceInvalid) {
t.Fatalf("error=%v", err)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if inventory.Slots["0:117"].Count != 2 || inventory.Slots["1:21"].ItemID != 9999 || inventory.Slots["1:22"].ItemID != 3002 {
t.Fatalf("rollback slots=%+v", inventory.Slots)
}
}
func newAvatarCompoundPVFTestSource(rareRate, upperRate int64) avatarCompoundPVFTestSource {
parts := []string{"hat", "hair", "face", "neck", "coat", "pants", "belt", "shoes"}
var rates strings.Builder
var upperRates strings.Builder
var pools strings.Builder
for index, part := range parts {
fmt.Fprintf(&rates, "`%s` %d ", part, rareRate)
fmt.Fprintf(&upperRates, "`%s` %d ", part, upperRate)
fmt.Fprintf(&pools, "[%s avatar]\n1 %d 10 %d 1000\n[/%s avatar]\n", part, 1000+index, 2000+index, part)
}
job := "[grade]\n1\n[rare rate]\n" + rates.String() + "\n[/rare rate]\n" +
"[upper grade]\n2\n[upper rare rate]\n" + upperRates.String() + "\n[/upper rare rate]\n" + pools.String() +
"[common bind cube]\n[material]\n1 999 1\n[/material]\n[/common bind cube]\n" +
"[material]\n1 21 1\n[/material]\n[material_upper]\n1 899 1\n[/material_upper]\n" +
"[material_master]\n1 490702265 1\n[/material_master]\n"
return avatarCompoundPVFTestSource{
"etc/compoundavatar.etc": "1 `CompoundAvatar_swordman.etc`\n",
"etc/CompoundAvatar_swordman.etc": job,
"etc/serverparameter.etc": "[upper bind cube rate bonus]\n200\n[master bind cube rate bonus]\n400\n",
}
}