Files
90CNServer/server/internal/services/dnfbridge/raid_buff_system_test.go
T
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

537 lines
22 KiB
Go

package dnfbridge
import (
"bytes"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
// runtimeRaidBuffRequestBody is the exact five-byte C2S 0x02B6 the client's own
// sender (0x010D7BB6) writes: u8 category, u16 target, u16 extra.
func runtimeRaidBuffRequestBody(category byte, target uint16) []byte {
return []byte{category, byte(target), byte(target >> 8), 0, 0}
}
func TestDecodeRuntimeRaidBuffRequest(t *testing.T) {
request, err := decodeRuntimeRaidBuffRequest([]byte{1, 0xE9, 0x03, 0x11, 0x22})
if err != nil {
t.Fatalf("live shape rejected: %v", err)
}
if request.Category != 1 || request.TargetKey != 1001 || request.ExtraKey != 0x2211 {
t.Fatalf("decoded %+v", request)
}
// A longer target list is 86JP's shape (u8 + u16 + u16[]) and still has to pass
// the shape checks even though this client only ever writes one entry.
if _, err := decodeRuntimeRaidBuffRequest([]byte{2, 1, 0, 2, 0, 3, 0}); err != nil {
t.Fatalf("multi-target shape rejected: %v", err)
}
for _, body := range [][]byte{
{},
{1, 0, 0, 0},
{1, 0, 0, 0, 0, 0},
{9, 1, 0, 0, 0},
{1, 1},
} {
if _, err := decodeRuntimeRaidBuffRequest(body); err == nil {
t.Fatalf("body=%x was accepted", body)
}
}
}
// The client's slot order and the category bytes are two different things, and the
// panel depends on both being right: slots 0/1/2 are the three squad skills and 3/4
// the two raid-wide ones, while the wire category of slot 0 is 2 (无敌) and of
// slot 3 is 1 (攻击力增加).
func TestRaidBuffSlotOrderMatchesTheClientPanel(t *testing.T) {
want := []byte{2, 3, 4, 1, 0}
if len(runtimeRaidClientBuffSlots) != len(want) {
t.Fatalf("slot count=%d want=%d", len(runtimeRaidClientBuffSlots), len(want))
}
for index, category := range want {
if runtimeRaidClientBuffSlots[index] != category {
t.Fatalf("slot %d category=%d want=%d", index, runtimeRaidClientBuffSlots[index], category)
}
}
// The split the client hard-codes at 0x015E27CF: the first three slots need a
// designated squad, the last two are raid-wide.
for slot := 0; slot < 3; slot++ {
definition, ok := runtimeRaidBuffDefinitionFor(runtimeRaidClientBuffSlots[slot])
if !ok || definition.RaidWide {
t.Fatalf("slot %d must be a squad skill: %+v", slot, definition)
}
}
for slot := 3; slot < 5; slot++ {
definition, ok := runtimeRaidBuffDefinitionFor(runtimeRaidClientBuffSlots[slot])
if !ok || !definition.RaidWide {
t.Fatalf("slot %d must be raid-wide: %+v", slot, definition)
}
}
// The PVF cooldowns, minute for minute as the client's own tooltips state them
// (strings 30300-30304).
for _, expected := range []struct {
category byte
cooldown uint32
duration uint32
effect uint32
raidWide bool
cooldownM string
}{
{runtimeRaidBuffIncreaseTime, 7200, 10, 300, true, "120 minutes"},
{runtimeRaidBuffAttackBonus, 420, 30, 15, true, "7 minutes"},
{runtimeRaidBuffInvincible, 360, 30, 0, false, "6 minutes"},
{runtimeRaidBuffRestore, 240, 60, 7, false, "4 minutes"},
{runtimeRaidBuffIncreaseCoin, 300, 10, 4, false, "5 minutes"},
} {
definition, ok := runtimeRaidBuffDefinitionFor(expected.category)
if !ok {
t.Fatalf("category %d has no definition", expected.category)
}
if definition.CooldownSecs != expected.cooldown || definition.DurationSecs != expected.duration ||
definition.EffectValue != expected.effect || definition.RaidWide != expected.raidWide {
t.Fatalf("category %d definition=%+v want cooldown=%d duration=%d effect=%d raidWide=%t (%s)",
expected.category, definition, expected.cooldown, expected.duration,
expected.effect, expected.raidWide, expected.cooldownM)
}
}
}
// An untouched raid's 0x0279 is byte-identical to the idle reference: five slots,
// no rows. This is what makes wiring live rows in safe -- nothing changes until a
// skill is actually used.
func TestRaidBuffStatusBodyIsIdleUntilSomethingIsActivated(t *testing.T) {
service, _, _, _ := newRaidSituationTestFixture(t)
state := *service.raids[raidSituationFixtureRaidKey(t, service)]
idle, err := runtimeRaidBuffIdleStatusBody()
if err != nil {
t.Fatalf("idle body: %v", err)
}
live, err := service.runtimeRaidBuffStatusBody(state)
if err != nil {
t.Fatalf("live body: %v", err)
}
if !bytes.Equal(live, idle) {
t.Fatalf("idle raid body=%x want the idle reference %x", live, idle)
}
if want := []byte{5, 2, 0, 3, 0, 4, 0, 1, 0, 0, 0}; !bytes.Equal(idle, want) {
t.Fatalf("idle body=%x want=%x", idle, want)
}
}
// A leader using a raid-wide skill gets the status with the new row, and NOTHING
// else: this client registers an inbound 0x02B6 handler that reads four u32s with
// no bounds check, so the one-byte ack 86JP sent would be read out of bounds. The
// panel is driven by 0x0279, so the status is the whole answer.
func TestRaidBuffActivationPublishesTheRowAndSendsNoAck(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
state := *service.raids[raidSituationFixtureRaidKey(t, service)]
connection.write.Reset()
handled, err := service.handleOnlineRaidBuffSystem(
session, 694, runtimeRaidBuffRequestBody(runtimeRaidBuffAttackBonus, 1001))
if err != nil || !handled {
t.Fatalf("activation handled=%t err=%v", handled, err)
}
activation, ok := service.runtimeRaidBuffActivationFor(state.RaidKey, runtimeRaidBuffAttackBonus)
if !ok {
t.Fatal("the activation was not recorded")
}
definition, _ := runtimeRaidBuffDefinitionFor(runtimeRaidBuffAttackBonus)
stream := connection.write.Bytes()
status, stream := splitGameServerUpperPacket(t, stream)
if status.Header.Classification != 0 || status.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("first packet msg=%#04x want the buff status %#04x",
status.Header.MsgID, raid.RaidBuffMsgID)
}
wantStatus := []byte{
5, // five slots
2, 0, // slot category 2 (无敌), idle
3, 0, // slot category 3 (恢复), idle
4, 0, // slot category 4 (复活币 buff), idle
1, 1, // slot category 1 (攻击力增加), one row
1, 0, // party 1
0xE9, 0x03, // member 1001
0, 0, 0, 0, // active deadline, filled below
0, 0, 0, 0, // cooldown deadline, filled below
0, 0, // slot category 0 (攻略时间增加), idle
}
binary.LittleEndian.PutUint32(wantStatus[13:17], activation.ActiveUntil)
binary.LittleEndian.PutUint32(wantStatus[17:21], activation.CooldownUntil)
if !bytes.Equal(status.Body, wantStatus) {
t.Fatalf("buff status body=%x want=%x", status.Body, wantStatus)
}
if len(stream) != 0 {
t.Fatalf("activation sent %d trailing bytes; this opcode must produce no reply at all: %x",
len(stream), stream)
}
now := uint32(time.Now().Unix())
if activation.ActiveUntil <= now || activation.CooldownUntil <= activation.ActiveUntil {
t.Fatalf("deadlines are not in the future: %+v (now=%d)", activation, now)
}
if gained := activation.CooldownUntil - now; gained > definition.CooldownSecs+2 {
t.Fatalf("cooldown=%d exceeds the PVF value %d", gained, definition.CooldownSecs)
}
}
// A second use inside the cooldown is refused and the panel is resynced, again with
// no ack. The client blocks its own send from the deadlines it was last given
// (string 30311 「技能冷却中」), so the server must not silently accept one.
func TestRaidBuffSecondUseInsideCooldownIsRefused(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
body := runtimeRaidBuffRequestBody(runtimeRaidBuffAttackBonus, 1001)
if _, err := service.handleOnlineRaidBuffSystem(session, 694, body); err != nil {
t.Fatalf("first activation: %v", err)
}
connection.write.Reset()
if _, err := service.handleOnlineRaidBuffSystem(session, 694, body); err != nil {
t.Fatalf("second activation: %v", err)
}
stream := connection.write.Bytes()
status, stream := splitGameServerUpperPacket(t, stream)
if status.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("refusal did not resync the panel: msg=%#04x", status.Header.MsgID)
}
if len(stream) != 0 {
t.Fatalf("refusal sent %d trailing bytes; there is no ack on this opcode: %x", len(stream), stream)
}
}
// Only the raid leader may use the panel (86JP CanUseAntonRaidBuff).
func TestRaidBuffNonLeaderIsRefused(t *testing.T) {
service, _, leaderConnection, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
member, memberConnection := addRaidSituationFixtureMember(t, service, raidKey, 1002, 2)
enterRaidSituationFixtureGate(member, raidKey, runtimeRaidDungeonPhaseTwo, 213)
leaderConnection.write.Reset()
memberConnection.write.Reset()
handled, err := service.handleOnlineRaidBuffSystem(
member, 694, runtimeRaidBuffRequestBody(runtimeRaidBuffAttackBonus, 1002))
if err != nil || !handled {
t.Fatalf("non-leader request handled=%t err=%v", handled, err)
}
resync, stream := splitGameServerUpperPacket(t, memberConnection.write.Bytes())
if resync.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("non-leader did not get a panel resync: msg=%#04x", resync.Header.MsgID)
}
if len(stream) != 0 {
t.Fatalf("non-leader refusal trailing bytes=%x", stream)
}
if len(leaderConnection.write.Bytes()) != 0 {
t.Fatal("a refused request must not broadcast to the raid")
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffAttackBonus); ok {
t.Fatal("a non-leader activation was recorded")
}
}
// A squad skill must name a squad member who is inside a gate, which is exactly
// what the client's string 30313 「只能指定已进入地下城的小队。」 tells the player,
// and what 86JP enforces through `group.DungeonId != 0`.
func TestRaidBuffSquadSkillRequiresAMemberInsideAGate(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
connection.write.Reset()
body := runtimeRaidBuffRequestBody(runtimeRaidBuffInvincible, 1001)
if _, err := service.handleOnlineRaidBuffSystem(session, 694, body); err != nil {
t.Fatalf("squad skill outside a gate: %v", err)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffInvincible); ok {
t.Fatal("a squad skill was accepted for a member outside every gate")
}
// Once inside a gate it is accepted, and the row names the member's own squad.
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
connection.write.Reset()
if _, err := service.handleOnlineRaidBuffSystem(session, 694, body); err != nil {
t.Fatalf("squad skill inside a gate: %v", err)
}
activation, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffInvincible)
if !ok {
t.Fatal("a squad skill inside a gate was not recorded")
}
if activation.PartyIndex != 1 || activation.MemberKey != 1001 {
t.Fatalf("activation target=%+v want party 1 member 1001", activation)
}
// The row's target is the MEMBER the panel selected, so a squad skill aimed at a
// raid-mate must name that mate, not the leader who pressed the button.
mate, _ := addRaidSituationFixtureMember(t, service, raidKey, 1002, 2)
enterRaidSituationFixtureGate(mate, raidKey, runtimeRaidDungeonPhaseTwo, 221)
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffRestore, 1002)); err != nil {
t.Fatalf("squad skill on a mate: %v", err)
}
restore, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffRestore)
if !ok {
t.Fatal("a squad skill aimed at a raid-mate was not recorded")
}
if restore.PartyIndex != 2 || restore.MemberKey != 1002 {
t.Fatalf("activation target=%+v want party 2 member 1002", restore)
}
}
// Cooldowns cannot outlive the raid that owns them.
func TestRaidBuffActivationsArePurgedWithTheRaid(t *testing.T) {
service, session, _, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffAttackBonus, 1001)); err != nil {
t.Fatalf("activation: %v", err)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffAttackBonus); !ok {
t.Fatal("nothing was recorded to purge")
}
service.raidMu.Lock()
service.purgeRuntimeRaidBuffActivationsLocked(raidKey)
service.raidMu.Unlock()
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffAttackBonus); ok {
t.Fatal("the purge left an activation behind")
}
}
// A body shape this client never sends is swallowed with a panel resync, never an
// error: the opcode is on the client's input path.
func TestRaidBuffMalformedBodyIsRefusedWithoutFailing(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
connection.write.Reset()
handled, err := service.handleOnlineRaidBuffSystem(session, 694, []byte{1, 0, 0, 0})
if err != nil || !handled {
t.Fatalf("malformed body handled=%t err=%v", handled, err)
}
resync, stream := splitGameServerUpperPacket(t, connection.write.Bytes())
if resync.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("malformed body did not resync the panel: msg=%#04x", resync.Header.MsgID)
}
if len(stream) != 0 {
t.Fatalf("malformed body trailing bytes=%x", stream)
}
}
// enterRaidBuffPhaseClock installs a fake gameplay clock and puts the raid inside a
// running phase with `remaining` left of its 2400-second budget, so the INCREASE
// TIME cap can be exercised at its edges.
func enterRaidBuffPhaseClock(
t *testing.T,
service *Service,
raidKey uint32,
remaining time.Duration,
) *runtimeRaidPhaseTestQueue {
t.Helper()
queue := &runtimeRaidPhaseTestQueue{now: time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)}
service.gameplayTimers = queue
service.raidMu.Lock()
state := service.raids[raidKey]
state.Lifecycle.PhaseStartedAt = queue.now.Add(-(2400*time.Second - remaining))
state.Lifecycle.PhaseDeadline = queue.now.Add(remaining)
service.raidMu.Unlock()
return queue
}
// INCREASE TIME is one of the only two skills with a server-side effect. It adds
// the PVF's 300 seconds to the phase deadline and re-arms the phase expiry under the
// same timer name -- leaving the old job armed would fail the phase at the old
// instant even though the deadline moved.
func TestRaidBuffIncreaseTimeExtendsThePhaseAndReArmsTheTimer(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
queue := enterRaidBuffPhaseClock(t, service, raidKey, 30*time.Minute)
before := service.raids[raidKey].Lifecycle.PhaseDeadline
connection.write.Reset()
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffIncreaseTime, 1001)); err != nil {
t.Fatalf("increase time: %v", err)
}
after := service.raids[raidKey].Lifecycle.PhaseDeadline
if got := after.Sub(before); got != 300*time.Second {
t.Fatalf("deadline moved by %s want 300s (before=%s after=%s)", got, before, after)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffIncreaseTime); !ok {
t.Fatal("increase time was not recorded")
}
if queue.scheduleCalls != 1 {
t.Fatalf("phase expiry re-arms=%d want 1", queue.scheduleCalls)
}
if want := after.Sub(queue.now); queue.scheduledDelay != want {
t.Fatalf("re-armed delay=%s want=%s", queue.scheduledDelay, want)
}
stream := connection.write.Bytes()
status, stream := splitGameServerUpperPacket(t, stream)
if status.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("first packet msg=%#04x want the buff status", status.Header.MsgID)
}
timer, stream := splitGameServerUpperPacket(t, stream)
if timer.Header.MsgID != raid.RaidTimerMsgID {
t.Fatalf("second packet msg=%#04x want the timer %#04x", timer.Header.MsgID, raid.RaidTimerMsgID)
}
remain, stream := splitGameServerUpperPacket(t, stream)
if remain.Header.MsgID != raid.RaidRemainTimeMsgID {
t.Fatalf("third packet msg=%#04x want the remain-time %#04x",
remain.Header.MsgID, raid.RaidRemainTimeMsgID)
}
if len(stream) != 0 {
t.Fatalf("increase time trailing bytes=%x", stream)
}
if want := uint32(30*time.Minute/time.Second) + 300; !bytes.Contains(remain.Body, []byte{
byte(want), byte(want >> 8), byte(want >> 16), byte(want >> 24),
}) {
t.Fatalf("remain-time body=%x want the extended remaining %d", remain.Body, want)
}
}
// The extension is capped at one full phase, and a phase whose timer is already
// full must not burn the skill's two-hour cooldown.
func TestRaidBuffIncreaseTimeRefusesAFullTimer(t *testing.T) {
service, session, _, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
enterRaidBuffPhaseClock(t, service, raidKey, 40*time.Minute)
before := service.raids[raidKey].Lifecycle.PhaseDeadline
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffIncreaseTime, 1001)); err != nil {
t.Fatalf("increase time: %v", err)
}
if after := service.raids[raidKey].Lifecycle.PhaseDeadline; !after.Equal(before) {
t.Fatalf("a full timer was extended: before=%s after=%s", before, after)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffIncreaseTime); ok {
t.Fatal("a refused extension still consumed the cooldown")
}
}
// An expired phase must not be revived: its timeout has already been scheduled and
// the raid is on its way to the failure result.
func TestRaidBuffIncreaseTimeRefusesAnExpiredPhase(t *testing.T) {
service, session, _, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
enterRaidBuffPhaseClock(t, service, raidKey, -time.Minute)
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffIncreaseTime, 1001)); err != nil {
t.Fatalf("increase time: %v", err)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffIncreaseTime); ok {
t.Fatal("an expired phase was revived and the cooldown consumed")
}
}
// INCREASE COIN cannot hand out coins: the client's denominator is its own per-gate
// allowance and never travels on the wire, so the server's only lever is the
// numerator. It raises the row's grant and reports `used - granted`, which makes the
// client's own `allowance - reported` come out higher. This is exactly what 86JP
// does.
func TestRaidBuffIncreaseCoinRaisesTheRowGrantAndReportsTheDifference(t *testing.T) {
service, session, connection, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
setRaidSituationFixtureSpentCoins(t, service, session, 6)
connection.write.Reset()
// Before the grant the row reports the whole spend.
rows, ok := service.runtimeRaidMonsterStateRows(*service.raids[raidKey])
if !ok || len(rows) != 1 || rows[0].Value0 != 6 {
t.Fatalf("pre-grant rows=%+v ok=%t want one row reporting 6", rows, ok)
}
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffIncreaseCoin, 1001)); err != nil {
t.Fatalf("increase coin: %v", err)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffIncreaseCoin); !ok {
t.Fatal("increase coin was not recorded")
}
granted := service.runtimeRaidReviveCoinGranted(runtimeRaidReviveCoinKey{
RaidKey: raidKey, Phase: runtimeRaidDungeonPhaseTwo, Group: 1, DungeonID: 213,
})
if granted != 4 {
t.Fatalf("granted=%d want the PVF effect value 4", granted)
}
rows, ok = service.runtimeRaidMonsterStateRows(*service.raids[raidKey])
if !ok || len(rows) != 1 || rows[0].Value0 != 2 {
t.Fatalf("post-grant rows=%+v ok=%t want one row reporting 6-4=2", rows, ok)
}
// The grant changes what the board draws, so the row packet is re-published
// after the panel status.
stream := connection.write.Bytes()
status, stream := splitGameServerUpperPacket(t, stream)
if status.Header.MsgID != raid.RaidBuffMsgID {
t.Fatalf("first packet msg=%#04x want the buff status", status.Header.MsgID)
}
board, stream := splitGameServerUpperPacket(t, stream)
if board.Header.MsgID != raid.RaidMonsterStateMsgID {
t.Fatalf("second packet msg=%#04x want the board rows %#04x",
board.Header.MsgID, raid.RaidMonsterStateMsgID)
}
if len(stream) != 0 {
t.Fatalf("increase coin trailing bytes=%x", stream)
}
wantBoard := []byte{
1, // groupCount
1, 0, // squad 1
1, // one member
0xE9, 0x03, // 1001
2, 0, 0, 0, // V0 = 6 spent - 4 granted
0, 0, 0, 0, // V1 default
0,
}
if !bytes.Equal(board.Body, wantBoard) {
t.Fatalf("board body=%x want=%x", board.Body, wantBoard)
}
}
// A squad that has not spent beyond its grant gets nothing, and -- like 86JP -- the
// skill does not burn its cooldown for that.
func TestRaidBuffIncreaseCoinRefusesASquadThatHasNotOverspent(t *testing.T) {
service, session, _, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
// No coins spent at all.
if _, err := service.handleOnlineRaidBuffSystem(session, 694,
runtimeRaidBuffRequestBody(runtimeRaidBuffIncreaseCoin, 1001)); err != nil {
t.Fatalf("increase coin: %v", err)
}
if _, ok := service.runtimeRaidBuffActivationFor(raidKey, runtimeRaidBuffIncreaseCoin); ok {
t.Fatal("a grant with nothing to cover still consumed the cooldown")
}
}
// The grant ledger obeys the same lifetime as the raid, for both counters: the
// spend ledger and the grant ledger are the two halves of the row's coin number and
// a purge must not leave one behind.
func TestRaidReviveCoinGrantsArePurgedWithTheRaid(t *testing.T) {
service, _, _, _ := newRaidSituationTestFixture(t)
raidKey := raidSituationFixtureRaidKey(t, service)
key := runtimeRaidReviveCoinKey{
RaidKey: raidKey, Phase: runtimeRaidDungeonPhaseTwo, Group: 1, DungeonID: 213,
}
if granted := service.grantRuntimeRaidReviveCoins(key, 4); granted != 4 {
t.Fatalf("granted=%d want 4", granted)
}
service.raidMu.Lock()
if service.raidReviveUses == nil {
service.raidReviveUses = make(map[runtimeRaidReviveCoinKey]uint32)
}
service.raidReviveUses[key] = 6
service.raidMu.Unlock()
service.raidMu.Lock()
service.purgeRuntimeRaidReviveCoinLedgerLocked(raidKey)
service.raidMu.Unlock()
if granted := service.runtimeRaidReviveCoinGranted(key); granted != 0 {
t.Fatalf("the purge left %d granted coins", granted)
}
if uses := service.runtimeRaidReviveCoinUses(key); uses != 0 {
t.Fatalf("the purge left %d spent coins", uses)
}
}