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