Files
90CNServer/server/internal/services/dnfbridge/raid_start_waiting_area_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

282 lines
10 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"testing"
"unicode/utf16"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// enterRuntimeRaidWaitingAreaPresenceForTest registers one session in the raid's
// waiting area exactly the way town_move.go does when the client walks through
// the team channel's portal: town + waiting area + InstanceKey == RaidKey.
//
// The start-time gate reads that registry, so every fixture that expects a start
// to be accepted has to place its members there.
func enterRuntimeRaidWaitingAreaPresenceForTest(
t *testing.T,
service *Service,
session *gameSession,
spec runtimeRaidTownSpec,
raidKey uint32,
) {
t.Helper()
if service.onlinePlayers == nil {
service.onlinePlayers = newOnlinePlayerManager()
}
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: session.selectedCharacterID,
TownID: byte(spec.townID),
AreaID: spec.waitingArea,
ChannelID: townPresenceChannelID(session),
InstanceKey: raidKey,
Session: session,
})
}
func testAntonWaitingAreaSpec(t *testing.T) runtimeRaidTownSpec {
t.Helper()
spec, ok := runtimeRaidTownSpecForOwnerMode(runtimeRaidOwnerAnton)
if !ok {
t.Fatal("Anton owner has no waiting-area spec")
}
return spec
}
func testLukeWaitingAreaSpec(t *testing.T) runtimeRaidTownSpec {
t.Helper()
spec, ok := runtimeRaidTownSpecForOwnerMode(runtimeRaidOwnerLuke)
if !ok {
t.Fatal("Luke owner has no waiting-area spec")
}
return spec
}
// readCurrentClientNoticeText decodes the op413 type-4 notice envelope, so a test
// can assert the exact sentence a player would read.
func readCurrentClientNoticeText(t *testing.T, body []byte) string {
t.Helper()
if len(body) < currentClientNoticeHeaderBytes {
t.Fatalf("notice body too short: % X", body)
}
if body[0] != currentClientNoticeType {
t.Fatalf("notice body type=%d want=%d (body=% X)", body[0], currentClientNoticeType, body)
}
header := currentClientNoticeHeaderBytes
if body[1] == currentClientNoticeVersionStyled {
header = currentClientNoticeStyledHeaderBytes
}
units := int(binary.LittleEndian.Uint16(body[2:4]))
encoded := body[header:]
decoded := make([]uint16, 0, units)
for index := 0; index+1 < len(encoded) && len(decoded) < units; index += 2 {
decoded = append(decoded, binary.LittleEndian.Uint16(encoded[index:index+2]))
}
return string(utf16.Decode(decoded))
}
// runtimeRaidStartNoticeTextsForTest decodes every client notice the session was
// sent, in wire order.
func runtimeRaidStartNoticeTextsForTest(t *testing.T, session *gameSession) []string {
t.Helper()
conn, ok := session.conn.(*bufferConn)
if !ok {
t.Fatalf("session conn is %T, want *bufferConn", session.conn)
}
texts := make([]string, 0, 2)
rest := conn.write.Bytes()
for len(rest) > 0 {
packet, remainder := splitGameServerUpperPacket(t, rest)
rest = remainder
if packet.Header.MsgID != currentClientNoticeMsgID {
continue
}
texts = append(texts, readCurrentClientNoticeText(t, packet.Body))
}
return texts
}
type runtimeRaidWaitingAreaStartFixture struct {
service *Service
repositories dnfrepo.Group
raidKey uint32
leader *gameSession
member *gameSession
leaderConn *bufferConn
memberConn *bufferConn
}
func newRuntimeRaidWaitingAreaStartFixture(t *testing.T) runtimeRaidWaitingAreaStartFixture {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002"} {
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: runtimeRaidEntryItemID, Count: 2},
},
}); err != nil {
t.Fatalf("seed ticket for %s: %v", characterID, err)
}
}
config := runtimeRaidBridgeTestAntonConfig()
config.StartMinimumMembers = 2
config.SingleStartMinimumMembers = 1
config.StartDelaySeconds = 3600
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidAnton: config,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
leader := &gameSession{conn: leaderConn, channel: channel}
member := &gameSession{conn: memberConn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
raidKey := uint32(200)<<16 | 1001
roster := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
}
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
RaidMode: runtimeRaidOwnerAnton,
NameBytes: []byte("anton"),
Members: append([]runtimeRaidMemberState(nil), roster...),
FrozenMembers: append([]runtimeRaidMemberState(nil), roster...),
}
return runtimeRaidWaitingAreaStartFixture{
service: service,
repositories: repositories,
raidKey: raidKey,
leader: leader,
member: member,
leaderConn: leaderConn,
memberConn: memberConn,
}
}
// TestRuntimeRaidStartWaitsForEveryMemberInTheWaitingArea is the operator's rule
// end to end: a member standing on the public deck (same channel, same town,
// instance 0) refuses the start, no entry ticket is spent, and every member --
// not only the leader -- is told which member the start is waiting for.
func TestRuntimeRaidStartWaitsForEveryMemberInTheWaitingArea(t *testing.T) {
fixture := newRuntimeRaidWaitingAreaStartFixture(t)
spec := testAntonWaitingAreaSpec(t)
// The leader is in the area, the member is not: the member's client is the one
// that used to freeze 0.24 s after the phase activated (see
// raid_start_waiting_area.go), so the start must be refused.
enterRuntimeRaidWaitingAreaPresenceForTest(t, fixture.service, fixture.leader, spec, fixture.raidKey)
fixture.service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: fixture.member.selectedCharacterID,
TownID: byte(currentAntonRaidTownID),
AreaID: 1, // the public deck: Town/BattleShip/BattleShip_Main.map
ChannelID: townPresenceChannelID(fixture.member),
InstanceKey: 0,
Session: fixture.member,
})
if err := fixture.service.handleOnlineStartRaid(
fixture.leader,
uint16(dnfenum.CmdPacketStartRaid),
nil,
); err != nil {
t.Fatalf("refused start returned error: %v", err)
}
state := fixture.service.raids[fixture.raidKey]
if state == nil || state.Started || state.StartPending || state.EntryCostPaid {
t.Fatalf("start was accepted or left half-open: %+v", state)
}
if state.FrozenMembers != nil {
t.Fatalf("refused start kept a frozen roster: %+v", state.FrozenMembers)
}
assertRuntimeRaidTicketCount(t, fixture.repositories, "1001", 2)
assertRuntimeRaidTicketCount(t, fixture.repositories, "1002", 2)
// Both members get the red reason, and it names the member that is missing.
wantText := currentRaidStartWaitingAreaNoticePrefix + "<member>"
for _, receiver := range []*gameSession{fixture.leader, fixture.member} {
texts := runtimeRaidStartNoticeTextsForTest(t, receiver)
if len(texts) == 0 {
t.Fatalf("char %d received no start notice", receiver.selectedCharacterID)
}
found := false
for _, text := range texts {
if text == wantText {
found = true
}
}
if !found {
t.Fatalf("char %d notices=%q want %q", receiver.selectedCharacterID, texts, wantText)
}
}
// Walking the member into the waiting area is what unblocks the start.
fixture.leaderConn.write.Reset()
fixture.memberConn.write.Reset()
enterRuntimeRaidWaitingAreaPresenceForTest(t, fixture.service, fixture.member, spec, fixture.raidKey)
if err := fixture.service.handleOnlineStartRaid(
fixture.leader,
uint16(dnfenum.CmdPacketStartRaid),
nil,
); err != nil {
t.Fatalf("start after the member walked in: %v", err)
}
state = fixture.service.raids[fixture.raidKey]
if state == nil || !state.Started || !state.EntryCostPaid {
t.Fatalf("start after the member walked in: %+v", state)
}
assertRuntimeRaidTicketCount(t, fixture.repositories, "1001", 1)
assertRuntimeRaidTicketCount(t, fixture.repositories, "1002", 1)
}
// TestRuntimeRaidStartWaitingAreaTreatsMissingPresenceAsOutside pins both halves
// of the evidence rule: an ONLINE member whose presence row is missing (between
// scenes) is not provably in the area and must refuse the start, while a member
// with no bound session never blocks it.
func TestRuntimeRaidStartWaitingAreaTreatsMissingPresenceAsOutside(t *testing.T) {
fixture := newRuntimeRaidWaitingAreaStartFixture(t)
spec := testAntonWaitingAreaSpec(t)
enterRuntimeRaidWaitingAreaPresenceForTest(t, fixture.service, fixture.leader, spec, fixture.raidKey)
outside := fixture.service.runtimeRaidMembersOutsideWaitingArea(
*fixture.service.raids[fixture.raidKey], spec)
if len(outside) != 1 || outside[0].CharID != fixture.member.selectedCharacterID {
t.Fatalf("outside=%+v want only the member without a presence row", outside)
}
// The member drops: a client that is not there cannot be pulled into the
// activation burst, so it must not strand the raid.
fixture.service.unbindGameSession(fixture.member)
outside = fixture.service.runtimeRaidMembersOutsideWaitingArea(
*fixture.service.raids[fixture.raidKey], spec)
if len(outside) != 0 {
t.Fatalf("offline member blocked the start: %+v", outside)
}
}
// TestRuntimeRaidWaitingAreaNoticeNamesEveryMissingMember keeps the composed
// sentence stable: the fixed prefix (which the operator specified) followed by
// each missing member's name wrapped in <>, and a fallback name for a roster row
// that has none.
func TestRuntimeRaidWaitingAreaNoticeNamesEveryMissingMember(t *testing.T) {
text := runtimeRaidWaitingAreaNoticeText([]runtimeRaidMemberState{
{CharID: 1002, Name: "memberA"},
{CharID: 1003},
})
want := currentRaidStartWaitingAreaNoticePrefix + "<memberA><角色1003>"
if text != want {
t.Fatalf("notice text=%q want %q", text, want)
}
}