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,271 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stalledWriteConn models the live 2026-09-25 stall measured on the production
|
||||
// server: the client stopped reading (8 clients on one home connection, the
|
||||
// machine busy), so the socket's send buffer filled and Write blocked.
|
||||
//
|
||||
// The write deadline is load-bearing for this double. gameConnWriteBudget is
|
||||
// what turns "blocked" into "blocked for at most 5 seconds", and it is the only
|
||||
// reason this test terminates on a build with no session writer: without it the
|
||||
// blocked write would park the session's event loop for the whole test binary
|
||||
// timeout instead of failing an assertion.
|
||||
type stalledWriteConn struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
deadline time.Time
|
||||
// entered is signalled by the first Write that actually reaches the socket,
|
||||
// so the test knows the stall is in progress rather than merely queued.
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
releaseDo sync.Once
|
||||
writes atomic.Int64
|
||||
}
|
||||
|
||||
func newStalledWriteConn() *stalledWriteConn {
|
||||
return &stalledWriteConn{
|
||||
entered: make(chan struct{}, 1),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stalledWriteConn) releaseAll() {
|
||||
c.releaseDo.Do(func() { close(c.release) })
|
||||
}
|
||||
|
||||
func (c *stalledWriteConn) written() []byte {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]byte(nil), c.buf.Bytes()...)
|
||||
}
|
||||
|
||||
func (c *stalledWriteConn) Read(_ []byte) (int, error) { return 0, io.EOF }
|
||||
func (c *stalledWriteConn) Close() error { return nil }
|
||||
func (c *stalledWriteConn) LocalAddr() net.Addr { return fakeAddr("local") }
|
||||
func (c *stalledWriteConn) RemoteAddr() net.Addr { return fakeAddr("remote") }
|
||||
func (c *stalledWriteConn) SetDeadline(_ time.Time) error { return nil }
|
||||
func (c *stalledWriteConn) SetReadDeadline(_ time.Time) error { return nil }
|
||||
|
||||
func (c *stalledWriteConn) SetWriteDeadline(deadline time.Time) error {
|
||||
c.mu.Lock()
|
||||
c.deadline = deadline
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stalledWriteConn) Write(payload []byte) (int, error) {
|
||||
select {
|
||||
case c.entered <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
c.mu.Lock()
|
||||
deadline := c.deadline
|
||||
c.mu.Unlock()
|
||||
var expiry <-chan time.Time
|
||||
if !deadline.IsZero() {
|
||||
timer := time.NewTimer(time.Until(deadline))
|
||||
defer timer.Stop()
|
||||
expiry = timer.C
|
||||
}
|
||||
select {
|
||||
case <-c.release:
|
||||
case <-expiry:
|
||||
return 0, timeoutNetError{}
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.writes.Add(1)
|
||||
return c.buf.Write(payload)
|
||||
}
|
||||
|
||||
// failFastWriteConn fails immediately, the way a closed socket does
|
||||
// ("use of closed network connection"). The distinction from stalledWriteConn is
|
||||
// the whole point of the session writer's design: a fast failure must keep
|
||||
// reaching its caller synchronously, because production arms recovery work off
|
||||
// that error (altar_runtime.go arms a server-side retry when the op38 write
|
||||
// fails), while a stalled write must not park the session's event loop.
|
||||
type failFastWriteConn struct {
|
||||
err error
|
||||
writes atomic.Int64
|
||||
}
|
||||
|
||||
func (c *failFastWriteConn) Read(_ []byte) (int, error) { return 0, io.EOF }
|
||||
func (c *failFastWriteConn) Close() error { return nil }
|
||||
func (c *failFastWriteConn) LocalAddr() net.Addr { return fakeAddr("local") }
|
||||
func (c *failFastWriteConn) RemoteAddr() net.Addr { return fakeAddr("remote") }
|
||||
func (c *failFastWriteConn) SetDeadline(_ time.Time) error { return nil }
|
||||
func (c *failFastWriteConn) SetReadDeadline(_ time.Time) error { return nil }
|
||||
func (c *failFastWriteConn) SetWriteDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *failFastWriteConn) Write(_ []byte) (int, error) {
|
||||
c.writes.Add(1)
|
||||
return 0, c.err
|
||||
}
|
||||
|
||||
func newGameSessionWriterTestSession(t *testing.T, ctx context.Context, conn net.Conn) (*Service, *gameSession) {
|
||||
t.Helper()
|
||||
service := &Service{}
|
||||
session := &gameSession{
|
||||
connID: "stalled-client-write",
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
selectedCharacterID: 19,
|
||||
characterGeneration: 7,
|
||||
}
|
||||
if err := service.startGameSessionEvents(ctx, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { stopTestGameSessionEvents(t, service, session) })
|
||||
return service, session
|
||||
}
|
||||
|
||||
// TestGameSessionWriteToStalledClientDoesNotParkTheSessionLoop pins the live
|
||||
// 2026-09-25 failure that made 通关后返回城镇 look dead.
|
||||
//
|
||||
// Measured on the production server, connection game-000048 (character 2):
|
||||
// the 返回城镇 click was ACKed at 07:40:29.958Z, the session then logged not one
|
||||
// further line for 48 seconds -- not even the 3-second security-status
|
||||
// heartbeat the client sends when it is idle -- and at 07:41:18.926Z the whole
|
||||
// town rebuild (op584 x2, the town actor packets, the op9 party frames) arrived
|
||||
// in one burst. So the route had not been lost; it was parked behind socket
|
||||
// writes to a client that had stopped reading, at up to gameConnWriteBudget
|
||||
// (5s) per frame.
|
||||
//
|
||||
// Because every write happens on the session's single event loop, that park
|
||||
// also stops every unrelated request on the same character: the queued
|
||||
// settlement exit, the consumable request behind it (the operator's "无色技能用
|
||||
// 不了" is the same stall, as he suspected: "我感觉就是卡住了导致的"), and the
|
||||
// chat line. Movement kept working because it is not routed through the loop in
|
||||
// the same way.
|
||||
func TestGameSessionWriteToStalledClientDoesNotParkTheSessionLoop(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stalled := newStalledWriteConn()
|
||||
service, session := newGameSessionWriterTestSession(t, ctx, stalled)
|
||||
// Registered after the cleanup above, so it runs first and the shutdown in
|
||||
// the cleanup is never left waiting on a write that is still blocked.
|
||||
defer stalled.releaseAll()
|
||||
|
||||
// The write has to run INSIDE an event-loop handler, which is where every
|
||||
// production write happens. Calling writeGameUpperPacket straight from the
|
||||
// test goroutine would block the test goroutine instead and prove nothing.
|
||||
firstWrite := make(chan error, 1)
|
||||
go func() {
|
||||
firstWrite <- service.callGameSession(ctx, session, "blocked-write", func() error {
|
||||
return service.writeGameUpperPacket(session, []byte{0x01, 0x02, 0x03})
|
||||
})
|
||||
}()
|
||||
waitTestSignal(t, stalled.entered, "the stalled write never reached the socket")
|
||||
|
||||
// A second, unrelated request on the same session. It must be served even
|
||||
// though the client is not reading.
|
||||
served := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
served <- service.callGameSession(ctx, session, "unrelated-request", func() error { return nil })
|
||||
}()
|
||||
// 1s is the assertion window: far below the 5s a blocked write is allowed to
|
||||
// park the loop today, far above the sub-300ms a session writer may spend
|
||||
// before it hands the stall to its own goroutine.
|
||||
select {
|
||||
case err := <-served:
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated request failed while the client was stalled: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("session event loop was parked %.0fms behind a stalled client write; "+
|
||||
"every later request on this character (返回城镇, 物品/无色, 聊天) waits behind it",
|
||||
float64(time.Since(start).Milliseconds()))
|
||||
}
|
||||
|
||||
stalled.releaseAll()
|
||||
select {
|
||||
case err := <-firstWrite:
|
||||
if err != nil {
|
||||
t.Fatalf("the write to the recovered client failed: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the blocked write call did not return after the client resumed reading")
|
||||
}
|
||||
// The handler is allowed to return before its frame has crossed the socket --
|
||||
// that is the entire point of the writer -- so wait for the bytes to land
|
||||
// before asserting on them.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !bytes.Equal(stalled.written(), []byte{0x01, 0x02, 0x03}) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("stalled-then-released write body = %x, want 010203", stalled.written())
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameSessionWriterKeepsFrameOrder is the ordering guard for moving socket
|
||||
// writes off the event loop. The client parses an ordered stream, so a writer
|
||||
// goroutine that reordered frames would corrupt every session it touched.
|
||||
func TestGameSessionWriterKeepsFrameOrder(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
service := &Service{}
|
||||
connection := &bufferConn{}
|
||||
session := &gameSession{
|
||||
connID: "writer-order",
|
||||
ctx: ctx,
|
||||
conn: connection,
|
||||
selectedCharacterID: 19,
|
||||
characterGeneration: 7,
|
||||
}
|
||||
if err := service.startGameSessionEvents(ctx, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopTestGameSessionEvents(t, service, session)
|
||||
|
||||
var want []byte
|
||||
for index := 0; index < 64; index++ {
|
||||
frame := []byte{byte(index), byte(index >> 8), 0xAA}
|
||||
want = append(want, frame...)
|
||||
if err := service.writeGameUpperPacket(session, frame); err != nil {
|
||||
t.Fatalf("frame %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
stream := connection.write.Bytes()
|
||||
if !bytes.Equal(stream, want) {
|
||||
t.Fatalf("writer reordered frames:\n got %x\nwant %x", stream, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameSessionWriteFailureStillReachesItsCallerSynchronously guards the
|
||||
// semantics the session writer must not break.
|
||||
//
|
||||
// A write error is load-bearing in production, not cosmetic: altar_runtime.go
|
||||
// arms a server-side retry when the dynamic op38 write fails
|
||||
// ("dynamic_monster_op38_write_failed_server_retry_armed"), and
|
||||
// altar_runtime_test.go pins "failed write committed or lost retry". If moving
|
||||
// writes to a goroutine made the error asynchronous, those recovery paths would
|
||||
// silently stop arming and the safety net would be gone without a single test
|
||||
// failing. A failure that is reported immediately must therefore still be
|
||||
// reported immediately.
|
||||
func TestGameSessionWriteFailureStillReachesItsCallerSynchronously(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
wantErr := errors.New("use of closed network connection")
|
||||
failing := &failFastWriteConn{err: wantErr}
|
||||
service, session := newGameSessionWriterTestSession(t, ctx, failing)
|
||||
|
||||
err := service.writeGameUpperPacket(session, []byte{0x07})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("write error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user