initial commit
This commit is contained in:
340
unity/Assets/Guildlib/Runtime/DataEntry.cs
Normal file
340
unity/Assets/Guildlib/Runtime/DataEntry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
unity/Assets/Guildlib/Runtime/GuildlibConfig.cs
Normal file
47
unity/Assets/Guildlib/Runtime/GuildlibConfig.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
222
unity/Assets/Guildlib/Runtime/SchemaExporter.cs
Normal file
222
unity/Assets/Guildlib/Runtime/SchemaExporter.cs
Normal 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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
252
unity/Assets/Guildlib/Runtime/ShardClient.cs
Normal file
252
unity/Assets/Guildlib/Runtime/ShardClient.cs
Normal 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() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user