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

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)
}