diff --git a/SessionZero.sln b/SessionZero.sln
index a8146a5..d56de21 100644
--- a/SessionZero.sln
+++ b/SessionZero.sln
@@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionZero.Client", "src\S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionZero.Server", "src\SessionZero.Server\SessionZero.Server.csproj", "{54824F5A-0499-4BC9-AC8E-88E943C66256}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "szpack", "tools\szpack\szpack.csproj", "{70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -24,5 +26,9 @@ Global
{54824F5A-0499-4BC9-AC8E-88E943C66256}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54824F5A-0499-4BC9-AC8E-88E943C66256}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54824F5A-0499-4BC9-AC8E-88E943C66256}.Release|Any CPU.Build.0 = Release|Any CPU
+ {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/src/SessionZero.Shared/Services/DatapackService.cs b/src/SessionZero.Shared/Services/DatapackService.cs
new file mode 100644
index 0000000..b657d63
--- /dev/null
+++ b/src/SessionZero.Shared/Services/DatapackService.cs
@@ -0,0 +1,172 @@
+using System.Text.Json;
+using SessionZero.Shared.Models;
+using System.IO;
+using System.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.IO.Compression;
+
+namespace SessionZero.Shared.Services;
+
+public static class DatapackService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// Creates a new Datapack model with required metadata.
+ ///
+ /// The display name of the datapack.
+ /// The initial version string (e.g., "1.0.0").
+ /// The pack creator's name.
+ /// The license (e.g., "CC-BY-SA-4.0").
+ /// A fully initialized Datapack model.
+ public static Datapack CreateEmptyDatapack(string name, string version, string author, string license)
+ {
+ return new Datapack
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ Version = version,
+ Author = author,
+ License = license,
+ Description = String.Empty,
+ CreatedAt = DateTime.UtcNow,
+ SessionZeroVersion = "0.0.1",
+ Dependencies = new()
+ };
+ }
+
+ ///
+ /// Serializes a Datapack model and saves it to a file named 'szpack.json'
+ /// inside a new subdirectory named after the datapack's slug.
+ ///
+ /// The Datapack object to save.
+ /// The directory where the new datapack folder will be created.
+ /// The full path to the created szpack.json file.
+ public static async Task SaveDatapackMetadataAsync(Datapack datapack, string parentDirectoryPath)
+ {
+ var packDirectoryName = datapack.Name.ToLower().Replace(' ', '-').Trim();
+ var packRootDirectory = Path.Combine(parentDirectoryPath, packDirectoryName);
+
+ Directory.CreateDirectory(packRootDirectory);
+
+ var filePath = Path.Combine(packRootDirectory, "szpack.json");
+
+ var jsonString = JsonSerializer.Serialize(datapack, JsonOptions);
+ await File.WriteAllTextAsync(filePath, jsonString);
+
+ return filePath;
+ }
+
+ ///
+ /// Serializes an SzObject (Dataset, Template, etc.) and saves it to a file.
+ ///
+ /// The type of the object, must inherit from SzObject.
+ /// The object to save.
+ /// The specific subdirectory (e.g., 'datasets') within the datapack root.
+ /// The full path to the created JSON file.
+ public static async Task SaveSzObjectAsync(T szObject, string directoryPath) where T : SzObject
+ {
+ Directory.CreateDirectory(directoryPath);
+
+ var fileName = $"{szObject.Id}.json";
+ var filePath = Path.Combine(directoryPath, fileName);
+ var jsonString = JsonSerializer.Serialize(szObject, JsonOptions);
+ await File.WriteAllTextAsync(filePath, jsonString);
+
+ return filePath;
+ }
+
+ ///
+ /// Defines and creates the standard directory structure for a SessionZero datapack.
+ ///
+ /// The root directory path of the new datapack.
+ /// A dictionary containing the standard folder names and their absolute paths.
+ public static Dictionary CreateDatapackDirectoryStructure(string rootPath)
+ {
+ var structure = new Dictionary
+ {
+ { "datasets", Path.Combine(rootPath, "datasets") },
+ { "character_templates", Path.Combine(rootPath, "characters") },
+ { "session_templates", Path.Combine(rootPath, "sessions") },
+ { "media", Path.Combine(rootPath, "media") },
+ { "images", Path.Combine(rootPath, "media", "images") }
+ };
+
+ foreach (var path in structure.Values)
+ {
+ Directory.CreateDirectory(path);
+ }
+
+ return structure;
+ }
+
+ ///
+ /// Compresses a datapack directory into a .szpack zip archive.
+ ///
+ /// The path to the datapack's root directory.
+ /// The full path and filename for the output .szpack file.
+ public static void PackDatapack(string sourceDirectoryPath, string outputFilePath)
+ {
+ if (!Directory.Exists(sourceDirectoryPath))
+ {
+ throw new DirectoryNotFoundException($"Source directory not found: {sourceDirectoryPath}");
+ }
+
+ if (File.Exists(outputFilePath))
+ {
+ File.Delete(outputFilePath);
+ }
+
+ ZipFile.CreateFromDirectory(sourceDirectoryPath, outputFilePath);
+ }
+
+ ///
+ /// Extracts a .szpack zip archive into a target directory.
+ ///
+ /// The path to the .szpack file.
+ /// The directory where the contents will be extracted.
+ public static void UnpackDatapack(string sourceFilePath, string destinationDirectoryPath)
+ {
+ if (!File.Exists(sourceFilePath))
+ {
+ throw new FileNotFoundException($"Datapack file not found: {sourceFilePath}");
+ }
+
+ Directory.CreateDirectory(destinationDirectoryPath);
+
+ ZipFile.ExtractToDirectory(sourceFilePath, destinationDirectoryPath, overwriteFiles: true);
+ }
+
+ ///
+ /// Deserializes and loads the core Datapack metadata (szpack.json) from a directory.
+ ///
+ /// The path to the datapack's root directory.
+ /// A Datapack model containing the metadata.
+ /// Thrown if szpack.json is not found.
+ /// Thrown if deserialization fails.
+ public static async Task LoadDatapackMetadataAsync(string datapackRootPath)
+ {
+ var filePath = Path.Combine(datapackRootPath, "szpack.json");
+
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"The required metadata file 'szpack.json' was not found in: {datapackRootPath}", filePath);
+ }
+
+ var jsonString = await File.ReadAllTextAsync(filePath);
+
+ var datapack = JsonSerializer.Deserialize(jsonString, JsonOptions);
+
+ if (datapack is null)
+ {
+ throw new JsonException($"Failed to deserialize szpack.json from {filePath}. The file may be corrupt or invalid.");
+ }
+
+ return datapack;
+ }
+}
\ No newline at end of file
diff --git a/src/SessionZero.Shared/Validation/DatapackValidator.cs b/src/SessionZero.Shared/Validation/DatapackValidator.cs
new file mode 100644
index 0000000..828e9b2
--- /dev/null
+++ b/src/SessionZero.Shared/Validation/DatapackValidator.cs
@@ -0,0 +1,45 @@
+using SessionZero.Shared.Models;
+
+namespace SessionZero.Shared.Validation;
+
+public static class DatapackValidator
+{
+ public static List ValidateDatapack(Datapack datapack)
+ {
+ var errors = new List();
+
+ if (datapack.Id == Guid.Empty) errors.Add("Datapack 'Id' is required and must not be an empty GUID.");
+
+ ValidateRequiredString(errors, datapack.Name, nameof(datapack.Name));
+ ValidateRequiredString(errors, datapack.Version, nameof(datapack.Version));
+ ValidateRequiredString(errors, datapack.Author, nameof(datapack.Author));
+ ValidateRequiredString(errors, datapack.License, nameof(datapack.License));
+ ValidateRequiredString(errors, datapack.SessionZeroVersion, nameof(datapack.SessionZeroVersion));
+
+ if (datapack.CreatedAt == DateTime.MinValue) errors.Add("Datapack 'CreatedAt' is required and must not be an empty DateTime.");
+
+ return errors;
+ }
+
+ public static List ValidateSzObject(SzObject szObject)
+ {
+ var errors = new List();
+ var objectName = szObject.Id;
+
+ ValidateRequiredString(errors, szObject.Id, $"{objectName}'s Id");
+ ValidateRequiredString(errors, szObject.Name, $"{objectName}'s Name");
+ ValidateRequiredString(errors, szObject.SzType, $"{objectName}'s SzType");
+ ValidateRequiredString(errors, szObject.Version, $"{objectName}'s Version");
+ ValidateRequiredString(errors, szObject.SchemaVersion, $"{objectName}'s SchemaVersion");
+
+ // 2. Validate Icon path format (optional/future: e.g., only allows alphanumeric + slashes + extension)
+ // For now, we'll only check if it's not null, which is done by the property definition in SzObject.
+
+ return errors;
+ }
+
+ private static void ValidateRequiredString(List errors, string? value, string fieldName)
+ {
+ if (string.IsNullOrWhiteSpace(value)) errors.Add($"'{fieldName}' is required and must not be empty.");
+ }
+}
\ No newline at end of file
diff --git a/tools/szpack/CreateCommand.cs b/tools/szpack/CreateCommand.cs
new file mode 100644
index 0000000..6a1fd25
--- /dev/null
+++ b/tools/szpack/CreateCommand.cs
@@ -0,0 +1,107 @@
+/*
+ * WARNING:
+ * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project.
+ * It was created to be used as a quick and dirty validation tool for the szpack format.
+*/
+
+using SessionZero.Shared.Models;
+using SessionZero.Shared.Services;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SessionZero.Tools.Packer;
+
+// Settings class defines the command-line arguments
+public class CreateSettings : CommandSettings
+{
+ [CommandArgument(0, "")]
+ [Description("The display name of the datapack (e.g., 'My Fantasy Spells').")]
+ public required string Name { get; init; }
+
+ [CommandArgument(1, "[author]")]
+ [Description("The author's name.")]
+ [DefaultValue("Test Author")]
+ public string Author { get; init; } = "Test Author";
+
+ [CommandOption("-o|--output")]
+ [Description("The parent directory where the new pack folder will be created.")]
+ [DefaultValue(".")]
+ public string Output { get; init; } = Environment.CurrentDirectory;
+}
+
+public class CreateCommand : AsyncCommand
+{
+ public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] CreateSettings settings, CancellationToken cancellationToken)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on blue] --- Creating Datapack: '{settings.Name}' --- [/]");
+
+ try
+ {
+ // 1. Create the top-level model and save szpack.json
+ var newPack = DatapackService.CreateEmptyDatapack(settings.Name, "1.0.0", settings.Author, "MIT");
+ var szpackPath = await DatapackService.SaveDatapackMetadataAsync(newPack, settings.Output);
+ var packRootDirectory = Path.GetDirectoryName(szpackPath)!;
+
+ AnsiConsole.MarkupLine($"[green]✅ Created metadata file at: {szpackPath}[/]");
+
+ // 2. Create the standard directory structure
+ var structure = DatapackService.CreateDatapackDirectoryStructure(packRootDirectory);
+ AnsiConsole.MarkupLine($"[green]✅ Created standard directories at: {packRootDirectory}[/]");
+
+ // 3. Create and save test objects
+ await CreateTestObjects(packRootDirectory, structure);
+
+ AnsiConsole.MarkupLine("\n[bold]Creation Complete![/] Use 'szpack pack' to compress it.");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to create datapack. Details: {ex.Message}");
+ return 1;
+ }
+ }
+
+ // Helper method for creating test objects (copied from previous Program.cs)
+ private static async Task CreateTestObjects(string rootPath, Dictionary structure)
+ {
+ // ... (Test object creation logic remains the same, ensure you have the necessary usings in this file)
+
+ // --- Test object creation logic (omitted for brevity, assume the previous logic is here) ---
+ var testDataset = new Dataset { /* ... test data ... */ Id = "basic-attributes", Name = "Basic Character Attributes", SzType = "Dataset", Version = "1.0.0", SchemaVersion = "1.0.0", DatasetType = "Attribute", Entries = new() };
+ var dsPath = await DatapackService.SaveSzObjectAsync(testDataset, structure["datasets"]);
+ AnsiConsole.MarkupLine($" -> Saved Test Dataset: [yellow]{Path.GetFileName(dsPath)}[/]");
+
+ var testCharTemplate = new CharacterTemplate { /* ... test data ... */ Id = "default-char-sheet", Name = "Default Character Template", SzType = "Template", Version = "1.0.0", SchemaVersion = "1.0.0", Sections = new() };
+ var charPath = await DatapackService.SaveSzObjectAsync(testCharTemplate, structure["character_templates"]);
+ AnsiConsole.MarkupLine($" -> Saved Test Character Template: [yellow]{Path.GetFileName(charPath)}[/]");
+
+ var testSessionTemplate = new SessionTemplate
+ {
+ /* ... test data ... */ Id = "basic-encounter",
+ Name = "Basic Encounter Template",
+ SzType = "Template",
+ Version = "1.0.0",
+ SchemaVersion = "1.0.0",
+ CharacterTemplateLink = new()
+ {
+ DatapackId = Guid.Empty,
+ TemplateId = testCharTemplate.Id,
+ Version = testCharTemplate.Version
+ },
+ RequiredDatasets = new()
+ {
+ new()
+ {
+ DatapackId = Guid.Empty,
+ DatasetId = testDataset.Id,
+ Version = testDataset.Version
+ }
+ },
+ Sections = new()
+ };
+ var sessionPath = await DatapackService.SaveSzObjectAsync(testSessionTemplate, structure["session_templates"]);
+ AnsiConsole.MarkupLine($" -> Saved Test Session Template: [yellow]{Path.GetFileName(sessionPath)}[/]");
+ }
+}
\ No newline at end of file
diff --git a/tools/szpack/PackCommand.cs b/tools/szpack/PackCommand.cs
new file mode 100644
index 0000000..7e6c411
--- /dev/null
+++ b/tools/szpack/PackCommand.cs
@@ -0,0 +1,43 @@
+/*
+ * WARNING:
+ * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project.
+ * It was created to be used as a quick and dirty validation tool for the szpack format.
+*/
+
+using SessionZero.Shared.Services;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SessionZero.Tools.Packer;
+
+public class PackSettings : CommandSettings
+{
+ [CommandArgument(0, "")]
+ [Description("The root directory of the datapack to pack.")]
+ public required string Input { get; init; }
+}
+
+public class PackCommand : Command
+{
+ public override int Execute([NotNull] CommandContext context, [NotNull] PackSettings settings, CancellationToken cancellationToken)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on blue] --- Packing Datapack: {settings.Input} --- [/]");
+ try
+ {
+ var packDirectoryName = new DirectoryInfo(settings.Input).Name;
+ var outputFilePath = Path.Combine(Path.GetDirectoryName(settings.Input) ?? Environment.CurrentDirectory, $"{packDirectoryName}.szpack");
+
+ DatapackService.PackDatapack(settings.Input, outputFilePath);
+
+ AnsiConsole.MarkupLine($"[green]✅ Successfully created archive: {outputFilePath}[/]");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to pack datapack. Details: {ex.Message}");
+ return 1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/szpack/Program.cs b/tools/szpack/Program.cs
new file mode 100644
index 0000000..eb8b3ee
--- /dev/null
+++ b/tools/szpack/Program.cs
@@ -0,0 +1,36 @@
+/*
+ * WARNING:
+ * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project.
+ * It was created to be used as a quick and dirty validation tool for the szpack format.
+*/
+
+using Spectre.Console.Cli;
+
+namespace SessionZero.Tools.Packer;
+
+internal class Program
+{
+ static int Main(string[] args)
+ {
+ var app = new CommandApp();
+
+ app.Configure(config =>
+ {
+ config.SetApplicationName("szpack");
+
+ config.AddCommand("create")
+ .WithDescription("Creates a new datapack directory with test objects.");
+
+ config.AddCommand("pack")
+ .WithDescription("Compresses a datapack directory into a .szpack file.");
+
+ config.AddCommand("unpack")
+ .WithDescription("Extracts a .szpack file and displays its metadata.");
+
+ // Optional: Set a default command if no command is specified
+ // config.SetDefaultCommand();
+ });
+
+ return app.Run(args);
+ }
+}
\ No newline at end of file
diff --git a/tools/szpack/UnpackCommand.cs b/tools/szpack/UnpackCommand.cs
new file mode 100644
index 0000000..6509b2d
--- /dev/null
+++ b/tools/szpack/UnpackCommand.cs
@@ -0,0 +1,55 @@
+/*
+ * WARNING:
+ * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project.
+ * It was created to be used as a quick and dirty validation tool for the szpack format.
+*/
+
+using SessionZero.Shared.Services;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SessionZero.Tools.Packer;
+
+public class UnpackSettings : CommandSettings
+{
+ [CommandArgument(0, "")]
+ [Description("The path to the .szpack file.")]
+ public required string Input { get; init; }
+
+ [CommandOption("-o|--output")]
+ [Description("The directory where the pack will be extracted.")]
+ [DefaultValue("unpacked")]
+ public string Output { get; init; } = "unpacked";
+}
+
+public class UnpackCommand : AsyncCommand
+{
+ public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] UnpackSettings settings, CancellationToken cancellationToken)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on blue] --- Unpacking Datapack: {settings.Input} --- [/]");
+ try
+ {
+ // Unpack the archive
+ DatapackService.UnpackDatapack(settings.Input, settings.Output);
+ AnsiConsole.MarkupLine($"[green]✅ Successfully unpacked archive to: {settings.Output}[/]");
+
+ // Load and display the core data
+ var datapack = await DatapackService.LoadDatapackMetadataAsync(settings.Output);
+
+ AnsiConsole.MarkupLine("\n[bold]--- Datapack Info (szpack.json) ---[/]");
+ AnsiConsole.MarkupLine($"[yellow]ID:[/]\t\t{datapack.Id}");
+ AnsiConsole.MarkupLine($"[yellow]Name:[/]\t{datapack.Name}");
+ AnsiConsole.MarkupLine($"[yellow]Version:[/]\t{datapack.Version}");
+ AnsiConsole.MarkupLine($"[yellow]Author:[/]\t{datapack.Author}");
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to unpack or load datapack. Details: {ex.Message}");
+ return 1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/szpack/readme.md b/tools/szpack/readme.md
new file mode 100644
index 0000000..4575808
--- /dev/null
+++ b/tools/szpack/readme.md
@@ -0,0 +1,10 @@
+# SZPACK CLI Tool
+
+## ***WARNING:***
+This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project.
+It was created to be used as a quick and dirty validation tool for the szpack format.
+
+## Usage
+* `szpack create `: Creates a sample datapack directory with a given name
+* `szpack pack `: Packs a datapack directory into a szpack file (must be a valid datapack)
+* `szpack unpack `: Unpacks a .szpack file into a datapack directory and displays some metadata
\ No newline at end of file
diff --git a/tools/szpack/szpack.csproj b/tools/szpack/szpack.csproj
new file mode 100644
index 0000000..2437a23
--- /dev/null
+++ b/tools/szpack/szpack.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+