服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
410 lines
16 KiB
Go
410 lines
16 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestQuestPassiveObjectOp39Completes3157AndRecomputes3146(t *testing.T) {
|
|
ctx := context.Background()
|
|
index, err := dnfpvf.Build(ctx, bridgePVFSource{
|
|
dnfquest.DefaultList: "3146 `parent.qst`\n3157 `passive.qst`\n",
|
|
"n_quest/parent.qst": "[grade]\n`[epic]`\n[type]\n`[quest clear]`\n[int data]\n3157\n",
|
|
"n_quest/passive.qst": "[grade]\n`[sub]`\n[type]\n`[hunt enemy]`\n[main quest]\n3146\n[int data]\n3 -1 13099 3 1\n",
|
|
}, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := dnfquest.Load(ctx, index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc", Level: 5}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
|
|
3146: {Status: "active", ProgressValue: 1},
|
|
3157: {Status: "active", ProgressValue: 1},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstMonsterKey := uint32(437)
|
|
room := &runtimeDungeonRoom{
|
|
monsters: []runtimeDungeonMonster{{ObjectKey: firstMonsterKey, State: runtimeDungeonMonsterAnnounced}},
|
|
byObjectKey: map[uint32]int{firstMonsterKey: 0},
|
|
}
|
|
runtime := &runtimeDungeonState{
|
|
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 3, Difficulty: 0},
|
|
Dungeon: worldmap.Dungeon{ID: 3},
|
|
Character: dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc", Level: 5},
|
|
Session: &worldmap.DungeonSession{},
|
|
Room: room,
|
|
MazeIndex: 2,
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, connID: "quest-passive-object", selectedCharacterID: 19, dungeon: dungeonSessionState{runtime: runtime}}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
questCatalog: catalog,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
scene := worldmap.DungeonRoomScene{Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 76136}}}
|
|
|
|
handled, err := service.handleCurrentDungeonQuestPassiveObjectDeathLocked(session, runtime, scene, firstMonsterKey-1)
|
|
if err != nil || !handled {
|
|
t.Fatalf("handle passive object: handled=%t err=%v", handled, err)
|
|
}
|
|
firstPacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if firstPacket.Header.MsgID != 38 || firstPacket.Header.Classification != 0 {
|
|
t.Fatalf("passive death ACK header=%+v body=%x", firstPacket.Header, firstPacket.Body)
|
|
}
|
|
questPacket, trailing := splitGameServerUpperPacket(t, rest)
|
|
if questPacket.Header.MsgID != currentActiveQuestSnapshotMsgID || questPacket.Header.Classification != 0 || len(trailing) != 0 {
|
|
t.Fatalf("quest snapshot header=%+v body=%x trailing=%x", questPacket.Header, questPacket.Body, trailing)
|
|
}
|
|
persisted, found, err := repositories.Quest.Load(ctx, "19")
|
|
if err != nil || !found {
|
|
t.Fatalf("load persisted quest: found=%t err=%v", found, err)
|
|
}
|
|
if child := persisted.States[3157]; child.Status != "completed" || child.ProgressValue != 0 || child.Extra["completion_enemy_code"] != "13099" {
|
|
t.Fatalf("3157=%+v", child)
|
|
}
|
|
if parent := persisted.States[3146]; parent.Status != "active" || parent.ProgressValue != 0 {
|
|
t.Fatalf("3146=%+v", parent)
|
|
}
|
|
}
|
|
|
|
func TestQuestPassiveObjectCandidateRejectsNonAdjacentUnknownObject(t *testing.T) {
|
|
room := &runtimeDungeonRoom{monsters: []runtimeDungeonMonster{{ObjectKey: 437, State: runtimeDungeonMonsterAnnounced}}}
|
|
if currentDungeonQuestObjectImmediatelyPrecedesAnnouncedActor(room, 435) {
|
|
t.Fatal("non-adjacent unknown object was accepted")
|
|
}
|
|
if !currentDungeonQuestObjectImmediatelyPrecedesAnnouncedActor(room, 436) {
|
|
t.Fatal("adjacent quest-scene object was not recognized")
|
|
}
|
|
}
|
|
|
|
func TestQuestPassiveObjectEnemyRewardTypeThreeGrantsOncePerMapInstance(t *testing.T) {
|
|
ctx := context.Background()
|
|
index, err := dnfpvf.Build(ctx, bridgePVFSource{
|
|
dnfquest.DefaultList: "4000 `passive_reward.qst`\n",
|
|
"n_quest/passive_reward.qst": "[grade]\n`[epic]`\n" +
|
|
"[type]\n`[seeking]`\n" +
|
|
"[enemy reward item]\n" +
|
|
"16111 3 102 -1 10164801 1 100 15\n" +
|
|
"[/enemy reward item]\n",
|
|
}, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := dnfquest.Load(ctx, index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
|
|
4000: {Status: "active"},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scene := worldmap.DungeonRoomScene{
|
|
Coordinate: worldmap.RoomCoordinate{X: 2, Y: 1},
|
|
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 76515}},
|
|
PassiveObjects: []worldmap.PassiveObject{
|
|
{ObjectID: 16111}, {ObjectID: 16112}, {ObjectID: 16111}, {ObjectID: 16111},
|
|
},
|
|
}
|
|
room := &runtimeDungeonRoom{coordinate: scene.Coordinate, mapID: scene.Map.Map.ID}
|
|
runtime := &runtimeDungeonState{
|
|
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 102, Difficulty: 3},
|
|
Dungeon: worldmap.Dungeon{ID: 102},
|
|
Character: dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc"},
|
|
Session: &worldmap.DungeonSession{},
|
|
Room: room,
|
|
MazeIndex: 2,
|
|
Seed: 7,
|
|
Rooms: map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit{
|
|
runtimeDungeonRoomKeyFromScene(scene): {Scene: scene, Room: room, Seed: 7, DropRNG: 7},
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn, connID: "quest-passive-object-reward", selectedCharacterID: 19,
|
|
dungeon: dungeonSessionState{runtime: runtime},
|
|
}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
questCatalog: catalog,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
pvfItemCatalog: &pvfDungeonDropCatalog{
|
|
source: bridgePVFSource{},
|
|
itemCache: map[uint32]dungeonDropItemDefinition{
|
|
10164801: {
|
|
ItemID: 10164801, Kind: dungeonDropItemStackable, StackableType: "[quest]",
|
|
StackLimit: 999, SlotStart: 177, SlotEnd: 232,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for attempt := 0; attempt < 4; attempt++ {
|
|
handled, handleErr := service.handleCurrentDungeonQuestPassiveObjectRewardDeathLocked(
|
|
session, runtime, scene, 16111,
|
|
)
|
|
if handleErr != nil || !handled {
|
|
t.Fatalf("attempt %d handled=%t err=%v", attempt+1, handled, handleErr)
|
|
}
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(ctx, "19")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%t err=%v", found, err)
|
|
}
|
|
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 177)]
|
|
if !found || stack.ItemID != 10164801 || stack.Count != 3 {
|
|
t.Fatalf("quest passive-object stack=%+v found=%t want item=10164801 count=3", stack, found)
|
|
}
|
|
visit := runtime.Rooms[runtimeDungeonRoomKeyFromScene(scene)]
|
|
if got := visit.QuestPassiveObjectDeaths[16111]; got != 3 {
|
|
t.Fatalf("retired passive-object count=%d want=3", got)
|
|
}
|
|
packets := 0
|
|
remaining := conn.write.Bytes()
|
|
for len(remaining) != 0 {
|
|
_, remaining = splitGameServerUpperPacket(t, remaining)
|
|
packets++
|
|
}
|
|
if packets != 7 {
|
|
t.Fatalf("packet count=%d want=7", packets)
|
|
}
|
|
}
|
|
|
|
func TestQuestPassiveObjectEnemyRewardAboveUint16GrantsCurrentPVFQuestItemsWithoutOp38(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rows string
|
|
items map[uint32]dungeonDropItemDefinition
|
|
expected map[int64]int64
|
|
}{
|
|
{
|
|
name: "discover tera stone",
|
|
rows: "85176 3 310 -1 10164804 1 100 10\n",
|
|
items: map[uint32]dungeonDropItemDefinition{
|
|
10164804: {
|
|
ItemID: 10164804, Kind: dungeonDropItemStackable, StackableType: "[quest]",
|
|
StackLimit: 10, SlotStart: 177, SlotEnd: 232,
|
|
},
|
|
},
|
|
expected: map[int64]int64{10164804: 1},
|
|
},
|
|
{
|
|
name: "avoid imperial attention",
|
|
rows: "85176 3 310 -1 10164804 1 100 10\n" +
|
|
"85176 3 310 -1 10164805 2 100 20\n",
|
|
items: map[uint32]dungeonDropItemDefinition{
|
|
10164804: {
|
|
ItemID: 10164804, Kind: dungeonDropItemStackable, StackableType: "[quest]",
|
|
StackLimit: 10, SlotStart: 177, SlotEnd: 232,
|
|
},
|
|
10164805: {
|
|
ItemID: 10164805, Kind: dungeonDropItemStackable, StackableType: "[quest]",
|
|
StackLimit: 20, SlotStart: 177, SlotEnd: 232,
|
|
},
|
|
},
|
|
expected: map[int64]int64{10164804: 1, 10164805: 2},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
index, err := dnfpvf.Build(ctx, bridgePVFSource{
|
|
dnfquest.DefaultList: "4000 `tera_reward.qst`\n",
|
|
"n_quest/tera_reward.qst": "[grade]\n`[epic]`\n" +
|
|
"[type]\n`[seeking]`\n" +
|
|
"[enemy reward item]\n" + test.rows + "[/enemy reward item]\n",
|
|
}, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := dnfquest.Load(ctx, index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
|
|
CharacterID: "19",
|
|
States: map[int64]dnfrepo.QuestState{4000: {Status: "active"}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scene := worldmap.DungeonRoomScene{
|
|
Coordinate: worldmap.RoomCoordinate{X: 2, Y: 1},
|
|
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 76721}},
|
|
PassiveObjects: []worldmap.PassiveObject{{ObjectID: 85176}},
|
|
}
|
|
room := &runtimeDungeonRoom{coordinate: scene.Coordinate, mapID: scene.Map.Map.ID}
|
|
runtime := &runtimeDungeonState{
|
|
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 310, Difficulty: 3},
|
|
Dungeon: worldmap.Dungeon{ID: 310},
|
|
Character: dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc"},
|
|
Session: &worldmap.DungeonSession{},
|
|
Room: room,
|
|
MazeIndex: 5,
|
|
Seed: 7,
|
|
Rooms: map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit{
|
|
runtimeDungeonRoomKeyFromScene(scene): {Scene: scene, Room: room, Seed: 7, DropRNG: 7},
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn, connID: "tera-quest-passive-object", selectedCharacterID: 19,
|
|
dungeon: dungeonSessionState{runtime: runtime},
|
|
}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
questCatalog: catalog,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
pvfItemCatalog: &pvfDungeonDropCatalog{
|
|
source: bridgePVFSource{},
|
|
itemCache: test.items,
|
|
},
|
|
}
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
handled, handleErr := service.handleCurrentDungeonQuestPassiveObjectRewardDeathLocked(
|
|
session, runtime, scene, 85176,
|
|
)
|
|
if handleErr != nil || !handled {
|
|
t.Fatalf("attempt %d handled=%t err=%v", attempt+1, handled, handleErr)
|
|
}
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(ctx, "19")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%t err=%v", found, err)
|
|
}
|
|
actual := make(map[int64]int64, len(test.expected))
|
|
for _, stack := range inventory.Slots {
|
|
if _, wanted := test.expected[stack.ItemID]; wanted {
|
|
actual[stack.ItemID] += stack.Count
|
|
}
|
|
}
|
|
for itemID, want := range test.expected {
|
|
if got := actual[itemID]; got != want {
|
|
t.Fatalf("item=%d count=%d want=%d inventory=%+v", itemID, got, want, inventory.Slots)
|
|
}
|
|
}
|
|
visit := runtime.Rooms[runtimeDungeonRoomKeyFromScene(scene)]
|
|
if got := visit.QuestPassiveObjectDeaths[85176]; got != 1 {
|
|
t.Fatalf("retired tera object count=%d want=1", got)
|
|
}
|
|
packet, trailing := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != 14 || packet.Header.Classification != 0 || len(trailing) != 0 {
|
|
t.Fatalf("above-u16 reward packets first=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRealScriptPVFTeraQuestPassiveObjectRewards(t *testing.T) {
|
|
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
pvfPath = os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
|
}
|
|
if pvfPath == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE or DNF_WORLDMAP_REAL_PVF_SMOKE to verify quests 3954..3957")
|
|
}
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := dnfquest.Load(context.Background(), index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectedItems := map[int64][]dnfquest.EnemyRewardItemEntry{
|
|
3954: {{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164804, Count: 1, DropRate: 100, MaxStack: 10}},
|
|
3955: {{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164804, Count: 1, DropRate: 100, MaxStack: 10}},
|
|
3956: {
|
|
{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164804, Count: 1, DropRate: 100, MaxStack: 10},
|
|
{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164805, Count: 2, DropRate: 100, MaxStack: 20},
|
|
},
|
|
3957: {
|
|
{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164804, Count: 1, DropRate: 100, MaxStack: 10},
|
|
{EnemyCode: 85176, EnemyType: 3, DungeonID: 310, Difficulty: -1, ItemID: 10164805, Count: 2, DropRate: 100, MaxStack: 20},
|
|
},
|
|
}
|
|
for questID, want := range expectedItems {
|
|
definition, found := catalog.Find(questID)
|
|
if !found {
|
|
t.Fatalf("real quest %d missing", questID)
|
|
}
|
|
if len(definition.EnemyRewardItems) != len(want) {
|
|
t.Fatalf("quest %d enemy reward rows=%+v want=%+v", questID, definition.EnemyRewardItems, want)
|
|
}
|
|
for index := range want {
|
|
if definition.EnemyRewardItems[index] != want[index] {
|
|
t.Fatalf("quest %d row %d=%+v want=%+v", questID, index, definition.EnemyRewardItems[index], want[index])
|
|
}
|
|
}
|
|
}
|
|
items, err := newPVFDungeonDropCatalog(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, itemID := range []uint32{10164804, 10164805} {
|
|
definition, err := items.ResolveItem(itemID)
|
|
if err != nil {
|
|
t.Fatalf("resolve quest item %d: %v", itemID, err)
|
|
}
|
|
if definition.Kind != dungeonDropItemStackable || normalizeDungeonPVFSymbol(definition.StackableType) != "quest" ||
|
|
definition.SlotStart != 177 || definition.SlotEnd != 232 {
|
|
t.Fatalf("quest item %d definition=%+v", itemID, definition)
|
|
}
|
|
}
|
|
objectText, err := archive.ReadText("passiveobject/actionobject/map/cataclysm/metrocenter/tera/85176_tera_quest_2.obj")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
objectText = strings.ToLower(objectText)
|
|
if !strings.Contains(objectText, "[hp destroy]") || !strings.Contains(objectText, "[quest item]") {
|
|
t.Fatalf("passive object 85176 is not a destructible quest-item object: %s", objectText)
|
|
}
|
|
for _, stage := range []string{"3954_", "3955_", "3956_", "3957_"} {
|
|
found := false
|
|
for _, file := range archive.Files() {
|
|
path := strings.ToLower(file.ArchivePath)
|
|
if !strings.HasPrefix(path, "map/cataclysm/metrocenter/01_time_plaza/"+stage) {
|
|
continue
|
|
}
|
|
text, readErr := archive.ReadText(file.ArchivePath)
|
|
if readErr == nil && strings.Contains(text, "85176") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("real Time Plaza stage %s has no passive object 85176", stage)
|
|
}
|
|
}
|
|
}
|