服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
135 lines
5.4 KiB
Go
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])
|
|
}
|