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