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

388 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package player
import (
"context"
"errors"
"fmt"
"hash/crc32"
"math"
"sort"
"strconv"
"strings"
"time"
)
// ErrMySQLShardConfig 表示 MySQL 玩家 Profile 分片配置不合法。
var ErrMySQLShardConfig = errors.New("mysql profile shard config is invalid")
const mysqlShardSlots = 1024
// MySQLShardOptions 描述单个 MySQL 玩家 Profile 分片的连接和槽位配置。
type MySQLShardOptions struct {
ID string
DSN string
TableName string
TablePrefix string
HashSlots string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
EnsureSchema bool
}
// ShardedMySQLStoreOptions 是分片 MySQL 玩家 Profile 存储的全局默认值和分片清单。
type ShardedMySQLStoreOptions struct {
DefaultDSN string
DefaultMaxOpenConns int
DefaultMaxIdleConns int
DefaultConnMaxLifetime time.Duration
EnsureSchema bool
Shards []MySQLShardOptions
}
// ShardedMySQLStore 按账号 ID 路由到多个 MySQL 玩家 Profile 存储。
type ShardedMySQLStore struct {
shards map[string]*MySQLStore
order []string
slotOwners []string
}
// NewShardedMySQLStore 创建分片 MySQL 玩家 Profile 存储。
func NewShardedMySQLStore(options ShardedMySQLStoreOptions) (*ShardedMySQLStore, error) {
if len(options.Shards) == 0 {
return nil, fmt.Errorf("%w: at least one mysql shard is required", ErrMySQLShardConfig)
}
out := &ShardedMySQLStore{
shards: make(map[string]*MySQLStore, len(options.Shards)),
}
normalizedShards := make([]MySQLShardOptions, 0, len(options.Shards))
for idx, shard := range options.Shards {
normalized, err := normMySQLShardOpts(options, shard, idx)
if err != nil {
return nil, err
}
if _, exists := out.shards[normalized.ID]; exists {
return nil, fmt.Errorf("%w: duplicated shard id %q", ErrMySQLShardConfig, normalized.ID)
}
normalizedShards = append(normalizedShards, normalized)
out.shards[normalized.ID] = NewMySQLStore(MySQLStoreOptions{
DSN: normalized.DSN,
TableName: normalized.TableName,
MaxOpenConns: normalized.MaxOpenConns,
MaxIdleConns: normalized.MaxIdleConns,
ConnMaxLifetime: normalized.ConnMaxLifetime,
EnsureSchema: normalized.EnsureSchema,
})
out.order = append(out.order, normalized.ID)
}
sort.Strings(out.order)
slotOwners, err := buildShardOwners(normalizedShards)
if err != nil {
_ = out.Close(context.Background())
return nil, err
}
out.slotOwners = slotOwners
return out, nil
}
func normMySQLShardOpts(defaults ShardedMySQLStoreOptions, shard MySQLShardOptions, idx int) (MySQLShardOptions, error) {
shard.ID = strings.TrimSpace(shard.ID)
if shard.ID == "" {
shard.ID = fmt.Sprintf("shard_%d", idx)
}
if !isSafeProfileShardID(shard.ID) {
return MySQLShardOptions{}, fmt.Errorf("%w: shard id %q can only contain letters, digits, dot, dash, or underscore", ErrMySQLShardConfig, shard.ID)
}
shard.DSN = strings.TrimSpace(shard.DSN)
if shard.DSN == "" {
shard.DSN = strings.TrimSpace(defaults.DefaultDSN)
}
if shard.DSN == "" {
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q dsn is required", ErrMySQLShardConfig, shard.ID)
}
shard.TableName = strings.TrimSpace(shard.TableName)
shard.TablePrefix = strings.TrimSpace(shard.TablePrefix)
shard.HashSlots = strings.TrimSpace(shard.HashSlots)
if shard.TableName == "" && shard.TablePrefix != "" {
shard.TableName = shard.TablePrefix + "_" + mysqlProfileTable
}
if shard.TableName == "" {
shard.TableName = mysqlProfileTable
}
if !isMySQLProfileID(shard.TableName) {
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q table name %q must be a sql identifier", ErrMySQLShardConfig, shard.ID, shard.TableName)
}
if shard.MaxOpenConns <= 0 {
shard.MaxOpenConns = defaults.DefaultMaxOpenConns
}
if shard.MaxIdleConns <= 0 {
shard.MaxIdleConns = defaults.DefaultMaxIdleConns
}
if shard.ConnMaxLifetime <= 0 {
shard.ConnMaxLifetime = defaults.DefaultConnMaxLifetime
}
shard.EnsureSchema = shard.EnsureSchema || defaults.EnsureSchema
return shard, nil
}
func buildShardOwners(shards []MySQLShardOptions) ([]string, error) {
useSlots := false
for _, shard := range shards {
if strings.TrimSpace(shard.HashSlots) != "" {
useSlots = true
break
}
}
if !useSlots {
return nil, nil
}
owners := make([]string, mysqlShardSlots)
for _, shard := range shards {
if strings.TrimSpace(shard.HashSlots) == "" {
return nil, fmt.Errorf("%w: shard %q hash_slots is required when any shard uses slot routing", ErrMySQLShardConfig, shard.ID)
}
ranges, err := parseShardSlots(shard.HashSlots)
if err != nil {
return nil, fmt.Errorf("%w: shard %q %w", ErrMySQLShardConfig, shard.ID, err)
}
for _, item := range ranges {
for slot := item.start; slot <= item.end; slot++ {
if owners[slot] != "" {
return nil, fmt.Errorf("%w: hash slot %d is assigned to both %q and %q", ErrMySQLShardConfig, slot, owners[slot], shard.ID)
}
owners[slot] = shard.ID
}
}
}
for slot, owner := range owners {
if owner == "" {
return nil, fmt.Errorf("%w: hash slot %d is not assigned to any shard", ErrMySQLShardConfig, slot)
}
}
return owners, nil
}
type mysqlShardSlotRange struct {
start int
end int
}
func parseShardSlots(value string) ([]mysqlShardSlotRange, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("hash_slots is required")
}
parts := strings.Split(value, ",")
out := make([]mysqlShardSlotRange, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("hash_slots contains empty range")
}
bounds := strings.Split(part, "-")
if len(bounds) > 2 {
return nil, fmt.Errorf("hash slot range %q is invalid", part)
}
start, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
if err != nil {
return nil, fmt.Errorf("hash slot range %q has invalid start", part)
}
end := start
if len(bounds) == 2 {
end, err = strconv.Atoi(strings.TrimSpace(bounds[1]))
if err != nil {
return nil, fmt.Errorf("hash slot range %q has invalid end", part)
}
}
if start < 0 || end < start || end >= mysqlShardSlots {
return nil, fmt.Errorf("hash slot range %q must be within 0-%d", part, mysqlShardSlots-1)
}
out = append(out, mysqlShardSlotRange{start: start, end: end})
}
return out, nil
}
func isSafeProfileShardID(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for _, r := range value {
if r == '.' || r == '-' || r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return !strings.HasPrefix(value, ".") && !strings.HasSuffix(value, ".") && !strings.Contains(value, "..")
}
func isMySQLProfileID(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for idx, r := range value {
if idx == 0 {
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
continue
}
return false
}
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
// Load 按账号 ID 路由到对应 MySQL 分片读取玩家 Profile。
func (s *ShardedMySQLStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
store, _, err := s.storeForAccount(accountID)
if err != nil {
return Profile{}, false, err
}
return store.Load(ctx, accountID)
}
// Save 按账号 ID 路由到对应 MySQL 分片保存完整玩家 Profile。
func (s *ShardedMySQLStore) Save(ctx context.Context, profile Profile) error {
return s.SaveFields(ctx, profile, AllProfileFields()...)
}
// SaveFields 按账号 ID 路由到对应 MySQL 分片保存指定玩家字段。
func (s *ShardedMySQLStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
profile, err := normMySQLIdentity(profile)
if err != nil {
return err
}
store, _, err := s.storeForAccount(profile.AccountID)
if err != nil {
return err
}
return store.SaveFields(ctx, profile, fields...)
}
// LoadByRoleID 遍历 MySQL 分片按角色 ID 查找玩家 Profile。
func (s *ShardedMySQLStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return Profile{}, false, err
}
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return Profile{}, false, fmt.Errorf("role id is required")
}
for _, shardID := range s.order {
profile, ok, err := s.shards[shardID].LoadByRoleID(ctx, roleID)
if err != nil {
return Profile{}, false, err
}
if ok {
return profile, true, nil
}
}
return Profile{}, false, nil
}
// ListProfiles 从所有 MySQL 分片按账号游标合并扫描玩家 Profile。
func (s *ShardedMySQLStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) ([]Profile, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
if limit <= 0 {
return nil, nil
}
afterAccountID = strings.TrimSpace(afterAccountID)
out := make([]Profile, 0, limit)
for _, shardID := range s.order {
items, err := s.shards[shardID].ListProfiles(ctx, afterAccountID, limit)
if err != nil {
return nil, err
}
out = append(out, items...)
}
sort.Slice(out, func(i, j int) bool {
return out[i].AccountID < out[j].AccountID
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// Check 检查全部 MySQL 玩家 Profile 分片。
func (s *ShardedMySQLStore) Check(ctx context.Context) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
for _, shardID := range s.order {
if err := s.shards[shardID].Check(ctx); err != nil {
return fmt.Errorf("check mysql profile shard %s: %w", shardID, err)
}
}
return nil
}
// Close 关闭全部 MySQL 玩家 Profile 分片连接。
func (s *ShardedMySQLStore) Close(ctx context.Context) error {
if s == nil {
return nil
}
var errs []error
for _, shardID := range s.order {
if err := s.shards[shardID].Close(ctx); err != nil {
errs = append(errs, fmt.Errorf("close mysql profile shard %s: %w", shardID, err))
}
}
return errors.Join(errs...)
}
// ResolveShardID 按账号 ID 解析目标 MySQL 分片 ID。
func (s *ShardedMySQLStore) ResolveShardID(accountID string) (string, error) {
if s == nil || len(s.order) == 0 {
return "", fmt.Errorf("%w: no mysql profile shard is configured", ErrMySQLShardConfig)
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return "", fmt.Errorf("account id is required")
}
if len(s.order) == 1 {
return s.order[0], nil
}
checksum := crc32.ChecksumIEEE([]byte(accountID))
if len(s.slotOwners) == mysqlShardSlots {
slot := int(checksum % mysqlShardSlots)
return s.slotOwners[slot], nil
}
if len(s.order) > math.MaxUint32 {
return "", fmt.Errorf("%w: mysql profile shard count exceeds uint32 range", ErrMySQLShardConfig)
}
shardIndex := int(checksum % uint32(len(s.order))) //nolint:gosec // G115:len 已先校验不超过 uint32,取模结果可安全转回 slice 下标。
return s.order[shardIndex], nil
}
// TableNames 返回每个 MySQL 分片对应的 Profile 表名。
func (s *ShardedMySQLStore) TableNames() map[string]string {
out := make(map[string]string, len(s.shards))
for _, shardID := range s.order {
out[shardID] = s.shards[shardID].TableName()
}
return out
}
func (s *ShardedMySQLStore) storeForAccount(accountID string) (*MySQLStore, string, error) {
shardID, err := s.ResolveShardID(accountID)
if err != nil {
return nil, "", err
}
store := s.shards[shardID]
if store == nil {
return nil, "", fmt.Errorf("%w: shard %q store is missing", ErrMySQLShardConfig, shardID)
}
return store, shardID, nil
}