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,578 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||||
"longheng.io/server/internal/modules/dnf/raid"
|
||||
)
|
||||
|
||||
// The current EXE prefixes C2S 695 with a reserved dword and puts the value
|
||||
// count at +4. The bodies below are the exact ones the live client sent on
|
||||
// 2026-09-17 (packet_log.txt), so a reader that starts the count at body[0] --
|
||||
// the 86JP offset -- would misread every one of them as "window opened".
|
||||
func TestDecodeRaidSituationRequestAcceptsTheCurrentExeShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
body []byte
|
||||
reserved uint32
|
||||
values []uint32
|
||||
}{
|
||||
{body: []byte{0, 0, 0, 0, 0}, reserved: 0, values: nil},
|
||||
{body: []byte{0, 0, 0, 0, 1, 100, 0, 0, 0}, reserved: 0, values: []uint32{100}},
|
||||
{
|
||||
body: []byte{0, 0, 0, 0, 4, 100, 0, 0, 0, 100, 0, 0, 0, 100, 0, 0, 0, 100, 0, 0, 0},
|
||||
reserved: 0,
|
||||
values: []uint32{100, 100, 100, 100},
|
||||
},
|
||||
}
|
||||
for index, testCase := range cases {
|
||||
reserved, values, err := decodeRaidSituationRequest(testCase.body)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d body=%x rejected: %v", index, testCase.body, err)
|
||||
}
|
||||
if reserved != testCase.reserved {
|
||||
t.Fatalf("case %d reserved=%d want=%d", index, reserved, testCase.reserved)
|
||||
}
|
||||
if len(values) != len(testCase.values) {
|
||||
t.Fatalf("case %d values=%v want=%v", index, values, testCase.values)
|
||||
}
|
||||
for valueIndex, want := range testCase.values {
|
||||
if values[valueIndex] != want {
|
||||
t.Fatalf("case %d value %d=%d want=%d", index, valueIndex, values[valueIndex], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, body := range [][]byte{
|
||||
{},
|
||||
{0, 0, 0, 0},
|
||||
{0, 0, 0, 0, 6, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 0, 1, 100, 0},
|
||||
{0, 0, 0, 0, 2, 100, 0, 0, 0},
|
||||
} {
|
||||
if _, _, err := decodeRaidSituationRequest(body); err == nil {
|
||||
t.Fatalf("body=%x was accepted", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// count==0 is "the 战况表 is open and asking for the situation". The answer is
|
||||
// the persistent selector projection, never the one-shot op587 state
|
||||
// announcement: the client repeats this request every 7-15s, so a replay of the
|
||||
// phase-start state would re-announce a phase that is already running.
|
||||
func TestOnlineRaidSituationWindowRefreshAnswersWithTheSelectorProjection(t *testing.T) {
|
||||
service, session, connection, lifecycle := newRaidSituationTestFixture(t)
|
||||
connection.write.Reset()
|
||||
|
||||
handled, err := service.handleOnlineRaidCommand(session, 695, []byte{0, 0, 0, 0, 0})
|
||||
if err != nil {
|
||||
t.Fatalf("situation refresh returned an error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("the situation refresh was not claimed by the raid handler")
|
||||
}
|
||||
wantIDs := []uint16{
|
||||
raid.RaidObjectMsgID,
|
||||
raid.RaidMemberRefreshMsgID,
|
||||
raid.RaidSymbolMsgID,
|
||||
raid.RaidDungeonStateMsgID,
|
||||
raid.RaidTimerMsgID,
|
||||
raid.RaidRemainTimeMsgID,
|
||||
}
|
||||
stream := connection.write.Bytes()
|
||||
if len(stream) == 0 {
|
||||
t.Fatal("the situation refresh answered with no packet at all")
|
||||
}
|
||||
for index, wantID := range wantIDs {
|
||||
packet, rest := splitGameServerUpperPacket(t, stream)
|
||||
stream = rest
|
||||
if packet.Header.Classification != 0 || packet.Header.MsgID != wantID {
|
||||
t.Fatalf("situation packet %d class=%d msg=%#04x want=%#04x",
|
||||
index, packet.Header.Classification, packet.Header.MsgID, wantID)
|
||||
}
|
||||
if packet.Header.MsgID == raid.RaidStateMsgID {
|
||||
t.Fatalf("situation refresh replayed the one-shot raid state: %x", packet.Body)
|
||||
}
|
||||
}
|
||||
if len(stream) != 0 {
|
||||
t.Fatalf("situation refresh trailing bytes=%d", len(stream))
|
||||
}
|
||||
_ = lifecycle
|
||||
}
|
||||
|
||||
// A value report is the client's own HUD telemetry. It must not fan out to the
|
||||
// squad and it must never fail the read loop that dispatched it.
|
||||
func TestOnlineRaidSituationValueReportWritesNothing(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
connection.write.Reset()
|
||||
|
||||
handled, err := service.handleOnlineRaidSituationMonsterHp(session, 695, []byte{0, 0, 0, 0, 1, 100, 0, 0, 0})
|
||||
if err != nil {
|
||||
t.Fatalf("value report returned an error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("the value report was not claimed by the raid handler")
|
||||
}
|
||||
if wrote := connection.write.Len(); wrote != 0 {
|
||||
t.Fatalf("value report wrote %d bytes", wrote)
|
||||
}
|
||||
}
|
||||
|
||||
// A body shape this client has never produced is logged and swallowed: 86JP
|
||||
// answers neither branch, so failing the caller would only tear down the
|
||||
// connection for a packet the client sends on a timer.
|
||||
func TestOnlineRaidSituationInvalidBodyWritesNothingAndDoesNotFail(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
connection.write.Reset()
|
||||
|
||||
handled, err := service.handleOnlineRaidSituationMonsterHp(session, 695, []byte{0, 0, 0, 0, 1, 100, 0})
|
||||
if err != nil {
|
||||
t.Fatalf("invalid body returned an error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("the invalid body was not claimed by the raid handler")
|
||||
}
|
||||
if wrote := connection.write.Len(); wrote != 0 {
|
||||
t.Fatalf("invalid body wrote %d bytes", wrote)
|
||||
}
|
||||
}
|
||||
|
||||
func newRaidSituationTestFixture(t *testing.T) (*Service, *gameSession, *runtimeRaidPhaseTestConn, time.Time) {
|
||||
t.Helper()
|
||||
preparedAt := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
|
||||
config := runtimeRaidBridgeTestAntonConfig()
|
||||
lifecycle, err := newRuntimeRaidLifecycleStateFromAntonModeAndType(config, preparedAt, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("create lifecycle: %v", err)
|
||||
}
|
||||
member := runtimeRaidMemberState{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0}
|
||||
connection := &runtimeRaidPhaseTestConn{}
|
||||
const raidKey = uint32(0x00c80901)
|
||||
service := &Service{
|
||||
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
raids: map[uint32]*runtimeRaidState{
|
||||
raidKey: {
|
||||
RaidKey: raidKey,
|
||||
LeaderID: member.CharID,
|
||||
AntonConfig: config,
|
||||
Members: []runtimeRaidMemberState{member},
|
||||
FrozenMembers: []runtimeRaidMemberState{member},
|
||||
Started: true,
|
||||
EntryCostPaid: true,
|
||||
ChargedMembers: []uint16{member.CharID},
|
||||
Lifecycle: lifecycle,
|
||||
},
|
||||
},
|
||||
}
|
||||
session := &gameSession{
|
||||
conn: connection,
|
||||
connID: "raid-situation-test",
|
||||
channel: channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10038},
|
||||
}
|
||||
service.bindGameSessionCharacter(session, member.CharID)
|
||||
if mode := runtimeRaidModeForSession(session); mode == 0 {
|
||||
t.Fatal("the fixture session is not on a raid channel")
|
||||
}
|
||||
state := service.raids[raidKey]
|
||||
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||||
state.Lifecycle.Phase = runtimeRaidDungeonPhaseTwo
|
||||
state.Lifecycle.PhaseStartedAt = preparedAt
|
||||
state.Lifecycle.PhaseDeadline = preparedAt.Add(40 * time.Minute)
|
||||
return service, session, connection, preparedAt
|
||||
}
|
||||
|
||||
// raidSituationValueBody is the exact C2S 695 shape the live client produced:
|
||||
// reserved dword, count at +4, then the values.
|
||||
func raidSituationValueBody(count int) []byte {
|
||||
body := []byte{0, 0, 0, 0, byte(count)}
|
||||
for index := 0; index < count; index++ {
|
||||
body = append(body, byte(100-index), 0, 0, 0)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func raidSituationFixtureRaidKey(t *testing.T, service *Service) uint32 {
|
||||
t.Helper()
|
||||
for key := range service.raids {
|
||||
return key
|
||||
}
|
||||
t.Fatal("fixture has no raid")
|
||||
return 0
|
||||
}
|
||||
|
||||
// enterRaidSituationFixtureGate puts the fixture session inside one raid gate.
|
||||
// Only the raid binding matters to the store, so the run itself stays minimal.
|
||||
func enterRaidSituationFixtureGate(session *gameSession, raidKey uint32, phase byte, mapID int) {
|
||||
session.dungeon.mu.Lock()
|
||||
session.dungeon.runtime = &runtimeDungeonState{
|
||||
raidDungeon: runtimeRaidDungeonBinding{
|
||||
RaidKey: raidKey,
|
||||
Phase: phase,
|
||||
MapID: mapID,
|
||||
LifecycleToken: 1,
|
||||
},
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
}
|
||||
|
||||
// A value report is attributed to the squad AND to the gate that squad is standing
|
||||
// in, because that pair is what 634's rows are keyed by. Two gates must therefore
|
||||
// leave two entries: keying by the squad alone would let a value reported inside
|
||||
// 擎天之柱 be drawn in 舰炮.
|
||||
func TestOnlineRaidSituationValueReportStoresPerSquadAndGate(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
connection.write.Reset()
|
||||
|
||||
if _, err := service.handleOnlineRaidSituationMonsterHp(
|
||||
session, 695, raidSituationValueBody(3)); err != nil {
|
||||
t.Fatalf("value report returned an error: %v", err)
|
||||
}
|
||||
// A recorded report changes what every row on the board should draw, so it is
|
||||
// answered with the whole raid's row table. Asserting the exact bytes here is
|
||||
// deliberate: this packet REPLACES the client's table, so a wrong field order or
|
||||
// width is not a cosmetic bug.
|
||||
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidMonsterStateMsgID {
|
||||
t.Fatalf("value report answered with class=%d msg=%#04x, want the board row packet %#04x",
|
||||
packet.Header.Classification, packet.Header.MsgID, raid.RaidMonsterStateMsgID)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("value report trailing bytes=%d", len(rest))
|
||||
}
|
||||
wantBody := []byte{
|
||||
1, // groupCount
|
||||
1, 0, // A = squad 1
|
||||
1, // memberCount
|
||||
0xE9, 0x03, // member 1001
|
||||
0, 0, 0, 0, // V0 = paid revive coins spent in this gate
|
||||
0, 0, 0, 0, // V1 = the reporting client's record-set key, echoed
|
||||
3, // valueCount
|
||||
100, 0, 0, 0, 99, 0, 0, 0, 98, 0, 0, 0,
|
||||
}
|
||||
if !bytes.Equal(packet.Body, wantBody) {
|
||||
t.Fatalf("board row body=%x want=%x", packet.Body, wantBody)
|
||||
}
|
||||
|
||||
jiandun := runtimeRaidMonsterRuntimeKey{RaidKey: raidKey, Group: 1, DungeonID: 213}
|
||||
stored := service.runtimeRaidMonsterRuntimeValuesFor(raidKey)
|
||||
values, ok := stored[jiandun]
|
||||
if !ok {
|
||||
t.Fatalf("the report was not stored under %+v; stored=%+v", jiandun, stored)
|
||||
}
|
||||
if len(values) != 3 {
|
||||
t.Fatalf("stored %d values, want 3: %v", len(values), values)
|
||||
}
|
||||
for index, want := range []uint32{100, 99, 98} {
|
||||
if values[index] != want {
|
||||
t.Fatalf("stored value %d=%d want=%d", index, values[index], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A second gate is a second entry, not a replacement of the first.
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 221)
|
||||
if _, err := service.handleOnlineRaidSituationMonsterHp(
|
||||
session, 695, raidSituationValueBody(1)); err != nil {
|
||||
t.Fatalf("second value report returned an error: %v", err)
|
||||
}
|
||||
stored = service.runtimeRaidMonsterRuntimeValuesFor(raidKey)
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("two gates produced %d entries, want 2: %+v", len(stored), stored)
|
||||
}
|
||||
if _, ok := stored[jiandun]; !ok {
|
||||
t.Fatalf("the first gate's entry was replaced: %+v", stored)
|
||||
}
|
||||
if _, ok := stored[runtimeRaidMonsterRuntimeKey{RaidKey: raidKey, Group: 1, DungeonID: 221}]; !ok {
|
||||
t.Fatalf("the second gate's entry is missing: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// The client sends this opcode on a 7-15s timer, so it also arrives in town and in
|
||||
// ordinary dungeons. A report that cannot be attributed to a live raid gate is
|
||||
// dropped, exactly like 86JP's non-participant branch (Situation.cs:233-236).
|
||||
func TestOnlineRaidSituationValueReportOutsideAGateRecordsNothing(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
connection.write.Reset()
|
||||
|
||||
if _, err := service.handleOnlineRaidSituationMonsterHp(
|
||||
session, 695, raidSituationValueBody(2)); err != nil {
|
||||
t.Fatalf("value report returned an error: %v", err)
|
||||
}
|
||||
if stored := service.runtimeRaidMonsterRuntimeValuesFor(raidKey); len(stored) != 0 {
|
||||
t.Fatalf("a report from outside a raid gate was stored: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A report is only meaningful while the raid that produced it is alive; the purge
|
||||
// runs where the raid itself is removed.
|
||||
func TestRuntimeRaidMonsterRuntimeValuesArePurgedWithTheRaid(t *testing.T) {
|
||||
service, session, _, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
if _, err := service.handleOnlineRaidSituationMonsterHp(
|
||||
session, 695, raidSituationValueBody(2)); err != nil {
|
||||
t.Fatalf("value report returned an error: %v", err)
|
||||
}
|
||||
if stored := service.runtimeRaidMonsterRuntimeValuesFor(raidKey); len(stored) == 0 {
|
||||
t.Fatal("nothing was stored to purge")
|
||||
}
|
||||
|
||||
service.raidMu.Lock()
|
||||
service.purgeRuntimeRaidMonsterRuntimeValuesLocked(raidKey)
|
||||
service.raidMu.Unlock()
|
||||
if stored := service.runtimeRaidMonsterRuntimeValuesFor(raidKey); len(stored) != 0 {
|
||||
t.Fatalf("the purge left %d entries: %+v", len(stored), stored)
|
||||
}
|
||||
}
|
||||
|
||||
// setRaidSituationFixtureSpentCoins records how many revive coins the session's
|
||||
// gate has spent, which is what the board renders as its coin row.
|
||||
//
|
||||
// It writes the raid-scoped ledger rather than a counter on the run, because that is
|
||||
// what the board reads (runtimeRaidReviveCoinUses): the client subtracts the number
|
||||
// we send from the GATE's own allowance, and a run is recreated on every re-entry,
|
||||
// so a per-run counter would silently reset inside the same gate.
|
||||
func setRaidSituationFixtureSpentCoins(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
session *gameSession,
|
||||
spent uint32,
|
||||
) {
|
||||
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()
|
||||
state := service.raids[binding.RaidKey]
|
||||
if state == nil {
|
||||
service.raidMu.Unlock()
|
||||
t.Fatalf("raid %d is not live", binding.RaidKey)
|
||||
}
|
||||
if service.raidReviveUses == nil {
|
||||
service.raidReviveUses = make(map[runtimeRaidReviveCoinKey]uint32)
|
||||
}
|
||||
service.raidReviveUses[raidReviveCoinLedgerKey(*state, binding, session.selectedCharacterID)] = spent
|
||||
service.raidMu.Unlock()
|
||||
}
|
||||
|
||||
// raidReviveCoinLedgerKey is the ledger entry one member's current gate owns: the
|
||||
// same (raid, phase, row, gate) key the op41 commit path bills
|
||||
// (recordRuntimeRaidReviveCoinUseLocked). Caller holds s.raidMu.
|
||||
func raidReviveCoinLedgerKey(
|
||||
state runtimeRaidState,
|
||||
binding runtimeRaidDungeonBinding,
|
||||
charID uint16,
|
||||
) runtimeRaidReviveCoinKey {
|
||||
key := runtimeRaidReviveCoinKey{
|
||||
RaidKey: binding.RaidKey,
|
||||
Phase: binding.Phase,
|
||||
DungeonID: binding.MapID,
|
||||
}
|
||||
if member, ok := runtimeRaidFrozenMember(state, charID); ok {
|
||||
key.Group = runtimeRaidMemberSquad(member.GroupIndex)
|
||||
if key.Group == 0 {
|
||||
key.SoloCharID = charID
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// raidReviveCoinLedgerEntries copies the spent-coin ledger so a test can assert what
|
||||
// the op41 commit path recorded.
|
||||
func raidReviveCoinLedgerEntries(service *Service) map[runtimeRaidReviveCoinKey]uint32 {
|
||||
service.raidMu.Lock()
|
||||
defer service.raidMu.Unlock()
|
||||
entries := make(map[runtimeRaidReviveCoinKey]uint32, len(service.raidReviveUses))
|
||||
for key, uses := range service.raidReviveUses {
|
||||
entries[key] = uses
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// addRaidSituationFixtureMember registers another ONLINE member of the same raid in
|
||||
// its own squad, so whole-table emission can be checked with more than one row.
|
||||
func addRaidSituationFixtureMember(
|
||||
t *testing.T,
|
||||
service *Service,
|
||||
raidKey uint32,
|
||||
charID uint16,
|
||||
group byte,
|
||||
) (*gameSession, *runtimeRaidPhaseTestConn) {
|
||||
t.Helper()
|
||||
member := runtimeRaidMemberState{CharID: charID, Name: "mate", GroupIndex: group, SlotOrder: 0}
|
||||
service.raidMu.Lock()
|
||||
state := service.raids[raidKey]
|
||||
state.Members = append(state.Members, member)
|
||||
state.FrozenMembers = append(state.FrozenMembers, member)
|
||||
service.raidMu.Unlock()
|
||||
|
||||
connection := &runtimeRaidPhaseTestConn{}
|
||||
session := &gameSession{
|
||||
conn: connection,
|
||||
connID: "raid-situation-test-mate",
|
||||
channel: channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10038},
|
||||
}
|
||||
service.bindGameSessionCharacter(session, charID)
|
||||
if _, online := service.onlineGameSession(charID); !online {
|
||||
t.Fatalf("member %d was not registered as online", charID)
|
||||
}
|
||||
return session, connection
|
||||
}
|
||||
|
||||
// A body with groupCount 0 is NOT "nothing to say": the client clears its entire
|
||||
// row table on it (0x17BF6AC, _probe_634_report.md section 6c Q1). So a raid with
|
||||
// nobody standing in a gate must send no row packet at all. This is the regression
|
||||
// guard for the sharpest edge in this feature.
|
||||
func TestRuntimeRaidMonsterStateIsNeverAnEmptyTable(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
state := *service.raids[raidKey]
|
||||
|
||||
body, ok, err := service.runtimeRaidMonsterStateBody(state)
|
||||
if err != nil || ok || body != nil {
|
||||
t.Fatalf("nobody in a gate produced body=%x ok=%t err=%v", body, ok, err)
|
||||
}
|
||||
connection.write.Reset()
|
||||
if err := service.sendRuntimeRaidMonsterState(session, state); err != nil {
|
||||
t.Fatalf("send with nobody in a gate: %v", err)
|
||||
}
|
||||
if wrote := connection.write.Len(); wrote != 0 {
|
||||
t.Fatalf("a raid with nobody in a gate wrote %d bytes; groupCount 0 would clear the client's table", wrote)
|
||||
}
|
||||
}
|
||||
|
||||
// The client replaces its WHOLE row table with this body, so two squads in two
|
||||
// gates must arrive as ONE packet carrying BOTH rows. Two packets, or a table with
|
||||
// only the reporting squad, would leave the other squad's row deleted.
|
||||
//
|
||||
// It also pins the two per-row values the client consumes: V0 is that squad's own
|
||||
// spent revive coins (the client renders `gate allowance - V0`), and V1 is the
|
||||
// record-set key its own report used, echoed back.
|
||||
func TestRuntimeRaidMonsterStateBroadcastsEverySquadInOneTable(t *testing.T) {
|
||||
service, leader, leaderConn, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(leader, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
setRaidSituationFixtureSpentCoins(t, service, leader, 2)
|
||||
|
||||
mate, mateConn := addRaidSituationFixtureMember(t, service, raidKey, 1002, 2)
|
||||
enterRaidSituationFixtureGate(mate, raidKey, runtimeRaidDungeonPhaseTwo, 221)
|
||||
setRaidSituationFixtureSpentCoins(t, service, mate, 5)
|
||||
|
||||
leaderConn.write.Reset()
|
||||
mateConn.write.Reset()
|
||||
handled, err := service.handleOnlineRaidSituationMonsterHp(leader, 695, raidSituationValueBody(2))
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("report handled=%t err=%v", handled, err)
|
||||
}
|
||||
|
||||
want := []byte{
|
||||
2, // groupCount: BOTH squads in one table
|
||||
1, 0, // squad 1
|
||||
1, // one member
|
||||
0xE9, 0x03, // 1001
|
||||
2, 0, 0, 0, // V0: squad 1 has spent 2 coins in 舰炮
|
||||
0, 0, 0, 0, // V1 echoed from squad 1's own report
|
||||
2, // valueCount
|
||||
100, 0, 0, 0, 99, 0, 0, 0, // the values squad 1 reported
|
||||
2, 0, // squad 2
|
||||
1, // one member
|
||||
0xEA, 0x03, // 1002
|
||||
5, 0, 0, 0, // V0: squad 2 has spent 5 coins in its own gate
|
||||
0, 0, 0, 0, // V1 default, because squad 2 has not reported yet
|
||||
0, // squad 2 reported nothing, so it must not overwrite its own bars
|
||||
}
|
||||
for index, conn := range []*runtimeRaidPhaseTestConn{leaderConn, mateConn} {
|
||||
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidMonsterStateMsgID {
|
||||
t.Fatalf("recipient %d msg=%#04x want %#04x",
|
||||
index, packet.Header.MsgID, raid.RaidMonsterStateMsgID)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("recipient %d trailing bytes=%d", index, len(rest))
|
||||
}
|
||||
if !bytes.Equal(packet.Body, want) {
|
||||
t.Fatalf("recipient %d body=%x want=%x", index, packet.Body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The client's 587 handler WIPES its row table, so every sequence that announces
|
||||
// raid state has to carry the rows again, and after the announcement, or an open
|
||||
// board stays blank until it is closed and reopened.
|
||||
func TestRaidStateBroadcastRepublishesTheBoardRows(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
setRaidSituationFixtureSpentCoins(t, service, session, 1)
|
||||
connection.write.Reset()
|
||||
|
||||
if err := service.broadcastRuntimeRaidWireSequence(
|
||||
*service.raids[raidKey],
|
||||
runtimeRaidWireNotification{
|
||||
msgID: raid.RaidStateMsgID,
|
||||
body: raid.BuildRaidStateBody(raid.RaidState{State: 3}),
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("raid state broadcast: %v", err)
|
||||
}
|
||||
|
||||
stream := connection.write.Bytes()
|
||||
statePacket, stream := splitGameServerUpperPacket(t, stream)
|
||||
if statePacket.Header.MsgID != raid.RaidStateMsgID {
|
||||
t.Fatalf("first packet msg=%#04x want the raid state %#04x",
|
||||
statePacket.Header.MsgID, raid.RaidStateMsgID)
|
||||
}
|
||||
rowPacket, stream := splitGameServerUpperPacket(t, stream)
|
||||
if rowPacket.Header.Classification != 0 || rowPacket.Header.MsgID != raid.RaidMonsterStateMsgID {
|
||||
t.Fatalf("second packet msg=%#04x want the board rows %#04x",
|
||||
rowPacket.Header.MsgID, raid.RaidMonsterStateMsgID)
|
||||
}
|
||||
if len(stream) != 0 {
|
||||
t.Fatalf("raid state broadcast trailing bytes=%d", len(stream))
|
||||
}
|
||||
wantBody := []byte{
|
||||
1, // groupCount
|
||||
1, 0, // squad 1
|
||||
1, // one member
|
||||
0xE9, 0x03, // 1001
|
||||
1, 0, 0, 0, // V0 = 1 spent coin
|
||||
0, 0, 0, 0, // V1 default
|
||||
0, // nothing reported yet
|
||||
}
|
||||
if !bytes.Equal(rowPacket.Body, wantBody) {
|
||||
t.Fatalf("board row body=%x want=%x", rowPacket.Body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule is tied to the 587 wipe, not to broadcasting in general: a sequence that
|
||||
// does not announce raid state must not grow an extra packet.
|
||||
func TestNonStateBroadcastDoesNotRepublishTheBoardRows(t *testing.T) {
|
||||
service, session, connection, _ := newRaidSituationTestFixture(t)
|
||||
raidKey := raidSituationFixtureRaidKey(t, service)
|
||||
enterRaidSituationFixtureGate(session, raidKey, runtimeRaidDungeonPhaseTwo, 213)
|
||||
connection.write.Reset()
|
||||
|
||||
if err := service.broadcastRuntimeRaidWireSequence(
|
||||
*service.raids[raidKey],
|
||||
runtimeRaidWireNotification{msgID: raid.RaidSymbolMsgID},
|
||||
); err != nil {
|
||||
t.Fatalf("symbol broadcast: %v", err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if packet.Header.MsgID != raid.RaidSymbolMsgID {
|
||||
t.Fatalf("msg=%#04x want the symbol packet %#04x", packet.Header.MsgID, raid.RaidSymbolMsgID)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("a non-state broadcast grew an extra packet: %x", rest)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user