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,306 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfepicwish "longheng.io/server/internal/modules/dnf/epicwish"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
func TestDecodeCurrentEpicWishUpdateMatchesCurrentEXELayout(t *testing.T) {
|
||||
want := []uint32{101000004, 101010005, 0, 101020006, 101030007}
|
||||
body, err := buildCurrentEpicWishSnapshotBody(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := decodeCurrentEpicWishUpdate(body)
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("decode=%v error=%v body=%x", got, err, body)
|
||||
}
|
||||
for _, invalid := range [][]byte{
|
||||
nil,
|
||||
{6},
|
||||
{1},
|
||||
{2, 1, 0, 0, 0, 1, 0, 0, 0},
|
||||
} {
|
||||
if _, err := decodeCurrentEpicWishUpdate(invalid); err == nil {
|
||||
t.Fatalf("invalid body accepted: %x", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCurrentEpicWishUpdateCommitsThenSendsAckAndClass0Snapshot(t *testing.T) {
|
||||
service, repositories, session := currentEpicWishTestService(t, "account-1")
|
||||
connection := &bufferConn{}
|
||||
session.conn = connection
|
||||
session.dungeon.runtime = &runtimeDungeonState{
|
||||
Character: dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"},
|
||||
}
|
||||
want := []uint32{101000004, 0, 101010005}
|
||||
body, err := buildCurrentEpicWishSnapshotBody(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.handleCurrentEpicWishUpdate(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character, found, err := repositories.Character.Load(context.Background(), "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load found=%t error=%v", found, err)
|
||||
}
|
||||
items, err := dnfepicwish.ItemsFromCharacter(character)
|
||||
if err != nil || !reflect.DeepEqual(items, want) {
|
||||
t.Fatalf("items=%v error=%v want=%v", items, err, want)
|
||||
}
|
||||
if runtimeItems, err := dnfepicwish.ItemsFromCharacter(session.dungeon.runtime.Character); err != nil ||
|
||||
!reflect.DeepEqual(runtimeItems, want) {
|
||||
t.Fatalf("runtime items=%v error=%v", runtimeItems, err)
|
||||
}
|
||||
|
||||
ack, rest := splitCurrentGameServerUpperPacketAuto(t, connection.write.Bytes())
|
||||
if ack.Header.Classification != 1 || ack.Header.MsgID != uint16(dnfenum.CmdPacketUpdateWishItem) ||
|
||||
len(ack.Body) != 4 || ack.Body[3] != 1 {
|
||||
t.Fatalf("ack=%+v body=%x", ack.Header, ack.Body)
|
||||
}
|
||||
snapshot, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
||||
if snapshot.Header.Classification != 0 || snapshot.Header.MsgID != currentEpicWishSnapshotWireOpcode ||
|
||||
len(snapshot.Body) != 3+len(body) || !reflect.DeepEqual(snapshot.Body[3:], body) {
|
||||
t.Fatalf("snapshot=%+v body=%x want=%x", snapshot.Header, snapshot.Body, body)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("trailing packets=%x", rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCurrentEpicWishUpdateFailureAcknowledgesWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accountID string
|
||||
items []uint32
|
||||
}{
|
||||
{name: "account mismatch", accountID: "foreign", items: []uint32{101000004}},
|
||||
{name: "non epic item", accountID: "account-1", items: []uint32{101040009}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service, repositories, session := currentEpicWishTestService(t, test.accountID)
|
||||
connection := &bufferConn{}
|
||||
session.conn = connection
|
||||
body, err := buildCurrentEpicWishSnapshotBody(test.items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.handleCurrentEpicWishUpdate(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character, found, err := repositories.Character.Load(context.Background(), "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load found=%t error=%v", found, err)
|
||||
}
|
||||
if items, err := dnfepicwish.ItemsFromCharacter(character); err != nil || len(items) != 0 {
|
||||
t.Fatalf("mutated items=%v error=%v", items, err)
|
||||
}
|
||||
failure, rest := splitCurrentGameServerUpperPacketAuto(t, connection.write.Bytes())
|
||||
if failure.Header.Classification != 1 ||
|
||||
failure.Header.MsgID != uint16(dnfenum.CmdPacketUpdateWishItem) ||
|
||||
len(failure.Body) != 4 || failure.Body[3] != 0 {
|
||||
t.Fatalf("failure=%+v body=%x", failure.Header, failure.Body)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("failure emitted snapshot/trailing=%x", rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCurrentDungeonEpicWishSnapshotIsTimeRiftOnly(t *testing.T) {
|
||||
service, repositories, session := currentEpicWishTestService(t, "account-1")
|
||||
owner, err := dnfepicwish.NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Apply(context.Background(), dnfepicwish.Command{
|
||||
CharacterID: "19",
|
||||
AccountID: "account-1",
|
||||
Items: []uint32{101000004},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connection := &bufferConn{}
|
||||
session.conn = connection
|
||||
runtime := &runtimeDungeonState{
|
||||
Dungeon: worldmap.Dungeon{Metadata: worldmap.DungeonMetadata{InfernoDungeon: true}},
|
||||
Character: result.Character,
|
||||
}
|
||||
if err := service.sendCurrentDungeonEpicWishSnapshot(session, runtime, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet, rest := splitCurrentGameServerUpperPacketAuto(t, connection.write.Bytes())
|
||||
if packet.Header.Classification != 0 || packet.Header.MsgID != currentEpicWishSnapshotWireOpcode ||
|
||||
len(packet.Body) != 8 || packet.Body[3] != 1 ||
|
||||
binary.LittleEndian.Uint32(packet.Body[4:8]) != 101000004 || len(rest) != 0 {
|
||||
t.Fatalf("packet=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
||||
}
|
||||
|
||||
for name, dungeon := range map[string]worldmap.Dungeon{
|
||||
"ordinary": {},
|
||||
"magic rift": {Metadata: worldmap.DungeonMetadata{DungeonType: "[particular hell]"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
connection := &bufferConn{}
|
||||
session.conn = connection
|
||||
runtime.Dungeon = dungeon
|
||||
if err := service.sendCurrentDungeonEpicWishSnapshot(session, runtime, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if connection.write.Len() != 0 {
|
||||
t.Fatalf("unexpected snapshot=%x", connection.write.Bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentTimeRiftEpicCandidateKeepsWishOutOfMissPool(t *testing.T) {
|
||||
pools := map[int][]currentDungeonHellPartyFragmentCandidate{
|
||||
85: {
|
||||
{EquipmentID: 101000004, FragmentID: 103000004, Weight: 1},
|
||||
{EquipmentID: 101010005, FragmentID: 103010005, Weight: 1},
|
||||
},
|
||||
90: {{EquipmentID: 101020006, FragmentID: 103020006, Weight: 1}},
|
||||
}
|
||||
wish := []uint32{101000004}
|
||||
wishHits := 0
|
||||
for seed := uint32(1); seed <= 1000; seed++ {
|
||||
candidate, found, wishHit := selectCurrentTimeRiftEpicCandidate(
|
||||
newCurrentDungeonDropLCG(seed),
|
||||
pools,
|
||||
90,
|
||||
wish,
|
||||
)
|
||||
if !found {
|
||||
t.Fatalf("seed=%d returned no candidate", seed)
|
||||
}
|
||||
if wishHit {
|
||||
wishHits++
|
||||
if candidate.EquipmentID != wish[0] {
|
||||
t.Fatalf("wish hit selected=%d", candidate.EquipmentID)
|
||||
}
|
||||
} else if candidate.EquipmentID == wish[0] {
|
||||
t.Fatalf("miss branch selected wished item for seed=%d", seed)
|
||||
}
|
||||
}
|
||||
if wishHits == 0 || wishHits == 1000 {
|
||||
t.Fatalf("wish branch coverage=%d", wishHits)
|
||||
}
|
||||
|
||||
allWished := []uint32{101000004, 101010005, 101020006}
|
||||
misses := 0
|
||||
for seed := uint32(1); seed <= 100; seed++ {
|
||||
candidate, found, wishHit := selectCurrentTimeRiftEpicCandidate(
|
||||
newCurrentDungeonDropLCG(seed),
|
||||
pools,
|
||||
90,
|
||||
allWished,
|
||||
)
|
||||
if !found {
|
||||
t.Fatalf("all-wished rarity-4 roll disappeared for seed=%d", seed)
|
||||
}
|
||||
probe := newCurrentDungeonDropLCG(seed)
|
||||
if probe.Next(currentEpicWishHitDenominator) >= currentEpicWishHitNumerator {
|
||||
misses++
|
||||
if wishHit || candidate.EquipmentID == 0 {
|
||||
t.Fatalf("all-wished miss was relabeled as a preference hit: seed=%d candidate=%+v hit=%t", seed, candidate, wishHit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if misses == 0 {
|
||||
t.Fatal("all-wished fallback did not exercise a compatibility miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentTimeRiftEpicCandidateWithoutWishPreservesBaseline(t *testing.T) {
|
||||
pools := map[int][]currentDungeonHellPartyFragmentCandidate{
|
||||
85: {
|
||||
{EquipmentID: 101000004, FragmentID: 103000004, Weight: 1},
|
||||
{EquipmentID: 101010005, FragmentID: 103010005, Weight: 4},
|
||||
},
|
||||
90: {{EquipmentID: 101020006, FragmentID: 103020006, Weight: 2}},
|
||||
}
|
||||
for seed := uint32(1); seed <= 100; seed++ {
|
||||
baselineRNG := newCurrentDungeonDropLCG(seed)
|
||||
want, wantFound := selectCurrentHellPartyFragmentCandidate(baselineRNG, pools, 90)
|
||||
wishRNG := newCurrentDungeonDropLCG(seed)
|
||||
got, gotFound, wishHit := selectCurrentTimeRiftEpicCandidate(wishRNG, pools, 90, nil)
|
||||
if gotFound != wantFound || got != want || wishHit || wishRNG.Seed() != baselineRNG.Seed() {
|
||||
t.Fatalf(
|
||||
"seed=%d got=%+v/%t/hit=%t/seed=%d want=%+v/%t/seed=%d",
|
||||
seed,
|
||||
got,
|
||||
gotFound,
|
||||
wishHit,
|
||||
wishRNG.Seed(),
|
||||
want,
|
||||
wantFound,
|
||||
baselineRNG.Seed(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpicWishClass0Op1288DoesNotReplaceClass1DamageFontOpcode(t *testing.T) {
|
||||
if currentEpicWishSnapshotWireOpcode != uint16(dnfenum.CmdPacketSelectDamageFontSkin) {
|
||||
t.Fatalf(
|
||||
"class0 wish=%d class1 damage-font=%d",
|
||||
currentEpicWishSnapshotWireOpcode,
|
||||
dnfenum.CmdPacketSelectDamageFontSkin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func currentEpicWishTestService(
|
||||
t *testing.T,
|
||||
sessionAccountID string,
|
||||
) (*Service, dnfrepo.Group, *gameSession) {
|
||||
t.Helper()
|
||||
source := dungeonDropCatalogTestSource{
|
||||
"monster/monster.lst": "",
|
||||
dungeonDropStackableList: "",
|
||||
dungeonDropEquipmentList: "101000004 `character/a.equ`\n101010005 `character/b.equ`\n101020006 `character/c.equ`\n101040009 `character/rare.equ`\n",
|
||||
"equipment/character/a.equ": "[rarity]\n4\n[minimum level]\n85\n[creation rate]\n1\n[equipment type]\n`[weapon]` 0\n",
|
||||
"equipment/character/b.equ": "[rarity]\n4\n[minimum level]\n85\n[creation rate]\n1\n[equipment type]\n`[weapon]` 0\n",
|
||||
"equipment/character/c.equ": "[rarity]\n4\n[minimum level]\n90\n[creation rate]\n1\n[equipment type]\n`[weapon]` 0\n",
|
||||
"equipment/character/rare.equ": "[rarity]\n3\n[minimum level]\n90\n[creation rate]\n1\n[equipment type]\n`[weapon]` 0\n",
|
||||
}
|
||||
catalog, err := newPVFDungeonDropCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "19",
|
||||
AccountID: "account-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{
|
||||
accountID: "account-1",
|
||||
gameUpperHeader: gameUpperHeaderServer16,
|
||||
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
||||
},
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
pvfItemCatalog: catalog,
|
||||
}
|
||||
return service, repositories, &gameSession{
|
||||
accountID: sessionAccountID,
|
||||
selectedCharacterID: 19,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user