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,331 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
AnnotationTagAfterTarget = "after-target"
|
||||
AnnotationTagLineEnd = "line-end"
|
||||
AnnotationTagHidden = "hidden"
|
||||
ExplorerOpenSingleClick = "single-click"
|
||||
ExplorerOpenDoubleClick = "double-click"
|
||||
ThemeDark = "dark"
|
||||
ThemeLight = "light"
|
||||
ThemeSystem = "system"
|
||||
|
||||
DefaultCacheLimitGiB = 10
|
||||
|
||||
DefaultMaxEditableSizeMB = 8
|
||||
maxEditableSizeMB = 1024
|
||||
|
||||
DefaultAutosaveIntervalSeconds = 300
|
||||
minAutosaveIntervalSeconds = 30
|
||||
maxAutosaveIntervalSeconds = 7200
|
||||
)
|
||||
|
||||
type AppSettings struct {
|
||||
// CacheLimitGiB is the soft disk budget; zero disables automatic eviction.
|
||||
CacheLimitGiB int `json:"cacheLimitGiB"`
|
||||
AnnotationTagPlacement string `json:"annotationTagPlacement"`
|
||||
ExplorerOpenMode string `json:"explorerOpenMode"`
|
||||
VimMode bool `json:"vimMode"`
|
||||
BackupSourceOnSave bool `json:"backupSourceOnSave"`
|
||||
NPKDirectory string `json:"npkDirectory"`
|
||||
Theme string `json:"theme"`
|
||||
// AutosaveEnabled turns the timed workspace snapshot on. It is off by
|
||||
// default: the snapshot rewrites a whole PVF, so the user opts in.
|
||||
AutosaveEnabled bool `json:"autosaveEnabled"`
|
||||
// AutosavePath is the single-slot snapshot file. Empty means the platform
|
||||
// cache directory resolved by DefaultAutosavePath.
|
||||
AutosavePath string `json:"autosavePath"`
|
||||
AutosaveIntervalSeconds int `json:"autosaveIntervalSeconds"`
|
||||
// ShortcutOverrides stores only bindings changed by the user. An empty
|
||||
// value explicitly disables a command's default binding.
|
||||
ShortcutOverrides map[string]string `json:"shortcutOverrides"`
|
||||
MaxEditableSizeMB int `json:"maxEditableSizeMB"`
|
||||
}
|
||||
|
||||
func DefaultAppSettings() AppSettings {
|
||||
return AppSettings{
|
||||
CacheLimitGiB: DefaultCacheLimitGiB,
|
||||
AnnotationTagPlacement: AnnotationTagAfterTarget,
|
||||
ExplorerOpenMode: ExplorerOpenSingleClick,
|
||||
VimMode: false,
|
||||
BackupSourceOnSave: true,
|
||||
NPKDirectory: "",
|
||||
Theme: ThemeDark,
|
||||
AutosaveEnabled: false,
|
||||
AutosavePath: "",
|
||||
AutosaveIntervalSeconds: DefaultAutosaveIntervalSeconds,
|
||||
ShortcutOverrides: map[string]string{},
|
||||
MaxEditableSizeMB: DefaultMaxEditableSizeMB,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAutosavePath is the backup location used when the user has not
|
||||
// configured one. It mirrors the search-index cache root so all derived state
|
||||
// of the application stays out of the game directory.
|
||||
func DefaultAutosavePath() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取用户缓存目录失败: %w", err)
|
||||
}
|
||||
return filepath.Join(cacheDir, "pvfine", "autosave.pvf"), nil
|
||||
}
|
||||
|
||||
type SettingsService struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
initErr error
|
||||
}
|
||||
|
||||
func NewSettingsService() *SettingsService {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return &SettingsService{initErr: fmt.Errorf("获取用户配置目录失败: %w", err)}
|
||||
}
|
||||
return newSettingsService(filepath.Join(configDir, "pvfine", "settings.json"))
|
||||
}
|
||||
|
||||
func newSettingsService(path string) *SettingsService {
|
||||
return &SettingsService{path: path}
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetSettings() (AppSettings, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.getSettingsLocked()
|
||||
}
|
||||
|
||||
func (s *SettingsService) getSettingsLocked() (AppSettings, error) {
|
||||
if s.initErr != nil {
|
||||
return AppSettings{}, s.initErr
|
||||
}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultAppSettings(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return AppSettings{}, fmt.Errorf("读取设置失败: %w", err)
|
||||
}
|
||||
settings := DefaultAppSettings()
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return AppSettings{}, fmt.Errorf("解析设置失败: %w", err)
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
if err := validateSettings(settings); err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) SaveSettings(settings AppSettings) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err := s.saveSettingsLocked(settings)
|
||||
if err == nil {
|
||||
requestCacheMaintenance()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SettingsService) saveSettingsLocked(settings AppSettings) error {
|
||||
if s.initErr != nil {
|
||||
return s.initErr
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
if err := validateSettings(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("创建设置目录失败: %w", err)
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(s.path), ".settings-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建设置临时文件失败: %w", err)
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("写入设置失败: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("同步设置失败: %w", err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭设置文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, s.path); err != nil {
|
||||
return fmt.Errorf("替换设置文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateNPKDirectory changes only the image resource directory while
|
||||
// preserving settings added by newer versions of the application.
|
||||
func (s *SettingsService) UpdateNPKDirectory(directory string) (AppSettings, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
settings, err := s.getSettingsLocked()
|
||||
if err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
settings.NPKDirectory = directory
|
||||
if err := s.saveSettingsLocked(settings); err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// UpdateShortcutOverrides updates shortcuts without replacing settings that
|
||||
// may have changed in another window since the caller last loaded them.
|
||||
func (s *SettingsService) UpdateShortcutOverrides(overrides map[string]string) (AppSettings, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
settings, err := s.getSettingsLocked()
|
||||
if err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
settings.ShortcutOverrides = overrides
|
||||
if err := s.saveSettingsLocked(settings); err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// normalizeSettings trims typed paths and fills in the autosave interval for
|
||||
// callers that never set it (legacy settings files, partial API payloads).
|
||||
func normalizeSettings(settings AppSettings) AppSettings {
|
||||
settings.AutosavePath = strings.TrimSpace(settings.AutosavePath)
|
||||
if settings.AutosaveIntervalSeconds <= 0 {
|
||||
settings.AutosaveIntervalSeconds = DefaultAutosaveIntervalSeconds
|
||||
}
|
||||
if settings.MaxEditableSizeMB == 0 {
|
||||
settings.MaxEditableSizeMB = DefaultMaxEditableSizeMB
|
||||
}
|
||||
if settings.ShortcutOverrides == nil {
|
||||
settings.ShortcutOverrides = map[string]string{}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func validateSettings(settings AppSettings) error {
|
||||
if settings.CacheLimitGiB < 0 || settings.CacheLimitGiB > 10240 {
|
||||
return fmt.Errorf("缓存上限需在 0-10240 GiB 之间(0 表示不限制)")
|
||||
}
|
||||
if settings.MaxEditableSizeMB < 1 || settings.MaxEditableSizeMB > maxEditableSizeMB {
|
||||
return fmt.Errorf("大文件编辑阈值需在 1-%d MB 之间", maxEditableSizeMB)
|
||||
}
|
||||
if len(settings.ShortcutOverrides) > 64 {
|
||||
return fmt.Errorf("快捷键配置数量超出限制")
|
||||
}
|
||||
used := make(map[string]string, len(settings.ShortcutOverrides))
|
||||
for command, binding := range settings.ShortcutOverrides {
|
||||
if len(command) == 0 || len(command) > 64 || len(binding) > 80 {
|
||||
return fmt.Errorf("无效的快捷键配置")
|
||||
}
|
||||
if binding != "" {
|
||||
if err := validateShortcutBinding(binding); err != nil {
|
||||
return fmt.Errorf("命令 %q: %w", command, err)
|
||||
}
|
||||
if previous, ok := used[binding]; ok {
|
||||
return fmt.Errorf("快捷键 %q 同时分配给 %q 和 %q", binding, previous, command)
|
||||
}
|
||||
used[binding] = command
|
||||
}
|
||||
}
|
||||
switch settings.AnnotationTagPlacement {
|
||||
case AnnotationTagAfterTarget, AnnotationTagLineEnd, AnnotationTagHidden:
|
||||
default:
|
||||
return fmt.Errorf("无效的标注 Tag 显示位置: %q", settings.AnnotationTagPlacement)
|
||||
}
|
||||
switch settings.ExplorerOpenMode {
|
||||
case ExplorerOpenSingleClick, ExplorerOpenDoubleClick:
|
||||
// Keep theme validation beside the other persisted enum settings so
|
||||
// invalid values cannot be written to the user configuration.
|
||||
default:
|
||||
return fmt.Errorf("无效的资源管理器打开方式: %q", settings.ExplorerOpenMode)
|
||||
}
|
||||
switch settings.Theme {
|
||||
case ThemeDark, ThemeLight, ThemeSystem:
|
||||
default:
|
||||
return fmt.Errorf("无效的主题: %q", settings.Theme)
|
||||
}
|
||||
if settings.AutosaveIntervalSeconds < minAutosaveIntervalSeconds ||
|
||||
settings.AutosaveIntervalSeconds > maxAutosaveIntervalSeconds {
|
||||
return fmt.Errorf(
|
||||
"定时缓存间隔需在 %d-%d 秒之间: %d",
|
||||
minAutosaveIntervalSeconds, maxAutosaveIntervalSeconds, settings.AutosaveIntervalSeconds,
|
||||
)
|
||||
}
|
||||
if settings.AutosavePath != "" && !strings.EqualFold(filepath.Ext(settings.AutosavePath), ".pvf") {
|
||||
return fmt.Errorf("定时缓存路径需要以 .pvf 结尾: %q", settings.AutosavePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateShortcutBinding(binding string) error {
|
||||
parts := strings.Split(binding, "+")
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("无效的快捷键: %q", binding)
|
||||
}
|
||||
modifiers := map[string]bool{}
|
||||
order := []string{"Mod", "Ctrl", "Meta", "Alt", "Shift"}
|
||||
last := -1
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
index := -1
|
||||
for i, modifier := range order {
|
||||
if modifier == part {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index <= last || modifiers[part] {
|
||||
return fmt.Errorf("无效的快捷键: %q", binding)
|
||||
}
|
||||
modifiers[part] = true
|
||||
last = index
|
||||
}
|
||||
code := parts[len(parts)-1]
|
||||
validCode := false
|
||||
if len(code) == 4 && strings.HasPrefix(code, "Key") && code[3] >= 'A' && code[3] <= 'Z' {
|
||||
validCode = true
|
||||
} else if len(code) == 6 && strings.HasPrefix(code, "Digit") && code[5] >= '0' && code[5] <= '9' {
|
||||
validCode = true
|
||||
} else {
|
||||
for _, allowed := range []string{"Enter", "Backslash", "Slash", "BracketLeft", "BracketRight", "Minus", "Equal", "Comma", "Period", "Semicolon", "Quote", "Backquote", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Insert", "Delete", "Backspace", "Space", "Tab", "Escape", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"} {
|
||||
if code == allowed {
|
||||
validCode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
functionKey := len(code) >= 2 && code[0] == 'F' && code[1] >= '1' && code[1] <= '9'
|
||||
hasNonShiftModifier := false
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
if part != "Shift" {
|
||||
hasNonShiftModifier = true
|
||||
}
|
||||
}
|
||||
if !validCode || code == "Tab" || code == "Escape" || (!functionKey && !hasNonShiftModifier) {
|
||||
return fmt.Errorf("无效的快捷键: %q", binding)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user