DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,730 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestCurrentDungeonEntryConsumesRequiredMainInventoryItemAndCanRestore(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:4": {ItemID: 4726, Count: 2},
|
||||
"0:8": {ItemID: 9001, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4726, Count: 1}},
|
||||
}}}
|
||||
updates, restore, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || restore == nil {
|
||||
t.Fatalf("updates=%+v restore=%v", updates, restore != nil)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || after.Slots["0:4"].Count != 1 {
|
||||
t.Fatalf("after consume=%+v found=%t err=%v", after, found, err)
|
||||
}
|
||||
if err := restore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || restored.Slots["0:4"].Count != 2 {
|
||||
t.Fatalf("after restore=%+v found=%t err=%v", restored, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTowerOfDespairEntryAcceptsEveryDirectInvitationAlternative(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
catalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.dungeonAdmissionCatalog = catalog
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 11008, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4473, Count: 1}},
|
||||
}}}
|
||||
for _, definition := range currentTowerOfDespairEntryItemDefinitions {
|
||||
t.Run(strconv.FormatInt(definition.ItemID, 10), func(t *testing.T) {
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: definition.ItemID, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 140 || admissionUpdateItemID(updates[0]) != uint32(definition.ItemID) || admissionUpdateCount(updates[0]) != 1 {
|
||||
t.Fatalf("item=%d updates=%+v", definition.ItemID, updates)
|
||||
}
|
||||
inventory := loadAdmissionInventory(t, repositories, "19")
|
||||
if stack := inventory.Slots["0:140"]; stack.ItemID != definition.ItemID || stack.Count != 1 {
|
||||
t.Fatalf("item=%d inventory=%+v", definition.ItemID, inventory.Slots)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTowerEntryConsumesOnlyTheMatchedFamilyInvitation(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
catalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.dungeonAdmissionCatalog = catalog
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:141": {ItemID: 10161065, Count: 2},
|
||||
"0:142": {ItemID: 490004952, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 5115, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 10161065, Count: 1}},
|
||||
}}}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 141 || admissionUpdateItemID(updates[0]) != 10161065 || admissionUpdateCount(updates[0]) != 1 {
|
||||
t.Fatalf("grief updates=%+v", updates)
|
||||
}
|
||||
inventory := loadAdmissionInventory(t, repositories, "19")
|
||||
if inventory.Slots["0:141"].Count != 1 || inventory.Slots["0:142"].Count != 2 {
|
||||
t.Fatalf("tower families were mixed: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTowerOfDespairEntryPrefersCanonicalInvitation(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
catalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.dungeonAdmissionCatalog = catalog
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:141": {ItemID: 490004952, Count: 2},
|
||||
"0:142": {ItemID: 4473, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 11008, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4473, Count: 1}},
|
||||
}}}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 142 || admissionUpdateItemID(updates[0]) != 4473 || admissionUpdateCount(updates[0]) != 1 {
|
||||
t.Fatalf("canonical priority updates=%+v", updates)
|
||||
}
|
||||
inventory := loadAdmissionInventory(t, repositories, "19")
|
||||
if inventory.Slots["0:141"].Count != 2 || inventory.Slots["0:142"].Count != 1 {
|
||||
t.Fatalf("canonical priority inventory=%+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTowerEntryDoesNotConsumeBoxesOrAllowanceItems(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
catalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.dungeonAdmissionCatalog = catalog
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
dungeonID int64
|
||||
canonical int64
|
||||
itemID int64
|
||||
}{
|
||||
{name: "despair booster", dungeonID: 11008, canonical: 4473, itemID: 10002584},
|
||||
{name: "despair allowance", dungeonID: 11008, canonical: 4473, itemID: 490002629},
|
||||
{name: "despair action", dungeonID: 11008, canonical: 4473, itemID: 10005999},
|
||||
{name: "grief booster", dungeonID: 5115, canonical: 10161065, itemID: 10161084},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:143": {ItemID: test.itemID, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: test.dungeonID, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: test.canonical, Count: 1}},
|
||||
}}}
|
||||
if _, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime); !errors.Is(err, errDungeonEntryItemMissing) {
|
||||
t.Fatalf("item=%d err=%v", test.itemID, err)
|
||||
}
|
||||
inventory := loadAdmissionInventory(t, repositories, "19")
|
||||
if stack := inventory.Slots["0:143"]; stack.ItemID != test.itemID || stack.Count != 1 {
|
||||
t.Fatalf("item=%d was consumed: %+v", test.itemID, inventory.Slots)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTowerEntryInvitationDefinitionsMatchRealPVF(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify tower entry invitation alternatives")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := newPVFDungeonDropCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for family, definitions := range map[string][]currentTowerEntryItemDefinition{
|
||||
"绝望之邀请函": currentTowerOfDespairEntryItemDefinitions,
|
||||
"悲叹之邀请函": currentTowerOfGriefEntryItemDefinitions,
|
||||
} {
|
||||
for _, expected := range definitions {
|
||||
definition, err := catalog.ResolveItem(uint32(expected.ItemID))
|
||||
if err != nil {
|
||||
t.Fatalf("%s item=%d: %v", family, expected.ItemID, err)
|
||||
}
|
||||
if !strings.EqualFold(definition.PVFPath, expected.PVFPath) ||
|
||||
!strings.Contains(definition.Name, family) ||
|
||||
!strings.EqualFold(normalizeDungeonDropStackableType(definition.StackableType), expected.StackableType) ||
|
||||
strings.TrimSpace(definition.ActionType) != "" {
|
||||
t.Fatalf("%s expected=%+v definition=%+v", family, expected, definition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryRestorePreservesUnrelatedConcurrentSlot(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:4": {ItemID: 4726, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4726, Count: 1}},
|
||||
}}}
|
||||
_, restore, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil || restore == nil {
|
||||
t.Fatalf("consume restore=%v err=%v", restore != nil, err)
|
||||
}
|
||||
concurrent := loadAdmissionInventory(t, repositories, "19")
|
||||
concurrent.Slots["0:9"] = dnfrepo.ItemStack{ItemID: 9009, Count: 7}
|
||||
if err := repositories.Inventory.Save(context.Background(), concurrent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := loadAdmissionInventory(t, repositories, "19")
|
||||
if stack := restored.Slots["0:4"]; stack.ItemID != 4726 || stack.Count != 2 {
|
||||
t.Fatalf("admission slot not restored: %+v", restored.Slots)
|
||||
}
|
||||
if stack := restored.Slots["0:9"]; stack.ItemID != 9009 || stack.Count != 7 {
|
||||
t.Fatalf("unrelated concurrent slot was overwritten: %+v", restored.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryRestoreConflictDoesNotPartiallyRestoreSlots(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:4": {ItemID: 4726, Count: 1},
|
||||
"0:5": {ItemID: 4727, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4726, Count: 1}, {ItemID: 4727, Count: 1}},
|
||||
}}}
|
||||
_, restore, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil || restore == nil {
|
||||
t.Fatalf("consume restore=%v err=%v", restore != nil, err)
|
||||
}
|
||||
concurrent := loadAdmissionInventory(t, repositories, "19")
|
||||
if concurrent.Slots == nil {
|
||||
concurrent.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
concurrent.Slots["0:4"] = dnfrepo.ItemStack{ItemID: 9004, Count: 3}
|
||||
if err := repositories.Inventory.Save(context.Background(), concurrent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restore(); !errors.Is(err, errDungeonEntryItemRestoreConflict) {
|
||||
t.Fatalf("restore conflict err=%v", err)
|
||||
}
|
||||
after := loadAdmissionInventory(t, repositories, "19")
|
||||
if stack := after.Slots["0:4"]; stack.ItemID != 9004 || stack.Count != 3 {
|
||||
t.Fatalf("conflicting slot was overwritten: %+v", after.Slots)
|
||||
}
|
||||
if _, found := after.Slots["0:5"]; found {
|
||||
t.Fatalf("nonconflicting admission slot was partially restored: %+v", after.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryPrefersParticularHellFreePassFromDynamicSlot(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
316 -1
|
||||
[/dungeon]
|
||||
[particularhell freepass item]
|
||||
490005479 1 490005974 1
|
||||
[/particularhell freepass item]
|
||||
`)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:4": {ItemID: 10158964, Count: 2},
|
||||
"0:177": {ItemID: 490005479, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 316, Metadata: worldmap.DungeonMetadata{
|
||||
DungeonType: "[particular hell]",
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 10158964, Count: 2}},
|
||||
}}}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 177 || admissionUpdateItemID(updates[0]) != 490005479 || admissionUpdateCount(updates[0]) != 1 {
|
||||
t.Fatalf("freepass updates=%+v", updates)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || after.Slots["0:177"].Count != 1 || after.Slots["0:4"].Count != 2 {
|
||||
t.Fatalf("after freepass consume=%+v found=%t err=%v", after, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryFallsBackToParticularHellRequiredItems(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
316 -1
|
||||
[/dungeon]
|
||||
[particularhell freepass item]
|
||||
490005479 1
|
||||
[/particularhell freepass item]
|
||||
`)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:4": {ItemID: 10158964, Count: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 316, Metadata: worldmap.DungeonMetadata{
|
||||
DungeonType: "[particular hell]",
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 10158964, Count: 2}},
|
||||
}}}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 4 || admissionUpdateCount(updates[0]) != 1 {
|
||||
t.Fatalf("required-item updates=%+v", updates)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || after.Slots["0:4"].Count != 1 {
|
||||
t.Fatalf("after required consume=%+v found=%t err=%v", after, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryUsesInfernoFreePassBeforePVFItemCondition(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
7539 -1
|
||||
[/dungeon]
|
||||
[item condition]
|
||||
3330
|
||||
[/item condition]
|
||||
[inferno freepass item]
|
||||
490701656 1 490006449 1
|
||||
[/inferno freepass item]
|
||||
`)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:9": {ItemID: 3330, Count: 40},
|
||||
"0:88": {ItemID: 490006449, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 7539, Metadata: worldmap.DungeonMetadata{
|
||||
InfernoDungeon: true,
|
||||
HellPartyRequiredItemCount: worldmap.OptionalInt{Value: 30, Set: true},
|
||||
}}}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 88 || admissionUpdateCount(updates[0]) != 0 {
|
||||
t.Fatalf("inferno freepass updates=%+v", updates)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || after.Slots["0:9"].Count != 40 {
|
||||
t.Fatalf("after inferno pass consume=%+v found=%t err=%v", after, found, err)
|
||||
}
|
||||
if _, passFound := after.Slots["0:88"]; passFound {
|
||||
t.Fatalf("consumed inferno pass remained: %+v", after.Slots["0:88"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryFallsBackToHellItemConditionCount(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
310 -1
|
||||
[/dungeon]
|
||||
[hell dungeon]
|
||||
1
|
||||
[item condition]
|
||||
3330
|
||||
[/item condition]
|
||||
[hell freepass item]
|
||||
490006398 1
|
||||
[/hell freepass item]
|
||||
`)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:9": {ItemID: 3330, Count: 40},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{
|
||||
Request: dungeoncmd.SelectDungeonRequest{SelectionMode: 1, RuntimeToken: 0xffff},
|
||||
Dungeon: worldmap.Dungeon{ID: 310, Metadata: worldmap.DungeonMetadata{
|
||||
HellPartyRequiredItemCount: worldmap.OptionalInt{Value: 28, Set: true},
|
||||
}},
|
||||
}
|
||||
updates, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updates) != 1 || admissionUpdateSlot(updates[0]) != 9 || admissionUpdateCount(updates[0]) != 12 {
|
||||
t.Fatalf("hell material updates=%+v", updates)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found || after.Slots["0:9"].Count != 12 {
|
||||
t.Fatalf("after hell material consume=%+v found=%t err=%v", after, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryRejectsMissingRequiredMainInventoryItem(t *testing.T) {
|
||||
service, session, _, _ := newPremiumServiceTestRuntime(t)
|
||||
runtime := &runtimeDungeonState{
|
||||
Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4726, Count: 1}},
|
||||
}},
|
||||
}
|
||||
if _, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime); err == nil {
|
||||
t.Fatal("missing ticket was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryMissingRequirementDoesNotPartiallyConsume(t *testing.T) {
|
||||
service, session, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:177": {ItemID: 4726, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{
|
||||
{ItemID: 4726, Count: 1},
|
||||
{ItemID: 4727, Count: 1},
|
||||
},
|
||||
}}}
|
||||
if _, _, err := service.consumeCurrentDungeonEntryItems(context.Background(), session, runtime); !errors.Is(err, errDungeonEntryItemMissing) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
after, found, err := repositories.Inventory.Load(context.Background(), "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load after rejection: found=%t err=%v", found, err)
|
||||
}
|
||||
if stack := after.Slots["0:177"]; stack.ItemID != 4726 || stack.Count != 2 {
|
||||
t.Fatalf("first requirement was partially consumed: %+v", stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryPartyConsumesEachMembersOwnAdmissionChoice(t *testing.T) {
|
||||
service, leader, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
follower := &gameSession{selectedCharacterID: 20}
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
316 -1
|
||||
[/dungeon]
|
||||
[particularhell freepass item]
|
||||
490005479 1
|
||||
[/particularhell freepass item]
|
||||
`)
|
||||
for characterID, slots := range map[string]map[string]dnfrepo.ItemStack{
|
||||
"19": {
|
||||
"0:177": {ItemID: 490005479, Count: 1},
|
||||
"0:4": {ItemID: 10158964, Count: 2},
|
||||
},
|
||||
"20": {
|
||||
"0:178": {ItemID: 10158964, Count: 2},
|
||||
},
|
||||
} {
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: characterID,
|
||||
Slots: slots,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
newRuntime := func() *runtimeDungeonState {
|
||||
return &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 316, Metadata: worldmap.DungeonMetadata{
|
||||
DungeonType: "[particular hell]",
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 10158964, Count: 2}},
|
||||
}}}
|
||||
}
|
||||
prepared := []*runtimePartyPreparedDungeonEntry{{session: follower, runtime: newRuntime()}}
|
||||
batch, err := service.consumeCurrentDungeonEntryPartyItems(
|
||||
context.Background(), leader, newRuntime(), prepared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaderUpdates := batch.updatesFor(leader)
|
||||
followerUpdates := batch.updatesFor(follower)
|
||||
if len(leaderUpdates) != 1 || admissionUpdateSlot(leaderUpdates[0]) != 177 ||
|
||||
admissionUpdateItemID(leaderUpdates[0]) != ^uint32(0) || admissionUpdateCount(leaderUpdates[0]) != 0 {
|
||||
t.Fatalf("leader freepass updates=%+v", leaderUpdates)
|
||||
}
|
||||
if len(followerUpdates) != 1 || admissionUpdateSlot(followerUpdates[0]) != 178 ||
|
||||
admissionUpdateItemID(followerUpdates[0]) != ^uint32(0) || admissionUpdateCount(followerUpdates[0]) != 0 {
|
||||
t.Fatalf("follower material updates=%+v", followerUpdates)
|
||||
}
|
||||
if len(prepared[0].entryItemUpdates) != 1 ||
|
||||
admissionUpdateSlot(prepared[0].entryItemUpdates[0]) != 178 {
|
||||
t.Fatalf("follower did not retain its own op14 projection: %+v", prepared[0].entryItemUpdates)
|
||||
}
|
||||
leaderInventory := loadAdmissionInventory(t, repositories, "19")
|
||||
followerInventory := loadAdmissionInventory(t, repositories, "20")
|
||||
if _, found := leaderInventory.Slots["0:177"]; found || leaderInventory.Slots["0:4"].Count != 2 {
|
||||
t.Fatalf("leader admission choice=%+v", leaderInventory.Slots)
|
||||
}
|
||||
if _, found := followerInventory.Slots["0:178"]; found {
|
||||
t.Fatalf("follower admission choice=%+v", followerInventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryPartyFollowerMissingRollsBackEveryEarlierMember(t *testing.T) {
|
||||
service, leader, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
follower := &gameSession{selectedCharacterID: 20}
|
||||
service.worldMapTable = currentDungeonEntryAdmissionTestWorldMap(t, `
|
||||
[dungeon]
|
||||
316 -1
|
||||
[/dungeon]
|
||||
[particularhell freepass item]
|
||||
490005479 1
|
||||
[/particularhell freepass item]
|
||||
`)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:177": {ItemID: 490005479, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "20",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := func() *runtimeDungeonState {
|
||||
return &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 316, Metadata: worldmap.DungeonMetadata{
|
||||
DungeonType: "[particular hell]",
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 10158964, Count: 2}},
|
||||
}}}
|
||||
}
|
||||
prepared := []*runtimePartyPreparedDungeonEntry{{session: follower, runtime: runtime()}}
|
||||
if _, err := service.consumeCurrentDungeonEntryPartyItems(
|
||||
context.Background(), leader, runtime(), prepared,
|
||||
); !errors.Is(err, errDungeonEntryItemMissing) {
|
||||
t.Fatalf("party missing error=%v", err)
|
||||
}
|
||||
leaderInventory := loadAdmissionInventory(t, repositories, "19")
|
||||
if stack := leaderInventory.Slots["0:177"]; stack.ItemID != 490005479 || stack.Count != 1 {
|
||||
t.Fatalf("leader freepass was not restored after follower rejection: %+v", leaderInventory.Slots)
|
||||
}
|
||||
if len(prepared[0].entryItemUpdates) != 0 {
|
||||
t.Fatalf("rejected follower retained stale op14 projection: %+v", prepared[0].entryItemUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonEntryLeaderRuntimeCommitFailureRestoresEveryPartyMember(t *testing.T) {
|
||||
service, leader, repositories, _ := newPremiumServiceTestRuntime(t)
|
||||
follower := &gameSession{selectedCharacterID: 20}
|
||||
for _, characterID := range []string{"19", "20"} {
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: characterID,
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:180": {ItemID: 4726, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
newRuntime := func() *runtimeDungeonState {
|
||||
return &runtimeDungeonState{Dungeon: worldmap.Dungeon{ID: 120, Metadata: worldmap.DungeonMetadata{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 4726, Count: 1}},
|
||||
}}}
|
||||
}
|
||||
prepared := []*runtimePartyPreparedDungeonEntry{{
|
||||
session: follower,
|
||||
runtime: newRuntime(),
|
||||
}}
|
||||
batch, err := service.consumeCurrentDungeonEntryPartyItems(
|
||||
context.Background(), leader, newRuntime(), prepared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The follower mailbox is released after its deduction. Preserve a later
|
||||
// unrelated owner mutation when the leader runtime subsequently fails.
|
||||
followerConcurrent := loadAdmissionInventory(t, repositories, "20")
|
||||
if followerConcurrent.Slots == nil {
|
||||
followerConcurrent.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
followerConcurrent.Slots["0:181"] = dnfrepo.ItemStack{ItemID: 9181, Count: 4}
|
||||
if err := repositories.Inventory.Save(context.Background(), followerConcurrent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A runtime without a worldmap session is rejected at the exact leader
|
||||
// commit boundary, after every member's admission has been persisted.
|
||||
if err := service.commitDungeonRuntimeWithPartyAdmission(
|
||||
leader,
|
||||
newRuntime(),
|
||||
prepared,
|
||||
batch,
|
||||
); err == nil {
|
||||
t.Fatal("invalid leader runtime commit unexpectedly succeeded")
|
||||
}
|
||||
for _, characterID := range []string{"19", "20"} {
|
||||
inventory := loadAdmissionInventory(t, repositories, characterID)
|
||||
if stack := inventory.Slots["0:180"]; stack.ItemID != 4726 || stack.Count != 1 {
|
||||
t.Fatalf("character %s admission was not restored after leader commit failure: %+v", characterID, inventory.Slots)
|
||||
}
|
||||
if characterID == "20" {
|
||||
if stack := inventory.Slots["0:181"]; stack.ItemID != 9181 || stack.Count != 4 {
|
||||
t.Fatalf("follower concurrent slot was overwritten: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The compensation owner is idempotent: an error-path retry cannot apply a
|
||||
// second restore over later inventory state.
|
||||
if err := service.restoreCurrentDungeonEntryInventoryBatch(batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDungeonEntryAdmissionDoesNotConjoinEventOrAddedItems(t *testing.T) {
|
||||
service := &Service{}
|
||||
for _, metadata := range []worldmap.DungeonMetadata{
|
||||
{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 1001, Count: 2}},
|
||||
EventRequiredItems: []worldmap.TicketItem{{ItemID: 2001, Count: 1}},
|
||||
},
|
||||
{
|
||||
RequiredItems: []worldmap.TicketItem{{ItemID: 1001, Count: 2}},
|
||||
AddedRequiredItems: []worldmap.TicketItem{{ItemID: 3001, Count: 3}},
|
||||
},
|
||||
} {
|
||||
_, err := service.resolveCurrentDungeonEntryAdmissionPlan(&runtimeDungeonState{
|
||||
Dungeon: worldmap.Dungeon{ID: 9000, Metadata: metadata},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("event/schedule admission became an ordinary material conjunction: %+v", metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDungeonEntryItemMissingFailureBody(t *testing.T) {
|
||||
got := buildSelectDungeonFailureBody(currentSelectDungeonEntryItemMissingReason)
|
||||
want := []byte{0, 17, 0}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("failure body=%x want=%x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func currentDungeonEntryAdmissionTestWorldMap(t *testing.T, areaDocument string) *worldmap.Table {
|
||||
t.Helper()
|
||||
table, err := worldmap.LoadSource(context.Background(), bridgePVFSource{
|
||||
worldmap.DefaultWorldMapList: "39 `metrocenter.wdm`\n",
|
||||
"worldmap/metrocenter.wdm": areaDocument,
|
||||
}, worldmap.Options{SkipMaps: true, SkipDungeons: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func admissionUpdateSlot(entry currentItemListEntry) int16 {
|
||||
return int16(binary.LittleEndian.Uint16(entry.data[0:2]))
|
||||
}
|
||||
|
||||
func admissionUpdateItemID(entry currentItemListEntry) uint32 {
|
||||
return binary.LittleEndian.Uint32(entry.data[2:6])
|
||||
}
|
||||
|
||||
func admissionUpdateCount(entry currentItemListEntry) uint32 {
|
||||
return binary.LittleEndian.Uint32(entry.data[6:10])
|
||||
}
|
||||
|
||||
func loadAdmissionInventory(
|
||||
t *testing.T,
|
||||
repositories dnfrepo.Group,
|
||||
characterID string,
|
||||
) dnfrepo.InventoryRecord {
|
||||
t.Helper()
|
||||
inventory, found, err := repositories.Inventory.Load(context.Background(), characterID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load admission inventory %s: found=%t err=%v", characterID, found, err)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
Reference in new issue
Block a user