initial commit
This commit is contained in:
3
docker/.env.example
Normal file
3
docker/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
GUILDLIB_API_KEY=changeme-use-a-real-secret
|
||||
# PORT=3000
|
||||
# DOMAIN=guildlib.yourgame.com
|
||||
4
docker/Caddyfile
Normal file
4
docker/Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
||||
{$DOMAIN:localhost} {
|
||||
reverse_proxy guildlib:3000
|
||||
encode gzip
|
||||
}
|
||||
17
docker/Dockerfile
Normal file
17
docker/Dockerfile
Normal 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
45
docker/docker-compose.yml
Normal 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
870
docs/DOCUMENTATION.md
Normal 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
82
docs/example-schema.json
Normal 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
11
server/package.json
Normal 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
68
server/src/index.ts
Normal 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;
|
||||
}
|
||||
8
server/src/middleware/auth.ts
Normal file
8
server/src/middleware/auth.ts
Normal 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;
|
||||
}
|
||||
97
server/src/routes/entries.ts
Normal file
97
server/src/routes/entries.ts
Normal 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);
|
||||
}
|
||||
8
server/src/routes/manifest.ts
Normal file
8
server/src/routes/manifest.ts
Normal 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
34
server/src/routes/pull.ts
Normal 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
28
server/src/routes/push.ts
Normal 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); }
|
||||
}
|
||||
24
server/src/routes/schema.ts
Normal file
24
server/src/routes/schema.ts
Normal 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
256
server/src/shardManager.ts
Normal 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"); }
|
||||
232
unity/Assets/Guildlib/Editor/GuildlibSyncWindow.cs
Normal file
232
unity/Assets/Guildlib/Editor/GuildlibSyncWindow.cs
Normal 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
|
||||
340
unity/Assets/Guildlib/Runtime/DataEntry.cs
Normal file
340
unity/Assets/Guildlib/Runtime/DataEntry.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Guildlib.Runtime
|
||||
{
|
||||
// =========================================================================
|
||||
// ATTRIBUTES
|
||||
// Apply these to your DataEntry subclasses to configure Guildlib behaviour.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// REQUIRED on every concrete DataEntry subclass.
|
||||
/// Declares which shard database this type belongs to.
|
||||
///
|
||||
/// The shard name is free-form — use any string you want.
|
||||
/// The server will auto-create a new .sqlite file for any new shard name
|
||||
/// the first time an entry of that type is pushed.
|
||||
///
|
||||
/// Usage:
|
||||
/// [ShardTarget("characters")]
|
||||
/// [ShardTarget("ui")]
|
||||
/// [ShardTarget("levels")]
|
||||
///
|
||||
/// Shard names are lowercase, no spaces. Use underscores if needed:
|
||||
/// [ShardTarget("vfx_particles")]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class ShardTargetAttribute : Attribute
|
||||
{
|
||||
public string ShardName { get; }
|
||||
public ShardTargetAttribute(string shardName) => ShardName = shardName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL — gives the type a human-readable label for the web editor.
|
||||
/// If omitted, the class name is used.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildLabel("Character Stats")]
|
||||
/// [GuildLabel("UI Button")]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class GuildLabelAttribute : Attribute
|
||||
{
|
||||
public string Label { get; }
|
||||
public GuildLabelAttribute(string label) => Label = label;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL — a short description shown in the web editor under the type name.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildDescription("Defines stats for all enemy and player characters.")]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class GuildDescriptionAttribute : Attribute
|
||||
{
|
||||
public string Description { get; }
|
||||
public GuildDescriptionAttribute(string desc) => Description = desc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL — declares which platforms this entry type is relevant for.
|
||||
/// This is a DEFAULT for new entries of this type. Individual entries can
|
||||
/// override their platform tags at runtime in the web editor.
|
||||
///
|
||||
/// Platforms are free-form strings — define your own set.
|
||||
/// Common examples: "pc", "ps5", "xbox", "switch", "mobile", "all"
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildPlatforms("all")]
|
||||
/// [GuildPlatforms("pc", "ps5", "xbox")]
|
||||
/// [GuildPlatforms("switch", "mobile")]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class GuildPlatformsAttribute : Attribute
|
||||
{
|
||||
public string[] Platforms { get; }
|
||||
public GuildPlatformsAttribute(params string[] platforms) => Platforms = platforms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL — declares which build configurations (tags) this entry type
|
||||
/// belongs to by default.
|
||||
///
|
||||
/// Tags are free-form — define your own taxonomy.
|
||||
/// Examples: "debug", "release", "demo", "dlc1", "alpha"
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildTags("release", "demo")]
|
||||
/// [GuildTags("debug")]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class GuildTagsAttribute : Attribute
|
||||
{
|
||||
public string[] Tags { get; }
|
||||
public GuildTagsAttribute(params string[] tags) => Tags = tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL on a field or property — exclude it from schema export and sync.
|
||||
/// Use for Unity-internal or editor-only fields that should not hit the server.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildExclude]
|
||||
/// public Texture2D previewTexture; // Unity asset ref, not serialisable to DB
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
|
||||
public sealed class GuildExcludeAttribute : Attribute { }
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL on a field — marks it as the display name for this entry
|
||||
/// in the web editor's entry list. Without this, entryId is shown.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildDisplayName]
|
||||
/// public string characterName;
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class GuildDisplayNameAttribute : Attribute { }
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL on a field — marks it as a reference to another shard entry.
|
||||
/// The web editor will show a picker instead of a plain text field.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildRef("characters")]
|
||||
/// public string ownerCharacterId;
|
||||
///
|
||||
/// [GuildRef("audio")]
|
||||
/// public string deathSoundId;
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class GuildRefAttribute : Attribute
|
||||
{
|
||||
public string TargetShard { get; }
|
||||
public GuildRefAttribute(string targetShard) => TargetShard = targetShard;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL on a field — hints the web editor to render a multiline
|
||||
/// textarea instead of a single-line input.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildMultiline]
|
||||
/// public string description;
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class GuildMultilineAttribute : Attribute { }
|
||||
|
||||
/// <summary>
|
||||
/// OPTIONAL on a string field — constrains the value to one of the
|
||||
/// provided options. The web editor renders a dropdown.
|
||||
///
|
||||
/// Usage:
|
||||
/// [GuildEnum("None", "Rare", "Epic", "Legendary")]
|
||||
/// public string rarity;
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class GuildEnumAttribute : Attribute
|
||||
{
|
||||
public string[] Options { get; }
|
||||
public GuildEnumAttribute(params string[] options) => Options = options;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PLATFORM / TAG REGISTRY
|
||||
// Define your project's platform and tag vocabulary here.
|
||||
// These are used for validation and for populating dropdowns in the
|
||||
// web editor. Add or remove entries freely — they're just strings.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Central registry for all platform identifiers used in this project.
|
||||
/// Edit this list to match your shipping targets.
|
||||
/// </summary>
|
||||
public static class GuildPlatformRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// All valid platform names for this project.
|
||||
/// Add your platforms here — these appear as checkboxes in the web editor.
|
||||
/// </summary>
|
||||
public static readonly string[] All = new[]
|
||||
{
|
||||
"pc",
|
||||
"ps5",
|
||||
"ps4",
|
||||
"xbox_series",
|
||||
"xbox_one",
|
||||
"switch",
|
||||
"mobile_ios",
|
||||
"mobile_android",
|
||||
"vr",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Convenience constant — use as a shorthand for "ships on every platform".
|
||||
/// </summary>
|
||||
public const string AllPlatforms = "all";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central registry for all tag identifiers used in this project.
|
||||
/// Tags can represent build configs, DLC groups, content regions,
|
||||
/// quality tiers, or any other grouping you need.
|
||||
/// </summary>
|
||||
public static class GuildTagRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// All valid tag names for this project.
|
||||
/// Add your tags here — these appear as checkboxes in the web editor.
|
||||
/// </summary>
|
||||
public static readonly string[] All = new[]
|
||||
{
|
||||
// Build types
|
||||
"release",
|
||||
"debug",
|
||||
"demo",
|
||||
|
||||
// DLC / content waves
|
||||
"base_game",
|
||||
"dlc_1",
|
||||
"dlc_2",
|
||||
|
||||
// Quality / LOD tiers
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
|
||||
// Region
|
||||
"global",
|
||||
"region_jp",
|
||||
"region_eu",
|
||||
"region_na",
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DataEntry BASE CLASS
|
||||
// Every entry stored in Guildlib inherits from this.
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all Guildlib-managed data entries.
|
||||
///
|
||||
/// To create a new data type:
|
||||
///
|
||||
/// 1. Subclass DataEntry
|
||||
/// 2. Add [ShardTarget("your_shard_name")] ← required
|
||||
/// 3. Optionally add [GuildLabel], [GuildDescription], [GuildPlatforms], [GuildTags]
|
||||
/// 4. Add your public fields
|
||||
/// 5. Open Window → Guildlib → Sync Panel and click Upload Schema
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// [ShardTarget("characters")]
|
||||
/// [GuildLabel("Character Stats")]
|
||||
/// [GuildPlatforms("all")]
|
||||
/// [GuildTags("base_game", "release")]
|
||||
/// [CreateAssetMenu(menuName = "Guildlib/Character Stats")]
|
||||
/// public class CharacterStats : DataEntry
|
||||
/// {
|
||||
/// [GuildDisplayName]
|
||||
/// public string characterName;
|
||||
/// public float maxHealth;
|
||||
/// public float moveSpeed;
|
||||
///
|
||||
/// [GuildEnum("Warrior", "Mage", "Rogue")]
|
||||
/// public string characterClass;
|
||||
/// }
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public abstract class DataEntry : ScriptableObject
|
||||
{
|
||||
// ── Guildlib-managed fields (not shown in web editor forms) ──────────
|
||||
|
||||
[HideInInspector]
|
||||
public string entryId = Guid.NewGuid().ToString("N");
|
||||
|
||||
[HideInInspector]
|
||||
public long rowVersion = 0;
|
||||
|
||||
/// <summary>
|
||||
/// ID of a parent entry (same shard). Set this to link a child entry
|
||||
/// to its parent, e.g. a specific SwordData instance linked to a
|
||||
/// WeaponData template.
|
||||
/// </summary>
|
||||
[HideInInspector]
|
||||
public string parentId = null;
|
||||
|
||||
[HideInInspector]
|
||||
public string typeName;
|
||||
|
||||
[HideInInspector]
|
||||
public string shardName;
|
||||
|
||||
/// <summary>
|
||||
/// Platform tags for this specific entry instance.
|
||||
/// Defaults to the class-level [GuildPlatforms] attribute value,
|
||||
/// but can be overridden per-entry in the web editor.
|
||||
/// </summary>
|
||||
[HideInInspector]
|
||||
public List<string> platforms = new();
|
||||
|
||||
/// <summary>
|
||||
/// Content/build tags for this specific entry instance.
|
||||
/// Defaults to the class-level [GuildTags] attribute value,
|
||||
/// but can be overridden per-entry in the web editor.
|
||||
/// </summary>
|
||||
[HideInInspector]
|
||||
public List<string> tags = new();
|
||||
|
||||
protected virtual void OnEnable()
|
||||
{
|
||||
typeName = GetType().FullName;
|
||||
var shard = GetType().GetCustomAttributes(typeof(ShardTargetAttribute), false);
|
||||
shardName = shard.Length > 0
|
||||
? ((ShardTargetAttribute)shard[0]).ShardName
|
||||
: "default";
|
||||
|
||||
// Apply class-level platform defaults if none set yet
|
||||
if (platforms.Count == 0)
|
||||
{
|
||||
var pa = (GuildPlatformsAttribute[])GetType()
|
||||
.GetCustomAttributes(typeof(GuildPlatformsAttribute), false);
|
||||
if (pa.Length > 0) platforms.AddRange(pa[0].Platforms);
|
||||
else platforms.Add(GuildPlatformRegistry.AllPlatforms);
|
||||
}
|
||||
|
||||
// Apply class-level tag defaults if none set yet
|
||||
if (tags.Count == 0)
|
||||
{
|
||||
var ta = (GuildTagsAttribute[])GetType()
|
||||
.GetCustomAttributes(typeof(GuildTagsAttribute), false);
|
||||
if (ta.Length > 0) tags.AddRange(ta[0].Tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
unity/Assets/Guildlib/Runtime/GuildlibConfig.cs
Normal file
47
unity/Assets/Guildlib/Runtime/GuildlibConfig.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Guildlib.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Project-level Guildlib configuration.
|
||||
///
|
||||
/// HOW TO CREATE:
|
||||
/// Assets → Create → Guildlib → Config
|
||||
/// Name it exactly "GuildlibConfig" and place it in any Resources/ folder.
|
||||
///
|
||||
/// HOW TO CONFIGURE:
|
||||
/// - Server URL: your Bun server address (local or production)
|
||||
/// - Project ID: any unique string identifying your game project
|
||||
/// - API Key: must match GUILDLIB_API_KEY env var on the server
|
||||
/// Leave blank during local development (server auth disabled)
|
||||
/// - Shard list: comma-separated list of shards to sync, or leave empty
|
||||
/// to sync ALL shards the server knows about
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Guildlib/Config", fileName = "GuildlibConfig")]
|
||||
public class GuildlibConfig : ScriptableObject
|
||||
{
|
||||
[Header("Server connection")]
|
||||
public string serverUrl = "http://localhost:3000";
|
||||
public string projectId = "";
|
||||
[Tooltip("Must match GUILDLIB_API_KEY on the server. Leave blank for local dev.")]
|
||||
public string apiKey = "";
|
||||
|
||||
[Header("Local storage")]
|
||||
[Tooltip("Folder name inside Application.persistentDataPath where .sqlite shards are saved.")]
|
||||
public string shardFolder = "GuildlibShards";
|
||||
|
||||
[Header("Sync targets")]
|
||||
[Tooltip("Leave empty to sync all shards. Or list specific shard names to limit syncing.")]
|
||||
public string[] shardsToSync = new string[0];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of shards to sync. If empty, returns null
|
||||
/// (meaning: ask the server for all available shards).
|
||||
/// </summary>
|
||||
public string[] GetSyncTargets() =>
|
||||
shardsToSync != null && shardsToSync.Length > 0 ? shardsToSync : null;
|
||||
|
||||
public static GuildlibConfig Load() =>
|
||||
Resources.Load<GuildlibConfig>("GuildlibConfig");
|
||||
}
|
||||
}
|
||||
222
unity/Assets/Guildlib/Runtime/SchemaExporter.cs
Normal file
222
unity/Assets/Guildlib/Runtime/SchemaExporter.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Guildlib.Runtime
|
||||
{
|
||||
// =========================================================================
|
||||
// Schema wire types — serialised to JSON and sent to the server
|
||||
// =========================================================================
|
||||
|
||||
[Serializable]
|
||||
public class FieldSchema
|
||||
{
|
||||
public string name;
|
||||
public string type; // "string" | "int" | "float" | "bool" | "string[]" …
|
||||
public bool isArray;
|
||||
public bool isInherited; // true = defined on a parent class
|
||||
public bool isDisplayName; // true = [GuildDisplayName] present
|
||||
public bool isMultiline; // true = [GuildMultiline] present
|
||||
public string refShard; // non-null = [GuildRef("shard")] present
|
||||
public string[] enumOptions; // non-null = [GuildEnum(...)] present
|
||||
public string defaultValue;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TypeSchema
|
||||
{
|
||||
public string typeName; // fully-qualified C# type name
|
||||
public string displayName; // from [GuildLabel] or class name
|
||||
public string description; // from [GuildDescription] or ""
|
||||
public string shard; // from [ShardTarget]
|
||||
public string parentType; // null if top-level DataEntry subclass
|
||||
public List<string> childTypes = new();
|
||||
public List<string> defaultPlatforms = new(); // from [GuildPlatforms]
|
||||
public List<string> defaultTags = new(); // from [GuildTags]
|
||||
public List<FieldSchema> fields = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class PlatformConfig
|
||||
{
|
||||
public List<string> platforms = new();
|
||||
public List<string> tags = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SchemaManifest
|
||||
{
|
||||
public string version = "2";
|
||||
public long exportedAt;
|
||||
public string unityProjectId;
|
||||
public List<string> shards = new(); // distinct shard names
|
||||
public PlatformConfig platformConfig = new(); // all valid platforms + tags
|
||||
public List<TypeSchema> types = new();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Exporter
|
||||
// =========================================================================
|
||||
|
||||
public static class SchemaExporter
|
||||
{
|
||||
public static string Export(string projectId = "")
|
||||
{
|
||||
var manifest = new SchemaManifest
|
||||
{
|
||||
exportedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
unityProjectId = projectId,
|
||||
platformConfig = new PlatformConfig
|
||||
{
|
||||
platforms = GuildPlatformRegistry.All.ToList(),
|
||||
tags = GuildTagRegistry.All.ToList(),
|
||||
}
|
||||
};
|
||||
|
||||
// Discover all concrete DataEntry subclasses in all loaded assemblies
|
||||
var baseType = typeof(DataEntry);
|
||||
var entryTypes = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
|
||||
.Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
|
||||
.ToList();
|
||||
|
||||
var shardSet = new HashSet<string>();
|
||||
var typeMap = new Dictionary<string, TypeSchema>();
|
||||
|
||||
foreach (var t in entryTypes)
|
||||
{
|
||||
var shardAttr = t.GetCustomAttribute<ShardTargetAttribute>(false);
|
||||
var shard = shardAttr?.ShardName ?? "default";
|
||||
shardSet.Add(shard);
|
||||
|
||||
// Display name
|
||||
var labelAttr = t.GetCustomAttribute<GuildLabelAttribute>(false);
|
||||
var displayName = labelAttr?.Label ?? t.Name;
|
||||
|
||||
// Description
|
||||
var descAttr = t.GetCustomAttribute<GuildDescriptionAttribute>(false);
|
||||
var desc = descAttr?.Description ?? "";
|
||||
|
||||
// Default platforms
|
||||
var platformAttr = t.GetCustomAttribute<GuildPlatformsAttribute>(false);
|
||||
var defaultPlats = platformAttr != null
|
||||
? platformAttr.Platforms.ToList()
|
||||
: new List<string> { GuildPlatformRegistry.AllPlatforms };
|
||||
|
||||
// Default tags
|
||||
var tagsAttr = t.GetCustomAttribute<GuildTagsAttribute>(false);
|
||||
var defaultTags = tagsAttr?.Tags.ToList() ?? new List<string>();
|
||||
|
||||
// Parent type (walk up skipping non-DataEntry classes)
|
||||
string parentTypeName = null;
|
||||
var parent = t.BaseType;
|
||||
while (parent != null && parent != baseType &&
|
||||
parent != typeof(ScriptableObject) &&
|
||||
parent != typeof(UnityEngine.Object))
|
||||
{
|
||||
if (entryTypes.Contains(parent)) { parentTypeName = parent.FullName; break; }
|
||||
parent = parent.BaseType;
|
||||
}
|
||||
|
||||
var schema = new TypeSchema
|
||||
{
|
||||
typeName = t.FullName,
|
||||
displayName = displayName,
|
||||
description = desc,
|
||||
shard = shard,
|
||||
parentType = parentTypeName,
|
||||
defaultPlatforms = defaultPlats,
|
||||
defaultTags = defaultTags,
|
||||
fields = BuildFields(t, entryTypes),
|
||||
};
|
||||
|
||||
typeMap[t.FullName] = schema;
|
||||
manifest.types.Add(schema);
|
||||
}
|
||||
|
||||
// Second pass — populate childTypes
|
||||
foreach (var ts in manifest.types)
|
||||
if (ts.parentType != null && typeMap.TryGetValue(ts.parentType, out var p))
|
||||
p.childTypes.Add(ts.typeName);
|
||||
|
||||
manifest.shards = shardSet.OrderBy(s => s).ToList();
|
||||
|
||||
return JsonConvert.SerializeObject(manifest, Formatting.Indented);
|
||||
}
|
||||
|
||||
// ── Field reflection ─────────────────────────────────────────────────
|
||||
|
||||
static List<FieldSchema> BuildFields(Type t, List<Type> allEntryTypes)
|
||||
{
|
||||
var result = new List<FieldSchema>();
|
||||
var baseType = typeof(DataEntry);
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
// Walk up chain so parent fields appear first
|
||||
var chain = new List<Type>();
|
||||
var cur = t;
|
||||
while (cur != null && cur != baseType && cur != typeof(ScriptableObject))
|
||||
{
|
||||
chain.Add(cur); cur = cur.BaseType;
|
||||
}
|
||||
chain.Reverse();
|
||||
|
||||
foreach (var type in chain)
|
||||
{
|
||||
bool inherited = type != t;
|
||||
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.Instance
|
||||
| BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (f.GetCustomAttribute<GuildExcludeAttribute>() != null) continue;
|
||||
if (f.GetCustomAttribute<HideInInspector>() != null) continue;
|
||||
if (seen.Contains(f.Name)) continue;
|
||||
seen.Add(f.Name);
|
||||
|
||||
var refAttr = f.GetCustomAttribute<GuildRefAttribute>();
|
||||
var enumAttr = f.GetCustomAttribute<GuildEnumAttribute>();
|
||||
|
||||
result.Add(new FieldSchema
|
||||
{
|
||||
name = f.Name,
|
||||
type = MapType(f.FieldType),
|
||||
isArray = f.FieldType.IsArray,
|
||||
isInherited = inherited,
|
||||
isDisplayName = f.GetCustomAttribute<GuildDisplayNameAttribute>() != null,
|
||||
isMultiline = f.GetCustomAttribute<GuildMultilineAttribute>() != null,
|
||||
refShard = refAttr?.TargetShard,
|
||||
enumOptions = enumAttr?.Options,
|
||||
defaultValue = GetDefault(f.FieldType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static string MapType(Type t)
|
||||
{
|
||||
if (t == typeof(string)) return "string";
|
||||
if (t == typeof(int)) return "int";
|
||||
if (t == typeof(long)) return "long";
|
||||
if (t == typeof(float)) return "float";
|
||||
if (t == typeof(double)) return "double";
|
||||
if (t == typeof(bool)) return "bool";
|
||||
if (t == typeof(string[])) return "string[]";
|
||||
if (t == typeof(int[])) return "int[]";
|
||||
if (t == typeof(float[])) return "float[]";
|
||||
return "string";
|
||||
}
|
||||
|
||||
static string GetDefault(Type t)
|
||||
{
|
||||
if (t == typeof(bool)) return "false";
|
||||
if (t == typeof(float) || t == typeof(double) ||
|
||||
t == typeof(int) || t == typeof(long)) return "0";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
252
unity/Assets/Guildlib/Runtime/ShardClient.cs
Normal file
252
unity/Assets/Guildlib/Runtime/ShardClient.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Guildlib.Runtime
|
||||
{
|
||||
// ── Wire types ────────────────────────────────────────────────────────────
|
||||
|
||||
public class ManifestRow
|
||||
{
|
||||
public string id;
|
||||
public long version;
|
||||
public string checksum;
|
||||
}
|
||||
|
||||
public class ShardManifest
|
||||
{
|
||||
public string shard;
|
||||
public List<ManifestRow> rows;
|
||||
}
|
||||
|
||||
public class PullRequest
|
||||
{
|
||||
public string shard;
|
||||
public List<string> ids;
|
||||
|
||||
// Optional platform filter — only pull rows tagged for these platforms.
|
||||
// The server will include rows tagged "all" regardless of this list.
|
||||
// Set to null to pull everything.
|
||||
public List<string> filterPlatforms;
|
||||
|
||||
// Optional tag filter — only pull rows that have at least one of these tags.
|
||||
// Set to null to pull everything.
|
||||
public List<string> filterTags;
|
||||
}
|
||||
|
||||
public class PullResponse
|
||||
{
|
||||
public string shard;
|
||||
public List<Dictionary<string,object>> rows;
|
||||
}
|
||||
|
||||
public class PushPayload
|
||||
{
|
||||
public string shard;
|
||||
public string typeName;
|
||||
public Dictionary<string,object> data;
|
||||
}
|
||||
|
||||
public class SyncResult
|
||||
{
|
||||
public string shard;
|
||||
public int added;
|
||||
public int updated;
|
||||
public int skipped;
|
||||
}
|
||||
|
||||
// ── ShardClient ───────────────────────────────────────────────────────────
|
||||
|
||||
public class ShardClient
|
||||
{
|
||||
readonly GuildlibConfig config;
|
||||
readonly HttpClient http;
|
||||
readonly string shardName;
|
||||
readonly string dbPath;
|
||||
|
||||
// Local manifest: entryId -> (version, checksum)
|
||||
Dictionary<string,(long version, string checksum)> localManifest = new();
|
||||
|
||||
public ShardClient(GuildlibConfig cfg, string shard)
|
||||
{
|
||||
config = cfg;
|
||||
shardName = shard;
|
||||
http = new HttpClient();
|
||||
if (!string.IsNullOrEmpty(cfg.apiKey))
|
||||
http.DefaultRequestHeaders.Add("X-Api-Key", cfg.apiKey);
|
||||
|
||||
var dir = Path.Combine(Application.persistentDataPath, cfg.shardFolder);
|
||||
Directory.CreateDirectory(dir);
|
||||
dbPath = Path.Combine(dir, $"{shard}.sqlite");
|
||||
|
||||
LoadLocalManifest();
|
||||
}
|
||||
|
||||
// ── Pull ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Pull all changed rows from the server for this shard.
|
||||
///
|
||||
/// platformFilter: if non-null, only pull rows whose platforms include
|
||||
/// at least one of the given values, or "all".
|
||||
/// Example: new List<string>{"pc","ps5"}
|
||||
///
|
||||
/// tagFilter: if non-null, only pull rows with at least one matching tag.
|
||||
/// Example: new List<string>{"release","base_game"}
|
||||
///
|
||||
/// Both filters are applied server-side so no extra data crosses the wire.
|
||||
/// </summary>
|
||||
public async Task<SyncResult> PullAsync(
|
||||
List<string> platformFilter = null,
|
||||
List<string> tagFilter = null)
|
||||
{
|
||||
// 1 — Fetch server manifest (lightweight: id/version/checksum only)
|
||||
var manifestJson = await http.GetStringAsync($"{config.serverUrl}/manifest/{shardName}");
|
||||
var serverManifest = JsonConvert.DeserializeObject<ShardManifest>(manifestJson);
|
||||
|
||||
// 2 — Diff against local manifest
|
||||
var needIds = new List<string>();
|
||||
foreach (var row in serverManifest.rows)
|
||||
{
|
||||
if (!localManifest.TryGetValue(row.id, out var local) ||
|
||||
local.version < row.version ||
|
||||
local.checksum != row.checksum)
|
||||
needIds.Add(row.id);
|
||||
}
|
||||
|
||||
var result = new SyncResult
|
||||
{
|
||||
shard = shardName,
|
||||
skipped = serverManifest.rows.Count - needIds.Count
|
||||
};
|
||||
if (needIds.Count == 0) return result;
|
||||
|
||||
// 3 — Request only the delta rows, with optional server-side filtering
|
||||
var pullReq = new PullRequest
|
||||
{
|
||||
shard = shardName,
|
||||
ids = needIds,
|
||||
filterPlatforms = platformFilter,
|
||||
filterTags = tagFilter,
|
||||
};
|
||||
var reqJson = JsonConvert.SerializeObject(pullReq);
|
||||
var response = await http.PostAsync(
|
||||
$"{config.serverUrl}/pull",
|
||||
new StringContent(reqJson, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var pullJson = await response.Content.ReadAsStringAsync();
|
||||
var pullResp = JsonConvert.DeserializeObject<PullResponse>(pullJson);
|
||||
|
||||
// 4 — Upsert into local SQLite shard
|
||||
int added = 0, updated = 0;
|
||||
using var db = new LocalShardDb(dbPath);
|
||||
db.EnsureTable(shardName);
|
||||
foreach (var row in pullResp.rows)
|
||||
{
|
||||
bool isNew = !localManifest.ContainsKey(row["id"].ToString());
|
||||
db.Upsert(shardName, row);
|
||||
if (isNew) added++; else updated++;
|
||||
}
|
||||
|
||||
// 5 — Persist updated manifest
|
||||
foreach (var mr in serverManifest.rows)
|
||||
localManifest[mr.id] = (mr.version, mr.checksum);
|
||||
SaveLocalManifest();
|
||||
|
||||
result.added = added;
|
||||
result.updated = updated;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Push ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task PushAsync(DataEntry entry)
|
||||
{
|
||||
var data = EntryToDict(entry);
|
||||
var payload = new PushPayload
|
||||
{
|
||||
shard = shardName,
|
||||
typeName = entry.typeName,
|
||||
data = data,
|
||||
};
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var response = await http.PostAsync(
|
||||
$"{config.serverUrl}/push",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// ── Local manifest ────────────────────────────────────────────────────
|
||||
|
||||
void LoadLocalManifest()
|
||||
{
|
||||
var path = dbPath + ".manifest.json";
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllText(path);
|
||||
localManifest = JsonConvert.DeserializeObject<
|
||||
Dictionary<string,(long, string)>>(raw) ?? new();
|
||||
}
|
||||
catch { localManifest = new(); }
|
||||
}
|
||||
|
||||
void SaveLocalManifest()
|
||||
{
|
||||
File.WriteAllText(dbPath + ".manifest.json",
|
||||
JsonConvert.SerializeObject(localManifest, Formatting.Indented));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
static Dictionary<string,object> EntryToDict(DataEntry entry)
|
||||
{
|
||||
var dict = new Dictionary<string,object>();
|
||||
foreach (var f in entry.GetType().GetFields(
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.Instance))
|
||||
{
|
||||
dict[f.Name] = f.GetValue(entry);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
// ── LocalShardDb stub ─────────────────────────────────────────────────────
|
||||
// Replace the method bodies with your chosen Unity SQLite package.
|
||||
// Recommended: https://github.com/gkoreman/SQLite4Unity3d
|
||||
// or sqlite-net-pcl via NuGet
|
||||
|
||||
public class LocalShardDb : IDisposable
|
||||
{
|
||||
readonly string path;
|
||||
public LocalShardDb(string dbPath) { path = dbPath; }
|
||||
|
||||
public void EnsureTable(string tableName)
|
||||
{
|
||||
// CREATE TABLE IF NOT EXISTS <tableName> (
|
||||
// id TEXT PRIMARY KEY,
|
||||
// type_name TEXT,
|
||||
// parent_id TEXT,
|
||||
// platforms TEXT, -- JSON array
|
||||
// tags TEXT, -- JSON array
|
||||
// row_version INTEGER,
|
||||
// data_json TEXT,
|
||||
// updated_at INTEGER
|
||||
// )
|
||||
}
|
||||
|
||||
public void Upsert(string tableName, Dictionary<string,object> row)
|
||||
{
|
||||
// INSERT OR REPLACE INTO <tableName> VALUES (...)
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
530
web/index.html
Normal file
530
web/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user