服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
295 lines
12 KiB
Go
295 lines
12 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// 服主 2026-09-29(单人卢克实测):结算条上「死亡次数 0」,而他确定死了并用了复活币。
|
|
// 根因:0x0259 的 deaths 字段(`raid.RaidSettlementResult.DeathCount`)从来没被填过
|
|
// (结算只给 ResultType/PhaseIndex/ClearSeconds)。这一组测试钉住
|
|
// "计数边沿 → 编码进包 → 所有团本都带真实值" 三件事。
|
|
//
|
|
// 服主 2026-09-29 拍板:这个字段对**所有团本**都要填真实值(依据见
|
|
// raid_phase_death_count.go)。安徒恩以前恒 0,所以这里的安徒恩用例同时是
|
|
// "本次唯一有意的安徒恩变化就是这个 u16" 的逐字节对照。
|
|
|
|
// lukeSettlementDeathFixture is one raid with one online member, in the state a
|
|
// reached phase settlement implies.
|
|
func lukeSettlementDeathFixture(t *testing.T, mode byte, tailFlag byte) (*Service, *gameSession, uint32) {
|
|
t.Helper()
|
|
const raidKey = uint32(0x0009c001)
|
|
channel := channelcatalog.Channel{ID: 28, Type: dnfenum.HiddenRaidType, Name: "luke", Port: 10028}
|
|
if mode != runtimeRaidOwnerLuke {
|
|
channel = channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType, Name: "anton", Port: 10023}
|
|
}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
raids: make(map[uint32]*runtimeRaidState),
|
|
}
|
|
session := &gameSession{conn: &bufferConn{}, channel: channel}
|
|
service.bindGameSessionCharacter(session, 1001)
|
|
members := []runtimeRaidMemberState{{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0}}
|
|
state := &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: 1001,
|
|
RaidMode: mode,
|
|
TailFlag: tailFlag,
|
|
Members: append([]runtimeRaidMemberState(nil), members...),
|
|
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
ChargedMembers: []uint16{1001},
|
|
StartAttempt: 1,
|
|
LukeConfig: &raid.LukeConfig{},
|
|
Lifecycle: newRuntimeRaidLifecycleState(time.Date(2026, 9, 29, 3, 0, 0, 0, time.UTC)),
|
|
}
|
|
service.raids[raidKey] = state
|
|
seedRuntimeRaidPhaseClearsForTest(&state.Lifecycle, 1001)
|
|
return service, session, raidKey
|
|
}
|
|
|
|
// lukeSettlementDeathRun is the gate binding one accepted death is recorded against.
|
|
func lukeSettlementDeathRun(raidKey uint32) *runtimeDungeonState {
|
|
return &runtimeDungeonState{
|
|
raidDungeon: runtimeRaidDungeonBinding{
|
|
RaidKey: raidKey,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
MapID: lukeBirthSanctumMapID,
|
|
},
|
|
}
|
|
}
|
|
|
|
// settlementResultBodyFromDrain pulls the 0x0259 body out of one session's wire buffer.
|
|
func settlementResultBodyFromDrain(t *testing.T, data []byte) []byte {
|
|
t.Helper()
|
|
total := len(data)
|
|
for len(data) > 0 {
|
|
packet, rest := splitGameServerUpperPacket(t, data)
|
|
if packet.Header.MsgID == raid.RaidResultMsgID {
|
|
return packet.Body
|
|
}
|
|
data = rest
|
|
}
|
|
t.Fatalf("no 0x0259 result in %d bytes", total)
|
|
return nil
|
|
}
|
|
|
|
// TestLukePhaseSettlementCarriesThePhasesDeathCount is the end-to-end pin: two accepted
|
|
// deaths, one phase settlement, and the u16 the client prints as 死亡次数 must be 2.
|
|
func TestLukePhaseSettlementCarriesThePhasesDeathCount(t *testing.T) {
|
|
service, session, raidKey := lukeSettlementDeathFixture(t, runtimeRaidOwnerLuke, 0)
|
|
runtime := lukeSettlementDeathRun(raidKey)
|
|
for i := 0; i < 2; i++ {
|
|
service.recordRuntimeRaidPhaseDeath(session, runtime)
|
|
}
|
|
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseOne); got != 2 {
|
|
t.Fatalf("phase deaths=%d want 2", got)
|
|
}
|
|
|
|
if err := service.publishRuntimeRaidPhaseSettlement(
|
|
raidKey,
|
|
runtimeRaidDungeonPhaseOne,
|
|
raid.RaidSettlementResult{PhaseIndex: 0, ClearSeconds: 60},
|
|
time.Date(2026, 9, 29, 3, 20, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("publish settlement: %v", err)
|
|
}
|
|
body := settlementResultBodyFromDrain(t, session.conn.(*bufferConn).write.Bytes())
|
|
if len(body) != 10 {
|
|
t.Fatalf("0x0259 body_len=%d want 10", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[6:8]); got != 2 {
|
|
t.Fatalf("0x0259 deaths=%d want 2 (body=% X)", got, body)
|
|
}
|
|
// Every other field keeps its old value.
|
|
if body[0] != 0 || body[1] != 0 || binary.LittleEndian.Uint32(body[2:6]) != 60 {
|
|
t.Fatalf("0x0259 header/clearSeconds moved: % X", body)
|
|
}
|
|
if body[8] != 0 || body[9] != 0 {
|
|
t.Fatalf("0x0259 score/flags moved: % X", body)
|
|
}
|
|
}
|
|
|
|
// TestAntonPhaseSettlementCarriesThePhasesDeathCount is the same end-to-end pin for
|
|
// the other faction: the u16 is the phase's whole-raid death count for Anton too
|
|
// (86JP RAID_RESULT's 4th argument; luke.etc [CONDITION FOR RANK] buckets by
|
|
// [DEAD COUNT]), so five accepted deaths must reach the wire as five.
|
|
//
|
|
// It is also the byte-for-byte control for "the death count is the ONLY Anton
|
|
// behaviour this change may move": the body is compared byte by byte against the
|
|
// exact 10 bytes the old code sent, with index 6..8 being the single difference.
|
|
func TestAntonPhaseSettlementCarriesThePhasesDeathCount(t *testing.T) {
|
|
service, session, raidKey := lukeSettlementDeathFixture(t, runtimeRaidOwnerAnton, 0)
|
|
runtime := lukeSettlementDeathRun(raidKey)
|
|
for i := 0; i < 5; i++ {
|
|
service.recordRuntimeRaidPhaseDeath(session, runtime)
|
|
}
|
|
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseOne); got != 5 {
|
|
t.Fatalf("the ledger counts every raid: got %d want 5", got)
|
|
}
|
|
if err := service.publishRuntimeRaidPhaseSettlement(
|
|
raidKey,
|
|
runtimeRaidDungeonPhaseOne,
|
|
raid.RaidSettlementResult{PhaseIndex: 0, ClearSeconds: 60},
|
|
time.Date(2026, 9, 29, 3, 20, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("publish settlement: %v", err)
|
|
}
|
|
body := settlementResultBodyFromDrain(t, session.conn.(*bufferConn).write.Bytes())
|
|
if len(body) != 10 {
|
|
t.Fatalf("0x0259 body_len=%d want 10", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[6:8]); got != 5 {
|
|
t.Fatalf("Anton 0x0259 deaths=%d want 5 (body=% X)", got, body)
|
|
}
|
|
// 逐字节对照:这是旧代码(安徒恩恒 0)会发的那 10 个字节,唯一允许变的只有 [6:8]。
|
|
before := []byte{0, 0, 60, 0, 0, 0, 0, 0, 0, 0}
|
|
for index := range before {
|
|
if index == 6 || index == 7 {
|
|
continue
|
|
}
|
|
if body[index] != before[index] {
|
|
t.Fatalf("Anton 0x0259 moved outside the death count at byte %d: body=% X before=% X",
|
|
index, body, before)
|
|
}
|
|
}
|
|
// 其余六个字段逐个点名,读失败时能直接看出是哪个字段动了。
|
|
if body[0] != 0 || body[1] != 0 {
|
|
t.Fatalf("Anton 0x0259 ResultType/PhaseIndex moved: % X", body)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[2:6]); got != 60 {
|
|
t.Fatalf("Anton 0x0259 ClearSeconds=%d want 60: % X", got, body)
|
|
}
|
|
if body[8] != 0 || body[9] != 0 {
|
|
t.Fatalf("Anton 0x0259 Score/Flags moved: % X", body)
|
|
}
|
|
t.Logf("Anton 0x0259 before=% X after=% X (only [6:8] moved: %d -> %d)",
|
|
before, body, binary.LittleEndian.Uint16(before[6:8]), binary.LittleEndian.Uint16(body[6:8]))
|
|
}
|
|
|
|
// TestAntonFailedPhaseReportCarriesThePhasesDeathCount covers the other 0x0259
|
|
// producer: the failure report. Both producers are shared, so both must carry the
|
|
// real value for every faction -- there is no "settlement only" carve-out.
|
|
func TestAntonFailedPhaseReportCarriesThePhasesDeathCount(t *testing.T) {
|
|
service, session, raidKey := lukeSettlementDeathFixture(t, runtimeRaidOwnerAnton, 0)
|
|
runtime := lukeSettlementDeathRun(raidKey)
|
|
for i := 0; i < 3; i++ {
|
|
service.recordRuntimeRaidPhaseDeath(session, runtime)
|
|
}
|
|
// publishFailedRuntimeRaid is the second 0x0259 producer and guards on the
|
|
// lifecycle having already failed; the transition itself is covered by
|
|
// TestRuntimeRaidPhaseTimeoutPublishesBeforeRemoval, so this fixture only has
|
|
// to put the raid in the state that producer requires.
|
|
service.raidMu.Lock()
|
|
state := service.raids[raidKey]
|
|
service.raidMu.Unlock()
|
|
if state == nil {
|
|
t.Fatal("the fixture raid disappeared")
|
|
}
|
|
state.Lifecycle.Status = runtimeRaidLifecycleFailed
|
|
if err := service.publishFailedRuntimeRaid(*state); err != nil {
|
|
t.Fatalf("publish failed raid: %v", err)
|
|
}
|
|
body := settlementResultBodyFromDrain(t, session.conn.(*bufferConn).write.Bytes())
|
|
if len(body) != 10 {
|
|
t.Fatalf("0x0259 body_len=%d want 10", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[6:8]); got != 3 {
|
|
t.Fatalf("failed Anton 0x0259 deaths=%d want 3 (body=% X)", got, body)
|
|
}
|
|
// 失败结果的固定形状仍然不变:ResultType=1、PhaseIndex=0、Score=0、Flags=1。
|
|
if body[0] != runtimeRaidFailResultType || body[1] != 0 || body[8] != 0 || body[9] != 1 {
|
|
t.Fatalf("failed 0x0259 shape moved: % X", body)
|
|
}
|
|
}
|
|
|
|
// TestSingleModePhaseSettlementCarriesThePhasesDeathCount covers 「单人阶段也要」:
|
|
// the same producer on a single-mode raid (TailFlag == 1) must carry the count too.
|
|
// Nothing in the path branches on the tail flag -- this pin exists so that adding
|
|
// such a branch later cannot silently zero the field for single mode.
|
|
func TestSingleModePhaseSettlementCarriesThePhasesDeathCount(t *testing.T) {
|
|
service, session, raidKey := lukeSettlementDeathFixture(t, runtimeRaidOwnerLuke, 1)
|
|
if state := service.raids[raidKey]; state == nil || state.TailFlag != 1 {
|
|
t.Fatalf("the fixture raid is not single mode: %+v", service.raids[raidKey])
|
|
}
|
|
runtime := lukeSettlementDeathRun(raidKey)
|
|
for i := 0; i < 4; i++ {
|
|
service.recordRuntimeRaidPhaseDeath(session, runtime)
|
|
}
|
|
if err := service.publishRuntimeRaidPhaseSettlement(
|
|
raidKey,
|
|
runtimeRaidDungeonPhaseOne,
|
|
raid.RaidSettlementResult{PhaseIndex: 0, ClearSeconds: 90},
|
|
time.Date(2026, 9, 29, 3, 20, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("publish single-mode settlement: %v", err)
|
|
}
|
|
body := settlementResultBodyFromDrain(t, session.conn.(*bufferConn).write.Bytes())
|
|
if len(body) != 10 {
|
|
t.Fatalf("0x0259 body_len=%d want 10", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[6:8]); got != 4 {
|
|
t.Fatalf("single-mode 0x0259 deaths=%d want 4 (body=% X)", got, body)
|
|
}
|
|
if body[0] != 0 || body[1] != 0 || binary.LittleEndian.Uint32(body[2:6]) != 90 || body[8] != 0 || body[9] != 0 {
|
|
t.Fatalf("single-mode 0x0259 moved outside the death count: % X", body)
|
|
}
|
|
}
|
|
|
|
// TestPhaseDeathCountFollowsTheLivePhase keeps the ledger honest: a death reported for
|
|
// a phase that is no longer running is not part of any result body.
|
|
func TestPhaseDeathCountFollowsTheLivePhase(t *testing.T) {
|
|
service, session, raidKey := lukeSettlementDeathFixture(t, runtimeRaidOwnerLuke, 0)
|
|
service.recordRuntimeRaidPhaseDeath(session, lukeSettlementDeathRun(raidKey))
|
|
|
|
stale := lukeSettlementDeathRun(raidKey)
|
|
stale.raidDungeon.Phase = runtimeRaidDungeonPhaseTwo
|
|
service.recordRuntimeRaidPhaseDeath(session, stale)
|
|
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseTwo); got != 0 {
|
|
t.Fatalf("a stale phase recorded %d deaths", got)
|
|
}
|
|
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseOne); got != 1 {
|
|
t.Fatalf("phase one deaths=%d want 1", got)
|
|
}
|
|
|
|
service.raidMu.Lock()
|
|
service.purgeRuntimeRaidPhaseDeathsLocked(raidKey)
|
|
service.raidMu.Unlock()
|
|
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseOne); got != 0 {
|
|
t.Fatalf("purge left %d deaths behind", got)
|
|
}
|
|
}
|
|
|
|
// TestAcceptedCharacterDeathIncrementsTheRaidPhaseCount drives the real C2S op40
|
|
// handler on a raid gate: the accepted death edge is what feeds the counter.
|
|
func TestAcceptedCharacterDeathIncrementsTheRaidPhaseCount(t *testing.T) {
|
|
fixture := newRuntimeRaidPhaseForceReturnFixture(t)
|
|
fixture.service.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
|
|
session, ok := fixture.service.onlineGameSession(99)
|
|
if !ok || session == nil {
|
|
t.Fatal("char 99 has no session")
|
|
}
|
|
fixture.enterGateRun(t, 99, 21, antonBlackFogSourceMapID)
|
|
|
|
// A body of the wrong width is refused before anything is counted.
|
|
if err := fixture.service.handleDungeonDieCharacterLocal(session, []byte{0}); err != nil {
|
|
t.Fatalf("short op40: %v", err)
|
|
}
|
|
if got := fixture.service.runtimeRaidPhaseDeathCount(fixture.raidKey, runtimeRaidDungeonPhaseOne); got != 0 {
|
|
t.Fatalf("a malformed op40 counted %d deaths", got)
|
|
}
|
|
|
|
if err := fixture.service.handleDungeonDieCharacterLocal(session, []byte{0, 0, 0, 0}); err != nil {
|
|
t.Fatalf("op40: %v", err)
|
|
}
|
|
if got := fixture.service.runtimeRaidPhaseDeathCount(fixture.raidKey, runtimeRaidDungeonPhaseOne); got != 1 {
|
|
t.Fatalf("accepted death counted %d deaths want 1", got)
|
|
}
|
|
}
|