@@ 2,6 2,7 @@ package main
import (
"bufio"
+ "bytes"
"encoding/json"
"errors"
"flag"
@@ 18,15 19,17 @@ import (
// OfflineSuffix is the suffix added to whitelist.json and playerdata.
const OfflineSuffix = ".offline"
-// Version is set at build time in the Makefile.
-var Version string
-
-// User represents a user in whitelist.json
+// User represents a user in whitelist.json or ops.json
type User struct {
- UUID string `json:"uuid"`
- Name string `json:"name"`
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Level int `json:"level,omitempty"`
+ BypassPlayerLimit bool `json:"bypassPlayerLimit,omitempty"`
}
+// Version is set at build time in the Makefile.
+var Version string
+
func main() {
log.SetPrefix("mcoffline " + Version + ": ")
log.SetFlags(0)
@@ 38,7 41,7 @@ func main() {
arg = "whitelist.json"
}
- in, err := os.Open(arg)
+ users, err := loadWhitelist(arg)
if errors.Is(err, os.ErrNotExist) && flag.Arg(0) != "" {
// Assume the argument was a username if the file does not exist, but an
// argument was given.
@@ 47,65 50,115 @@ func main() {
} else if err != nil {
log.Fatalf("failed opening whitelist: %v\n", err)
}
- defer in.Close()
+ basePath := filepath.Dir(arg)
- out, err := os.Create(arg + OfflineSuffix)
+ opsPath := filepath.Join(basePath, "ops.json")
+ in, err := os.Open(opsPath)
if err != nil {
- log.Fatalf("failed creating offline-mode whitelist file: %v\n", err)
+ log.Fatalf("failed opening ops.json: %v\n", err)
}
- defer out.Close()
+ defer in.Close()
- if err := convertWhitelist(in, out); err != nil {
- log.Fatalf("failed parsing whitelist: %v\n", err)
+ src, err := io.ReadAll(in)
+ if err != nil {
+ log.Fatalf("failed reading ops.json: %v\n", err)
}
- // Reset file seek so we can read it again later to make a map of the UUIDs.
- if _, err := in.Seek(0, io.SeekStart); err != nil {
- log.Fatalf("failed reading whitelist: %v\n", err)
+ if err = createOfflineJSON(src, opsPath+OfflineSuffix); err != nil {
+ log.Fatalf("failed to create ops.json.offline: %v\n", err)
}
- basePath := filepath.Dir(arg)
worldName, err := getLevelName(filepath.Join(basePath, "server.properties"))
if err != nil {
log.Fatalf("failed reading server.properties to get world name: %v\n", err)
}
- playerDataPath := filepath.Join(basePath, worldName, "playerdata")
- users, err := mapUsers(in)
- if err != nil {
- log.Fatalln("failed mapping users to UUIDs")
+ advancementsPath := filepath.Join(basePath, worldName, "advancements")
+ if err := convertDirectory(users, advancementsPath); err != nil {
+ log.Fatalf("failed creating offline advancements: %v\n", err)
}
- if err := convertPlayerdata(users, playerDataPath); err != nil {
+ playerdataPath := filepath.Join(basePath, worldName, "playerdata")
+ if err := convertDirectory(users, playerdataPath); err != nil {
log.Fatalf("failed creating offline playerdata: %v\n", err)
}
+
+ statsPath := filepath.Join(basePath, worldName, "stats")
+ if err := convertDirectory(users, statsPath); err != nil {
+ log.Fatalf("failed creating offline stats: %v\n", err)
+ }
}
-// convertWhitelist reads an online-mode whitelist.json file from a reader and
-// writes an equivalent offline-mode whitelist.json to it's writer. The output
-// is indented with 2 spaces to match the format Mojang seems to like.
-func convertWhitelist(in io.Reader, out io.Writer) error {
+// loadWhitelist reads an online-mode whitelist file by path, creates an
+// offline mode whitelist in the same directory, and returns a map of
+// online-mode UUIDs to usernames.
+func loadWhitelist(path string) (map[string]User, error) {
+ in, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer in.Close()
+
+ src, err := io.ReadAll(in)
+ if err != nil {
+ return nil, err
+ }
+
+ if err = createOfflineJSON(src, path+OfflineSuffix); err != nil {
+ return nil, err
+ }
+
+ // Reset file seek so we can read it again to make a map of the UUIDs.
+ if _, err := in.Seek(0, io.SeekStart); err != nil {
+ log.Fatalf("failed reading whitelist: %v\n", err)
+ }
+ return mapUsers(in)
+}
+
+// createOfflineJSON reads online-mode .json data (whitelist or ops) and writes
+// an equivalent offline-mode .json file to the path specified.
+func createOfflineJSON(src []byte, path string) error {
+ in := bytes.NewReader(src)
+ out, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if err := convertJSON(in, out); err != nil {
+ return fmt.Errorf("failed converting json file: %w\n", err)
+ }
+ return nil
+}
+
+// convertJSON reads online-mode .json (whitelist or ops) from a reader and
+// writes an equivalent offline-mode .json to it's writer. The output is
+// indented with 2 spaces to match the format Mojang seems to like.
+func convertJSON(in io.Reader, out io.Writer) error {
var users []User
- d := json.NewDecoder(in)
- err := d.Decode(&users)
+ dec := json.NewDecoder(in)
+ err := dec.Decode(&users)
if err != nil {
return err
}
+ offlineUsers := make([]User, len(users))
for i, user := range users {
- var offlineUser User
- offlineUser.UUID = offline.NameToUUID(user.Name).String()
- offlineUser.Name = user.Name
- users[i] = offlineUser
+ // Do some type checking as a treat (and to avoid crashing if the json
+ // is corrupted).
+ offlineUsers[i].UUID = offline.NameToUUID(user.Name).String()
+ offlineUsers[i].Name = user.Name
+ offlineUsers[i].Level = user.Level
+ offlineUsers[i].BypassPlayerLimit = user.BypassPlayerLimit
}
e := json.NewEncoder(out)
e.SetIndent("", " ") // MC uses 2 space indented json.
- return e.Encode(&users)
+ return e.Encode(&offlineUsers)
}
-// mapUsers reads a whitelist and creates a map of UUIDs to usernames.
-func mapUsers(in io.Reader) (map[string]string, error) {
+// mapUsers reads a whitelist and creates a map of online-mode UUIDs to Users.
+func mapUsers(in io.Reader) (map[string]User, error) {
var users []User
d := json.NewDecoder(in)
err := d.Decode(&users)
@@ 113,9 166,9 @@ func mapUsers(in io.Reader) (map[string]string, error) {
return nil, err
}
- m := make(map[string]string)
+ m := make(map[string]User, len(users))
for _, user := range users {
- m[user.UUID] = user.Name
+ m[user.UUID] = user
}
return m, nil
}
@@ 149,15 202,15 @@ func getLevelName(path string) (string, error) {
return "", fmt.Errorf("level-name not found in %v", path)
}
-// convertPlayerdata takes a map of online-mode UUIDs to player named and the
-// path to a playerdata folder. It creates a playerdata.offline directory.
-// Then, For each file in the input directory, a hard link is make to the
-// offline directory named with the offline-mode UUID instead of it's previous
-// online-mode UUID.
-func convertPlayerdata(users map[string]string, path string) error {
+// convertDirectory takes a map of online-mode UUIDs to player names and the
+// path to a folder with player data (stats, advancements, or playerdata). It
+// creates a new directory with a .offline suffix. Then, For each file in the
+// input directory, a hard link is made to the offline directory named with the
+// offline-mode UUID instead of it's previous online-mode UUID.
+func convertDirectory(users map[string]User, path string) error {
files, err := os.ReadDir(path)
if err != nil {
- return fmt.Errorf("failed reading playerdata folder: %w", err)
+ return fmt.Errorf("failed reading folder: %w", err)
}
offlineDir := filepath.Clean(path) + OfflineSuffix
@@ 176,7 229,7 @@ func convertPlayerdata(users map[string]string, path string) error {
ext := filepath.Ext(file.Name())
uuid := strings.TrimSuffix(file.Name(), ext)
- name, ok := users[uuid]
+ user, ok := users[uuid]
if !ok {
fmt.Printf("skipping non-whitelisted player: %v\n", onlinePath)
continue
@@ 184,7 237,7 @@ func convertPlayerdata(users map[string]string, path string) error {
offlinePath := filepath.Join(
offlineDir,
- offline.NameToUUID(name).String()+ext,
+ offline.NameToUUID(user.Name).String()+ext,
)
// Using a hardlink to avoid pointless disk writing. Dunno if this works