DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
}