Files
galaxy-game/pkg/storage/fs/fs.go
T
Ilia Denisov a7793f5416 ui calculator
2026-03-30 19:38:24 +02:00

751 lines
20 KiB
Go

// Package fs implements galaxy/storage.Storage using the filesystem.
package fs
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
gerr "galaxy/error"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
"galaxy/util"
)
const (
// stateFileName is the file name under the storage root where [client.State] is stored.
stateFileName = "state.dat"
// gameDataFileSuffix is the extension for per-turn [client.GameData] files.
gameDataFileSuffix = ".dat"
defaultFilePerm = 0o644
oldFileSuffix = ".old"
newFileSuffix = ".new"
)
// StateFilePath returns the path to the persisted [client.State] file under root.
func StateFilePath(root string) string {
return filepath.Join(root, stateFileName)
}
// GameDataFilePath returns the legacy per-game data file path under root.
//
// The storage implementation keeps turn data in per-turn files under a game directory
// and does not use this helper internally.
func GameDataFilePath(root string, id fmt.Stringer) string {
return filepath.Join(root, id.String()) + gameDataFileSuffix
}
type pathLock struct {
mu sync.Mutex
refs int
}
type fsStorage struct {
storageRoot string
locksMu sync.Mutex
locks map[string]*pathLock
readFileFn func(string) ([]byte, error)
writeFileFn func(string, []byte, os.FileMode) error
renameFileFn func(string, string) error
removeFileFn func(string) error
}
type storedOrder struct {
UpdatedAt int `json:"updatedAt"`
Commands []json.RawMessage `json:"cmd"`
}
type storedGameData struct {
Turn uint `json:"turn"`
Report report.Report `json:"report"`
Order *storedOrder `json:"order,omitempty"`
}
// NewFS returns a filesystem-backed implementation of galaxy/storage.Storage rooted at storageRoot.
// storageRoot must already exist, be a directory, and be writable by the current user.
func NewFS(storageRoot string) (*fsStorage, error) {
fmt.Println("using fs root:", storageRoot)
absRoot, err := filepath.Abs(storageRoot)
if err != nil {
return nil, classifyStorageError(fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err))
}
if ok, err := util.PathExists(absRoot, true); err != nil {
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err))
} else if !ok {
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q does not exist", absRoot))
}
if ok, err := util.Writable(absRoot); err != nil {
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err))
} else if !ok {
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q is not writable", absRoot))
}
return &fsStorage{
storageRoot: absRoot,
locks: make(map[string]*pathLock),
readFileFn: os.ReadFile,
writeFileFn: os.WriteFile,
renameFileFn: os.Rename,
removeFileFn: os.Remove,
}, nil
}
func (s *fsStorage) StateExistsAsync(callback func(bool, error)) {
go func() {
exists, err := s.StateExists()
if callback != nil {
callback(exists, err)
}
}()
}
func (s *fsStorage) LoadStateAsync(callback func(client.State, error)) {
go func() {
state, err := s.LoadState()
if callback != nil {
callback(state, err)
}
}()
}
func (s *fsStorage) SaveStateAsync(state client.State, callback func(error)) {
go func() {
err := s.SaveState(state)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) ReportExistsAsync(id client.GameID, turn uint, callback func(bool, error)) {
go func() {
exists, err := s.gameDataExistsSync(id, turn)
if callback != nil {
callback(exists, err)
}
}()
}
func (s *fsStorage) LoadReportAsync(id client.GameID, turn uint, callback func(report.Report, error)) {
go func() {
rep, err := s.loadReportSync(id, turn)
if callback != nil {
callback(rep, err)
}
}()
}
func (s *fsStorage) SaveReportAsync(id client.GameID, turn uint, rep report.Report, callback func(error)) {
go func() {
err := s.saveReportSync(id, turn, rep)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) LoadOrderAsync(id client.GameID, turn uint, callback func(order.Order, error)) {
go func() {
o, err := s.loadOrderSync(id, turn)
if callback != nil {
callback(o, err)
}
}()
}
func (s *fsStorage) SaveOrderAsync(id client.GameID, turn uint, o order.Order, callback func(error)) {
go func() {
err := s.saveOrderSync(id, turn, o)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) FileExists(path string) (bool, string, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return false, "", classifyStorageError(err)
}
var exists bool
err = s.withPathLock(absPath, func() error {
var opErr error
exists, opErr = s.fileExistsUnlocked(absPath)
return opErr
})
if err != nil {
return false, "", classifyStorageError(err)
}
if !exists {
return false, absPath, nil
}
return true, absPath, nil
}
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return nil, classifyStorageError(err)
}
var data []byte
err = s.withPathLock(absPath, func() error {
var opErr error
data, opErr = s.readFileUnlocked(absPath)
return opErr
})
return data, classifyStorageError(err)
}
func (s *fsStorage) WriteFile(path string, data []byte) error {
absPath, err := s.resolvePath(path)
if err != nil {
return classifyStorageError(err)
}
return classifyStorageError(s.withPathLock(absPath, func() error {
return s.writeFileUnlocked(absPath, data)
}))
}
func (s *fsStorage) DeleteFile(path string) error {
absPath, err := s.resolvePath(path)
if err != nil {
return classifyStorageError(err)
}
return classifyStorageError(s.withPathLock(absPath, func() error {
exists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("delete file %q: %w", absPath, os.ErrNotExist)
}
if err := s.removeFileFn(absPath); err != nil {
return fmt.Errorf("delete file %q: %w", absPath, err)
}
return nil
}))
}
func (s *fsStorage) ListFiles() ([]string, error) {
files := make([]string, 0)
err := filepath.WalkDir(s.storageRoot, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), newFileSuffix) || strings.HasSuffix(d.Name(), oldFileSuffix) {
return nil
}
relPath, err := filepath.Rel(s.storageRoot, path)
if err != nil {
return fmt.Errorf("resolve relative path for %q: %w", path, err)
}
files = append(files, filepath.Clean(relPath))
return nil
})
if err != nil {
return nil, classifyStorageError(fmt.Errorf("list files under %q: %w", s.storageRoot, err))
}
slices.Sort(files)
return files, nil
}
func (s *fsStorage) StateExists() (bool, error) {
exists, _, err := s.FileExists(stateFileName)
return exists, classifyStorageError(err)
}
func (s *fsStorage) LoadState() (client.State, error) {
data, err := s.ReadFile(stateFileName)
if err != nil {
return client.State{}, classifyStorageError(err)
}
state, err := unmarshalState(data)
return state, classifyStorageError(err)
}
func (s *fsStorage) SaveState(state client.State) error {
data, err := marshalState(state)
if err != nil {
return classifyStorageError(err)
}
return classifyStorageError(s.WriteFile(stateFileName, data))
}
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return report.Report{}, classifyStorageError(err)
}
return gameData.Report, nil
}
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return classifyStorageError(err)
}
return classifyStorageError(s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
gameData = client.GameData{Turn: turn}
}
gameData.Turn = turn
gameData.Report = rep
return s.writeGameDataUnlocked(absPath, gameData)
}))
}
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return order.Order{}, classifyStorageError(err)
}
if gameData.Order == nil {
return order.Order{}, classifyStorageError(fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist))
}
return *gameData.Order, nil
}
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return classifyStorageError(err)
}
return classifyStorageError(s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("save order for game %q turn %d: %w", id, turn, os.ErrNotExist)
}
return err
}
gameData.Turn = turn
gameData.Order = &o
return s.writeGameDataUnlocked(absPath, gameData)
}))
}
func (s *fsStorage) gameDataExistsSync(id client.GameID, turn uint) (bool, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return false, classifyStorageError(err)
}
exists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return false, classifyStorageError(err)
}
return exists, nil
}
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return client.GameData{}, classifyStorageError(err)
}
var gameData client.GameData
err = s.withPathLock(absPath, func() error {
var opErr error
gameData, opErr = s.loadGameDataUnlocked(absPath)
return opErr
})
return gameData, classifyStorageError(err)
}
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
data, err := s.readFileUnlocked(absPath)
if err != nil {
return client.GameData{}, err
}
return unmarshalGameData(data)
}
func (s *fsStorage) writeGameDataUnlocked(absPath string, gameData client.GameData) error {
data, err := marshalGameData(gameData)
if err != nil {
return err
}
return s.writeFileUnlocked(absPath, data)
}
func marshalState(state client.State) ([]byte, error) {
return marshalJSON(state)
}
func unmarshalState(data []byte) (client.State, error) {
var state client.State
if err := unmarshalJSON(data, &state); err != nil {
return client.State{}, err
}
return state, nil
}
func marshalGameData(gameData client.GameData) ([]byte, error) {
stored, err := makeStoredGameData(gameData)
if err != nil {
return nil, err
}
return marshalJSON(stored)
}
func unmarshalGameData(data []byte) (client.GameData, error) {
var stored storedGameData
if err := unmarshalJSON(data, &stored); err != nil {
return client.GameData{}, err
}
return stored.toGameData()
}
func marshalJSON(value any) ([]byte, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("marshal json: %w", err)
}
return data, nil
}
func unmarshalJSON(data []byte, target any) error {
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("unmarshal json: %w", err)
}
return nil
}
func makeStoredGameData(gameData client.GameData) (storedGameData, error) {
stored := storedGameData{
Turn: gameData.Turn,
Report: gameData.Report,
}
if gameData.Order == nil {
return stored, nil
}
storedOrder, err := makeStoredOrder(*gameData.Order)
if err != nil {
return storedGameData{}, err
}
stored.Order = &storedOrder
return stored, nil
}
func (d storedGameData) toGameData() (client.GameData, error) {
gameData := client.GameData{
Turn: d.Turn,
Report: d.Report,
}
if d.Order == nil {
return gameData, nil
}
o, err := d.Order.toOrder()
if err != nil {
return client.GameData{}, err
}
gameData.Order = o
return gameData, nil
}
func makeStoredOrder(o order.Order) (storedOrder, error) {
result := storedOrder{
UpdatedAt: o.UpdatedAt,
Commands: make([]json.RawMessage, len(o.Commands)),
}
for i := range o.Commands {
data, err := marshalJSON(o.Commands[i])
if err != nil {
return storedOrder{}, fmt.Errorf("marshal order command %d: %w", i, err)
}
result.Commands[i] = data
}
return result, nil
}
func (o *storedOrder) toOrder() (*order.Order, error) {
if o == nil {
return nil, nil
}
result := &order.Order{
UpdatedAt: o.UpdatedAt,
Commands: make([]order.DecodableCommand, len(o.Commands)),
}
for i := range o.Commands {
cmd, err := parseOrderCommand(o.Commands[i])
if err != nil {
return nil, fmt.Errorf("decode order command %d: %w", i, err)
}
result.Commands[i] = cmd
}
return result, nil
}
func parseOrderCommand(data json.RawMessage) (order.DecodableCommand, error) {
meta := new(order.CommandMeta)
if err := json.Unmarshal(data, meta); err != nil {
return nil, fmt.Errorf("decode order command metadata: %w", err)
}
switch meta.CmdType {
case order.CommandTypeRaceQuit:
return decodeOrderCommand(data, new(order.CommandRaceQuit))
case order.CommandTypeRaceVote:
return decodeOrderCommand(data, new(order.CommandRaceVote))
case order.CommandTypeRaceRelation:
return decodeOrderCommand(data, new(order.CommandRaceRelation))
case order.CommandTypeShipClassCreate:
return decodeOrderCommand(data, new(order.CommandShipClassCreate))
case order.CommandTypeShipClassMerge:
return decodeOrderCommand(data, new(order.CommandShipClassMerge))
case order.CommandTypeShipClassRemove:
return decodeOrderCommand(data, new(order.CommandShipClassRemove))
case order.CommandTypeShipGroupBreak:
return decodeOrderCommand(data, new(order.CommandShipGroupBreak))
case order.CommandTypeShipGroupLoad:
return decodeOrderCommand(data, new(order.CommandShipGroupLoad))
case order.CommandTypeShipGroupUnload:
return decodeOrderCommand(data, new(order.CommandShipGroupUnload))
case order.CommandTypeShipGroupSend:
return decodeOrderCommand(data, new(order.CommandShipGroupSend))
case order.CommandTypeShipGroupUpgrade:
return decodeOrderCommand(data, new(order.CommandShipGroupUpgrade))
case order.CommandTypeShipGroupMerge:
return decodeOrderCommand(data, new(order.CommandShipGroupMerge))
case order.CommandTypeShipGroupDismantle:
return decodeOrderCommand(data, new(order.CommandShipGroupDismantle))
case order.CommandTypeShipGroupTransfer:
return decodeOrderCommand(data, new(order.CommandShipGroupTransfer))
case order.CommandTypeShipGroupJoinFleet:
return decodeOrderCommand(data, new(order.CommandShipGroupJoinFleet))
case order.CommandTypeFleetMerge:
return decodeOrderCommand(data, new(order.CommandFleetMerge))
case order.CommandTypeFleetSend:
return decodeOrderCommand(data, new(order.CommandFleetSend))
case order.CommandTypeScienceCreate:
return decodeOrderCommand(data, new(order.CommandScienceCreate))
case order.CommandTypeScienceRemove:
return decodeOrderCommand(data, new(order.CommandScienceRemove))
case order.CommandTypePlanetRename:
return decodeOrderCommand(data, new(order.CommandPlanetRename))
case order.CommandTypePlanetProduce:
return decodeOrderCommand(data, new(order.CommandPlanetProduce))
case order.CommandTypePlanetRouteSet:
return decodeOrderCommand(data, new(order.CommandPlanetRouteSet))
case order.CommandTypePlanetRouteRemove:
return decodeOrderCommand(data, new(order.CommandPlanetRouteRemove))
default:
return nil, fmt.Errorf("unknown order command type %q", meta.CmdType)
}
}
func decodeOrderCommand[T order.DecodableCommand](data json.RawMessage, target T) (T, error) {
if err := json.Unmarshal(data, target); err != nil {
return target, err
}
return target, nil
}
func (s *fsStorage) resolvePath(path string) (string, error) {
relPath, err := normalizeRelativePath(path)
if err != nil {
return "", err
}
return filepath.Join(s.storageRoot, relPath), nil
}
func normalizeRelativePath(path string) (string, error) {
path = strings.ReplaceAll(path, "\\", string(filepath.Separator))
path = strings.TrimLeft(path, string(filepath.Separator))
path = filepath.Clean(path)
switch {
case path == "." || path == "":
return "", errors.New("path must not be empty")
case filepath.IsAbs(path):
return "", fmt.Errorf("path %q must be relative", path)
case filepath.VolumeName(path) != "":
return "", fmt.Errorf("path %q must not include a volume name", path)
case path == "..":
return "", fmt.Errorf("path %q escapes storage root", path)
case strings.HasPrefix(path, ".."+string(filepath.Separator)):
return "", fmt.Errorf("path %q escapes storage root", path)
}
return path, nil
}
func gameTurnFilePath(id fmt.Stringer, turn uint) string {
return filepath.Join(id.String(), strconv.FormatUint(uint64(turn), 10)+gameDataFileSuffix)
}
func (s *fsStorage) withPathLock(absPath string, fn func() error) error {
lock := s.acquirePathLock(absPath)
defer s.releasePathLock(absPath)
lock.mu.Lock()
defer lock.mu.Unlock()
return fn()
}
func (s *fsStorage) acquirePathLock(absPath string) *pathLock {
s.locksMu.Lock()
defer s.locksMu.Unlock()
lock, ok := s.locks[absPath]
if !ok {
lock = &pathLock{}
s.locks[absPath] = lock
}
lock.refs++
return lock
}
func (s *fsStorage) releasePathLock(absPath string) {
s.locksMu.Lock()
defer s.locksMu.Unlock()
lock, ok := s.locks[absPath]
if !ok {
return
}
lock.refs--
if lock.refs == 0 {
delete(s.locks, absPath)
}
}
func (s *fsStorage) fileExistsUnlocked(absPath string) (bool, error) {
ok, err := util.FileExists(absPath)
if err != nil {
return false, fmt.Errorf("check file %q exists: %w", absPath, err)
}
return ok, nil
}
func (s *fsStorage) readFileUnlocked(absPath string) ([]byte, error) {
data, err := s.readFileFn(absPath)
if err != nil {
return nil, fmt.Errorf("read file %q: %w", absPath, err)
}
return data, nil
}
func (s *fsStorage) writeFileUnlocked(absPath string, data []byte) error {
if err := s.ensureParentDir(absPath); err != nil {
return err
}
targetExists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return err
}
oldPath := absPath + oldFileSuffix
oldExists, err := s.fileExistsUnlocked(oldPath)
if err != nil {
return err
}
if oldExists {
return fmt.Errorf("write file %q: old file already exists at %q", absPath, oldPath)
}
newPath := absPath + newFileSuffix
newExists, err := s.fileExistsUnlocked(newPath)
if err != nil {
return err
}
if newExists {
return fmt.Errorf("write file %q: new file already exists at %q", absPath, newPath)
}
if err := s.writeFileFn(newPath, data, defaultFilePerm); err != nil {
return fmt.Errorf("write new file %q: %w", newPath, err)
}
if targetExists {
if err := s.renameFileFn(absPath, oldPath); err != nil {
return errors.Join(
fmt.Errorf("rename file %q to %q: %w", absPath, oldPath, err),
s.cleanupTempFile(newPath),
)
}
}
if err := s.renameFileFn(newPath, absPath); err != nil {
var restoreErr error
if targetExists {
restoreErr = s.renameFileFn(oldPath, absPath)
if restoreErr != nil {
restoreErr = fmt.Errorf("restore file %q from %q: %w", absPath, oldPath, restoreErr)
}
}
return errors.Join(
fmt.Errorf("rename new file %q to %q: %w", newPath, absPath, err),
restoreErr,
s.cleanupTempFile(newPath),
)
}
if !targetExists {
return nil
}
if err := s.removeFileFn(oldPath); err != nil {
return fmt.Errorf("remove old file %q: %w", oldPath, err)
}
return nil
}
func (s *fsStorage) cleanupTempFile(path string) error {
if err := s.removeFileFn(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove temp file %q: %w", path, err)
}
return nil
}
func (s *fsStorage) ensureParentDir(absPath string) error {
parentDir := filepath.Dir(absPath)
if err := os.MkdirAll(parentDir, os.ModePerm); err != nil {
return fmt.Errorf("create parent directory %q: %w", parentDir, err)
}
return nil
}
func classifyStorageError(err error) error {
if err == nil {
return nil
}
if gerr.IsStorage(err) {
return err
}
return gerr.WrapStorage(err)
}