// Package levelpack handles ZIP archives for level packs. package levelpack import ( "archive/zip" "bytes" "encoding/json" "errors" "fmt" "io" "io/ioutil" "os" "runtime" "sort" "strings" "time" "git.kirsle.net/SketchyMaze/doodle/assets" "git.kirsle.net/SketchyMaze/doodle/pkg/balance" "git.kirsle.net/SketchyMaze/doodle/pkg/enum" "git.kirsle.net/SketchyMaze/doodle/pkg/filesystem" "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" ) // LevelPack describes the contents of a levelpack file. type LevelPack struct { Title string `json:"title"` Description string `json:"description"` Author string `json:"author"` Created time.Time `json:"created"` // Cached metadata about the (displayed) levels. Levels []Level `json:"levels"` // Number of levels unlocked by default. // 0 = all levels unlocked FreeLevels int `json:"freeLevels"` // The loaded zip file for reading an existing levelpack. Zipfile *zip.Reader `json:"-"` // A reference to the original filename, not stored in json. Filename string `json:"-"` // Signature to allow free versions of the game to load embedded // custom doodads inside this levelpack for its levels. Signature []byte `json:"signature,omitempty"` } // Level holds metadata about the levels in the levelpack. type Level struct { UUID string `json:"uuid"` Title string `json:"title"` Author string `json:"author"` Filename string `json:"filename"` } // LoadFile reads a .levelpack zip file. func LoadFile(filename string) (*LevelPack, error) { var ( fh io.ReaderAt filesize int64 ) // Look in embedded bindata. if data, err := assets.Asset(filename); err == nil { filesize = int64(len(data)) fh = bytes.NewReader(data) } // Try the filesystem. if fh == nil { stat, err := os.Stat(filename) if err != nil { return nil, err } filesize = stat.Size() fh, err = os.Open(filename) if err != nil { return nil, err } } // No luck? if fh == nil { return nil, errors.New("no file found") } reader, err := zip.NewReader(fh, filesize) if err != nil { return nil, err } lp := &LevelPack{ Filename: filename, Zipfile: reader, } // Read the index.json. lp.GetJSON(lp, "index.json") return lp, nil } // LoadAllAvailable loads every levelpack visible to the game. Returns // the sorted list of filenames as from ListFiles, plus a deeply loaded // hash map associating the filenames with their data. func LoadAllAvailable() ([]string, map[string]*LevelPack, error) { filenames, err := ListFiles() if err != nil { return filenames, nil, err } var dictionary = map[string]*LevelPack{} for _, filename := range filenames { // Resolve the filename to a definite path on disk. path, err := filesystem.FindFile(filename) if err != nil { log.Error("LoadAllAvailable: FindFile(%s): %s", path, err) return filenames, nil, err } lp, err := LoadFile(path) if err != nil { return filenames, nil, fmt.Errorf("LoadAllAvailable: LoadFile(%s): %s", path, err) } dictionary[filename] = lp } return filenames, dictionary, nil } // ListFiles lists all the discoverable levelpack files, starting from // the game's built-ins all the way to user levelpacks. func ListFiles() ([]string, error) { var names []string // List levelpacks embedded into the binary. if files, err := assets.AssetDir("assets/levelpacks"); err == nil { names = append(names, files...) } // WASM stops here, no filesystem access. if runtime.GOOS == "js" { return names, nil } // Read system-level levelpacks. files, _ := ioutil.ReadDir(filesystem.SystemLevelPacksPath) for _, file := range files { name := file.Name() if strings.HasSuffix(name, enum.LevelPackExt) { names = append(names, name) } } // Append user levelpacks. files, _ = ioutil.ReadDir(userdir.LevelPackDirectory) for _, file := range files { name := file.Name() if strings.HasSuffix(name, enum.LevelPackExt) { names = append(names, name) } } // Deduplicate strings. Can happen e.g. because assets/ is baked // in to bindata but files also exist there locally. var ( dedupe []string seen = map[string]interface{}{} ) for _, value := range names { if _, ok := seen[value]; !ok { seen[value] = nil dedupe = append(dedupe, value) } } sort.Strings(dedupe) return dedupe, nil } // WriteFile saves the metadata to a .json file on disk. func (l LevelPack) WriteFile(filename string) error { out, err := json.Marshal(l) if err != nil { return err } return ioutil.WriteFile(filename, out, 0655) } // WriteZipfile saves a levelpack back into a zip file. func (l LevelPack) WriteZipfile(filename string) error { fh, err := os.Create(filename) if err != nil { return err } defer fh.Close() // Copy all of the levels and other files from the old zip to new zip. zf := zip.NewWriter(fh) defer zf.Close() // Copy attached doodads and levels. for _, file := range l.Zipfile.File { if !strings.HasPrefix(file.Name, "doodads/") && !strings.HasPrefix(file.Name, "levels/") { continue } if err := zf.Copy(file); err != nil { return err } } // Write the index.json metadata. meta, err := json.Marshal(l) if err != nil { return err } writer, err := zf.Create("index.json") if err != nil { return err } _, err = writer.Write(meta) return err } // GetFile returns file data from inside the loaded zipfile of a levelpack. // // This also implements the Embeddable interface. func (l LevelPack) GetFile(filename string) ([]byte, error) { if l.Zipfile == nil { return []byte{}, errors.New("zipfile not loaded") } // NOTE: levelpacks don't have an "assets/" prefix but the game // might come looking for "assets/doodads" if strings.HasPrefix(filename, balance.EmbeddedDoodadsBasePath) { filename = strings.Replace(filename, balance.EmbeddedDoodadsBasePath, "doodads/", 1) } file, err := l.Zipfile.Open(filename) if err != nil { return []byte{}, err } return ioutil.ReadAll(file) } // GetLevel returns a parsed Level object from a file inside the zipfile. func (l LevelPack) GetLevel(filename string) (*level.Level, error) { levelbin, err := l.GetFile("levels/" + filename) if err != nil { return nil, err } lvl, err := level.FromJSON(filename, levelbin) if err != nil { return nil, fmt.Errorf("LevelPack.GetLevel(%s) parsing from zipfile: %s", filename, err) } return lvl, nil } // GetJSON loads a JSON file from the zipfile and marshals it into your struct. func (l LevelPack) GetJSON(v interface{}, filename string) error { data, err := l.GetFile(filename) if err != nil { return err } return json.Unmarshal(data, v) } // ListFiles returns the files in the zipfile that match the prefix given. func (l LevelPack) ListFiles(prefix string) []string { var result []string if l.Zipfile != nil { for _, file := range l.Zipfile.File { if strings.HasPrefix(file.Name, prefix) { result = append(result, file.Name) } } } return result }