vanaheimr 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/clientImageToDataUrl.ts +141 -0
- package/lib/content/gameContent.ts +106 -0
- package/lib/corsPublicRead.ts +19 -0
- package/lib/db/studioBlobStore.ts +286 -0
- package/lib/db/studioLookup.ts +39 -0
- package/lib/enemyTypes.ts +13 -0
- package/lib/entityClassStorage.ts +89 -0
- package/lib/entityRegistryStorage.ts +75 -0
- package/lib/entityRuntimeBundle.ts +31 -0
- package/lib/entityTypes.ts +60 -0
- package/lib/entry.ts +20 -0
- package/lib/equipmentStorage.ts +68 -0
- package/lib/equipmentTypes.ts +14 -0
- package/lib/generated/ally.ts +18 -0
- package/lib/generated/enemy.ts +23 -0
- package/lib/sdk/allyMap.ts +37 -0
- package/lib/sdk/allyProps.ts +72 -0
- package/lib/sdk/index.ts +17 -0
- package/lib/sdk/node.ts +11 -0
- package/lib/sdk/parseStore.ts +31 -0
- package/lib/storage.ts +57 -0
- package/lib/supabase/admin.ts +28 -0
- package/lib/supabase/client.ts +8 -0
- package/lib/supabase/middleware.ts +50 -0
- package/lib/supabase/server.ts +28 -0
- package/lib/tools.ts +42 -0
- package/lib/types.ts +37 -0
- package/package.json +50 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel caps serverless request bodies around ~4.5MB; base64 adds ~37%.
|
|
3
|
+
* Compress in the browser so PUT /api/data stays under the limit.
|
|
4
|
+
*
|
|
5
|
+
* Tuned for common mobile splash / character art at **1024×1536** (portrait):
|
|
6
|
+
* we try lowering quality at **full pixel dimensions** before downscaling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TARGET_BINARY_BYTES = 2_200_000;
|
|
10
|
+
/** Skip canvas for small files — base64 payload stays safe with rest of JSON. */
|
|
11
|
+
const DIRECT_READ_MAX_BYTES = 450 * 1024;
|
|
12
|
+
|
|
13
|
+
/** Never upsample; cap longest edge only if asset is enormous. */
|
|
14
|
+
const ABSOLUTE_MAX_LONG_EDGE = 2048;
|
|
15
|
+
|
|
16
|
+
const QUALITIES = [
|
|
17
|
+
0.92, 0.88, 0.85, 0.8, 0.74, 0.68, 0.62, 0.55, 0.48, 0.42,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
function fileToRawDataUrl(file: File): Promise<string> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const reader = new FileReader();
|
|
23
|
+
reader.onload = () => {
|
|
24
|
+
const s = reader.result;
|
|
25
|
+
if (typeof s === "string") resolve(s);
|
|
26
|
+
else reject(new Error("Read failed"));
|
|
27
|
+
};
|
|
28
|
+
reader.onerror = () => reject(reader.error);
|
|
29
|
+
reader.readAsDataURL(file);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blobToDataUrl(blob: Blob): Promise<string> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const reader = new FileReader();
|
|
36
|
+
reader.onload = () => {
|
|
37
|
+
const s = reader.result;
|
|
38
|
+
if (typeof s === "string") resolve(s);
|
|
39
|
+
else reject(new Error("Read failed"));
|
|
40
|
+
};
|
|
41
|
+
reader.onerror = () => reject(reader.error);
|
|
42
|
+
reader.readAsDataURL(blob);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function canvasToBlob(
|
|
47
|
+
canvas: HTMLCanvasElement,
|
|
48
|
+
type: string,
|
|
49
|
+
quality?: number
|
|
50
|
+
): Promise<Blob | null> {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
canvas.toBlob((b) => resolve(b), type, quality);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function layoutToCanvas(
|
|
57
|
+
bmp: ImageBitmap,
|
|
58
|
+
canvas: HTMLCanvasElement,
|
|
59
|
+
ctx: CanvasRenderingContext2D,
|
|
60
|
+
maxLongEdge: number
|
|
61
|
+
) {
|
|
62
|
+
let w = bmp.width;
|
|
63
|
+
let h = bmp.height;
|
|
64
|
+
const longEdge = Math.max(w, h, 1);
|
|
65
|
+
const scale = Math.min(1, maxLongEdge / longEdge);
|
|
66
|
+
w = Math.max(1, Math.round(w * scale));
|
|
67
|
+
h = Math.max(1, Math.round(h * scale));
|
|
68
|
+
canvas.width = w;
|
|
69
|
+
canvas.height = h;
|
|
70
|
+
ctx.clearRect(0, 0, w, h);
|
|
71
|
+
ctx.drawImage(bmp, 0, 0, w, h);
|
|
72
|
+
return { w, h };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function encodeUnderBudget(
|
|
76
|
+
canvas: HTMLCanvasElement
|
|
77
|
+
): Promise<Blob | null> {
|
|
78
|
+
for (const q of QUALITIES) {
|
|
79
|
+
for (const mime of ["image/webp", "image/jpeg"] as const) {
|
|
80
|
+
const blob = await canvasToBlob(canvas, mime, q);
|
|
81
|
+
if (blob && blob.size > 0 && blob.size <= TARGET_BINARY_BYTES) {
|
|
82
|
+
return blob;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Turns an image file into a data URL for embedded studio JSON (mobile-friendly).
|
|
91
|
+
*/
|
|
92
|
+
export async function compressImageFileToDataUrl(file: File): Promise<{
|
|
93
|
+
dataUrl: string;
|
|
94
|
+
compressed: boolean;
|
|
95
|
+
}> {
|
|
96
|
+
if (!file.type.startsWith("image/")) {
|
|
97
|
+
throw new Error("Not an image file");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (file.size <= DIRECT_READ_MAX_BYTES) {
|
|
101
|
+
return { dataUrl: await fileToRawDataUrl(file), compressed: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const bmp = await createImageBitmap(file);
|
|
105
|
+
|
|
106
|
+
let maxLongEdge = Math.min(
|
|
107
|
+
ABSOLUTE_MAX_LONG_EDGE,
|
|
108
|
+
Math.max(bmp.width, bmp.height)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const canvas = document.createElement("canvas");
|
|
112
|
+
const ctx = canvas.getContext("2d");
|
|
113
|
+
if (!ctx) {
|
|
114
|
+
bmp.close();
|
|
115
|
+
return { dataUrl: await fileToRawDataUrl(file), compressed: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// 1024×1536 → longest edge 1536: first pass keeps full pixels, sweeps quality only.
|
|
120
|
+
for (let shrink = 0; shrink < 18; shrink++) {
|
|
121
|
+
layoutToCanvas(bmp, canvas, ctx, maxLongEdge);
|
|
122
|
+
const blob = await encodeUnderBudget(canvas);
|
|
123
|
+
if (blob) {
|
|
124
|
+
return { dataUrl: await blobToDataUrl(blob), compressed: true };
|
|
125
|
+
}
|
|
126
|
+
maxLongEdge = Math.max(360, Math.floor(maxLongEdge * 0.9));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
canvas.width = Math.max(1, Math.min(512, bmp.width));
|
|
130
|
+
canvas.height = Math.max(1, Math.round((bmp.height / bmp.width) * canvas.width));
|
|
131
|
+
ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
|
132
|
+
const fallback = await canvasToBlob(canvas, "image/jpeg", 0.38);
|
|
133
|
+
if (fallback && fallback.size > 0) {
|
|
134
|
+
return { dataUrl: await blobToDataUrl(fallback), compressed: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { dataUrl: await fileToRawDataUrl(file), compressed: false };
|
|
138
|
+
} finally {
|
|
139
|
+
bmp.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Ally as bundleAlly } from "../generated/ally";
|
|
2
|
+
import { Enemies as bundleEnemies } from "../generated/enemy";
|
|
3
|
+
import type { EntityClass } from "../entityTypes";
|
|
4
|
+
import type { AllyTree } from "../sdk/allyMap";
|
|
5
|
+
import type { SchemaField } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Inject studio entity data (from Tools / your API). Until set, built-in bundles are empty trees.
|
|
9
|
+
*/
|
|
10
|
+
export type GameContentProvider = {
|
|
11
|
+
getEntityClasses(): EntityClass[];
|
|
12
|
+
/** Map **primary-key string -> row** for a class slug. */
|
|
13
|
+
getEntities(classSlug: string): AllyTree;
|
|
14
|
+
getEntityPlayerSchema(classSlug: string): SchemaField[];
|
|
15
|
+
getVersion?(): string | null;
|
|
16
|
+
/** @deprecated Use `getEntities("allies")`. */
|
|
17
|
+
getAllies(): AllyTree;
|
|
18
|
+
/** @deprecated Use `getEntities("enemies")`. */
|
|
19
|
+
getEnemies(): AllyTree;
|
|
20
|
+
/** @deprecated Use `getEntityPlayerSchema("allies")`. */
|
|
21
|
+
getAllyPlayerSchema?(): SchemaField[];
|
|
22
|
+
/** @deprecated Use `getEntityPlayerSchema("enemies")`. */
|
|
23
|
+
getEnemyPlayerSchema?(): SchemaField[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type GameContentState = {
|
|
27
|
+
classes: EntityClass[];
|
|
28
|
+
entities: Record<string, AllyTree>;
|
|
29
|
+
playerSchemas: Record<string, SchemaField[]>;
|
|
30
|
+
version: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const emptyTree: AllyTree = {};
|
|
34
|
+
|
|
35
|
+
const staticProvider: GameContentProvider = {
|
|
36
|
+
getEntityClasses: () => [],
|
|
37
|
+
getEntities: (slug) => {
|
|
38
|
+
if (slug === "allies") return bundleAlly;
|
|
39
|
+
if (slug === "enemies") return bundleEnemies;
|
|
40
|
+
return emptyTree;
|
|
41
|
+
},
|
|
42
|
+
getEntityPlayerSchema: () => [],
|
|
43
|
+
getAllies: () => bundleAlly,
|
|
44
|
+
getEnemies: () => bundleEnemies,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let activeProvider: GameContentProvider = staticProvider;
|
|
48
|
+
|
|
49
|
+
export function setGameContentProvider(provider: GameContentProvider): void {
|
|
50
|
+
activeProvider = provider;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setGameContentState(state: GameContentState): void {
|
|
54
|
+
setGameContentProvider({
|
|
55
|
+
getEntityClasses: () => state.classes,
|
|
56
|
+
getEntities: (slug) => state.entities[slug] ?? emptyTree,
|
|
57
|
+
getEntityPlayerSchema: (slug) => state.playerSchemas[slug] ?? [],
|
|
58
|
+
getVersion: () => state.version,
|
|
59
|
+
getAllies: () => state.entities.allies ?? emptyTree,
|
|
60
|
+
getEnemies: () => state.entities.enemies ?? emptyTree,
|
|
61
|
+
getAllyPlayerSchema: () => state.playerSchemas.allies ?? [],
|
|
62
|
+
getEnemyPlayerSchema: () => state.playerSchemas.enemies ?? [],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getGameContentProvider(): GameContentProvider {
|
|
67
|
+
return activeProvider;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getEntityClasses(): EntityClass[] {
|
|
71
|
+
return activeProvider.getEntityClasses();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getEntities(classSlug: string): AllyTree {
|
|
75
|
+
return activeProvider.getEntities(classSlug);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getEntityPlayerSchema(classSlug: string): SchemaField[] {
|
|
79
|
+
return activeProvider.getEntityPlayerSchema(classSlug);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Returns the slug if a class with that slug exists, else null. */
|
|
83
|
+
export function resolveEntityClassSlug(slug: string): string | null {
|
|
84
|
+
const normalized = slug.trim().toLowerCase();
|
|
85
|
+
return getEntityClasses().some((c) => c.slug === normalized) ? normalized : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getAllies(): AllyTree {
|
|
89
|
+
return activeProvider.getAllies();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getEnemies(): AllyTree {
|
|
93
|
+
return activeProvider.getEnemies();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getVersion(): string | null {
|
|
97
|
+
return activeProvider.getVersion?.() ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getAllyPlayerSchema(): SchemaField[] {
|
|
101
|
+
return activeProvider.getAllyPlayerSchema?.() ?? activeProvider.getEntityPlayerSchema("allies");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getEnemyPlayerSchema(): SchemaField[] {
|
|
105
|
+
return activeProvider.getEnemyPlayerSchema?.() ?? activeProvider.getEntityPlayerSchema("enemies");
|
|
106
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/** Lets another deployed site (your Game) fetch GET /api/data and GET /api/enemy from the browser. */
|
|
4
|
+
export function withPublicReadCors(response: NextResponse): NextResponse {
|
|
5
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
6
|
+
return response;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function publicReadPreflight(): NextResponse {
|
|
10
|
+
return new NextResponse(null, {
|
|
11
|
+
status: 204,
|
|
12
|
+
headers: {
|
|
13
|
+
"Access-Control-Allow-Origin": "*",
|
|
14
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
15
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
16
|
+
"Access-Control-Max-Age": "86400",
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GetObjectCommand,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
S3Client,
|
|
5
|
+
} from "@aws-sdk/client-s3";
|
|
6
|
+
import { list, put } from "@vercel/blob";
|
|
7
|
+
import type { EntityClassRegistry } from "../entityTypes";
|
|
8
|
+
import type { AllyStore } from "../types";
|
|
9
|
+
import type { EquipmentStore } from "../equipmentTypes";
|
|
10
|
+
|
|
11
|
+
const ENTITY_REGISTRY_BLOB = "entity-classes";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Studio JSON assets (allies / enemies, draft & live lanes).
|
|
15
|
+
*
|
|
16
|
+
* **Cloudflare R2** (preferred when set): S3-compatible API via `@aws-sdk/client-s3`.
|
|
17
|
+
* - `R2_ENDPOINT` — S3 API URL only: `https://<accountid>.r2.cloudflarestorage.com` (not the public `*.r2.dev` URL)
|
|
18
|
+
* - `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` — R2 API token credentials
|
|
19
|
+
* - `R2_BUCKET_NAME` — bucket name
|
|
20
|
+
*
|
|
21
|
+
* **Vercel Blob** (fallback): used only when R2 vars are not all set.
|
|
22
|
+
* - `BLOB_READ_WRITE_TOKEN`
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type BlobLane = "draft" | "live";
|
|
26
|
+
|
|
27
|
+
function pathnameFor(studioId: string, id: string, lane: BlobLane): string {
|
|
28
|
+
return `studios/${studioId}/${lane}/${id}.json`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cloudflare R2 (S3-compatible) when all vars are set. Takes precedence over Vercel Blob. */
|
|
32
|
+
export function isR2Configured(): boolean {
|
|
33
|
+
return Boolean(
|
|
34
|
+
process.env.R2_ENDPOINT?.trim() &&
|
|
35
|
+
process.env.R2_ACCESS_KEY_ID?.trim() &&
|
|
36
|
+
process.env.R2_SECRET_ACCESS_KEY?.trim() &&
|
|
37
|
+
process.env.R2_BUCKET_NAME?.trim()
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isVercelBlobConfigured(): boolean {
|
|
42
|
+
return Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remote object storage for studio JSON (R2 preferred, else legacy Vercel Blob). */
|
|
46
|
+
export function isBlobStoreConfigured(): boolean {
|
|
47
|
+
return isR2Configured() || isVercelBlobConfigured();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let r2Client: S3Client | null = null;
|
|
51
|
+
|
|
52
|
+
function normalizeR2Endpoint(raw: string): string {
|
|
53
|
+
let t = raw.trim();
|
|
54
|
+
while (t.endsWith("/")) t = t.slice(0, -1);
|
|
55
|
+
if (t.startsWith("http://") || t.startsWith("https://")) return t;
|
|
56
|
+
return `https://${t}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getR2Client(): S3Client {
|
|
60
|
+
if (r2Client) return r2Client;
|
|
61
|
+
const endpoint = normalizeR2Endpoint(process.env.R2_ENDPOINT!);
|
|
62
|
+
r2Client = new S3Client({
|
|
63
|
+
region: "auto",
|
|
64
|
+
endpoint,
|
|
65
|
+
/** R2 S3 API expects path-style URLs; avoids signing/host issues on some deployments. */
|
|
66
|
+
forcePathStyle: true,
|
|
67
|
+
credentials: {
|
|
68
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!.trim(),
|
|
69
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!.trim(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
return r2Client;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function r2Bucket(): string {
|
|
76
|
+
return process.env.R2_BUCKET_NAME!.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isR2NotFound(err: unknown): boolean {
|
|
80
|
+
if (!err || typeof err !== "object") return false;
|
|
81
|
+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
82
|
+
return e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function r2GetJson(key: string): Promise<unknown | null> {
|
|
86
|
+
try {
|
|
87
|
+
const out = await getR2Client().send(
|
|
88
|
+
new GetObjectCommand({ Bucket: r2Bucket(), Key: key })
|
|
89
|
+
);
|
|
90
|
+
const text = await out.Body?.transformToString();
|
|
91
|
+
if (text == null) return null;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(text) as unknown;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (isR2NotFound(err)) return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function r2PutJson(key: string, payload: unknown): Promise<void> {
|
|
104
|
+
await getR2Client().send(
|
|
105
|
+
new PutObjectCommand({
|
|
106
|
+
Bucket: r2Bucket(),
|
|
107
|
+
Key: key,
|
|
108
|
+
Body: JSON.stringify(payload),
|
|
109
|
+
ContentType: "application/json",
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readBlobJson(studioId: string, id: string, lane: BlobLane): Promise<unknown | null> {
|
|
115
|
+
const pathname = pathnameFor(studioId, id, lane);
|
|
116
|
+
|
|
117
|
+
if (isR2Configured()) {
|
|
118
|
+
return r2GetJson(pathname);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const token = process.env.BLOB_READ_WRITE_TOKEN?.trim();
|
|
122
|
+
if (!token) return null;
|
|
123
|
+
|
|
124
|
+
const prefix = `studios/${studioId}/${lane}/`;
|
|
125
|
+
const { blobs } = await list({ prefix, token, limit: 100 });
|
|
126
|
+
|
|
127
|
+
const blob = blobs.find((b) => b.pathname === pathname);
|
|
128
|
+
if (!blob?.url) return null;
|
|
129
|
+
|
|
130
|
+
const res = await fetch(blob.url);
|
|
131
|
+
if (!res.ok) return null;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(await res.text()) as unknown;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function writeStudioBlob(
|
|
140
|
+
studioId: string,
|
|
141
|
+
id: string,
|
|
142
|
+
lane: BlobLane,
|
|
143
|
+
payload: unknown
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const pathname = pathnameFor(studioId, id, lane);
|
|
146
|
+
|
|
147
|
+
if (isR2Configured()) {
|
|
148
|
+
await r2PutJson(pathname, payload);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const token = process.env.BLOB_READ_WRITE_TOKEN?.trim();
|
|
153
|
+
if (!token) return;
|
|
154
|
+
|
|
155
|
+
await put(pathname, JSON.stringify(payload), {
|
|
156
|
+
access: "public",
|
|
157
|
+
token,
|
|
158
|
+
addRandomSuffix: false,
|
|
159
|
+
contentType: "application/json",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function readEntityRegistryFromBlob(
|
|
164
|
+
studioId: string,
|
|
165
|
+
lane: BlobLane = "draft"
|
|
166
|
+
): Promise<EntityClassRegistry | null> {
|
|
167
|
+
const raw = await readBlobJson(studioId, ENTITY_REGISTRY_BLOB, lane);
|
|
168
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
169
|
+
return raw as EntityClassRegistry;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function writeEntityRegistryToBlob(
|
|
173
|
+
studioId: string,
|
|
174
|
+
registry: EntityClassRegistry,
|
|
175
|
+
lane: BlobLane = "draft"
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
await writeStudioBlob(studioId, ENTITY_REGISTRY_BLOB, lane, registry);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function readEntityClassFromBlob(
|
|
181
|
+
studioId: string,
|
|
182
|
+
slug: string,
|
|
183
|
+
lane: BlobLane = "draft"
|
|
184
|
+
): Promise<unknown | null> {
|
|
185
|
+
return readBlobJson(studioId, slug, lane);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function writeEntityClassToBlob(
|
|
189
|
+
studioId: string,
|
|
190
|
+
slug: string,
|
|
191
|
+
lane: BlobLane,
|
|
192
|
+
payload: unknown
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
await writeStudioBlob(studioId, slug, lane, payload);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function readAllyStoreFromBlob(studioId: string, lane: BlobLane = "draft"): Promise<AllyStore | null> {
|
|
198
|
+
const raw = await readEntityClassFromBlob(studioId, "allies", lane);
|
|
199
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
200
|
+
return raw as AllyStore;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function writeAllyStoreToBlob(studioId: string, store: AllyStore, lane: BlobLane = "draft"): Promise<void> {
|
|
204
|
+
await writeEntityClassToBlob(studioId, "allies", lane, store);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function readEquipmentStoreFromBlob(
|
|
208
|
+
studioId: string,
|
|
209
|
+
lane: BlobLane = "draft"
|
|
210
|
+
): Promise<EquipmentStore | null> {
|
|
211
|
+
const raw = await readEntityClassFromBlob(studioId, "enemies", lane);
|
|
212
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
213
|
+
return raw as EquipmentStore;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function writeEquipmentStoreToBlob(
|
|
217
|
+
studioId: string,
|
|
218
|
+
store: EquipmentStore,
|
|
219
|
+
lane: BlobLane = "draft"
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
await writeEntityClassToBlob(studioId, "enemies", lane, store);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Copy entity registry and every class blob from draft to live. */
|
|
225
|
+
export async function copyDraftToLive(studioId: string): Promise<void> {
|
|
226
|
+
const { defaultEntityRegistry } = await import("../entityTypes");
|
|
227
|
+
const registry =
|
|
228
|
+
(await readEntityRegistryFromBlob(studioId, "draft")) ?? defaultEntityRegistry();
|
|
229
|
+
await writeEntityRegistryToBlob(studioId, registry, "live");
|
|
230
|
+
|
|
231
|
+
for (const c of registry.classes) {
|
|
232
|
+
const raw = await readEntityClassFromBlob(studioId, c.slug, "draft");
|
|
233
|
+
if (raw) await writeEntityClassToBlob(studioId, c.slug, "live", raw);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read from the old flat path (studios/{studioId}/{id}.json) used before
|
|
239
|
+
* the draft/live lane system. Returns null if nothing exists there.
|
|
240
|
+
*/
|
|
241
|
+
async function readLegacyBlobJson(studioId: string, id: string): Promise<unknown | null> {
|
|
242
|
+
const pathname = `studios/${studioId}/${id}.json`;
|
|
243
|
+
|
|
244
|
+
if (isR2Configured()) {
|
|
245
|
+
return r2GetJson(pathname);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const token = process.env.BLOB_READ_WRITE_TOKEN?.trim();
|
|
249
|
+
if (!token) return null;
|
|
250
|
+
|
|
251
|
+
const prefix = `studios/${studioId}/`;
|
|
252
|
+
const { blobs } = await list({ prefix, token, limit: 100 });
|
|
253
|
+
|
|
254
|
+
const blob = blobs.find((b) => b.pathname === pathname);
|
|
255
|
+
if (!blob?.url) return null;
|
|
256
|
+
|
|
257
|
+
const res = await fetch(blob.url);
|
|
258
|
+
if (!res.ok) return null;
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(await res.text()) as unknown;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Migrate data from the old flat path to both draft and live lanes.
|
|
268
|
+
* Called once when draft lane is empty on first access after the upgrade.
|
|
269
|
+
*/
|
|
270
|
+
export async function migrateLegacyToLanes(studioId: string): Promise<boolean> {
|
|
271
|
+
const legacyAllies = await readLegacyBlobJson(studioId, "allies");
|
|
272
|
+
const legacyEnemies = await readLegacyBlobJson(studioId, "enemies");
|
|
273
|
+
|
|
274
|
+
if (!legacyAllies && !legacyEnemies) return false;
|
|
275
|
+
|
|
276
|
+
if (legacyAllies) {
|
|
277
|
+
await writeStudioBlob(studioId, "allies", "draft", legacyAllies);
|
|
278
|
+
await writeStudioBlob(studioId, "allies", "live", legacyAllies);
|
|
279
|
+
}
|
|
280
|
+
if (legacyEnemies) {
|
|
281
|
+
await writeStudioBlob(studioId, "enemies", "draft", legacyEnemies);
|
|
282
|
+
await writeStudioBlob(studioId, "enemies", "live", legacyEnemies);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createClient } from "@/lib/supabase/server";
|
|
2
|
+
|
|
3
|
+
export type StudioRow = {
|
|
4
|
+
id: string;
|
|
5
|
+
owner_id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
read_key: string;
|
|
8
|
+
current_version: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Look up a studio by its public read key. Returns null if not found. */
|
|
12
|
+
export async function getStudioByReadKey(readKey: string): Promise<StudioRow | null> {
|
|
13
|
+
const supabase = await createClient();
|
|
14
|
+
const { data, error } = await supabase
|
|
15
|
+
.from("studios")
|
|
16
|
+
.select("id, owner_id, name, read_key, current_version")
|
|
17
|
+
.eq("read_key", readKey)
|
|
18
|
+
.maybeSingle();
|
|
19
|
+
|
|
20
|
+
if (error || !data) return null;
|
|
21
|
+
return data as StudioRow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Look up the first studio owned by the currently authenticated user. */
|
|
25
|
+
export async function getStudioForCurrentUser(): Promise<StudioRow | null> {
|
|
26
|
+
const supabase = await createClient();
|
|
27
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
28
|
+
if (authError || !user) return null;
|
|
29
|
+
|
|
30
|
+
const { data, error } = await supabase
|
|
31
|
+
.from("studios")
|
|
32
|
+
.select("id, owner_id, name, read_key, current_version")
|
|
33
|
+
.eq("owner_id", user.id)
|
|
34
|
+
.limit(1)
|
|
35
|
+
.maybeSingle();
|
|
36
|
+
|
|
37
|
+
if (error || !data) return null;
|
|
38
|
+
return data as StudioRow;
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AllyRow, AllySchema } from "./types";
|
|
2
|
+
|
|
3
|
+
export type EnemyRow = AllyRow;
|
|
4
|
+
|
|
5
|
+
export type EnemyStore = {
|
|
6
|
+
schema: AllySchema;
|
|
7
|
+
playerSchema: AllySchema;
|
|
8
|
+
enemies: EnemyRow[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function emptyEnemyStore(): EnemyStore {
|
|
12
|
+
return { schema: { fields: [] }, playerSchema: { fields: [] }, enemies: [] };
|
|
13
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BlobLane,
|
|
3
|
+
isBlobStoreConfigured,
|
|
4
|
+
migrateLegacyToLanes,
|
|
5
|
+
readEntityClassFromBlob,
|
|
6
|
+
writeEntityClassToBlob,
|
|
7
|
+
} from "./db/studioBlobStore";
|
|
8
|
+
import { normalizeStore } from "./sdk/parseStore";
|
|
9
|
+
import type { AllyRow, AllyStore } from "./types";
|
|
10
|
+
import { emptyStore } from "./types";
|
|
11
|
+
|
|
12
|
+
function requireStudioId(studioId: string | undefined, op: string): asserts studioId is string {
|
|
13
|
+
if (!studioId?.trim()) {
|
|
14
|
+
throw new Error(`${op}: studioId is required.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requireRemoteStorage(op: string): void {
|
|
19
|
+
if (!isBlobStoreConfigured()) {
|
|
20
|
+
throw new Error(`${op}: Remote storage is not configured.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rowsFromBlobPayload(raw: Record<string, unknown>, slug: string): AllyRow[] {
|
|
25
|
+
if (slug === "allies" && Array.isArray(raw.allies)) return raw.allies as AllyRow[];
|
|
26
|
+
if (slug === "enemies" && Array.isArray(raw.equipment)) return raw.equipment as AllyRow[];
|
|
27
|
+
if (Array.isArray(raw.rows)) return raw.rows as AllyRow[];
|
|
28
|
+
if (Array.isArray(raw.entities)) return raw.entities as AllyRow[];
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blobPayloadFromStore(store: AllyStore, slug: string): Record<string, unknown> {
|
|
33
|
+
const base = {
|
|
34
|
+
schema: store.schema,
|
|
35
|
+
playerSchema: store.playerSchema,
|
|
36
|
+
};
|
|
37
|
+
if (slug === "allies") return { ...base, allies: store.allies };
|
|
38
|
+
if (slug === "enemies") return { ...base, equipment: store.allies };
|
|
39
|
+
return { ...base, rows: store.allies };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readEntityClassStore(
|
|
43
|
+
studioId: string,
|
|
44
|
+
slug: string,
|
|
45
|
+
lane: BlobLane = "draft"
|
|
46
|
+
): Promise<AllyStore> {
|
|
47
|
+
requireStudioId(studioId, "readEntityClassStore");
|
|
48
|
+
requireRemoteStorage("readEntityClassStore");
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
let raw = await readEntityClassFromBlob(studioId, slug, lane);
|
|
52
|
+
|
|
53
|
+
if (!raw && lane === "draft" && (slug === "allies" || slug === "enemies")) {
|
|
54
|
+
const migrated = await migrateLegacyToLanes(studioId);
|
|
55
|
+
if (migrated) raw = await readEntityClassFromBlob(studioId, slug, lane);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (raw && typeof raw === "object") {
|
|
59
|
+
const o = raw as Record<string, unknown>;
|
|
60
|
+
const schema = o.schema;
|
|
61
|
+
if (!schema || typeof schema !== "object" || !Array.isArray((schema as { fields?: unknown }).fields)) {
|
|
62
|
+
return emptyStore();
|
|
63
|
+
}
|
|
64
|
+
const allies = rowsFromBlobPayload(o, slug);
|
|
65
|
+
return normalizeStore({
|
|
66
|
+
schema: o.schema as AllyStore["schema"],
|
|
67
|
+
playerSchema: (o.playerSchema as AllyStore["playerSchema"]) ?? { fields: [] },
|
|
68
|
+
allies,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error("[entityClassStorage] read failed", slug, err);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return emptyStore();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeEntityClassStore(
|
|
80
|
+
studioId: string,
|
|
81
|
+
slug: string,
|
|
82
|
+
store: AllyStore,
|
|
83
|
+
lane: BlobLane = "draft"
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
requireStudioId(studioId, "writeEntityClassStore");
|
|
86
|
+
requireRemoteStorage("writeEntityClassStore");
|
|
87
|
+
const normalized = normalizeStore(store);
|
|
88
|
+
await writeEntityClassToBlob(studioId, slug, lane, blobPayloadFromStore(normalized, slug));
|
|
89
|
+
}
|