服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
172 lines
7.6 KiB
Go
172 lines
7.6 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"testing"
|
|
|
|
dnfcarrygold "longheng.io/server/internal/modules/dnf/carrygold"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
// currentCarryGoldRequest reproduces the client builder: 14 bytes of stack
|
|
// residue whose only initialized field is the trailing requested stage.
|
|
func currentCarryGoldRequest(stage byte) []byte {
|
|
body := []byte{0x50, 0x8F, 0xEC, 0x3A, 0, 0, 0, 0, 0xD8, 0xF8, 0x19, 0x00, 0x30, 0x00}
|
|
body[currentCarryGoldStageOffset] = stage
|
|
return body
|
|
}
|
|
|
|
func newCarryGoldTest(t *testing.T, gold int64, stage int64) (*Service, *gameSession, dnfrepo.Group, *bufferConn) {
|
|
t.Helper()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
stats := map[string]int64{"gold": gold}
|
|
if stage > 0 {
|
|
stats[dnfcarrygold.StageStat] = stage
|
|
}
|
|
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
|
CharacterID: "17", AccountID: defaultAccountPrefix + "1", Level: 90, Stats: stats,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
return service, &gameSession{conn: connection, selectedCharacterID: 17}, repositories, connection
|
|
}
|
|
|
|
func assertCarryGoldReply(t *testing.T, connection *bufferConn, wantStage byte, wantResult uint32) []byte {
|
|
t.Helper()
|
|
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.CmdPacketUpgradeCarryGold) ||
|
|
packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
|
t.Fatalf("carry-gold reply header=%+v", packet.Header)
|
|
}
|
|
// The client requires a non-zero envelope byte, then reads u8 stage and u32
|
|
// result; a non-zero result takes its failure branch.
|
|
if len(packet.Body) != 1+currentCarryGoldResponseSize || packet.Body[0] != 1 ||
|
|
packet.Body[1] != wantStage ||
|
|
binary.LittleEndian.Uint32(packet.Body[2:6]) != wantResult {
|
|
t.Fatalf("carry-gold reply body=%x want stage=%d result=%d", packet.Body, wantStage, wantResult)
|
|
}
|
|
return rest
|
|
}
|
|
|
|
func TestUpgradeCarryGoldChargesGoldAndAdvancesOneStage(t *testing.T) {
|
|
service, session, repositories, connection := newCarryGoldTest(t, 250_000_000, 0)
|
|
if err := service.handleCurrentUpgradeCarryGold(session, currentCarryGoldRequest(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rest := assertCarryGoldReply(t, connection, 1, currentCarryGoldResultOK)
|
|
// The wallet row is the client's only gold display, so the charge is
|
|
// republished right after the result.
|
|
walletUpdate, trailing := splitGameServerUpperPacket(t, rest)
|
|
if walletUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || len(trailing) != 0 {
|
|
t.Fatalf("wallet refresh header=%+v trailing=%x", walletUpdate.Header, trailing)
|
|
}
|
|
stored, found, err := repositories.Character.Load(context.Background(), "17")
|
|
if err != nil || !found {
|
|
t.Fatalf("load character found=%t err=%v", found, err)
|
|
}
|
|
if stored.Stats["gold"] != 250_000_000-dnfcarrygold.StageCost {
|
|
t.Fatalf("gold after upgrade = %d", stored.Stats["gold"])
|
|
}
|
|
if stored.Stats[dnfcarrygold.StageStat] != 1 {
|
|
t.Fatalf("stage after upgrade = %d", stored.Stats[dnfcarrygold.StageStat])
|
|
}
|
|
// The purchased stage has to move the limit that gold gains are measured
|
|
// against, otherwise the upgrade is a number nobody reads.
|
|
if got := stored.Stats[dnfcarrygold.StageStat]; dnfcarrygold.StageBonus(byte(got)) != dnfcarrygold.Unit {
|
|
t.Fatalf("carry limit bonus after upgrade = %d", dnfcarrygold.StageBonus(byte(got)))
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCarryGoldRefusesWithoutMutationWhenGoldIsShort(t *testing.T) {
|
|
service, session, repositories, connection := newCarryGoldTest(t, dnfcarrygold.StageCost-1, 0)
|
|
if err := service.handleCurrentUpgradeCarryGold(session, currentCarryGoldRequest(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rest := assertCarryGoldReply(t, connection, 0, currentCarryGoldResultInsufficientGold); len(rest) != 0 {
|
|
t.Fatalf("insufficient-gold reply trailing=%x", rest)
|
|
}
|
|
stored, _, _ := repositories.Character.Load(context.Background(), "17")
|
|
if stored.Stats["gold"] != dnfcarrygold.StageCost-1 ||
|
|
stored.Stats[dnfcarrygold.StageStat] != 0 {
|
|
t.Fatalf("refused upgrade mutated character stats=%+v", stored.Stats)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCarryGoldRejectsOutOfSequenceAndExhaustedStages(t *testing.T) {
|
|
// A stage that is not exactly current+1 must not skip ahead.
|
|
service, session, repositories, connection := newCarryGoldTest(t, 900_000_000, 1)
|
|
if err := service.handleCurrentUpgradeCarryGold(session, currentCarryGoldRequest(3)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertCarryGoldReply(t, connection, 1, currentCarryGoldResultInvalidStage)
|
|
stored, _, _ := repositories.Character.Load(context.Background(), "17")
|
|
if stored.Stats["gold"] != 900_000_000 || stored.Stats[dnfcarrygold.StageStat] != 1 {
|
|
t.Fatalf("out-of-sequence upgrade mutated stats=%+v", stored.Stats)
|
|
}
|
|
|
|
// The client stops offering upgrades at stage 4; the server refuses too.
|
|
service, session, repositories, connection = newCarryGoldTest(t, 900_000_000, int64(dnfcarrygold.MaxStage))
|
|
if err := service.handleCurrentUpgradeCarryGold(
|
|
session, currentCarryGoldRequest(dnfcarrygold.MaxStage+1),
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertCarryGoldReply(t, connection, dnfcarrygold.MaxStage, currentCarryGoldResultInvalidStage)
|
|
stored, _, _ = repositories.Character.Load(context.Background(), "17")
|
|
if stored.Stats["gold"] != 900_000_000 {
|
|
t.Fatalf("exhausted upgrade charged gold stats=%+v", stored.Stats)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCarryGoldAlwaysAnswersAMalformedRequest(t *testing.T) {
|
|
service, session, _, connection := newCarryGoldTest(t, 900_000_000, 0)
|
|
if err := service.handleCurrentUpgradeCarryGold(session, []byte{1, 2, 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A silent drop is exactly what left the confirm button dead, so even an
|
|
// unparsable request gets the failure branch.
|
|
assertCarryGoldReply(t, connection, 0, currentCarryGoldResultInvalidStage)
|
|
}
|
|
|
|
func TestSendCurrentCarryGoldStagePublishesTheDurableStage(t *testing.T) {
|
|
service, session, _, connection := newCarryGoldTest(t, 0, 3)
|
|
if err := service.sendCurrentCarryGoldStage(session, "unit_test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, trailing := splitGameServerUpperPacketWithHeader(
|
|
t, connection.write.Bytes(), dnfproto.GameServerUpperHeaderSize16,
|
|
)
|
|
if packet.Header.MsgID != currentCarryGoldStageMsgID || packet.Header.Classification != 0 ||
|
|
len(packet.Body) != 1 || packet.Body[0] != 3 || len(trailing) != 0 {
|
|
t.Fatalf("carry-gold stage packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
|
|
}
|
|
}
|
|
|
|
func TestCarryGoldGameplayModuleRoutesLegacyAndUpperOp978(t *testing.T) {
|
|
opcode := uint16(dnfenum.CmdPacketUpgradeCarryGold)
|
|
service, session, _, connection := newCarryGoldTest(t, 250_000_000, 0)
|
|
handled, err := currentGameplayModules.DispatchLegacy(service, session, opcode, currentCarryGoldRequest(1))
|
|
if err != nil || !handled {
|
|
t.Fatalf("legacy dispatch handled=%t err=%v", handled, err)
|
|
}
|
|
assertCarryGoldReply(t, connection, 1, currentCarryGoldResultOK)
|
|
|
|
service, session, _, connection = newCarryGoldTest(t, 250_000_000, 0)
|
|
handled, err = currentGameplayModules.DispatchUpper(
|
|
service, session, opcode, dnfproto.DefaultChannelClassification, currentCarryGoldRequest(1),
|
|
)
|
|
if err != nil || !handled {
|
|
t.Fatalf("upper dispatch handled=%t err=%v", handled, err)
|
|
}
|
|
assertCarryGoldReply(t, connection, 1, currentCarryGoldResultOK)
|
|
}
|