服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
332 lines
11 KiB
Go
332 lines
11 KiB
Go
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
|
|
}
|