服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
351 lines
14 KiB
Go
351 lines
14 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// One crashed client must not swallow everybody else's flip.
|
|
//
|
|
// Live 2026-09-17: character 1's client faulted at 08:52:12Z, character 22
|
|
// cleared the last phase-two objective at 08:54:07Z and confirmed the result
|
|
// movie at 08:54:16Z, and the raid then stayed on AwaitingMovie until the log
|
|
// ended -- the movie gate required a confirmation from a socket that no longer
|
|
// existed. The surviving member's confirmation has to close the phase instead.
|
|
func TestRuntimeRaidPhaseTwoMovieSkipsADisconnectedMember(t *testing.T) {
|
|
fixture := newRuntimeRaidRewardTestFixture(t, true)
|
|
reserveRuntimeRaidDisconnectForTest(fixture, 1002)
|
|
|
|
state := fixture.service.raids[fixture.raidKey]
|
|
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
clear(state.Lifecycle.RewardSelections)
|
|
clear(state.Lifecycle.MovieConfirmed)
|
|
state.Lifecycle.RewardRosterFrozen = false
|
|
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
|
|
|
|
settled, all, err := fixture.service.confirmRuntimeRaidSettlementMovie(
|
|
1001,
|
|
runtimeRaidDungeonPhaseOne,
|
|
fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("surviving member's movie confirmation: %v", err)
|
|
}
|
|
if !all {
|
|
t.Fatal("a disconnected member still held the phase-two movie gate open")
|
|
}
|
|
if settled.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards {
|
|
t.Fatalf("lifecycle status=%d want awaiting-rewards", settled.Lifecycle.Status)
|
|
}
|
|
if !settled.Lifecycle.RewardSkippedMembers[1002] {
|
|
t.Fatalf("the dropped member was not recorded as skipped: %+v", settled.Lifecycle.RewardSkippedMembers)
|
|
}
|
|
if settled.Lifecycle.RewardSkippedMembers[1001] {
|
|
t.Fatal("the member that confirmed the movie was skipped")
|
|
}
|
|
}
|
|
|
|
// The member the settlement gave up on is not paid: its row is absent from the
|
|
// committed reward list and its inventory is untouched.
|
|
func TestRuntimeRaidSettlementDoesNotPayASkippedMember(t *testing.T) {
|
|
fixture := newRuntimeRaidRewardTestFixture(t, true)
|
|
reserveRuntimeRaidDisconnectForTest(fixture, 1002)
|
|
before := loadRuntimeRaidRewardTestInventories(t, fixture.repositories)
|
|
|
|
state := fixture.service.raids[fixture.raidKey]
|
|
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
clear(state.Lifecycle.RewardSelections)
|
|
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
|
|
state.Lifecycle.RewardRosterFrozen = false
|
|
state.Lifecycle.RewardSelections[1001] = runtimeRaidRewardSelection{Mode: 0, Index: 2, ConfirmedAt: fixture.now}
|
|
|
|
delivery, err := fixture.service.commitRuntimeRaidSettlementRewards(
|
|
fixture.raidKey,
|
|
runtimeRaidDungeonPhaseOne,
|
|
fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("commit with one member skipped: %v", err)
|
|
}
|
|
if len(delivery.Rewards.Members) != 1 || delivery.Rewards.Members[0].MemberKey != 1001 {
|
|
t.Fatalf("committed rows=%+v want only the member that confirmed", delivery.Rewards.Members)
|
|
}
|
|
after := loadRuntimeRaidRewardTestInventories(t, fixture.repositories)
|
|
skippedKey := runtimeRaidRewardTestCharacterKey(1002)
|
|
if len(after[skippedKey].Slots) != len(before[skippedKey].Slots) {
|
|
t.Fatalf("the skipped member was paid: before=%d slots after=%d slots",
|
|
len(before[skippedKey].Slots), len(after[skippedKey].Slots))
|
|
}
|
|
paidKey := runtimeRaidRewardTestCharacterKey(1001)
|
|
if len(after[paidKey].Slots) == len(before[paidKey].Slots) {
|
|
t.Fatalf("the member that confirmed was not paid: %+v", after[paidKey].Slots)
|
|
}
|
|
}
|
|
|
|
// A member that comes back after the settlement froze its eligible roster
|
|
// neither receives a second grant nor pulls the flow back into the waiting
|
|
// state: the record of who was skipped is monotonic for the phase.
|
|
func TestRuntimeRaidSettlementSurvivesTheSkippedMembersReconnect(t *testing.T) {
|
|
fixture := newRuntimeRaidRewardTestFixture(t, true)
|
|
reserveRuntimeRaidDisconnectForTest(fixture, 1002)
|
|
|
|
state := fixture.service.raids[fixture.raidKey]
|
|
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
clear(state.Lifecycle.RewardSelections)
|
|
clear(state.Lifecycle.MovieConfirmed)
|
|
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
|
|
state.Lifecycle.RewardRosterFrozen = false
|
|
|
|
if _, all, err := fixture.service.confirmRuntimeRaidSettlementMovie(
|
|
1001, runtimeRaidDungeonPhaseOne, fixture.now,
|
|
); err != nil || !all {
|
|
t.Fatalf("movie gate all=%t err=%v", all, err)
|
|
}
|
|
if _, _, err := fixture.service.selectRuntimeRaidSettlementCard(
|
|
1001, runtimeRaidDungeonPhaseOne,
|
|
raid.RaidRewardCardRequest{Mode: 1, CardIndex: 2}, fixture.now,
|
|
); err != nil {
|
|
t.Fatalf("surviving member mode1: %v", err)
|
|
}
|
|
if _, all, err := fixture.service.selectRuntimeRaidSettlementCard(
|
|
1001, runtimeRaidDungeonPhaseOne,
|
|
raid.RaidRewardCardRequest{Mode: 0, CardIndex: 2}, fixture.now,
|
|
); err != nil || !all {
|
|
t.Fatalf("surviving member mode0 all=%t err=%v", all, err)
|
|
}
|
|
first, err := fixture.service.commitRuntimeRaidSettlementRewards(
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("commit before the reconnect: %v", err)
|
|
}
|
|
afterCommit := loadRuntimeRaidRewardTestInventories(t, fixture.repositories)
|
|
|
|
// The dropped member reconnects inside its grace window.
|
|
fixture.service.raidMu.Lock()
|
|
delete(fixture.service.raidDisconnects, 1002)
|
|
fixture.service.raidMu.Unlock()
|
|
fixture.service.restoreRuntimeRaidPresence(fixture.member, "test_reconnect_after_settlement")
|
|
|
|
fixture.service.raidMu.Lock()
|
|
if !fixture.service.runtimeRaidRewardRosterConfirmedLocked(fixture.service.raids[fixture.raidKey]) {
|
|
fixture.service.raidMu.Unlock()
|
|
t.Fatal("the reconnect pulled the settled roster back into the waiting state")
|
|
}
|
|
fixture.service.raidMu.Unlock()
|
|
if err := fixture.service.validateRuntimeRaidRewardDeliveryCommitted(
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, first.Token,
|
|
); err != nil {
|
|
t.Fatalf("a committed delivery stopped validating after the reconnect: %v", err)
|
|
}
|
|
|
|
// The reconnected client's card click is acknowledged and ignored.
|
|
lateState, all, err := fixture.service.selectRuntimeRaidSettlementCard(
|
|
1002, runtimeRaidDungeonPhaseOne,
|
|
raid.RaidRewardCardRequest{Mode: 0, CardIndex: 2}, fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("the skipped member's late card click was rejected: %v", err)
|
|
}
|
|
if !all {
|
|
t.Fatal("the skipped member's late card click was not reported as settled")
|
|
}
|
|
if _, exists := lateState.Lifecycle.RewardSelections[1002]; exists {
|
|
t.Fatal("the skipped member's late click was recorded as a reward selection")
|
|
}
|
|
replayed, err := fixture.service.commitRuntimeRaidSettlementRewards(
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("commit after the reconnect: %v", err)
|
|
}
|
|
if replayed.Token != first.Token || len(replayed.Rewards.Members) != 1 ||
|
|
replayed.Rewards.Members[0].MemberKey != 1001 {
|
|
t.Fatalf("replayed delivery=%+v first=%+v", replayed.Rewards.Members, first.Rewards.Members)
|
|
}
|
|
if afterReplay := loadRuntimeRaidRewardTestInventories(t, fixture.repositories); !runtimeRaidRewardInventoriesEqual(afterCommit, afterReplay) {
|
|
t.Fatal("the reconnect granted the skipped member a second reward")
|
|
}
|
|
}
|
|
|
|
// An online member that simply never picks a card must not hold the squad.
|
|
//
|
|
// The card window has no countdown of its own: the only timer on the reward path
|
|
// is the 3s one that opens it (runtimeRaidRewardOpenCountdown). 86JP settles the
|
|
// window itself instead -- AutoSelectPendingPhaseOneCardsAsync
|
|
// (RaidHandler.Rewards.cs:383-416) waits ten seconds, gives every still-pending
|
|
// member the lowest free card index as operation 1 then 0, and settles the phase
|
|
// once the eligible roster is complete. This is the same timer, armed where
|
|
// 86JP arms it: once, right after the cards are published.
|
|
func TestRuntimeRaidAutoFlipSettlesAMemberThatNeverPicks(t *testing.T) {
|
|
fixture := newRuntimeRaidRewardTestFixture(t, true)
|
|
queue := newFakeCurrentDungeonDeathTimerQueue()
|
|
queue.now = fixture.now
|
|
fixture.service.gameplayTimers = queue
|
|
|
|
state := fixture.service.raids[fixture.raidKey]
|
|
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
state.Lifecycle.RewardOpenedAt = fixture.now
|
|
state.Lifecycle.RewardRosterFrozen = false
|
|
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
|
|
clear(state.Lifecycle.RewardSelections)
|
|
|
|
// 1001 picks. 1002 is online and never will.
|
|
if _, _, err := fixture.service.selectRuntimeRaidSettlementCard(
|
|
1001, runtimeRaidDungeonPhaseOne,
|
|
raid.RaidRewardCardRequest{Mode: 1, CardIndex: 2}, fixture.now,
|
|
); err != nil {
|
|
t.Fatalf("leader mode1: %v", err)
|
|
}
|
|
if _, all, err := fixture.service.selectRuntimeRaidSettlementCard(
|
|
1001, runtimeRaidDungeonPhaseOne,
|
|
raid.RaidRewardCardRequest{Mode: 0, CardIndex: 2}, fixture.now,
|
|
); err != nil || all {
|
|
t.Fatalf("leader mode0 all=%t err=%v", all, err)
|
|
}
|
|
snapshot, ok := fixture.service.runtimeRaidForMember(1001)
|
|
if !ok {
|
|
t.Fatal("the raid was not found for the leader")
|
|
}
|
|
if snapshot.Lifecycle.RewardOpenedAt.IsZero() {
|
|
t.Fatal("the card window has no open time to measure the auto flip from")
|
|
}
|
|
if snapshot.Lifecycle.RewardSkippedMembers[1002] {
|
|
t.Fatal("the online member that has not clicked yet was written off")
|
|
}
|
|
|
|
if err := fixture.service.scheduleRuntimeRaidRewardAutoFlip(snapshot); err != nil {
|
|
t.Fatalf("arm the auto flip: %v", err)
|
|
}
|
|
name := fmt.Sprintf("raid-reward-auto-flip:%d:%d:%d",
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, snapshot.StartAttempt)
|
|
queue.mu.Lock()
|
|
task := queue.active[name]
|
|
queue.mu.Unlock()
|
|
if task == nil {
|
|
t.Fatal("the card window was opened without arming its auto flip")
|
|
}
|
|
if task.delay != runtimeRaidRewardAutoFlipDelay {
|
|
t.Fatalf("auto flip delay=%s want=%s", task.delay, runtimeRaidRewardAutoFlipDelay)
|
|
}
|
|
queue.fire(task, false)
|
|
|
|
settled := fixture.service.raids[fixture.raidKey]
|
|
selection, exists := settled.Lifecycle.RewardSelections[1002]
|
|
if !exists || selection.Mode != 0 {
|
|
t.Fatalf("the idle member's card was not picked for it: %+v", settled.Lifecycle.RewardSelections)
|
|
}
|
|
delivery, err := fixture.service.commitRuntimeRaidSettlementRewards(
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, fixture.now,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("commit after the auto flip: %v", err)
|
|
}
|
|
if len(delivery.Rewards.Members) != 2 {
|
|
t.Fatalf("committed rows=%+v want both members", delivery.Rewards.Members)
|
|
}
|
|
after := loadRuntimeRaidRewardTestInventories(t, fixture.repositories)
|
|
idleKey := runtimeRaidRewardTestCharacterKey(1002)
|
|
if len(after[idleKey].Slots) == 0 {
|
|
t.Fatal("the member the server picked for was not paid")
|
|
}
|
|
|
|
// Idempotent: the same task fired again, and a duplicate commit, grant
|
|
// nothing a second time.
|
|
queue.fire(task, true)
|
|
if _, err := fixture.service.commitRuntimeRaidSettlementRewards(
|
|
fixture.raidKey, runtimeRaidDungeonPhaseOne, fixture.now,
|
|
); err != nil {
|
|
t.Fatalf("duplicate commit: %v", err)
|
|
}
|
|
if replayed := loadRuntimeRaidRewardTestInventories(t, fixture.repositories); !runtimeRaidRewardInventoriesEqual(after, replayed) {
|
|
t.Fatal("the auto flip paid a member twice")
|
|
}
|
|
}
|
|
|
|
// The automatic pick fills in for a member that is present. It never fills in
|
|
// for one the settlement already wrote off: that member has no card, is absent
|
|
// from the committed receipt, and is not granted anything.
|
|
func TestRuntimeRaidAutoFlipLeavesASkippedMemberAlone(t *testing.T) {
|
|
fixture := newRuntimeRaidRewardTestFixture(t, true)
|
|
reserveRuntimeRaidDisconnectForTest(fixture, 1002)
|
|
|
|
state := fixture.service.raids[fixture.raidKey]
|
|
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
|
|
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
state.Lifecycle.RewardOpenedAt = fixture.now
|
|
state.Lifecycle.RewardRosterFrozen = false
|
|
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
|
|
clear(state.Lifecycle.RewardSelections)
|
|
|
|
snapshot, ok := fixture.service.runtimeRaidForMember(1001)
|
|
if !ok {
|
|
t.Fatal("the raid was not found for the leader")
|
|
}
|
|
fixture.service.autoFlipRuntimeRaidPendingCards(snapshot)
|
|
|
|
settled, ok := fixture.service.runtimeRaidForMember(1001)
|
|
if !ok {
|
|
t.Fatal("the raid disappeared during the auto flip")
|
|
}
|
|
if _, exists := settled.Lifecycle.RewardSelections[1002]; exists {
|
|
t.Fatal("the auto flip picked a card for a member the settlement gave up on")
|
|
}
|
|
if selection, exists := settled.Lifecycle.RewardSelections[1001]; !exists || selection.Mode != 0 {
|
|
t.Fatalf("the auto flip did not settle the only eligible member: %+v", settled.Lifecycle.RewardSelections)
|
|
}
|
|
}
|
|
|
|
// reserveRuntimeRaidDisconnectForTest parks exactly what a dropped transport
|
|
// leaves behind: the roster row stays, and raid_disconnect.go holds the seat
|
|
// until the grace window expires.
|
|
func reserveRuntimeRaidDisconnectForTest(fixture runtimeRaidRewardTestFixture, characterID uint16) {
|
|
fixture.service.raidMu.Lock()
|
|
defer fixture.service.raidMu.Unlock()
|
|
if fixture.service.raidDisconnects == nil {
|
|
fixture.service.raidDisconnects = make(map[uint16]runtimeRaidDisconnectReservation)
|
|
}
|
|
fixture.service.raidDisconnects[characterID] = runtimeRaidDisconnectReservation{
|
|
RaidKey: fixture.raidKey,
|
|
CharacterID: characterID,
|
|
Generation: 1,
|
|
DisconnectedAt: fixture.now,
|
|
ExpiresAt: fixture.now.Add(runtimeRaidDisconnectGrace),
|
|
}
|
|
if state := fixture.service.raids[fixture.raidKey]; state != nil {
|
|
if index := runtimeRaidMemberIndex(*state, characterID); index >= 0 {
|
|
state.Members[index].UserState = runtimeRaidMemberUserStateOffline
|
|
}
|
|
}
|
|
}
|
|
|
|
func runtimeRaidRewardInventoriesEqual(
|
|
left map[string]dnfrepo.InventoryRecord,
|
|
right map[string]dnfrepo.InventoryRecord,
|
|
) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for key, leftInventory := range left {
|
|
rightInventory, ok := right[key]
|
|
if !ok || len(leftInventory.Slots) != len(rightInventory.Slots) {
|
|
return false
|
|
}
|
|
for slot, stack := range leftInventory.Slots {
|
|
other, ok := rightInventory.Slots[slot]
|
|
if !ok || !reflect.DeepEqual(other, stack) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|