Files
90CNServer/server/internal/services/dnfbridge/expert_job_experience_push_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

135 lines
5.4 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"path/filepath"
"testing"
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"
)
// TestCurrentExpertJobProjectionRepublishesOnEveryExperienceGain pins the
// decision that made 附魔师 crafting look like it did nothing: the profession's
// experience only reaches the client through class-0 op205, so an operation
// that moved the bar without crossing a level threshold still has to
// republish. Gating on LevelChanged alone left the gain invisible until the
// next character select replayed the login push.
func TestCurrentExpertJobProjectionRepublishesOnEveryExperienceGain(t *testing.T) {
for name, probe := range map[string]struct {
gain int64
levelChanged bool
want bool
}{
"experience_without_level_change": {gain: 12, levelChanged: false, want: true},
"experience_with_level_change": {gain: 12, levelChanged: true, want: true},
"level_change_without_experience": {gain: 0, levelChanged: true, want: true},
"nothing_changed": {gain: 0, levelChanged: false, want: false},
} {
if got := currentExpertJobProjectionNeedsRepublish(probe.gain, probe.levelChanged); got != probe.want {
t.Fatalf("%s: republish=%t, want %t", name, got, probe.want)
}
}
}
// TestRealScriptPVFExpertJobInfoCarriesExperienceWithoutLevelChange proves the
// republish is worth sending: two pushes for the same enchanter at the same
// level must differ on the wire, because the experience rides in the EJXP
// projection trailer the compatibility DLL writes into the current actor.
func TestRealScriptPVFExpertJobInfoCarriesExperienceWithoutLevelChange(t *testing.T) {
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)
}
config, ok := expert.Config(dnfexpertjob.EnchanterType)
if !ok {
t.Fatal("active PVF has no enchanter config")
}
// Two experience values inside the same level band, so only the trailer
// moves and every level-derived field stays identical.
const before, after = int64(1), int64(2)
if config.Level(before) != config.Level(after) {
t.Fatalf("probe experiences straddle a level threshold: %d vs %d", config.Level(before), config.Level(after))
}
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "7", AccountID: "account-1", Name: "Enchanter",
Stats: map[string]int64{"expert_job_type": int64(dnfexpertjob.EnchanterType), "expert_job_exp": before},
}
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"}
if err := service.sendCurrentExpertJobInfoForSelectedCharacter(session, false); err != nil {
t.Fatal(err)
}
first, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || first.Header.MsgID != currentExpertJobInfoNotification {
t.Fatalf("first projection header=%+v trailing=%d", first.Header, len(rest))
}
if got := currentExpertJobTrailerExperience(t, first.Body); got != uint32(before) {
t.Fatalf("first projection experience=%d, want %d", got, before)
}
// A craft that does not cross a threshold: the durable row moves, and the
// forced republish has to carry it even though the session already saw
// this character.
character.Stats["expert_job_exp"] = after
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
connection.write.Reset()
if err := service.sendCurrentExpertJobInfoForSelectedCharacter(session, true); err != nil {
t.Fatal(err)
}
second, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || second.Header.MsgID != currentExpertJobInfoNotification {
t.Fatalf("second projection header=%+v trailing=%d", second.Header, len(rest))
}
if got := currentExpertJobTrailerExperience(t, second.Body); got != uint32(after) {
t.Fatalf("second projection experience=%d, want %d", got, after)
}
// Without force the same character is suppressed, which is exactly why the
// craft path has to ask for force=true.
connection.write.Reset()
if err := service.sendCurrentExpertJobInfoForSelectedCharacter(session, false); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("unforced projection re-sent %d bytes", connection.write.Len())
}
}
func currentExpertJobTrailerExperience(t *testing.T, body []byte) uint32 {
t.Helper()
if len(body) < 8 {
t.Fatalf("expert job projection body too short: %x", body)
}
trailer := body[len(body)-8:]
if magic := binary.LittleEndian.Uint32(trailer[0:4]); magic != currentExpertJobProjectionTrailerMagic {
t.Fatalf("expert job projection trailer magic=%08x body=%x", magic, body)
}
return binary.LittleEndian.Uint32(trailer[4:8])
}