initial commit

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

3
docker/.env.example Normal file
View File

@@ -0,0 +1,3 @@
GUILDLIB_API_KEY=changeme-use-a-real-secret
# PORT=3000
# DOMAIN=guildlib.yourgame.com

4
docker/Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
{$DOMAIN:localhost} {
reverse_proxy guildlib:3000
encode gzip
}

17
docker/Dockerfile Normal file
View File

@@ -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"]

45
docker/docker-compose.yml Normal file
View File

@@ -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:

870
docs/DOCUMENTATION.md Normal file
View File

@@ -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 `<name>.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<string> { "pc", "ps5" },
tagFilter: new List<string> { "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: <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":["<paste-id-here>"]}'
Invoke-RestMethod -Method POST `
-Uri http://localhost:3000/pull `
-ContentType "application/json" `
-Body $pull
# 3. Test platform filter
$pull = '{"shard":"abilities","ids":["<id>"],"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}
]
}
]
}
```

82
docs/example-schema.json Normal file
View File

@@ -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":""}
]
}
]
}

11
server/package.json Normal file
View File

@@ -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" }
}

68
server/src/index.ts Normal file
View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
};
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<string, unknown>;
};
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);
}

View File

@@ -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); }
}

34
server/src/routes/pull.ts Normal file
View File

@@ -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); }
}

28
server/src/routes/push.ts Normal file
View File

@@ -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<string, unknown>;
};
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); }
}

View File

@@ -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" }
});
}
};

256
server/src/shardManager.ts Normal file
View File

@@ -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<string, Database> = 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<string, unknown>;
}): 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"); }

View File

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

View File

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

View File

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

View File

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

View File

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

530
web/index.html Normal file
View File

@@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Guildlib Editor</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"/>
<style>
:root {
--bg:#0d0e11; --bg2:#13151a; --bg3:#1c1f27; --bg4:#252930;
--border:#2e3340; --border2:#3d4455;
--accent:#e8ff6e; --accent2:#6ef0ff; --accent3:#ff6e8a;
--text:#d4d9e8; --text2:#7c859e; --text3:#4a5068;
--mono:'JetBrains Mono',monospace; --display:'Syne',sans-serif;
--r:6px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;line-height:1.5}
#root{height:100vh;display:flex;flex-direction:column}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
/* topbar */
.topbar{height:48px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:16px;flex-shrink:0;z-index:10}
.logo{font-family:var(--display);font-weight:800;font-size:16px;color:var(--accent);display:flex;align-items:center;gap:8px}
.logo span{color:var(--text2);font-weight:400;font-size:12px}
.sep{width:1px;height:20px;background:var(--border)}
.status{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}
.dot{width:6px;height:6px;border-radius:50%;background:var(--text3);transition:background .3s}
.dot.ok{background:#6effb4}.dot.err{background:var(--accent3)}
.right{margin-left:auto;display:flex;align-items:center;gap:10px}
input.srv{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:11px;padding:5px 10px;width:220px;outline:none}
input.srv:focus{border-color:var(--accent)}
/* buttons */
.btn{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:6px 14px;cursor:pointer;transition:all .15s;white-space:nowrap}
.btn:hover{background:var(--bg4);border-color:var(--accent);color:var(--accent)}
.btn.pri{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:700}
.btn.pri:hover{background:#d4eb5a}
.btn.danger{border-color:var(--accent3);color:var(--accent3)}
.btn.danger:hover{background:rgba(255,110,138,.1)}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn.sm{font-size:11px;padding:3px 10px}
/* layout */
.layout{display:grid;grid-template-columns:220px 280px 1fr;flex:1;overflow:hidden}
.panel{border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.ph{padding:12px 16px;border-bottom:1px solid var(--border);font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text3);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.pb{flex:1;overflow-y:auto}
/* shard list */
.shard-item{padding:10px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--border);transition:background .1s}
.shard-item:hover,.shard-item.active{background:var(--bg3)}
.shard-item.active .sname{color:var(--accent)}
.sdot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
.sname{font-size:12px;font-weight:500}
.scnt{margin-left:auto;font-size:10px;color:var(--text3);background:var(--bg4);padding:1px 6px;border-radius:10px}
/* type tree */
.type-item{padding:8px 16px 8px 28px;cursor:pointer;display:flex;align-items:center;gap:8px;border-bottom:1px solid rgba(46,51,64,.5);transition:background .1s}
.type-item:hover{background:var(--bg3)}
.type-item.active{background:var(--bg3);border-left:2px solid var(--accent);padding-left:26px}
.tname{font-size:12px}
.tbadge{margin-left:auto;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--bg4);color:var(--text3);text-transform:lowercase}
.tbadge.child{background:rgba(232,255,110,.1);color:var(--accent)}
/* entry list */
.search-bar{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;flex-shrink:0}
.sinput{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:6px 10px;outline:none}
.sinput:focus{border-color:var(--accent2)}
.erow{padding:10px 16px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .1s}
.erow:hover{background:var(--bg3)}
.erow.active{background:var(--bg3);border-left:2px solid var(--accent2);padding-left:14px}
.ename{font-size:12px;font-weight:500;margin-bottom:3px}
.emeta{font-size:10px;color:var(--text3);display:flex;gap:8px;flex-wrap:wrap}
.chip{font-size:9px;padding:1px 5px;border-radius:3px;background:var(--bg4);color:var(--text3);white-space:nowrap}
.chip.plat{background:rgba(110,240,255,.1);color:var(--accent2)}
.chip.tag{background:rgba(232,255,110,.08);color:#b8cc55}
/* form */
.form-area{flex:1;overflow:hidden;display:flex;flex-direction:column}
.ftopbar{padding:12px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;background:var(--bg2)}
.ftitle{font-family:var(--display);font-size:15px;font-weight:600}
.fsub{font-size:11px;color:var(--text3);margin-left:auto}
.fbody{flex:1;overflow-y:auto;padding:24px}
.fsave{padding:16px 24px;border-top:1px solid var(--border);display:flex;gap:10px;flex-shrink:0}
/* field rows */
.fgroup{margin-bottom:22px}
.fsec{font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text3);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.fsec-badge{font-size:9px;background:var(--bg4);color:var(--text3);padding:1px 6px;border-radius:3px;text-transform:none;letter-spacing:0}
.frow{display:grid;grid-template-columns:160px 1fr;align-items:start;gap:12px;margin-bottom:10px}
.flabel{font-size:12px;color:var(--text2);padding-top:7px;display:flex;flex-direction:column;gap:2px}
.fhint{font-size:10px;color:var(--text3)}
.finput{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--text);font-family:var(--mono);font-size:12px;padding:7px 10px;outline:none;width:100%;transition:border-color .2s}
.finput:focus{border-color:var(--accent2);background:var(--bg2)}
.finput[type=number]::-webkit-inner-spin-button{opacity:.3}
select.finput{cursor:pointer}
textarea.finput{resize:vertical;min-height:80px}
/* checkbox toggle */
.toggle-wrap{display:flex;align-items:center;gap:8px;padding-top:6px}
.toggle{position:relative;width:34px;height:18px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-track{position:absolute;inset:0;background:var(--bg4);border:1px solid var(--border2);border-radius:9px;transition:background .2s}
.toggle input:checked+.toggle-track{background:var(--accent);border-color:var(--accent)}
.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .2s;pointer-events:none}
.toggle input:checked~.toggle-thumb{transform:translateX(16px)}
.toggle-label{font-size:12px;color:var(--text)}
/* platform/tag editor */
.tag-grid{display:flex;flex-wrap:wrap;gap:6px;padding-top:4px}
.tag-chip{display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:4px;border:1px solid var(--border);cursor:pointer;font-size:11px;color:var(--text2);transition:all .15s;user-select:none}
.tag-chip:hover{border-color:var(--border2);color:var(--text)}
.tag-chip.on{border-color:var(--accent2);color:var(--accent2);background:rgba(110,240,255,.08)}
.tag-chip.on.tag-type{border-color:var(--accent);color:var(--accent);background:rgba(232,255,110,.06)}
.tag-dot{width:5px;height:5px;border-radius:50%;background:currentColor}
/* empty */
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text3);font-size:12px}
.eicon{font-size:28px;opacity:.25}
.lbar{height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:scan 1.2s linear infinite;overflow:hidden}
@keyframes scan{0%{transform:translateX(-100%)}100%{transform:translateX(200%)}
}
/* toast */
.toast-host{position:fixed;bottom:20px;right:20px;display:flex;flex-direction:column;gap:8px;z-index:999;pointer-events:none}
.toast{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);padding:10px 16px;font-size:12px;display:flex;align-items:center;gap:10px;animation:tin .2s ease;box-shadow:0 4px 24px rgba(0,0,0,.5)}
.toast.ok{border-color:#6effb4}.toast.err{border-color:var(--accent3)}
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.hint-bar{padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-presets="react">
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// ── helpers ───────────────────────────────────────────────────────────────────
const SHARD_COLORS = ['#ff8c42','#6ef0ff','#c06eff','#6effb4','#ff6e8a','#ffe06e','#6ea8ff'];
function shardColor(name, allShards) {
const i = allShards.indexOf(name);
return SHARD_COLORS[i % SHARD_COLORS.length] || '#7c859e';
}
function makeApi(base, key) {
const h = { 'Content-Type':'application/json', ...(key ? {'X-Api-Key':key} : {}) };
return {
get: p => fetch(base+p,{headers:h}).then(r=>r.json()),
post: (p,b)=> fetch(base+p,{method:'POST', headers:h,body:JSON.stringify(b)}).then(r=>r.json()),
put: (p,b)=> fetch(base+p,{method:'PUT', headers:h,body:JSON.stringify(b)}).then(r=>r.json()),
delete: p => fetch(base+p,{method:'DELETE',headers:h}).then(r=>r.json()),
};
}
function useToasts() {
const [toasts, set] = useState([]);
const push = useCallback((msg, type='ok') => {
const id = Date.now();
set(t=>[...t,{id,msg,type}]);
setTimeout(()=>set(t=>t.filter(x=>x.id!==id)),3000);
},[]);
return { toasts, push };
}
// ── TagEditor ─────────────────────────────────────────────────────────────────
function TagEditor({ label, available, selected, onChange, colorClass }) {
return (
<div className="fgroup">
<div className="fsec">{label}</div>
<div className="frow">
<label className="flabel">{label.toLowerCase()}<span className="fhint">click to toggle</span></label>
<div className="tag-grid">
{available.map(v => {
const on = selected.includes(v) || selected.includes('all');
return (
<div
key={v}
className={`tag-chip ${on?'on':''} ${colorClass||''}`}
onClick={() => {
if (v === 'all') { onChange(['all']); return; }
const next = selected.filter(s=>s!=='all');
onChange(next.includes(v) ? next.filter(s=>s!==v) : [...next, v]);
}}
>
<span className="tag-dot"/>
{v}
</div>
);
})}
</div>
</div>
</div>
);
}
// ── FieldInput ────────────────────────────────────────────────────────────────
function FieldInput({ field, value, onChange }) {
// enum → dropdown
if (field.enumOptions?.length) return (
<select className="finput" value={value??''} onChange={e=>onChange(e.target.value)}>
<option value=""> select </option>
{field.enumOptions.map(o=><option key={o} value={o}>{o}</option>)}
</select>
);
// bool → toggle
if (field.type==='bool') return (
<div className="toggle-wrap">
<label className="toggle">
<input type="checkbox" checked={!!value} onChange={e=>onChange(e.target.checked)}/>
<div className="toggle-track"/>
<div className="toggle-thumb"/>
</label>
<span className="toggle-label">{value ? 'true' : 'false'}</span>
</div>
);
// number
if (['int','long','float','double'].includes(field.type)) return (
<input type="number" className="finput"
step={['int','long'].includes(field.type)?1:0.001}
value={value??''}
onChange={e=>onChange(['int','long'].includes(field.type)?parseInt(e.target.value):parseFloat(e.target.value))}/>
);
// string[] → textarea one-per-line
if (field.type==='string[]') return (
<textarea className="finput" rows={3}
placeholder="One value per line"
value={Array.isArray(value)?value.join('\n'):(value||'')}
onChange={e=>onChange(e.target.value.split('\n').filter(Boolean))}/>
);
// multiline
if (field.isMultiline) return (
<textarea className="finput" rows={4}
value={value??''}
onChange={e=>onChange(e.target.value)}/>
);
return (
<input type="text" className="finput"
value={value??''}
onChange={e=>onChange(e.target.value)}/>
);
}
// ── EntryForm ─────────────────────────────────────────────────────────────────
function EntryForm({ schema, shard, entryId, typeSchema, api, onSaved, onDelete }) {
const [data, setData] = useState({});
const [plats, setPlats] = useState(typeSchema?.defaultPlatforms ?? ['all']);
const [tags, setTags] = useState(typeSchema?.defaultTags ?? []);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
const isNew = !entryId;
const allFields = typeSchema?.fields ?? [];
const ownFields = allFields.filter(f=>!f.isInherited);
const inheritedFlds = allFields.filter(f=>f.isInherited);
const availPlats = schema?.platformConfig?.platforms ?? [];
const availTags = schema?.platformConfig?.tags ?? [];
useEffect(()=>{
if (!entryId){setData({});setPlats(typeSchema?.defaultPlatforms??['all']);setTags(typeSchema?.defaultTags??[]);return;}
setLoading(true);
api.get(`/entries/${shard}/${entryId}`)
.then(r=>{setData(r.data||{});setPlats(r.platforms||['all']);setTags(r.tags||[]);})
.finally(()=>setLoading(false));
},[entryId, shard]);
function setField(name,val){setData(d=>({...d,[name]:val}));}
async function save(){
setSaving(true);
try {
if (isNew) await api.post(`/entries/${shard}`,{typeName:typeSchema.typeName,platforms:plats,tags,data});
else await api.put(`/entries/${shard}/${entryId}`,{platforms:plats,tags,data});
onSaved();
} finally { setSaving(false); }
}
async function del(){
if(!confirm('Delete this entry?')) return;
await api.delete(`/entries/${shard}/${entryId}`);
onDelete();
}
if(loading) return <div className="form-area"><div className="lbar"/></div>;
const renderFields = (fields, sectionLabel, inh) => {
if (!fields.length) return null;
return (
<div className="fgroup" key={sectionLabel}>
<div className="fsec">
{sectionLabel}
{inh && <span className="fsec-badge">inherited</span>}
</div>
{fields.map(f=>(
<div className="frow" key={f.name}>
<label className="flabel">
{f.name}
<span className="fhint">
{f.refShard ? `${f.refShard}` : f.type}
{f.isDisplayName ? ' · display name' : ''}
</span>
</label>
<FieldInput field={f} value={data[f.name]} onChange={v=>setField(f.name,v)}/>
</div>
))}
</div>
);
};
return (
<div className="form-area">
<div className="ftopbar">
<div className="ftitle">{isNew?`New ${typeSchema?.displayName}`:(data.name||data.entryId||entryId)}</div>
<span className="chip">{typeSchema?.displayName}</span>
<span className="fsub">{isNew?'unsaved':`v${data.rowVersion||'?'}`}</span>
</div>
<div className="fbody">
{availPlats.length > 0 && (
<TagEditor
label="Platforms"
available={['all',...availPlats]}
selected={plats}
onChange={setPlats}
colorClass=""
/>
)}
{availTags.length > 0 && (
<TagEditor
label="Tags"
available={availTags}
selected={tags}
onChange={setTags}
colorClass="tag-type"
/>
)}
{typeSchema?.description && (
<div style={{marginBottom:16,padding:'10px 14px',background:'var(--bg3)',borderRadius:'var(--r)',fontSize:11,color:'var(--text2)'}}>
{typeSchema.description}
</div>
)}
{renderFields(inheritedFlds, 'Inherited fields', true)}
{renderFields(ownFields, `${typeSchema?.displayName||'Entry'} fields`, false)}
</div>
<div className="fsave">
<button className="btn pri" onClick={save} disabled={saving}>
{saving?'Saving…':(isNew?'Create entry':'Save changes')}
</button>
{!isNew && <button className="btn danger" onClick={del}>Delete</button>}
</div>
</div>
);
}
// ── App ───────────────────────────────────────────────────────────────────────
function App() {
const [serverUrl, setServerUrl] = useState(localStorage.getItem('gl_srv')||'http://localhost:3000');
const [apiKey, setApiKey] = useState(localStorage.getItem('gl_key')||'');
const [schema, setSchema] = useState(null);
const [serverOk, setServerOk] = useState(null);
const [selShard, setSelShard] = useState(null);
const [selType, setSelType] = useState(null);
const [entries, setEntries] = useState([]);
const [selEntry, setSelEntry] = useState(null);
const [search, setSearch] = useState('');
const [loadingE, setLoadingE] = useState(false);
const { toasts, push } = useToasts();
const api = useMemo(()=>makeApi(serverUrl,apiKey),[serverUrl,apiKey]);
useEffect(()=>localStorage.setItem('gl_srv',serverUrl),[serverUrl]);
useEffect(()=>localStorage.setItem('gl_key',apiKey),[apiKey]);
async function connect(){
try {
await api.get('/health');
setServerOk(true);
const s = await api.get('/schema');
if(s.types){ setSchema(s); if(!selShard&&s.shards?.length) setSelShard(s.shards[0]); push('Connected','ok'); }
else { setServerOk(true); push('Connected — no schema yet','ok'); }
} catch(e){ setServerOk(false); push(`Failed: ${e.message}`,'err'); }
}
async function loadEntries(){
if(!selShard||!selType) return;
setLoadingE(true);
try { const r=await api.get(`/entries/${selShard}?type=${encodeURIComponent(selType)}`); setEntries(Array.isArray(r)?r:[]); }
catch(e){ push(`Load failed: ${e.message}`,'err'); }
finally { setLoadingE(false); }
}
useEffect(()=>{loadEntries();},[selShard,selType]);
const allShards = schema?.shards || [];
const typesForShard = useMemo(()=>schema?schema.types.filter(t=>t.shard===selShard):[],[schema,selShard]);
const selTypeSchema = useMemo(()=>schema?.types.find(t=>t.typeName===selType),[schema,selType]);
const rootTypes = typesForShard.filter(t=>!t.parentType);
const childOf = p=>typesForShard.filter(t=>t.parentType===p.typeName);
const filtered = entries.filter(e=>{
const n=(e.data?.name||e.id||'').toLowerCase();
return n.includes(search.toLowerCase());
});
function getDisplayName(entry){
// Find the [GuildDisplayName] field from schema
const schema2 = selTypeSchema;
if(schema2){
const dnField = schema2.fields.find(f=>f.isDisplayName);
if(dnField && entry.data?.[dnField.name]) return entry.data[dnField.name];
}
return entry.data?.name || entry.id;
}
return (
<>
<div className="topbar">
<div className="logo"> Guildlib <span>editor</span></div>
<div className="sep"/>
<div className="status">
<div className={`dot ${serverOk===true?'ok':serverOk===false?'err':''}`}/>
{serverOk===null?'not connected':serverOk?'connected':'unreachable'}
</div>
<div className="right">
<input className="srv" value={serverUrl} onChange={e=>setServerUrl(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&connect()} placeholder="http://localhost:3000"/>
<button className="btn pri" onClick={connect}>Connect</button>
</div>
</div>
<div className="layout">
{/* Col 1 — shards + type tree */}
<div className="panel">
<div className="ph">Shards</div>
<div className="pb">
{allShards.length===0&&<div className="empty"><div className="eicon"></div>Connect to load</div>}
{allShards.map(sh=>(
<React.Fragment key={sh}>
<div className={`shard-item${selShard===sh?' active':''}`}
onClick={()=>{setSelShard(sh);setSelType(null);setSelEntry(null);}}>
<div className="sdot" style={{background:shardColor(sh,allShards)}}/>
<span className="sname">{sh}</span>
<span className="scnt">{schema?.types.filter(t=>t.shard===sh).length||0}</span>
</div>
{selShard===sh&&rootTypes.map(rt=>(
<div key={rt.typeName}>
<div className={`type-item${selType===rt.typeName?' active':''}`}
onClick={()=>{setSelType(rt.typeName);setSelEntry(null);}}>
<span className="tname">{rt.displayName}</span>
{rt.childTypes.length>0&&<span className="tbadge">{rt.childTypes.length} sub</span>}
</div>
{childOf(rt).map(ct=>(
<div key={ct.typeName}
className={`type-item${selType===ct.typeName?' active':''}`}
style={{paddingLeft:44}}
onClick={()=>{setSelType(ct.typeName);setSelEntry(null);}}>
<span style={{color:'var(--text3)',marginRight:4}}></span>
<span className="tname">{ct.displayName}</span>
<span className="tbadge child">child</span>
</div>
))}
</div>
))}
</React.Fragment>
))}
</div>
</div>
{/* Col 2 — entry list */}
<div className="panel">
<div className="ph">
{selType?selTypeSchema?.displayName:'Entries'}
{selType&&<button className="btn sm pri" onClick={()=>setSelEntry('new')}>+ New</button>}
</div>
{selType&&<div className="search-bar"><input className="sinput" placeholder="Search…" value={search} onChange={e=>setSearch(e.target.value)}/></div>}
<div className="pb">
{!selType&&<div className="empty"><div className="eicon"></div>Select a type</div>}
{selType&&loadingE&&<div className="lbar"/>}
{selType&&!loadingE&&filtered.length===0&&<div className="empty"><div className="eicon"></div>No entries</div>}
{filtered.map(e=>(
<div key={e.id} className={`erow${selEntry===e.id?' active':''}`} onClick={()=>setSelEntry(e.id)}>
<div className="ename">{getDisplayName(e)}</div>
<div className="emeta">
<span className="chip">{e.id?.slice(0,10)}</span>
<span>v{e.row_version}</span>
{(e.platforms||[]).filter(p=>p!=='all').slice(0,3).map(p=><span key={p} className="chip plat">{p}</span>)}
{(e.tags||[]).slice(0,2).map(t=><span key={t} className="chip tag">{t}</span>)}
</div>
</div>
))}
</div>
<div className="hint-bar">
{selType&&<button className="btn" style={{width:'100%'}} onClick={()=>setSelEntry('new')}>+ Create {selTypeSchema?.displayName}</button>}
</div>
</div>
{/* Col 3 — form */}
<div style={{display:'flex',flexDirection:'column',overflow:'hidden'}}>
{!selType&&<div className="empty"><div className="eicon"></div>{schema?'Select a type':'Connect to a server'}</div>}
{selType&&(selEntry==='new'||selEntry)&&(
<EntryForm
key={selEntry+selType}
schema={schema}
shard={selShard}
entryId={selEntry==='new'?null:selEntry}
typeSchema={selTypeSchema}
api={api}
onSaved={()=>{push('Saved ✓','ok');setSelEntry(null);loadEntries();}}
onDelete={()=>{push('Deleted','ok');setSelEntry(null);loadEntries();}}
/>
)}
{selType&&!selEntry&&<div className="empty"><div className="eicon"></div>Select or create an entry</div>}
</div>
</div>
<div className="toast-host">
{toasts.map(t=><div key={t.id} className={`toast ${t.type}`}>{t.type==='ok'?'✓':'✗'} {t.msg}</div>)}
</div>
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>