commit d1eeccbefc5038c34f04650e5082667589da606c Author: Vuk Savić Date: Mon Mar 16 21:38:49 2026 +0100 initial commit diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..4d76eed --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,3 @@ +GUILDLIB_API_KEY=changeme-use-a-real-secret +# PORT=3000 +# DOMAIN=guildlib.yourgame.com diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..848b1a6 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,4 @@ +{$DOMAIN:localhost} { + reverse_proxy guildlib:3000 + encode gzip +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..b812f7c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,17 @@ +FROM oven/bun:1 AS base +WORKDIR /app +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile --production +COPY src ./src + +FROM oven/bun:1-distroless AS runtime +WORKDIR /app +COPY --from=base /app/node_modules ./node_modules +COPY --from=base /app/src ./src +COPY --from=base /app/package.json ./ +VOLUME ["/app/shards", "/app/config"] +ENV PORT=3000 +ENV SHARD_DIR=/app/shards +ENV SCHEMA_PATH=/app/config/schema.json +EXPOSE 3000 +CMD ["bun", "run", "src/index.ts"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..325ca10 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,45 @@ +version: "3.9" + +services: + guildlib: + build: + context: ../server + dockerfile: ../docker/Dockerfile + container_name: guildlib-server + restart: unless-stopped + environment: + PORT: "3000" + SHARD_DIR: /app/shards + SCHEMA_PATH: /app/config/schema.json + GUILDLIB_API_KEY: "${GUILDLIB_API_KEY:-changeme}" + volumes: + - guildlib-shards:/app/shards + - guildlib-config:/app/config + ports: + - "3000:3000" + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r=>r.ok||process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + + # Uncomment + set DOMAIN in .env for production HTTPS + # caddy: + # image: caddy:2-alpine + # container_name: guildlib-caddy + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./Caddyfile:/etc/caddy/Caddyfile + # - caddy-data:/data + # - caddy-config:/config + # depends_on: + # - guildlib + +volumes: + guildlib-shards: + guildlib-config: + # caddy-data: + # caddy-config: diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 0000000..caf3b98 --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -0,0 +1,870 @@ +# Guildlib v2 — Complete Documentation + +--- + +## Table of contents + +1. [How it works — overview](#1-how-it-works) +2. [Unity attributes — complete reference](#2-unity-attributes) +3. [Creating your own shards and types](#3-creating-shards-and-types) +4. [Platform and tag system](#4-platform-and-tag-system) +5. [The sync cycle — step by step](#5-the-sync-cycle) +6. [Server API reference](#6-server-api-reference) +7. [Web editor guide](#7-web-editor-guide) +8. [Local testing (Windows / PowerShell)](#8-local-testing) +9. [Docker deployment](#9-docker-deployment) +10. [Changing things — common tasks](#10-changing-things) + +--- + +## 1. How it works + +``` +Unity C# classes + │ + │ [ShardTarget], [GuildLabel], [GuildPlatforms] … + │ (attributes on your classes describe everything) + ▼ +SchemaExporter.Export() + │ + │ produces schema.json + ▼ +POST /schema ──────────────────────────► Bun server + │ + │ stores schema.json + │ auto-creates .sqlite per shard + ▼ +Web editor reads schema.json and renders type-safe forms +Users create / edit entries in the browser +Changes stored in weapons.sqlite, audio.sqlite, etc. + +Unity Sync Panel clicks "Pull All Shards": + 1. GET /manifest/weapons → [{id, version, checksum}] (lightweight!) + 2. diff against local cache + 3. POST /pull { ids: [only what changed] } + 4. receive only changed rows + 5. upsert into local weapons.sqlite +``` + +Key design decisions inherited from the Perforce paper: +- **Manifest-first**: only checksums cross the wire on the first pass. + No data is transferred if nothing changed. +- **Shard independence**: each shard is a completely separate .sqlite file. + You can pull just `audio` without touching `weapons`. +- **Server-side filtering**: platform and tag filters are applied on the server + before rows are returned, so a Switch build only downloads Switch data. + +--- + +## 2. Unity attributes + +All attributes live in `Guildlib.Runtime`. Import the namespace at the top of +your C# files. + +### `[ShardTarget("name")]` ← **REQUIRED** + +Declares which shard database this type belongs to. + +```csharp +[ShardTarget("characters")] +public class CharacterStats : DataEntry { ... } + +[ShardTarget("ui")] +public class UIButtonConfig : DataEntry { ... } + +[ShardTarget("vfx_particles")] // underscores OK, lowercase +public class ParticlePreset : DataEntry { ... } +``` + +Rules: +- Lowercase, no spaces, no special characters except underscore +- The server auto-creates a new `.sqlite` file the first time an entry + of that type is pushed — you do not need to pre-register shards anywhere +- If two classes share a `[ShardTarget]` they live in the same database file + (the shard is a file, not a type) + +--- + +### `[GuildLabel("Display Name")]` ← optional + +Sets the human-readable name shown in the web editor's type list. +Defaults to the C# class name if omitted. + +```csharp +[ShardTarget("characters")] +[GuildLabel("Character Stats")] +public class CharacterStats : DataEntry { ... } +// Shows as "Character Stats" in the web editor, not "CharacterStats" +``` + +--- + +### `[GuildDescription("...")]` ← optional + +A one-sentence description shown in the web editor's form panel under the type name. +Use it to explain what this entry type is for. + +```csharp +[GuildDescription("Defines base stats for all playable and enemy characters.")] +public class CharacterStats : DataEntry { ... } +``` + +--- + +### `[GuildPlatforms("p1", "p2", ...)]` ← optional + +Sets the **default** platform tags for newly created entries of this type. +Can be overridden per-entry in the web editor. + +```csharp +// This entry type ships on all platforms by default +[GuildPlatforms("all")] +public class CharacterStats : DataEntry { ... } + +// PC and console only — Switch gets different data +[GuildPlatforms("pc", "ps5", "xbox_series")] +public class HighResTextureConfig : DataEntry { ... } + +// Switch-specific preset +[GuildPlatforms("switch", "mobile_ios", "mobile_android")] +public class LowResTextureConfig : DataEntry { ... } +``` + +If omitted, defaults to `["all"]` — meaning every platform gets this data. + +--- + +### `[GuildTags("t1", "t2", ...)]` ← optional + +Sets the **default** content/build tags for new entries of this type. +Can be overridden per-entry in the web editor. + +```csharp +[GuildTags("base_game", "release")] +public class CharacterStats : DataEntry { ... } + +[GuildTags("dlc_1")] +public class DLC1Character : CharacterStats { ... } + +[GuildTags("debug")] +public class DebugTestCharacter : CharacterStats { ... } +``` + +--- + +### `[GuildDisplayName]` ← optional, on a field + +Marks one field as the "display name" for the entry list in the web editor. +Without this, the entry ID is shown. Put it on whichever field is most useful +as a human-readable label. + +```csharp +public class CharacterStats : DataEntry +{ + [GuildDisplayName] + public string characterName; // web editor shows this in the list + + public float maxHealth; +} +``` + +--- + +### `[GuildEnum("A", "B", "C")]` ← optional, on a string field + +Constrains a string field to a fixed set of options. +The web editor renders a dropdown instead of a plain text input. + +```csharp +[GuildEnum("Warrior", "Mage", "Rogue", "Ranger")] +public string characterClass; + +[GuildEnum("Common", "Uncommon", "Rare", "Epic", "Legendary")] +public string rarity; + +[GuildEnum("Fire", "Ice", "Lightning", "Physical", "Poison")] +public string damageType; +``` + +--- + +### `[GuildRef("shard_name")]` ← optional, on a string field + +Marks a string field as a foreign key reference to an entry in another shard. +The web editor shows an annotated hint (full picker UI is a planned enhancement). +The actual stored value is still the entryId string. + +```csharp +public class CharacterStats : DataEntry +{ + [GuildRef("audio")] + public string deathSoundId; // points to an entry in audio.sqlite + + [GuildRef("models")] + public string modelId; // points to an entry in models.sqlite +} +``` + +--- + +### `[GuildMultiline]` ← optional, on a string field + +Makes the web editor render a textarea instead of a single-line input. +Use for long descriptions or multi-line text content. + +```csharp +[GuildMultiline] +public string loreText; + +[GuildMultiline] +public string debugNotes; +``` + +--- + +### `[GuildExclude]` ← optional, on a field + +Excludes a field from schema export and sync entirely. +Use for Unity-only fields (Texture2D refs, EditorGUILayout state, etc.) +that cannot be serialised to the database. + +```csharp +[GuildExclude] +public Texture2D previewTexture; // Unity asset ref — never synced + +[GuildExclude] +public bool editorFoldout; // editor UI state — never synced +``` + +--- + +## 3. Creating shards and types + +### Step 1 — Write a C# class + +Create a new .cs file anywhere under `Assets/` in your Unity project. + +```csharp +using UnityEngine; +using Guildlib.Runtime; + +// ── A brand new shard called "abilities" ───────────────────────────────────── + +[ShardTarget("abilities")] +[GuildLabel("Ability Definition")] +[GuildDescription("Defines a single player or enemy ability.")] +[GuildPlatforms("all")] +[GuildTags("base_game", "release")] +[CreateAssetMenu(menuName = "Guildlib/Ability")] +public class AbilityDefinition : DataEntry +{ + [GuildDisplayName] + public string abilityName; + + [GuildMultiline] + public string description; + + [GuildEnum("Active", "Passive", "Toggle")] + public string abilityType; + + public float cooldown; + public float manaCost; + public int maxLevel; + + [GuildRef("audio")] + public string castSoundId; + + [GuildRef("vfx_particles")] + public string castVFXId; +} + +// ── A child type — inherits all AbilityDefinition fields ───────────────────── + +[ShardTarget("abilities")] +[GuildLabel("Area Ability")] +[GuildDescription("An ability that affects an area rather than a single target.")] +public class AreaAbility : AbilityDefinition +{ + public float radius; + public bool falloffDamage; + + [GuildEnum("Circle", "Cone", "Rectangle")] + public string areaShape; +} +``` + +### Step 2 — Upload the schema + +Open **Window → Guildlib → Sync Panel**, then click **Upload Schema**. + +This sends all your C# type definitions to the server. The server stores +the schema as `config/schema.json`. The web editor reads this file to know +what types exist, what their fields are, and what options are available. + +The `abilities` shard now exists on the server. Its `.sqlite` file is created +automatically the first time an entry is pushed. + +### Step 3 — Use in the web editor + +Refresh the web editor. You will see the `abilities` shard appear in the left +column. Click it to see `Ability Definition` and `Area Ability` in the type tree. + +### Inheritance rules + +- A child type appears nested under its parent in the web editor +- The form shows inherited fields first (marked "inherited"), then own fields +- Child types go into the **same shard** as their parent +- You can nest arbitrarily deep: `DataEntry → Ability → AreaAbility → AOEBomb` +- Multiple children of the same parent are all shown in the tree + +--- + +## 4. Platform and tag system + +### What they are + +**Platforms** describe which shipping targets an entry is relevant for. +**Tags** describe which content group, build configuration, or DLC an entry belongs to. + +Both are stored per-entry (not per-type) and can be edited in the web editor. +Type-level `[GuildPlatforms]` and `[GuildTags]` attributes only set the default +for newly created entries — users can change them freely in the web editor. + +### Defining your vocabulary + +Both lists live in `DataEntry.cs`: + +```csharp +public static class GuildPlatformRegistry +{ + public static readonly string[] All = new[] + { + "pc", + "ps5", + "ps4", + "xbox_series", + "xbox_one", + "switch", + "mobile_ios", + "mobile_android", + "vr", + }; +} + +public static class GuildTagRegistry +{ + public static readonly string[] All = new[] + { + "release", + "debug", + "demo", + "base_game", + "dlc_1", + "dlc_2", + "high", + "medium", + "low", + "global", + "region_jp", + "region_eu", + "region_na", + }; +} +``` + +**To add a platform or tag**: add a string to the appropriate array and run +**Upload Schema** from the Sync Panel. The web editor will show the new option +immediately. + +**To remove one**: remove it from the array and re-upload the schema. Existing +entries that had the old value keep it stored in the database — the web editor +won't show the removed value as a selectable checkbox, but the data is preserved. + +### The special "all" platform + +An entry tagged `"all"` is returned for every platform filter. This means if +you add a new platform later, entries tagged "all" are automatically included +without any data migration. + +### Using platform/tag filters in Unity + +In the Sync Panel: + +1. Toggle on **Filter by platform** +2. Enter comma-separated platforms: `pc,ps5` +3. Toggle on **Filter by tag** (optional) +4. Enter tags: `release,base_game` +5. Click **Pull All Shards** + +Only rows matching the filters come down the wire. + +Programmatically from C#: + +```csharp +var client = new ShardClient(config, "abilities"); + +// Pull only PC/PS5 release content +await client.PullAsync( + platformFilter: new List { "pc", "ps5" }, + tagFilter: new List { "release", "base_game" } +); + +// Pull everything (no filter) +await client.PullAsync(); +``` + +### Using filters on the server API directly + +``` +GET /entries/abilities?platforms=pc,ps5&tags=release,base_game +POST /pull { "shard":"abilities", "ids":[...], "filterPlatforms":["switch"], "filterTags":["base_game"] } +``` + +--- + +## 5. The sync cycle + +### Full cycle (first time or clean slate) + +``` +1. GET /manifest/weapons + ← { shard:"weapons", rows: [] } ← empty, nothing on server yet + +2. (nothing to diff — skip pull) + +3. (user creates entries via web editor) + +4. GET /manifest/weapons + ← { rows: [{id:"abc", version:1, checksum:"d41d..."}, ...] } + +5. Diff: local has nothing → need all IDs + +6. POST /pull { shard:"weapons", ids:["abc", ...] } + ← { rows: [{id:"abc", weaponType:"Sword", damage:45, ...}, ...] } + +7. Upsert into local weapons.sqlite +8. Save manifest cache +``` + +### Incremental sync (normal operation) + +``` +1. GET /manifest/weapons + ← 10,000 rows of {id, version, checksum} + +2. Compare with local manifest: + - 9,995 rows unchanged → skip + - 5 rows have newer version → add to needIds + +3. POST /pull { ids: ["id1","id2","id3","id4","id5"] } + ← only those 5 rows + +4. Upsert 5 rows locally +5. Update manifest cache +``` + +This means a sync where nothing changed transfers only the manifest +(a flat JSON list of id/version/checksum — a few KB even for large datasets) +and then completes immediately. + +--- + +## 6. Server API reference + +All endpoints accept and return `application/json`. +If `GUILDLIB_API_KEY` is set, include `X-Api-Key: ` on every request +except `/health`. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Server status, version, shard list | +| GET | `/manifest/:shard` | Lightweight manifest for one shard | +| POST | `/pull` | Fetch rows by ID list with optional filters | +| POST | `/push` | Upsert one entry from Unity | +| POST | `/schema` | Upload schema.json from Unity | +| GET | `/schema` | Retrieve current schema.json | +| GET | `/entries/:shard` | List entries (supports query params) | +| POST | `/entries/:shard` | Create entry | +| GET | `/entries/:shard/:id` | Single entry | +| PUT | `/entries/:shard/:id` | Update entry | +| DELETE | `/entries/:shard/:id` | Soft-delete entry | + +### `/manifest/:shard` + +``` +GET /manifest/weapons +→ { + "shard": "weapons", + "rows": [ + { "id": "abc123", "version": 3, "checksum": "d41d8cd9..." }, + ... + ] + } +``` + +### `/pull` + +``` +POST /pull +{ + "shard": "weapons", + "ids": ["abc123", "def456"], + "filterPlatforms": ["pc", "ps5"], // optional + "filterTags": ["release"] // optional +} +→ { + "shard": "weapons", + "rows": [ + { "id":"abc123", "type_name":"...", "platforms":["all"], + "tags":["release","base_game"], "weaponType":"Sword", ... }, + ... + ] + } +``` + +### `/entries/:shard` (list) + +Query params: +- `type` — filter by C# type name +- `platforms` — comma-separated platform filter +- `tags` — comma-separated tag filter +- `parent` — filter by parent entry ID +- `limit` — default 100 +- `offset` — default 0 + +``` +GET /entries/weapons?type=Guildlib.Runtime.SwordData&platforms=pc,ps5&tags=release +``` + +### `/entries/:shard` (create) + +``` +POST /entries/weapons +{ + "typeName": "MyGame.AbilityDefinition", + "platforms": ["all"], + "tags": ["base_game", "release"], + "data": { + "abilityName": "Fireball", + "cooldown": 3.5, + "manaCost": 25 + } +} +→ { "id": "generated-uuid", "version": 1 } +``` + +### `/entries/:shard/:id` (update) + +``` +PUT /entries/weapons/abc123 +{ + "platforms": ["pc", "ps5"], + "tags": ["release"], + "data": { "cooldown": 4.0 } +} +``` + +Only fields present in `data` are merged. Other fields are preserved. + +--- + +## 7. Web editor guide + +Open `web/index.html` in Chrome or Edge (no build step, no server needed for the file itself). + +### Connecting + +Enter your server URL (default `http://localhost:3000`) and click **Connect**. +The editor fetches `/health` to verify, then loads `/schema` to build the UI. + +### Left column — shard and type tree + +Lists all shards the schema describes. Each shard has a colour dot. +Click a shard to expand its type tree. Types are shown as root + children: + +``` +weapons + ├─ WeaponData + │ └─ SwordData [child] + │ └─ DaggerData [child] + └─ ... +abilities + ├─ AbilityDefinition + │ └─ AreaAbility [child] +``` + +Click any type to load its entries. + +### Middle column — entry list + +Shows all entries of the selected type. Each row shows: +- The display name (from `[GuildDisplayName]` field, or entry ID) +- A short ID chip +- Row version +- Up to 3 platform tags (blue) +- Up to 2 content tags (yellow) + +Use the search box to filter by display name. + +### Right column — entry form + +**Platform tags**: checkbox grid of all defined platforms. Click to toggle. +Click "all" to mark as platform-agnostic. + +**Content tags**: checkbox grid of all defined tags. Click to toggle. + +**Fields**: rendered based on the type's field schema: +- `[GuildEnum]` → dropdown +- `bool` → toggle switch +- `int`/`float` → number input +- `string[]` → textarea (one value per line) +- `[GuildMultiline]` → textarea +- `[GuildRef]` → text input with shard hint +- Everything else → text input + +Inherited fields appear first with an "inherited" badge. + +Click **Create entry** or **Save changes** to persist. Click **Delete** to soft-delete +(the row is marked `deleted=1` and version-bumped so Unity clients know to remove it). + +--- + +## 8. Local testing (Windows / PowerShell) + +### Install Bun (once) + +```powershell +# Run as Administrator +powershell -c "irm bun.sh/install.ps1 | iex" +# Close and reopen PowerShell, then verify: +bun --version +``` + +### Start the server + +```powershell +cd C:\path\to\guildlib-v2\server + +bun install # first time only + +New-Item -ItemType Directory -Force -Path ".\shards",".\config" + +$env:GUILDLIB_API_KEY = "" # blank = auth disabled for local dev +$env:SHARD_DIR = ".\shards" +$env:SCHEMA_PATH = ".\config\schema.json" + +bun run dev +``` + +Expected output: +``` +[Guildlib] :3000 shards: auth: OFF +[ShardManager] Opened: weapons.sqlite +[ShardManager] Opened: audio.sqlite +... +``` + +### Verify it works + +```powershell +Invoke-RestMethod http://localhost:3000/health +``` + +### Push a test schema (without Unity) + +```powershell +$schema = Get-Content -Raw "C:\path\to\guildlib-v2\docs\example-schema.json" +Invoke-RestMethod -Method POST ` + -Uri http://localhost:3000/schema ` + -ContentType "application/json" ` + -Body $schema +``` + +Or use the example at the bottom of this document. + +### Create an entry via PowerShell + +```powershell +$body = '{"typeName":"MyGame.AbilityDefinition","platforms":["all"],"tags":["base_game"],"data":{"abilityName":"Fireball","cooldown":3.5}}' +Invoke-RestMethod -Method POST ` + -Uri http://localhost:3000/entries/abilities ` + -ContentType "application/json" ` + -Body $body +``` + +### Test the delta sync + +```powershell +# 1. Get manifest +Invoke-RestMethod http://localhost:3000/manifest/abilities + +# 2. Pull specific IDs +$pull = '{"shard":"abilities","ids":[""]}' +Invoke-RestMethod -Method POST ` + -Uri http://localhost:3000/pull ` + -ContentType "application/json" ` + -Body $pull + +# 3. Test platform filter +$pull = '{"shard":"abilities","ids":[""],"filterPlatforms":["switch"]}' +Invoke-RestMethod -Method POST ` + -Uri http://localhost:3000/pull ` + -ContentType "application/json" ` + -Body $pull +``` + +--- + +## 9. Docker deployment + +```bash +cd guildlib-v2/docker + +# 1. Copy env template +cp .env.example .env +# Edit .env — set GUILDLIB_API_KEY to a real secret + +# 2. Build and start +docker compose up -d + +# 3. Check it's running +curl http://localhost:3000/health +``` + +The `guildlib-shards` and `guildlib-config` Docker volumes persist your data +across container restarts and updates. + +To update the server: +```bash +docker compose pull +docker compose up -d +``` + +For HTTPS in production, uncomment the `caddy` block in `docker-compose.yml` +and set `DOMAIN=yourdomain.com` in `.env`. + +--- + +## 10. Changing things — common tasks + +### Add a new shard + +Just write a class with `[ShardTarget("new_shard_name")]` and upload the schema. +No server config changes needed. The `.sqlite` file is created automatically +on first push. + +### Add a new platform + +1. Open `DataEntry.cs` +2. Add your platform string to `GuildPlatformRegistry.All` +3. Click **Upload Schema** in the Sync Panel +4. The web editor now shows the new platform as a checkbox + +### Add a new tag + +Same as adding a platform, but edit `GuildTagRegistry.All`. + +### Add a field to an existing type + +1. Add the `public` field to your C# class +2. Click **Upload Schema** +3. Existing entries in the database keep all their old data — the new field + will be empty/default until entries are edited and re-saved + +### Rename a field + +Rename it in C# and re-upload the schema. The old field name remains in existing +database rows (the data is stored as JSON internally). Use the web editor to +open and re-save affected entries so they write the new field name. + +### Remove a field + +Remove it from C# and re-upload. Existing data with the old field name is +preserved in the database JSON blob but won't appear in the web editor forms. +It won't be synced to new Unity clients. + +### Change a shard name + +This is a breaking change. Rename the `[ShardTarget]` value, re-upload schema, +then manually copy the old `.sqlite` file on the server to the new name. +The server will open it automatically on restart. + +### Add a child type + +Subclass an existing DataEntry subclass, give it the same `[ShardTarget]`, +add its own fields, and upload the schema. It appears nested in the web editor +under its parent type immediately. + +--- + +## Example schema JSON (for testing without Unity) + +Save this as `example-schema.json` and POST it to `/schema`: + +```json +{ + "version": "2", + "exportedAt": 1710000000, + "unityProjectId": "test", + "shards": ["abilities", "characters"], + "platformConfig": { + "platforms": ["pc","ps5","xbox_series","switch","mobile_ios"], + "tags": ["release","debug","demo","base_game","dlc_1","high","low"] + }, + "types": [ + { + "typeName": "MyGame.AbilityDefinition", + "displayName": "Ability Definition", + "description": "Defines a single player or enemy ability.", + "shard": "abilities", + "parentType": null, + "childTypes": ["MyGame.AreaAbility"], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"abilityName","type":"string","isInherited":false,"isDisplayName":true}, + {"name":"description","type":"string","isInherited":false,"isMultiline":true}, + {"name":"abilityType","type":"string","isInherited":false,"enumOptions":["Active","Passive","Toggle"]}, + {"name":"cooldown","type":"float","isInherited":false}, + {"name":"manaCost","type":"float","isInherited":false}, + {"name":"maxLevel","type":"int","isInherited":false}, + {"name":"castSoundId","type":"string","isInherited":false,"refShard":"audio"} + ] + }, + { + "typeName": "MyGame.AreaAbility", + "displayName": "Area Ability", + "description": "An ability that affects an area.", + "shard": "abilities", + "parentType": "MyGame.AbilityDefinition", + "childTypes": [], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"abilityName","type":"string","isInherited":true,"isDisplayName":true}, + {"name":"cooldown","type":"float","isInherited":true}, + {"name":"radius","type":"float","isInherited":false}, + {"name":"falloffDamage","type":"bool","isInherited":false}, + {"name":"areaShape","type":"string","isInherited":false,"enumOptions":["Circle","Cone","Rectangle"]} + ] + }, + { + "typeName": "MyGame.CharacterStats", + "displayName": "Character Stats", + "description": "Base stats for all characters.", + "shard": "characters", + "parentType": null, + "childTypes": [], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"characterName","type":"string","isInherited":false,"isDisplayName":true}, + {"name":"maxHealth","type":"float","isInherited":false}, + {"name":"moveSpeed","type":"float","isInherited":false}, + {"name":"characterClass","type":"string","isInherited":false,"enumOptions":["Warrior","Mage","Rogue","Ranger"]}, + {"name":"loreText","type":"string","isInherited":false,"isMultiline":true} + ] + } + ] +} +``` diff --git a/docs/example-schema.json b/docs/example-schema.json new file mode 100644 index 0000000..2b973b1 --- /dev/null +++ b/docs/example-schema.json @@ -0,0 +1,82 @@ +{ + "version": "2", + "exportedAt": 1710000000, + "unityProjectId": "test", + "shards": ["abilities", "characters"], + "platformConfig": { + "platforms": ["pc", "ps5", "xbox_series", "switch", "mobile_ios"], + "tags": ["release", "debug", "demo", "base_game", "dlc_1", "high", "low"] + }, + "types": [ + { + "typeName": "MyGame.AbilityDefinition", + "displayName": "Ability Definition", + "description": "Defines a single player or enemy ability.", + "shard": "abilities", + "parentType": null, + "childTypes": ["MyGame.AreaAbility"], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"abilityName","type":"string","isInherited":false,"isDisplayName":true,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":""}, + {"name":"description","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":true,"refShard":null,"enumOptions":null,"defaultValue":""}, + {"name":"abilityType","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":["Active","Passive","Toggle"],"defaultValue":""}, + {"name":"cooldown","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"manaCost","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"maxLevel","type":"int","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"castSoundId","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":"audio","enumOptions":null,"defaultValue":""} + ] + }, + { + "typeName": "MyGame.AreaAbility", + "displayName": "Area Ability", + "description": "An ability that affects an area rather than a single target.", + "shard": "abilities", + "parentType": "MyGame.AbilityDefinition", + "childTypes": [], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"abilityName","type":"string","isInherited":true,"isDisplayName":true,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":""}, + {"name":"cooldown","type":"float","isInherited":true,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"radius","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"falloffDamage","type":"bool","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"false"}, + {"name":"areaShape","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":["Circle","Cone","Rectangle"],"defaultValue":""} + ] + }, + { + "typeName": "MyGame.CharacterStats", + "displayName": "Character Stats", + "description": "Base stats for all playable and enemy characters.", + "shard": "characters", + "parentType": null, + "childTypes": ["MyGame.EnemyStats"], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"characterName","type":"string","isInherited":false,"isDisplayName":true,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":""}, + {"name":"maxHealth","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"moveSpeed","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"characterClass","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":["Warrior","Mage","Rogue","Ranger"],"defaultValue":""}, + {"name":"loreText","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":true,"refShard":null,"enumOptions":null,"defaultValue":""} + ] + }, + { + "typeName": "MyGame.EnemyStats", + "displayName": "Enemy Stats", + "description": "Extended stats for enemy characters.", + "shard": "characters", + "parentType": "MyGame.CharacterStats", + "childTypes": [], + "defaultPlatforms": ["all"], + "defaultTags": ["base_game", "release"], + "fields": [ + {"name":"characterName","type":"string","isInherited":true,"isDisplayName":true,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":""}, + {"name":"maxHealth","type":"float","isInherited":true,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"aggroRange","type":"float","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"xpReward","type":"int","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":null,"defaultValue":"0"}, + {"name":"enemyType","type":"string","isInherited":false,"isDisplayName":false,"isMultiline":false,"refShard":null,"enumOptions":["Melee","Ranged","Caster","Boss"],"defaultValue":""} + ] + } + ] +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..897ceb8 --- /dev/null +++ b/server/package.json @@ -0,0 +1,11 @@ +{ + "name": "guildlib-server", + "version": "2.0.0", + "module": "src/index.ts", + "type": "module", + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "devDependencies": { "@types/bun": "latest" } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..33738cb --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,68 @@ +import { serve } from "bun"; +import { ShardManager } from "./shardManager"; +import { manifestHandler } from "./routes/manifest"; +import { pullHandler } from "./routes/pull"; +import { pushHandler } from "./routes/push"; +import { schemaHandler } from "./routes/schema"; +import { entriesHandler } from "./routes/entries"; +import { authMiddleware } from "./middleware/auth"; + +const PORT = Number(Bun.env.PORT ?? 3000); +const API_KEY = Bun.env.GUILDLIB_API_KEY ?? ""; + +export const shards = new ShardManager(Bun.env.SHARD_DIR ?? "./shards"); +await shards.init(); + +console.log(`[Guildlib] :${PORT} shards: ${shards.shardNames().join(", ")} auth: ${API_KEY ? "on" : "OFF"}`); + +serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (method === "OPTIONS") return cors(new Response(null, { status: 204 })); + + if (path !== "/health" && API_KEY) { + const err = authMiddleware(req, API_KEY); + if (err) return cors(err); + } + + if (path === "/health" && method === "GET") + return cors(json({ ok: true, version: "2.0.0", shards: shards.shardNames(), ts: Date.now() })); + + if (path.startsWith("/manifest/") && method === "GET") + return cors(await manifestHandler(shards, path.split("/")[2])); + + if (path === "/pull" && method === "POST") return cors(await pullHandler(shards, req)); + if (path === "/push" && method === "POST") return cors(await pushHandler(shards, req)); + + if (path === "/schema") { + if (method === "POST") return cors(await schemaHandler.post(req)); + if (method === "GET") return cors(await schemaHandler.get()); + } + + if (path.startsWith("/entries/")) + return cors(await entriesHandler(shards, req, url, path, method)); + + return cors(new Response("Not found", { status: 404 })); + }, + error(e) { + console.error(e); + return cors(json({ error: e.message }, 500)); + }, +}); + +export function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, headers: { "Content-Type": "application/json" } + }); +} + +export function cors(res: Response) { + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.headers.set("Access-Control-Allow-Headers", "Content-Type, X-Api-Key"); + return res; +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..3bea560 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,8 @@ +// middleware/auth.ts +export function authMiddleware(req: Request, key: string): Response | null { + if (req.headers.get("X-Api-Key") !== key) + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, headers: { "Content-Type": "application/json" } + }); + return null; +} diff --git a/server/src/routes/entries.ts b/server/src/routes/entries.ts new file mode 100644 index 0000000..b27e2ba --- /dev/null +++ b/server/src/routes/entries.ts @@ -0,0 +1,97 @@ +// routes/entries.ts +import type { ShardManager } from "../shardManager"; +import { json } from "../index"; +import { randomUUID } from "crypto"; + +function parseRow(r: any) { + return { + ...r, + platforms: JSON.parse(r.platforms || '["all"]'), + tags: JSON.parse(r.tags || "[]"), + data: JSON.parse(r.data_json), + data_json: undefined, + }; +} + +export async function entriesHandler( + shards: ShardManager, req: Request, url: URL, path: string, method: string +) { + const parts = path.split("/").filter(Boolean); + const shard = parts[1]; + const entryId = parts[2]; + + if (!shard) return json({ error: "Missing shard" }, 400); + + // ── List ──────────────────────────────────────────────────────────────── + if (!entryId && method === "GET") { + const platforms = url.searchParams.get("platforms")?.split(",").filter(Boolean); + const tags = url.searchParams.get("tags")?.split(",").filter(Boolean); + try { + const rows = shards.listEntries(shard, { + typeName: url.searchParams.get("type") ?? undefined, + parentId: url.searchParams.get("parent") ?? undefined, + platforms, tags, + limit: Number(url.searchParams.get("limit") ?? 100), + offset: Number(url.searchParams.get("offset") ?? 0), + }); + return json(rows.map(parseRow)); + } catch (e: any) { return json({ error: e.message }, 404); } + } + + // ── Create ─────────────────────────────────────────────────────────────── + if (!entryId && method === "POST") { + try { + const body = await req.json() as { + typeName: string; parentId?: string; + platforms?: string[]; tags?: string[]; + data: Record; + }; + const id = randomUUID().replace(/-/g, ""); + const row = shards.upsert(shard, { + id, + type_name: body.typeName, + parent_id: body.parentId ?? null, + platforms: body.platforms ?? ["all"], + tags: body.tags ?? [], + data: { ...body.data, entryId: id }, + }); + return json({ id: row.id, version: row.row_version }, 201); + } catch (e: any) { return json({ error: e.message }, 400); } + } + + // ── Single ─────────────────────────────────────────────────────────────── + if (entryId && method === "GET") { + const row = shards.getEntry(shard, entryId); + if (!row) return json({ error: "Not found" }, 404); + return json(parseRow(row)); + } + + // ── Update ─────────────────────────────────────────────────────────────── + if (entryId && method === "PUT") { + const existing = shards.getEntry(shard, entryId); + if (!existing) return json({ error: "Not found" }, 404); + try { + const body = await req.json() as { + platforms?: string[]; tags?: string[]; + data?: Record; + }; + const row = shards.upsert(shard, { + id: entryId, + type_name: existing.type_name, + parent_id: existing.parent_id, + platforms: body.platforms ?? JSON.parse(existing.platforms || '["all"]'), + tags: body.tags ?? JSON.parse(existing.tags || "[]"), + data: { ...JSON.parse(existing.data_json), ...(body.data ?? {}) }, + }); + return json({ id: row.id, version: row.row_version }); + } catch (e: any) { return json({ error: e.message }, 400); } + } + + // ── Delete ─────────────────────────────────────────────────────────────── + if (entryId && method === "DELETE") { + try { shards.softDelete(shard, entryId); return json({ ok: true }); } + catch (e: any) { return json({ error: e.message }, 400); } + } + + return json({ error: "Method not allowed" }, 405); +} diff --git a/server/src/routes/manifest.ts b/server/src/routes/manifest.ts new file mode 100644 index 0000000..e979091 --- /dev/null +++ b/server/src/routes/manifest.ts @@ -0,0 +1,8 @@ +// routes/manifest.ts +import type { ShardManager } from "../shardManager"; +import { json } from "../index"; + +export async function manifestHandler(shards: ShardManager, shardName: string) { + try { return json({ shard: shardName, rows: shards.getManifest(shardName) }); } + catch (e: any) { return json({ error: e.message }, 404); } +} diff --git a/server/src/routes/pull.ts b/server/src/routes/pull.ts new file mode 100644 index 0000000..c06873e --- /dev/null +++ b/server/src/routes/pull.ts @@ -0,0 +1,34 @@ +// routes/pull.ts +import type { ShardManager } from "../shardManager"; +import { json } from "../index"; + +export async function pullHandler(shards: ShardManager, req: Request) { + try { + const body = await req.json() as { + shard: string; + ids: string[]; + filterPlatforms?: string[] | null; + filterTags?: string[] | null; + }; + + const rows = shards.getRowsByIds( + body.shard, + body.ids, + body.filterPlatforms, + body.filterTags + ); + + const out = rows.map(r => ({ + id: r.id, + type_name: r.type_name, + parent_id: r.parent_id, + row_version: r.row_version, + platforms: JSON.parse(r.platforms || '["all"]'), + tags: JSON.parse(r.tags || "[]"), + deleted: r.deleted, + ...JSON.parse(r.data_json), + })); + + return json({ shard: body.shard, rows: out }); + } catch (e: any) { return json({ error: e.message }, 400); } +} diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts new file mode 100644 index 0000000..eaf93d8 --- /dev/null +++ b/server/src/routes/push.ts @@ -0,0 +1,28 @@ +// routes/push.ts +import type { ShardManager } from "../shardManager"; +import { json } from "../index"; +import { randomUUID } from "crypto"; + +export async function pushHandler(shards: ShardManager, req: Request) { + try { + const body = await req.json() as { + shard: string; + typeName: string; + platforms?: string[]; + tags?: string[]; + data: Record; + }; + + const id = (body.data.entryId as string) || randomUUID().replace(/-/g, ""); + const row = shards.upsert(body.shard, { + id, + type_name: body.typeName, + parent_id: (body.data.parentId as string) ?? null, + platforms: body.platforms ?? ["all"], + tags: body.tags ?? [], + data: body.data, + }); + + return json({ ok: true, id: row.id, version: row.row_version }); + } catch (e: any) { return json({ error: e.message }, 400); } +} diff --git a/server/src/routes/schema.ts b/server/src/routes/schema.ts new file mode 100644 index 0000000..bc39fba --- /dev/null +++ b/server/src/routes/schema.ts @@ -0,0 +1,24 @@ +// routes/schema.ts +import { json } from "../index"; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs"; +import { dirname } from "path"; + +const SCHEMA_PATH = Bun.env.SCHEMA_PATH ?? "./config/schema.json"; + +export const schemaHandler = { + async post(req: Request) { + try { + const text = await req.text(); + JSON.parse(text); // validate JSON + mkdirSync(dirname(SCHEMA_PATH), { recursive: true }); + writeFileSync(SCHEMA_PATH, text, "utf8"); + return json({ ok: true }); + } catch (e: any) { return json({ error: e.message }, 400); } + }, + async get() { + if (!existsSync(SCHEMA_PATH)) return json({ error: "No schema yet" }, 404); + return new Response(readFileSync(SCHEMA_PATH, "utf8"), { + headers: { "Content-Type": "application/json" } + }); + } +}; diff --git a/server/src/shardManager.ts b/server/src/shardManager.ts new file mode 100644 index 0000000..8fa4ac2 --- /dev/null +++ b/server/src/shardManager.ts @@ -0,0 +1,256 @@ +/** + * ShardManager v2 + * + * Schema (per shard .sqlite file): + * + * entries ( + * id TEXT PRIMARY KEY, + * type_name TEXT NOT NULL, + * parent_id TEXT, + * row_version INTEGER NOT NULL DEFAULT 1, + * checksum TEXT NOT NULL, + * platforms TEXT NOT NULL DEFAULT '["all"]', -- JSON array of platform strings + * tags TEXT NOT NULL DEFAULT '[]', -- JSON array of tag strings + * data_json TEXT NOT NULL, + * created_at INTEGER NOT NULL, + * updated_at INTEGER NOT NULL, + * deleted INTEGER NOT NULL DEFAULT 0 + * ) + * + * manifest_cache ( + * id TEXT PRIMARY KEY, + * row_version INTEGER NOT NULL, + * checksum TEXT NOT NULL + * ) + */ + +import { Database } from "bun:sqlite"; +import { mkdirSync, readdirSync } from "fs"; +import { join } from "path"; +import { createHash } from "crypto"; + +export interface ManifestRow { + id: string; version: number; checksum: string; +} + +export interface EntryRow { + id: string; type_name: string; parent_id: string | null; + row_version: number; checksum: string; + platforms: string; // stored as JSON string, e.g. '["pc","ps5"]' + tags: string; // stored as JSON string, e.g. '["release","base_game"]' + data_json: string; + created_at: number; updated_at: number; deleted: number; +} + +export class ShardManager { + private dir: string; + private dbs: Map = new Map(); + + constructor(dir: string) { this.dir = dir; } + + async init() { + mkdirSync(this.dir, { recursive: true }); + // Open any .sqlite files already on disk + for (const file of readdirSync(this.dir)) { + if (file.endsWith(".sqlite")) + this.openShard(file.replace(".sqlite", "")); + } + } + + // Opens (or creates) a shard SQLite file + openShard(name: string) { + if (this.dbs.has(name)) return; + const path = join(this.dir, `${name}.sqlite`); + const db = new Database(path, { create: true }); + db.exec("PRAGMA journal_mode=WAL;"); + db.exec("PRAGMA synchronous=NORMAL;"); + db.exec(` + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + type_name TEXT NOT NULL, + parent_id TEXT, + row_version INTEGER NOT NULL DEFAULT 1, + checksum TEXT NOT NULL, + platforms TEXT NOT NULL DEFAULT '["all"]', + tags TEXT NOT NULL DEFAULT '[]', + data_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_type ON entries(type_name); + CREATE INDEX IF NOT EXISTS idx_parent ON entries(parent_id); + CREATE INDEX IF NOT EXISTS idx_version ON entries(row_version); + CREATE INDEX IF NOT EXISTS idx_deleted ON entries(deleted); + + CREATE TABLE IF NOT EXISTS manifest_cache ( + id TEXT PRIMARY KEY, + row_version INTEGER NOT NULL, + checksum TEXT NOT NULL + ); + `); + this.dbs.set(name, db); + console.log(`[ShardManager] Opened: ${name}.sqlite`); + } + + shardNames(): string[] { return Array.from(this.dbs.keys()); } + + private require(name: string): Database { + if (!this.dbs.has(name)) this.openShard(name); // auto-create new shards + return this.dbs.get(name)!; + } + + // ── Manifest ───────────────────────────────────────────────────────────── + + getManifest(shardName: string): ManifestRow[] { + return this.require(shardName) + .query("SELECT id, row_version AS version, checksum FROM manifest_cache") + .all() as ManifestRow[]; + } + + // ── Pull: fetch rows by IDs with optional platform/tag filtering ────────── + + getRowsByIds( + shardName: string, + ids: string[], + filterPlatforms?: string[] | null, + filterTags?: string[] | null + ): EntryRow[] { + if (ids.length === 0) return []; + const db = this.require(shardName); + const placeholders = ids.map(() => "?").join(","); + + // Start with ID list + let rows = db.query( + `SELECT * FROM entries WHERE id IN (${placeholders}) AND deleted = 0` + ).all(...ids) as EntryRow[]; + + // Apply platform filter in JS (SQLite JSON functions vary by version) + if (filterPlatforms && filterPlatforms.length > 0) { + rows = rows.filter(r => { + const plats: string[] = JSON.parse(r.platforms || '["all"]'); + return plats.includes("all") || + plats.some(p => filterPlatforms.includes(p)); + }); + } + + // Apply tag filter + if (filterTags && filterTags.length > 0) { + rows = rows.filter(r => { + const rowTags: string[] = JSON.parse(r.tags || "[]"); + return rowTags.length === 0 || // no tags = unrestricted + rowTags.some(t => filterTags.includes(t)); + }); + } + + return rows; + } + + // ── Upsert ──────────────────────────────────────────────────────────────── + + upsert(shardName: string, entry: { + id: string; + type_name: string; + parent_id?: string | null; + platforms?: string[]; + tags?: string[]; + data: Record; + }): EntryRow { + const db = this.require(shardName); + const now = Date.now(); + const dataJson = JSON.stringify(entry.data); + const checksum = md5(dataJson); + const platforms = JSON.stringify(entry.platforms ?? ["all"]); + const tags = JSON.stringify(entry.tags ?? []); + + const existing = db.query( + "SELECT row_version FROM entries WHERE id = ?" + ).get(entry.id) as { row_version: number } | null; + + const newVersion = (existing?.row_version ?? 0) + 1; + + db.query(` + INSERT INTO entries + (id, type_name, parent_id, row_version, checksum, platforms, tags, data_json, created_at, updated_at, deleted) + VALUES + ($id, $type_name, $parent_id, $ver, $chk, $plats, $tags, $data, $now, $now, 0) + ON CONFLICT(id) DO UPDATE SET + type_name = $type_name, + parent_id = $parent_id, + row_version = $ver, + checksum = $chk, + platforms = $plats, + tags = $tags, + data_json = $data, + updated_at = $now, + deleted = 0 + `).run({ + $id: entry.id, $type_name: entry.type_name, + $parent_id: entry.parent_id ?? null, + $ver: newVersion, $chk: checksum, + $plats: platforms, $tags: tags, + $data: dataJson, $now: now, + }); + + db.query(` + INSERT INTO manifest_cache (id, row_version, checksum) + VALUES ($id, $ver, $chk) + ON CONFLICT(id) DO UPDATE SET row_version=$ver, checksum=$chk + `).run({ $id: entry.id, $ver: newVersion, $chk: checksum }); + + return db.query("SELECT * FROM entries WHERE id = ?").get(entry.id) as EntryRow; + } + + // ── CRUD ────────────────────────────────────────────────────────────────── + + listEntries(shardName: string, opts: { + typeName?: string; parentId?: string; + platforms?: string[]; tags?: string[]; + limit?: number; offset?: number; + } = {}): EntryRow[] { + const db = this.require(shardName); + const clauses = ["deleted = 0"]; + const params: unknown[] = []; + + if (opts.typeName) { clauses.push("type_name = ?"); params.push(opts.typeName); } + if (opts.parentId) { clauses.push("parent_id = ?"); params.push(opts.parentId); } + + params.push(opts.limit ?? 100, opts.offset ?? 0); + let rows = db.query( + `SELECT * FROM entries WHERE ${clauses.join(" AND ")} ORDER BY updated_at DESC LIMIT ? OFFSET ?` + ).all(...params) as EntryRow[]; + + // Platform filter + if (opts.platforms?.length) { + rows = rows.filter(r => { + const plats: string[] = JSON.parse(r.platforms || '["all"]'); + return plats.includes("all") || plats.some(p => opts.platforms!.includes(p)); + }); + } + // Tag filter + if (opts.tags?.length) { + rows = rows.filter(r => { + const rowTags: string[] = JSON.parse(r.tags || "[]"); + return rowTags.length === 0 || rowTags.some(t => opts.tags!.includes(t)); + }); + } + + return rows; + } + + getEntry(shardName: string, id: string): EntryRow | null { + return this.require(shardName) + .query("SELECT * FROM entries WHERE id = ? AND deleted = 0") + .get(id) as EntryRow | null; + } + + softDelete(shardName: string, id: string) { + const db = this.require(shardName); + const now = Date.now(); + db.query("UPDATE entries SET deleted=1, updated_at=?, row_version=row_version+1 WHERE id=?").run(now, id); + const row = db.query("SELECT row_version, checksum FROM entries WHERE id=?").get(id) as any; + if (row) db.query("UPDATE manifest_cache SET row_version=? WHERE id=?").run(row.row_version, id); + } +} + +function md5(s: string) { return createHash("md5").update(s).digest("hex"); } diff --git a/unity/Assets/Guildlib/Editor/GuildlibSyncWindow.cs b/unity/Assets/Guildlib/Editor/GuildlibSyncWindow.cs new file mode 100644 index 0000000..661757b --- /dev/null +++ b/unity/Assets/Guildlib/Editor/GuildlibSyncWindow.cs @@ -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("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(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(platformFilter.Split(',', StringSplitOptions.RemoveEmptyEntries)) + : null; + var tagList = filterByTag && !string.IsNullOrWhiteSpace(tagFilter) + ? new System.Collections.Generic.List(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(json); + var arr = (Newtonsoft.Json.Linq.JArray)data.shards; + targets = arr?.ToObject() ?? Array.Empty(); + } + + 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 fn) + { + busy = true; + fn().ContinueWith(_ => + { + busy = false; + EditorApplication.delayCall += Repaint; + }); + } + } +} +#endif diff --git a/unity/Assets/Guildlib/Runtime/DataEntry.cs b/unity/Assets/Guildlib/Runtime/DataEntry.cs new file mode 100644 index 0000000..0d47a3a --- /dev/null +++ b/unity/Assets/Guildlib/Runtime/DataEntry.cs @@ -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. + // ========================================================================= + + /// + /// 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")] + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class ShardTargetAttribute : Attribute + { + public string ShardName { get; } + public ShardTargetAttribute(string shardName) => ShardName = shardName; + } + + /// + /// 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")] + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GuildLabelAttribute : Attribute + { + public string Label { get; } + public GuildLabelAttribute(string label) => Label = label; + } + + /// + /// OPTIONAL — a short description shown in the web editor under the type name. + /// + /// Usage: + /// [GuildDescription("Defines stats for all enemy and player characters.")] + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GuildDescriptionAttribute : Attribute + { + public string Description { get; } + public GuildDescriptionAttribute(string desc) => Description = desc; + } + + /// + /// 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")] + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GuildPlatformsAttribute : Attribute + { + public string[] Platforms { get; } + public GuildPlatformsAttribute(params string[] platforms) => Platforms = platforms; + } + + /// + /// 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")] + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class GuildTagsAttribute : Attribute + { + public string[] Tags { get; } + public GuildTagsAttribute(params string[] tags) => Tags = tags; + } + + /// + /// 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 + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] + public sealed class GuildExcludeAttribute : Attribute { } + + /// + /// 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; + /// + [AttributeUsage(AttributeTargets.Field)] + public sealed class GuildDisplayNameAttribute : Attribute { } + + /// + /// 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; + /// + [AttributeUsage(AttributeTargets.Field)] + public sealed class GuildRefAttribute : Attribute + { + public string TargetShard { get; } + public GuildRefAttribute(string targetShard) => TargetShard = targetShard; + } + + /// + /// OPTIONAL on a field — hints the web editor to render a multiline + /// textarea instead of a single-line input. + /// + /// Usage: + /// [GuildMultiline] + /// public string description; + /// + [AttributeUsage(AttributeTargets.Field)] + public sealed class GuildMultilineAttribute : Attribute { } + + /// + /// 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; + /// + [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. + // ========================================================================= + + /// + /// Central registry for all platform identifiers used in this project. + /// Edit this list to match your shipping targets. + /// + public static class GuildPlatformRegistry + { + /// + /// All valid platform names for this project. + /// Add your platforms here — these appear as checkboxes in the web editor. + /// + public static readonly string[] All = new[] + { + "pc", + "ps5", + "ps4", + "xbox_series", + "xbox_one", + "switch", + "mobile_ios", + "mobile_android", + "vr", + }; + + /// + /// Convenience constant — use as a shorthand for "ships on every platform". + /// + public const string AllPlatforms = "all"; + } + + /// + /// 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. + /// + public static class GuildTagRegistry + { + /// + /// All valid tag names for this project. + /// Add your tags here — these appear as checkboxes in the web editor. + /// + 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. + // ========================================================================= + + /// + /// 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; + /// } + /// + [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; + + /// + /// 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. + /// + [HideInInspector] + public string parentId = null; + + [HideInInspector] + public string typeName; + + [HideInInspector] + public string shardName; + + /// + /// 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. + /// + [HideInInspector] + public List platforms = new(); + + /// + /// 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. + /// + [HideInInspector] + public List 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); + } + } + } +} diff --git a/unity/Assets/Guildlib/Runtime/GuildlibConfig.cs b/unity/Assets/Guildlib/Runtime/GuildlibConfig.cs new file mode 100644 index 0000000..62e6253 --- /dev/null +++ b/unity/Assets/Guildlib/Runtime/GuildlibConfig.cs @@ -0,0 +1,47 @@ +using UnityEngine; + +namespace Guildlib.Runtime +{ + /// + /// 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 + /// + [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]; + + /// + /// Returns the list of shards to sync. If empty, returns null + /// (meaning: ask the server for all available shards). + /// + public string[] GetSyncTargets() => + shardsToSync != null && shardsToSync.Length > 0 ? shardsToSync : null; + + public static GuildlibConfig Load() => + Resources.Load("GuildlibConfig"); + } +} diff --git a/unity/Assets/Guildlib/Runtime/SchemaExporter.cs b/unity/Assets/Guildlib/Runtime/SchemaExporter.cs new file mode 100644 index 0000000..22351dc --- /dev/null +++ b/unity/Assets/Guildlib/Runtime/SchemaExporter.cs @@ -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 childTypes = new(); + public List defaultPlatforms = new(); // from [GuildPlatforms] + public List defaultTags = new(); // from [GuildTags] + public List fields = new(); + } + + [Serializable] + public class PlatformConfig + { + public List platforms = new(); + public List tags = new(); + } + + [Serializable] + public class SchemaManifest + { + public string version = "2"; + public long exportedAt; + public string unityProjectId; + public List shards = new(); // distinct shard names + public PlatformConfig platformConfig = new(); // all valid platforms + tags + public List 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(); } }) + .Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t)) + .ToList(); + + var shardSet = new HashSet(); + var typeMap = new Dictionary(); + + foreach (var t in entryTypes) + { + var shardAttr = t.GetCustomAttribute(false); + var shard = shardAttr?.ShardName ?? "default"; + shardSet.Add(shard); + + // Display name + var labelAttr = t.GetCustomAttribute(false); + var displayName = labelAttr?.Label ?? t.Name; + + // Description + var descAttr = t.GetCustomAttribute(false); + var desc = descAttr?.Description ?? ""; + + // Default platforms + var platformAttr = t.GetCustomAttribute(false); + var defaultPlats = platformAttr != null + ? platformAttr.Platforms.ToList() + : new List { GuildPlatformRegistry.AllPlatforms }; + + // Default tags + var tagsAttr = t.GetCustomAttribute(false); + var defaultTags = tagsAttr?.Tags.ToList() ?? new List(); + + // 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 BuildFields(Type t, List allEntryTypes) + { + var result = new List(); + var baseType = typeof(DataEntry); + var seen = new HashSet(); + + // Walk up chain so parent fields appear first + var chain = new List(); + 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() != null) continue; + if (f.GetCustomAttribute() != null) continue; + if (seen.Contains(f.Name)) continue; + seen.Add(f.Name); + + var refAttr = f.GetCustomAttribute(); + var enumAttr = f.GetCustomAttribute(); + + result.Add(new FieldSchema + { + name = f.Name, + type = MapType(f.FieldType), + isArray = f.FieldType.IsArray, + isInherited = inherited, + isDisplayName = f.GetCustomAttribute() != null, + isMultiline = f.GetCustomAttribute() != 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 ""; + } + } +} diff --git a/unity/Assets/Guildlib/Runtime/ShardClient.cs b/unity/Assets/Guildlib/Runtime/ShardClient.cs new file mode 100644 index 0000000..fc0df8f --- /dev/null +++ b/unity/Assets/Guildlib/Runtime/ShardClient.cs @@ -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 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() { } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..abfda7c --- /dev/null +++ b/web/index.html @@ -0,0 +1,530 @@ + + + + + +Guildlib Editor + + + + + +
+ + + + + +