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

337 lines
14 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"os"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
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 TestDecodeCurrentCompoundRecipeRequestUsesCurrentEXELayouts(t *testing.T) {
request, err := decodeCurrentCompoundRecipeRequest([]byte{0x49, 0, 0, 0, 0, 1, 0, 0, 1})
if err != nil || request.SourceValue != 73 || request.SourceIsID || request.RequestedCount != 1 {
t.Fatalf("nine-byte request=%+v err=%v", request, err)
}
request, err = decodeCurrentCompoundRecipeRequest([]byte{0xE8, 3, 0, 0, 1, 2, 0})
if err != nil || request.SourceValue != 1000 || !request.SourceIsID || request.RequestedCount != 2 {
t.Fatalf("seven-byte request=%+v err=%v", request, err)
}
for _, body := range [][]byte{
{0x49, 0, 0, 0, 0, 1, 0, 1, 1},
{0x49, 0, 0, 0, 2, 1, 0},
{0x49, 0, 0, 0, 0, 0, 0},
make([]byte, 8),
} {
if _, err := decodeCurrentCompoundRecipeRequest(body); err == nil {
t.Fatalf("malformed request accepted: %x", body)
}
}
}
func TestParseCurrentCompoundRecipeSeparatesPurchaseMaterialFromCraftInputs(t *testing.T) {
catalog := mustCurrentCompoundRecipeCatalog(t)
preserved, err := parseCurrentCompoundRecipe(catalog, 1000)
if err != nil {
t.Fatal(err)
}
if preserved.MaintainAbility != "normal" || len(preserved.NeedMaterial) != 1 ||
preserved.NeedMaterial[0].ItemID != 4000 || preserved.NeedMaterial[0].Count != 3 {
t.Fatalf("preserved recipe=%+v", preserved)
}
for _, input := range preserved.Inputs {
if input.ItemID == 4000 {
t.Fatalf("purchase material leaked into craft inputs: %+v", preserved)
}
}
}
func TestResolveCurrentCompoundRecipeIDDirectCatalogDoesNotRequireRecipeScroll(t *testing.T) {
repositories := mustCurrentCompoundRecipeRepositories(t, 90, map[string]int64{"gold": 5000}, map[string]dnfrepo.ItemStack{
"0:74": {ItemID: 2000, Count: 1},
"0:75": {ItemID: 3000, Count: 800},
})
recipeID, recipeSlot, err := resolveCurrentCompoundRecipeID(
context.Background(),
repositories.Inventory,
"19",
currentCompoundRecipeRequest{SourceValue: 1000, SourceIsID: true, RequestedCount: 1},
)
if err != nil || recipeID != 1000 || recipeSlot != -1 {
t.Fatalf("direct production recipe id=%d slot=%d err=%v", recipeID, recipeSlot, err)
}
}
func TestCommitCurrentCompoundRecipeDirectCatalogConsumesOnlyInputs(t *testing.T) {
catalog := mustCurrentCompoundRecipeCatalog(t)
recipe, err := parseCurrentCompoundRecipe(catalog, 1000)
if err != nil {
t.Fatal(err)
}
repositories := mustCurrentCompoundRecipeRepositories(t, 90, map[string]int64{"gold": 5000}, map[string]dnfrepo.ItemStack{
"0:74": {ItemID: 2000, Count: 1},
"0:75": {ItemID: 3000, Count: 800},
})
request := currentCompoundRecipeRequest{SourceValue: 1000, SourceIsID: true, RequestedCount: 1}
result, err := commitCurrentCompoundRecipe(context.Background(), repositories, "account-1", "19", request, -1, recipe)
if err != nil {
t.Fatal(err)
}
if len(result.Consumed) != 2 || len(result.Outputs) != 1 || len(result.Refreshes) != 3 {
t.Fatalf("direct production result=%+v", result)
}
for _, row := range result.Consumed {
if row.Slot == -1 || row.DeletedCount == 0 {
t.Fatalf("catalog recipe appeared as consumed inventory row: %+v", result.Consumed)
}
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if _, exists := inventory.Slots["0:-1"]; exists {
t.Fatalf("direct production fabricated recipe source slot: %+v", inventory.Slots["0:-1"])
}
if output := inventory.Slots["0:74"]; output.ItemID != 2001 || output.Count != 1 {
t.Fatalf("direct production output=%+v", output)
}
}
func TestCommitCurrentCompoundRecipeRejectsDirectUseOfPhysicalBlueprint(t *testing.T) {
catalog := mustCurrentCompoundRecipeCatalog(t)
recipe, err := parseCurrentCompoundRecipe(catalog, 1000)
if err != nil {
t.Fatal(err)
}
recipe.CatalogDirect = false
repositories := mustCurrentCompoundRecipeRepositories(t, 90, map[string]int64{"gold": 5000}, map[string]dnfrepo.ItemStack{
"0:74": {ItemID: 2000, Count: 1},
"0:75": {ItemID: 3000, Count: 800},
})
request := currentCompoundRecipeRequest{SourceValue: 1000, SourceIsID: true, RequestedCount: 1}
_, err = commitCurrentCompoundRecipe(context.Background(), repositories, "account-1", "19", request, -1, recipe)
if !errors.Is(err, errCurrentCompoundRecipeInvalid) {
t.Fatalf("physical blueprint direct-use error=%v", err)
}
inventory, found, loadErr := repositories.Inventory.Load(context.Background(), "19")
if loadErr != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, loadErr)
}
if source := inventory.Slots["0:74"]; source.ItemID != 2000 || source.Count != 1 {
t.Fatalf("rejected physical blueprint changed source=%+v", source)
}
}
func TestCommitCurrentCompoundRecipeConsumesRecipePreservesEquipmentAndBuildsRefreshes(t *testing.T) {
catalog := mustCurrentCompoundRecipeCatalog(t)
recipe, err := parseCurrentCompoundRecipe(catalog, 1000)
if err != nil {
t.Fatal(err)
}
raw := make([]byte, currentItemListEntryWireSize)
for index := range raw {
raw[index] = byte(index)
}
source := dnfrepo.ItemStack{
ItemID: 2000,
Count: 1,
RawEntry: raw,
Extra: map[string]string{
"reinforce_level": "12",
"amplify_stat": "3",
"forging_level": "7",
"enchant_card_id": "9876",
},
}
repositories := mustCurrentCompoundRecipeRepositories(t, 90, map[string]int64{"gold": 5000}, map[string]dnfrepo.ItemStack{
"0:73": {ItemID: 1000, Count: 1},
"0:74": source,
"0:75": {ItemID: 3000, Count: 4399},
})
result, err := commitCurrentCompoundRecipe(context.Background(), repositories, "account-1", "19", currentCompoundRecipeRequest{SourceValue: 73, RequestedCount: 1}, 73, recipe)
if err != nil {
t.Fatal(err)
}
if len(result.Consumed) != 3 || len(result.Outputs) != 1 || result.Outputs[0].Action != 1 || len(result.Refreshes) != 4 {
t.Fatalf("compound result=%+v", result)
}
refreshed := make(map[int16]bool)
for _, refresh := range result.Refreshes {
if refresh.ListType != dnfrepo.MainInventoryListType {
t.Fatalf("unexpected refresh=%+v", refresh)
}
refreshed[refresh.SlotIndex] = true
}
for _, slot := range []int16{73, 74, 75} {
if !refreshed[slot] {
t.Fatalf("changed slot %d missing from refreshes=%+v", slot, result.Refreshes)
}
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if recipeStack, found := inventory.Slots["0:73"]; found {
t.Fatalf("recipe stack was not consumed: %+v", recipeStack)
}
output, found := inventory.Slots["0:74"]
if !found || output.ItemID != 2001 || output.Count != 1 || output.Extra["reinforce_level"] != "12" ||
output.Extra["amplify_stat"] != "3" || output.Extra["forging_level"] != "7" || output.Extra["enchant_card_id"] != "9876" {
t.Fatalf("preserved output=%+v found=%t", output, found)
}
if material := inventory.Slots["0:75"]; material.ItemID != 3000 || material.Count != 3599 {
t.Fatalf("input material remaining=%+v want count=3599", material)
}
body := buildCurrentCompoundRecipeSuccessBody(result)
if len(body) != 1+1+3*7+1+1+currentItemListEntryWireSize || body[0] != 1 || body[1] != 3 ||
binary.LittleEndian.Uint32(body[5:9]) != 1 || binary.LittleEndian.Uint32(body[12:16]) != 1 ||
binary.LittleEndian.Uint32(body[19:23]) != 800 || body[23] != 1 || body[24] != 1 ||
binary.LittleEndian.Uint32(body[27:31]) != 2001 {
t.Fatalf("success body len=%d body=%x", len(body), body)
}
}
func TestCommitCurrentCompoundRecipeRollsBackRecipeConsumptionWhenInputIsMissing(t *testing.T) {
catalog := mustCurrentCompoundRecipeCatalog(t)
recipe, err := parseCurrentCompoundRecipe(catalog, 1000)
if err != nil {
t.Fatal(err)
}
repositories := mustCurrentCompoundRecipeRepositories(t, 90, map[string]int64{"gold": 5000}, map[string]dnfrepo.ItemStack{
"0:73": {ItemID: 1000, Count: 1},
"0:74": {ItemID: 2000, Count: 1},
})
_, err = commitCurrentCompoundRecipe(context.Background(), repositories, "account-1", "19", currentCompoundRecipeRequest{SourceValue: 73, RequestedCount: 1}, 73, recipe)
if !errors.Is(err, errCurrentCompoundRecipeMaterial) {
t.Fatalf("commit error=%v want material error", err)
}
inventory, found, loadErr := repositories.Inventory.Load(context.Background(), "19")
if loadErr != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, loadErr)
}
if stack := inventory.Slots["0:73"]; stack.ItemID != 1000 || stack.Count != 1 {
t.Fatalf("failed commit consumed recipe: %+v", stack)
}
if stack := inventory.Slots["0:74"]; stack.ItemID != 2000 || stack.Count != 1 {
t.Fatalf("failed commit changed equipment input: %+v", stack)
}
}
func TestCurrentCompoundRecipeRealPVF(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify current recipe PVF")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
preserved, err := parseCurrentCompoundRecipe(catalog, 10303386)
if err != nil || preserved.MaintainAbility != "normal" || len(preserved.NeedMaterial) != 1 ||
preserved.NeedMaterial[0].ItemID != 10158964 || preserved.NeedMaterial[0].Count != 3 {
t.Fatalf("real preserved recipe=%+v err=%v", preserved, err)
}
ring, err := parseCurrentCompoundRecipe(catalog, 10093312)
if err != nil || len(ring.Inputs) == 0 || len(ring.Outputs) == 0 {
t.Fatalf("real ring recipe=%+v err=%v", ring, err)
}
cloth, err := parseCurrentCompoundRecipe(catalog, 5057)
if err != nil || len(cloth.Inputs) != 2 || cloth.Inputs[0].ItemID != 3030 || cloth.Inputs[0].Count != 1 ||
cloth.Inputs[1].ItemID != 0 || cloth.Inputs[1].Count != 50 || len(cloth.Outputs) != 1 ||
cloth.Outputs[0].ItemID != 3134 || cloth.Outputs[0].Count != 1 {
t.Fatalf("real production cloth recipe=%+v err=%v", cloth, err)
}
t.Logf("real recipe refs=%d weapon_ref=%+v ring_ref=%+v", len(catalog.itemRefs), catalog.itemRefs[10303386], catalog.itemRefs[10093312])
}
func TestRealPVFTownProductionCatalogRecipesResolve(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify current recipe PVF")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
checked := 0
// Current-client production tabs (cloth, leather, organic material,
// metal and other) project the contiguous PVF catalog 5057..5080.
for itemID := uint32(5057); itemID <= 5080; itemID++ {
definition, resolveErr := catalog.ResolveItem(itemID)
if resolveErr != nil {
t.Fatalf("resolve production recipe item=%d: %v", itemID, resolveErr)
}
if definition.Kind != dungeonDropItemStackable || normalizeDungeonDropStackableType(definition.StackableType) != "[recipe]" {
t.Fatalf("production item=%d path=%s kind=%s type=%s", itemID, definition.PVFPath, definition.Kind, definition.StackableType)
}
text, readErr := archive.ReadText(definition.PVFPath)
if readErr != nil {
t.Fatalf("read recipe item=%d path=%s: %v", itemID, definition.PVFPath, readErr)
}
document, documentErr := dnfpvf.Parse(definition.PVFPath, text)
if documentErr != nil {
t.Fatalf("parse recipe item=%d path=%s: %v", itemID, definition.PVFPath, documentErr)
}
stackableTypeValues := document.Ints("stackable type")
if len(stackableTypeValues) == 0 || stackableTypeValues[0] != 0 {
t.Fatalf("production recipe item=%d path=%s subtype=%v", itemID, definition.PVFPath, stackableTypeValues)
}
checked++
if _, parseErr := parseCurrentCompoundRecipe(catalog, itemID); parseErr != nil {
t.Fatalf("recipe item=%d path=%s: %v", itemID, definition.PVFPath, parseErr)
}
}
if checked != 24 {
t.Fatalf("town production recipe count=%d want=24", checked)
}
t.Logf("town production recipe audit definitions=%d", checked)
}
func mustCurrentCompoundRecipeCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
source := bridgePVFSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "1000 `preserve_recipe.stk`\n3000 `material.stk`\n4000 `purchase_material.stk`\n",
"equipment/equipment.lst": "2000 `source_weapon.equ`\n2001 `output_weapon.equ`\n",
"stackable/preserve_recipe.stk": "[stackable type]\n`[recipe]` 0\n[input item]\n2000 1 3000 800\n[/input item]\n[output item]\n2001 1\n[/output item]\n[success rate]\n10000\n[maintain ability]\n`normal`\n[need material]\n4000 3\n[/need material]\n",
"stackable/material.stk": "[stackable type]\n`[material]`\n[stack limit]\n1000\n",
"stackable/purchase_material.stk": "[stackable type]\n`[material]`\n[stack limit]\n1000\n",
"equipment/source_weapon.equ": "[equipment type]\n`[weapon]` 1\n[durability]\n40\n",
"equipment/output_weapon.equ": "[equipment type]\n`[weapon]` 1\n[durability]\n40\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
return catalog
}
func mustCurrentCompoundRecipeRepositories(t *testing.T, level int, stats map[string]int64, slots map[string]dnfrepo.ItemStack) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1", Level: level, Stats: stats}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: slots}); err != nil {
t.Fatal(err)
}
return repositories
}