gamedev 0.1.4 → 0.1.5
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/app-server/scaffold.js +6 -0
- package/app-server/templates/claude/CLAUDE.md +16 -0
- package/app-server/templates/cursor/rules.md +16 -0
- package/app-server/templates/openai/AGENTS.md +16 -0
- package/app-server/templates/scripts/world-entities.mjs +459 -0
- package/app-server/templates/skills/lobby-ws/SKILL.md +16 -0
- package/build/public/admin.html +2 -2
- package/build/public/index.html +2 -2
- package/docs/world-entities.md +92 -0
- package/package.json +1 -1
package/app-server/scaffold.js
CHANGED
|
@@ -365,6 +365,12 @@ export function scaffoldBaseProject({
|
|
|
365
365
|
report,
|
|
366
366
|
})
|
|
367
367
|
|
|
368
|
+
writeFileWithPolicy(path.join(rootDir, 'tmp', '.gitkeep'), '', {
|
|
369
|
+
force,
|
|
370
|
+
writeFile,
|
|
371
|
+
report,
|
|
372
|
+
})
|
|
373
|
+
|
|
368
374
|
if (fs.existsSync(README_TEMPLATE)) {
|
|
369
375
|
const readmeContent = readText(README_TEMPLATE)
|
|
370
376
|
if (readmeContent != null) {
|
|
@@ -9,6 +9,22 @@ Apps live in `apps/` and each app folder contains blueprint JSON plus a script e
|
|
|
9
9
|
Local-first (project files):
|
|
10
10
|
|
|
11
11
|
- Run `npm run apps:new <AppName>` (creates `apps/<AppName>/` with `index.js` + blueprint)
|
|
12
|
+
- Batch-edit `world.json` entities: `npm run world:entities -- add --template-id <id> --transforms <file>` / `npm run world:entities -- delete --blueprint <name>`
|
|
13
|
+
- To duplicate one app many times (eg, make a forest from one `Tree`), generate many transforms and run `npm run world:entities -- add --template-id <TreeEntityId> --transforms tmp/forest.json --yes`, then delete `tmp/forest.json`
|
|
14
|
+
- See `docs/world-entities-cli.md` for full flags (`--replace`, `--ids`, `--yes`, `--world`)
|
|
15
|
+
- Transform file format for `--transforms` (JSON array):
|
|
16
|
+
```json
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
"position": [0, 0, 0],
|
|
20
|
+
"quaternion": [0, 0, 0, 1],
|
|
21
|
+
"scale": [1, 1, 1],
|
|
22
|
+
"pinned": false,
|
|
23
|
+
"props": {},
|
|
24
|
+
"state": {}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
```
|
|
12
28
|
- Put assets in top-level `assets/` and reference them from blueprint JSON for app.config props
|
|
13
29
|
- Run `npm run dev` for hot reload
|
|
14
30
|
|
|
@@ -9,6 +9,22 @@ Apps live in `apps/` and each app folder contains blueprint JSON plus a script e
|
|
|
9
9
|
Local-first (project files):
|
|
10
10
|
|
|
11
11
|
- Run `npm run apps:new <AppName>` (creates `apps/<AppName>/` with `index.js` + blueprint)
|
|
12
|
+
- Batch-edit `world.json` entities: `npm run world:entities -- add --template-id <id> --transforms <file>` / `npm run world:entities -- delete --blueprint <name>`
|
|
13
|
+
- To duplicate one app many times (eg, make a forest from one `Tree`), generate many transforms and run `npm run world:entities -- add --template-id <TreeEntityId> --transforms tmp/forest.json --yes`, then delete `tmp/forest.json`
|
|
14
|
+
- See `docs/world-entities-cli.md` for full flags (`--replace`, `--ids`, `--yes`, `--world`)
|
|
15
|
+
- Transform file format for `--transforms` (JSON array):
|
|
16
|
+
```json
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
"position": [0, 0, 0],
|
|
20
|
+
"quaternion": [0, 0, 0, 1],
|
|
21
|
+
"scale": [1, 1, 1],
|
|
22
|
+
"pinned": false,
|
|
23
|
+
"props": {},
|
|
24
|
+
"state": {}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
```
|
|
12
28
|
- Put assets in top-level `assets/` and reference them from blueprint JSON for app.config props
|
|
13
29
|
- Run `npm run dev` for hot reload
|
|
14
30
|
|
|
@@ -9,6 +9,22 @@ Apps live in `apps/` and each app folder contains blueprint JSON plus a script e
|
|
|
9
9
|
Local-first (project files):
|
|
10
10
|
|
|
11
11
|
- Run `npm run apps:new <AppName>` (creates `apps/<AppName>/` with `index.js` + blueprint)
|
|
12
|
+
- Batch-edit `world.json` entities: `npm run world:entities -- add --template-id <id> --transforms <file>` / `npm run world:entities -- delete --blueprint <name>`
|
|
13
|
+
- To duplicate one app many times (eg, make a forest from one `Tree`), generate many transforms and run `npm run world:entities -- add --template-id <TreeEntityId> --transforms tmp/forest.json --yes`, then delete `tmp/forest.json`
|
|
14
|
+
- See `docs/world-entities-cli.md` for full flags (`--replace`, `--ids`, `--yes`, `--world`)
|
|
15
|
+
- Transform file format for `--transforms` (JSON array):
|
|
16
|
+
```json
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
"position": [0, 0, 0],
|
|
20
|
+
"quaternion": [0, 0, 0, 1],
|
|
21
|
+
"scale": [1, 1, 1],
|
|
22
|
+
"pinned": false,
|
|
23
|
+
"props": {},
|
|
24
|
+
"state": {}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
```
|
|
12
28
|
- Put assets in top-level `assets/` and reference them from blueprint JSON for app.config props
|
|
13
29
|
- Run `npm run dev` for hot reload
|
|
14
30
|
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import readline from 'node:readline/promises'
|
|
7
|
+
|
|
8
|
+
const usage = `Usage:
|
|
9
|
+
npm run world:entities -- add --template-id <entityId> --transforms <path> [--world <path>] [--replace] [--yes]
|
|
10
|
+
npm run world:entities -- delete --blueprint <name> [--world <path>] [--yes]
|
|
11
|
+
npm run world:entities -- delete --ids <path> [--world <path>] [--yes]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
add Create entity clones from a template entity and a transform array.
|
|
15
|
+
delete Delete entities by blueprint name or explicit ID list.
|
|
16
|
+
|
|
17
|
+
Flags:
|
|
18
|
+
--transforms <path> (add only) JSON array file. Recommended: tmp/<name>.json (delete after run)
|
|
19
|
+
--ids <path> (delete only) JSON array of entity IDs. Recommended: tmp/<name>.json (delete after run)
|
|
20
|
+
--world <path> Path to world JSON file (default: world.json)
|
|
21
|
+
--yes Skip confirmations and allow no-op operations
|
|
22
|
+
--replace (add only) Delete existing entities using template blueprint before adding
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
26
|
+
const ID_LENGTH = 10
|
|
27
|
+
|
|
28
|
+
main().catch(error => {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
30
|
+
console.error(`Error: ${message}`)
|
|
31
|
+
process.exitCode = 1
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const argv = process.argv.slice(2)
|
|
36
|
+
|
|
37
|
+
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
38
|
+
console.log(usage.trimEnd())
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { command, options, extraArgs } = parseArgs(argv)
|
|
43
|
+
|
|
44
|
+
if (extraArgs.length > 0) {
|
|
45
|
+
throw new Error(`Unexpected positional arguments: ${extraArgs.join(' ')}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const worldPath = path.resolve(process.cwd(), getStringOption(options, 'world', 'world.json'))
|
|
49
|
+
const yes = Boolean(options.yes)
|
|
50
|
+
|
|
51
|
+
const world = await readWorld(worldPath)
|
|
52
|
+
|
|
53
|
+
if (command === 'add') {
|
|
54
|
+
await runAdd({ worldPath, world, options, yes })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (command === 'delete') {
|
|
59
|
+
await runDelete({ worldPath, world, options, yes })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(`Unknown command: ${command}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const [command, ...rest] = argv
|
|
68
|
+
const options = {}
|
|
69
|
+
const extraArgs = []
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
72
|
+
const token = rest[i]
|
|
73
|
+
|
|
74
|
+
if (!token.startsWith('--')) {
|
|
75
|
+
extraArgs.push(token)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const key = token.slice(2)
|
|
80
|
+
if (!key) {
|
|
81
|
+
throw new Error(`Invalid flag: ${token}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const maybeValue = rest[i + 1]
|
|
85
|
+
if (!maybeValue || maybeValue.startsWith('--')) {
|
|
86
|
+
options[key] = true
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
options[key] = maybeValue
|
|
91
|
+
i += 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
command,
|
|
96
|
+
options,
|
|
97
|
+
extraArgs,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runAdd({ worldPath, world, options, yes }) {
|
|
102
|
+
const templateId = getRequiredStringOption(options, 'template-id')
|
|
103
|
+
const transformsPath = path.resolve(process.cwd(), getRequiredStringOption(options, 'transforms'))
|
|
104
|
+
const replace = Boolean(options.replace)
|
|
105
|
+
|
|
106
|
+
const unsupported = ['blueprint', 'ids']
|
|
107
|
+
for (const flag of unsupported) {
|
|
108
|
+
if (options[flag] !== undefined) {
|
|
109
|
+
throw new Error(`--${flag} is not valid for the add command`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const template = world.entities.find(entity => entity?.id === templateId)
|
|
114
|
+
if (!template) {
|
|
115
|
+
throw new Error(`Template entity not found: ${templateId}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof template.blueprint !== 'string' || template.blueprint.length === 0) {
|
|
119
|
+
throw new Error(`Template entity ${templateId} is missing a valid blueprint`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const transforms = await readTransforms(transformsPath)
|
|
123
|
+
|
|
124
|
+
const baseEntities = replace
|
|
125
|
+
? world.entities.filter(entity => entity?.blueprint !== template.blueprint)
|
|
126
|
+
: [...world.entities]
|
|
127
|
+
|
|
128
|
+
const existingIds = new Set(baseEntities.map(entity => entity.id))
|
|
129
|
+
const newEntities = []
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < transforms.length; i += 1) {
|
|
132
|
+
const transform = validateTransformItem(transforms[i], i)
|
|
133
|
+
const id = transform.id ?? generateUniqueId(existingIds)
|
|
134
|
+
|
|
135
|
+
if (existingIds.has(id)) {
|
|
136
|
+
throw new Error(`Transform #${i} id already exists in world.json: ${id}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
existingIds.add(id)
|
|
140
|
+
newEntities.push(buildEntityFromTemplate(template, transform, id))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const removedCount = world.entities.length - baseEntities.length
|
|
144
|
+
const addedCount = newEntities.length
|
|
145
|
+
|
|
146
|
+
if (addedCount === 0 && removedCount === 0) {
|
|
147
|
+
if (!yes) {
|
|
148
|
+
throw new Error('No changes to apply. Pass --yes to acknowledge and continue.')
|
|
149
|
+
}
|
|
150
|
+
console.log('No changes applied (no-op acknowledged with --yes).')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (replace) {
|
|
155
|
+
const confirmed = await confirmDangerousAction({
|
|
156
|
+
yes,
|
|
157
|
+
summary: `Replace mode will delete ${removedCount} existing \"${template.blueprint}\" entities, then add ${addedCount}.`,
|
|
158
|
+
})
|
|
159
|
+
if (!confirmed) {
|
|
160
|
+
console.log('Cancelled.')
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
world.entities = baseEntities.concat(newEntities)
|
|
166
|
+
await writeWorld(worldPath, world)
|
|
167
|
+
|
|
168
|
+
console.log(`Updated ${path.basename(worldPath)}`)
|
|
169
|
+
console.log(`Blueprint: ${template.blueprint}`)
|
|
170
|
+
console.log(`Removed: ${removedCount}`)
|
|
171
|
+
console.log(`Added: ${addedCount}`)
|
|
172
|
+
console.log(`Total entities: ${world.entities.length}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runDelete({ worldPath, world, options, yes }) {
|
|
176
|
+
const byBlueprint = options.blueprint
|
|
177
|
+
const idsPath = options.ids
|
|
178
|
+
|
|
179
|
+
if (options['template-id'] !== undefined || options.transforms !== undefined || options.replace !== undefined) {
|
|
180
|
+
throw new Error('--template-id, --transforms, and --replace are only valid for add')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ((byBlueprint && idsPath) || (!byBlueprint && !idsPath)) {
|
|
184
|
+
throw new Error('Delete requires exactly one of --blueprint <name> or --ids <path>')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let keepEntities = []
|
|
188
|
+
let removedCount = 0
|
|
189
|
+
let missingIds = []
|
|
190
|
+
let summary = ''
|
|
191
|
+
|
|
192
|
+
if (byBlueprint) {
|
|
193
|
+
const blueprint = getStringOption(options, 'blueprint')
|
|
194
|
+
keepEntities = world.entities.filter(entity => entity?.blueprint !== blueprint)
|
|
195
|
+
removedCount = world.entities.length - keepEntities.length
|
|
196
|
+
summary = `Delete ${removedCount} entities with blueprint \"${blueprint}\".`
|
|
197
|
+
} else {
|
|
198
|
+
const absoluteIdsPath = path.resolve(process.cwd(), getStringOption(options, 'ids'))
|
|
199
|
+
const ids = await readIdArray(absoluteIdsPath)
|
|
200
|
+
const requested = new Set(ids)
|
|
201
|
+
const found = new Set()
|
|
202
|
+
|
|
203
|
+
keepEntities = world.entities.filter(entity => {
|
|
204
|
+
if (requested.has(entity.id)) {
|
|
205
|
+
found.add(entity.id)
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
removedCount = world.entities.length - keepEntities.length
|
|
212
|
+
missingIds = [...requested].filter(id => !found.has(id))
|
|
213
|
+
summary = `Delete ${removedCount} entities by explicit ID list (${ids.length} requested).`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (removedCount === 0) {
|
|
217
|
+
if (!yes) {
|
|
218
|
+
throw new Error('No matching entities to delete. Pass --yes to acknowledge and continue.')
|
|
219
|
+
}
|
|
220
|
+
console.log('No changes applied (no-op acknowledged with --yes).')
|
|
221
|
+
if (missingIds.length > 0) {
|
|
222
|
+
console.log(`Missing IDs: ${missingIds.length}`)
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const confirmed = await confirmDangerousAction({ yes, summary })
|
|
228
|
+
if (!confirmed) {
|
|
229
|
+
console.log('Cancelled.')
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
world.entities = keepEntities
|
|
234
|
+
await writeWorld(worldPath, world)
|
|
235
|
+
|
|
236
|
+
console.log(`Updated ${path.basename(worldPath)}`)
|
|
237
|
+
console.log(`Removed: ${removedCount}`)
|
|
238
|
+
console.log(`Total entities: ${world.entities.length}`)
|
|
239
|
+
if (missingIds.length > 0) {
|
|
240
|
+
console.log(`Missing IDs: ${missingIds.length}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildEntityFromTemplate(template, transform, id) {
|
|
245
|
+
const entity = deepClone(template)
|
|
246
|
+
|
|
247
|
+
entity.id = id
|
|
248
|
+
entity.blueprint = template.blueprint
|
|
249
|
+
entity.position = transform.position
|
|
250
|
+
entity.quaternion = transform.quaternion ?? [0, 0, 0, 1]
|
|
251
|
+
entity.scale = transform.scale ?? [1, 1, 1]
|
|
252
|
+
entity.pinned = transform.pinned ?? false
|
|
253
|
+
entity.props = transform.props ?? deepClone(template.props ?? {})
|
|
254
|
+
entity.state = transform.state ?? deepClone(template.state ?? {})
|
|
255
|
+
|
|
256
|
+
return entity
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateTransformItem(item, index) {
|
|
260
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
261
|
+
throw new Error(`Transform #${index} must be an object`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const validated = {}
|
|
265
|
+
|
|
266
|
+
if (item.id !== undefined) {
|
|
267
|
+
if (typeof item.id !== 'string' || item.id.length === 0) {
|
|
268
|
+
throw new Error(`Transform #${index} field \"id\" must be a non-empty string`)
|
|
269
|
+
}
|
|
270
|
+
validated.id = item.id
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (item.position === undefined) {
|
|
274
|
+
throw new Error(`Transform #${index} is missing required field \"position\"`)
|
|
275
|
+
}
|
|
276
|
+
validated.position = validateVector(item.position, 3, `Transform #${index} field \"position\"`)
|
|
277
|
+
|
|
278
|
+
if (item.quaternion !== undefined) {
|
|
279
|
+
validated.quaternion = validateVector(item.quaternion, 4, `Transform #${index} field \"quaternion\"`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (item.scale !== undefined) {
|
|
283
|
+
validated.scale = validateVector(item.scale, 3, `Transform #${index} field \"scale\"`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (item.pinned !== undefined) {
|
|
287
|
+
if (typeof item.pinned !== 'boolean') {
|
|
288
|
+
throw new Error(`Transform #${index} field \"pinned\" must be boolean`)
|
|
289
|
+
}
|
|
290
|
+
validated.pinned = item.pinned
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (item.props !== undefined) {
|
|
294
|
+
if (!isPlainObject(item.props)) {
|
|
295
|
+
throw new Error(`Transform #${index} field \"props\" must be an object`)
|
|
296
|
+
}
|
|
297
|
+
validated.props = deepClone(item.props)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (item.state !== undefined) {
|
|
301
|
+
if (!isPlainObject(item.state)) {
|
|
302
|
+
throw new Error(`Transform #${index} field \"state\" must be an object`)
|
|
303
|
+
}
|
|
304
|
+
validated.state = deepClone(item.state)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return validated
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateVector(value, expectedLength, label) {
|
|
311
|
+
if (!Array.isArray(value)) {
|
|
312
|
+
throw new Error(`${label} must be an array of ${expectedLength} numbers`)
|
|
313
|
+
}
|
|
314
|
+
if (value.length !== expectedLength) {
|
|
315
|
+
throw new Error(`${label} must contain exactly ${expectedLength} numbers`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const out = []
|
|
319
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
320
|
+
const num = value[i]
|
|
321
|
+
if (typeof num !== 'number' || !Number.isFinite(num)) {
|
|
322
|
+
throw new Error(`${label} index ${i} must be a finite number`)
|
|
323
|
+
}
|
|
324
|
+
out.push(num)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return out
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function generateUniqueId(existing) {
|
|
331
|
+
let id = ''
|
|
332
|
+
do {
|
|
333
|
+
const bytes = randomBytes(ID_LENGTH)
|
|
334
|
+
let next = ''
|
|
335
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
336
|
+
next += ID_ALPHABET[bytes[i] % ID_ALPHABET.length]
|
|
337
|
+
}
|
|
338
|
+
id = next
|
|
339
|
+
} while (existing.has(id))
|
|
340
|
+
|
|
341
|
+
return id
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function confirmDangerousAction({ yes, summary }) {
|
|
345
|
+
if (yes) return true
|
|
346
|
+
|
|
347
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
348
|
+
throw new Error('Confirmation required in non-interactive mode. Re-run with --yes.')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const rl = readline.createInterface({
|
|
352
|
+
input: process.stdin,
|
|
353
|
+
output: process.stdout,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const response = await rl.question(`${summary}\nType \"yes\" to continue: `)
|
|
358
|
+
return response.trim().toLowerCase() === 'yes'
|
|
359
|
+
} finally {
|
|
360
|
+
rl.close()
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function readWorld(worldPath) {
|
|
365
|
+
const raw = await fs.readFile(worldPath, 'utf8')
|
|
366
|
+
let parsed
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(raw)
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new Error(`Failed to parse ${worldPath}: ${error.message}`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
375
|
+
throw new Error(`${worldPath} must contain a JSON object`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!Array.isArray(parsed.entities)) {
|
|
379
|
+
throw new Error(`${worldPath} must contain an \"entities\" array`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return parsed
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function readTransforms(transformsPath) {
|
|
386
|
+
const raw = await fs.readFile(transformsPath, 'utf8')
|
|
387
|
+
let parsed
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(raw)
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw new Error(`Failed to parse transforms file ${transformsPath}: ${error.message}`)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!Array.isArray(parsed)) {
|
|
396
|
+
throw new Error(`Transforms file ${transformsPath} must be a JSON array`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return parsed
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function readIdArray(idsPath) {
|
|
403
|
+
const raw = await fs.readFile(idsPath, 'utf8')
|
|
404
|
+
let parsed
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
parsed = JSON.parse(raw)
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw new Error(`Failed to parse IDs file ${idsPath}: ${error.message}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!Array.isArray(parsed)) {
|
|
413
|
+
throw new Error(`IDs file ${idsPath} must be a JSON array of strings`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (let i = 0; i < parsed.length; i += 1) {
|
|
417
|
+
if (typeof parsed[i] !== 'string' || parsed[i].length === 0) {
|
|
418
|
+
throw new Error(`IDs file ${idsPath} item #${i} must be a non-empty string`)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return parsed
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function writeWorld(worldPath, world) {
|
|
426
|
+
const dir = path.dirname(worldPath)
|
|
427
|
+
const tempPath = path.join(dir, `.${path.basename(worldPath)}.${process.pid}.tmp`)
|
|
428
|
+
const content = `${JSON.stringify(world, null, 2)}\n`
|
|
429
|
+
|
|
430
|
+
await fs.writeFile(tempPath, content, 'utf8')
|
|
431
|
+
await fs.rename(tempPath, worldPath)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function getRequiredStringOption(options, key) {
|
|
435
|
+
if (typeof options[key] !== 'string' || options[key].length === 0) {
|
|
436
|
+
throw new Error(`Missing required flag: --${key}`)
|
|
437
|
+
}
|
|
438
|
+
return options[key]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function getStringOption(options, key, fallback = undefined) {
|
|
442
|
+
const value = options[key]
|
|
443
|
+
|
|
444
|
+
if (value === undefined) return fallback
|
|
445
|
+
|
|
446
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
447
|
+
throw new Error(`Flag --${key} requires a value`)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return value
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function deepClone(value) {
|
|
454
|
+
return JSON.parse(JSON.stringify(value))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isPlainObject(value) {
|
|
458
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
459
|
+
}
|
|
@@ -14,6 +14,22 @@ Apps live in `apps/` and each app folder contains blueprint JSON plus a script e
|
|
|
14
14
|
Local-first (project files):
|
|
15
15
|
|
|
16
16
|
- Run `npm run apps:new <AppName>` (creates `apps/<AppName>/` with `index.js` + blueprint)
|
|
17
|
+
- Batch-edit `world.json` entities: `npm run world:entities -- add --template-id <id> --transforms <file>` / `npm run world:entities -- delete --blueprint <name>`
|
|
18
|
+
- To duplicate one app many times (eg, make a forest from one `Tree`), generate many transforms and run `npm run world:entities -- add --template-id <TreeEntityId> --transforms tmp/forest.json --yes`, then delete `tmp/forest.json`
|
|
19
|
+
- See `docs/world-entities-cli.md` for full flags (`--replace`, `--ids`, `--yes`, `--world`)
|
|
20
|
+
- Transform file format for `--transforms` (JSON array):
|
|
21
|
+
```json
|
|
22
|
+
[
|
|
23
|
+
{
|
|
24
|
+
"position": [0, 0, 0],
|
|
25
|
+
"quaternion": [0, 0, 0, 1],
|
|
26
|
+
"scale": [1, 1, 1],
|
|
27
|
+
"pinned": false,
|
|
28
|
+
"props": {},
|
|
29
|
+
"state": {}
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
```
|
|
17
33
|
- Put assets in top-level `assets/` and reference them from blueprint JSON for app.config props
|
|
18
34
|
- Run `npm run dev` for hot reload
|
|
19
35
|
|
package/build/public/admin.html
CHANGED
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
26
26
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
27
27
|
<link rel="preload" href="/SpaceMono-regular.woff2" as="font" type="font/woff2" crossorigin />
|
|
28
|
-
<link rel="stylesheet" type="text/css" href="/index.css?v=
|
|
28
|
+
<link rel="stylesheet" type="text/css" href="/index.css?v=1771723069907" />
|
|
29
29
|
<script>
|
|
30
30
|
window.PARTICLES_PATH = '/particles-4YQR4CFO.js'
|
|
31
31
|
</script>
|
|
32
32
|
</head>
|
|
33
33
|
<body>
|
|
34
34
|
<div id="root"></div>
|
|
35
|
-
<script src="/env.js?v=
|
|
35
|
+
<script src="/env.js?v=1771723069907"></script>
|
|
36
36
|
<script src="/admin-VV3XAOYE.js" type="module"></script>
|
|
37
37
|
</body>
|
|
38
38
|
</html>
|
package/build/public/index.html
CHANGED
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
<meta name="theme-color" content="#ffffff"> -->
|
|
45
45
|
|
|
46
46
|
<link rel="preload" href="/SpaceMono-regular.woff2" as="font" type="font/woff2" crossorigin />
|
|
47
|
-
<link rel="stylesheet" type="text/css" href="/index.css?v=
|
|
47
|
+
<link rel="stylesheet" type="text/css" href="/index.css?v=1771723069906" />
|
|
48
48
|
<script>
|
|
49
49
|
window.PARTICLES_PATH = '/particles-4YQR4CFO.js'
|
|
50
50
|
</script>
|
|
51
51
|
</head>
|
|
52
52
|
<body>
|
|
53
53
|
<div id="root"></div>
|
|
54
|
-
<script src="/env.js?v=
|
|
54
|
+
<script src="/env.js?v=1771723069906"></script>
|
|
55
55
|
<script src="/index-FOLIOXLC.js" type="module"></script>
|
|
56
56
|
</body>
|
|
57
57
|
</html>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# World Entities CLI
|
|
2
|
+
|
|
3
|
+
Batch-create and batch-delete entities in `world.json` for agent-driven workflows.
|
|
4
|
+
|
|
5
|
+
This tool is generic by design: agents decide what to place and where, and provide transform data.
|
|
6
|
+
|
|
7
|
+
## Command
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run world:entities -- <command> [flags]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Add entities from a template
|
|
14
|
+
|
|
15
|
+
Clone an existing entity instance by ID, but apply transform overrides from a JSON file.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run world:entities -- add \
|
|
19
|
+
--template-id k3sbGG4iq4 \
|
|
20
|
+
--transforms tmp/add-trees.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Replace mode (delete all current instances of the template blueprint first, then add):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run world:entities -- add \
|
|
27
|
+
--template-id k3sbGG4iq4 \
|
|
28
|
+
--transforms tmp/add-trees.json \
|
|
29
|
+
--replace \
|
|
30
|
+
--yes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Delete entities
|
|
34
|
+
|
|
35
|
+
Delete by blueprint:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm run world:entities -- delete --blueprint Tree --yes
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Delete by explicit ID list file:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm run world:entities -- delete --ids tmp/delete-ids.json --yes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Flags
|
|
48
|
+
|
|
49
|
+
- `--world <path>`: world file path (default `world.json`)
|
|
50
|
+
- `--yes`: skip confirmation prompts and allow no-op operations
|
|
51
|
+
- `--replace`: add mode only; remove existing entities with template blueprint before add
|
|
52
|
+
|
|
53
|
+
## Transforms File Format
|
|
54
|
+
|
|
55
|
+
`--transforms` must point to a JSON array of objects:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
[
|
|
59
|
+
{
|
|
60
|
+
"position": [0, 0, 0],
|
|
61
|
+
"quaternion": [0, 0, 0, 1],
|
|
62
|
+
"scale": [1, 1, 1],
|
|
63
|
+
"pinned": false,
|
|
64
|
+
"props": {},
|
|
65
|
+
"state": {}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Fields:
|
|
71
|
+
|
|
72
|
+
- `position` required (`[x, y, z]`)
|
|
73
|
+
- `quaternion` optional (`[x, y, z, w]`, default `[0,0,0,1]`)
|
|
74
|
+
- `scale` optional (`[x, y, z]`, default `[1,1,1]`)
|
|
75
|
+
- `pinned` optional (default `false`)
|
|
76
|
+
- `props` optional (default cloned from template entity)
|
|
77
|
+
- `state` optional (default cloned from template entity)
|
|
78
|
+
- `id` optional (auto-generated short 10-char ID if omitted)
|
|
79
|
+
|
|
80
|
+
## Temp file workflow for agents
|
|
81
|
+
|
|
82
|
+
Store generated transform files in `tmp/` (gitignored except `.gitkeep`/`README.md`), run the command, then delete the temp file.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# 1) Create temp transforms JSON under tmp/
|
|
88
|
+
# 2) Run CLI
|
|
89
|
+
npm run world:entities -- add --template-id k3sbGG4iq4 --transforms tmp/run-001.json --yes
|
|
90
|
+
# 3) Remove temp file
|
|
91
|
+
rm tmp/run-001.json
|
|
92
|
+
```
|