@fzgame/cli 0.0.0-stage → 0.1.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/LICENSE +15 -0
- package/README.md +313 -2
- package/dist/cli.cjs +1077 -0
- package/dist/cli.cjs.map +1 -0
- package/example/click-counter/README.md +11 -0
- package/example/click-counter/editor/index.html +68 -0
- package/example/click-counter/fzgame.json +65 -0
- package/example/click-counter/game/index.html +75 -0
- package/example/click-counter/handler/index.js +13 -0
- package/package.json +61 -4
- package/src/cli.ts +11 -0
- package/src/dev.ts +311 -0
- package/src/env.ts +30 -0
- package/src/fetch.ts +37 -0
- package/src/fzgame.schema.json +69 -0
- package/src/init.ts +74 -0
- package/src/kit-path.ts +40 -0
- package/src/manifest.ts +182 -0
- package/src/pack.ts +108 -0
- package/src/program.ts +110 -0
- package/src/publish.ts +59 -0
- package/src/runner-pipeline.ts +48 -0
- package/src/runner-receipt.ts +35 -0
- package/src/runner.ts +304 -0
- package/src/version.ts +19 -0
- package/src/zip.ts +80 -0
package/src/dev.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
4
|
+
import { extname, join, resolve, sep } from 'node:path'
|
|
5
|
+
import { KIT_HINT, resolveKitFile } from './kit-path'
|
|
6
|
+
import type { FzGameManifest } from './manifest'
|
|
7
|
+
import { loadManifest } from './pack'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HOST = '127.0.0.1'
|
|
10
|
+
|
|
11
|
+
function applyCors(res: ServerResponse) {
|
|
12
|
+
res.setHeader('access-control-allow-origin', '*')
|
|
13
|
+
res.setHeader('access-control-allow-headers', '*')
|
|
14
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,OPTIONS')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MIME: Record<string, string> = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
20
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Session = {
|
|
28
|
+
id: string
|
|
29
|
+
token: string
|
|
30
|
+
status: string
|
|
31
|
+
score: number | null
|
|
32
|
+
result: unknown
|
|
33
|
+
metadata: { snapshot?: unknown }
|
|
34
|
+
gameInstanceId: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DevServerOptions = {
|
|
38
|
+
/** Bind address. Defaults to loopback: the mock runtime has no auth. */
|
|
39
|
+
host?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startDevServer(
|
|
43
|
+
root: string,
|
|
44
|
+
port: number,
|
|
45
|
+
options: DevServerOptions = {},
|
|
46
|
+
) {
|
|
47
|
+
const host = options.host || DEFAULT_HOST
|
|
48
|
+
const manifest = await loadManifest(root)
|
|
49
|
+
const sessions = new Map<string, Session>()
|
|
50
|
+
|
|
51
|
+
const server = createServer(async (req, res) => {
|
|
52
|
+
applyCors(res)
|
|
53
|
+
applySecurityHeaders(res)
|
|
54
|
+
if (req.method === 'OPTIONS') {
|
|
55
|
+
res.writeHead(204)
|
|
56
|
+
res.end()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
61
|
+
if (url.pathname === '/health') {
|
|
62
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
63
|
+
res.end(JSON.stringify({ ok: true, name: manifest.name, port }))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
url.pathname.startsWith('/games/runtime') ||
|
|
68
|
+
url.pathname.startsWith('/pub/game-instances')
|
|
69
|
+
) {
|
|
70
|
+
await handleRuntime(req, res, url, sessions, manifest)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === '/@fzgame/kit.js') {
|
|
74
|
+
await serveKit(res)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
78
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
79
|
+
res.end(launcherHtml(requestOrigin(req, host, port), manifest.name))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
await serveStatic(root, url.pathname, res)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
85
|
+
res.end(JSON.stringify({ message: (error as Error).message }))
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolveReady, reject) => {
|
|
90
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
91
|
+
reject(
|
|
92
|
+
error.code === 'EADDRINUSE'
|
|
93
|
+
? new Error(`Port ${port} is already in use. Pass --port to pick another one.`)
|
|
94
|
+
: new Error(`Cannot serve on ${host}:${port} (${error.code || error.message}).`),
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
server.listen(port, host, () => resolveReady())
|
|
98
|
+
})
|
|
99
|
+
const address = server.address()
|
|
100
|
+
return {
|
|
101
|
+
server,
|
|
102
|
+
host,
|
|
103
|
+
port: typeof address === 'object' && address ? address.port : port,
|
|
104
|
+
manifest,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applySecurityHeaders(res: ServerResponse) {
|
|
109
|
+
// Allow cross-origin requests
|
|
110
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
111
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE')
|
|
112
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
|
|
113
|
+
|
|
114
|
+
// Any parent may embed the game, so the portal origin works without a proxy.
|
|
115
|
+
res.setHeader('Content-Security-Policy', 'frame-ancestors *')
|
|
116
|
+
|
|
117
|
+
// Remove restrictive framing header if set by default
|
|
118
|
+
res.removeHeader('X-Frame-Options')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function serveKit(res: ServerResponse) {
|
|
122
|
+
try {
|
|
123
|
+
const body = await readFile(resolveKitFile())
|
|
124
|
+
res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' })
|
|
125
|
+
res.end(body)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
128
|
+
res.end(`Cannot serve @fzgame/kit: ${(error as Error).message}\n${KIT_HINT}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function serveStatic(root: string, pathname: string, res: ServerResponse) {
|
|
133
|
+
const rel = decodeURIComponent(pathname).replace(/^\//, '') || 'index.html'
|
|
134
|
+
const abs = resolve(root, rel)
|
|
135
|
+
const rootAbs = resolve(root)
|
|
136
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) {
|
|
137
|
+
res.writeHead(403)
|
|
138
|
+
res.end('Forbidden')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const info = await stat(abs)
|
|
143
|
+
const file = info.isDirectory() ? join(abs, 'index.html') : abs
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
'content-type': MIME[extname(file)] || 'application/octet-stream',
|
|
146
|
+
})
|
|
147
|
+
createReadStream(file).pipe(res)
|
|
148
|
+
} catch {
|
|
149
|
+
res.writeHead(404)
|
|
150
|
+
res.end('Not found')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function handleRuntime(
|
|
155
|
+
req: IncomingMessage,
|
|
156
|
+
res: ServerResponse,
|
|
157
|
+
url: URL,
|
|
158
|
+
sessions: Map<string, Session>,
|
|
159
|
+
manifest: FzGameManifest,
|
|
160
|
+
) {
|
|
161
|
+
res.setHeader('content-type', 'application/json')
|
|
162
|
+
res.setHeader('access-control-allow-origin', '*')
|
|
163
|
+
res.setHeader('access-control-allow-headers', 'content-type')
|
|
164
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,OPTIONS')
|
|
165
|
+
if (req.method === 'OPTIONS') {
|
|
166
|
+
res.writeHead(204)
|
|
167
|
+
res.end()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
req.method === 'GET' &&
|
|
173
|
+
(url.pathname.startsWith('/pub/game-instances/') ||
|
|
174
|
+
url.pathname.startsWith('/games/runtime/instances/'))
|
|
175
|
+
) {
|
|
176
|
+
res.end(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
id: url.pathname.split('/').pop(),
|
|
179
|
+
name: manifest.name,
|
|
180
|
+
status: 'PUBLISHED',
|
|
181
|
+
overrides: {},
|
|
182
|
+
game: { name: manifest.name, configSchema: manifest.configSchema ?? {} },
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (req.method === 'POST' && url.pathname === '/games/runtime/sessions') {
|
|
189
|
+
const body = await readJson(req)
|
|
190
|
+
const existing = [...sessions.values()].find(
|
|
191
|
+
(s) => s.gameInstanceId === body.gameInstanceId && s.status !== 'FINISHED',
|
|
192
|
+
)
|
|
193
|
+
if (existing) {
|
|
194
|
+
res.end(JSON.stringify(existing))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
const session: Session = {
|
|
198
|
+
id: `dev-${sessions.size + 1}`,
|
|
199
|
+
token: `dev-token-${sessions.size + 1}`,
|
|
200
|
+
status: 'CREATED',
|
|
201
|
+
score: null,
|
|
202
|
+
result: null,
|
|
203
|
+
metadata: { snapshot: {} },
|
|
204
|
+
gameInstanceId: String(body.gameInstanceId || 'dev-instance'),
|
|
205
|
+
}
|
|
206
|
+
sessions.set(session.token, session)
|
|
207
|
+
res.end(JSON.stringify(session))
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const emitMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)\/emit$/)
|
|
212
|
+
if (req.method === 'POST' && emitMatch) {
|
|
213
|
+
const session = sessions.get(emitMatch[1]!)
|
|
214
|
+
if (!session) {
|
|
215
|
+
res.writeHead(404)
|
|
216
|
+
res.end(JSON.stringify({ message: 'Session not found' }))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const body = await readJson(req)
|
|
220
|
+
applyDevEmit(
|
|
221
|
+
session,
|
|
222
|
+
String(body.type || ''),
|
|
223
|
+
body.payload as Record<string, unknown> | undefined,
|
|
224
|
+
)
|
|
225
|
+
res.end(JSON.stringify(session))
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const snapMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)\/snapshot$/)
|
|
230
|
+
if (req.method === 'PUT' && snapMatch) {
|
|
231
|
+
const session = sessions.get(snapMatch[1]!)
|
|
232
|
+
if (!session) {
|
|
233
|
+
res.writeHead(404)
|
|
234
|
+
res.end(JSON.stringify({ message: 'Session not found' }))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const body = await readJson(req)
|
|
238
|
+
session.metadata.snapshot = body.state ?? body
|
|
239
|
+
res.end(JSON.stringify(session))
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const getMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)$/)
|
|
244
|
+
if (req.method === 'GET' && getMatch) {
|
|
245
|
+
const session = sessions.get(getMatch[1]!)
|
|
246
|
+
if (!session) {
|
|
247
|
+
res.writeHead(404)
|
|
248
|
+
res.end(JSON.stringify({ message: 'Session not found' }))
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
res.end(JSON.stringify(session))
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
res.writeHead(404)
|
|
256
|
+
res.end(JSON.stringify({ message: 'Not found' }))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function applyDevEmit(session: Session, type: string, payload?: Record<string, unknown>) {
|
|
260
|
+
if (type === 'ready') session.status = 'READY'
|
|
261
|
+
if (type === 'start') session.status = 'PLAYING'
|
|
262
|
+
if (type === 'pause') session.status = 'PAUSED'
|
|
263
|
+
if (type === 'finish') {
|
|
264
|
+
session.status = 'FINISHED'
|
|
265
|
+
if (typeof payload?.score === 'number') session.score = payload.score
|
|
266
|
+
if (payload?.result) session.result = payload.result
|
|
267
|
+
}
|
|
268
|
+
if (type === 'abort') session.status = 'ABORTED'
|
|
269
|
+
if (type === 'score' && typeof payload?.score === 'number') session.score = payload.score
|
|
270
|
+
if (type === 'snapshot') session.metadata.snapshot = payload?.state ?? payload
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
274
|
+
return new Promise((resolveJson, reject) => {
|
|
275
|
+
const chunks: Buffer[] = []
|
|
276
|
+
req.on('data', (c) => chunks.push(c as Buffer))
|
|
277
|
+
req.on('end', () => {
|
|
278
|
+
if (chunks.length === 0) return resolveJson({})
|
|
279
|
+
try {
|
|
280
|
+
resolveJson(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
281
|
+
} catch (error) {
|
|
282
|
+
reject(error)
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
req.on('error', reject)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function requestOrigin(req: IncomingMessage, host: string, port: number) {
|
|
290
|
+
if (req.headers.host) return `http://${req.headers.host}`
|
|
291
|
+
const shown = host === '0.0.0.0' || host === '::' ? DEFAULT_HOST : host
|
|
292
|
+
return `http://${shown}:${port}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function launcherHtml(origin: string, name: string) {
|
|
296
|
+
const kit = `kit=${encodeURIComponent('/@fzgame/kit.js')}`
|
|
297
|
+
const play = `/game/index.html?gameInstanceId=dev-instance&playerToken=dev-player&apiUrl=${encodeURIComponent(origin)}&${kit}`
|
|
298
|
+
const preview = `/game/index.html?preview=true&${kit}`
|
|
299
|
+
const editor = `/editor/index.html?gameInstanceId=dev-instance&apiUrl=${encodeURIComponent(origin)}&${kit}`
|
|
300
|
+
return `<!doctype html>
|
|
301
|
+
<html>
|
|
302
|
+
<head><meta charset="utf-8"><title>${name} — fzgame dev</title></head>
|
|
303
|
+
<body style="font-family:system-ui;padding:24px">
|
|
304
|
+
<h1>${name}</h1>
|
|
305
|
+
<p>Local game developer mode.</p>
|
|
306
|
+
<p><a href="${play}">Open game</a></p>
|
|
307
|
+
<p><a href="${preview}">Open preview</a> (no play session; pass <code>gameSetting</code> JSON to try config)</p>
|
|
308
|
+
<p><a href="${editor}">Open editor</a></p>
|
|
309
|
+
</body>
|
|
310
|
+
</html>`
|
|
311
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load KEY=VALUE pairs from `<dir>/.env` into process.env.
|
|
6
|
+
*
|
|
7
|
+
* Variables already set in the environment win, so a shell export or a CI
|
|
8
|
+
* secret is never overridden by a checked-in file. Missing file is not an error.
|
|
9
|
+
*/
|
|
10
|
+
export function loadEnvFile(dir: string = process.cwd()): boolean {
|
|
11
|
+
let raw: string
|
|
12
|
+
try {
|
|
13
|
+
raw = readFileSync(join(dir, '.env'), 'utf8')
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const line of raw.split('\n')) {
|
|
19
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line)
|
|
20
|
+
if (!match) continue
|
|
21
|
+
const key = match[1]!
|
|
22
|
+
let value = match[2]!
|
|
23
|
+
const quoted =
|
|
24
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
25
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
26
|
+
if (quoted) value = value.slice(1, -1)
|
|
27
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
28
|
+
}
|
|
29
|
+
return true
|
|
30
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
2
|
+
|
|
3
|
+
export type RequestOptions = RequestInit & { timeoutMs?: number }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* fetch with a hard deadline and an error message that names the URL.
|
|
7
|
+
*
|
|
8
|
+
* undici's defaults bound connection and idle time, so a wedged API or proxy
|
|
9
|
+
* can otherwise hold a CLI or a runner forever with nothing printed.
|
|
10
|
+
*/
|
|
11
|
+
export async function request(url: string, options: RequestOptions = {}): Promise<Response> {
|
|
12
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options
|
|
13
|
+
try {
|
|
14
|
+
return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error(`${url} ${describeFailure(error, timeoutMs)}`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function describeFailure(error: unknown, timeoutMs: number) {
|
|
21
|
+
const name = (error as Error)?.name
|
|
22
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
23
|
+
return `did not respond within ${Math.round(timeoutMs / 1000)}s`
|
|
24
|
+
}
|
|
25
|
+
// fetch reports every transport problem as "fetch failed"; the reason is on the cause.
|
|
26
|
+
const cause = (error as { cause?: unknown })?.cause
|
|
27
|
+
const detail =
|
|
28
|
+
cause instanceof AggregateError
|
|
29
|
+
? cause.errors.map(codeOf).join(', ')
|
|
30
|
+
: codeOf(cause) ?? (error as Error)?.message ?? String(error)
|
|
31
|
+
return `failed: ${detail}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function codeOf(error: unknown) {
|
|
35
|
+
if (error instanceof Error) return (error as NodeJS.ErrnoException).code ?? error.message
|
|
36
|
+
return String(error)
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://fzgame.dev/schema/fzgame.json",
|
|
4
|
+
"title": "FZGame pack manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["info", "formSchema", "resultSchema", "snapshotSchema", "gameDataSchema"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"info": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"description": "Catalog identity for the game pack",
|
|
13
|
+
"required": ["name", "slug"],
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"name": { "type": "string", "minLength": 2 },
|
|
17
|
+
"slug": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
20
|
+
},
|
|
21
|
+
"version": { "type": "integer", "minimum": 1, "default": 1 },
|
|
22
|
+
"image": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Cover image URL or pack-relative path"
|
|
25
|
+
},
|
|
26
|
+
"description": { "type": "string" },
|
|
27
|
+
"author": { "type": "string" },
|
|
28
|
+
"git_url": { "type": "string", "format": "uri" }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"formSchema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"description": "JSON Schema for the create/edit form (instance config). Optional JSON Forms uiSchema lives in formUiSchema."
|
|
34
|
+
},
|
|
35
|
+
"formUiSchema": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "Optional JSON Forms / RJSF uiSchema for formSchema"
|
|
38
|
+
},
|
|
39
|
+
"resultSchema": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"description": "JSON Schema for the value the handler/finish event stores on the play session result"
|
|
42
|
+
},
|
|
43
|
+
"snapshotSchema": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"description": "JSON Schema for mid-play session snapshot (kit.setState / metadata.snapshot)"
|
|
46
|
+
},
|
|
47
|
+
"gameDataSchema": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"description": "JSON Schema for persistent game data on the instance (levels, content, save payload)"
|
|
50
|
+
},
|
|
51
|
+
"entry": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"properties": {
|
|
55
|
+
"game": { "type": "string" },
|
|
56
|
+
"editor": { "type": "string" },
|
|
57
|
+
"handler": { "type": "string" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"name": { "type": "string", "description": "Legacy alias for info.name" },
|
|
61
|
+
"slug": { "type": "string", "description": "Legacy alias for info.slug" },
|
|
62
|
+
"version": { "type": "integer", "description": "Legacy alias for info.version" },
|
|
63
|
+
"description": { "type": "string" },
|
|
64
|
+
"configSchema": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"description": "Legacy alias for formSchema"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
const EXAMPLE_DIR = join(here, '..', 'example', 'click-counter')
|
|
7
|
+
|
|
8
|
+
export type InitOptions = { force?: boolean }
|
|
9
|
+
|
|
10
|
+
export async function initGame(targetDir: string, options: InitOptions = {}) {
|
|
11
|
+
const dest = resolve(targetDir)
|
|
12
|
+
const template = await stat(EXAMPLE_DIR).then(
|
|
13
|
+
(info) => info.isDirectory(),
|
|
14
|
+
() => false,
|
|
15
|
+
)
|
|
16
|
+
if (!template) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Game template missing at ${EXAMPLE_DIR}. Reinstall @fzgame/cli, the "example" folder must ship with it.`,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await mkdir(dest, { recursive: true })
|
|
23
|
+
const existing = (await readdir(dest)).filter((name) => name !== '.DS_Store' && name !== '.git')
|
|
24
|
+
if (existing.length > 0 && !options.force) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`${dest} is not empty (${existing.slice(0, 5).join(', ')}${existing.length > 5 ? ', ...' : ''}). Pass --force to write into it.`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hasGitignore = await stat(join(dest, '.gitignore')).then(
|
|
31
|
+
() => true,
|
|
32
|
+
() => false,
|
|
33
|
+
)
|
|
34
|
+
await cp(EXAMPLE_DIR, dest, {
|
|
35
|
+
recursive: true,
|
|
36
|
+
filter: (source) => keep(source) && !(hasGitignore && source.endsWith(`${sep}.gitignore`)),
|
|
37
|
+
})
|
|
38
|
+
await ensureGitignore(dest)
|
|
39
|
+
|
|
40
|
+
const files: string[] = []
|
|
41
|
+
await walk(dest, (file) => {
|
|
42
|
+
files.push(relative(dest, file).split(sep).join('/'))
|
|
43
|
+
})
|
|
44
|
+
files.sort()
|
|
45
|
+
return { dir: dest, files }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Skip build output and installed dependencies so the scaffold stays a source tree. */
|
|
49
|
+
function keep(source: string) {
|
|
50
|
+
const name = source.split(sep).pop() ?? ''
|
|
51
|
+
return name !== 'dist' && name !== 'node_modules' && name !== '.DS_Store'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ensureGitignore(dest: string) {
|
|
55
|
+
const path = join(dest, '.gitignore')
|
|
56
|
+
let current = ''
|
|
57
|
+
try {
|
|
58
|
+
current = await readFile(path, 'utf8')
|
|
59
|
+
} catch {
|
|
60
|
+
/* no .gitignore yet */
|
|
61
|
+
}
|
|
62
|
+
if (/(^|\n)\s*dist\/?\s*(\n|$)/.test(current)) return
|
|
63
|
+
const next = current && !current.endsWith('\n') ? `${current}\n` : current
|
|
64
|
+
await writeFile(path, `${next}dist/\n`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function walk(dir: string, visit: (file: string) => void) {
|
|
68
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
69
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue
|
|
70
|
+
const abs = join(dir, entry.name)
|
|
71
|
+
if (entry.isDirectory()) await walk(abs, visit)
|
|
72
|
+
else visit(abs)
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/kit-path.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
|
|
7
|
+
export const KIT_HINT = 'Run `pnpm --filter @fzgame/kit build` (monorepo) or `npm install` (installed CLI)'
|
|
8
|
+
|
|
9
|
+
type ExportTarget = string | { import?: ExportTarget; require?: ExportTarget; default?: ExportTarget }
|
|
10
|
+
|
|
11
|
+
type KitManifest = {
|
|
12
|
+
module?: string
|
|
13
|
+
main?: string
|
|
14
|
+
exports?: Record<string, ExportTarget>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The ESM file behind an export condition, which may be a bare path or a tree
|
|
19
|
+
* of conditions (`{ import: { types, default } }`).
|
|
20
|
+
*/
|
|
21
|
+
function resolveTarget(target: ExportTarget | undefined): string | undefined {
|
|
22
|
+
if (typeof target === 'string') return target
|
|
23
|
+
if (!target) return undefined
|
|
24
|
+
return resolveTarget(target.import) ?? resolveTarget(target.default)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Absolute path of the kit's browser (ESM) bundle, the file the game loads as
|
|
29
|
+
* `/@fzgame/kit.js` in dev and as `game/vendor/kit.js` in a packed zip.
|
|
30
|
+
*
|
|
31
|
+
* The kit's own manifest decides the filename, so changing its build layout
|
|
32
|
+
* cannot silently ship a zip with no vendor script.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveKitFile(): string {
|
|
35
|
+
const manifestPath = require.resolve('@fzgame/kit/package.json')
|
|
36
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as KitManifest
|
|
37
|
+
const entry =
|
|
38
|
+
manifest.module ?? resolveTarget(manifest.exports?.['.']) ?? manifest.main ?? './dist/index.js'
|
|
39
|
+
return join(dirname(manifestPath), entry)
|
|
40
|
+
}
|