Files
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

206 lines
7.6 KiB
Go

package dnfbridge
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// These tests pin the town-visibility invariants a party must satisfy after the
// settlement exit. They exist because a manual test cycle was the only oracle for
// too long: the field report is "after 通关 -> 选择其他地下城 -> 从选图界面返回城镇 the two
// members cannot see each other and each other's movement never arrives", while the
// same return straight from the settlement (and the same return without a completed
// run) works.
//
// The invariants asserted here are the ones every later packet path depends on:
// 1. both characters hold a live town presence registration,
// 2. they are in the same area key (channel+instance+town+area), which is what
// every movement relay and later actor install is filtered by,
// 3. a position update from one of them is relayed to the other,
// 4. each client is told about the other's actor (op2 / mode0).
func assertPartyTownVisibilityInvariants(
t *testing.T,
service *Service,
leader *gameSession,
follower *gameSession,
leaderConn *bufferConn,
followerConn *bufferConn,
stage string,
) {
t.Helper()
leaderID := leader.selectedCharacterID
followerID := follower.selectedCharacterID
if _, ok := service.onlinePlayers.PlayerForCharacter(leaderID); !ok {
t.Fatalf("%s: leader %d has no town presence registration", stage, leaderID)
}
if _, ok := service.onlinePlayers.PlayerForCharacter(followerID); !ok {
t.Fatalf("%s: follower %d has no town presence registration", stage, followerID)
}
if !service.onlinePlayers.PeerInSameArea(leaderID, followerID) {
t.Fatalf("%s: %d and %d are not in the same area key", stage, leaderID, followerID)
}
// The movement relay is the invariant the field report sees as "A walks, B does
// not move": if this returns nothing, no peer ever learns about the movement.
peers := service.onlinePlayers.UpdatePosition(leaderID, 640, 320)
found := false
for _, peer := range peers {
if peer.CharacterID == followerID {
found = true
}
}
if !found {
t.Fatalf("%s: movement of %d is not relayed to %d (peers=%d)",
stage, leaderID, followerID, len(peers))
}
// And each client must have been told about the other's actor record.
for name, probe := range map[string]struct {
session *gameSession
conn *bufferConn
peerID uint16
}{
"leader": {leader, leaderConn, followerID},
"follower": {follower, followerConn, leaderID},
} {
if probe.conn == nil {
continue
}
installSeen := false
for _, packet := range splitAllCurrentGameServerUpperPackets(t, probe.conn.write.Bytes()) {
if packet.Header.MsgID == 2 {
installSeen = true
break
}
}
if !installSeen {
t.Fatalf("%s: %s never received the peer %d actor install (op2)", stage, name, probe.peerID)
}
}
}
func newPartyTownVisibilityFixture(t *testing.T) (service *Service, leader, follower *gameSession,
leaderConn, followerConn *bufferConn) {
t.Helper()
base, session, _ := newBackToVillageRuntime(t)
base.onlinePlayers = newOnlinePlayerManager()
service = base
leader = session
leader.selectedCharacterID = 99
leaderConn = leader.conn.(*bufferConn)
leader.dungeon.runtime = nil
service.bindGameSessionCharacter(leader, 99)
bindDungeonSelectorOriginForTestAt(t, service, leader, 7, 3, 474, 234)
leader.enterSelectDungeonSent = true
leader.enterSelectDungeonAckSent = true
leader.enterSelectDungeonContextSent = true
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "100",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 7, "area_id": 3,
"pos_x": 500, "pos_y": 240, "direction": 6, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
follower = &gameSession{
conn: &bufferConn{},
connID: "party-visibility-follower",
channel: leader.channel,
residentChannel: leader.residentChannel,
selectedCharacterID: 100,
}
followerConn = follower.conn.(*bufferConn)
service.bindGameSessionCharacter(follower, 100)
bindDungeonSelectorOriginForTestAt(t, service, follower, 7, 3, 500, 240)
follower.enterSelectDungeonSent = true
follower.enterSelectDungeonPassiveParty = true
follower.enterSelectDungeonContextSent = true
partyState := alignedcmd.PartyState{
PartyID: 9,
UserID: 99,
Members: []alignedcmd.PartyMemberState{
{UserID: 99, Slot: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 100, Slot: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
storeRuntimePartyState(leader, partyState)
storeRuntimePartyState(follower, partyState)
// Both characters are already registered in the town, which is the state the
// party is in right before it enters the dungeon.
service.registerTownPresenceForTest(t, leader, 99, 7, 3, 474, 234)
service.registerTownPresenceForTest(t, follower, 100, 7, 3, 500, 240)
return service, leader, follower, leaderConn, followerConn
}
// The invariant must already hold while the party stands in town, so a failure
// here means the fixture itself is wrong rather than the return path.
func TestPartyVisibilityInvariantsHoldInTown(t *testing.T) {
service, leader, follower, leaderConn, followerConn := newPartyTownVisibilityFixture(t)
assertPartyTownVisibilityInvariants(t, service, leader, follower, leaderConn, followerConn, "town")
}
// The reported flow: the leader closes the shared selector with 返回城镇 and the
// passive follower is returned with it. After that both must satisfy the same
// invariants -- that is exactly what the field report says does not happen.
func TestPartyVisibilityInvariantsSurviveSelectorReturn(t *testing.T) {
service, leader, follower, leaderConn, followerConn := newPartyTownVisibilityFixture(t)
leaderConn.write.Reset()
followerConn.write.Reset()
service.onlinePlayers.LeaveArea(follower.selectedCharacterID)
if err := service.handleDungeonBackToVillage(leader, nil); err != nil {
t.Fatalf("leader selector return: %v", err)
}
assertPartyTownVisibilityInvariants(t, service, leader, follower, leaderConn, followerConn,
"after selector return")
}
// The settlement exit itself (选择其他地下城) must not break the invariants either.
func TestPartyVisibilityInvariantsSurviveSettlementSelectOther(t *testing.T) {
service, leader, follower, leaderConn, followerConn := newPartyTownVisibilityFixture(t)
if err := service.handleDungeonEplpCommand(leader, []byte{1, 1}); err != nil {
t.Fatalf("leader select-other: %v", err)
}
assertPartyTownVisibilityInvariants(t, service, leader, follower, leaderConn, followerConn,
"after select-other")
}
// registerTownPresenceForTest files a character in the town area table exactly like
// an area enter does, which is the state the party must be in before the flow under
// test starts (and the state the return path has to restore).
func (s *Service) registerTownPresenceForTest(
t *testing.T,
session *gameSession,
characterID uint16,
townID, areaID byte,
posX, posY uint16,
) {
t.Helper()
if s == nil || session == nil || s.onlinePlayers == nil {
t.Fatal("town presence fixture requires a service with an online player manager")
}
info := &onlinePlayerInfo{
CharacterID: characterID,
ChannelID: townPresenceChannelID(session),
TownID: townID,
AreaID: areaID,
PositionX: posX,
PositionY: posY,
Session: session,
}
others := s.onlinePlayers.EnterArea(info)
if len(others) > 0 {
s.broadcastTownPlayerEnter(info, others)
}
}