Files
90CNServer/server/internal/services/dnfbridge/dungeon_quest_passive_object_test.go
T
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

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)
}
}
}