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,204 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// newSceneRebuildExpertJobTest opens the active runtime PVF and returns a
|
||||
// service whose expert-job catalog is the real one, because the scene-rebuild
|
||||
// path builds the same profession-carrying body the live server sends.
|
||||
func newSceneRebuildExpertJobTest(t *testing.T) (*Service, *gameSession, *bufferConn, dnfrepo.CharacterRecord) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
pvfPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Skipf("runtime Script.pvf unavailable: %v", err)
|
||||
}
|
||||
expert, err := dnfexpertjob.Load(ctx, archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character := dnfrepo.CharacterRecord{
|
||||
CharacterID: "7",
|
||||
AccountID: "account-1",
|
||||
Name: "Enchanter",
|
||||
Stats: map[string]int64{
|
||||
"expert_job_type": int64(dnfexpertjob.EnchanterType),
|
||||
"expert_job_exp": 1,
|
||||
},
|
||||
}
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection := &bufferConn{}
|
||||
service := &Service{
|
||||
options: options{
|
||||
accountID: "account-1",
|
||||
gameUpperHeader: gameUpperHeaderChannel13,
|
||||
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
||||
},
|
||||
expertJobCatalog: expert,
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
session := &gameSession{conn: connection, selectedCharacterID: 7, accountID: "account-1"}
|
||||
return service, session, connection, character
|
||||
}
|
||||
|
||||
// TestSceneRebuildSuppressesUnchangedExpertJobProjection pins the fix for the
|
||||
// "副职业资格认证 4/4" banner: on this 90CN client class-0 op205 is an
|
||||
// acquisition/mutation notification, and a scene reconstruction that
|
||||
// deliberately does not replay op19 must therefore not replay an unchanged
|
||||
// profession either. A real durable change still publishes.
|
||||
func TestSceneRebuildSuppressesUnchangedExpertJobProjection(t *testing.T) {
|
||||
service, session, connection, character := newSceneRebuildExpertJobTest(t)
|
||||
|
||||
// Login / dungeon-entry boundary: the client accepts the profession body.
|
||||
if err := service.sendCurrentExpertJobInfoForCharacter(session, character, false); err != nil {
|
||||
t.Fatalf("initial expert-job publish: %v", err)
|
||||
}
|
||||
published, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if len(rest) != 0 || published.Header.MsgID != currentExpertJobInfoNotification {
|
||||
t.Fatalf("initial publish header=%+v trailing=%d", published.Header, len(rest))
|
||||
}
|
||||
|
||||
// Completed dungeon return with identical durable state: nothing may be
|
||||
// written. Anything written here is what opened the qualification banner.
|
||||
connection.write.Reset()
|
||||
if err := service.sendCurrentExpertJobInfoForSceneRebuild(session, character, "completed_dungeon_return"); err != nil {
|
||||
t.Fatalf("unchanged scene rebuild: %v", err)
|
||||
}
|
||||
if connection.write.Len() != 0 {
|
||||
t.Fatalf("unchanged scene rebuild replayed %d bytes", connection.write.Len())
|
||||
}
|
||||
|
||||
// A durable change since the last accepted body must still reach the client.
|
||||
character.Stats["expert_job_exp"] = 2
|
||||
connection.write.Reset()
|
||||
if err := service.sendCurrentExpertJobInfoForSceneRebuild(session, character, "completed_dungeon_return"); err != nil {
|
||||
t.Fatalf("changed scene rebuild: %v", err)
|
||||
}
|
||||
changed, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if len(rest) != 0 || changed.Header.MsgID != currentExpertJobInfoNotification {
|
||||
t.Fatalf("changed scene rebuild header=%+v trailing=%d", changed.Header, len(rest))
|
||||
}
|
||||
if got := currentExpertJobTrailerExperience(t, changed.Body); got != 2 {
|
||||
t.Fatalf("changed scene rebuild experience=%d, want 2", got)
|
||||
}
|
||||
|
||||
// That body is now the accepted state, so the next rebuild is quiet again.
|
||||
connection.write.Reset()
|
||||
if err := service.sendCurrentExpertJobInfoForSceneRebuild(session, character, "completed_dungeon_return"); err != nil {
|
||||
t.Fatalf("repeat scene rebuild: %v", err)
|
||||
}
|
||||
if connection.write.Len() != 0 {
|
||||
t.Fatalf("repeat scene rebuild replayed %d bytes", connection.write.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSceneRebuildKeepsClearedProfessionProjection proves the suppression is
|
||||
// scoped to a character that actually has an auxiliary profession: the
|
||||
// type-zero cleared form is not acquisition-shaped, so it keeps its previous
|
||||
// unconditional behaviour byte for byte.
|
||||
func TestSceneRebuildKeepsClearedProfessionProjection(t *testing.T) {
|
||||
service, session, connection, character := newSceneRebuildExpertJobTest(t)
|
||||
character.Stats = map[string]int64{}
|
||||
|
||||
connection.write.Reset()
|
||||
if err := service.sendCurrentExpertJobInfoForSceneRebuild(session, character, "completed_dungeon_return"); err != nil {
|
||||
t.Fatalf("cleared scene rebuild: %v", err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if len(rest) != 0 || packet.Header.MsgID != currentExpertJobInfoNotification {
|
||||
t.Fatalf("cleared scene rebuild header=%+v trailing=%d", packet.Header, len(rest))
|
||||
}
|
||||
if len(packet.Body) != 18 || packet.Body[1] != 0 ||
|
||||
currentExpertJobProjectionMode(t, packet.Body) != currentExpertJobProjectionSilent {
|
||||
t.Fatalf("cleared scene rebuild body=%x", packet.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpertJobProjectionModeSeparatesAcquisitionFromReconstruction pins the
|
||||
// exact contract the compatibility DLL relies on: the qualification window is
|
||||
// decided by the 4-byte mode word, and the two modes differ in NOTHING else.
|
||||
//
|
||||
// The native op205 reader cannot tell a real acquisition from a re-projection of
|
||||
// state the client already holds, so it raises 「副职业资格认证」 for both - that
|
||||
// is the relogin popup. Marking the reconstruction silent is what suppresses
|
||||
// it, and only the quest-completion path may announce.
|
||||
func TestExpertJobProjectionModeSeparatesAcquisitionFromReconstruction(t *testing.T) {
|
||||
service, session, connection, character := newSceneRebuildExpertJobTest(t)
|
||||
|
||||
// Every state-reconstruction send is silent.
|
||||
if err := service.sendCurrentExpertJobInfoForCharacter(session, character, false); err != nil {
|
||||
t.Fatalf("silent publish: %v", err)
|
||||
}
|
||||
silent, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if len(rest) != 0 || silent.Header.MsgID != currentExpertJobInfoNotification {
|
||||
t.Fatalf("silent publish header=%+v trailing=%d", silent.Header, len(rest))
|
||||
}
|
||||
if mode := currentExpertJobProjectionMode(t, silent.Body); mode != currentExpertJobProjectionSilent {
|
||||
t.Fatalf("state reconstruction mode=%d, want silent(%d)", mode, currentExpertJobProjectionSilent)
|
||||
}
|
||||
if got := currentExpertJobTrailerExperience(t, silent.Body); got != 1 {
|
||||
t.Fatalf("silent experience=%d, want 1", got)
|
||||
}
|
||||
|
||||
// The real acquisition path first republishes the actor record and only then
|
||||
// publishes the same op205 body marked as announcing.
|
||||
//
|
||||
// The order is part of the contract, not an implementation detail: op205 does
|
||||
// not carry the sub-profession into the client's actor record, so an
|
||||
// announcement that arrives before the record refresh lands on a record whose
|
||||
// +0x368 still reads "no profession" and both the profession row and the
|
||||
// acquisition feedback are dropped until the next relogin.
|
||||
connection.write.Reset()
|
||||
if err := service.sendCurrentExpertJobInfoForCharacterAnnouncing(session, character, true); err != nil {
|
||||
t.Fatalf("announcing publish: %v", err)
|
||||
}
|
||||
packets := connection.write.Bytes()
|
||||
refresh, afterRefresh := splitGameServerUpperPacket(t, packets)
|
||||
if refresh.Header.Classification != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
||||
len(refresh.Body) == 0 || refresh.Body[0] != 0 {
|
||||
t.Fatalf("acquisition must refresh the actor record first: header=%+v body=%x",
|
||||
refresh.Header, refresh.Body)
|
||||
}
|
||||
if got := refresh.Body[len(refresh.Body)-sceneObjectExpertJobTypeFromEnd]; got != byte(character.Stats["expert_job_type"]) {
|
||||
t.Fatalf("actor refresh record+0x368 = %d, want %d", got, character.Stats["expert_job_type"])
|
||||
}
|
||||
announcing, rest := splitGameServerUpperPacket(t, afterRefresh)
|
||||
if len(rest) != 0 || announcing.Header.MsgID != currentExpertJobInfoNotification {
|
||||
t.Fatalf("announcing publish header=%+v trailing=%d", announcing.Header, len(rest))
|
||||
}
|
||||
if mode := currentExpertJobProjectionMode(t, announcing.Body); mode != currentExpertJobProjectionAnnounce {
|
||||
t.Fatalf("quest completion mode=%d, want announce(%d)", mode, currentExpertJobProjectionAnnounce)
|
||||
}
|
||||
if got := currentExpertJobTrailerExperience(t, announcing.Body); got != 1 {
|
||||
t.Fatalf("announcing experience=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Same length, and identical everywhere except the mode word: the native
|
||||
// reader must see byte-identical state in both modes.
|
||||
if len(announcing.Body) != len(silent.Body) {
|
||||
t.Fatalf("mode changed body length %d -> %d", len(silent.Body), len(announcing.Body))
|
||||
}
|
||||
modeOffset := len(silent.Body) - 12
|
||||
for i := range silent.Body {
|
||||
if i >= modeOffset && i < modeOffset+4 {
|
||||
continue
|
||||
}
|
||||
if silent.Body[i] != announcing.Body[i] {
|
||||
t.Fatalf("mode changed body byte %d: silent=%02x announcing=%02x",
|
||||
i, silent.Body[i], announcing.Body[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user