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

259 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRuntimeRaidAdmissionCounterIsSharedByAccount(t *testing.T) {
service, repositories, catalog, _ := newRuntimeRaidAdmissionCounterTest(t, 2, 1001, 1002)
for index, characterID := range []uint16{1001, 1002} {
state := runtimeRaidState{
RaidKey: uint32(index + 1),
StartAttempt: 1,
FrozenMembers: []runtimeRaidMemberState{{
CharID: characterID,
}},
}
if batch, err := service.reserveRuntimeRaidAdmissionCounters(
context.Background(), state, runtimeRaidOwnerAnton, time.Now().UTC()); err != nil || batch == nil {
t.Fatalf("character=%d batch=%+v err=%v", characterID, batch, err)
}
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 2)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1002", "raid.anton.account.daily", 2)
}
// 现在一次开团同时收三条 policy(账号日 / 账号周 / 角色周),所以"哪个先到顶"
// 取决于三者中最紧的那个。用真档形状(安徒恩 [DAILY ACCOUNT ENTER LIMIT] 10、
// [WEEKLY ENTER COUNT] 30 3 = 账号周 30 / 角色周 3)时,**同一个角色**最紧的是
// 角色周 3 —— 第 4 次就该被拒,而不是第 11 次。
func TestRuntimeRaidAdmissionCounterRejectsTheFourthCharacterWeeklyEntry(t *testing.T) {
service, repositories, catalog, _ := newRuntimeRaidAdmissionCounterTest(t, 1, 1001)
enter := func(raidKey uint32) error {
t.Helper()
state := runtimeRaidState{
RaidKey: raidKey,
StartAttempt: 1,
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
}
_, err := service.reserveRuntimeRaidAdmissionCounters(
context.Background(), state, runtimeRaidOwnerAnton, time.Now().UTC())
return err
}
for attempt := 1; attempt <= 3; attempt++ {
if err := enter(uint32(attempt)); err != nil {
t.Fatalf("attempt=%d should be allowed (character weekly limit is 3): %v", attempt, err)
}
}
err := enter(4)
if !errors.Is(err, dnfdungeonadmission.ErrLimitReached) {
t.Fatalf("fourth entry err=%v want ErrLimitReached", err)
}
if !strings.Contains(err.Error(), "raid.anton.character.weekly") {
t.Fatalf("fourth entry was refused by the wrong policy: %v", err)
}
// 被拒的那次不能把计数往前推,而且账号日/账号周都只走到 3 —— 这正是
// "角色周先绑定" 的证据。角色周自己无法用这个 helper 读:它此刻正好卡在
// 3/3,而 Owner.Preflight 对已用尽的 policy 是**拒绝**(LimitError)而不是
// 回报快照 —— 上面那条 err 里已经印出了 used=3 limit=3。
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 3)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.weekly", 3)
}
// 同一个账号下每个角色各进一次:角色周(每人 3)与账号周(共 30)都不绑定,
// 最紧的是**账号日 10**,所以第 11 个角色被拒。这条钉住"账号日这条老限制没被
// 周限制悄悄取代"。
func TestRuntimeRaidAdmissionCounterRejectsTheEleventhAccountDailyEntryAcrossCharacters(t *testing.T) {
characterIDs := make([]uint16, 0, 12)
for index := 0; index < 12; index++ {
characterIDs = append(characterIDs, uint16(1001+index))
}
service, _, _, _ := newRuntimeRaidAdmissionCounterTest(t, 1, characterIDs...)
enter := func(characterID uint16, raidKey uint32) error {
t.Helper()
state := runtimeRaidState{
RaidKey: raidKey,
StartAttempt: 1,
FrozenMembers: []runtimeRaidMemberState{{CharID: characterID}},
}
_, err := service.reserveRuntimeRaidAdmissionCounters(
context.Background(), state, runtimeRaidOwnerAnton, time.Now().UTC())
return err
}
for index, characterID := range characterIDs[:10] {
if err := enter(characterID, uint32(index+1)); err != nil {
t.Fatalf("character=%d entry %d should be allowed: %v", characterID, index+1, err)
}
}
err := enter(characterIDs[10], 11)
if !errors.Is(err, dnfdungeonadmission.ErrLimitReached) {
t.Fatalf("eleventh entry err=%v want ErrLimitReached", err)
}
if !strings.Contains(err.Error(), "raid.anton.account.daily") {
t.Fatalf("eleventh entry was refused by the wrong policy: %v", err)
}
}
func TestRuntimeRaidStartMaterialFailureRestoresAdmissionCounter(t *testing.T) {
service, repositories, catalog, session := newRuntimeRaidAdmissionCounterTest(t, 0, 1001)
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
t.Fatal(err)
}
state, ok := service.runtimeRaidForMember(1001)
if !ok || state.Started || state.StartPending || state.EntryCostPaid {
t.Fatalf("failed start state=%+v found=%t", state, ok)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 0)
}
func TestRuntimeRaidSuccessfulStartCountsOnceAndReplayDoesNotCountAgain(t *testing.T) {
service, repositories, catalog, session := newRuntimeRaidAdmissionCounterTest(t, 2, 1001)
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
t.Fatal(err)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 1)
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
t.Fatal(err)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 1)
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
}
func TestRuntimeRaidCommitFailureCanRestoreTicketAndAdmissionCounter(t *testing.T) {
service, repositories, catalog, _ := newRuntimeRaidAdmissionCounterTest(t, 1, 1001)
reservation, err := service.reserveRuntimeRaidStart(1001)
if err != nil {
t.Fatal(err)
}
state := reservation.State
state.AntonConfig = service.raidAnton
counters, err := service.reserveRuntimeRaidAdmissionCounters(
context.Background(), state, runtimeRaidOwnerAnton, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
entryCost, err := service.consumeRuntimeRaidEntryCostBatch(state)
if err != nil {
t.Fatal(err)
}
service.raidMu.Lock()
service.raids[state.RaidKey].StartAttempt++
service.raidMu.Unlock()
if _, committed := service.commitRuntimeRaidStart(reservation, entryCost.updates, service.raidAnton, time.Now().UTC()); committed {
t.Fatal("sabotaged raid start unexpectedly committed")
}
var restoreErr error
if err := service.restoreRuntimeRaidEntryCostBatch(entryCost); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
if err := service.restoreCurrentDungeonAdmissionCounterBatch(counters); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
if restoreErr != nil {
t.Fatal(restoreErr)
}
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.daily", 0)
}
func newRuntimeRaidAdmissionCounterTest(
t *testing.T,
ticketCount int64,
characterIDs ...uint16,
) (*Service, dnfrepo.Group, *dnfdungeonadmission.Catalog, *gameSession) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{AccountID: "raid-counter-account"}); err != nil {
t.Fatal(err)
}
for _, characterID := range characterIDs {
characterKey := fmt.Sprint(characterID)
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: characterKey,
AccountID: "raid-counter-account",
}); err != nil {
t.Fatal(err)
}
slots := map[string]dnfrepo.ItemStack{}
if ticketCount > 0 {
slots["0:0"] = dnfrepo.ItemStack{ItemID: runtimeRaidEntryItemID, Count: ticketCount}
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: characterKey,
Slots: slots,
}); err != nil {
t.Fatal(err)
}
}
catalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
if err != nil {
t.Fatal(err)
}
config := runtimeRaidBridgeTestAntonConfig()
config.StartMinimumMembers = 1
config.SingleStartMinimumMembers = 1
config.StartDelaySeconds = 3600
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidAnton: config,
gameplayTimers: &raidStartRecordingTimeQueue{now: time.Now().UTC()},
dungeonAdmissionCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if len(characterIDs) == 0 {
return service, repositories, catalog, nil
}
leaderID := characterIDs[0]
session := &gameSession{
conn: &bufferConn{},
channel: channelcatalog.Channel{ID: 200, Type: dnfenum.RaidChannelType},
}
service.bindGameSessionCharacter(session, leaderID)
member := runtimeRaidMemberState{CharID: leaderID, GroupIndex: 1, SlotOrder: 0}
raidKey := uint32(200)<<16 | uint32(leaderID)
// The start command refuses a roster that is not standing in the raid waiting
// area (see raid_start_waiting_area.go).
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, session, testAntonWaitingAreaSpec(t), raidKey)
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey, LeaderID: leaderID, RaidMode: runtimeRaidOwnerAnton,
AntonConfig: config, Members: []runtimeRaidMemberState{member},
}
return service, repositories, catalog, session
}
func assertRuntimeRaidAdmissionUsed(
t *testing.T,
repositories dnfrepo.Group,
catalog *dnfdungeonadmission.Catalog,
characterID string,
policyKey string,
want uint32,
) {
t.Helper()
policy, found := catalog.Policy(policyKey)
if !found {
t.Fatalf("policy %s missing", policyKey)
}
owner, err := dnfdungeonadmission.NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
state, err := owner.Preflight(context.Background(), characterID, []dnfdungeonadmission.Policy{policy}, time.Now().UTC())
if err != nil || len(state) != 1 || state[0].Used != want {
t.Fatalf("character=%s policy=%s state=%+v err=%v want=%d", characterID, policyKey, state, err, want)
}
}