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,196 @@
// Package dnf 提供 DNF 项目接入 logic 服务的装配入口。
package dnf
import (
"errors"
"fmt"
"strings"
"time"
"github.com/BurntSushi/toml"
appkit "longheng.io/server/internal/platform/app"
)
const (
// EnvConfigPath 是 DNF logic manifest 使用的外部配置路径环境变量。
EnvConfigPath = "LONGHENG_DNF_CONFIG"
// DefaultConfigPath 是本地 DNF logic manifest 配置路径。
DefaultConfigPath = "configs/dnf/logic.toml"
)
var ErrConfigInvalid = errors.New("dnf logic config is invalid")
type Config struct {
Repository RepositoryConfig `toml:"repository"`
}
type RepositoryConfig struct {
Enabled bool `toml:"enabled"`
Driver string `toml:"driver"`
SQLitePath string `toml:"sqlite_path"`
MySQLDSN string `toml:"mysql_dsn"`
MySQLMaxOpenConns int `toml:"mysql_max_open_conns"`
MySQLMaxIdleConns int `toml:"mysql_max_idle_conns"`
MySQLConnMaxLifetime int `toml:"mysql_conn_max_lifetime_seconds"`
RedisEnabled bool `toml:"redis_enabled"`
RedisAddress string `toml:"redis_address"`
RedisPassword string `toml:"redis_password"`
RedisDB int `toml:"redis_db"`
RedisPoolSize int `toml:"redis_pool_size"`
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
RedisKeyPrefix string `toml:"redis_key_prefix"`
RedisTTLSeconds int `toml:"redis_ttl_seconds"`
ShardID string `toml:"shard_id"`
TablePrefix string `toml:"table_prefix"`
ServerGroupPlanFile string `toml:"server_group_plan_file"`
AutoCreateSchema bool `toml:"auto_create_schema"`
CSharpLegacySchema bool `toml:"csharp_legacy_schema"`
CreateDatabases bool `toml:"create_databases"`
}
// LoadConfig 读取 DNF logic manifest 外部配置。
// 该配置只属于 DNF 项目装配层,不进入平台通用 ServiceConfig;环境相关默认值由 LoadConfigForEnv 补齐。
func LoadConfig(path string) (Config, error) {
path = strings.TrimSpace(path)
if path == "" {
path = DefaultConfigPath
}
var cfg Config
meta, err := toml.DecodeFile(path, &cfg)
if err != nil {
return Config{}, err
}
if keys := meta.Undecoded(); len(keys) > 0 {
names := make([]string, 0, len(keys))
for _, key := range keys {
names = append(names, key.String())
}
return Config{}, fmt.Errorf("%w: unknown keys: %s", ErrConfigInvalid, strings.Join(names, ", "))
}
return cfg, nil
}
// LoadConfigForEnv 读取并校验 DNF logic manifest 配置。
// 它会从框架 Env 补齐当前区服 shard_id,再执行配置校验。
func LoadConfigForEnv(path string, env *appkit.Env) (Config, error) {
cfg, err := LoadConfig(path)
if err != nil {
return Config{}, err
}
cfg.Normalize(env)
return cfg, cfg.Validate()
}
// Normalize 补齐 DNF 仓储装配默认值。
// ShardID 默认读取框架区服 ID,读写库名仍由 servergroup route meta 派生。
func (c *Config) Normalize(env *appkit.Env) {
if c == nil {
return
}
repo := &c.Repository
repo.Driver = strings.ToLower(strings.TrimSpace(repo.Driver))
repo.SQLitePath = strings.TrimSpace(repo.SQLitePath)
repo.MySQLDSN = strings.TrimSpace(repo.MySQLDSN)
repo.RedisAddress = strings.TrimSpace(repo.RedisAddress)
repo.RedisPassword = strings.TrimSpace(repo.RedisPassword)
repo.RedisKeyPrefix = strings.TrimSpace(repo.RedisKeyPrefix)
repo.ShardID = strings.TrimSpace(repo.ShardID)
repo.TablePrefix = strings.TrimSpace(repo.TablePrefix)
repo.ServerGroupPlanFile = strings.TrimSpace(repo.ServerGroupPlanFile)
if repo.ShardID == "" && env != nil && env.Config.Cluster.ShardID != 0 {
repo.ShardID = fmt.Sprintf("%d", env.Config.Cluster.ShardID)
}
if repo.ServerGroupPlanFile == "" && env != nil {
repo.ServerGroupPlanFile = strings.TrimSpace(env.Config.ServerGroup.PlanFile)
}
if repo.TablePrefix == "" {
repo.TablePrefix = "dnf"
}
// An omitted driver preserves every existing installation's MySQL
// behavior. New one-click templates opt in to SQLite explicitly.
if repo.Driver == "" {
repo.Driver = "mysql"
}
if repo.Driver == "sqlite" && repo.SQLitePath == "" {
repo.SQLitePath = "data/dnf90.db"
}
if repo.MySQLMaxOpenConns <= 0 {
repo.MySQLMaxOpenConns = 32
}
if repo.MySQLMaxIdleConns <= 0 {
repo.MySQLMaxIdleConns = 8
}
if repo.MySQLConnMaxLifetime <= 0 {
repo.MySQLConnMaxLifetime = 300
}
if repo.RedisEnabled {
if repo.RedisAddress == "" {
repo.RedisAddress = "127.0.0.1:6379"
}
if repo.RedisPoolSize <= 0 {
repo.RedisPoolSize = 8
}
if repo.RedisTimeoutSeconds <= 0 {
repo.RedisTimeoutSeconds = 2
}
if repo.RedisKeyPrefix == "" {
repo.RedisKeyPrefix = "dnf:repository"
if repo.ShardID != "" {
repo.RedisKeyPrefix += ":" + repo.ShardID
}
}
if repo.RedisTTLSeconds < 0 {
repo.RedisTTLSeconds = 0
}
}
}
// Validate 校验 DNF 仓储装配配置。
// 开启仓储时必须显式配置 MySQL DSN,并通过 servergroup 提供 shard 路由。
func (c Config) Validate() error {
repo := c.Repository
if !repo.Enabled {
return nil
}
switch strings.ToLower(strings.TrimSpace(repo.Driver)) {
case "mysql":
if strings.TrimSpace(repo.MySQLDSN) == "" {
return fmt.Errorf("%w: repository mysql_dsn is required", ErrConfigInvalid)
}
if strings.TrimSpace(repo.ShardID) == "" {
return fmt.Errorf("%w: repository shard_id is required", ErrConfigInvalid)
}
if repo.MySQLMaxOpenConns <= 0 || repo.MySQLMaxIdleConns <= 0 || repo.MySQLConnMaxLifetime <= 0 {
return fmt.Errorf("%w: repository mysql pool values must be positive", ErrConfigInvalid)
}
case "sqlite":
if strings.TrimSpace(repo.SQLitePath) == "" {
return fmt.Errorf("%w: repository sqlite_path is required", ErrConfigInvalid)
}
default:
return fmt.Errorf("%w: repository driver must be mysql or sqlite", ErrConfigInvalid)
}
if repo.RedisEnabled {
if strings.TrimSpace(repo.RedisAddress) == "" {
return fmt.Errorf("%w: repository redis_address is required", ErrConfigInvalid)
}
if repo.RedisDB < 0 {
return fmt.Errorf("%w: repository redis_db must be non-negative", ErrConfigInvalid)
}
if repo.RedisPoolSize <= 0 || repo.RedisTimeoutSeconds <= 0 {
return fmt.Errorf("%w: repository redis pool values must be positive", ErrConfigInvalid)
}
if strings.TrimSpace(repo.RedisKeyPrefix) == "" {
return fmt.Errorf("%w: repository redis_key_prefix is required", ErrConfigInvalid)
}
}
return nil
}
func (c RepositoryConfig) connMaxLife() time.Duration {
if c.MySQLConnMaxLifetime <= 0 {
return 5 * time.Minute
}
return time.Duration(c.MySQLConnMaxLifetime) * time.Second
}
@@ -0,0 +1,53 @@
// Package game 装配演示游戏框架的 DNF logic 组件。
// 该子包隔离 internal/services/logic 演示框架依赖,
// 生产二进制(cmd/server/dnf90 经 internal/app/dnf90)只装配仓储底座,
// 不引用本包,演示框架因此留在生产依赖闭包之外。
package game
import (
"os"
"strings"
appkit "longheng.io/server/internal/platform/app"
logicsvc "longheng.io/server/internal/services/logic"
logicdnf "longheng.io/server/internal/services/logic/dnf"
)
// Components 按外部配置构建 DNF logic 服务组件列表。
// 仓储组件排在 logic service 前启动,后续 DNF owner 可从 Runtime 读取 repository.Group。
func Components(env *appkit.Env) ([]appkit.Component, error) {
path := strings.TrimSpace(os.Getenv(logicdnf.EnvConfigPath))
if path == "" {
path = logicdnf.DefaultConfigPath
}
cfg, err := logicdnf.LoadConfigForEnv(path, env)
if err != nil {
return nil, err
}
return ComponentsWithConfig(env, cfg)
}
// ComponentsWithConfig 使用已解析配置构建 DNF logic 服务组件列表。
// 该入口用于测试和项目侧自定义配置装配。
func ComponentsWithConfig(env *appkit.Env, cfg logicdnf.Config) ([]appkit.Component, error) {
runtime, err := logicdnf.NewRuntime(env, cfg)
if err != nil {
return nil, err
}
components := make([]appkit.Component, 0, 2)
if runtime.Repository != nil {
components = append(components, runtime.Repository)
}
components = append(components, logicsvc.NewWithOptions(env, logicsvc.Options{
Game: func(e *appkit.Env) (logicsvc.GameManifest, error) {
return GameManifest(runtime, e)
},
}))
return components, nil
}
// GameManifest 返回当前 DNF logic 的业务 manifest。
// 本阶段只接入仓储底座,协议 handler 和 owner 后续在这里继续挂接。
func GameManifest(_ *logicdnf.Runtime, env *appkit.Env) (logicsvc.GameManifest, error) {
return logicsvc.BlankManifest(env)
}
@@ -0,0 +1,282 @@
package dnf
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
sqliterepo "longheng.io/server/internal/modules/dnf/repository/sqlite"
appkit "longheng.io/server/internal/platform/app"
platformdb "longheng.io/server/internal/platform/db"
"longheng.io/server/internal/platform/servergroup"
)
var openSQL = sql.Open
type Runtime struct {
Repository *RepoComponent
}
type RepoComponent struct {
component repositoryLifecycle
driver string
db *sql.DB
redis *platformdb.RedigoExecutor
cacheOpts dnfrepo.RedisCacheOptions
}
type repositoryLifecycle interface {
Name() string
Preflight(context.Context) error
Start(context.Context) error
Stop(context.Context) error
Group() (dnfrepo.Group, bool)
}
type RepoSnapshot struct {
Driver string `json:"driver"`
MySQL *mysql.ComponentSnapshot `json:"mysql,omitempty"`
SQLite *sqliterepo.Snapshot `json:"sqlite,omitempty"`
}
// NewRuntime 创建 DNF logic 运行时装配对象。
// 它只负责项目依赖装配,不注册协议 handler,也不直接修改玩家状态。
func NewRuntime(env *appkit.Env, cfg Config) (*Runtime, error) {
cfg.Normalize(env)
if err := cfg.Validate(); err != nil {
return nil, err
}
runtime := &Runtime{}
if cfg.Repository.Enabled {
component, err := newRepoComponent(env, cfg.Repository)
if err != nil {
return nil, err
}
runtime.Repository = component
}
return runtime, nil
}
// Group 返回启动后的 DNF 玩家仓储聚合。
// owner/service 只能通过该聚合访问账号、角色、背包、技能、设置和 packet 模板。
func (r *Runtime) Group() (dnfrepo.Group, bool) {
if r == nil || r.Repository == nil {
return dnfrepo.Group{}, false
}
return r.Repository.Group()
}
// NewRepoComponent 按 DNF 配置打开 MySQL 连接池并创建仓储组件。
// 连接池由返回的组件在 Stop 阶段关闭。
func NewRepoComponent(env *appkit.Env, cfg RepositoryConfig) (*RepoComponent, error) {
envCfg := Config{Repository: cfg}
envCfg.Normalize(env)
if err := envCfg.Validate(); err != nil {
return nil, err
}
cfg = envCfg.Repository
return newRepoComponent(env, cfg)
}
func newRepoComponent(env *appkit.Env, cfg RepositoryConfig) (*RepoComponent, error) {
if openSQL == nil {
return nil, fmt.Errorf("%w: sql opener is required", ErrConfigInvalid)
}
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
dataSource := cfg.MySQLDSN
if driver == "sqlite" {
dataSource = filepath.Clean(cfg.SQLitePath)
parent := filepath.Dir(dataSource)
if parent != "." && parent != "" {
if err := os.MkdirAll(parent, 0o755); err != nil {
return nil, fmt.Errorf("create sqlite repository directory: %w", err)
}
}
}
db, err := openSQL(driver, dataSource)
if err != nil {
return nil, err
}
if db == nil {
return nil, fmt.Errorf("%w: sql db is nil", ErrConfigInvalid)
}
if driver == "sqlite" {
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
} else {
db.SetMaxOpenConns(cfg.MySQLMaxOpenConns)
db.SetMaxIdleConns(cfg.MySQLMaxIdleConns)
db.SetConnMaxLifetime(cfg.connMaxLife())
}
var redisExec *platformdb.RedigoExecutor
if cfg.RedisEnabled {
redisExec = platformdb.NewRedigoExecutor(platformdb.RedigoOptions{
Address: cfg.RedisAddress,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
PoolSize: cfg.RedisPoolSize,
Timeout: cfg.redisTimeout(),
})
}
var component repositoryLifecycle
if driver == "sqlite" {
component = sqliterepo.NewComponent(db, dataSource)
} else {
var manager *servergroup.Manager
if env != nil {
manager = env.ServerGroup
}
if manager == nil && cfg.ServerGroupPlanFile != "" {
var loadErr error
manager, loadErr = loadServerGroupPlan(cfg.ServerGroupPlanFile)
if loadErr != nil {
_ = db.Close()
return nil, loadErr
}
}
component = mysql.NewComponentFromServerGroup(mysql.SQLDBAdapter{DB: db}, manager, mysql.ComponentOptions{
ShardID: cfg.ShardID,
TablePrefix: cfg.TablePrefix,
AutoCreateSchema: cfg.AutoCreateSchema,
IncludeCSharpLegacySchema: cfg.CSharpLegacySchema,
CreateDatabases: cfg.CreateDatabases,
Now: time.Now,
})
}
return &RepoComponent{
component: component,
driver: driver,
db: db,
redis: redisExec,
cacheOpts: dnfrepo.RedisCacheOptions{
KeyPrefix: cfg.RedisKeyPrefix,
TTL: cfg.redisTTL(),
},
}, nil
}
// loadServerGroupPlan 从外部区服计划文件构造只读 manager。
// DNF manifest 可能早于框架 servergroup 注入运行,仓储仍必须按同一份 route meta 派生库名。
func loadServerGroupPlan(path string) (*servergroup.Manager, error) {
store, err := servergroup.NewFileStore(path)
if err != nil {
return nil, err
}
plan, err := store.Load(context.Background())
if err != nil {
return nil, err
}
return servergroup.New(plan)
}
// Name 返回框架生命周期组件名。
func (c *RepoComponent) Name() string {
if c == nil || c.component == nil {
return "dnf-repository"
}
return c.component.Name()
}
// Preflight 检查 DNF 仓储启动依赖,不创建 schema。
func (c *RepoComponent) Preflight(ctx context.Context) error {
if c == nil || c.component == nil {
return dnfrepo.ErrRepoMissing
}
if err := c.component.Preflight(ctx); err != nil {
return err
}
return c.pingRedis(ctx)
}
// Start 启动 DNF 仓储组件。
func (c *RepoComponent) Start(ctx context.Context) error {
if c == nil || c.component == nil {
return dnfrepo.ErrRepoMissing
}
if err := c.component.Start(ctx); err != nil {
return err
}
return c.pingRedis(ctx)
}
// Stop 停止 DNF 仓储组件并关闭项目侧 SQL 连接池。
func (c *RepoComponent) Stop(ctx context.Context) error {
if c == nil {
return nil
}
var errs []error
if c.component != nil {
errs = append(errs, c.component.Stop(ctx))
}
if c.db != nil {
errs = append(errs, c.db.Close())
}
if c.redis != nil {
errs = append(errs, c.redis.Close())
}
return errors.Join(errs...)
}
// Group 返回启动后的 DNF 玩家仓储聚合。
func (c *RepoComponent) Group() (dnfrepo.Group, bool) {
if c == nil || c.component == nil {
return dnfrepo.Group{}, false
}
group, ok := c.component.Group()
if !ok {
return dnfrepo.Group{}, false
}
if c.redis != nil {
group = dnfrepo.NewCachedGroup(group, c.redis, c.cacheOpts)
}
return group, true
}
// Snapshot 返回 DNF 仓储装配状态。
func (c *RepoComponent) Snapshot() RepoSnapshot {
if c == nil || c.component == nil {
return RepoSnapshot{}
}
snapshot := RepoSnapshot{Driver: c.driver}
switch component := c.component.(type) {
case *mysql.Component:
value := component.Snapshot()
snapshot.MySQL = &value
case *sqliterepo.Component:
value := component.Snapshot()
snapshot.SQLite = &value
}
return snapshot
}
func (c *RepoComponent) pingRedis(ctx context.Context) error {
if c == nil || c.redis == nil {
return nil
}
_, err := c.redis.Do(ctx, "PING")
return err
}
func (c RepositoryConfig) redisTimeout() time.Duration {
if c.RedisTimeoutSeconds <= 0 {
return 2 * time.Second
}
return time.Duration(c.RedisTimeoutSeconds) * time.Second
}
func (c RepositoryConfig) redisTTL() time.Duration {
if c.RedisTTLSeconds <= 0 {
return 0
}
return time.Duration(c.RedisTTLSeconds) * time.Second
}
@@ -0,0 +1,60 @@
package dnf
import (
"context"
"path/filepath"
"testing"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestSQLiteRepoComponentLifecycle(t *testing.T) {
path := filepath.Join(t.TempDir(), "dnf90.db")
component, err := NewRepoComponent(nil, RepositoryConfig{
Enabled: true,
Driver: "sqlite",
SQLitePath: path,
})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := component.Preflight(ctx); err != nil {
t.Fatal(err)
}
if err := component.Start(ctx); err != nil {
t.Fatal(err)
}
group, ok := component.Group()
if !ok {
t.Fatal("SQLite repository group is unavailable")
}
if group.CharacterSettlement == nil {
t.Fatal("SQLite repository group does not expose the atomic quest/inventory settlement")
}
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if snapshot := component.Snapshot(); snapshot.Driver != "sqlite" || snapshot.SQLite == nil || snapshot.MySQL != nil {
t.Fatalf("SQLite component snapshot = %+v", snapshot)
}
if err := component.Stop(ctx); err != nil {
t.Fatal(err)
}
}
func TestRepositoryConfigKeepsLegacyMySQLDefault(t *testing.T) {
cfg := Config{Repository: RepositoryConfig{
Enabled: true,
MySQLDSN: "root:password@tcp(127.0.0.1:3306)/dnf",
ShardID: "9999",
TablePrefix: "dnf",
}}
cfg.Normalize(nil)
if cfg.Repository.Driver != "mysql" {
t.Fatalf("default repository driver = %q, want mysql", cfg.Repository.Driver)
}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
}