diff --git a/file/jsonops.go b/file/jsonops.go index 1b43b51..5572a5c 100644 --- a/file/jsonops.go +++ b/file/jsonops.go @@ -23,6 +23,27 @@ type JSONReader interface { type JSONWriter interface { WriteObj(map[string]interface{}, string) error WriteObjArray([]map[string]interface{}, string) error + WriteSavedObj(map[string]interface{}, string) error +} + +type SavedObject struct { + SaveName string + Date string + VersionNumber string + GameMode string + GameType string + GameComplexity string + Tags []string + Gravity float64 + PlayArea float64 + Table string + Sky string + Note string + TabStates map[string]interface{} + LuaScript string + LuaScriptState string + XmlUI string + ObjectStates []map[string]interface{} // This will hold the original `m` } // NewJSONOps initializes our object on a directory @@ -80,6 +101,50 @@ func (j *JSONOps) WriteObj(m map[string]interface{}, filename string) error { return os.WriteFile(p, b, 0644) } +// WriteSavedObj writes a serialized JSON object to a file with the boilerplate for TTS saved objects. +// For additional information, see saved-object-feature.md in the project repository. +func (j *JSONOps) WriteSavedObj(m map[string]interface{}, filename string) error { + var b []byte + var err error + + savedObject := SavedObject{ + SaveName: "", + Date: "", + VersionNumber: "", + GameMode: "", + GameType: "", + GameComplexity: "", + Tags: []string{}, + Gravity: 0.5, + PlayArea: 0.5, + Table: "", + Sky: "", + Note: "", + TabStates: map[string]interface{}{}, + LuaScript: "", + LuaScriptState: "", + XmlUI: "", + ObjectStates: []map[string]interface{}{m}, + } + + b, err = json.MarshalIndent(savedObject, "", " ") + if err != nil { + return err + } + + // end-of-file newline + b = append(b, '\n') + + // ensure the path exists + p := path.Join(j.basepath, filename) + err = os.MkdirAll(path.Dir(p), 0750) + if err != nil && !os.IsExist(err) { + return fmt.Errorf("MkdirAll(%s): %v", path.Dir(p), err) + } + + return os.WriteFile(p, b, 0644) +} + // WriteObjArray writes an array of serialized json objects to a file. func (j *JSONOps) WriteObjArray(m []map[string]interface{}, filename string) error { b, err := json.MarshalIndent(m, "", " ") diff --git a/main.go b/main.go index cb5919c..3a33547 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ var ( modfile = flag.String("modfile", "", "where to read from when reversing.") objin = flag.String("objin", "", "if non-empty, don't build/reverse a full mod, only an object state array") objout = flag.String("objout", "", "if building only object state list, output to this filename") + savedobj = flag.Bool("savedobj", false, "if present, will add the boiler plate for TTS to recognize as saved object.") ) var ( @@ -135,6 +136,7 @@ func main() { RootRead: rootops, RootWrite: outputOps, OnlyObjStates: OnlyObjStates, + SavedObj: *savedobj, } err := m.GenerateFromConfig() if err != nil { diff --git a/mod/generate.go b/mod/generate.go index d97d0a0..f26a066 100644 --- a/mod/generate.go +++ b/mod/generate.go @@ -37,6 +37,7 @@ type Mod struct { Modsettings file.JSONReader Objs file.JSONReader Objdirs file.DirExplorer + SavedObj bool // If not-empty: this holds the root filename for the object state json object OnlyObjStates string @@ -148,7 +149,11 @@ func (m *Mod) generate(raw types.J) error { // Print outputs internal representation of mod to json file with indents func (m *Mod) Print(basename string) error { - return m.RootWrite.WriteObj(m.Data, basename) + if m.SavedObj { + return m.RootWrite.WriteSavedObj(m.Data, basename) + } else { + return m.RootWrite.WriteObj(m.Data, basename) + } } func tryPut(d *types.J, from, to string, fun func(string) (interface{}, error)) { diff --git a/mod/generate_test.go b/mod/generate_test.go index 36204f1..f2749b0 100644 --- a/mod/generate_test.go +++ b/mod/generate_test.go @@ -14,9 +14,10 @@ func TestGenerate(t *testing.T) { name string inputRoot types.J inputModSettings map[string]interface{} - inputOjbs map[string]types.J + inputObjs map[string]types.J inputLuaSrc map[string]string inputObjTexts map[string]string + flags map[string]interface{} want map[string]interface{} }{ { @@ -63,7 +64,7 @@ func TestGenerate(t *testing.T) { inputLuaSrc: map[string]string{ "parent/eda22b/childstate2.ttslua": "var foo = 42\nvar foo = 42\nvar foo = 42\nvar foo = 42\nvar foo = 42\nvar foo = 42\nvar foo = 42\nvar foo = 42\n", }, - inputOjbs: map[string]types.J{ + inputObjs: map[string]types.J{ "parent.json": map[string]interface{}{ "GUID": "parent", "States_path": map[string]interface{}{ @@ -134,7 +135,7 @@ func TestGenerate(t *testing.T) { "ObjectStates_order": []interface{}{"parent"}, }, inputLuaSrc: map[string]string{}, - inputOjbs: map[string]types.J{ + inputObjs: map[string]types.J{ "parent.json": map[string]interface{}{ "GUID": "parent", "States_path": map[string]interface{}{ @@ -195,6 +196,43 @@ func TestGenerate(t *testing.T) { "DecalPallet": nil, }, }, + { + name: "Saved Object (Simple)", + inputObjs: map[string]types.J{ + "test123.json": map[string]interface{}{ + "GUID": "test123", + "Description": "A test object", + }, + }, + flags: map[string]interface{}{ + "OnlyObjStates": true, + "SavedObj": true, + }, + want: map[string]interface{}{ + "SaveName": "", + "Date": "", + "VersionNumber": "", + "GameMode": "", + "GameType": "", + "GameComplexity": "", + "Tags": []any{}, + "Gravity": 0.5, + "PlayArea": 0.5, + "Table": "", + "Sky": "", + "Note": "", + "TabStates": map[string]interface{}{}, + "LuaScript": "", + "LuaScriptState": "", + "XmlUI": "", + "ObjectStates": []interface{}{ + map[string]interface{}{ + "GUID": "test123", + "Description": "A test object", + }, + }, + }, + }, } { t.Run(tc.name, func(t *testing.T) { rootff := &tests.FakeFiles{ @@ -207,7 +245,7 @@ func TestGenerate(t *testing.T) { } msff := &tests.FakeFiles{} objs := &tests.FakeFiles{ - Data: tc.inputOjbs, + Data: tc.inputObjs, Fs: tc.inputObjTexts, } m := Mod{ @@ -218,6 +256,12 @@ func TestGenerate(t *testing.T) { Objs: objs, Objdirs: objs, } + if OnlyObjStatesFlag, ok := tc.flags["OnlyObjStates"]; ok && OnlyObjStatesFlag == true { + m.OnlyObjStates = "test123.json" + } + if savedObjFlag, ok := tc.flags["SavedObj"]; ok && savedObjFlag == true { + m.SavedObj = true + } err := m.GenerateFromConfig() if err != nil { t.Fatalf("Error reading config %v", err) diff --git a/saved-object-feature.md b/saved-object-feature.md new file mode 100644 index 0000000..57b3a61 --- /dev/null +++ b/saved-object-feature.md @@ -0,0 +1,33 @@ +# TTS Mod Manager (TTSMM) - Savegame Handling Documentation + +## Overview + +The TTS Mod Manager (TTSMM) is a tool designed to manage savegames from **Tabletop Simulator (TTS)**. TTS savegames are large JSON files that encapsulate the entire state of a game session, including all objects, settings, and scripts. Due to their size and complexity, these files are not well-suited for source control systems like GitHub. + +TTSMM provides functionality to split savegames into separate files for individual objects and reconstruct them as needed. This makes it easier to manage and version control TTS savegames. + +## Feature: "Saved Object" Output + +In addition to handling regular savegames, TTSMM supports generating "Saved Object" files. Saved Objects are a special type of savegame in TTS with the same overall structure as regular savegames, but with many of the outer-layer fields left empty. These files are typically used to save and share individual objects or small groups of objects independently of a full savegame. + +### Key Differences: Regular Savegames vs. Saved Objects + +| Field | Regular Savegame Value | Saved Object Value | +| ---------------- | -------------------------- | --------------------- | +| `SaveName` | Populated with save name | Empty (`""`) | +| `Date` | Timestamp of save creation | Empty (`""`) | +| `VersionNumber` | Current TTS version | Empty (`""`) | +| `GameMode` | Game mode description | Empty (`""`) | +| `GameType` | Type of game | Empty (`""`) | +| `GameComplexity` | Complexity descriptor | Empty (`""`) | +| `Tags` | Array of tags | Empty (`[]`) | +| `Gravity` | Physics gravity setting | Default (`0.5`) | +| `PlayArea` | Play area scale | Default (`0.5`) | +| `Table` | Table model used | Empty (`""`) | +| `Sky` | Skybox setting | Empty (`""`) | +| `Note` | Session note | Empty (`""`) | +| `TabStates` | Tab states information | Empty (`{}`) | +| `LuaScript` | Global Lua script content | Empty (`""`) | +| `LuaScriptState` | Lua script state data | Empty (`""`) | +| `XmlUI` | XML UI data | Empty (`""`) | +| `ObjectStates` | Array of game objects | Array of game objects | diff --git a/tests/fakefiles.go b/tests/fakefiles.go index 5b6490d..6ebe915 100644 --- a/tests/fakefiles.go +++ b/tests/fakefiles.go @@ -78,6 +78,32 @@ func (f *FakeFiles) WriteObjArray(data []map[string]interface{}, path string) er return nil } +// WriteSavedObj satisfies JSONWriter and mimics the behavior of the real WriteSavedObj. +func (f *FakeFiles) WriteSavedObj(data map[string]interface{}, path string) error { + savedObject := map[string]interface{}{ + "SaveName": "", + "Date": "", + "VersionNumber": "", + "GameMode": "", + "GameType": "", + "GameComplexity": "", + "Tags": []any{}, + "Gravity": 0.5, + "PlayArea": 0.5, + "Table": "", + "Sky": "", + "Note": "", + "TabStates": map[string]interface{}{}, + "LuaScript": "", + "LuaScriptState": "", + "XmlUI": "", + "ObjectStates": []map[string]interface{}{data}, + } + + f.Data[path] = savedObject + return nil +} + // EncodeToFile satisfies LuaWriter func (f *FakeFiles) EncodeToFile(script, file string) error { f.Fs[file] = script