DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,553 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/raid"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// The three tests in this file pin the lifetime of the revive-coin ledger that the
|
||||
// 634 battle-situation rows report, because getting that lifetime wrong is not
|
||||
// cosmetic: the client renders `its own per-gate allowance - the value we send`, so
|
||||
// a ledger that resets early shows the operator coins they no longer have.
|
||||
//
|
||||
// Live 2026-09-23, raid with two squads: six paid revives were spent and the panel
|
||||
// stayed at 10/12. Two independent defects produced that, and each one has a test
|
||||
// here:
|
||||
//
|
||||
// - the spend lived on runtimeDungeonState, which is allocated per RUN, so
|
||||
// re-entering the same gate (210 -> town -> 210) silently zeroed it while the
|
||||
// client's denominator stayed the gate's own. See
|
||||
// TestRuntimeRaidReviveCoinSpendAccumulatesAcrossGateReEntries.
|
||||
// - no 634 was emitted when a coin was spent at all, so the panel only moved when
|
||||
// something else happened to publish rows. See
|
||||
// TestRuntimeRaidReviveCoinSpendPublishesTheBoard.
|
||||
// - a gate whose clear quota is met has to hand its allowance back, which is
|
||||
// 86JP's own participation lifetime. See
|
||||
// TestRuntimeRaidReviveCoinLedgerResetsWhenTheGateIsCleared.
|
||||
|
||||
// runtimeRaidReviveCoinTestKey is the ledger entry for one member's current gate.
|
||||
func runtimeRaidReviveCoinTestKey(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
session *gameSession,
|
||||
) runtimeRaidReviveCoinKey {
|
||||
t.Helper()
|
||||
session.dungeon.mu.Lock()
|
||||
binding := runtimeRaidDungeonBinding{}
|
||||
if session.dungeon.runtime != nil {
|
||||
binding = session.dungeon.runtime.raidDungeon
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if binding.RaidKey == 0 || binding.MapID == 0 {
|
||||
t.Fatalf("session %d is not inside a raid gate: %+v", session.selectedCharacterID, binding)
|
||||
}
|
||||
service.raidMu.Lock()
|
||||
defer service.raidMu.Unlock()
|
||||
state := service.raids[binding.RaidKey]
|
||||
if state == nil {
|
||||
t.Fatalf("raid %d is not live", binding.RaidKey)
|
||||
}
|
||||
return raidReviveCoinLedgerKey(*state, binding, session.selectedCharacterID)
|
||||
}
|
||||
|
||||
// attachRuntimeRaidReviveCoinRaid gives an ordinary-dungeon fixture a live raid whose
|
||||
// only frozen member is the fixture's own character, and binds that character's run
|
||||
// to one gate of it.
|
||||
//
|
||||
// The raid is built from the real Anton config and the real lifecycle constructor so
|
||||
// the clear path the tests below drive is the production one, not a stub.
|
||||
func attachRuntimeRaidReviveCoinRaid(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
session *gameSession,
|
||||
raidKey uint32,
|
||||
phase byte,
|
||||
mapID int,
|
||||
) {
|
||||
t.Helper()
|
||||
preparedAt := time.Date(2026, 9, 23, 8, 0, 0, 0, time.UTC)
|
||||
config := runtimeRaidBridgeTestAntonConfig()
|
||||
lifecycle, err := newRuntimeRaidLifecycleStateFromAntonModeAndType(config, preparedAt, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("create lifecycle: %v", err)
|
||||
}
|
||||
lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||||
lifecycle.Phase = phase
|
||||
lifecycle.PhaseStartedAt = preparedAt
|
||||
lifecycle.PhaseDeadline = preparedAt.Add(40 * time.Minute)
|
||||
member := runtimeRaidMemberState{
|
||||
CharID: session.selectedCharacterID,
|
||||
Name: "leader",
|
||||
GroupIndex: 1,
|
||||
}
|
||||
service.raidMu.Lock()
|
||||
if service.raids == nil {
|
||||
service.raids = make(map[uint32]*runtimeRaidState)
|
||||
}
|
||||
service.raids[raidKey] = &runtimeRaidState{
|
||||
RaidKey: raidKey,
|
||||
LeaderID: member.CharID,
|
||||
AntonConfig: config,
|
||||
Members: []runtimeRaidMemberState{member},
|
||||
FrozenMembers: []runtimeRaidMemberState{member},
|
||||
Started: true,
|
||||
EntryCostPaid: true,
|
||||
ChargedMembers: []uint16{member.CharID},
|
||||
Lifecycle: lifecycle,
|
||||
}
|
||||
service.raidMu.Unlock()
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
if session.dungeon.runtime == nil {
|
||||
session.dungeon.mu.Unlock()
|
||||
t.Fatal("the fixture session has no dungeon run to bind")
|
||||
}
|
||||
session.dungeon.runtime.raidDungeon = runtimeRaidDungeonBinding{
|
||||
RaidKey: raidKey,
|
||||
Phase: phase,
|
||||
MapID: mapID,
|
||||
LifecycleToken: 1,
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
}
|
||||
|
||||
// armRuntimeRaidReviveDeathState runs the real op32 death on an ordinary dungeon run
|
||||
// and returns the op41 body that revive answers to, so a test can replay it.
|
||||
func armRuntimeRaidReviveDeathState(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
session *gameSession,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
if err := service.handleDungeonDieCharacter(
|
||||
session, make([]byte, currentDungeonDieCharacterBodySize)); err != nil {
|
||||
t.Fatalf("op32 death state: %v", err)
|
||||
}
|
||||
body := make([]byte, currentDungeonUseCoinBodySize)
|
||||
binary.LittleEndian.PutUint16(body, currentSceneActorObjectKey(session.selectedCharacterID))
|
||||
return body
|
||||
}
|
||||
|
||||
// spendRuntimeRaidReviveCoinThroughThePacketPath runs one real op32 + op41 revive and
|
||||
// returns the op41 body and the handler's error.
|
||||
func spendRuntimeRaidReviveCoinThroughThePacketPath(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
session *gameSession,
|
||||
) ([]byte, error) {
|
||||
t.Helper()
|
||||
body := armRuntimeRaidReviveDeathState(t, service, session)
|
||||
return body, service.handleDungeonUseCoin(session, body)
|
||||
}
|
||||
|
||||
// fireRaidMonsterStateTasks runs every board refresh the code under test queued and
|
||||
// reports how many there were.
|
||||
//
|
||||
// The refresh is scheduled, never sent inline (a revive holds the spending member's
|
||||
// session lock, and building the rows takes each member's lock in turn), so a test
|
||||
// has to fire the queue the way the gameplay timer loop would.
|
||||
func fireRaidMonsterStateTasks(
|
||||
t *testing.T,
|
||||
queue *fakeCurrentDungeonDeathTimerQueue,
|
||||
) int {
|
||||
t.Helper()
|
||||
tasks := queue.tasksNamed("raid-monster-state:")
|
||||
for _, task := range tasks {
|
||||
queue.fire(task, false)
|
||||
}
|
||||
return len(tasks)
|
||||
}
|
||||
|
||||
// countRaidMonsterStatePackets counts the 634 packets inside one connection stream.
|
||||
func countRaidMonsterStatePackets(t *testing.T, stream []byte) (int, []byte) {
|
||||
t.Helper()
|
||||
count := 0
|
||||
last := []byte(nil)
|
||||
for len(stream) != 0 {
|
||||
packet, rest := splitGameServerUpperPacket(t, stream)
|
||||
if packet.Header.MsgID == raid.RaidMonsterStateMsgID {
|
||||
count++
|
||||
last = packet.Body
|
||||
}
|
||||
stream = rest
|
||||
}
|
||||
return count, last
|
||||
}
|
||||
|
||||
// A gate's allowance belongs to the GATE, so a squad that spends coins, leaves and
|
||||
// re-enters the same gate still reports everything it has spent. This is the defect
|
||||
// the operator reported live: two coins spent in 黑雾之源 showed as 10/12, the squad
|
||||
// re-entered, and the board went back to 12/12 because the counter lived on the run.
|
||||
func TestRuntimeRaidReviveCoinSpendAccumulatesAcrossGateReEntries(t *testing.T) {
|
||||
service, session, runtime := newBackToVillageRuntime(t)
|
||||
// The row builder resolves a squad's gate through the online session registry.
|
||||
service.bindGameSessionCharacter(session, session.selectedCharacterID)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
repositories, ok := service.repositoryGroup()
|
||||
if !ok {
|
||||
t.Fatal("revive test repositories unavailable")
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: int64(csharpReviveCoinItemID), Count: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const raidKey = uint32(0x00c80923)
|
||||
attachRuntimeRaidReviveCoinRaid(t, service, session, raidKey, runtimeRaidDungeonPhaseTwo, 210)
|
||||
|
||||
reviveBody, err := spendRuntimeRaidReviveCoinThroughThePacketPath(t, service, session)
|
||||
if err != nil {
|
||||
t.Fatalf("first revive: %v", err)
|
||||
}
|
||||
key := runtimeRaidReviveCoinTestKey(t, service, session)
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 1 {
|
||||
t.Fatalf("after one paid revive the gate ledger=%d want 1 (key %+v)", uses, key)
|
||||
}
|
||||
|
||||
// The client retries op41 for a revive the server already committed. That retry
|
||||
// must not bill a second coin: the commit branch is the only place that counts,
|
||||
// and the retry finds the pending death state already resolved.
|
||||
if err := service.handleDungeonUseCoin(session, reviveBody); err != nil {
|
||||
t.Fatalf("replayed revive: %v", err)
|
||||
}
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 1 {
|
||||
t.Fatalf("a replayed revive billed the gate again: ledger=%d want 1", uses)
|
||||
}
|
||||
|
||||
// Re-entering the gate allocates a NEW run for the same (raid, phase, gate). This
|
||||
// is the exact shape that used to reset the count.
|
||||
reentered := &runtimeDungeonState{
|
||||
raidDungeon: runtime.raidDungeon,
|
||||
}
|
||||
if _, _, counted := service.recordRuntimeRaidReviveCoinUseLocked(session, reentered); !counted {
|
||||
t.Fatal("the re-entered run's coin was not billed to the raid ledger")
|
||||
}
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 2 {
|
||||
t.Fatalf("the gate ledger reset on re-entry: %d want 2 (key %+v)", uses, key)
|
||||
}
|
||||
|
||||
// And the board must report the gate's total, not the current run's.
|
||||
rows, ok := service.runtimeRaidMonsterStateRows(*service.raids[raidKey])
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v ok=%t want one row for the squad in gate 210", rows, ok)
|
||||
}
|
||||
if rows[0].Value0 != 2 {
|
||||
t.Fatalf("board V0=%d want 2 coins spent in this gate", rows[0].Value0)
|
||||
}
|
||||
}
|
||||
|
||||
// A new phase starts from a full allowance: 86JP builds fresh participation records
|
||||
// per phase, so the previous phase's spend must not follow the squad into the next
|
||||
// one -- and must not be erased for the phase it belongs to either.
|
||||
func TestRuntimeRaidReviveCoinSpendIsPerPhase(t *testing.T) {
|
||||
service, session, _, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 210)
|
||||
setRaidSituationFixtureSpentCoins(t, service, session, 2)
|
||||
phaseTwoKey := runtimeRaidReviveCoinTestKey(t, service, session)
|
||||
|
||||
service.raidMu.Lock()
|
||||
state := service.raids[raidKey]
|
||||
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
||||
service.raidMu.Unlock()
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseOne, 210)
|
||||
phaseOneKey := runtimeRaidReviveCoinTestKey(t, service, session)
|
||||
if phaseOneKey == phaseTwoKey {
|
||||
t.Fatalf("both phases produced the same ledger key %+v", phaseOneKey)
|
||||
}
|
||||
|
||||
rows, ok := service.runtimeRaidMonsterStateRows(*service.raids[raidKey])
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v ok=%t want one row", rows, ok)
|
||||
}
|
||||
if rows[0].Value0 != 0 {
|
||||
t.Fatalf("the new phase inherited %d coins of spend", rows[0].Value0)
|
||||
}
|
||||
if uses := service.runtimeRaidReviveCoinUses(phaseTwoKey); uses != 2 {
|
||||
t.Fatalf("the finished phase's ledger was erased: %d want 2", uses)
|
||||
}
|
||||
}
|
||||
|
||||
// A spent coin has to reach the panel by itself. Nothing else refreshes the rows in
|
||||
// the middle of a gate, which is why six live revives left the board at 10/12.
|
||||
//
|
||||
// It also pins the two rules the refresh has to obey: it is scheduled rather than
|
||||
// sent from the revive's own goroutine (which still holds that member's session
|
||||
// lock), and it reaches every member.
|
||||
func TestRuntimeRaidReviveCoinSpendPublishesTheBoard(t *testing.T) {
|
||||
service, session, _ := newBackToVillageRuntime(t)
|
||||
// The board is fanned out over the raid's ONLINE members, which is the online
|
||||
// session registry, so the fixture character has to be registered in it.
|
||||
service.bindGameSessionCharacter(session, session.selectedCharacterID)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
repositories, ok := service.repositoryGroup()
|
||||
if !ok {
|
||||
t.Fatal("revive test repositories unavailable")
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: int64(csharpReviveCoinItemID), Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const raidKey = uint32(0x00c80924)
|
||||
attachRuntimeRaidReviveCoinRaid(t, service, session, raidKey, runtimeRaidDungeonPhaseTwo, 210)
|
||||
|
||||
if _, err := spendRuntimeRaidReviveCoinThroughThePacketPath(t, service, session); err != nil {
|
||||
t.Fatalf("revive: %v", err)
|
||||
}
|
||||
conn := session.conn.(*bufferConn)
|
||||
if count, _ := countRaidMonsterStatePackets(t, conn.write.Bytes()); count != 0 {
|
||||
t.Fatalf("the revive sent the board inline %d times; it must be scheduled", count)
|
||||
}
|
||||
if scheduled := fireRaidMonsterStateTasks(t, queue); scheduled != 1 {
|
||||
t.Fatalf("the revive queued %d board refreshes, want exactly 1", scheduled)
|
||||
}
|
||||
count, body := countRaidMonsterStatePackets(t, conn.write.Bytes())
|
||||
if count != 1 {
|
||||
t.Fatalf("the queued refresh produced %d board packets, want 1", count)
|
||||
}
|
||||
memberKey := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(memberKey, currentSceneActorObjectKey(session.selectedCharacterID))
|
||||
want := []byte{
|
||||
1, // groupCount
|
||||
1, 0, // squad 1
|
||||
1, // one member
|
||||
}
|
||||
want = append(want, memberKey...)
|
||||
// V0 = the one coin this gate has spent, the number the client subtracts from its
|
||||
// own allowance. V1 stays 0 because this squad has reported no monster HP yet, and
|
||||
// the value count is 0 for the same reason.
|
||||
want = append(want, 1, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
if !bytes.Equal(body, want) {
|
||||
t.Fatalf("board body=%x want=%x", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The board refresh must not run on the reviving member's own goroutine while that
|
||||
// member's packet path is still finishing, and it must not run under any session lock
|
||||
// at all: building the rows reads every online member's session lock in turn, so a
|
||||
// refresh that ran inline would make two members reviving at the same instant wait
|
||||
// for each other -- the live 2026-09-20 08:32:02Z shape, which
|
||||
// TestRuntimeRaidVolcanoClearNeverWaitsOnAMateSessionLock pins for the phase tail.
|
||||
//
|
||||
// This is the revive's version of that test, and it fails the same way the old
|
||||
// inline shape would: the mate holds its own session lock while the revive runs, so
|
||||
// an inline refresh would park on it.
|
||||
func TestRuntimeRaidReviveCoinBoardRefreshNeverWaitsOnAMateSessionLock(t *testing.T) {
|
||||
service, session, _ := newBackToVillageRuntime(t)
|
||||
service.bindGameSessionCharacter(session, session.selectedCharacterID)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
repositories, ok := service.repositoryGroup()
|
||||
if !ok {
|
||||
t.Fatal("revive test repositories unavailable")
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: int64(csharpReviveCoinItemID), Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const raidKey = uint32(0x00c80925)
|
||||
attachRuntimeRaidReviveCoinRaid(t, service, session, raidKey, runtimeRaidDungeonPhaseTwo, 210)
|
||||
mate, mateConn := attachRuntimeRaidReviveCoinMate(t, service, session, raidKey, 210, 100)
|
||||
|
||||
mateHeld := holdMateSessionLock(t, mate)
|
||||
defer mateHeld.releaseLock()
|
||||
mateHeld.enterLock()
|
||||
select {
|
||||
case <-mateHeld.held:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the mate never took its own session lock")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := spendRuntimeRaidReviveCoinThroughThePacketPath(t, service, session)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("revive with a mate holding its own session lock: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("队友握着自己的 session 锁时复活包卡住了:板子刷新跑在了复活者自己的调用栈里" +
|
||||
"(构造行要逐个读在线成员的 session 锁,两人同时复活就是永久互等)。")
|
||||
}
|
||||
|
||||
// The refresh still has to happen -- just not there.
|
||||
mateHeld.releaseLock()
|
||||
if scheduled := fireRaidMonsterStateTasks(t, queue); scheduled != 1 {
|
||||
t.Fatalf("the revive queued %d board refreshes, want exactly 1", scheduled)
|
||||
}
|
||||
count, body := countRaidMonsterStatePackets(t, mateConn.write.Bytes())
|
||||
if count != 1 {
|
||||
t.Fatalf("the mate received %d board packets, want 1", count)
|
||||
}
|
||||
if len(body) == 0 || body[0] != 2 {
|
||||
t.Fatalf("the mate's board row body=%x want two squads", body)
|
||||
}
|
||||
}
|
||||
|
||||
// attachRuntimeRaidReviveCoinMate adds a second online raid member standing in its own
|
||||
// gate, which is what makes the row builder take a lock other than the reviver's.
|
||||
func attachRuntimeRaidReviveCoinMate(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
leader *gameSession,
|
||||
raidKey uint32,
|
||||
mapID int,
|
||||
charID uint16,
|
||||
) (*gameSession, *bufferConn) {
|
||||
t.Helper()
|
||||
mate := &gameSession{
|
||||
conn: &bufferConn{},
|
||||
connID: "raid-revive-coin-mate",
|
||||
channel: leader.channel,
|
||||
residentChannel: leader.residentChannel,
|
||||
selectedCharacterID: charID,
|
||||
}
|
||||
service.bindGameSessionCharacter(mate, charID)
|
||||
if _, online := service.onlineGameSession(charID); !online {
|
||||
t.Fatalf("mate %d is not registered as online", charID)
|
||||
}
|
||||
service.raidMu.Lock()
|
||||
state := service.raids[raidKey]
|
||||
if state == nil {
|
||||
service.raidMu.Unlock()
|
||||
t.Fatalf("raid %d is not live", raidKey)
|
||||
}
|
||||
member := runtimeRaidMemberState{CharID: charID, Name: "mate", GroupIndex: 2}
|
||||
state.Members = append(state.Members, member)
|
||||
state.FrozenMembers = append(state.FrozenMembers, member)
|
||||
phase := state.Lifecycle.Phase
|
||||
service.raidMu.Unlock()
|
||||
|
||||
mate.dungeon.mu.Lock()
|
||||
mate.dungeon.runtime = &runtimeDungeonState{
|
||||
raidDungeon: runtimeRaidDungeonBinding{
|
||||
RaidKey: raidKey,
|
||||
Phase: phase,
|
||||
MapID: mapID,
|
||||
LifecycleToken: 1,
|
||||
},
|
||||
}
|
||||
mate.dungeon.mu.Unlock()
|
||||
return mate, mate.conn.(*bufferConn)
|
||||
}
|
||||
|
||||
// A gate whose own clear quota is met is finished, and 86JP's participation record --
|
||||
// which owns both coin counters -- ends with it, so a reopened gate hands back a full
|
||||
// allowance. A gate that still has clears left must keep its spend.
|
||||
func TestRuntimeRaidReviveCoinLedgerResetsWhenTheGateIsCleared(t *testing.T) {
|
||||
service, session, _, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
setRaidSituationFixtureSpentCoins(t, service, session, 3)
|
||||
key := runtimeRaidReviveCoinTestKey(t, service, session)
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 3 {
|
||||
t.Fatalf("fixture ledger=%d want 3", uses)
|
||||
}
|
||||
|
||||
// One clear left on the quota: this clear finishes the gate.
|
||||
setRuntimeRaidReviveCoinTestObjective(t, service, raidKey, runtimeRaidDungeonPhaseTwo, 213, 1)
|
||||
advanceRuntimeRaidReviveCoinTestObjective(t, service, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
if err := service.afterRuntimeRaidObjectiveAdvance(raidKey, runtimeRaidDungeonPhaseTwo, 213); err != nil {
|
||||
t.Fatalf("after clear: %v", err)
|
||||
}
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 0 {
|
||||
t.Fatalf("a cleared gate kept %d coins of spend; the allowance must start over", uses)
|
||||
}
|
||||
|
||||
// Two clears left: the same call must not touch the ledger.
|
||||
setRaidSituationFixtureSpentCoins(t, service, session, 3)
|
||||
setRuntimeRaidReviveCoinTestObjective(t, service, raidKey, runtimeRaidDungeonPhaseTwo, 213, 2)
|
||||
advanceRuntimeRaidReviveCoinTestObjective(t, service, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
if err := service.afterRuntimeRaidObjectiveAdvance(raidKey, runtimeRaidDungeonPhaseTwo, 213); err != nil {
|
||||
t.Fatalf("after partial clear: %v", err)
|
||||
}
|
||||
if uses := service.runtimeRaidReviveCoinUses(key); uses != 3 {
|
||||
t.Fatalf("a gate with clears left lost its spend: %d want 3", uses)
|
||||
}
|
||||
|
||||
// And the reset is visible on the board, not just in the map: after the final
|
||||
// clear the row reports no spend again.
|
||||
advanceRuntimeRaidReviveCoinTestObjective(t, service, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
if err := service.afterRuntimeRaidObjectiveAdvance(raidKey, runtimeRaidDungeonPhaseTwo, 213); err != nil {
|
||||
t.Fatalf("after last clear: %v", err)
|
||||
}
|
||||
rows, ok := service.runtimeRaidMonsterStateRows(*service.raids[raidKey])
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v ok=%t want one row", rows, ok)
|
||||
}
|
||||
if rows[0].Value0 != 0 {
|
||||
t.Fatalf("the finished gate still reports %d spent coins", rows[0].Value0)
|
||||
}
|
||||
}
|
||||
|
||||
// setRuntimeRaidReviveCoinTestObjective pins one gate's quota so the clear path is
|
||||
// exercised with a known number of clears left.
|
||||
func setRuntimeRaidReviveCoinTestObjective(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
raidKey uint32,
|
||||
phase byte,
|
||||
mapID int,
|
||||
remainingHP int,
|
||||
) {
|
||||
t.Helper()
|
||||
service.raidMu.Lock()
|
||||
defer service.raidMu.Unlock()
|
||||
state := service.raids[raidKey]
|
||||
if state == nil {
|
||||
t.Fatalf("raid %d is not live", raidKey)
|
||||
}
|
||||
if state.Lifecycle.Objectives == nil {
|
||||
state.Lifecycle.Objectives = make(map[int]runtimeRaidDungeonObjective)
|
||||
}
|
||||
objective := state.Lifecycle.Objectives[mapID]
|
||||
objective.Phase = phase
|
||||
objective.State = raid.AntonDungeonOpen
|
||||
objective.MaxHP = remainingHP
|
||||
objective.RemainingHP = remainingHP
|
||||
state.Lifecycle.Objectives[mapID] = objective
|
||||
}
|
||||
|
||||
// advanceRuntimeRaidReviveCoinTestObjective books one clear, which is what the
|
||||
// dungeon lifecycle does before it calls afterRuntimeRaidObjectiveAdvance.
|
||||
func advanceRuntimeRaidReviveCoinTestObjective(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
raidKey uint32,
|
||||
phase byte,
|
||||
mapID int,
|
||||
) {
|
||||
t.Helper()
|
||||
service.raidMu.Lock()
|
||||
defer service.raidMu.Unlock()
|
||||
state := service.raids[raidKey]
|
||||
if state == nil {
|
||||
t.Fatalf("raid %d is not live", raidKey)
|
||||
}
|
||||
if err := advanceRuntimeRaidObjectiveLocked(state, phase, mapID); err != nil {
|
||||
t.Fatalf("advance objective %d: %v", mapID, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user