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,395 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestPVFDungeonTutorialRewardCatalogReadsExactProgressTriples(t *testing.T) {
|
||||
catalog, err := newPVFDungeonTutorialRewardCatalog(tutorialRewardFixturePVF())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := catalog.Rewards(37); len(got) != 0 {
|
||||
t.Fatalf("progress 37 rewards=%+v", got)
|
||||
}
|
||||
got := catalog.Rewards(currentDungeonTutorialRewardProgress)
|
||||
want := []pvfDungeonTutorialReward{{
|
||||
Progress: currentDungeonTutorialRewardProgress,
|
||||
ItemID: 8474,
|
||||
Count: 1,
|
||||
}}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("progress 38 rewards=%+v want=%+v", got, want)
|
||||
}
|
||||
got[0].ItemID = 1
|
||||
if replay := catalog.Rewards(currentDungeonTutorialRewardProgress); replay[0].ItemID != 8474 {
|
||||
t.Fatalf("catalog exposed mutable reward slice: %+v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFDungeonTutorialRewardCatalogRejectsMalformedRows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want error
|
||||
}{
|
||||
{name: "missing section", text: "[other]\n1\n", want: errDungeonTutorialRewardSectionMissing},
|
||||
{name: "empty section", text: "[escalade tutorial reward]\n[/escalade tutorial reward]\n", want: errDungeonTutorialRewardRowInvalid},
|
||||
{name: "short row", text: "[escalade tutorial reward]\n38 8474\n[/escalade tutorial reward]\n", want: errDungeonTutorialRewardRowInvalid},
|
||||
{name: "non integer", text: "[escalade tutorial reward]\n38 `8474` 1\n[/escalade tutorial reward]\n", want: errDungeonTutorialRewardRowInvalid},
|
||||
{name: "zero count", text: "[escalade tutorial reward]\n38 8474 0\n[/escalade tutorial reward]\n", want: errDungeonTutorialRewardRowInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := tutorialRewardFixturePVF()
|
||||
source[currentDungeonTutorialRewardPVFPath] = test.text
|
||||
_, err := newPVFDungeonTutorialRewardCatalog(source)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("error=%v want=%v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentDungeonTutorialRewardSuccessPayloadMatchesCurrentEXEReader(t *testing.T) {
|
||||
payload, err := buildCurrentDungeonTutorialRewardSuccessPayload([]currentDungeonTutorialRewardRow{{
|
||||
Slot: 3,
|
||||
ItemID: 8474,
|
||||
Count: 1,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte{1, 3, 0, 0x1A, 0x21, 0, 0, 1, 0, 0, 0}
|
||||
if !bytes.Equal(payload, want) {
|
||||
t.Fatalf("op143 reward payload=%x want=%x", payload, want)
|
||||
}
|
||||
if binary.LittleEndian.Uint16(payload[1:3]) != 3 ||
|
||||
binary.LittleEndian.Uint32(payload[3:7]) != 8474 ||
|
||||
binary.LittleEndian.Uint32(payload[7:11]) != 1 {
|
||||
t.Fatalf("op143 reward row did not round trip: %x", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonTutorialProgress38GrantsPVFHPToQuickSlotOne(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
|
||||
installTutorialRewardFixturePVF(t, service)
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "progress38-op143-hp-reward-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
if err := service.handleDungeonTutorialFlag(session, tutorialRewardFlagRequestBody()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
wantBody := []byte{1, 1, 3, 0, 0x1A, 0x21, 0, 0, 1, 0, 0, 0}
|
||||
if ack.Header.MsgID != uint16(dnfenum.CmdPacketChangeTutorialFlag) ||
|
||||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
!bytes.Equal(ack.Body, wantBody) || len(rest) != 0 {
|
||||
t.Fatalf("progress38 ack=%+v body=%x want=%x rest=%x", ack.Header, ack.Body, wantBody, rest)
|
||||
}
|
||||
|
||||
repositories := tutorialRewardRepositories(t, service)
|
||||
character, found, err := repositories.Character.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load rewarded character found=%t err=%v", found, err)
|
||||
}
|
||||
if got := character.Stats[currentDungeonTutorialRewardMarker(currentDungeonTutorialRewardProgress)]; got != currentDungeonTutorialRewardMarkerValue {
|
||||
t.Fatalf("progress38 durable marker=%d", got)
|
||||
}
|
||||
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load rewarded inventory found=%t err=%v", found, err)
|
||||
}
|
||||
stack, ok := inventory.Slots["0:3"]
|
||||
if !ok || stack.ItemID != 8474 || stack.Count != 1 || stack.Bind || !stack.ExpireAt.IsZero() {
|
||||
t.Fatalf("quick slot one stack=%+v present=%t inventory=%+v", stack, ok, inventory.Slots)
|
||||
}
|
||||
if stack.Extra["source"] != "tutorial_pvf_reward" ||
|
||||
stack.Extra["pvf_path"] != "stackable/tutorial/tutorial_8474.stk" ||
|
||||
stack.Extra["stackable_type"] != "[waste]" ||
|
||||
stack.Extra["stack_limit"] != "1000" || len(stack.RawEntry) != 0 {
|
||||
t.Fatalf("quick slot one metadata=%+v raw_len=%d", stack.Extra, len(stack.RawEntry))
|
||||
}
|
||||
if _, misplaced := inventory.Slots["0:65"]; misplaced {
|
||||
t.Fatalf("new tutorial potion bypassed empty quick slot: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonTutorialProgress38ReplayIsPersistentlyIdempotent(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
|
||||
installTutorialRewardFixturePVF(t, service)
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "progress38-op143-idempotency-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
if err := service.handleDungeonTutorialFlag(session, tutorialRewardFlagRequestBody()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.write.Reset()
|
||||
if err := service.handleDungeonTutorialFlag(session, tutorialRewardFlagRequestBody()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if !bytes.Equal(ack.Body, []byte{1, 0}) || len(rest) != 0 {
|
||||
t.Fatalf("idempotent replay ack=%x rest=%x", ack.Body, rest)
|
||||
}
|
||||
|
||||
repositories := tutorialRewardRepositories(t, service)
|
||||
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load replay inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if stack := inventory.Slots["0:3"]; stack.ItemID != 8474 || stack.Count != 1 || len(inventory.Slots) != 1 {
|
||||
t.Fatalf("replay duplicated tutorial reward: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonTutorialProgress38UsesNextEmptyQuickSlot(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
|
||||
installTutorialRewardFixturePVF(t, service)
|
||||
repositories := tutorialRewardRepositories(t, service)
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:3": {ItemID: 900001, Count: 1},
|
||||
},
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "progress38-op143-next-quick-slot-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
if err := service.handleDungeonTutorialFlag(session, tutorialRewardFlagRequestBody()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if len(ack.Body) != 12 || binary.LittleEndian.Uint16(ack.Body[2:4]) != 4 {
|
||||
t.Fatalf("occupied first quick slot ack=%x", ack.Body)
|
||||
}
|
||||
inventory, _, err := repositories.Inventory.Load(context.Background(), "99")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inventory.Slots["0:3"].ItemID != 900001 ||
|
||||
inventory.Slots["0:4"].ItemID != 8474 || inventory.Slots["0:4"].Count != 1 {
|
||||
t.Fatalf("next quick slot inventory=%+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCurrentDungeonTutorialRewardUsesEmptyQuickSlotBeforeCategoryStack(t *testing.T) {
|
||||
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:65": {ItemID: 8474, Count: 5},
|
||||
}}
|
||||
definition := currentDungeonTutorialRewardDefinition{
|
||||
Reward: pvfDungeonTutorialReward{
|
||||
Progress: currentDungeonTutorialRewardProgress,
|
||||
ItemID: 8474,
|
||||
Count: 1,
|
||||
},
|
||||
Item: currentDungeonTutorialRewardItemDefinition{
|
||||
ItemID: 8474,
|
||||
PVFPath: "stackable/tutorial/tutorial_8474.stk",
|
||||
StackableType: "[waste]",
|
||||
StackLimit: 1000,
|
||||
SlotStart: 65,
|
||||
SlotEnd: 120,
|
||||
},
|
||||
}
|
||||
|
||||
slot, err := addCurrentDungeonTutorialRewardToInventory(
|
||||
&record,
|
||||
currentDungeonTutorialRewardProgress,
|
||||
definition,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slot != currentDungeonTutorialQuickSlotStart ||
|
||||
record.Slots["0:3"].ItemID != 8474 ||
|
||||
record.Slots["0:3"].Count != 1 ||
|
||||
record.Slots["0:65"].Count != 5 {
|
||||
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonTutorialProgress38RequiresOwnedActivePVFTutorial(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
|
||||
singleMonster: true,
|
||||
disableTutorial: true,
|
||||
})
|
||||
installTutorialRewardFixturePVF(t, service)
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "progress38-op143-non-tutorial-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
if err := service.handleDungeonTutorialFlag(session, tutorialRewardFlagRequestBody()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn.write.Len() != 0 {
|
||||
t.Fatalf("non-tutorial progress38 wrote=%x", conn.write.Bytes())
|
||||
}
|
||||
repositories := tutorialRewardRepositories(t, service)
|
||||
character, _, _ := repositories.Character.Load(context.Background(), "99")
|
||||
inventory, _, _ := repositories.Inventory.Load(context.Background(), "99")
|
||||
if character.Stats[currentDungeonTutorialRewardMarker(currentDungeonTutorialRewardProgress)] != 0 ||
|
||||
len(inventory.Slots) != 0 {
|
||||
t.Fatalf("non-tutorial progress38 mutated character=%+v inventory=%+v", character.Stats, inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverDungeonTutorialRewardRollsBackAllRowsWhenInventoryIsFull(t *testing.T) {
|
||||
service, _ := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
|
||||
repositories := tutorialRewardRepositories(t, service)
|
||||
slots := map[string]dnfrepo.ItemStack{
|
||||
"0:3": {ItemID: 8474, Count: 1},
|
||||
}
|
||||
for slot := int16(4); slot <= currentDungeonTutorialQuickSlotEnd; slot++ {
|
||||
slots[currentDungeonTutorialMainSlotKey(slot)] = dnfrepo.ItemStack{ItemID: 900000 + int64(slot), Count: 1}
|
||||
}
|
||||
for slot := int16(65); slot <= 120; slot++ {
|
||||
slots[currentDungeonTutorialMainSlotKey(slot)] = dnfrepo.ItemStack{ItemID: 910000 + int64(slot), Count: 1}
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: slots,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definitions := []currentDungeonTutorialRewardDefinition{
|
||||
{
|
||||
Reward: pvfDungeonTutorialReward{Progress: 38, ItemID: 8474, Count: 1},
|
||||
Item: currentDungeonTutorialRewardItemDefinition{
|
||||
ItemID: 8474, StackableType: "[waste]", StackLimit: 1000,
|
||||
SlotStart: 65, SlotEnd: 120,
|
||||
},
|
||||
},
|
||||
{
|
||||
Reward: pvfDungeonTutorialReward{Progress: 38, ItemID: 8475, Count: 1},
|
||||
Item: currentDungeonTutorialRewardItemDefinition{
|
||||
ItemID: 8475, StackableType: "[waste]", StackLimit: 1000,
|
||||
SlotStart: 65, SlotEnd: 120,
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := deliverCurrentDungeonTutorialReward(
|
||||
context.Background(),
|
||||
repositories.CharacterAssets,
|
||||
"99",
|
||||
38,
|
||||
definitions,
|
||||
time.Now().UTC(),
|
||||
); !errors.Is(err, errDungeonTutorialRewardInventoryFull) {
|
||||
t.Fatalf("delivery error=%v want=%v", err, errDungeonTutorialRewardInventoryFull)
|
||||
}
|
||||
|
||||
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load rolled-back inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if stack := inventory.Slots["0:3"]; stack.ItemID != 8474 || stack.Count != 1 {
|
||||
t.Fatalf("first reward row escaped rollback: %+v", stack)
|
||||
}
|
||||
character, found, err := repositories.Character.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load rolled-back character found=%t err=%v", found, err)
|
||||
}
|
||||
if marker := character.Stats[currentDungeonTutorialRewardMarker(38)]; marker != 0 {
|
||||
t.Fatalf("failed bundle persisted reward marker=%d", marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFTutorialProgress38RewardIsBeginnerHPPotion(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to inspect the real tutorial reward")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewardCatalog, err := newPVFDungeonTutorialRewardCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewards := rewardCatalog.Rewards(currentDungeonTutorialRewardProgress)
|
||||
if len(rewards) != 1 || rewards[0].ItemID != 8474 || rewards[0].Count != 1 {
|
||||
t.Fatalf("real progress38 rewards=%+v", rewards)
|
||||
}
|
||||
item, err := resolveCurrentDungeonTutorialRewardItem(archive, rewards[0].ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.PVFPath != "stackable/tutorial/tutorial_8474.stk" ||
|
||||
item.StackableType != "[waste]" || item.StackLimit != 1000 ||
|
||||
item.SlotStart != 65 || item.SlotEnd != 120 {
|
||||
t.Fatalf("real tutorial potion=%+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func tutorialRewardFixturePVF() bridgePVFSource {
|
||||
return bridgePVFSource{
|
||||
currentDungeonTutorialRewardPVFPath: "[escalade tutorial reward]\n38 8474 1\n[/escalade tutorial reward]\n",
|
||||
"monster/monster.lst": "3001 `tutorial_scope.gob`\n",
|
||||
"stackable/stackable.lst": "8474 `tutorial/tutorial_8474.stk`\n",
|
||||
"equipment/equipment.lst": "9001 `weapon/placeholder.equ`\n",
|
||||
"stackable/tutorial/tutorial_8474.stk": "[name]\n`Beginner HP Potion`\n" +
|
||||
"[stackable type]\n`[waste]` 4\n" +
|
||||
"[stack limit]\n1000\n" +
|
||||
"[hp recovery]\n`+` 300 1000 `myself`\n",
|
||||
}
|
||||
}
|
||||
|
||||
func installTutorialRewardFixturePVF(t *testing.T, service *Service) {
|
||||
t.Helper()
|
||||
catalog, err := newPVFDungeonMonsterCatalog(tutorialRewardFixturePVF())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.worldMapMu.Lock()
|
||||
service.dungeonMonsterTable = catalog
|
||||
service.worldMapMu.Unlock()
|
||||
}
|
||||
|
||||
func tutorialRewardRepositories(t *testing.T, service *Service) dnfrepo.Group {
|
||||
t.Helper()
|
||||
repositories, ok := service.repositoryGroup()
|
||||
if !ok {
|
||||
t.Fatal("tutorial reward repositories unavailable")
|
||||
}
|
||||
return repositories
|
||||
}
|
||||
|
||||
func tutorialRewardFlagRequestBody() []byte {
|
||||
return []byte{
|
||||
currentDungeonTutorialFinalPrefix,
|
||||
byte(currentDungeonTutorialRewardProgress), 0, 0, 0,
|
||||
currentDungeonTutorialFinalCommit,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user