服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
288 lines
10 KiB
Go
288 lines
10 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"longheng.io/server/internal/modules/dnf/progression"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
var errTestPVFTextMissing = errors.New("test PVF text missing")
|
|
|
|
type pvpFatigueTestSource map[string]string
|
|
|
|
func (s pvpFatigueTestSource) ReadText(path string) (string, error) {
|
|
text, ok := s[path]
|
|
if !ok {
|
|
return "", errTestPVFTextMissing
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func TestCurrentPVPFatigueRulesRealPVF(t *testing.T) {
|
|
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if path == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
|
|
}
|
|
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: path})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rules, err := loadCurrentPVPFatigueRulesFromSource(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rules.ArcadeUse != 30 || rules.ArcadeDifficulty[0] != 40 || rules.ArcadeDifficulty[1] != 45 || rules.ArcadeDifficulty[2] != 50 || rules.ColosseumUse != 10 || len(rules.Standards) != 41 {
|
|
t.Fatalf("rules=%+v", rules)
|
|
}
|
|
s := pvpRoomTestService(t)
|
|
s.initialEquipmentArchive = archive
|
|
s.pvpFatigueRules = rules
|
|
tables, err := progression.Load(context.Background(), archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, level := range []int{50, 89, 90} {
|
|
entry, err := tables.ThresholdToNext(level - 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if level == 89 {
|
|
entry, err = tables.ThresholdToNext(89)
|
|
entry--
|
|
}
|
|
c := dnfrepo.CharacterRecord{CharacterID: "29", AccountID: "pvp-29", Job: "0", Level: level, Stats: map[string]int64{"exp": int64(entry), "gold": 10}}
|
|
skill := dnfrepo.SkillRecord{CharacterID: "29", Points: dnfrepo.SkillPointState{SyncedLevel: level, TotalSP: 100, RemainingSP: 50}}
|
|
r, err := s.planCurrentPVPArcadeReward(context.Background(), rules, c, skill)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := rules.arcadeReward(level)
|
|
if r.Reward.Experience != want.Experience || r.Reward.Gold != want.Gold || r.Character.Stats["gold"] != 10+int64(want.Gold) {
|
|
t.Fatalf("level %d reward %+v", level, r)
|
|
}
|
|
if level == 50 && r.Character.Stats["exp"] <= int64(entry) {
|
|
t.Fatal("ordinary EXP did not increase")
|
|
}
|
|
if level == 89 && (r.Character.Level != 90 || r.Skill.Points.SyncedLevel != 90 || r.Skill.Points.TotalSP <= 100) {
|
|
t.Fatal("level and SP did not advance together")
|
|
}
|
|
if level == 90 && r.Character.Stats[currentHonorExpertProgressExperienceStatKey] == 0 {
|
|
t.Fatal("level-cap EXP lost")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentPVPFatigueRulesReadPVFAndBuildNativeBodies(t *testing.T) {
|
|
source := pvpFatigueTestSource{"etc/pvpfatigueinfo.etc": "" +
|
|
"[arcadeMode]\n" +
|
|
"[fatigue accelerate use]\n30\n[difficulty]\n0 40 1 45 2 50\n[/difficulty]\n[/arcadeMode]\n" +
|
|
"[colosseum]\n[fatigue accelerate use]\n10\n[/colosseum]\n" +
|
|
"[exp reward percent]\n90\n[gold reward percent]\n90\n" +
|
|
"[fatigue and reward standard value]\n50 47972 382 90 230577 1705\n" +
|
|
"[exp calc const]\n2\n[gold calc const]\n2\n"}
|
|
rules, err := loadCurrentPVPFatigueRulesFromSource(source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rules.ArcadeUse != 30 || rules.ColosseumUse != 10 || rules.ArcadeDifficulty[0] != 40 || rules.ArcadeDifficulty[2] != 50 {
|
|
t.Fatalf("rules=%+v", rules)
|
|
}
|
|
if reward := rules.arcadeReward(90); reward.Experience != 115288 || reward.Gold != 852 {
|
|
t.Fatalf("reward=%+v", reward)
|
|
}
|
|
if got := buildCurrentPVPFatigueUseBody(true); len(got) != 1 || got[0] != 1 {
|
|
t.Fatalf("usage body=%x", got)
|
|
}
|
|
if got := buildCurrentPVPFatigueRewardBody(true, 115288, 852); len(got) != 9 || got[0] != 1 {
|
|
t.Fatalf("reward body=%x", got)
|
|
}
|
|
}
|
|
|
|
func TestCurrentPVPArcadeRewardReceiptIsPerMatch(t *testing.T) {
|
|
first := time.Unix(100, 1)
|
|
second := time.Unix(100, 2)
|
|
room := ¤tPVPRoom{id: 7, arcade: ¤tPVPArcadeRun{startedAt: first}}
|
|
if got, want := currentPVPArcadeRewardReceipt(room), "pvp_arcade_reward_7_100000000001"; got != want {
|
|
t.Fatalf("receipt=%q, want %q", got, want)
|
|
}
|
|
room.arcade.startedAt = second
|
|
if got := currentPVPArcadeRewardReceipt(room); got == "pvp_arcade_reward_7_100000000001" {
|
|
t.Fatal("different match reused reward receipt")
|
|
}
|
|
}
|
|
|
|
func TestCurrentPVPFatigueAccelerationAndRoundInfo(t *testing.T) {
|
|
_, _, ok := parseCurrentPVPArcadeRoundInfo(make([]byte, 18))
|
|
if ok {
|
|
t.Fatal("short round packet accepted")
|
|
}
|
|
body := make([]byte, 19)
|
|
body[5], body[6] = 2, 1
|
|
if end, won, ok := parseCurrentPVPArcadeRoundInfo(body); !ok || end || won {
|
|
t.Fatalf("round start parsed as end=%v won=%v ok=%v", end, won, ok)
|
|
}
|
|
body[0], body[12] = 1, 1
|
|
if end, won, ok := parseCurrentPVPArcadeRoundInfo(body); !ok || !end || !won {
|
|
t.Fatalf("round end parsed as end=%v won=%v ok=%v", end, won, ok)
|
|
}
|
|
}
|
|
|
|
func TestPVPFatigueNativeRequestsAndPaymentRetry(t *testing.T) {
|
|
for _, upper := range []bool{false, true} {
|
|
s := pvpRoomTestService(t)
|
|
conn := &pvpFailNthConn{}
|
|
session := &gameSession{conn: conn, accountID: "pvp-29", selectedCharacterID: 29}
|
|
room := ¤tPVPRoom{id: 7, battleMode: 10, request: currentPVPMakeRoomRequest{dojoDifficulty: 1}}
|
|
room.members[0] = ¤tPVPMember{session: session, characterID: 29, level: 90}
|
|
session.pvpRoom = room
|
|
s.pvpFatigueRules = ¤tPVPFatigueRules{ArcadeDifficulty: map[byte]int64{0: 40}}
|
|
repos, _ := s.repositoryGroup()
|
|
ctx := context.Background()
|
|
character, _, _ := repos.Character.Load(ctx, "29")
|
|
character.Stats["fatigue"], character.Stats["fatigue_limit"] = 188, 188
|
|
if err := repos.Character.Save(ctx, character); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
quests := dnfrepo.QuestRecord{CharacterID: "29", States: map[int64]dnfrepo.QuestState{}}
|
|
for id := int64(2670); id <= 2674; id++ {
|
|
quests.States[id] = dnfrepo.QuestState{Status: "completed"}
|
|
}
|
|
if err := repos.Quest.Save(ctx, quests); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
send := func(op uint16, body []byte) error {
|
|
if !upper {
|
|
return s.handleGameCommand(session, 1, op, body)
|
|
}
|
|
packet, err := dnfproto.BuildChannelPacket(op, body, 0, 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.handleGameUpper(session, packet)
|
|
}
|
|
// 注:参考实现此处先跑一遍 op562(疲劳值加速勾选框)的 ACK + fatigue_acceleration
|
|
// 持久化断言。op562 需要 fatigue_burn / returnee-lucky 整套非决斗场链路,本次
|
|
// "仅决斗场"移植刻意没有注册该 opcode,所以这一段一并去掉,只验证街机付费链路。
|
|
conn.write.Reset()
|
|
conn.failAt = conn.writes + 1
|
|
if err := send(1271, []byte{1, 1}); err == nil {
|
|
t.Fatal("missing injected payment publication error")
|
|
}
|
|
stored, _, _ := repos.Character.Load(ctx, "29")
|
|
if stored.Stats["fatigue"] != 148 || !room.arcade.paid {
|
|
t.Fatal("payment did not commit before network failure")
|
|
}
|
|
if err := send(1271, []byte{1, 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, _, _ = repos.Character.Load(ctx, "29")
|
|
if stored.Stats["fatigue"] != 148 {
|
|
t.Fatal("retry charged fatigue twice")
|
|
}
|
|
state, rest := splitCurrentGameServerUpperPacketAuto(t, conn.write.Bytes())
|
|
ack, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
|
|
if state.Header.MsgID != 36 || state.Header.Classification != 0 || binary.LittleEndian.Uint16(state.Body) != 40 || ack.Header.MsgID != 1271 || ack.Header.Classification != 1 || !bytes.Equal(ack.Body, []byte{1}) || len(rest) != 0 {
|
|
t.Fatal("fatigue HUD must precede native usage ACK")
|
|
}
|
|
start := []byte{0, 0x5b, 0x66, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
if err := send(1285, start); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
end := append([]byte(nil), start...)
|
|
end[0], end[12] = 1, 1
|
|
if err := send(1285, end); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.write.Reset()
|
|
if err := send(1272, []byte{1, 1, 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conn.write.Len() != 0 || room.arcade.reward != nil {
|
|
t.Fatal("first round received full-run reward")
|
|
}
|
|
start[6], end[6] = 2, 2
|
|
if err := send(1285, start); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Preseed a committed receipt snapshot to isolate wire retries from PVF I/O.
|
|
room.arcade.reward = &dnfpvp.ArcadeRewardSnapshot{Character: stored, Reward: dnfpvp.ArcadeReward{Experience: 100, Gold: 20}}
|
|
if err := send(1285, end); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !room.arcade.roundEnded || room.arcade.round != 2 {
|
|
t.Fatal("last round was not tracked")
|
|
}
|
|
conn.write.Reset()
|
|
if err := send(1272, []byte{1, 0, 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ack, _ = splitCurrentGameServerUpperPacketAuto(t, conn.write.Bytes())
|
|
if !bytes.Equal(ack.Body, []byte{0}) {
|
|
t.Fatal("contradictory result accepted")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPVPFatigueTutorialAndInvalidUsageDoNotCharge(t *testing.T) {
|
|
for _, test := range []string{"tutorial", "disabled", "short_balance", "wrong_mode", "wrong_difficulty"} {
|
|
t.Run(test, func(t *testing.T) {
|
|
s := pvpRoomTestService(t)
|
|
session := &gameSession{conn: &bufferConn{}, accountID: "pvp-29", selectedCharacterID: 29}
|
|
room := ¤tPVPRoom{id: 1, battleMode: 10, request: currentPVPMakeRoomRequest{dojoDifficulty: 1}}
|
|
room.members[0] = ¤tPVPMember{session: session, characterID: 29}
|
|
session.pvpRoom = room
|
|
s.pvpFatigueRules = ¤tPVPFatigueRules{ArcadeDifficulty: map[byte]int64{0: 40}}
|
|
repos, _ := s.repositoryGroup()
|
|
ctx := context.Background()
|
|
c, _, _ := repos.Character.Load(ctx, "29")
|
|
c.Stats["fatigue"] = 188
|
|
if test == "short_balance" {
|
|
c.Stats["fatigue"] = 39
|
|
}
|
|
if err := repos.Character.Save(ctx, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := dnfrepo.QuestRecord{CharacterID: "29", States: map[int64]dnfrepo.QuestState{}}
|
|
for id := int64(2670); id <= 2674; id++ {
|
|
q.States[id] = dnfrepo.QuestState{Status: "completed"}
|
|
}
|
|
if test == "tutorial" {
|
|
q.States[2674] = dnfrepo.QuestState{Status: "active", ProgressValue: 262657}
|
|
}
|
|
if err := repos.Quest.Save(ctx, q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := []byte{1, 1}
|
|
if test == "wrong_mode" {
|
|
body = []byte{2, 0}
|
|
}
|
|
if test == "wrong_difficulty" {
|
|
body = []byte{1, 2}
|
|
}
|
|
if test == "disabled" {
|
|
start := make([]byte, 19)
|
|
start[5], start[6] = 2, 1
|
|
if err := s.handleCurrentPVPArcadeRoundInfo(session, start); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else if err := s.handleCurrentPVPFatigueUsage(session, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _, _ := repos.Character.Load(ctx, "29")
|
|
if got.Stats["fatigue"] != c.Stats["fatigue"] || (room.arcade != nil && room.arcade.paid) {
|
|
t.Fatal("ineligible run charged fatigue")
|
|
}
|
|
})
|
|
}
|
|
}
|