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 rows; } public class PullRequest { public string shard; public List 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 filterPlatforms; // Optional tag filter — only pull rows that have at least one of these tags. // Set to null to pull everything. public List filterTags; } public class PullResponse { public string shard; public List> rows; } public class PushPayload { public string shard; public string typeName; public Dictionary 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 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 ───────────────────────────────────────────────────────────── /// /// 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{"pc","ps5"} /// /// tagFilter: if non-null, only pull rows with at least one matching tag. /// Example: new List{"release","base_game"} /// /// Both filters are applied server-side so no extra data crosses the wire. /// public async Task PullAsync( List platformFilter = null, List tagFilter = null) { // 1 — Fetch server manifest (lightweight: id/version/checksum only) var manifestJson = await http.GetStringAsync($"{config.serverUrl}/manifest/{shardName}"); var serverManifest = JsonConvert.DeserializeObject(manifestJson); // 2 — Diff against local manifest var needIds = new List(); 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(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>(raw) ?? new(); } catch { localManifest = new(); } } void SaveLocalManifest() { File.WriteAllText(dbPath + ".manifest.json", JsonConvert.SerializeObject(localManifest, Formatting.Indented)); } // ── Helpers ─────────────────────────────────────────────────────────── static Dictionary EntryToDict(DataEntry entry) { var dict = new Dictionary(); 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 ( // 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 row) { // INSERT OR REPLACE INTO VALUES (...) } public void Dispose() { } } }