服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
245 lines
9.7 KiB
Go
245 lines
9.7 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestParseCurrentConsumableRecoveryReadsOperatorAmountAndDuration(t *testing.T) {
|
|
// Shapes copied from the runtime PVF: a flat HP potion, a percentage
|
|
// consumable that restores both pools, and a material with no section.
|
|
for name, probe := range map[string]struct {
|
|
text string
|
|
wantHP, wantMP bool
|
|
hpOperator, mpOperator string
|
|
hpAmount, mpAmount int64
|
|
hpDurationMS, mpDurationMS int64
|
|
}{
|
|
"flat_hp_only": {
|
|
text: "[name]\n`新手HP药剂`\n[stackable type]\n`[waste]` 4\n[hp recovery]\n`+` 70 1000\n",
|
|
wantHP: true,
|
|
hpOperator: "+", hpAmount: 70, hpDurationMS: 1000,
|
|
},
|
|
"percent_both_pools": {
|
|
text: "[name]\n`甜蜜草莓汁`\n[stackable type]\n`[waste]` 4\n[hp recovery]\n`%` 20 500\n[mp recovery]\n`%` 20 500\n",
|
|
wantHP: true, wantMP: true,
|
|
hpOperator: "%", hpAmount: 20, hpDurationMS: 500,
|
|
mpOperator: "%", mpAmount: 20, mpDurationMS: 500,
|
|
},
|
|
"material_without_recovery": {
|
|
text: "[name]\n`无色小晶块`\n[stackable type]\n`[material]` 2\n",
|
|
},
|
|
} {
|
|
document, err := dnfpvf.Parse("stackable/probe.stk", probe.text)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
definition := dungeonDropItemDefinition{}
|
|
parseCurrentConsumableRecovery(document, &definition)
|
|
if definition.HasHPRecovery != probe.wantHP || definition.HasMPRecovery != probe.wantMP {
|
|
t.Fatalf("%s: hp_found=%t mp_found=%t want %t/%t", name,
|
|
definition.HasHPRecovery, definition.HasMPRecovery, probe.wantHP, probe.wantMP)
|
|
}
|
|
if definition.HPRecoveryOperator != probe.hpOperator || definition.HPRecoveryAmount != probe.hpAmount ||
|
|
definition.HPRecoveryDurationMS != probe.hpDurationMS ||
|
|
definition.MPRecoveryOperator != probe.mpOperator || definition.MPRecoveryAmount != probe.mpAmount ||
|
|
definition.MPRecoveryDurationMS != probe.mpDurationMS {
|
|
t.Fatalf("%s: parsed=%+v", name, definition)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCurrentPotionRecoveryAmountKeepsPVFSemantics pins the conversion from a
|
|
// declared recovery to the single scalar the recovery frame carries.
|
|
func TestCurrentPotionRecoveryAmountKeepsPVFSemantics(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{Stats: map[string]int64{"stat_hp_max": 11600}}
|
|
poolless := dnfrepo.CharacterRecord{Stats: map[string]int64{}}
|
|
for name, probe := range map[string]struct {
|
|
declared bool
|
|
operator string
|
|
amount int64
|
|
character dnfrepo.CharacterRecord
|
|
wantAmount uint16
|
|
wantReason string
|
|
}{
|
|
"flat_is_the_declared_amount": {true, "+", 1520, character, 1520, ""},
|
|
"percent_scales_the_pool": {true, "%", 20, character, 2320, ""},
|
|
"percent_without_pool_refuses": {true, "%", 20, poolless, 0, "percent_recovery_without_known_maximum"},
|
|
// The archive carries `% -7` on a [virtual] item. A negative or zero
|
|
// declaration is damage or a no-op, never a heal.
|
|
"negative_never_heals": {true, "%", -7, character, 0, ""},
|
|
"absent_section_is_nil": {false, "+", 500, character, 0, ""},
|
|
"unknown_operator": {true, "*", 500, character, 0, "unproven_recovery_operator"},
|
|
} {
|
|
amount, reason := currentPotionRecoveryAmount(probe.declared, probe.operator, probe.amount, probe.character, "stat_hp_max")
|
|
if amount != probe.wantAmount || reason != probe.wantReason {
|
|
t.Fatalf("%s: amount=%d reason=%q want %d/%q", name, amount, reason, probe.wantAmount, probe.wantReason)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentPotionRecoveryBodyMatchesMode1Reader(t *testing.T) {
|
|
body := buildCurrentDungeonPotionRecoveryBody(0x1234, 700)
|
|
want := []byte{0x34, 0x12, 0x00, 0x00, 0x01, 0xff, 0x00, 0x34, 0x12, 0xbc, 0x02}
|
|
if len(body) != len(want) {
|
|
t.Fatalf("body=%x len=%d want len=%d", body, len(body), len(want))
|
|
}
|
|
for index := range want {
|
|
if body[index] != want[index] {
|
|
t.Fatalf("body=%x want=%x", body, want)
|
|
}
|
|
}
|
|
// The zero-drop death body is the same reader with mode 0. Keeping the
|
|
// shared prefix identical is what makes the mode byte the only difference.
|
|
death, err := buildCurrentDungeonDeathNotificationBody(0x1234, currentDungeonDeathResponseMonster)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(death) != currentDungeonZeroDropDeathBodySize || death[4] != 0 || body[4] != 1 {
|
|
t.Fatalf("death=%x recovery=%x", death, body)
|
|
}
|
|
}
|
|
|
|
func TestCurrentPotionCastPairingWindowConsumesTheDeleteExactlyOnce(t *testing.T) {
|
|
service := &Service{}
|
|
session := &gameSession{}
|
|
base := time.Unix(1_800_000_000, 0).UTC()
|
|
arm := func() {
|
|
session.potionCast.pending = ¤tPendingConsumableUse{itemID: 3001, listType: 0, slotIndex: 12, committedAt: base}
|
|
}
|
|
|
|
arm()
|
|
if pending := session.takeCurrentConsumableDeleteIfFresh(base.Add(50 * time.Millisecond)); pending == nil || pending.itemID != 3001 {
|
|
t.Fatalf("fresh delete was not returned: %+v", pending)
|
|
}
|
|
// A second cast must not reuse the same dose.
|
|
if pending := session.takeCurrentConsumableDeleteIfFresh(base.Add(60 * time.Millisecond)); pending != nil {
|
|
t.Fatalf("delete answered a second cast: %+v", pending)
|
|
}
|
|
arm()
|
|
if pending := session.takeCurrentConsumableDeleteIfFresh(base.Add(currentPotionCastWindow + time.Second)); pending != nil {
|
|
t.Fatalf("stale delete was claimed: %+v", pending)
|
|
}
|
|
// An expired record is still cleared, so it cannot resurface later.
|
|
if session.potionCast.pending != nil {
|
|
t.Fatal("expired delete stayed pending")
|
|
}
|
|
// A delete with no resolvable item is never recorded at all.
|
|
service.noteCurrentConsumableDelete(session, 0, 12, 0)
|
|
if session.potionCast.pending != nil {
|
|
t.Fatal("item-less delete was recorded")
|
|
}
|
|
}
|
|
|
|
func TestCurrentPotionCastIgnoresOrdinarySkillsAndShapes(t *testing.T) {
|
|
service := &Service{}
|
|
session := &gameSession{}
|
|
ordinary := make([]byte, currentLocalDebugUseSkillRequestSize)
|
|
binary.BigEndian.PutUint16(ordinary[0:2], 77)
|
|
if handled, err := service.handleCurrentDungeonPotionCast(session, ordinary); handled || err != nil {
|
|
t.Fatalf("ordinary skill cast was claimed: handled=%t err=%v", handled, err)
|
|
}
|
|
potion := make([]byte, currentLocalDebugUseSkillRequestSize)
|
|
binary.BigEndian.PutUint16(potion[0:2], currentPotionCastSkillID)
|
|
// Outside a dungeon runtime there is nothing to answer.
|
|
if handled, err := service.handleCurrentDungeonPotionCast(session, potion); handled || err != nil {
|
|
t.Fatalf("potion cast outside a dungeon was claimed: handled=%t err=%v", handled, err)
|
|
}
|
|
if handled, err := service.handleCurrentDungeonPotionCast(session, potion[:5]); handled || err != nil {
|
|
t.Fatalf("short body was claimed: handled=%t err=%v", handled, err)
|
|
}
|
|
}
|
|
|
|
// TestCurrentPotionRealPVFRecoveryIsAmountNotRange is the evidence for reading
|
|
// `[hp recovery]` as `<operator> <amount> <duration>`. The twelve ordinary
|
|
// potions are the proof: their second value is a constant 1000 while the first
|
|
// climbs in tier order, and two of them declare a first value LARGER than the
|
|
// second, which no minimum/maximum pair could be.
|
|
func TestCurrentPotionRealPVFRecoveryIsAmountNotRange(t *testing.T) {
|
|
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate potion recovery parsing")
|
|
}
|
|
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := newPVFDungeonDropCatalog(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantHP := map[string]int64{
|
|
"ptn_hplowest.stk": 70,
|
|
"ptn_hplow.stk": 180,
|
|
"ptn_hpintermediate.stk": 410,
|
|
"ptn_hpupper.stk": 710,
|
|
"ptn_hphighest.stk": 1080,
|
|
"ptn_hpspecial.stk": 1520,
|
|
}
|
|
wantMP := map[string]int64{
|
|
"ptn_mplowest.stk": 65,
|
|
"ptn_mplow.stk": 150,
|
|
"ptn_mpintermediate.stk": 330,
|
|
"ptn_mpupper.stk": 560,
|
|
"ptn_mphighest.stk": 850,
|
|
"ptn_mpspecial.stk": 1180,
|
|
}
|
|
seenHP, seenMP := 0, 0
|
|
inverted := 0
|
|
for itemID, reference := range catalog.itemRefs {
|
|
if reference.kind != dungeonDropItemStackable {
|
|
continue
|
|
}
|
|
expectHP, isHP := wantHP[reference.path]
|
|
expectMP, isMP := wantMP[reference.path]
|
|
if !isHP && !isMP {
|
|
continue
|
|
}
|
|
definition, resolveErr := catalog.ResolveItem(itemID)
|
|
if resolveErr != nil {
|
|
t.Fatalf("resolve %s: %v", reference.path, resolveErr)
|
|
}
|
|
if !currentPotionWasteStackable(definition.StackableType) {
|
|
t.Fatalf("%s stackable type=%q, want [waste]", reference.path, definition.StackableType)
|
|
}
|
|
if isHP {
|
|
seenHP++
|
|
if !definition.HasHPRecovery || definition.HPRecoveryOperator != "+" ||
|
|
definition.HPRecoveryAmount != expectHP || definition.HPRecoveryDurationMS != 1000 {
|
|
t.Fatalf("%s hp=%q %d/%d, want + %d/1000", reference.path,
|
|
definition.HPRecoveryOperator, definition.HPRecoveryAmount, definition.HPRecoveryDurationMS, expectHP)
|
|
}
|
|
if definition.HasMPRecovery {
|
|
t.Fatalf("%s declares mp recovery", reference.path)
|
|
}
|
|
if definition.HPRecoveryAmount > definition.HPRecoveryDurationMS {
|
|
inverted++
|
|
}
|
|
}
|
|
if isMP {
|
|
seenMP++
|
|
if !definition.HasMPRecovery || definition.MPRecoveryOperator != "+" ||
|
|
definition.MPRecoveryAmount != expectMP || definition.MPRecoveryDurationMS != 1000 {
|
|
t.Fatalf("%s mp=%q %d/%d, want + %d/1000", reference.path,
|
|
definition.MPRecoveryOperator, definition.MPRecoveryAmount, definition.MPRecoveryDurationMS, expectMP)
|
|
}
|
|
if definition.MPRecoveryAmount > definition.MPRecoveryDurationMS {
|
|
inverted++
|
|
}
|
|
}
|
|
}
|
|
if seenHP != len(wantHP) || seenMP != len(wantMP) {
|
|
t.Fatalf("resolved hp=%d/%d mp=%d/%d ordinary potions", seenHP, len(wantHP), seenMP, len(wantMP))
|
|
}
|
|
if inverted == 0 {
|
|
t.Fatal("no potion has first value > second; the min/max reading would still be tenable and this test proves nothing")
|
|
}
|
|
}
|