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