@pixelvibing/cli 0.10.0 → 0.11.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.
@@ -4,8 +4,8 @@ import { realpath, promises } from "node:fs";
4
4
  import { promisify, parseArgs } from "node:util";
5
5
  import { randomBytes, createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
- import { stat, readFile, writeFile, mkdir, readdir, lstat, rm, rename, realpath as realpath$1 } from "node:fs/promises";
8
- import { inflate, deflateSync, inflateSync } from "node:zlib";
7
+ import { stat, readFile, writeFile, mkdir, readdir, rename, lstat, rm, realpath as realpath$1 } from "node:fs/promises";
8
+ import { inflate, deflate, deflateSync, inflateSync } from "node:zlib";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawn } from "node:child_process";
11
11
  import { hostname, homedir } from "node:os";
@@ -623,7 +623,7 @@ function bearerFetch(fetcher, apiUrl, token) {
623
623
  }
624
624
  function platformWith(fetcher, apiUrl, token) {
625
625
  const carrying = bearerFetch(fetcher, apiUrl, token);
626
- return { apiUrl, web: createWebApi(carrying, apiUrl), account: createAccountApi(carrying, apiUrl) };
626
+ return { apiUrl, web: createWebApi(carrying, apiUrl), account: createAccountApi(carrying, apiUrl), call: createCaller(carrying, apiUrl) };
627
627
  }
628
628
  async function signedIn(context) {
629
629
  const apiUrl = apiUrlOf(context);
@@ -1016,66 +1016,6 @@ function nameProblem(name) {
1016
1016
  }
1017
1017
  return null;
1018
1018
  }
1019
- const PULLED_FILE = `${PROJECT_DIRECTORY}/pulled.json`;
1020
- const PULLED_FORMAT = 1;
1021
- const HEX64 = /^[0-9a-f]{64}$/;
1022
- const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
1023
- const EMPTY_PULLED = { guide: null, assets: {} };
1024
- class PulledError extends Error {
1025
- constructor(message) {
1026
- super(message);
1027
- this.name = "PulledError";
1028
- }
1029
- }
1030
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1031
- function isPulledPath(path) {
1032
- if (!path.startsWith("out/") || !path.endsWith(".png")) return false;
1033
- const name = path.slice("out/".length, -".png".length);
1034
- return !name.includes("\\") && nameProblem(name) === null;
1035
- }
1036
- function assetOf(id2, raw) {
1037
- if (!UUID.test(id2)) throw new PulledError(`${PULLED_FILE}: ${id2} is not an asset's id`);
1038
- if (!isRecord$2(raw)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} is not an object`);
1039
- const { kind, name, hash, files } = raw;
1040
- if (typeof kind !== "string" || kind === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no kind`);
1041
- if (typeof name !== "string" || name === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no name`);
1042
- if (typeof hash !== "string" || !HEX64.test(hash)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} has no hash`);
1043
- if (!Array.isArray(files) || files.length === 0 || !files.every((file) => typeof file === "string" && isPulledPath(file))) {
1044
- throw new PulledError(`${PULLED_FILE}: the asset ${id2} names a file that is not a picture under out/`);
1045
- }
1046
- return { kind, name, hash, files };
1047
- }
1048
- function guideOf(raw) {
1049
- if (raw === void 0 || raw === null) return null;
1050
- if (!isRecord$2(raw) || !Number.isInteger(raw.version) || raw.version < 1 || typeof raw.sha256 !== "string" || !HEX64.test(raw.sha256)) {
1051
- throw new PulledError(`${PULLED_FILE}: the style guide's entry is not a version and a digest`);
1052
- }
1053
- return { version: raw.version, sha256: raw.sha256 };
1054
- }
1055
- function readPulled(text2) {
1056
- let parsed2;
1057
- try {
1058
- parsed2 = JSON.parse(text2);
1059
- } catch (error) {
1060
- throw new PulledError(`${PULLED_FILE} is not valid JSON: ${error.message}`);
1061
- }
1062
- if (!isRecord$2(parsed2)) throw new PulledError(`${PULLED_FILE} is not an object`);
1063
- if (parsed2.format !== PULLED_FORMAT) throw new PulledError(`${PULLED_FILE} names no format this CLI reads: expected "format": ${PULLED_FORMAT}`);
1064
- const assets = {};
1065
- if (parsed2.assets !== void 0) {
1066
- if (!isRecord$2(parsed2.assets)) throw new PulledError(`${PULLED_FILE}: "assets" is not an object`);
1067
- for (const [id2, raw] of Object.entries(parsed2.assets)) assets[id2] = assetOf(id2, raw);
1068
- }
1069
- return { guide: guideOf(parsed2.guide), assets };
1070
- }
1071
- function writePulled(record) {
1072
- return `${JSON.stringify({ format: PULLED_FORMAT, ...record.guide === null ? {} : { guide: record.guide }, assets: record.assets }, null, 2)}
1073
- `;
1074
- }
1075
- function ownerOf(record, file) {
1076
- for (const [id2, asset] of Object.entries(record.assets)) if (asset.files.includes(file)) return id2;
1077
- return null;
1078
- }
1079
1019
  const HUD_PIECE_NAMES = ["health-bar", "mana-bar", "button", "inventory-slot", "panel-frame", "minimap-frame"];
1080
1020
  const ICON_SIZES = [16, 32, 64];
1081
1021
  const DEFAULT_ICON_SIZE = 32;
@@ -1105,225 +1045,58 @@ function describedInterface(manifest2, kept, earlier) {
1105
1045
  next[name] = { meta: { kind: "ui", ...piece.nineGrid === void 0 ? {} : { nineGrid: piece.nineGrid, scale: "stretch" } } };
1106
1046
  return next;
1107
1047
  }
1108
- const SIGNATURE$1 = [137, 80, 78, 71, 13, 10, 26, 10];
1109
- function isPng(bytes) {
1110
- return bytes.length >= 24 && SIGNATURE$1.every((byte, index) => bytes[index] === byte);
1111
- }
1112
- function pngSize(bytes) {
1113
- if (!isPng(bytes)) throw new RangeError("those bytes are not a PNG");
1114
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1115
- return { width: view.getUint32(16), height: view.getUint32(20) };
1116
- }
1117
- const MAX_RECORD_BYTES = 4 * 1024 * 1024;
1118
- const artBoundary = (local) => new Boundary([artOf(local)]);
1119
- const recordPath = (local) => join(local.root, ...PULLED_FILE.split("/"));
1120
- async function readRecord(local) {
1121
- const boundary = artBoundary(local);
1122
- const path = recordPath(local);
1123
- if (!await boundary.exists(path)) return EMPTY_PULLED;
1124
- const { size } = await stat(await boundary.resolve(path));
1125
- if (size > MAX_RECORD_BYTES) throw new CliError(`${PULLED_FILE} is ${size} bytes, and is read at most ${MAX_RECORD_BYTES}`);
1126
- try {
1127
- return readPulled(await boundary.readText(path));
1128
- } catch (error) {
1129
- if (error instanceof PulledError) throw new CliError(error.message);
1130
- throw error;
1131
- }
1132
- }
1133
- async function writeRecord(local, record) {
1134
- await artBoundary(local).writeFileReplacing(recordPath(local), writePulled(record));
1135
- }
1136
- const INTERFACE_MANIFEST = "manifest/interface.json";
1137
- function setPieces(asset) {
1138
- return setPiecesOf(asset.kind, asset.images.length, asset.data);
1139
- }
1140
- function pieceFiles(name, pieces) {
1141
- return pieceNames(name, pieces).map((piece) => `out/${piece}.png`);
1142
- }
1143
- const pictureName = (file) => file.replace(/^out\//, "").replace(/\.png$/, "");
1144
- function filesOf(name, count) {
1145
- return count === 1 ? [`out/${name}.png`] : Array.from({ length: count }, (_, index) => `out/${name}/${index}.png`);
1146
- }
1147
- function nameFor(asset, record, given) {
1148
- const name = given ?? record.assets[asset.id]?.name ?? asset.name ?? SET_NAMES[asset.kind] ?? `${asset.kind}-${asset.id.slice(0, 8)}`;
1149
- const problem = nameProblem(name);
1150
- if (problem !== null) throw new CliError(`${name} is not a name a picture can be written under (${problem}): give one with --as`);
1151
- return name;
1152
- }
1153
- async function pullAsset(platform, local, ref, given) {
1154
- const project = linkedOf(local);
1155
- const asset = await platform.web.asset(project, ref);
1156
- if (asset.images.length === 0) throw new CliError(`the asset ${asset.id} has no image the platform still keeps`);
1157
- const record = await readRecord(local);
1158
- const boundary = artBoundary(local);
1159
- const art = artOf(local);
1160
- const name = nameFor(asset, record, given);
1161
- const pieces = setPieces(asset);
1162
- const files = pieces === null ? filesOf(name, asset.images.length) : pieceFiles(name, pieces);
1163
- const pulled = { id: asset.id, kind: asset.kind, name, hash: asset.hash, files, data: asset.data };
1164
- const before = record.assets[asset.id];
1165
- const onDisk = async (file) => boundary.exists(join(art, ...file.split("/")));
1166
- if (before !== void 0 && before.hash === asset.hash && before.name === name && (await Promise.all(before.files.map(onDisk))).every(Boolean)) {
1167
- return { ...pulled, files: before.files, current: true };
1168
- }
1169
- for (const file of files) {
1170
- const owner = ownerOf(record, file);
1171
- if (owner !== asset.id && await onDisk(file)) {
1172
- throw new CliError(
1173
- `pixelvibing/${file} is already there, ${owner === null ? "written by the editor or by hand" : `pulled for the asset ${owner}`}: nothing was written. Give this one another name with --as`
1174
- );
1175
- }
1176
- }
1177
- const images = [];
1178
- for (const image of asset.images) {
1179
- const bytes = await platform.web.imageBytes(image.id);
1180
- if (!isPng(bytes)) throw new CliError(`the platform answered something that is not a PNG for the image ${image.id}: nothing was written`);
1181
- images.push(bytes);
1182
- }
1183
- for (const [index, file] of files.entries()) await boundary.writeFileReplacing(join(art, ...file.split("/")), images[index]);
1184
- for (const stale of before?.files ?? []) {
1185
- if (!files.includes(stale)) await boundary.removeFile(join(art, ...stale.split("/")));
1186
- }
1187
- if (pieces !== null || before?.files.some((file) => file.startsWith("out/ui/"))) {
1188
- await describeInterface(boundary, art, pieces === null ? [] : files.map((file, index) => [file, pieces[index]]), before?.files ?? []);
1189
- }
1190
- await writeRecord(local, { ...record, assets: { ...record.assets, [asset.id]: { kind: asset.kind, name, hash: asset.hash, files } } });
1191
- return { ...pulled, current: false };
1192
- }
1193
- async function describeInterface(boundary, art, written, earlier) {
1194
- const path = join(art, ...INTERFACE_MANIFEST.split("/"));
1195
- let manifest2 = {};
1196
- if (await boundary.exists(path)) {
1197
- try {
1198
- const parsed2 = JSON.parse(await boundary.readText(path));
1199
- if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) manifest2 = parsed2;
1200
- else throw new Error("not an object");
1201
- } catch (error) {
1202
- throw new CliError(`pixelvibing/${INTERFACE_MANIFEST} is not a manifest (${error.message}): the pieces were written, and not described`);
1203
- }
1204
- }
1205
- manifest2 = describedInterface(
1206
- manifest2,
1207
- written.map(([file, piece]) => [pictureName(file), piece]),
1208
- earlier.map(pictureName)
1209
- );
1210
- await boundary.writeFileReplacing(path, new TextEncoder().encode(`${JSON.stringify(manifest2, null, 2)}
1211
- `));
1212
- }
1213
- function pulledWords(pulled) {
1214
- const where = pulled.files.length === 1 ? `pixelvibing/${pulled.files[0]}` : `pixelvibing/out/${pulled.name}/ (${pulled.files.length} images)`;
1215
- return pulled.current ? `${pulled.name} is current: ${where} is what the platform has.` : `Pulled ${pulled.name}, ${pulled.kind}, into ${where}.`;
1216
- }
1217
- const date = (at) => at.slice(0, 10);
1218
- function listed(assets) {
1219
- if (assets.length === 0) return "No assets.";
1220
- return assets.map(
1221
- (asset) => `${asset.id} ${asset.kind.padEnd(12)} ${(asset.name ?? "—").padEnd(28)} ${asset.images} image${asset.images === 1 ? "" : "s"} ${date(asset.createdAt)}`
1222
- ).join("\n");
1223
- }
1224
- const list = {
1225
- name: "list",
1226
- summary: "List the platform project's assets, of one kind when given, newest first",
1227
- usage: "[<kind>] [--before <id>]",
1228
- options: { before: { type: "string", value: "<id>", description: "the page after the asset with this id, as the last page said" } },
1229
- positionals: [0, 1],
1230
- run: async (context, args) => {
1231
- const local = await projectHere(context.io.cwd);
1232
- const project = linkedOf(local);
1233
- const platform = await signedIn(context);
1234
- const [kind] = args.positionals;
1235
- const page = await platform.web.assets(project, kind ?? null, stringOf(args, "before") ?? null);
1236
- const more = page.next === null ? "" : `
1237
- More: pixel-vibing-cli list${kind === void 0 ? "" : ` ${kind}`} --before ${page.next}`;
1238
- answer(context, page, `${listed(page.assets)}${more}`);
1239
- }
1240
- };
1241
- const pull = {
1242
- name: "pull",
1243
- summary: "Pull one asset, by its id or its name, into the local project",
1244
- usage: "<id|name> [--as <name>]",
1245
- options: { as: { type: "string", value: "<name>", description: "write it under this name instead of the platform's" } },
1246
- positionals: [1, 1],
1247
- run: async (context, args) => {
1248
- const local = await projectHere(context.io.cwd);
1249
- linkedOf(local);
1250
- const platform = await signedIn(context);
1251
- const pulled = await pullAsset(platform, local, args.positionals[0], stringOf(args, "as"));
1252
- answer(context, pulled, pulledWords(pulled));
1253
- }
1254
- };
1255
- const DOWNLOAD_URL = "https://github.com/Pixel-Vibing/pixel-vibing/releases/latest";
1256
- const EDITOR_VARIABLE = "PIXELVIBING_EDITOR";
1257
- async function exists$1(path) {
1258
- try {
1259
- await stat(path);
1260
- return true;
1261
- } catch {
1262
- return false;
1263
- }
1264
- }
1265
- async function findEditor(platform, env, home) {
1266
- const named = env[EDITOR_VARIABLE]?.trim();
1267
- if (named !== void 0 && named !== "") {
1268
- if (!await exists$1(named)) throw new CliError(`${EDITOR_VARIABLE} names ${named}, which is not there`);
1269
- return platform === "darwin" && named.endsWith(".app") ? { command: "open", args: ["-a", named, "--args"] } : { command: named, args: [] };
1270
- }
1271
- if (platform === "win32") {
1272
- const places = [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "PixelVibing"), env.ProgramFiles && join(env.ProgramFiles, "PixelVibing")];
1273
- for (const place of places) {
1274
- if (typeof place === "string" && await exists$1(join(place, "PixelVibing.exe"))) return { command: join(place, "PixelVibing.exe"), args: [] };
1275
- }
1276
- return null;
1277
- }
1278
- if (platform === "darwin") {
1279
- for (const app of ["/Applications/PixelVibing.app", join(home, "Applications", "PixelVibing.app")]) {
1280
- if (await exists$1(app)) return { command: "open", args: ["-a", app, "--args"] };
1281
- }
1282
- return null;
1283
- }
1284
- const applications = join(home, "Applications");
1285
- let found = [];
1286
- try {
1287
- found = (await readdir(applications)).filter((name) => /^PixelVibing.*\.AppImage$/i.test(name)).sort();
1288
- } catch {
1289
- return null;
1290
- }
1291
- const newest = found.at(-1);
1292
- return newest === void 0 ? null : { command: join(applications, newest), args: [] };
1293
- }
1294
- const editor = {
1295
- name: "editor",
1296
- summary: "Open the PixelVibing desktop app on this local project",
1297
- run: async (context) => {
1298
- const local = await projectHere(context.io.cwd);
1299
- const { platform, home } = context.services;
1300
- const found = await findEditor(platform, context.io.env, home);
1301
- if (found === null) {
1302
- throw new CliError(`the PixelVibing desktop app is not installed here: download it from ${DOWNLOAD_URL}, or set ${EDITOR_VARIABLE} to where it is`);
1303
- }
1304
- await context.services.launch(found.command, [...found.args, "--project", local.root]);
1305
- answer(context, { opened: local.root, app: found.command }, `Opening ${local.root} in PixelVibing.`);
1306
- }
1307
- };
1308
- const SECTOR_SIDE = 128;
1309
- const SECTOR_CELLS = SECTOR_SIDE * SECTOR_SIDE;
1048
+ const SECTOR_SIDE$1 = 128;
1049
+ const SECTOR_CELLS = SECTOR_SIDE$1 * SECTOR_SIDE$1;
1310
1050
  const SECTOR_BYTES = 512 * 1024;
1311
1051
  const MAP_LEVELS = { first: 0, last: 14 };
1312
1052
  const LARGEST_MAP_SIDE = 1e4;
1313
- const OUTLINE_ACROSS = Math.ceil(LARGEST_MAP_SIDE / SECTOR_SIDE);
1053
+ const OUTLINE_ACROSS = Math.ceil(LARGEST_MAP_SIDE / SECTOR_SIDE$1);
1314
1054
  const SECTOR_FORMAT = 1;
1315
1055
  const MAGIC = [80, 86, 67];
1316
1056
  const PRESENT = 1;
1317
1057
  const AUDIO = 2;
1318
1058
  const INTERACT = 4;
1319
1059
  const EXTRA = 8;
1320
- const NAMED = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"]);
1060
+ const U16 = 65535;
1061
+ const MAX_LAYERS = 255;
1062
+ const NAMED$1 = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"]);
1321
1063
  class SectorError extends Error {
1322
1064
  constructor(message) {
1323
1065
  super(message);
1324
1066
  this.name = "SectorError";
1325
1067
  }
1326
1068
  }
1069
+ function u16(value, what) {
1070
+ if (!Number.isInteger(value) || value < 0 || value > U16) throw new SectorError(`${what} is ${value}, and a sector holds a whole number from 0 to 65,535`);
1071
+ return value;
1072
+ }
1073
+ function encodeSector(cells) {
1074
+ if (cells.length !== SECTOR_CELLS) throw new SectorError(`a sector is ${SECTOR_CELLS} cells, not ${cells.length}`);
1075
+ const encoder2 = new TextEncoder();
1076
+ const bytes = [...MAGIC, SECTOR_FORMAT];
1077
+ const push16 = (value) => bytes.push(value & 255, value >> 8);
1078
+ for (const cell of cells) {
1079
+ if (cell === void 0) {
1080
+ bytes.push(0);
1081
+ continue;
1082
+ }
1083
+ const extra = cell.extra === void 0 ? null : encoder2.encode(JSON.stringify(cell.extra));
1084
+ if (extra !== null && extra.length > U16) throw new SectorError("a tile's extra fields are past 65,535 bytes");
1085
+ if (cell.layers.length > MAX_LAYERS) throw new SectorError(`a tile has ${cell.layers.length} layers, and a sector holds at most ${MAX_LAYERS}`);
1086
+ bytes.push(PRESENT | (cell.audioZone === void 0 ? 0 : AUDIO) | (cell.interact === void 0 ? 0 : INTERACT) | (extra === null ? 0 : EXTRA));
1087
+ bytes.push(cell.layers.length);
1088
+ for (const layer of cell.layers) push16(u16(layer, "a layer id"));
1089
+ push16(u16(cell.blocking, "blocking"));
1090
+ push16(u16(cell.zone, "a zone"));
1091
+ if (cell.audioZone !== void 0) push16(u16(cell.audioZone, "an audio zone"));
1092
+ if (cell.interact !== void 0) push16(u16(cell.interact, "interact"));
1093
+ if (extra !== null) {
1094
+ push16(extra.length);
1095
+ for (const byte of extra) bytes.push(byte);
1096
+ }
1097
+ }
1098
+ return Uint8Array.from(bytes);
1099
+ }
1327
1100
  function decodeSector(bytes) {
1328
1101
  if (bytes.length < 4 || MAGIC.some((byte, index) => bytes[index] !== byte)) throw new SectorError("these bytes are not a map sector");
1329
1102
  if (bytes[3] !== SECTOR_FORMAT) throw new SectorError(`a map sector of format ${bytes[3]} is not one this version reads`);
@@ -1367,7 +1140,7 @@ function decodeSector(bytes) {
1367
1140
  throw new SectorError("a tile's extra fields are not JSON");
1368
1141
  }
1369
1142
  if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new SectorError("a tile's extra fields are not an object");
1370
- const named = Object.keys(parsed2).find((key) => NAMED.has(key));
1143
+ const named = Object.keys(parsed2).find((key) => NAMED$1.has(key));
1371
1144
  if (named !== void 0) throw new SectorError(`a tile's extra fields name ${named}, which the sector holds itself`);
1372
1145
  extra = parsed2;
1373
1146
  at += length;
@@ -1384,6 +1157,18 @@ function decodeSector(bytes) {
1384
1157
  if (at !== bytes.length) throw new SectorError("the sector's bytes go on past its last cell");
1385
1158
  return cells;
1386
1159
  }
1160
+ const NAMED = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"]);
1161
+ function cellOfTile(tile) {
1162
+ const extra = Object.fromEntries(Object.entries(tile).filter(([key]) => !NAMED.has(key)));
1163
+ return {
1164
+ layers: tile.layers,
1165
+ blocking: tile.blocking,
1166
+ zone: tile.zone,
1167
+ ...tile.audioZone === void 0 ? {} : { audioZone: tile.audioZone },
1168
+ ...tile.interact === void 0 ? {} : { interact: tile.interact },
1169
+ ...Object.keys(extra).length === 0 ? {} : { extra }
1170
+ };
1171
+ }
1387
1172
  function tileOfCell(cell, x, y, z) {
1388
1173
  return {
1389
1174
  ...cell.extra ?? {},
@@ -1420,6 +1205,10 @@ function isSize(value) {
1420
1205
  const side = (n) => Number.isInteger(n) && n >= 1 && n <= LARGEST_MAP_SIDE;
1421
1206
  return typeof value === "object" && value !== null && side(value.width) && side(value.height);
1422
1207
  }
1208
+ function mirrorIndexText(index) {
1209
+ return `${JSON.stringify(index, null, 2)}
1210
+ `;
1211
+ }
1423
1212
  function parseMirrorIndex(text2) {
1424
1213
  let value;
1425
1214
  try {
@@ -1444,7 +1233,7 @@ function parseMirrorIndex(text2) {
1444
1233
  throw new MirrorError(`${MIRROR_INDEX_FILE} lists a sector that is none: ${JSON.stringify(sector)}`);
1445
1234
  }
1446
1235
  const key = `${z}:${cx}:${cy}`;
1447
- if (cx * SECTOR_SIDE >= index.size.width || cy * SECTOR_SIDE >= index.size.height) {
1236
+ if (cx * SECTOR_SIDE$1 >= index.size.width || cy * SECTOR_SIDE$1 >= index.size.height) {
1448
1237
  throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key}, outside the map's size`);
1449
1238
  }
1450
1239
  if (seen.has(key)) throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key} twice`);
@@ -1464,8 +1253,8 @@ async function readMirroredMap(index, read, inflate2) {
1464
1253
  }
1465
1254
  for (const [at, cell] of cells.entries()) {
1466
1255
  if (cell === void 0) continue;
1467
- const x = cx * SECTOR_SIDE + at % SECTOR_SIDE;
1468
- const y = cy * SECTOR_SIDE + Math.floor(at / SECTOR_SIDE);
1256
+ const x = cx * SECTOR_SIDE$1 + at % SECTOR_SIDE$1;
1257
+ const y = cy * SECTOR_SIDE$1 + Math.floor(at / SECTOR_SIDE$1);
1469
1258
  if (x >= index.size.width || y >= index.size.height) continue;
1470
1259
  tiles.push(tileOfCell(cell, x, y, z));
1471
1260
  }
@@ -1473,41 +1262,180 @@ async function readMirroredMap(index, read, inflate2) {
1473
1262
  tiles.sort((a, b) => a.z - b.z || a.y - b.y || a.x - b.x);
1474
1263
  return { width: index.size.width, height: index.size.height, tiles };
1475
1264
  }
1476
- const TILE_SIZE = 256;
1477
- const EAI_MAGIC = 1229018181;
1478
- const EAI_VERSION = 1;
1479
- const HEADER_SIZE$1 = 32;
1480
- const ASSET_SIZE = 24;
1481
- const ATLAS_SIZE = 12;
1482
- class AssetIndexError extends Error {
1265
+ function sectorsOfMap(map) {
1266
+ const sectors = /* @__PURE__ */ new Map();
1267
+ for (const tile of map.tiles) {
1268
+ const { x, y, z } = tile;
1269
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= LARGEST_MAP_SIDE || y >= LARGEST_MAP_SIDE || !isPlace(z, 0, 0)) {
1270
+ throw new MirrorError(`the tile at (${x}, ${y}) on level ${z} is at no place a map has`);
1271
+ }
1272
+ const cx = Math.floor(x / SECTOR_SIDE$1);
1273
+ const cy = Math.floor(y / SECTOR_SIDE$1);
1274
+ const key = `${z}:${cx}:${cy}`;
1275
+ let sector = sectors.get(key);
1276
+ if (sector === void 0) {
1277
+ sector = { z, cx, cy, cells: new Array(SECTOR_CELLS) };
1278
+ sectors.set(key, sector);
1279
+ }
1280
+ sector.cells[y % SECTOR_SIDE$1 * SECTOR_SIDE$1 + x % SECTOR_SIDE$1] = cellOfTile(tile);
1281
+ }
1282
+ return [...sectors.values()].sort((a, b) => a.z - b.z || a.cy - b.cy || a.cx - b.cx);
1283
+ }
1284
+ class MapTextError extends Error {
1483
1285
  // Assigned in the body: the suites run under Node's strip-only TypeScript
1484
1286
  // mode, which rejects parameter properties (docs/notes.md n-0023).
1485
1287
  constructor(message) {
1486
1288
  super(message);
1487
- this.name = "AssetIndexError";
1289
+ this.name = "MapTextError";
1488
1290
  }
1489
1291
  }
1490
- function parseAssetIndex(buffer) {
1491
- if (buffer.byteLength < HEADER_SIZE$1) {
1492
- throw new AssetIndexError(`buffer too small: ${buffer.byteLength} < ${HEADER_SIZE$1}`);
1493
- }
1494
- const view = new DataView(buffer);
1495
- const magic = view.getUint32(0, true);
1496
- if (magic !== EAI_MAGIC) {
1497
- throw new AssetIndexError(`bad magic 0x${magic.toString(16)} (expected 0x${EAI_MAGIC.toString(16)})`);
1498
- }
1499
- const version2 = view.getUint32(4, true);
1500
- if (version2 !== EAI_VERSION) {
1501
- throw new AssetIndexError(`unsupported version ${version2}, reader speaks v${EAI_VERSION}`);
1502
- }
1503
- const assetCount = view.getUint32(8, true);
1504
- const atlasCount = view.getUint32(12, true);
1505
- const assetsOffset = view.getUint32(16, true);
1506
- const atlasesOffset = view.getUint32(20, true);
1507
- const stringsOffset = view.getUint32(24, true);
1508
- const stringsLength = view.getUint32(28, true);
1509
- if (buffer.byteLength !== stringsOffset + stringsLength) {
1510
- throw new AssetIndexError(`file size ${buffer.byteLength} ≠ header-declared ${stringsOffset + stringsLength}`);
1292
+ function tileKey(x, y, z) {
1293
+ return `${x}:${y}:${z}`;
1294
+ }
1295
+ function scanTileSources(text2) {
1296
+ const key = '"tiles"';
1297
+ let at = text2.indexOf(key);
1298
+ if (at === -1) return [];
1299
+ at = text2.indexOf("[", at + key.length);
1300
+ if (at === -1) return [];
1301
+ const sources = [];
1302
+ let depth = 0;
1303
+ let inString = false;
1304
+ let start = -1;
1305
+ for (let index = at + 1; index < text2.length; index += 1) {
1306
+ const char = text2[index];
1307
+ if (inString) {
1308
+ if (char === "\\") index += 1;
1309
+ else if (char === '"') inString = false;
1310
+ continue;
1311
+ }
1312
+ if (char === '"') {
1313
+ inString = true;
1314
+ } else if (char === "{") {
1315
+ if (depth === 0) start = index;
1316
+ depth += 1;
1317
+ } else if (char === "}") {
1318
+ depth -= 1;
1319
+ if (depth === 0 && start !== -1) {
1320
+ sources.push(text2.slice(start, index + 1));
1321
+ start = -1;
1322
+ }
1323
+ } else if (char === "]" && depth === 0) {
1324
+ break;
1325
+ }
1326
+ }
1327
+ return sources;
1328
+ }
1329
+ function isTile(value) {
1330
+ if (typeof value !== "object" || value === null) return false;
1331
+ const record = value;
1332
+ return typeof record.x === "number" && typeof record.y === "number" && typeof record.z === "number" && Array.isArray(record.layers);
1333
+ }
1334
+ function parseMapText(text2, path = "the map") {
1335
+ let parsed2;
1336
+ try {
1337
+ parsed2 = JSON.parse(text2);
1338
+ } catch (error) {
1339
+ throw new MapTextError(`${path} is not valid JSON: ${error.message}`);
1340
+ }
1341
+ if (typeof parsed2 !== "object" || parsed2 === null || !Array.isArray(parsed2.tiles)) {
1342
+ throw new MapTextError(`${path} holds no tiles array`);
1343
+ }
1344
+ const document = parsed2;
1345
+ const sources = scanTileSources(text2);
1346
+ if (sources.length !== document.tiles.length) {
1347
+ throw new MapTextError(`${path}: the scanner found ${sources.length} tile sources for ${document.tiles.length} tiles`);
1348
+ }
1349
+ const byKey = /* @__PURE__ */ new Map();
1350
+ document.tiles.forEach((tile, index) => {
1351
+ if (!isTile(tile)) throw new MapTextError(`${path}: tile ${index} lacks x, y, z or layers`);
1352
+ byKey.set(tileKey(tile.x, tile.y, tile.z), { tile, source: sources[index] });
1353
+ });
1354
+ return { document, sources: byKey };
1355
+ }
1356
+ const TILE_KEYS = ["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"];
1357
+ function serializeTile(tile) {
1358
+ const ordered = {};
1359
+ for (const key of TILE_KEYS) {
1360
+ if (tile[key] !== void 0) ordered[key] = tile[key];
1361
+ }
1362
+ for (const key of Object.keys(tile)) {
1363
+ if (!(key in ordered) && tile[key] !== void 0) ordered[key] = tile[key];
1364
+ }
1365
+ return JSON.stringify(ordered);
1366
+ }
1367
+ function formatMap(next, previous) {
1368
+ const lines = [];
1369
+ let changed = 0;
1370
+ for (const tile of next.tiles) {
1371
+ {
1372
+ lines.push(serializeTile(tile));
1373
+ changed += 1;
1374
+ }
1375
+ }
1376
+ const rest = {};
1377
+ for (const key of Object.keys(next)) {
1378
+ if (key !== "width" && key !== "height" && key !== "tiles") rest[key] = next[key];
1379
+ }
1380
+ const trailer = Object.keys(rest).length === 0 ? "" : `,${JSON.stringify(rest).slice(1, -1)}`;
1381
+ const head = `{"width":${JSON.stringify(next.width)},"height":${JSON.stringify(next.height)},"tiles":[`;
1382
+ const body = lines.length === 0 ? "" : `
1383
+ ${lines.join(",\n")}
1384
+ `;
1385
+ return { text: `${head}${body}]${trailer}}
1386
+ `, changed };
1387
+ }
1388
+ const SIGNATURE$1 = [137, 80, 78, 71, 13, 10, 26, 10];
1389
+ function isPng(bytes) {
1390
+ return bytes.length >= 24 && SIGNATURE$1.every((byte, index) => bytes[index] === byte);
1391
+ }
1392
+ function pngSize(bytes) {
1393
+ if (!isPng(bytes)) throw new RangeError("those bytes are not a PNG");
1394
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1395
+ return { width: view.getUint32(16), height: view.getUint32(20) };
1396
+ }
1397
+ const GROUND_KINDS = /* @__PURE__ */ new Set(["ground", "floor", "wall", "cliff", "interior", "grass-stone"]);
1398
+ function isGroundName(name) {
1399
+ const { inner } = splitTileset(name);
1400
+ const kind = inner.split("/")[1] ?? "";
1401
+ return GROUND_KINDS.has(kind) || inner.includes("/wang-");
1402
+ }
1403
+ const MAX_ASSEMBLED_PX = 4096;
1404
+ const TILE_SIZE = 256;
1405
+ const EAI_MAGIC = 1229018181;
1406
+ const EAI_VERSION = 1;
1407
+ const HEADER_SIZE$1 = 32;
1408
+ const ASSET_SIZE = 24;
1409
+ const ATLAS_SIZE = 12;
1410
+ class AssetIndexError extends Error {
1411
+ // Assigned in the body: the suites run under Node's strip-only TypeScript
1412
+ // mode, which rejects parameter properties (docs/notes.md n-0023).
1413
+ constructor(message) {
1414
+ super(message);
1415
+ this.name = "AssetIndexError";
1416
+ }
1417
+ }
1418
+ function parseAssetIndex(buffer) {
1419
+ if (buffer.byteLength < HEADER_SIZE$1) {
1420
+ throw new AssetIndexError(`buffer too small: ${buffer.byteLength} < ${HEADER_SIZE$1}`);
1421
+ }
1422
+ const view = new DataView(buffer);
1423
+ const magic = view.getUint32(0, true);
1424
+ if (magic !== EAI_MAGIC) {
1425
+ throw new AssetIndexError(`bad magic 0x${magic.toString(16)} (expected 0x${EAI_MAGIC.toString(16)})`);
1426
+ }
1427
+ const version2 = view.getUint32(4, true);
1428
+ if (version2 !== EAI_VERSION) {
1429
+ throw new AssetIndexError(`unsupported version ${version2}, reader speaks v${EAI_VERSION}`);
1430
+ }
1431
+ const assetCount = view.getUint32(8, true);
1432
+ const atlasCount = view.getUint32(12, true);
1433
+ const assetsOffset = view.getUint32(16, true);
1434
+ const atlasesOffset = view.getUint32(20, true);
1435
+ const stringsOffset = view.getUint32(24, true);
1436
+ const stringsLength = view.getUint32(28, true);
1437
+ if (buffer.byteLength !== stringsOffset + stringsLength) {
1438
+ throw new AssetIndexError(`file size ${buffer.byteLength} ≠ header-declared ${stringsOffset + stringsLength}`);
1511
1439
  }
1512
1440
  if (assetsOffset < HEADER_SIZE$1) {
1513
1441
  throw new AssetIndexError(`assetsOffset ${assetsOffset} is inside the ${HEADER_SIZE$1}-byte header`);
@@ -1727,7 +1655,7 @@ class AssetMetadataError extends Error {
1727
1655
  this.name = "AssetMetadataError";
1728
1656
  }
1729
1657
  }
1730
- function isRecord$1(value) {
1658
+ function isRecord$2(value) {
1731
1659
  return typeof value === "object" && value !== null && !Array.isArray(value);
1732
1660
  }
1733
1661
  const MAX_ANIMATION_FRAMES = 64;
@@ -1739,7 +1667,7 @@ function readFrames(value, asset, animation) {
1739
1667
  throw new AssetMetadataError(`${asset}: animation ${animation} has ${value.length} frames, and an animation holds at most ${MAX_ANIMATION_FRAMES}`);
1740
1668
  }
1741
1669
  return value.map((raw, index) => {
1742
- if (!isRecord$1(raw)) {
1670
+ if (!isRecord$2(raw)) {
1743
1671
  throw new AssetMetadataError(`${asset}: animation ${animation} frame ${index} is not a frame`);
1744
1672
  }
1745
1673
  const name = raw.asset;
@@ -1758,7 +1686,7 @@ function readFrames(value, asset, animation) {
1758
1686
  function readAnimations(value, asset, kind) {
1759
1687
  const animations = /* @__PURE__ */ new Map();
1760
1688
  if (value === void 0) return animations;
1761
- if (!isRecord$1(value)) {
1689
+ if (!isRecord$2(value)) {
1762
1690
  throw new AssetMetadataError(`${asset}: animations is not a map of name to frames`);
1763
1691
  }
1764
1692
  for (const [name, frames] of Object.entries(value)) {
@@ -1775,10 +1703,10 @@ function readAnimations(value, asset, kind) {
1775
1703
  function readPlays(value, asset, animations, kind) {
1776
1704
  const plays = /* @__PURE__ */ new Map();
1777
1705
  if (value === void 0) return plays;
1778
- if (!isRecord$1(value)) throw new AssetMetadataError(`${asset}: plays is not a map of animation to how it plays`);
1706
+ if (!isRecord$2(value)) throw new AssetMetadataError(`${asset}: plays is not a map of animation to how it plays`);
1779
1707
  for (const [name, play] of Object.entries(value)) {
1780
1708
  if (!animations.has(name)) throw new AssetMetadataError(`${asset}: plays names ${name}, and there is no such animation`);
1781
- if (!isRecord$1(play)) throw new AssetMetadataError(`${asset}: how ${name} plays is not an object`);
1709
+ if (!isRecord$2(play)) throw new AssetMetadataError(`${asset}: how ${name} plays is not an object`);
1782
1710
  if (kind === "outfit" && play.mode !== "loop")
1783
1711
  throw new AssetMetadataError(`${asset}: ${name} is an outfit's animation, which plays as the game drives it, in a loop`);
1784
1712
  if (play.mode === "loop" || play.mode === "change") {
@@ -1801,7 +1729,7 @@ function readPlays(value, asset, animations, kind) {
1801
1729
  }
1802
1730
  function readNineGrid(value, asset) {
1803
1731
  if (value === void 0) return void 0;
1804
- if (!isRecord$1(value)) {
1732
+ if (!isRecord$2(value)) {
1805
1733
  throw new AssetMetadataError(`${asset}: nineGrid is not an object of four insets`);
1806
1734
  }
1807
1735
  const insets = {};
@@ -1826,7 +1754,7 @@ function readScale(value, asset) {
1826
1754
  }
1827
1755
  function readCollide(value, asset) {
1828
1756
  if (value === void 0) return void 0;
1829
- if (!isRecord$1(value)) {
1757
+ if (!isRecord$2(value)) {
1830
1758
  throw new AssetMetadataError(`${asset}: collide is not a box of x, y, w and h in cells`);
1831
1759
  }
1832
1760
  const box = {};
@@ -1847,7 +1775,7 @@ const MIN_DRAW_SCALE = 0.05;
1847
1775
  const MAX_DRAW_SCALE = 8;
1848
1776
  function readOffset(value, asset) {
1849
1777
  if (value === void 0) return void 0;
1850
- if (!isRecord$1(value) || typeof value.x !== "number" || typeof value.y !== "number" || !Number.isFinite(value.x) || !Number.isFinite(value.y)) {
1778
+ if (!isRecord$2(value) || typeof value.x !== "number" || typeof value.y !== "number" || !Number.isFinite(value.x) || !Number.isFinite(value.y)) {
1851
1779
  throw new AssetMetadataError(`${asset}: offset is not an x and a y in pixels`);
1852
1780
  }
1853
1781
  for (const axis of ["x", "y"]) {
@@ -1869,7 +1797,7 @@ function readDrawScale(value, asset) {
1869
1797
  return value;
1870
1798
  }
1871
1799
  function readEntry(value, asset) {
1872
- if (!isRecord$1(value)) {
1800
+ if (!isRecord$2(value)) {
1873
1801
  throw new AssetMetadataError(`${asset}: not an entry`);
1874
1802
  }
1875
1803
  const kind = value.kind;
@@ -1914,7 +1842,7 @@ function parseAssetMetadata(text2) {
1914
1842
  } catch (error) {
1915
1843
  throw new AssetMetadataError(`not JSON: ${error.message}`);
1916
1844
  }
1917
- if (!isRecord$1(parsed2)) {
1845
+ if (!isRecord$2(parsed2)) {
1918
1846
  throw new AssetMetadataError("not an object");
1919
1847
  }
1920
1848
  const version2 = parsed2.version;
@@ -1922,7 +1850,7 @@ function parseAssetMetadata(text2) {
1922
1850
  throw new AssetMetadataError(`unsupported version ${String(version2)}, reader speaks v${METADATA_VERSION}`);
1923
1851
  }
1924
1852
  const rawAssets = parsed2.assets;
1925
- if (rawAssets !== void 0 && !isRecord$1(rawAssets)) {
1853
+ if (rawAssets !== void 0 && !isRecord$2(rawAssets)) {
1926
1854
  throw new AssetMetadataError("assets is not a map of name to entry");
1927
1855
  }
1928
1856
  const assets = /* @__PURE__ */ new Map();
@@ -2117,129 +2045,79 @@ function placeTileSprite(tx, ty, width, height, options = {}) {
2117
2045
  height: scaledHeight
2118
2046
  };
2119
2047
  }
2120
- class MapTextError extends Error {
2121
- // Assigned in the body: the suites run under Node's strip-only TypeScript
2122
- // mode, which rejects parameter properties (docs/notes.md n-0023).
2048
+ const WINDOW_PX = { width: 1536, height: 1024 };
2049
+ const SCALES = [TILE_SIZE, TILE_SIZE / 2, TILE_SIZE / 4, TILE_SIZE / 8, TILE_SIZE / 16];
2050
+ function scaleFor(tilesWide, tilesHigh) {
2051
+ if (tilesWide <= 0 || tilesHigh <= 0) return null;
2052
+ for (const scale of SCALES) {
2053
+ if (tilesWide * scale <= WINDOW_PX.width && tilesHigh * scale <= WINDOW_PX.height) return scale;
2054
+ }
2055
+ return null;
2056
+ }
2057
+ ({
2058
+ width: Math.floor(WINDOW_PX.width / SCALES.at(-1)),
2059
+ height: Math.floor(WINDOW_PX.height / SCALES.at(-1))
2060
+ });
2061
+ Array.from({ length: 16 }, (_value, index) => index);
2062
+ const REFERENCE_FILE = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)*\.png$/i;
2063
+ class ArtError extends Error {
2064
+ // Assigned in the body: Node's strip-only TypeScript mode rejects parameter
2065
+ // properties (docs/notes.md n-0023).
2123
2066
  constructor(message) {
2124
2067
  super(message);
2125
- this.name = "MapTextError";
2068
+ this.name = "ArtError";
2126
2069
  }
2127
2070
  }
2128
- function tileKey(x, y, z) {
2129
- return `${x}:${y}:${z}`;
2130
- }
2131
- function scanTileSources(text2) {
2132
- const key = '"tiles"';
2133
- let at = text2.indexOf(key);
2134
- if (at === -1) return [];
2135
- at = text2.indexOf("[", at + key.length);
2136
- if (at === -1) return [];
2137
- const sources = [];
2138
- let depth = 0;
2139
- let inString = false;
2140
- let start = -1;
2141
- for (let index = at + 1; index < text2.length; index += 1) {
2142
- const char = text2[index];
2143
- if (inString) {
2144
- if (char === "\\") index += 1;
2145
- else if (char === '"') inString = false;
2146
- continue;
2071
+ const CONTAINER_FILE = join("build", "assets.eac");
2072
+ const STYLE_GUIDE_FILE$1 = "style-guide.md";
2073
+ const REFERENCE_DIRECTORY = "ref";
2074
+ async function listReferences(boundary, artDirectory) {
2075
+ const root = join(artDirectory, REFERENCE_DIRECTORY);
2076
+ const names = [];
2077
+ const walk = async (directory, prefix) => {
2078
+ let entries;
2079
+ try {
2080
+ entries = await readdir(await boundary.resolve(directory), { withFileTypes: true });
2081
+ } catch {
2082
+ return;
2147
2083
  }
2148
- if (char === '"') {
2149
- inString = true;
2150
- } else if (char === "{") {
2151
- if (depth === 0) start = index;
2152
- depth += 1;
2153
- } else if (char === "}") {
2154
- depth -= 1;
2155
- if (depth === 0 && start !== -1) {
2156
- sources.push(text2.slice(start, index + 1));
2157
- start = -1;
2158
- }
2159
- } else if (char === "]" && depth === 0) {
2160
- break;
2084
+ for (const entry of entries) {
2085
+ const relative2 = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
2086
+ if (entry.isDirectory()) await walk(join(directory, entry.name), relative2);
2087
+ else if (entry.name.toLowerCase().endsWith(".png")) names.push(relative2);
2161
2088
  }
2089
+ };
2090
+ await walk(root, "");
2091
+ return names.sort();
2092
+ }
2093
+ async function readReference(boundary, artDirectory, name) {
2094
+ if (!REFERENCE_FILE.test(name) || name.split("/").includes("..")) {
2095
+ throw new ArtError(`${name} is not a reference under ${REFERENCE_DIRECTORY}/`);
2162
2096
  }
2163
- return sources;
2097
+ return boundary.readBytes(join(artDirectory, REFERENCE_DIRECTORY, name));
2164
2098
  }
2165
- function isTile(value) {
2166
- if (typeof value !== "object" || value === null) return false;
2167
- const record = value;
2168
- return typeof record.x === "number" && typeof record.y === "number" && typeof record.z === "number" && Array.isArray(record.layers);
2099
+ const MANIFEST_DIRECTORY = "manifest";
2100
+ const OUT_DIRECTORY = "out";
2101
+ const ID_MAP_FILE = "id-map.json";
2102
+ const MAX_SIDE$1 = 65535;
2103
+ const encoder$1 = new TextEncoder();
2104
+ function isRecord$1(value) {
2105
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2169
2106
  }
2170
- function parseMapText(text2, path = "the map") {
2171
- let parsed2;
2107
+ function metadataOf(entry) {
2108
+ const raw = isRecord$1(entry.meta) && entry.meta.kind === void 0 ? { kind: "tile", ...entry.meta } : entry.meta;
2172
2109
  try {
2173
- parsed2 = JSON.parse(text2);
2110
+ const parsed2 = parseAssetMetadata(JSON.stringify({ version: METADATA_VERSION, assets: { [entry.name]: raw } }));
2111
+ return parsed2.assets.get(entry.name);
2174
2112
  } catch (error) {
2175
- throw new MapTextError(`${path} is not valid JSON: ${error.message}`);
2176
- }
2177
- if (typeof parsed2 !== "object" || parsed2 === null || !Array.isArray(parsed2.tiles)) {
2178
- throw new MapTextError(`${path} holds no tiles array`);
2179
- }
2180
- const document = parsed2;
2181
- const sources = scanTileSources(text2);
2182
- if (sources.length !== document.tiles.length) {
2183
- throw new MapTextError(`${path}: the scanner found ${sources.length} tile sources for ${document.tiles.length} tiles`);
2113
+ throw new ProjectError(`the manifest entry ${entry.name} does not describe its asset: ${error.message}`);
2184
2114
  }
2185
- const byKey = /* @__PURE__ */ new Map();
2186
- document.tiles.forEach((tile, index) => {
2187
- if (!isTile(tile)) throw new MapTextError(`${path}: tile ${index} lacks x, y, z or layers`);
2188
- byKey.set(tileKey(tile.x, tile.y, tile.z), { tile, source: sources[index] });
2189
- });
2190
- return { document, sources: byKey };
2191
2115
  }
2192
- const TILE_KEYS = ["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"];
2193
- function serializeTile(tile) {
2194
- const ordered = {};
2195
- for (const key of TILE_KEYS) {
2196
- if (tile[key] !== void 0) ordered[key] = tile[key];
2197
- }
2198
- for (const key of Object.keys(tile)) {
2199
- if (!(key in ordered) && tile[key] !== void 0) ordered[key] = tile[key];
2200
- }
2201
- return JSON.stringify(ordered);
2202
- }
2203
- function formatMap(next, previous) {
2204
- const lines = [];
2205
- let changed = 0;
2206
- for (const tile of next.tiles) {
2207
- {
2208
- lines.push(serializeTile(tile));
2209
- changed += 1;
2210
- }
2211
- }
2212
- const rest = {};
2213
- for (const key of Object.keys(next)) {
2214
- if (key !== "width" && key !== "height" && key !== "tiles") rest[key] = next[key];
2215
- }
2216
- const trailer = Object.keys(rest).length === 0 ? "" : `,${JSON.stringify(rest).slice(1, -1)}`;
2217
- const head = `{"width":${JSON.stringify(next.width)},"height":${JSON.stringify(next.height)},"tiles":[`;
2218
- const body = lines.length === 0 ? "" : `
2219
- ${lines.join(",\n")}
2220
- `;
2221
- return { text: `${head}${body}]${trailer}}
2222
- `, changed };
2223
- }
2224
- const MAX_SIDE$1 = 65535;
2225
- const encoder$1 = new TextEncoder();
2226
- function isRecord(value) {
2227
- return typeof value === "object" && value !== null && !Array.isArray(value);
2228
- }
2229
- function metadataOf(entry) {
2230
- const raw = isRecord(entry.meta) && entry.meta.kind === void 0 ? { kind: "tile", ...entry.meta } : entry.meta;
2231
- try {
2232
- const parsed2 = parseAssetMetadata(JSON.stringify({ version: METADATA_VERSION, assets: { [entry.name]: raw } }));
2233
- return parsed2.assets.get(entry.name);
2234
- } catch (error) {
2235
- throw new ProjectError(`the manifest entry ${entry.name} does not describe its asset: ${error.message}`);
2236
- }
2237
- }
2238
- function describedAssets(entries) {
2239
- const described = /* @__PURE__ */ new Map();
2240
- for (const entry of entries) {
2241
- if (entry.meta === void 0) continue;
2242
- described.set(entry.name, metadataOf(entry));
2116
+ function describedAssets(entries) {
2117
+ const described = /* @__PURE__ */ new Map();
2118
+ for (const entry of entries) {
2119
+ if (entry.meta === void 0) continue;
2120
+ described.set(entry.name, metadataOf(entry));
2243
2121
  }
2244
2122
  return described;
2245
2123
  }
@@ -2277,132 +2155,737 @@ function sealProjectArt(art) {
2277
2155
  }
2278
2156
  return { container: sealContainer([...entries, ...pages]), pictures: pictures.length, described: described.size };
2279
2157
  }
2280
- const GROUND_KINDS = /* @__PURE__ */ new Set(["ground", "floor", "wall", "cliff", "interior", "grass-stone"]);
2281
- function isGroundName(name) {
2282
- const { inner } = splitTileset(name);
2283
- const kind = inner.split("/")[1] ?? "";
2284
- return GROUND_KINDS.has(kind) || inner.includes("/wang-");
2158
+ const PROJECT_SEAL_STEP = "project:seal";
2159
+ const MAX_PICTURE_BYTES = 64 * 1024 * 1024;
2160
+ const MAX_TEXT_BYTES = 16 * 1024 * 1024;
2161
+ const MAX_PICTURES = 2e4;
2162
+ function shown(artDirectory, relative2) {
2163
+ return [basename(artDirectory), ...relative2.split(/[\\/]/)].join("/");
2285
2164
  }
2286
- const MAX_ASSEMBLED_PX = 4096;
2287
- const WINDOW_PX = { width: 1536, height: 1024 };
2288
- const SCALES = [TILE_SIZE, TILE_SIZE / 2, TILE_SIZE / 4, TILE_SIZE / 8, TILE_SIZE / 16];
2289
- function scaleFor(tilesWide, tilesHigh) {
2290
- if (tilesWide <= 0 || tilesHigh <= 0) return null;
2291
- for (const scale of SCALES) {
2292
- if (tilesWide * scale <= WINDOW_PX.width && tilesHigh * scale <= WINDOW_PX.height) return scale;
2165
+ async function sized(boundary, path, most, what) {
2166
+ const { size } = await stat(await boundary.resolve(path));
2167
+ if (size > most) throw new ProjectError(`${what} is ${size} bytes, and one is read at most ${most}`);
2168
+ }
2169
+ async function readPictures(boundary, artDirectory) {
2170
+ const pictures = [];
2171
+ const walk = async (directory, prefix) => {
2172
+ let entries;
2173
+ try {
2174
+ entries = await readdir(await boundary.resolve(directory), { withFileTypes: true });
2175
+ } catch {
2176
+ return;
2177
+ }
2178
+ for (const entry of entries) {
2179
+ const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
2180
+ if (entry.isDirectory()) await walk(join(directory, entry.name), name);
2181
+ else if (entry.isFile() && entry.name.endsWith(".png")) {
2182
+ if (pictures.length >= MAX_PICTURES) throw new ProjectError(`${shown(artDirectory, OUT_DIRECTORY)} holds more than ${MAX_PICTURES} pictures`);
2183
+ const path = join(directory, entry.name);
2184
+ await sized(boundary, path, MAX_PICTURE_BYTES, `${shown(artDirectory, OUT_DIRECTORY)}/${name}`);
2185
+ pictures.push({ name: name.slice(0, -".png".length), bytes: await boundary.readBytes(path) });
2186
+ }
2187
+ }
2188
+ };
2189
+ await walk(join(artDirectory, OUT_DIRECTORY), "");
2190
+ return pictures;
2191
+ }
2192
+ async function readEntries(boundary, artDirectory) {
2193
+ const directory = join(artDirectory, MANIFEST_DIRECTORY);
2194
+ const entries = [];
2195
+ for (const file of (await boundary.list(directory)).filter((name) => name.endsWith(".json")).sort()) {
2196
+ let manifest2;
2197
+ await sized(boundary, join(directory, file), MAX_TEXT_BYTES, `${MANIFEST_DIRECTORY}/${file}`);
2198
+ try {
2199
+ manifest2 = JSON.parse(await boundary.readText(join(directory, file)));
2200
+ } catch (error) {
2201
+ throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not valid JSON: ${error.message}`);
2202
+ }
2203
+ if (typeof manifest2 !== "object" || manifest2 === null || Array.isArray(manifest2)) throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not an object`);
2204
+ for (const [name, entry] of Object.entries(manifest2)) {
2205
+ if (name.startsWith("$")) continue;
2206
+ entries.push({ name, meta: entry?.meta });
2207
+ }
2208
+ }
2209
+ return entries;
2210
+ }
2211
+ async function readIdMap(boundary, artDirectory) {
2212
+ const path = join(artDirectory, ID_MAP_FILE);
2213
+ if (!await boundary.exists(path)) return null;
2214
+ await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, ID_MAP_FILE));
2215
+ return boundary.readText(path);
2216
+ }
2217
+ async function readProjectArt(boundary, artDirectory) {
2218
+ return {
2219
+ pictures: await readPictures(boundary, artDirectory),
2220
+ entries: await readEntries(boundary, artDirectory),
2221
+ idMap: await readIdMap(boundary, artDirectory)
2222
+ };
2223
+ }
2224
+ async function readProjectMap(boundary, artDirectory) {
2225
+ const path = join(artDirectory, "map.json");
2226
+ if (!await boundary.exists(path)) return null;
2227
+ await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, "map.json"));
2228
+ return boundary.readText(path);
2229
+ }
2230
+ const MAX_MIRRORED_SECTOR_BYTES = 256 * 1024;
2231
+ const inflated$1 = promisify(inflate);
2232
+ async function readMirroredProjectMap(boundary, artDirectory) {
2233
+ const indexPath = join(artDirectory, MIRROR_INDEX_FILE);
2234
+ if (!await boundary.exists(indexPath)) return null;
2235
+ await sized(boundary, indexPath, MAX_TEXT_BYTES, shown(artDirectory, MIRROR_INDEX_FILE));
2236
+ const index = parseMirrorIndex(await boundary.readText(indexPath));
2237
+ return readMirroredMap(
2238
+ index,
2239
+ async (relative2) => {
2240
+ const path = join(artDirectory, relative2);
2241
+ await sized(boundary, path, MAX_MIRRORED_SECTOR_BYTES, shown(artDirectory, relative2));
2242
+ return boundary.readBytes(path);
2243
+ },
2244
+ async (bytes) => new Uint8Array(await inflated$1(bytes, { maxOutputLength: SECTOR_BYTES }))
2245
+ );
2246
+ }
2247
+ async function sealProject(boundary, artDirectory) {
2248
+ try {
2249
+ const sealed = sealProjectArt(await readProjectArt(boundary, artDirectory));
2250
+ await boundary.writeFileReplacing(join(artDirectory, CONTAINER_FILE), sealed.container);
2251
+ const pictures = `${sealed.pictures} ${sealed.pictures === 1 ? "picture" : "pictures"}`;
2252
+ const described = `${sealed.described} ${sealed.described === 1 ? "asset" : "assets"} described`;
2253
+ return [{ step: PROJECT_SEAL_STEP, code: 0, output: `sealed ${pictures} into ${shown(artDirectory, CONTAINER_FILE)}, ${described}` }];
2254
+ } catch (error) {
2255
+ if (!(error instanceof ProjectError)) throw error;
2256
+ return [{ step: PROJECT_SEAL_STEP, code: 1, output: `the container was not rebuilt: ${error.message}` }];
2257
+ }
2258
+ }
2259
+ const MIRROR_FILE = ".mirror.json";
2260
+ const MOVING_FILE = ".moving";
2261
+ const ASIDE_DIRECTORY = "before-platform";
2262
+ const OWN = ["map.json", OUT_DIRECTORY, MANIFEST_DIRECTORY, ID_MAP_FILE, REFERENCE_DIRECTORY, STYLE_GUIDE_FILE$1];
2263
+ const MAX_SECTOR_FILE_BYTES = 256 * 1024;
2264
+ const MANIFEST_NAME = /^[a-z][a-z0-9-]{0,39}$/;
2265
+ const IMAGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2266
+ const MAX_RECORD_BYTES$1 = MAX_TEXT_BYTES;
2267
+ const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;
2268
+ class MirrorRefused extends Error {
2269
+ // Assigned in the body: Node's strip-only TypeScript mode rejects parameter
2270
+ // properties (docs/notes.md n-0023).
2271
+ constructor(message) {
2272
+ super(message);
2273
+ this.name = "MirrorRefused";
2274
+ }
2275
+ }
2276
+ async function readMirrorFile(boundary, art) {
2277
+ const path = join(art, MIRROR_FILE);
2278
+ if (!await boundary.exists(path)) return { format: MIRROR_FORMAT, art: null };
2279
+ let read = null;
2280
+ await sized(boundary, path, MAX_RECORD_BYTES$1, MIRROR_FILE);
2281
+ try {
2282
+ read = JSON.parse(await boundary.readText(path));
2283
+ } catch {
2284
+ throw new MirrorRefused(`${MIRROR_FILE} is not JSON: remove it and the mirror is written again`);
2285
+ }
2286
+ if (read === null || read.format !== MIRROR_FORMAT) throw new MirrorRefused(`${MIRROR_FILE} is not a mirror's record this editor reads`);
2287
+ const index = read.art ?? null;
2288
+ const record = (value) => typeof value === "object" && value !== null && Object.values(value).every((id2) => typeof id2 === "string");
2289
+ if (index !== null && (!record(index.pictures) || !record(index.references) || !Array.isArray(index.manifests))) {
2290
+ throw new MirrorRefused(`${MIRROR_FILE} does not say what the mirror holds: remove it and the mirror is written again`);
2291
+ }
2292
+ if (index !== null) {
2293
+ const named = [...Object.keys(index.pictures), ...Object.keys(index.references)];
2294
+ const bad = named.find((name) => nameProblem(name) !== null) ?? index.manifests.find((manifest2) => typeof manifest2 !== "string" || !MANIFEST_NAME.test(manifest2));
2295
+ if (bad !== void 0)
2296
+ throw new MirrorRefused(`${MIRROR_FILE} names ${String(bad)}, which the mirror never writes: remove it and the mirror is written again`);
2297
+ }
2298
+ return { format: MIRROR_FORMAT, art: index };
2299
+ }
2300
+ async function writeMirrorFile(boundary, art, file) {
2301
+ await boundary.writeFileReplacing(join(art, MIRROR_FILE), `${JSON.stringify(file, null, 2)}
2302
+ `);
2303
+ }
2304
+ async function isMirrored(boundary, art) {
2305
+ return await boundary.exists(join(art, MIRROR_FILE)) || await boundary.exists(join(art, MIRROR_INDEX_FILE));
2306
+ }
2307
+ async function holdsAny(boundary, directory) {
2308
+ if (!await boundary.exists(directory)) return false;
2309
+ for (const entry of await readdir(await boundary.resolve(directory), { withFileTypes: true })) {
2310
+ if (!entry.isDirectory() || await holdsAny(boundary, join(directory, entry.name))) return true;
2311
+ }
2312
+ return false;
2313
+ }
2314
+ async function paintsAny(boundary, path) {
2315
+ if (!await boundary.exists(path)) return false;
2316
+ await sized(boundary, path, MAX_TEXT_BYTES, "map.json");
2317
+ try {
2318
+ const map = parseMapText(await boundary.readText(path), "pixelvibing/map.json").document;
2319
+ const SCAFFOLD_TILE = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone"]);
2320
+ if (Object.keys(map).some((key) => key !== "width" && key !== "height" && key !== "tiles")) return true;
2321
+ return map.tiles.some(
2322
+ (tile) => tile.layers.length > 0 || tile.blocking !== 0 || tile.zone !== 0 || Object.keys(tile).some((key) => !SCAFFOLD_TILE.has(key))
2323
+ );
2324
+ } catch {
2325
+ return true;
2326
+ }
2327
+ }
2328
+ async function mirrorState(boundary, art) {
2329
+ const file = await readMirrorFile(boundary, art);
2330
+ const indexPath = join(art, MIRROR_INDEX_FILE);
2331
+ const mirrored2 = await isMirrored(boundary, art);
2332
+ const movingPath = join(art, MOVING_FILE);
2333
+ const readSized = async (path, what) => {
2334
+ await sized(boundary, path, MAX_RECORD_BYTES$1, what);
2335
+ return boundary.readText(path);
2336
+ };
2337
+ const moving = await boundary.exists(movingPath) ? (await readSized(movingPath, MOVING_FILE)).trim() : "";
2338
+ return {
2339
+ map: await boundary.exists(indexPath) ? parseMirrorIndex(await readSized(indexPath, MIRROR_INDEX_FILE)) : null,
2340
+ art: file.art,
2341
+ moving: moving === "" ? null : moving,
2342
+ mirrored: mirrored2,
2343
+ own: mirrored2 ? { map: false, art: false } : {
2344
+ map: await paintsAny(boundary, join(art, "map.json")),
2345
+ art: await holdsAny(boundary, join(art, OUT_DIRECTORY)) || await holdsAny(boundary, join(art, REFERENCE_DIRECTORY))
2346
+ }
2347
+ };
2348
+ }
2349
+ async function markMoving(boundary, art, project) {
2350
+ if (project === null) return boundary.removeFile(join(art, MOVING_FILE));
2351
+ if (!IMAGE_ID.test(project)) throw new MirrorRefused("a folder is moved onto a platform project named by its id");
2352
+ await boundary.writeFileReplacing(join(art, MOVING_FILE), `${project}
2353
+ `);
2354
+ }
2355
+ async function setAside(boundary, art) {
2356
+ if (await isMirrored(boundary, art)) return;
2357
+ const held = [];
2358
+ for (const name of OWN) if (await boundary.exists(join(art, name))) held.push(name);
2359
+ if (held.length > 0) {
2360
+ let aside = join(art, ASIDE_DIRECTORY);
2361
+ for (let second = 2; await boundary.exists(aside); second += 1) aside = join(art, `${ASIDE_DIRECTORY}-${second}`);
2362
+ await boundary.writeFileReplacing(join(aside, ".kept"), "the folder's own, before it was a mirror of its platform project\n");
2363
+ for (const name of held) await rename(await boundary.resolve(join(art, name)), await boundary.resolve(join(aside, name)));
2364
+ }
2365
+ await writeMirrorFile(boundary, art, { format: MIRROR_FORMAT, art: null });
2366
+ }
2367
+ const MOST_MANIFEST_BYTES = MAX_TEXT_BYTES;
2368
+ async function readFolder(boundary, art) {
2369
+ if (await isMirrored(boundary, art)) throw new MirrorRefused("this folder is already a mirror: it holds nothing of its own to move");
2370
+ const read = await readProjectArt(boundary, art);
2371
+ const assets = [];
2372
+ const directory = join(art, MANIFEST_DIRECTORY);
2373
+ const files = await boundary.exists(directory) ? (await boundary.list(directory)).filter((name) => name.endsWith(".json")).sort() : [];
2374
+ for (const file of files) {
2375
+ const manifest2 = file.slice(0, -".json".length);
2376
+ if (!MANIFEST_NAME.test(manifest2)) throw new MirrorRefused(`${MANIFEST_DIRECTORY}/${file} is not a manifest the platform keeps: rename it`);
2377
+ const text2 = await boundary.readText(join(directory, file));
2378
+ if (text2.length > MOST_MANIFEST_BYTES) throw new MirrorRefused(`${MANIFEST_DIRECTORY}/${file} is past what a manifest may weigh`);
2379
+ let entries;
2380
+ try {
2381
+ entries = JSON.parse(text2);
2382
+ } catch {
2383
+ throw new MirrorRefused(`${MANIFEST_DIRECTORY}/${file} is not JSON`);
2384
+ }
2385
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries)) throw new MirrorRefused(`${MANIFEST_DIRECTORY}/${file} is not a manifest`);
2386
+ for (const [name, entry] of Object.entries(entries)) {
2387
+ if (typeof entry === "object" && entry !== null) assets.push({ name: checkedName(name, "the asset"), manifest: manifest2, entry });
2388
+ }
2389
+ }
2390
+ const idMap = {};
2391
+ if (read.idMap !== null) {
2392
+ let parsed2 = {};
2393
+ try {
2394
+ parsed2 = JSON.parse(read.idMap);
2395
+ } catch {
2396
+ throw new MirrorRefused(`${ID_MAP_FILE} is not JSON`);
2397
+ }
2398
+ for (const [id2, name] of Object.entries(parsed2.looktypes ?? {})) if (typeof name === "string") idMap[id2] = name;
2399
+ }
2400
+ let total = read.pictures.reduce((sum, picture) => sum + picture.bytes.length, 0);
2401
+ if (total > MAX_FOLDER_BYTES) throw new MirrorRefused(`this folder's pictures weigh past ${MAX_FOLDER_BYTES / 1024 / 1024} MB, more than one move sends`);
2402
+ const references = [];
2403
+ for (const file of await listReferences(boundary, art)) {
2404
+ await sized(boundary, join(art, REFERENCE_DIRECTORY, file), MAX_PICTURE_BYTES, `${REFERENCE_DIRECTORY}/${file}`);
2405
+ const bytes = await readReference(boundary, art, file);
2406
+ total += bytes.length;
2407
+ if (total > MAX_FOLDER_BYTES) throw new MirrorRefused(`this folder's pictures weigh past ${MAX_FOLDER_BYTES / 1024 / 1024} MB, more than one move sends`);
2408
+ references.push({ name: file.replace(/\.png$/i, ""), bytes });
2409
+ }
2410
+ return { map: await readProjectMap(boundary, art), assets, pictures: read.pictures.map(({ name, bytes }) => ({ name, bytes })), idMap, references };
2411
+ }
2412
+ function checkedName(name, what) {
2413
+ const problem = typeof name === "string" ? nameProblem(name) : "it is not text";
2414
+ if (problem !== null) throw new MirrorRefused(`${what} ${String(name)} cannot be written to the folder: ${problem}`);
2415
+ return name;
2416
+ }
2417
+ async function writeMapMirror(boundary, art, mirror) {
2418
+ await setAside(boundary, art);
2419
+ const current = await mirrorState(boundary, art);
2420
+ const versions = new Map(
2421
+ (current.map?.sectors ?? []).map((sector) => [sector.slice(0, 3).join(":"), sector])
2422
+ );
2423
+ for (const sector of mirror.write) {
2424
+ const path = mirrorSectorPath(sector.z, sector.cx, sector.cy);
2425
+ if (!(sector.data instanceof Uint8Array) || sector.data.length > MAX_SECTOR_FILE_BYTES) throw new MirrorRefused(`${path} is past what a sector is kept in`);
2426
+ if (!Number.isInteger(sector.version) || sector.version < 1) throw new MirrorRefused(`${path} names no version`);
2427
+ await boundary.writeFileReplacing(join(art, path), sector.data);
2428
+ versions.set(`${sector.z}:${sector.cx}:${sector.cy}`, [sector.z, sector.cx, sector.cy, sector.version]);
2429
+ }
2430
+ const dropped = mirror.drop.map((place) => mirrorSectorPath(place.z, place.cx, place.cy));
2431
+ for (const place of mirror.drop) versions.delete(`${place.z}:${place.cx}:${place.cy}`);
2432
+ const index = parseMirrorIndex(
2433
+ mirrorIndexText({
2434
+ format: MIRROR_FORMAT,
2435
+ revision: mirror.revision,
2436
+ size: mirror.size,
2437
+ limit: mirror.limit,
2438
+ names: mirror.names,
2439
+ sectors: [...versions.values()]
2440
+ })
2441
+ );
2442
+ await boundary.writeFileReplacing(join(art, MIRROR_INDEX_FILE), mirrorIndexText(index));
2443
+ for (const path of dropped) await boundary.removeFile(join(art, path)).catch(() => void 0);
2444
+ return index;
2445
+ }
2446
+ function checkedPictures(pictures, held, what) {
2447
+ const names = Object.keys(pictures);
2448
+ if (names.length > MAX_PICTURES) throw new MirrorRefused(`the mirror holds at most ${MAX_PICTURES} ${what}s`);
2449
+ for (const name of names) {
2450
+ checkedName(name, `the ${what}`);
2451
+ const picture = pictures[name];
2452
+ if (typeof picture.image !== "string" || !IMAGE_ID.test(picture.image)) throw new MirrorRefused(`the ${what} ${name} names no image`);
2453
+ if (picture.bytes === void 0) {
2454
+ if (held[name] !== picture.image) throw new MirrorRefused(`the ${what} ${name} comes with no bytes, and the folder does not hold its image`);
2455
+ continue;
2456
+ }
2457
+ if (!(picture.bytes instanceof Uint8Array) || picture.bytes.length > MAX_PICTURE_BYTES || !isPng(picture.bytes)) {
2458
+ throw new MirrorRefused(`the ${what} ${name} is not a PNG the folder can keep`);
2459
+ }
2460
+ }
2461
+ }
2462
+ async function writeArtMirror(boundary, art, mirror) {
2463
+ await setAside(boundary, art);
2464
+ const file = await readMirrorFile(boundary, art);
2465
+ const before = file.art ?? { pictures: {}, references: {}, manifests: [] };
2466
+ checkedPictures(mirror.pictures, before.pictures, "picture");
2467
+ checkedPictures(mirror.references, before.references, "reference");
2468
+ const manifests = /* @__PURE__ */ new Map();
2469
+ for (const asset of mirror.assets) {
2470
+ checkedName(asset.name, "the asset");
2471
+ if (typeof asset.manifest !== "string" || !MANIFEST_NAME.test(asset.manifest)) throw new MirrorRefused(`${asset.name} names no manifest the folder keeps`);
2472
+ const entries = manifests.get(asset.manifest) ?? {};
2473
+ entries[asset.name] = asset.entry;
2474
+ manifests.set(asset.manifest, entries);
2475
+ }
2476
+ const looktypes = {};
2477
+ for (const [id2, name] of Object.entries(mirror.idMap)) {
2478
+ if (!/^[0-9]+$/.test(id2)) throw new MirrorRefused(`the id map names an id that is none: ${id2}`);
2479
+ looktypes[id2] = checkedName(name, "the id map's asset");
2480
+ }
2481
+ if (mirror.styleGuide !== null && (typeof mirror.styleGuide !== "string" || mirror.styleGuide.length > MAX_TEXT_BYTES)) {
2482
+ throw new MirrorRefused("the style guide is not text the folder can keep");
2483
+ }
2484
+ for (const [name, picture] of Object.entries(mirror.pictures)) {
2485
+ if (picture.bytes !== void 0) await boundary.writeFileReplacing(join(art, OUT_DIRECTORY, `${name}.png`), picture.bytes);
2486
+ }
2487
+ for (const [name, picture] of Object.entries(mirror.references)) {
2488
+ if (picture.bytes !== void 0) await boundary.writeFileReplacing(join(art, REFERENCE_DIRECTORY, `${name}.png`), picture.bytes);
2489
+ }
2490
+ for (const [manifest2, entries] of manifests) {
2491
+ await boundary.writeFileReplacing(join(art, MANIFEST_DIRECTORY, `${manifest2}.json`), `${JSON.stringify(entries, null, 2)}
2492
+ `);
2493
+ }
2494
+ await boundary.writeFileReplacing(join(art, ID_MAP_FILE), `${JSON.stringify({ looktypes }, null, 2)}
2495
+ `);
2496
+ if (mirror.styleGuide !== null) await boundary.writeFileReplacing(join(art, STYLE_GUIDE_FILE$1), mirror.styleGuide);
2497
+ const index = {
2498
+ revision: mirror.revision,
2499
+ pictures: Object.fromEntries(Object.entries(mirror.pictures).map(([name, picture]) => [name, picture.image])),
2500
+ references: Object.fromEntries(Object.entries(mirror.references).map(([name, picture]) => [name, picture.image])),
2501
+ manifests: [...manifests.keys()].sort()
2502
+ };
2503
+ await writeMirrorFile(boundary, art, { ...file, art: index });
2504
+ const gone = async (relative2) => boundary.removeFile(join(art, relative2)).catch(() => void 0);
2505
+ for (const name of Object.keys(before.pictures)) if (index.pictures[name] === void 0) await gone(join(OUT_DIRECTORY, `${name}.png`));
2506
+ for (const name of Object.keys(before.references)) if (index.references[name] === void 0) await gone(join(REFERENCE_DIRECTORY, `${name}.png`));
2507
+ for (const manifest2 of before.manifests) if (!index.manifests.includes(manifest2)) await gone(join(MANIFEST_DIRECTORY, `${manifest2}.json`));
2508
+ return index;
2509
+ }
2510
+ const SECTOR_SIDE = 128;
2511
+ const MOST_READ = 32;
2512
+ const MOST_WAIT_SECONDS$1 = 60;
2513
+ const WRITTEN_AT_ONCE = 64;
2514
+ const pause = (seconds) => new Promise((done) => setTimeout(done, seconds * 1e3));
2515
+ async function patiently(deps, attempt, most = MOST_WAIT_SECONDS$1) {
2516
+ let waited = 0;
2517
+ for (; ; ) {
2518
+ try {
2519
+ return await attempt();
2520
+ } catch (error) {
2521
+ const wait = error instanceof ApiError && error.status === 429 ? Math.max(1, Math.min(error.retryAfter ?? 1, 30)) : null;
2522
+ if (wait === null || waited + wait > most) throw error;
2523
+ waited += wait;
2524
+ await (deps.wait ?? pause)(wait);
2525
+ }
2526
+ }
2527
+ }
2528
+ function fromBase64(text2) {
2529
+ const binary = atob(text2);
2530
+ const bytes = new Uint8Array(binary.length);
2531
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
2532
+ return bytes;
2533
+ }
2534
+ function readsOf(places) {
2535
+ const rows = /* @__PURE__ */ new Map();
2536
+ for (const [cx, cy] of places) rows.set(cy, [...rows.get(cy) ?? [], cx]);
2537
+ const reads = [];
2538
+ for (const [cy, columns] of [...rows.entries()].sort((a, b) => a[0] - b[0])) {
2539
+ const sorted = [...columns].sort((a, b) => a - b);
2540
+ let from = sorted[0];
2541
+ let to = from;
2542
+ for (const cx of sorted.slice(1)) {
2543
+ if (cx - from + 1 > MOST_READ) {
2544
+ reads.push({ cy, from, to });
2545
+ from = cx;
2546
+ }
2547
+ to = cx;
2548
+ }
2549
+ reads.push({ cy, from, to });
2550
+ }
2551
+ return reads;
2552
+ }
2553
+ async function syncMap(deps) {
2554
+ const base = `/projects/${encodeURIComponent(deps.project)}/map`;
2555
+ const get = (path) => patiently(deps, async () => (await deps.call(path)).json());
2556
+ const outline = await get(base);
2557
+ const held = (await deps.mirror.state()).map;
2558
+ const holds = new Map((held?.sectors ?? []).map(([z, cx, cy, version2]) => [`${z}:${cx}:${cy}`, version2]));
2559
+ const names = outline.levels.flatMap((level) => level.name === null ? [] : [{ z: level.z, name: level.name }]);
2560
+ const meta = { revision: outline.revision, size: outline.size, limit: outline.limit, names };
2561
+ const kept = /* @__PURE__ */ new Set();
2562
+ let index = null;
2563
+ let pending = [];
2564
+ const flush = async () => {
2565
+ if (pending.length === 0) return;
2566
+ index = await deps.mirror.map({ ...meta, write: pending, drop: [] });
2567
+ pending = [];
2568
+ };
2569
+ for (const { z } of outline.levels) {
2570
+ const versions = await get(`${base}/versions?z=${z}`);
2571
+ const changed = [];
2572
+ const wanted = /* @__PURE__ */ new Map();
2573
+ for (const [cx, cy, version2] of versions.sectors) {
2574
+ kept.add(`${z}:${cx}:${cy}`);
2575
+ if (holds.get(`${z}:${cx}:${cy}`) !== version2) {
2576
+ changed.push([cx, cy]);
2577
+ wanted.set(`${cx}:${cy}`, version2);
2578
+ }
2579
+ }
2580
+ for (const read of readsOf(changed)) {
2581
+ const area = {
2582
+ z,
2583
+ left: read.from * SECTOR_SIDE,
2584
+ top: read.cy * SECTOR_SIDE,
2585
+ right: Math.min(read.to * SECTOR_SIDE + SECTOR_SIDE - 1, outline.size.width - 1),
2586
+ bottom: Math.min(read.cy * SECTOR_SIDE + SECTOR_SIDE - 1, outline.size.height - 1)
2587
+ };
2588
+ const query = `z=${area.z}&left=${area.left}&top=${area.top}&right=${area.right}&bottom=${area.bottom}`;
2589
+ const found = await get(`${base}/sectors?${query}`);
2590
+ for (const sector of found.sectors) {
2591
+ if (!wanted.has(`${sector.cx}:${sector.cy}`)) continue;
2592
+ pending.push({ z, cx: sector.cx, cy: sector.cy, version: sector.version, data: fromBase64(sector.data) });
2593
+ if (pending.length >= WRITTEN_AT_ONCE) await flush();
2594
+ }
2595
+ }
2596
+ }
2597
+ await flush();
2598
+ const drop = [...holds.keys()].filter((key) => !kept.has(key)).map((key) => {
2599
+ const [z, cx, cy] = key.split(":").map(Number);
2600
+ return { z, cx, cy };
2601
+ });
2602
+ if (drop.length > 0 || index === null) index = await deps.mirror.map({ ...meta, write: [], drop });
2603
+ return index;
2604
+ }
2605
+ async function syncArt(deps) {
2606
+ const base = `/projects/${encodeURIComponent(deps.project)}`;
2607
+ const get = (path) => patiently(deps, async () => (await deps.call(path)).json());
2608
+ const bytes = (image) => patiently(deps, async () => new Uint8Array(await (await deps.call(`/images/${encodeURIComponent(image)}`)).arrayBuffer()));
2609
+ const art = await get(`${base}/art`);
2610
+ const references = await get(`${base}/art/references`);
2611
+ let styleGuide = null;
2612
+ try {
2613
+ styleGuide = (await get(`${base}/style-guide`)).text;
2614
+ } catch (error) {
2615
+ if (!(error instanceof ApiError && error.status === 404)) throw error;
2616
+ }
2617
+ const held = (await deps.mirror.state()).art;
2618
+ const pictureOf = async (name, image, holds) => holds?.[name] === image ? { image } : { image, bytes: await bytes(image) };
2619
+ const pictures = {};
2620
+ for (const [name, image] of Object.entries(art.pictures)) pictures[name] = await pictureOf(name, image, held?.pictures);
2621
+ const referenced = {};
2622
+ for (const { name, image } of references) referenced[name] = await pictureOf(name, image, held?.references);
2623
+ await deps.mirror.art({ revision: art.revision, assets: art.assets, pictures, idMap: art.idMap, references: referenced, styleGuide });
2624
+ }
2625
+ function mirrorOf(local) {
2626
+ const art = artOf(local);
2627
+ const boundary = new Boundary([art]);
2628
+ return {
2629
+ state: () => mirrorState(boundary, art),
2630
+ map: (each) => writeMapMirror(boundary, art, each),
2631
+ art: (each) => writeArtMirror(boundary, art, each),
2632
+ folder: () => readFolder(boundary, art),
2633
+ moving: (project) => markMoving(boundary, art, project)
2634
+ };
2635
+ }
2636
+ const deflated = promisify(deflate);
2637
+ const inflated = promisify(inflate);
2638
+ const nodeZlib = {
2639
+ deflate: async (bytes) => new Uint8Array(await deflated(bytes)),
2640
+ inflate: async (bytes) => new Uint8Array(await inflated(bytes, { maxOutputLength: SECTOR_BYTES }))
2641
+ };
2642
+ const HOLDS_ITS_OWN = "this folder holds a map or art of its own, written before it was its platform project's: move it onto the platform with `pixel-vibing-cli move`, or pass --aside to keep its own files under pixelvibing/before-platform/";
2643
+ async function checkFolder(local, aside) {
2644
+ const state = await mirrorOf(local).state();
2645
+ if (state.moving !== null) throw new CliError("a move of this folder onto the platform stopped before it was done: run `pixel-vibing-cli move` again");
2646
+ if (!aside && !state.mirrored && (state.own.map || state.own.art)) throw new CliError(HOLDS_ITS_OWN);
2647
+ }
2648
+ async function bringFolder(platform, local, options) {
2649
+ await checkFolder(local, options.aside);
2650
+ const deps = { project: linkedOf(local), call: platform.call, mirror: mirrorOf(local) };
2651
+ if (options.map) await syncMap(deps);
2652
+ await syncArt(deps);
2653
+ }
2654
+ const PULLED_FILE = `${PROJECT_DIRECTORY}/pulled.json`;
2655
+ const PULLED_FORMAT = 1;
2656
+ const HEX64 = /^[0-9a-f]{64}$/;
2657
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
2658
+ const EMPTY_PULLED = { guide: null, assets: {} };
2659
+ class PulledError extends Error {
2660
+ constructor(message) {
2661
+ super(message);
2662
+ this.name = "PulledError";
2663
+ }
2664
+ }
2665
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2666
+ function isPulledPath(path) {
2667
+ const directory = path.startsWith("out/") ? "out/" : path.startsWith("ref/") ? "ref/" : null;
2668
+ if (directory === null || !path.endsWith(".png")) return false;
2669
+ const name = path.slice(directory.length, -".png".length);
2670
+ return !name.includes("\\") && nameProblem(name) === null;
2671
+ }
2672
+ function assetOf(id2, raw) {
2673
+ if (!UUID.test(id2)) throw new PulledError(`${PULLED_FILE}: ${id2} is not an asset's id`);
2674
+ if (!isRecord(raw)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} is not an object`);
2675
+ const { kind, name, hash, files } = raw;
2676
+ if (typeof kind !== "string" || kind === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no kind`);
2677
+ if (typeof name !== "string" || name === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no name`);
2678
+ if (typeof hash !== "string" || !HEX64.test(hash)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} has no hash`);
2679
+ if (!Array.isArray(files) || files.length === 0 || !files.every((file) => typeof file === "string" && isPulledPath(file))) {
2680
+ throw new PulledError(`${PULLED_FILE}: the asset ${id2} names a file that is not a picture under out/ or ref/`);
2681
+ }
2682
+ return { kind, name, hash, files };
2683
+ }
2684
+ function guideOf(raw) {
2685
+ if (raw === void 0 || raw === null) return null;
2686
+ if (!isRecord(raw) || !Number.isInteger(raw.version) || raw.version < 1 || typeof raw.sha256 !== "string" || !HEX64.test(raw.sha256)) {
2687
+ throw new PulledError(`${PULLED_FILE}: the style guide's entry is not a version and a digest`);
2688
+ }
2689
+ return { version: raw.version, sha256: raw.sha256 };
2690
+ }
2691
+ function readPulled(text2) {
2692
+ let parsed2;
2693
+ try {
2694
+ parsed2 = JSON.parse(text2);
2695
+ } catch (error) {
2696
+ throw new PulledError(`${PULLED_FILE} is not valid JSON: ${error.message}`);
2697
+ }
2698
+ if (!isRecord(parsed2)) throw new PulledError(`${PULLED_FILE} is not an object`);
2699
+ if (parsed2.format !== PULLED_FORMAT) throw new PulledError(`${PULLED_FILE} names no format this CLI reads: expected "format": ${PULLED_FORMAT}`);
2700
+ const assets = {};
2701
+ if (parsed2.assets !== void 0) {
2702
+ if (!isRecord(parsed2.assets)) throw new PulledError(`${PULLED_FILE}: "assets" is not an object`);
2703
+ for (const [id2, raw] of Object.entries(parsed2.assets)) assets[id2] = assetOf(id2, raw);
2704
+ }
2705
+ return { guide: guideOf(parsed2.guide), assets };
2706
+ }
2707
+ function writePulled(record) {
2708
+ return `${JSON.stringify({ format: PULLED_FORMAT, ...record.guide === null ? {} : { guide: record.guide }, assets: record.assets }, null, 2)}
2709
+ `;
2710
+ }
2711
+ const MAX_RECORD_BYTES = 4 * 1024 * 1024;
2712
+ const artBoundary = (local) => new Boundary([artOf(local)]);
2713
+ const recordPath = (local) => join(local.root, ...PULLED_FILE.split("/"));
2714
+ async function readRecord(local) {
2715
+ const boundary = artBoundary(local);
2716
+ const path = recordPath(local);
2717
+ if (!await boundary.exists(path)) return EMPTY_PULLED;
2718
+ const { size } = await stat(await boundary.resolve(path));
2719
+ if (size > MAX_RECORD_BYTES) throw new CliError(`${PULLED_FILE} is ${size} bytes, and is read at most ${MAX_RECORD_BYTES}`);
2720
+ try {
2721
+ return readPulled(await boundary.readText(path));
2722
+ } catch (error) {
2723
+ if (error instanceof PulledError) throw new CliError(error.message);
2724
+ throw error;
2293
2725
  }
2294
- return null;
2295
2726
  }
2296
- ({
2297
- width: Math.floor(WINDOW_PX.width / SCALES.at(-1)),
2298
- height: Math.floor(WINDOW_PX.height / SCALES.at(-1))
2299
- });
2300
- Array.from({ length: 16 }, (_value, index) => index);
2301
- const CONTAINER_FILE = join("build", "assets.eac");
2302
- const MANIFEST_DIRECTORY = "manifest";
2303
- const OUT_DIRECTORY = "out";
2304
- const ID_MAP_FILE = "id-map.json";
2305
- const PROJECT_SEAL_STEP = "project:seal";
2306
- const MAX_PICTURE_BYTES = 64 * 1024 * 1024;
2307
- const MAX_TEXT_BYTES = 16 * 1024 * 1024;
2308
- const MAX_PICTURES = 2e4;
2309
- function shown(artDirectory, relative2) {
2310
- return [basename(artDirectory), ...relative2.split(/[\\/]/)].join("/");
2727
+ async function writeRecord(local, record) {
2728
+ await artBoundary(local).writeFileReplacing(recordPath(local), writePulled(record));
2311
2729
  }
2312
- async function sized(boundary, path, most, what) {
2313
- const { size } = await stat(await boundary.resolve(path));
2314
- if (size > most) throw new ProjectError(`${what} is ${size} bytes, and one is read at most ${most}`);
2730
+ const REFERENCE_KINDS = /* @__PURE__ */ new Set(["level-design", "hud-mockup"]);
2731
+ function setPieces(asset) {
2732
+ return setPiecesOf(asset.kind, asset.images.length, asset.data);
2315
2733
  }
2316
- async function readPictures(boundary, artDirectory) {
2317
- const pictures = [];
2318
- const walk = async (directory, prefix) => {
2319
- let entries;
2320
- try {
2321
- entries = await readdir(await boundary.resolve(directory), { withFileTypes: true });
2322
- } catch {
2323
- return;
2324
- }
2325
- for (const entry of entries) {
2326
- const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
2327
- if (entry.isDirectory()) await walk(join(directory, entry.name), name);
2328
- else if (entry.isFile() && entry.name.endsWith(".png")) {
2329
- if (pictures.length >= MAX_PICTURES) throw new ProjectError(`${shown(artDirectory, OUT_DIRECTORY)} holds more than ${MAX_PICTURES} pictures`);
2330
- const path = join(directory, entry.name);
2331
- await sized(boundary, path, MAX_PICTURE_BYTES, `${shown(artDirectory, OUT_DIRECTORY)}/${name}`);
2332
- pictures.push({ name: name.slice(0, -".png".length), bytes: await boundary.readBytes(path) });
2333
- }
2334
- }
2335
- };
2336
- await walk(join(artDirectory, OUT_DIRECTORY), "");
2337
- return pictures;
2734
+ function namesOf(name, count) {
2735
+ return count === 1 ? [name] : Array.from({ length: count }, (_, index) => `${name}/${index}`);
2338
2736
  }
2339
- async function readEntries(boundary, artDirectory) {
2340
- const directory = join(artDirectory, MANIFEST_DIRECTORY);
2341
- const entries = [];
2342
- for (const file of (await boundary.list(directory)).filter((name) => name.endsWith(".json")).sort()) {
2343
- let manifest2;
2344
- await sized(boundary, join(directory, file), MAX_TEXT_BYTES, `${MANIFEST_DIRECTORY}/${file}`);
2345
- try {
2346
- manifest2 = JSON.parse(await boundary.readText(join(directory, file)));
2347
- } catch (error) {
2348
- throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not valid JSON: ${error.message}`);
2737
+ function nameFor(asset, record, given) {
2738
+ const name = given ?? record.assets[asset.id]?.name ?? asset.name ?? SET_NAMES[asset.kind] ?? `${asset.kind}-${asset.id.slice(0, 8)}`;
2739
+ const problem = nameProblem(name);
2740
+ if (problem !== null) throw new CliError(`${name} is not a name a picture can be landed under (${problem}): give one with --as`);
2741
+ return name;
2742
+ }
2743
+ async function pullAsset(platform, local, ref, given, options = {}) {
2744
+ const project = linkedOf(local);
2745
+ await checkFolder(local, options.aside === true);
2746
+ const asset = await platform.web.asset(project, ref);
2747
+ if (asset.images.length === 0) throw new CliError(`the asset ${asset.id} has no image the platform still keeps`);
2748
+ if (asset.kind === "terrain") {
2749
+ throw new CliError(`${asset.id} is a terrain set: land it from the editor's Library, which cuts a set into the cells its brush paints. Nothing was landed`);
2750
+ }
2751
+ const record = await readRecord(local);
2752
+ const name = nameFor(asset, record, given);
2753
+ const pieces = setPieces(asset);
2754
+ const reference = REFERENCE_KINDS.has(asset.kind);
2755
+ const names = pieces === null ? namesOf(name, asset.images.length) : pieceNames(name, pieces);
2756
+ const files = names.map((each) => `${reference ? "ref" : "out"}/${each}.png`);
2757
+ const pulled = { id: asset.id, kind: asset.kind, name, hash: asset.hash, files, data: asset.data };
2758
+ const base = `/projects/${encodeURIComponent(project)}`;
2759
+ const json2 = async (path, init2) => (await platform.call(path, init2)).json();
2760
+ const art = await json2(`${base}/art`);
2761
+ const references = new Map((await json2(`${base}/art/references`)).map((each) => [each.name, each.image]));
2762
+ const held = (each) => reference ? references.get(each) : art.pictures[each];
2763
+ const landing = [];
2764
+ for (const [index, each] of names.entries()) {
2765
+ const image = asset.images[index].id;
2766
+ const holds = held(each);
2767
+ if (holds === image) continue;
2768
+ if (holds !== void 0) {
2769
+ throw new CliError(
2770
+ `the project's art already holds ${reference ? "a reference" : "a picture"} named ${each}, of something else: nothing was landed. Give this one another name with --as`
2771
+ );
2349
2772
  }
2350
- if (typeof manifest2 !== "object" || manifest2 === null || Array.isArray(manifest2)) throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not an object`);
2351
- for (const [name, entry] of Object.entries(manifest2)) {
2352
- if (name.startsWith("$")) continue;
2353
- entries.push({ name, meta: entry?.meta });
2773
+ landing.push({ name: each, image, piece: pieces?.[index] ?? null });
2774
+ }
2775
+ if (landing.length > 0) {
2776
+ if (reference) {
2777
+ let revision = art.revision;
2778
+ for (const each of landing)
2779
+ revision = (await json2(`${base}/art/references`, { method: "POST", body: { revision, name: each.name, image: each.image } })).revision;
2780
+ } else {
2781
+ const assets = landing.map(
2782
+ (each) => each.piece === null ? { name: each.name, manifest: "editor", entry: { command: ["cli", asset.kind, asset.id] }, image: each.image } : { name: each.name, manifest: "interface", entry: describedInterface({}, [[each.name, each.piece]], [])[each.name], image: each.image }
2783
+ );
2784
+ await json2(`${base}/art/land`, { method: "POST", body: { revision: art.revision, assets } });
2354
2785
  }
2355
2786
  }
2356
- return entries;
2357
- }
2358
- async function readIdMap(boundary, artDirectory) {
2359
- const path = join(artDirectory, ID_MAP_FILE);
2360
- if (!await boundary.exists(path)) return null;
2361
- await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, ID_MAP_FILE));
2362
- return boundary.readText(path);
2787
+ await writeRecord(local, { ...record, assets: { ...record.assets, [asset.id]: { kind: asset.kind, name, hash: asset.hash, files } } });
2788
+ await bringFolder(platform, local, { map: false, aside: options.aside === true });
2789
+ return { ...pulled, current: landing.length === 0 };
2363
2790
  }
2364
- async function readProjectArt(boundary, artDirectory) {
2365
- return {
2366
- pictures: await readPictures(boundary, artDirectory),
2367
- entries: await readEntries(boundary, artDirectory),
2368
- idMap: await readIdMap(boundary, artDirectory)
2369
- };
2791
+ function pulledWords(pulled) {
2792
+ const where = pulled.files.length === 1 ? `pixelvibing/${pulled.files[0]}` : `${pulled.files.length} pictures under pixelvibing/`;
2793
+ return pulled.current ? `${pulled.name} is current: the project's art holds it, and ${where} is what the platform has.` : `Pulled ${pulled.name}, ${pulled.kind}, into the project's art, and wrote ${where}.`;
2370
2794
  }
2371
- async function readProjectMap(boundary, artDirectory) {
2372
- const path = join(artDirectory, "map.json");
2373
- if (!await boundary.exists(path)) return null;
2374
- await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, "map.json"));
2375
- return boundary.readText(path);
2795
+ const date = (at) => at.slice(0, 10);
2796
+ function listed(assets) {
2797
+ if (assets.length === 0) return "No assets.";
2798
+ return assets.map(
2799
+ (asset) => `${asset.id} ${asset.kind.padEnd(12)} ${(asset.name ?? "—").padEnd(28)} ${asset.images} image${asset.images === 1 ? "" : "s"} ${date(asset.createdAt)}`
2800
+ ).join("\n");
2376
2801
  }
2377
- const MAX_MIRRORED_SECTOR_BYTES = 256 * 1024;
2378
- const inflated = promisify(inflate);
2379
- async function readMirroredProjectMap(boundary, artDirectory) {
2380
- const indexPath = join(artDirectory, MIRROR_INDEX_FILE);
2381
- if (!await boundary.exists(indexPath)) return null;
2382
- await sized(boundary, indexPath, MAX_TEXT_BYTES, shown(artDirectory, MIRROR_INDEX_FILE));
2383
- const index = parseMirrorIndex(await boundary.readText(indexPath));
2384
- return readMirroredMap(
2385
- index,
2386
- async (relative2) => {
2387
- const path = join(artDirectory, relative2);
2388
- await sized(boundary, path, MAX_MIRRORED_SECTOR_BYTES, shown(artDirectory, relative2));
2389
- return boundary.readBytes(path);
2390
- },
2391
- async (bytes) => new Uint8Array(await inflated(bytes, { maxOutputLength: SECTOR_BYTES }))
2392
- );
2802
+ const list = {
2803
+ name: "list",
2804
+ summary: "List the platform project's assets, of one kind when given, newest first",
2805
+ usage: "[<kind>] [--before <id>]",
2806
+ options: { before: { type: "string", value: "<id>", description: "the page after the asset with this id, as the last page said" } },
2807
+ positionals: [0, 1],
2808
+ run: async (context, args) => {
2809
+ const local = await projectHere(context.io.cwd);
2810
+ const project = linkedOf(local);
2811
+ const platform = await signedIn(context);
2812
+ const [kind] = args.positionals;
2813
+ const page = await platform.web.assets(project, kind ?? null, stringOf(args, "before") ?? null);
2814
+ const more = page.next === null ? "" : `
2815
+ More: pixel-vibing-cli list${kind === void 0 ? "" : ` ${kind}`} --before ${page.next}`;
2816
+ answer(context, page, `${listed(page.assets)}${more}`);
2817
+ }
2818
+ };
2819
+ const pull = {
2820
+ name: "pull",
2821
+ summary: "Land one asset, by its id or its name, in the project's art, and write it into the local project",
2822
+ usage: "<id|name> [--as <name>] [--aside]",
2823
+ options: {
2824
+ as: { type: "string", value: "<name>", description: "land it under this name instead of the platform's" },
2825
+ aside: { type: "boolean", description: "keep a folder's own map and art under pixelvibing/before-platform/, and write it from the platform" }
2826
+ },
2827
+ positionals: [1, 1],
2828
+ run: async (context, args) => {
2829
+ const local = await projectHere(context.io.cwd);
2830
+ linkedOf(local);
2831
+ const platform = await signedIn(context);
2832
+ const pulled = await pullAsset(platform, local, args.positionals[0], stringOf(args, "as"), { aside: flagOf(args, "aside") });
2833
+ answer(context, pulled, pulledWords(pulled));
2834
+ }
2835
+ };
2836
+ const DOWNLOAD_URL = "https://github.com/Pixel-Vibing/pixel-vibing/releases/latest";
2837
+ const EDITOR_VARIABLE = "PIXELVIBING_EDITOR";
2838
+ async function exists$1(path) {
2839
+ try {
2840
+ await stat(path);
2841
+ return true;
2842
+ } catch {
2843
+ return false;
2844
+ }
2393
2845
  }
2394
- async function sealProject(boundary, artDirectory) {
2846
+ async function findEditor(platform, env, home) {
2847
+ const named = env[EDITOR_VARIABLE]?.trim();
2848
+ if (named !== void 0 && named !== "") {
2849
+ if (!await exists$1(named)) throw new CliError(`${EDITOR_VARIABLE} names ${named}, which is not there`);
2850
+ return platform === "darwin" && named.endsWith(".app") ? { command: "open", args: ["-a", named, "--args"] } : { command: named, args: [] };
2851
+ }
2852
+ if (platform === "win32") {
2853
+ const places = [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "PixelVibing"), env.ProgramFiles && join(env.ProgramFiles, "PixelVibing")];
2854
+ for (const place of places) {
2855
+ if (typeof place === "string" && await exists$1(join(place, "PixelVibing.exe"))) return { command: join(place, "PixelVibing.exe"), args: [] };
2856
+ }
2857
+ return null;
2858
+ }
2859
+ if (platform === "darwin") {
2860
+ for (const app of ["/Applications/PixelVibing.app", join(home, "Applications", "PixelVibing.app")]) {
2861
+ if (await exists$1(app)) return { command: "open", args: ["-a", app, "--args"] };
2862
+ }
2863
+ return null;
2864
+ }
2865
+ const applications = join(home, "Applications");
2866
+ let found = [];
2395
2867
  try {
2396
- const sealed = sealProjectArt(await readProjectArt(boundary, artDirectory));
2397
- await boundary.writeFileReplacing(join(artDirectory, CONTAINER_FILE), sealed.container);
2398
- const pictures = `${sealed.pictures} ${sealed.pictures === 1 ? "picture" : "pictures"}`;
2399
- const described = `${sealed.described} ${sealed.described === 1 ? "asset" : "assets"} described`;
2400
- return [{ step: PROJECT_SEAL_STEP, code: 0, output: `sealed ${pictures} into ${shown(artDirectory, CONTAINER_FILE)}, ${described}` }];
2401
- } catch (error) {
2402
- if (!(error instanceof ProjectError)) throw error;
2403
- return [{ step: PROJECT_SEAL_STEP, code: 1, output: `the container was not rebuilt: ${error.message}` }];
2868
+ found = (await readdir(applications)).filter((name) => /^PixelVibing.*\.AppImage$/i.test(name)).sort();
2869
+ } catch {
2870
+ return null;
2404
2871
  }
2872
+ const newest = found.at(-1);
2873
+ return newest === void 0 ? null : { command: join(applications, newest), args: [] };
2405
2874
  }
2875
+ const editor = {
2876
+ name: "editor",
2877
+ summary: "Open the PixelVibing desktop app on this local project",
2878
+ run: async (context) => {
2879
+ const local = await projectHere(context.io.cwd);
2880
+ const { platform, home } = context.services;
2881
+ const found = await findEditor(platform, context.io.env, home);
2882
+ if (found === null) {
2883
+ throw new CliError(`the PixelVibing desktop app is not installed here: download it from ${DOWNLOAD_URL}, or set ${EDITOR_VARIABLE} to where it is`);
2884
+ }
2885
+ await context.services.launch(found.command, [...found.args, "--project", local.root]);
2886
+ answer(context, { opened: local.root, app: found.command }, `Opening ${local.root} in PixelVibing.`);
2887
+ }
2888
+ };
2406
2889
  const DRAW_LAYERS = ["ground", "object", "foreground"];
2407
2890
  function bandOfAsset(facts) {
2408
2891
  if (facts.name === null) return "ground";
@@ -3113,10 +3596,12 @@ async function writeWhole(out, files, engine) {
3113
3596
  const exportCommand = {
3114
3597
  name: "export",
3115
3598
  summary: "Export the local project's art and map for an engine: default, pixi, phaser or godot",
3116
- usage: "<default|pixi|phaser|godot> [--out <dir>] [--godot-root <res://…>]",
3599
+ usage: "<default|pixi|phaser|godot> [--out <dir>] [--godot-root <res://…>] [--offline] [--aside]",
3117
3600
  options: {
3118
3601
  out: { type: "string", value: "<dir>", description: "where to write it; pixelvibing/export/<engine>/ unless given" },
3119
- "godot-root": { type: "string", value: "<res://…>", description: `the Godot project folder the files go in, ${DEFAULT_GODOT_ROOT} unless given` }
3602
+ "godot-root": { type: "string", value: "<res://…>", description: `the Godot project folder the files go in, ${DEFAULT_GODOT_ROOT} unless given` },
3603
+ offline: { type: "boolean", description: "read the folder as it is, without bringing it to the platform's state first" },
3604
+ aside: { type: "boolean", description: "keep a folder's own map and art under pixelvibing/before-platform/, and write it from the platform" }
3120
3605
  },
3121
3606
  positionals: [1, 1],
3122
3607
  run: async (context, args) => {
@@ -3127,6 +3612,7 @@ const exportCommand = {
3127
3612
  throw new CliError("--godot-root is a res:// path of letters, digits and . _ - /, with no ..");
3128
3613
  }
3129
3614
  const local = await projectHere(context.io.cwd);
3615
+ if (!flagOf(args, "offline")) await bringFolder(await signedIn(context), local, { map: true, aside: flagOf(args, "aside") });
3130
3616
  const art = artOf(local);
3131
3617
  const out = resolve(context.io.cwd, stringOf(args, "out") ?? join(art, "export", engine));
3132
3618
  if (!await replaceable(out)) throw new CliError(`${out} holds files that are not an export's: nothing was written. Name an empty directory with --out`);
@@ -3616,7 +4102,8 @@ const generate = {
3616
4102
  reference: { type: "string", multiple: true, value: "<png>", description: "an image to draw from" },
3617
4103
  quality: { type: "string", value: "<quality>", description: `${EDIT_QUALITIES.join(", ")}; ${DEFAULT_QUALITY} unless given` },
3618
4104
  yes: { type: "boolean", short: "y", description: "start it without asking, once the cost is said" },
3619
- as: { type: "string", value: "<name>", description: "pull it under this name instead of the platform's" }
4105
+ as: { type: "string", value: "<name>", description: "land it under this name instead of the platform's" },
4106
+ aside: { type: "boolean", description: "keep a folder's own map and art under pixelvibing/before-platform/, and write it from the platform" }
3620
4107
  },
3621
4108
  positionals: [1, 1],
3622
4109
  run: async (context, args) => {
@@ -3624,6 +4111,7 @@ const generate = {
3624
4111
  const project = linkedOf(local);
3625
4112
  const kind = args.positionals[0];
3626
4113
  const request = await requestOf(context, kind, args);
4114
+ await checkFolder(local, flagOf(args, "aside"));
3627
4115
  const platform = await signedIn(context);
3628
4116
  const cost = costOf(await platform.web.prices(), request);
3629
4117
  await confirmed(context, platform, request.words, cost, flagOf(args, "yes"));
@@ -3632,7 +4120,7 @@ const generate = {
3632
4120
  `);
3633
4121
  const job = await followed(context, platform, jobId);
3634
4122
  if (job.state === "failed") throw new CliError(`the generation failed: ${job.reason ?? "the platform gave no reason"}`);
3635
- const pulled = await pullAsset(platform, local, jobId, stringOf(args, "as"));
4123
+ const pulled = await pullAsset(platform, local, jobId, stringOf(args, "as"), { aside: flagOf(args, "aside") });
3636
4124
  answer(context, { job: jobId, cost, ...pulled }, pulledWords(pulled));
3637
4125
  }
3638
4126
  };
@@ -3940,15 +4428,156 @@ const link = {
3940
4428
  const seal = {
3941
4429
  name: "seal",
3942
4430
  summary: "Seal the local project's art into its container, as the editor does",
3943
- run: async (context) => {
4431
+ usage: "[--offline] [--aside]",
4432
+ options: {
4433
+ offline: { type: "boolean", description: "read the folder as it is, without bringing it to the platform's state first" },
4434
+ aside: { type: "boolean", description: "keep a folder's own map and art under pixelvibing/before-platform/, and write it from the platform" }
4435
+ },
4436
+ run: async (context, args) => {
3944
4437
  const local = await projectHere(context.io.cwd);
4438
+ if (!flagOf(args, "offline")) await bringFolder(await signedIn(context), local, { map: false, aside: flagOf(args, "aside") });
3945
4439
  const art = artOf(local);
3946
4440
  const [result] = await sealProject(new Boundary([art]), art);
3947
4441
  if (result === void 0 || result.code !== 0) throw new CliError(result?.output ?? "the container was not rebuilt");
3948
4442
  answer(context, { sealed: true, output: result.output }, result.output);
3949
4443
  }
3950
4444
  };
3951
- const version = "0.10.0";
4445
+ const SECTORS_A_WRITE = 64;
4446
+ const BYTES_A_WRITE = 11 * 1024 * 1024;
4447
+ const MOST_WAIT_SECONDS = 30 * 60;
4448
+ class MoveRefused extends Error {
4449
+ // Assigned in the body: Node's strip-only TypeScript mode rejects parameter
4450
+ // properties (docs/notes.md n-0023).
4451
+ constructor(message) {
4452
+ super(message);
4453
+ this.name = "MoveRefused";
4454
+ }
4455
+ }
4456
+ function toBase64(bytes) {
4457
+ let binary = "";
4458
+ for (let at = 0; at < bytes.length; at += 32768) binary += String.fromCharCode(...bytes.subarray(at, at + 32768));
4459
+ return btoa(binary);
4460
+ }
4461
+ function marksAny(outline) {
4462
+ const bits = atob(outline);
4463
+ for (let at = 0; at < bits.length; at += 1) if (bits.charCodeAt(at) !== 0) return true;
4464
+ return false;
4465
+ }
4466
+ async function platformIsEmpty(deps) {
4467
+ const base = `/projects/${encodeURIComponent(deps.project)}`;
4468
+ const outline = await (await deps.call(`${base}/map`)).json();
4469
+ if (outline.levels.some((level) => marksAny(level.sectors))) return false;
4470
+ const art = await (await deps.call(`${base}/art`)).json();
4471
+ if (art.assets.length > 0) return false;
4472
+ const references = await (await deps.call(`${base}/art/references`)).json();
4473
+ return references.length === 0;
4474
+ }
4475
+ async function moveFolder(deps) {
4476
+ const base = `/projects/${encodeURIComponent(deps.project)}`;
4477
+ const call = (path, init2) => patiently(deps, async () => (await deps.call(path, init2)).json(), MOST_WAIT_SECONDS);
4478
+ const folder = await deps.mirror.folder();
4479
+ const pictured = new Set(folder.pictures.map((picture) => picture.name));
4480
+ const drawn = folder.assets.filter((asset) => pictured.has(asset.name));
4481
+ const named = new Set(drawn.map((asset) => asset.name));
4482
+ const idMap = Object.fromEntries(Object.entries(folder.idMap).filter(([, name]) => named.has(name)));
4483
+ try {
4484
+ sealProjectArt({
4485
+ pictures: folder.pictures,
4486
+ entries: drawn.map((asset) => ({ name: asset.name, meta: asset.entry.meta })),
4487
+ idMap: Object.keys(idMap).length === 0 ? null : JSON.stringify({ looktypes: idMap })
4488
+ });
4489
+ } catch (error) {
4490
+ throw new MoveRefused(
4491
+ `this folder's art cannot be read as the editor reads it, and nothing was sent: ${error instanceof Error ? error.message : String(error)}`
4492
+ );
4493
+ }
4494
+ let map;
4495
+ try {
4496
+ map = folder.map === null ? null : parseMapText(folder.map, "pixelvibing/map.json").document;
4497
+ } catch (error) {
4498
+ throw new MoveRefused(`this folder's map cannot be read, and nothing was sent: ${error instanceof Error ? error.message : String(error)}`);
4499
+ }
4500
+ const outline = await call(`${base}/map`);
4501
+ if (map !== null && (map.width > outline.limit.width || map.height > outline.limit.height)) {
4502
+ throw new MoveRefused(
4503
+ `this folder's map is ${map.width} × ${map.height} tiles, and the project's map grows to at most ${outline.limit.width} × ${outline.limit.height}: nothing was sent`
4504
+ );
4505
+ }
4506
+ const sectors = map === null ? [] : sectorsOfMap(map);
4507
+ await deps.mirror.moving(deps.project);
4508
+ const keep2 = (bytes2) => call("/images", { method: "POST", bytes: { data: bytes2, type: "image/png" } }).then((kept) => kept.id);
4509
+ const pictures = {};
4510
+ for (const [index, picture] of folder.pictures.entries()) {
4511
+ deps.progress?.(`sending the pictures: ${index + 1} of ${folder.pictures.length}`);
4512
+ pictures[picture.name] = await keep2(picture.bytes);
4513
+ }
4514
+ const references = [];
4515
+ for (const reference of folder.references) references.push({ name: reference.name, image: await keep2(reference.bytes) });
4516
+ const assets = drawn.map((asset) => ({ ...asset, image: pictures[asset.name] }));
4517
+ const art = await call(`${base}/art`);
4518
+ deps.progress?.("sending the art");
4519
+ await call(`${base}/art/import`, { method: "POST", body: { revision: art.revision, assets, pictures, idMap, references } });
4520
+ let revision = outline.revision;
4521
+ if (map !== null && (map.width > outline.size.width || map.height > outline.size.height)) {
4522
+ const size = { width: Math.max(map.width, outline.size.width), height: Math.max(map.height, outline.size.height) };
4523
+ revision = (await call(`${base}/map/size`, { method: "PUT", body: { revision, ...size } })).revision;
4524
+ }
4525
+ let batch = [];
4526
+ let bytes = 0;
4527
+ let sent = 0;
4528
+ const send = async () => {
4529
+ if (batch.length === 0) return;
4530
+ revision = (await call(`${base}/map/sectors`, { method: "PUT", body: { revision, sectors: batch } })).revision;
4531
+ sent += batch.length;
4532
+ deps.progress?.(`sending the map: ${sent} of ${sectors.length} parts`);
4533
+ batch = [];
4534
+ bytes = 0;
4535
+ };
4536
+ for (const sector of sectors) {
4537
+ const data = toBase64(await deps.zlib.deflate(encodeSector(sector.cells)));
4538
+ if (batch.length >= SECTORS_A_WRITE || bytes + data.length > BYTES_A_WRITE) await send();
4539
+ batch.push({ z: sector.z, cx: sector.cx, cy: sector.cy, data });
4540
+ bytes += data.length;
4541
+ }
4542
+ await send();
4543
+ await deps.mirror.moving(null);
4544
+ }
4545
+ const move = {
4546
+ name: "move",
4547
+ summary: "Move this folder's own map and art onto its platform project, once, when that project holds none",
4548
+ run: async (context) => {
4549
+ const local = await projectHere(context.io.cwd);
4550
+ const project = linkedOf(local);
4551
+ const platform = await signedIn(context);
4552
+ const mirror = mirrorOf(local);
4553
+ const state = await mirror.state();
4554
+ const again = state.moving === project;
4555
+ if (!again) {
4556
+ if (state.mirrored || !(state.own.map || state.own.art)) {
4557
+ throw new CliError("this folder holds no map or art of its own to move: it is written from its platform project already");
4558
+ }
4559
+ if (!await platformIsEmpty({ project, call: platform.call })) {
4560
+ throw new CliError(
4561
+ "the platform project already holds a map, art or a reference, and a folder is moved only onto one holding none: nothing was sent. Pass --aside to a pull, a seal or an export to keep this folder's own files aside instead"
4562
+ );
4563
+ }
4564
+ }
4565
+ try {
4566
+ await moveFolder({ project, call: platform.call, mirror, zlib: nodeZlib, progress: (words) => context.io.warn(`${words}
4567
+ `) });
4568
+ } catch (error) {
4569
+ if (error instanceof MoveRefused) throw new CliError(error.message);
4570
+ throw new CliError(`the move stopped, and is sent whole again by \`pixel-vibing-cli move\`: ${error instanceof Error ? error.message : String(error)}`);
4571
+ }
4572
+ await bringFolder(platform, local, { map: true, aside: true });
4573
+ answer(
4574
+ context,
4575
+ { moved: true, project },
4576
+ `Moved this folder onto its platform project${again ? ", sent whole again" : ""}: its own files are kept under pixelvibing/before-platform/, and the folder is written from the platform.`
4577
+ );
4578
+ }
4579
+ };
4580
+ const version = "0.11.0";
3952
4581
  const manifest = {
3953
4582
  version
3954
4583
  };
@@ -4395,6 +5024,7 @@ const COMMANDS = [
4395
5024
  whoami,
4396
5025
  init,
4397
5026
  link,
5027
+ move,
4398
5028
  generate,
4399
5029
  list,
4400
5030
  pull,