initial commit

This commit is contained in:
Vuk Savić
2026-03-16 21:38:49 +01:00
commit d1eeccbefc
21 changed files with 3178 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using Guildlib.Runtime;
using Newtonsoft.Json;
namespace Guildlib.Editor
{
public class GuildlibSyncWindow : EditorWindow
{
GuildlibConfig config;
string log = "";
bool busy = false;
Vector2 scroll;
string statusText = "Not connected";
bool statusOk = false;
// Platform / tag filter toggles for pull operations
bool filterByPlatform = false;
bool filterByTag = false;
string platformFilter = "";
string tagFilter = "";
[MenuItem("Window/Guildlib/Sync Panel")]
static void Open() => GetWindow<GuildlibSyncWindow>("Guildlib").Show();
void OnEnable()
{
config = GuildlibConfig.Load();
if (config == null)
Log("No GuildlibConfig found. Create one via Assets > Create > Guildlib > Config.");
}
void OnGUI()
{
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("Guildlib Sync Panel", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
config = (GuildlibConfig)EditorGUILayout.ObjectField("Config", config, typeof(GuildlibConfig), false);
if (config == null)
{
EditorGUILayout.HelpBox("Assign a GuildlibConfig asset.", MessageType.Warning);
return;
}
EditorGUILayout.LabelField("Server", config.serverUrl, EditorStyles.miniLabel);
var style = statusOk ? EditorStyles.boldLabel : EditorStyles.miniLabel;
EditorGUILayout.LabelField("Status", statusText, style);
EditorGUILayout.Space(8);
// ── Filters ──────────────────────────────────────────────────────
EditorGUILayout.LabelField("Pull filters (optional)", EditorStyles.boldLabel);
filterByPlatform = EditorGUILayout.Toggle("Filter by platform", filterByPlatform);
if (filterByPlatform)
{
EditorGUI.indentLevel++;
platformFilter = EditorGUILayout.TextField("Platforms (comma-separated)", platformFilter);
EditorGUILayout.HelpBox(
"Example: pc,ps5\n" +
"Valid values: " + string.Join(", ", GuildPlatformRegistry.All),
MessageType.Info);
EditorGUI.indentLevel--;
}
filterByTag = EditorGUILayout.Toggle("Filter by tag", filterByTag);
if (filterByTag)
{
EditorGUI.indentLevel++;
tagFilter = EditorGUILayout.TextField("Tags (comma-separated)", tagFilter);
EditorGUILayout.HelpBox(
"Example: release,base_game\n" +
"Valid values: " + string.Join(", ", GuildTagRegistry.All),
MessageType.Info);
EditorGUI.indentLevel--;
}
EditorGUILayout.Space(8);
// ── Action buttons ────────────────────────────────────────────────
EditorGUI.BeginDisabledGroup(busy);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Ping Server", GUILayout.Height(28))) RunAsync(PingAsync);
if (GUILayout.Button("Pull All Shards", GUILayout.Height(28))) RunAsync(PullAllAsync);
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Export Schema", GUILayout.Height(28))) RunAsync(ExportAsync);
if (GUILayout.Button("Upload Schema", GUILayout.Height(28))) RunAsync(UploadAsync);
EditorGUILayout.EndHorizontal();
EditorGUI.EndDisabledGroup();
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Log", EditorStyles.boldLabel);
scroll = EditorGUILayout.BeginScrollView(scroll, GUILayout.Height(240));
EditorGUILayout.TextArea(log, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
if (GUILayout.Button("Clear", GUILayout.Height(18))) log = "";
}
// ── Handlers ─────────────────────────────────────────────────────────
async Task PingAsync()
{
Log("Pinging...");
try
{
using var http = MakeHttp();
var json = await http.GetStringAsync($"{config.serverUrl}/health");
var data = JsonConvert.DeserializeObject<dynamic>(json);
statusOk = true;
statusText = $"Online — shards: {string.Join(", ", ((Newtonsoft.Json.Linq.JArray)data.shards) ?? new Newtonsoft.Json.Linq.JArray())}";
Log($"OK — {statusText}");
}
catch (Exception e) { statusOk = false; statusText = "Unreachable"; Log($"FAIL: {e.Message}"); }
}
async Task PullAllAsync()
{
var platforms = filterByPlatform && !string.IsNullOrWhiteSpace(platformFilter)
? new System.Collections.Generic.List<string>(platformFilter.Split(',', StringSplitOptions.RemoveEmptyEntries))
: null;
var tagList = filterByTag && !string.IsNullOrWhiteSpace(tagFilter)
? new System.Collections.Generic.List<string>(tagFilter.Split(',', StringSplitOptions.RemoveEmptyEntries))
: null;
if (platforms != null) Log($"Platform filter: {string.Join(", ", platforms)}");
if (tagList != null) Log($"Tag filter: {string.Join(", ", tagList)}");
// Determine which shards to sync
var targets = config.GetSyncTargets();
if (targets == null)
{
// Ask server for all available shards
using var http = MakeHttp();
var json = await http.GetStringAsync($"{config.serverUrl}/health");
var data = JsonConvert.DeserializeObject<dynamic>(json);
var arr = (Newtonsoft.Json.Linq.JArray)data.shards;
targets = arr?.ToObject<string[]>() ?? Array.Empty<string>();
}
Log($"Pulling {targets.Length} shard(s): {string.Join(", ", targets)}");
int totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
foreach (var shard in targets)
{
try
{
var client = new ShardClient(config, shard);
var result = await client.PullAsync(platforms, tagList);
totalAdded += result.added;
totalUpdated += result.updated;
totalSkipped += result.skipped;
Log($" {shard}: +{result.added} added ~{result.updated} updated {result.skipped} unchanged");
}
catch (Exception e) { Log($" {shard}: FAIL — {e.Message}"); }
}
Log($"Done — added:{totalAdded} updated:{totalUpdated} skipped:{totalSkipped}");
AssetDatabase.Refresh();
}
async Task ExportAsync()
{
Log("Exporting schema...");
try
{
var json = SchemaExporter.Export(config.projectId);
var dir = Path.Combine(Application.dataPath, "..", "GuildlibExports");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "schema.json");
File.WriteAllText(path, json);
Log($"Exported to: {path}");
}
catch (Exception e) { Log($"FAIL: {e.Message}"); }
await Task.CompletedTask;
}
async Task UploadAsync()
{
Log("Uploading schema...");
try
{
var json = SchemaExporter.Export(config.projectId);
using var http = MakeHttp();
var resp = await http.PostAsync(
$"{config.serverUrl}/schema",
new StringContent(json, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
Log("Schema uploaded.");
}
catch (Exception e) { Log($"FAIL: {e.Message}"); }
}
// ── Helpers ───────────────────────────────────────────────────────────
HttpClient MakeHttp()
{
var h = new HttpClient();
if (!string.IsNullOrEmpty(config.apiKey))
h.DefaultRequestHeaders.Add("X-Api-Key", config.apiKey);
return h;
}
void Log(string msg)
{
log = $"[{DateTime.Now:HH:mm:ss}] {msg}\n{log}";
Repaint();
}
void RunAsync(Func<Task> fn)
{
busy = true;
fn().ContinueWith(_ =>
{
busy = false;
EditorApplication.delayCall += Repaint;
});
}
}
}
#endif

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Guildlib.Runtime
{
// =========================================================================
// ATTRIBUTES
// Apply these to your DataEntry subclasses to configure Guildlib behaviour.
// =========================================================================
/// <summary>
/// REQUIRED on every concrete DataEntry subclass.
/// Declares which shard database this type belongs to.
///
/// The shard name is free-form — use any string you want.
/// The server will auto-create a new .sqlite file for any new shard name
/// the first time an entry of that type is pushed.
///
/// Usage:
/// [ShardTarget("characters")]
/// [ShardTarget("ui")]
/// [ShardTarget("levels")]
///
/// Shard names are lowercase, no spaces. Use underscores if needed:
/// [ShardTarget("vfx_particles")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ShardTargetAttribute : Attribute
{
public string ShardName { get; }
public ShardTargetAttribute(string shardName) => ShardName = shardName;
}
/// <summary>
/// OPTIONAL — gives the type a human-readable label for the web editor.
/// If omitted, the class name is used.
///
/// Usage:
/// [GuildLabel("Character Stats")]
/// [GuildLabel("UI Button")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildLabelAttribute : Attribute
{
public string Label { get; }
public GuildLabelAttribute(string label) => Label = label;
}
/// <summary>
/// OPTIONAL — a short description shown in the web editor under the type name.
///
/// Usage:
/// [GuildDescription("Defines stats for all enemy and player characters.")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildDescriptionAttribute : Attribute
{
public string Description { get; }
public GuildDescriptionAttribute(string desc) => Description = desc;
}
/// <summary>
/// OPTIONAL — declares which platforms this entry type is relevant for.
/// This is a DEFAULT for new entries of this type. Individual entries can
/// override their platform tags at runtime in the web editor.
///
/// Platforms are free-form strings — define your own set.
/// Common examples: "pc", "ps5", "xbox", "switch", "mobile", "all"
///
/// Usage:
/// [GuildPlatforms("all")]
/// [GuildPlatforms("pc", "ps5", "xbox")]
/// [GuildPlatforms("switch", "mobile")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildPlatformsAttribute : Attribute
{
public string[] Platforms { get; }
public GuildPlatformsAttribute(params string[] platforms) => Platforms = platforms;
}
/// <summary>
/// OPTIONAL — declares which build configurations (tags) this entry type
/// belongs to by default.
///
/// Tags are free-form — define your own taxonomy.
/// Examples: "debug", "release", "demo", "dlc1", "alpha"
///
/// Usage:
/// [GuildTags("release", "demo")]
/// [GuildTags("debug")]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GuildTagsAttribute : Attribute
{
public string[] Tags { get; }
public GuildTagsAttribute(params string[] tags) => Tags = tags;
}
/// <summary>
/// OPTIONAL on a field or property — exclude it from schema export and sync.
/// Use for Unity-internal or editor-only fields that should not hit the server.
///
/// Usage:
/// [GuildExclude]
/// public Texture2D previewTexture; // Unity asset ref, not serialisable to DB
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class GuildExcludeAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a field — marks it as the display name for this entry
/// in the web editor's entry list. Without this, entryId is shown.
///
/// Usage:
/// [GuildDisplayName]
/// public string characterName;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildDisplayNameAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a field — marks it as a reference to another shard entry.
/// The web editor will show a picker instead of a plain text field.
///
/// Usage:
/// [GuildRef("characters")]
/// public string ownerCharacterId;
///
/// [GuildRef("audio")]
/// public string deathSoundId;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildRefAttribute : Attribute
{
public string TargetShard { get; }
public GuildRefAttribute(string targetShard) => TargetShard = targetShard;
}
/// <summary>
/// OPTIONAL on a field — hints the web editor to render a multiline
/// textarea instead of a single-line input.
///
/// Usage:
/// [GuildMultiline]
/// public string description;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildMultilineAttribute : Attribute { }
/// <summary>
/// OPTIONAL on a string field — constrains the value to one of the
/// provided options. The web editor renders a dropdown.
///
/// Usage:
/// [GuildEnum("None", "Rare", "Epic", "Legendary")]
/// public string rarity;
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class GuildEnumAttribute : Attribute
{
public string[] Options { get; }
public GuildEnumAttribute(params string[] options) => Options = options;
}
// =========================================================================
// PLATFORM / TAG REGISTRY
// Define your project's platform and tag vocabulary here.
// These are used for validation and for populating dropdowns in the
// web editor. Add or remove entries freely — they're just strings.
// =========================================================================
/// <summary>
/// Central registry for all platform identifiers used in this project.
/// Edit this list to match your shipping targets.
/// </summary>
public static class GuildPlatformRegistry
{
/// <summary>
/// All valid platform names for this project.
/// Add your platforms here — these appear as checkboxes in the web editor.
/// </summary>
public static readonly string[] All = new[]
{
"pc",
"ps5",
"ps4",
"xbox_series",
"xbox_one",
"switch",
"mobile_ios",
"mobile_android",
"vr",
};
/// <summary>
/// Convenience constant — use as a shorthand for "ships on every platform".
/// </summary>
public const string AllPlatforms = "all";
}
/// <summary>
/// Central registry for all tag identifiers used in this project.
/// Tags can represent build configs, DLC groups, content regions,
/// quality tiers, or any other grouping you need.
/// </summary>
public static class GuildTagRegistry
{
/// <summary>
/// All valid tag names for this project.
/// Add your tags here — these appear as checkboxes in the web editor.
/// </summary>
public static readonly string[] All = new[]
{
// Build types
"release",
"debug",
"demo",
// DLC / content waves
"base_game",
"dlc_1",
"dlc_2",
// Quality / LOD tiers
"high",
"medium",
"low",
// Region
"global",
"region_jp",
"region_eu",
"region_na",
};
}
// =========================================================================
// DataEntry BASE CLASS
// Every entry stored in Guildlib inherits from this.
// =========================================================================
/// <summary>
/// Base class for all Guildlib-managed data entries.
///
/// To create a new data type:
///
/// 1. Subclass DataEntry
/// 2. Add [ShardTarget("your_shard_name")] ← required
/// 3. Optionally add [GuildLabel], [GuildDescription], [GuildPlatforms], [GuildTags]
/// 4. Add your public fields
/// 5. Open Window → Guildlib → Sync Panel and click Upload Schema
///
/// Example:
///
/// [ShardTarget("characters")]
/// [GuildLabel("Character Stats")]
/// [GuildPlatforms("all")]
/// [GuildTags("base_game", "release")]
/// [CreateAssetMenu(menuName = "Guildlib/Character Stats")]
/// public class CharacterStats : DataEntry
/// {
/// [GuildDisplayName]
/// public string characterName;
/// public float maxHealth;
/// public float moveSpeed;
///
/// [GuildEnum("Warrior", "Mage", "Rogue")]
/// public string characterClass;
/// }
/// </summary>
[Serializable]
public abstract class DataEntry : ScriptableObject
{
// ── Guildlib-managed fields (not shown in web editor forms) ──────────
[HideInInspector]
public string entryId = Guid.NewGuid().ToString("N");
[HideInInspector]
public long rowVersion = 0;
/// <summary>
/// ID of a parent entry (same shard). Set this to link a child entry
/// to its parent, e.g. a specific SwordData instance linked to a
/// WeaponData template.
/// </summary>
[HideInInspector]
public string parentId = null;
[HideInInspector]
public string typeName;
[HideInInspector]
public string shardName;
/// <summary>
/// Platform tags for this specific entry instance.
/// Defaults to the class-level [GuildPlatforms] attribute value,
/// but can be overridden per-entry in the web editor.
/// </summary>
[HideInInspector]
public List<string> platforms = new();
/// <summary>
/// Content/build tags for this specific entry instance.
/// Defaults to the class-level [GuildTags] attribute value,
/// but can be overridden per-entry in the web editor.
/// </summary>
[HideInInspector]
public List<string> tags = new();
protected virtual void OnEnable()
{
typeName = GetType().FullName;
var shard = GetType().GetCustomAttributes(typeof(ShardTargetAttribute), false);
shardName = shard.Length > 0
? ((ShardTargetAttribute)shard[0]).ShardName
: "default";
// Apply class-level platform defaults if none set yet
if (platforms.Count == 0)
{
var pa = (GuildPlatformsAttribute[])GetType()
.GetCustomAttributes(typeof(GuildPlatformsAttribute), false);
if (pa.Length > 0) platforms.AddRange(pa[0].Platforms);
else platforms.Add(GuildPlatformRegistry.AllPlatforms);
}
// Apply class-level tag defaults if none set yet
if (tags.Count == 0)
{
var ta = (GuildTagsAttribute[])GetType()
.GetCustomAttributes(typeof(GuildTagsAttribute), false);
if (ta.Length > 0) tags.AddRange(ta[0].Tags);
}
}
}
}

View File

@@ -0,0 +1,47 @@
using UnityEngine;
namespace Guildlib.Runtime
{
/// <summary>
/// Project-level Guildlib configuration.
///
/// HOW TO CREATE:
/// Assets → Create → Guildlib → Config
/// Name it exactly "GuildlibConfig" and place it in any Resources/ folder.
///
/// HOW TO CONFIGURE:
/// - Server URL: your Bun server address (local or production)
/// - Project ID: any unique string identifying your game project
/// - API Key: must match GUILDLIB_API_KEY env var on the server
/// Leave blank during local development (server auth disabled)
/// - Shard list: comma-separated list of shards to sync, or leave empty
/// to sync ALL shards the server knows about
/// </summary>
[CreateAssetMenu(menuName = "Guildlib/Config", fileName = "GuildlibConfig")]
public class GuildlibConfig : ScriptableObject
{
[Header("Server connection")]
public string serverUrl = "http://localhost:3000";
public string projectId = "";
[Tooltip("Must match GUILDLIB_API_KEY on the server. Leave blank for local dev.")]
public string apiKey = "";
[Header("Local storage")]
[Tooltip("Folder name inside Application.persistentDataPath where .sqlite shards are saved.")]
public string shardFolder = "GuildlibShards";
[Header("Sync targets")]
[Tooltip("Leave empty to sync all shards. Or list specific shard names to limit syncing.")]
public string[] shardsToSync = new string[0];
/// <summary>
/// Returns the list of shards to sync. If empty, returns null
/// (meaning: ask the server for all available shards).
/// </summary>
public string[] GetSyncTargets() =>
shardsToSync != null && shardsToSync.Length > 0 ? shardsToSync : null;
public static GuildlibConfig Load() =>
Resources.Load<GuildlibConfig>("GuildlibConfig");
}
}

View File

@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEngine;
using Newtonsoft.Json;
namespace Guildlib.Runtime
{
// =========================================================================
// Schema wire types — serialised to JSON and sent to the server
// =========================================================================
[Serializable]
public class FieldSchema
{
public string name;
public string type; // "string" | "int" | "float" | "bool" | "string[]" …
public bool isArray;
public bool isInherited; // true = defined on a parent class
public bool isDisplayName; // true = [GuildDisplayName] present
public bool isMultiline; // true = [GuildMultiline] present
public string refShard; // non-null = [GuildRef("shard")] present
public string[] enumOptions; // non-null = [GuildEnum(...)] present
public string defaultValue;
}
[Serializable]
public class TypeSchema
{
public string typeName; // fully-qualified C# type name
public string displayName; // from [GuildLabel] or class name
public string description; // from [GuildDescription] or ""
public string shard; // from [ShardTarget]
public string parentType; // null if top-level DataEntry subclass
public List<string> childTypes = new();
public List<string> defaultPlatforms = new(); // from [GuildPlatforms]
public List<string> defaultTags = new(); // from [GuildTags]
public List<FieldSchema> fields = new();
}
[Serializable]
public class PlatformConfig
{
public List<string> platforms = new();
public List<string> tags = new();
}
[Serializable]
public class SchemaManifest
{
public string version = "2";
public long exportedAt;
public string unityProjectId;
public List<string> shards = new(); // distinct shard names
public PlatformConfig platformConfig = new(); // all valid platforms + tags
public List<TypeSchema> types = new();
}
// =========================================================================
// Exporter
// =========================================================================
public static class SchemaExporter
{
public static string Export(string projectId = "")
{
var manifest = new SchemaManifest
{
exportedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
unityProjectId = projectId,
platformConfig = new PlatformConfig
{
platforms = GuildPlatformRegistry.All.ToList(),
tags = GuildTagRegistry.All.ToList(),
}
};
// Discover all concrete DataEntry subclasses in all loaded assemblies
var baseType = typeof(DataEntry);
var entryTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
.Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
.ToList();
var shardSet = new HashSet<string>();
var typeMap = new Dictionary<string, TypeSchema>();
foreach (var t in entryTypes)
{
var shardAttr = t.GetCustomAttribute<ShardTargetAttribute>(false);
var shard = shardAttr?.ShardName ?? "default";
shardSet.Add(shard);
// Display name
var labelAttr = t.GetCustomAttribute<GuildLabelAttribute>(false);
var displayName = labelAttr?.Label ?? t.Name;
// Description
var descAttr = t.GetCustomAttribute<GuildDescriptionAttribute>(false);
var desc = descAttr?.Description ?? "";
// Default platforms
var platformAttr = t.GetCustomAttribute<GuildPlatformsAttribute>(false);
var defaultPlats = platformAttr != null
? platformAttr.Platforms.ToList()
: new List<string> { GuildPlatformRegistry.AllPlatforms };
// Default tags
var tagsAttr = t.GetCustomAttribute<GuildTagsAttribute>(false);
var defaultTags = tagsAttr?.Tags.ToList() ?? new List<string>();
// Parent type (walk up skipping non-DataEntry classes)
string parentTypeName = null;
var parent = t.BaseType;
while (parent != null && parent != baseType &&
parent != typeof(ScriptableObject) &&
parent != typeof(UnityEngine.Object))
{
if (entryTypes.Contains(parent)) { parentTypeName = parent.FullName; break; }
parent = parent.BaseType;
}
var schema = new TypeSchema
{
typeName = t.FullName,
displayName = displayName,
description = desc,
shard = shard,
parentType = parentTypeName,
defaultPlatforms = defaultPlats,
defaultTags = defaultTags,
fields = BuildFields(t, entryTypes),
};
typeMap[t.FullName] = schema;
manifest.types.Add(schema);
}
// Second pass — populate childTypes
foreach (var ts in manifest.types)
if (ts.parentType != null && typeMap.TryGetValue(ts.parentType, out var p))
p.childTypes.Add(ts.typeName);
manifest.shards = shardSet.OrderBy(s => s).ToList();
return JsonConvert.SerializeObject(manifest, Formatting.Indented);
}
// ── Field reflection ─────────────────────────────────────────────────
static List<FieldSchema> BuildFields(Type t, List<Type> allEntryTypes)
{
var result = new List<FieldSchema>();
var baseType = typeof(DataEntry);
var seen = new HashSet<string>();
// Walk up chain so parent fields appear first
var chain = new List<Type>();
var cur = t;
while (cur != null && cur != baseType && cur != typeof(ScriptableObject))
{
chain.Add(cur); cur = cur.BaseType;
}
chain.Reverse();
foreach (var type in chain)
{
bool inherited = type != t;
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.DeclaredOnly))
{
if (f.GetCustomAttribute<GuildExcludeAttribute>() != null) continue;
if (f.GetCustomAttribute<HideInInspector>() != null) continue;
if (seen.Contains(f.Name)) continue;
seen.Add(f.Name);
var refAttr = f.GetCustomAttribute<GuildRefAttribute>();
var enumAttr = f.GetCustomAttribute<GuildEnumAttribute>();
result.Add(new FieldSchema
{
name = f.Name,
type = MapType(f.FieldType),
isArray = f.FieldType.IsArray,
isInherited = inherited,
isDisplayName = f.GetCustomAttribute<GuildDisplayNameAttribute>() != null,
isMultiline = f.GetCustomAttribute<GuildMultilineAttribute>() != null,
refShard = refAttr?.TargetShard,
enumOptions = enumAttr?.Options,
defaultValue = GetDefault(f.FieldType),
});
}
}
return result;
}
static string MapType(Type t)
{
if (t == typeof(string)) return "string";
if (t == typeof(int)) return "int";
if (t == typeof(long)) return "long";
if (t == typeof(float)) return "float";
if (t == typeof(double)) return "double";
if (t == typeof(bool)) return "bool";
if (t == typeof(string[])) return "string[]";
if (t == typeof(int[])) return "int[]";
if (t == typeof(float[])) return "float[]";
return "string";
}
static string GetDefault(Type t)
{
if (t == typeof(bool)) return "false";
if (t == typeof(float) || t == typeof(double) ||
t == typeof(int) || t == typeof(long)) return "0";
return "";
}
}
}

View File

@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using Newtonsoft.Json;
namespace Guildlib.Runtime
{
// ── Wire types ────────────────────────────────────────────────────────────
public class ManifestRow
{
public string id;
public long version;
public string checksum;
}
public class ShardManifest
{
public string shard;
public List<ManifestRow> rows;
}
public class PullRequest
{
public string shard;
public List<string> ids;
// Optional platform filter — only pull rows tagged for these platforms.
// The server will include rows tagged "all" regardless of this list.
// Set to null to pull everything.
public List<string> filterPlatforms;
// Optional tag filter — only pull rows that have at least one of these tags.
// Set to null to pull everything.
public List<string> filterTags;
}
public class PullResponse
{
public string shard;
public List<Dictionary<string,object>> rows;
}
public class PushPayload
{
public string shard;
public string typeName;
public Dictionary<string,object> data;
}
public class SyncResult
{
public string shard;
public int added;
public int updated;
public int skipped;
}
// ── ShardClient ───────────────────────────────────────────────────────────
public class ShardClient
{
readonly GuildlibConfig config;
readonly HttpClient http;
readonly string shardName;
readonly string dbPath;
// Local manifest: entryId -> (version, checksum)
Dictionary<string,(long version, string checksum)> localManifest = new();
public ShardClient(GuildlibConfig cfg, string shard)
{
config = cfg;
shardName = shard;
http = new HttpClient();
if (!string.IsNullOrEmpty(cfg.apiKey))
http.DefaultRequestHeaders.Add("X-Api-Key", cfg.apiKey);
var dir = Path.Combine(Application.persistentDataPath, cfg.shardFolder);
Directory.CreateDirectory(dir);
dbPath = Path.Combine(dir, $"{shard}.sqlite");
LoadLocalManifest();
}
// ── Pull ─────────────────────────────────────────────────────────────
/// <summary>
/// Pull all changed rows from the server for this shard.
///
/// platformFilter: if non-null, only pull rows whose platforms include
/// at least one of the given values, or "all".
/// Example: new List<string>{"pc","ps5"}
///
/// tagFilter: if non-null, only pull rows with at least one matching tag.
/// Example: new List<string>{"release","base_game"}
///
/// Both filters are applied server-side so no extra data crosses the wire.
/// </summary>
public async Task<SyncResult> PullAsync(
List<string> platformFilter = null,
List<string> tagFilter = null)
{
// 1 — Fetch server manifest (lightweight: id/version/checksum only)
var manifestJson = await http.GetStringAsync($"{config.serverUrl}/manifest/{shardName}");
var serverManifest = JsonConvert.DeserializeObject<ShardManifest>(manifestJson);
// 2 — Diff against local manifest
var needIds = new List<string>();
foreach (var row in serverManifest.rows)
{
if (!localManifest.TryGetValue(row.id, out var local) ||
local.version < row.version ||
local.checksum != row.checksum)
needIds.Add(row.id);
}
var result = new SyncResult
{
shard = shardName,
skipped = serverManifest.rows.Count - needIds.Count
};
if (needIds.Count == 0) return result;
// 3 — Request only the delta rows, with optional server-side filtering
var pullReq = new PullRequest
{
shard = shardName,
ids = needIds,
filterPlatforms = platformFilter,
filterTags = tagFilter,
};
var reqJson = JsonConvert.SerializeObject(pullReq);
var response = await http.PostAsync(
$"{config.serverUrl}/pull",
new StringContent(reqJson, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
var pullJson = await response.Content.ReadAsStringAsync();
var pullResp = JsonConvert.DeserializeObject<PullResponse>(pullJson);
// 4 — Upsert into local SQLite shard
int added = 0, updated = 0;
using var db = new LocalShardDb(dbPath);
db.EnsureTable(shardName);
foreach (var row in pullResp.rows)
{
bool isNew = !localManifest.ContainsKey(row["id"].ToString());
db.Upsert(shardName, row);
if (isNew) added++; else updated++;
}
// 5 — Persist updated manifest
foreach (var mr in serverManifest.rows)
localManifest[mr.id] = (mr.version, mr.checksum);
SaveLocalManifest();
result.added = added;
result.updated = updated;
return result;
}
// ── Push ─────────────────────────────────────────────────────────────
public async Task PushAsync(DataEntry entry)
{
var data = EntryToDict(entry);
var payload = new PushPayload
{
shard = shardName,
typeName = entry.typeName,
data = data,
};
var json = JsonConvert.SerializeObject(payload);
var response = await http.PostAsync(
$"{config.serverUrl}/push",
new StringContent(json, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
}
// ── Local manifest ────────────────────────────────────────────────────
void LoadLocalManifest()
{
var path = dbPath + ".manifest.json";
if (!File.Exists(path)) return;
try
{
var raw = File.ReadAllText(path);
localManifest = JsonConvert.DeserializeObject<
Dictionary<string,(long, string)>>(raw) ?? new();
}
catch { localManifest = new(); }
}
void SaveLocalManifest()
{
File.WriteAllText(dbPath + ".manifest.json",
JsonConvert.SerializeObject(localManifest, Formatting.Indented));
}
// ── Helpers ───────────────────────────────────────────────────────────
static Dictionary<string,object> EntryToDict(DataEntry entry)
{
var dict = new Dictionary<string,object>();
foreach (var f in entry.GetType().GetFields(
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.Instance))
{
dict[f.Name] = f.GetValue(entry);
}
return dict;
}
}
// ── LocalShardDb stub ─────────────────────────────────────────────────────
// Replace the method bodies with your chosen Unity SQLite package.
// Recommended: https://github.com/gkoreman/SQLite4Unity3d
// or sqlite-net-pcl via NuGet
public class LocalShardDb : IDisposable
{
readonly string path;
public LocalShardDb(string dbPath) { path = dbPath; }
public void EnsureTable(string tableName)
{
// CREATE TABLE IF NOT EXISTS <tableName> (
// id TEXT PRIMARY KEY,
// type_name TEXT,
// parent_id TEXT,
// platforms TEXT, -- JSON array
// tags TEXT, -- JSON array
// row_version INTEGER,
// data_json TEXT,
// updated_at INTEGER
// )
}
public void Upsert(string tableName, Dictionary<string,object> row)
{
// INSERT OR REPLACE INTO <tableName> VALUES (...)
}
public void Dispose() { }
}
}