Files
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

211 lines
7.1 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"os"
"reflect"
"strings"
"testing"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func warMissionSource() dungeonDropCatalogTestSource {
s := warOperationSource()
s["etc/agitwar.etc"] += "\n[agitwar day]\n0 1 2 3 4 5 6\n[/agitwar day]\n[war limit time]\n`5:30` `6:00`\n[play time]\n900\n[daily max challenge]\n5\n[hp]\n2\n[enemy hp]\n100\n[enemy agit]\n[index]\n8514\n[clear damage]\n10\n[required clear dungeon]\n8515\n[/required clear dungeon]\n[/enemy agit]\n[enemy barricade dungeon]\n8515\n[/enemy barricade dungeon]\n[challenge chance]\n0 100 200 300 400 500\n[/challenge chance]\n[mission system]\n[daily title mission index]\n1\n[season title mission index]\n10\n"
for _, v := range []struct {
id int
category, condition string
required int
}{{1, "daily", "mission", 1}, {2, "daily", "join", 1}, {10, "season", "mission", 1}, {11, "season", "clear", 2}} {
s["etc/agitwar.etc"] += fmt.Sprintf("[mission]\n[index]\n%d\n[category]\n`%s`\n[title]\n`mission %d`\n[condition]\n`%s` %d\n[reward]\n10161523 1\n[/reward]\n[/mission]\n", v.id, v.category, v.id, v.condition, v.required)
}
s["etc/agitwar.etc"] += "[/mission system]"
return s
}
func TestGuildWarRealMissionsAndRules(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("real PVF not selected")
}
a, e := platformpvf.Open(path)
if e != nil {
t.Fatal(e)
}
c, e := dnfguild.LoadWarMissionsCatalog(a)
if e != nil {
t.Fatal(e)
}
r, e := dnfguild.LoadWarRules(a)
if e != nil {
t.Fatal(e)
}
if len(c.Missions) != 30 || c.DailyTitle != 1 || c.SeasonTitle != 10 || c.Missions[0].Required != 8 || r.DailyMax != 5 || len(r.Dungeons) != 11 || r.EnemyDungeon != 8514 {
t.Fatal(c, r)
}
items, e := newPVFDungeonDropCatalog(a)
if e != nil {
t.Fatal(e)
}
for _, m := range c.Missions {
for _, v := range m.Rewards {
def, e := items.ResolveItem(v.ItemID)
if e != nil || def.Kind != dungeonDropItemStackable {
t.Fatal(v, e)
}
}
}
b := buildCurrentGuildWarMissionsBody(c, dnfguild.WarMissionState{})
if len(b) != 278 || binary.LittleEndian.Uint32(b) != 9 || binary.LittleEndian.Uint32(b[85:]) != 21 {
t.Fatalf("native task packet %x", b)
}
if r.FriendlyHP != 2 || r.PauseStart != 330 || r.PauseEnd != 360 || r.PlaySeconds != 900 {
t.Fatal("PVF schedule and health", r)
}
guardians, e := dnfguild.LoadWarGuardians(a)
if e != nil {
t.Fatal(e)
}
if id, e := guardians.Monster(dnfguild.Guardian{ID: 1, Level: 21}); e != nil || id != 71023 {
t.Fatal("PVF guardian star", id, e)
}
}
func TestGuildWarMissionsRejectMissingCategory(t *testing.T) {
s := warMissionSource()
s["etc/agitwar.etc"] = strings.Replace(s["etc/agitwar.etc"], "[category]\n`daily`\n", "", 1)
if _, e := dnfguild.LoadWarMissionsCatalog(s); e == nil {
t.Fatal("missing category accepted")
}
}
func TestGuildWarMissionClaimAtomicAndReplay(t *testing.T) {
for _, legacy := range []bool{false, true} {
t.Run(fmt.Sprint(legacy), func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
fail := &guildFailSettlement{base: repos.CharacterSettlement}
repos.CharacterSettlement = fail
c := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "dnf:master", Name: "master", Level: 90, Stats: map[string]int64{"gold": 100}}
if e := repos.Character.Save(ctx, c); e != nil {
t.Fatal(e)
}
if e := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "16", Slots: map[string]dnfrepo.ItemStack{}}); e != nil {
t.Fatal(e)
}
r := dnfguild.NewRegistry()
g, e := r.Create(c, "guild", serviceTestNow())
if e != nil {
t.Fatal(e)
}
g.ActiveGuardian = 1
if e = dnfguild.Save(ctx, repos.Settings, r, serviceTestNow()); e != nil {
t.Fatal(e)
}
items, e := newPVFDungeonDropCatalog(warMissionSource())
if e != nil {
t.Fatal(e)
}
s := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}, pvfItemCatalog: items, repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true }}
ss := &gameSession{conn: &bufferConn{}, selectedCharacterID: 16, accountID: c.AccountID, initialTownRouteCharacterID: 16, initialTownRouteStage: currentInitialTownRoutePlayerStateSent}
send := func(id uint32, want byte) []byte {
t.Helper()
ss.conn.(*bufferConn).write.Reset()
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, id)
h := guildGameplayModule().UpperHandlers[1346]
if legacy {
h = guildGameplayModule().LegacyHandlers[1346]
}
if e := h(s, ss, gameplayRequest{Opcode: 1346, Classification: 1, Body: b}); e != nil {
t.Fatal(e)
}
ack, rest := splitGameServerUpperPacket(t, ss.conn.(*bufferConn).write.Bytes())
v := []byte{1}
if want != 0 {
v = []byte{0, want}
}
if ack.Header.MsgID != 1346 || !bytes.Equal(ack.Body, v) {
t.Fatalf("ack %x want %x", ack.Body, v)
}
return rest
}
send(2, 251)
catalog, e := s.currentGuildWarMissionsCatalog()
if e != nil {
t.Fatal(e)
}
var state dnfguild.WarMissionState
now := s.gameplayNow()
if e = state.Observe(catalog, "join", 8515, "battle", now, now); e != nil {
t.Fatal(e)
}
if e = dnfguild.SaveWarMissions(ctx, repos.Settings, "16", state, now); e != nil {
t.Fatal(e)
}
before, _, e := repos.Inventory.Load(ctx, "16")
if e != nil {
t.Fatal(e)
}
fail.fail = true
send(2, 253)
fail.fail = false
after, _, e := repos.Inventory.Load(ctx, "16")
if e != nil || !reflect.DeepEqual(before, after) {
t.Fatal("commit failure changed inventory", e)
}
check, e := dnfguild.LoadWarMissions(ctx, repos.Settings, "16", catalog, now)
if e != nil || check.Values[2].Claimed {
t.Fatal("commit failure consumed claim", e)
}
full := dnfrepo.CloneInventory(before)
full.Slots = fullCurrentNPCShopWasteInventory()
if e = repos.Inventory.Save(ctx, full); e != nil {
t.Fatal(e)
}
send(2, 252)
after, _, e = repos.Inventory.Load(ctx, "16")
if e != nil || !reflect.DeepEqual(full, after) {
t.Fatal("full inventory changed", e)
}
check, e = dnfguild.LoadWarMissions(ctx, repos.Settings, "16", catalog, now)
if e != nil || check.Values[2].Claimed {
t.Fatal("full inventory consumed claim", e)
}
if e = repos.Inventory.Save(ctx, before); e != nil {
t.Fatal(e)
}
rest := send(2, 0)
if len(rest) == 0 {
t.Fatal("missing item refresh")
}
first, _, e := repos.Inventory.Load(ctx, "16")
if e != nil {
t.Fatal(e)
}
rest = send(2, 0)
p, remaining := splitGameServerUpperPacket(t, rest)
if p.Header.MsgID != 1283 || len(remaining) != 0 {
t.Fatal("replay updated inventory")
}
second, _, e := repos.Inventory.Load(ctx, "16")
if e != nil || len(first.Slots) != len(second.Slots) {
t.Fatal("claim twice")
}
for key, v := range first.Slots {
if second.Slots[key].Count != v.Count {
t.Fatal("quantity changed")
}
}
})
}
}