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