@pixelvibing/cli 0.9.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 { 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,206 +1045,362 @@ 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);
1048
+ const SECTOR_SIDE$1 = 128;
1049
+ const SECTOR_CELLS = SECTOR_SIDE$1 * SECTOR_SIDE$1;
1050
+ const SECTOR_BYTES = 512 * 1024;
1051
+ const MAP_LEVELS = { first: 0, last: 14 };
1052
+ const LARGEST_MAP_SIDE = 1e4;
1053
+ const OUTLINE_ACROSS = Math.ceil(LARGEST_MAP_SIDE / SECTOR_SIDE$1);
1054
+ const SECTOR_FORMAT = 1;
1055
+ const MAGIC = [80, 86, 67];
1056
+ const PRESENT = 1;
1057
+ const AUDIO = 2;
1058
+ const INTERACT = 4;
1059
+ const EXTRA = 8;
1060
+ const U16 = 65535;
1061
+ const MAX_LAYERS = 255;
1062
+ const NAMED$1 = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"]);
1063
+ class SectorError extends Error {
1064
+ constructor(message) {
1065
+ super(message);
1066
+ this.name = "SectorError";
1067
+ }
1111
1068
  }
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) };
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;
1116
1072
  }
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;
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
+ }
1131
1097
  }
1098
+ return Uint8Array.from(bytes);
1132
1099
  }
1133
- async function writeRecord(local, record) {
1134
- await artBoundary(local).writeFileReplacing(recordPath(local), writePulled(record));
1100
+ function decodeSector(bytes) {
1101
+ if (bytes.length < 4 || MAGIC.some((byte, index) => bytes[index] !== byte)) throw new SectorError("these bytes are not a map sector");
1102
+ if (bytes[3] !== SECTOR_FORMAT) throw new SectorError(`a map sector of format ${bytes[3]} is not one this version reads`);
1103
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1104
+ let at = 4;
1105
+ const need = (count) => {
1106
+ if (at + count > bytes.length) throw new SectorError("the sector's bytes end before its last cell");
1107
+ };
1108
+ const read8 = () => {
1109
+ need(1);
1110
+ return bytes[at++];
1111
+ };
1112
+ const read16 = () => {
1113
+ need(2);
1114
+ const value = bytes[at] | bytes[at + 1] << 8;
1115
+ at += 2;
1116
+ return value;
1117
+ };
1118
+ const cells = [];
1119
+ for (let index = 0; index < SECTOR_CELLS; index += 1) {
1120
+ const flags = read8();
1121
+ if ((flags & PRESENT) === 0) {
1122
+ cells.push(void 0);
1123
+ continue;
1124
+ }
1125
+ const count = read8();
1126
+ const layers = [];
1127
+ for (let layer = 0; layer < count; layer += 1) layers.push(read16());
1128
+ const blocking = read16();
1129
+ const zone = read16();
1130
+ const audioZone = (flags & AUDIO) === 0 ? void 0 : read16();
1131
+ const interact = (flags & INTERACT) === 0 ? void 0 : read16();
1132
+ let extra;
1133
+ if ((flags & EXTRA) !== 0) {
1134
+ const length = read16();
1135
+ need(length);
1136
+ let parsed2;
1137
+ try {
1138
+ parsed2 = JSON.parse(decoder.decode(bytes.subarray(at, at + length)));
1139
+ } catch {
1140
+ throw new SectorError("a tile's extra fields are not JSON");
1141
+ }
1142
+ if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new SectorError("a tile's extra fields are not an object");
1143
+ const named = Object.keys(parsed2).find((key) => NAMED$1.has(key));
1144
+ if (named !== void 0) throw new SectorError(`a tile's extra fields name ${named}, which the sector holds itself`);
1145
+ extra = parsed2;
1146
+ at += length;
1147
+ }
1148
+ cells.push({
1149
+ layers,
1150
+ blocking,
1151
+ zone,
1152
+ ...audioZone === void 0 ? {} : { audioZone },
1153
+ ...interact === void 0 ? {} : { interact },
1154
+ ...extra === void 0 ? {} : { extra }
1155
+ });
1156
+ }
1157
+ if (at !== bytes.length) throw new SectorError("the sector's bytes go on past its last cell");
1158
+ return cells;
1135
1159
  }
1136
- const INTERFACE_MANIFEST = "manifest/interface.json";
1137
- function setPieces(asset) {
1138
- return setPiecesOf(asset.kind, asset.images.length, asset.data);
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
+ };
1139
1171
  }
1140
- function pieceFiles(name, pieces) {
1141
- return pieceNames(name, pieces).map((piece) => `out/${piece}.png`);
1172
+ function tileOfCell(cell, x, y, z) {
1173
+ return {
1174
+ ...cell.extra ?? {},
1175
+ x,
1176
+ y,
1177
+ z,
1178
+ layers: [...cell.layers],
1179
+ blocking: cell.blocking,
1180
+ zone: cell.zone,
1181
+ ...cell.audioZone === void 0 ? {} : { audioZone: cell.audioZone },
1182
+ ...cell.interact === void 0 ? {} : { interact: cell.interact }
1183
+ };
1142
1184
  }
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`);
1185
+ const MIRROR_MAP_DIRECTORY = "map";
1186
+ const MIRROR_INDEX_FILE = `${MIRROR_MAP_DIRECTORY}/index.json`;
1187
+ const MIRROR_FORMAT = 1;
1188
+ class MirrorError extends Error {
1189
+ // Assigned in the body: Node's strip-only TypeScript mode rejects parameter
1190
+ // properties (docs/notes.md n-0023).
1191
+ constructor(message) {
1192
+ super(message);
1193
+ this.name = "MirrorError";
1194
+ }
1146
1195
  }
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;
1196
+ function mirrorSectorPath(z, cx, cy) {
1197
+ if (!isPlace(z, cx, cy)) throw new MirrorError(`no sector of a map is at level ${z}, column ${cx}, row ${cy}`);
1198
+ return `${MIRROR_MAP_DIRECTORY}/${z}/${cx}-${cy}.bin`;
1152
1199
  }
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 };
1200
+ function isPlace(z, cx, cy) {
1201
+ const within = (value, last) => Number.isInteger(value) && value >= 0 && value <= last;
1202
+ return within(z, MAP_LEVELS.last) && z >= MAP_LEVELS.first && within(cx, OUTLINE_ACROSS - 1) && within(cy, OUTLINE_ACROSS - 1);
1203
+ }
1204
+ function isSize(value) {
1205
+ const side = (n) => Number.isInteger(n) && n >= 1 && n <= LARGEST_MAP_SIDE;
1206
+ return typeof value === "object" && value !== null && side(value.width) && side(value.height);
1207
+ }
1208
+ function mirrorIndexText(index) {
1209
+ return `${JSON.stringify(index, null, 2)}
1210
+ `;
1211
+ }
1212
+ function parseMirrorIndex(text2) {
1213
+ let value;
1214
+ try {
1215
+ value = JSON.parse(text2);
1216
+ } catch {
1217
+ throw new MirrorError(`${MIRROR_INDEX_FILE} is not JSON`);
1218
+ }
1219
+ const index = value;
1220
+ if (typeof index !== "object" || index === null) throw new MirrorError(`${MIRROR_INDEX_FILE} is not an object`);
1221
+ if (index.format !== MIRROR_FORMAT)
1222
+ throw new MirrorError(`${MIRROR_INDEX_FILE} is in format ${String(index.format)}, and this reads format ${MIRROR_FORMAT}`);
1223
+ if (!Number.isInteger(index.revision) || index.revision < 0) throw new MirrorError(`${MIRROR_INDEX_FILE} names no revision`);
1224
+ if (!isSize(index.size) || !isSize(index.limit)) throw new MirrorError(`${MIRROR_INDEX_FILE} names no size or limit a map may have`);
1225
+ if (!Array.isArray(index.names) || !Array.isArray(index.sectors)) throw new MirrorError(`${MIRROR_INDEX_FILE} lists no levels or sectors`);
1226
+ for (const named of index.names) {
1227
+ if (typeof named?.name !== "string" || !isPlace(named.z, 0, 0)) throw new MirrorError(`${MIRROR_INDEX_FILE} names a level that is none`);
1168
1228
  }
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
- );
1229
+ const seen = /* @__PURE__ */ new Set();
1230
+ for (const sector of index.sectors) {
1231
+ const [z, cx, cy, version2] = Array.isArray(sector) ? sector : [];
1232
+ if (!isPlace(z, cx, cy) || !Number.isInteger(version2) || version2 < 1) {
1233
+ throw new MirrorError(`${MIRROR_INDEX_FILE} lists a sector that is none: ${JSON.stringify(sector)}`);
1175
1234
  }
1235
+ const key = `${z}:${cx}:${cy}`;
1236
+ if (cx * SECTOR_SIDE$1 >= index.size.width || cy * SECTOR_SIDE$1 >= index.size.height) {
1237
+ throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key}, outside the map's size`);
1238
+ }
1239
+ if (seen.has(key)) throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key} twice`);
1240
+ seen.add(key);
1176
1241
  }
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 };
1242
+ return index;
1192
1243
  }
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)) {
1244
+ async function readMirroredMap(index, read, inflate2) {
1245
+ const tiles = [];
1246
+ for (const [z, cx, cy] of index.sectors) {
1247
+ const path = mirrorSectorPath(z, cx, cy);
1248
+ let cells;
1197
1249
  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");
1250
+ cells = decodeSector(await inflate2(await read(path)));
1201
1251
  } catch (error) {
1202
- throw new CliError(`pixelvibing/${INTERFACE_MANIFEST} is not a manifest (${error.message}): the pieces were written, and not described`);
1252
+ throw new MirrorError(`${path} is not a sector the map can read: ${error instanceof Error ? error.message : String(error)}`);
1253
+ }
1254
+ for (const [at, cell] of cells.entries()) {
1255
+ if (cell === void 0) continue;
1256
+ const x = cx * SECTOR_SIDE$1 + at % SECTOR_SIDE$1;
1257
+ const y = cy * SECTOR_SIDE$1 + Math.floor(at / SECTOR_SIDE$1);
1258
+ if (x >= index.size.width || y >= index.size.height) continue;
1259
+ tiles.push(tileOfCell(cell, x, y, z));
1203
1260
  }
1204
1261
  }
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
- `));
1262
+ tiles.sort((a, b) => a.z - b.z || a.y - b.y || a.x - b.x);
1263
+ return { width: index.size.width, height: index.size.height, tiles };
1212
1264
  }
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");
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);
1223
1283
  }
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}`);
1284
+ class MapTextError extends Error {
1285
+ // Assigned in the body: the suites run under Node's strip-only TypeScript
1286
+ // mode, which rejects parameter properties (docs/notes.md n-0023).
1287
+ constructor(message) {
1288
+ super(message);
1289
+ this.name = "MapTextError";
1239
1290
  }
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));
1291
+ }
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
+ }
1253
1326
  }
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) {
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;
1258
1336
  try {
1259
- await stat(path);
1260
- return true;
1261
- } catch {
1262
- return false;
1337
+ parsed2 = JSON.parse(text2);
1338
+ } catch (error) {
1339
+ throw new MapTextError(`${path} is not valid JSON: ${error.message}`);
1263
1340
  }
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: [] };
1341
+ if (typeof parsed2 !== "object" || parsed2 === null || !Array.isArray(parsed2.tiles)) {
1342
+ throw new MapTextError(`${path} holds no tiles array`);
1270
1343
  }
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;
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`);
1277
1348
  }
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;
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];
1283
1361
  }
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;
1362
+ for (const key of Object.keys(tile)) {
1363
+ if (!(key in ordered) && tile[key] !== void 0) ordered[key] = tile[key];
1290
1364
  }
1291
- const newest = found.at(-1);
1292
- return newest === void 0 ? null : { command: join(applications, newest), args: [] };
1365
+ return JSON.stringify(ordered);
1293
1366
  }
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`);
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;
1303
1374
  }
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
1375
  }
1307
- };
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;
1308
1404
  const TILE_SIZE = 256;
1309
1405
  const EAI_MAGIC = 1229018181;
1310
1406
  const EAI_VERSION = 1;
@@ -1559,7 +1655,7 @@ class AssetMetadataError extends Error {
1559
1655
  this.name = "AssetMetadataError";
1560
1656
  }
1561
1657
  }
1562
- function isRecord$1(value) {
1658
+ function isRecord$2(value) {
1563
1659
  return typeof value === "object" && value !== null && !Array.isArray(value);
1564
1660
  }
1565
1661
  const MAX_ANIMATION_FRAMES = 64;
@@ -1571,7 +1667,7 @@ function readFrames(value, asset, animation) {
1571
1667
  throw new AssetMetadataError(`${asset}: animation ${animation} has ${value.length} frames, and an animation holds at most ${MAX_ANIMATION_FRAMES}`);
1572
1668
  }
1573
1669
  return value.map((raw, index) => {
1574
- if (!isRecord$1(raw)) {
1670
+ if (!isRecord$2(raw)) {
1575
1671
  throw new AssetMetadataError(`${asset}: animation ${animation} frame ${index} is not a frame`);
1576
1672
  }
1577
1673
  const name = raw.asset;
@@ -1590,7 +1686,7 @@ function readFrames(value, asset, animation) {
1590
1686
  function readAnimations(value, asset, kind) {
1591
1687
  const animations = /* @__PURE__ */ new Map();
1592
1688
  if (value === void 0) return animations;
1593
- if (!isRecord$1(value)) {
1689
+ if (!isRecord$2(value)) {
1594
1690
  throw new AssetMetadataError(`${asset}: animations is not a map of name to frames`);
1595
1691
  }
1596
1692
  for (const [name, frames] of Object.entries(value)) {
@@ -1607,10 +1703,10 @@ function readAnimations(value, asset, kind) {
1607
1703
  function readPlays(value, asset, animations, kind) {
1608
1704
  const plays = /* @__PURE__ */ new Map();
1609
1705
  if (value === void 0) return plays;
1610
- 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`);
1611
1707
  for (const [name, play] of Object.entries(value)) {
1612
1708
  if (!animations.has(name)) throw new AssetMetadataError(`${asset}: plays names ${name}, and there is no such animation`);
1613
- 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`);
1614
1710
  if (kind === "outfit" && play.mode !== "loop")
1615
1711
  throw new AssetMetadataError(`${asset}: ${name} is an outfit's animation, which plays as the game drives it, in a loop`);
1616
1712
  if (play.mode === "loop" || play.mode === "change") {
@@ -1633,7 +1729,7 @@ function readPlays(value, asset, animations, kind) {
1633
1729
  }
1634
1730
  function readNineGrid(value, asset) {
1635
1731
  if (value === void 0) return void 0;
1636
- if (!isRecord$1(value)) {
1732
+ if (!isRecord$2(value)) {
1637
1733
  throw new AssetMetadataError(`${asset}: nineGrid is not an object of four insets`);
1638
1734
  }
1639
1735
  const insets = {};
@@ -1658,7 +1754,7 @@ function readScale(value, asset) {
1658
1754
  }
1659
1755
  function readCollide(value, asset) {
1660
1756
  if (value === void 0) return void 0;
1661
- if (!isRecord$1(value)) {
1757
+ if (!isRecord$2(value)) {
1662
1758
  throw new AssetMetadataError(`${asset}: collide is not a box of x, y, w and h in cells`);
1663
1759
  }
1664
1760
  const box = {};
@@ -1679,7 +1775,7 @@ const MIN_DRAW_SCALE = 0.05;
1679
1775
  const MAX_DRAW_SCALE = 8;
1680
1776
  function readOffset(value, asset) {
1681
1777
  if (value === void 0) return void 0;
1682
- 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)) {
1683
1779
  throw new AssetMetadataError(`${asset}: offset is not an x and a y in pixels`);
1684
1780
  }
1685
1781
  for (const axis of ["x", "y"]) {
@@ -1701,7 +1797,7 @@ function readDrawScale(value, asset) {
1701
1797
  return value;
1702
1798
  }
1703
1799
  function readEntry(value, asset) {
1704
- if (!isRecord$1(value)) {
1800
+ if (!isRecord$2(value)) {
1705
1801
  throw new AssetMetadataError(`${asset}: not an entry`);
1706
1802
  }
1707
1803
  const kind = value.kind;
@@ -1746,7 +1842,7 @@ function parseAssetMetadata(text2) {
1746
1842
  } catch (error) {
1747
1843
  throw new AssetMetadataError(`not JSON: ${error.message}`);
1748
1844
  }
1749
- if (!isRecord$1(parsed2)) {
1845
+ if (!isRecord$2(parsed2)) {
1750
1846
  throw new AssetMetadataError("not an object");
1751
1847
  }
1752
1848
  const version2 = parsed2.version;
@@ -1754,7 +1850,7 @@ function parseAssetMetadata(text2) {
1754
1850
  throw new AssetMetadataError(`unsupported version ${String(version2)}, reader speaks v${METADATA_VERSION}`);
1755
1851
  }
1756
1852
  const rawAssets = parsed2.assets;
1757
- if (rawAssets !== void 0 && !isRecord$1(rawAssets)) {
1853
+ if (rawAssets !== void 0 && !isRecord$2(rawAssets)) {
1758
1854
  throw new AssetMetadataError("assets is not a map of name to entry");
1759
1855
  }
1760
1856
  const assets = /* @__PURE__ */ new Map();
@@ -1949,117 +2045,67 @@ function placeTileSprite(tx, ty, width, height, options = {}) {
1949
2045
  height: scaledHeight
1950
2046
  };
1951
2047
  }
1952
- class MapTextError extends Error {
1953
- // Assigned in the body: the suites run under Node's strip-only TypeScript
1954
- // mode, which rejects parameter properties (docs/notes.md n-0023).
1955
- constructor(message) {
1956
- super(message);
1957
- this.name = "MapTextError";
1958
- }
1959
- }
1960
- function tileKey(x, y, z) {
1961
- return `${x}:${y}:${z}`;
1962
- }
1963
- function scanTileSources(text2) {
1964
- const key = '"tiles"';
1965
- let at = text2.indexOf(key);
1966
- if (at === -1) return [];
1967
- at = text2.indexOf("[", at + key.length);
1968
- if (at === -1) return [];
1969
- const sources = [];
1970
- let depth = 0;
1971
- let inString = false;
1972
- let start = -1;
1973
- for (let index = at + 1; index < text2.length; index += 1) {
1974
- const char = text2[index];
1975
- if (inString) {
1976
- if (char === "\\") index += 1;
1977
- else if (char === '"') inString = false;
1978
- continue;
1979
- }
1980
- if (char === '"') {
1981
- inString = true;
1982
- } else if (char === "{") {
1983
- if (depth === 0) start = index;
1984
- depth += 1;
1985
- } else if (char === "}") {
1986
- depth -= 1;
1987
- if (depth === 0 && start !== -1) {
1988
- sources.push(text2.slice(start, index + 1));
1989
- start = -1;
1990
- }
1991
- } else if (char === "]" && depth === 0) {
1992
- break;
1993
- }
1994
- }
1995
- return sources;
1996
- }
1997
- function isTile(value) {
1998
- if (typeof value !== "object" || value === null) return false;
1999
- const record = value;
2000
- return typeof record.x === "number" && typeof record.y === "number" && typeof record.z === "number" && Array.isArray(record.layers);
2001
- }
2002
- function parseMapText(text2, path = "the map") {
2003
- let parsed2;
2004
- try {
2005
- parsed2 = JSON.parse(text2);
2006
- } catch (error) {
2007
- throw new MapTextError(`${path} is not valid JSON: ${error.message}`);
2008
- }
2009
- if (typeof parsed2 !== "object" || parsed2 === null || !Array.isArray(parsed2.tiles)) {
2010
- throw new MapTextError(`${path} holds no tiles array`);
2011
- }
2012
- const document = parsed2;
2013
- const sources = scanTileSources(text2);
2014
- if (sources.length !== document.tiles.length) {
2015
- throw new MapTextError(`${path}: the scanner found ${sources.length} tile sources for ${document.tiles.length} tiles`);
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;
2016
2054
  }
2017
- const byKey = /* @__PURE__ */ new Map();
2018
- document.tiles.forEach((tile, index) => {
2019
- if (!isTile(tile)) throw new MapTextError(`${path}: tile ${index} lacks x, y, z or layers`);
2020
- byKey.set(tileKey(tile.x, tile.y, tile.z), { tile, source: sources[index] });
2021
- });
2022
- return { document, sources: byKey };
2055
+ return null;
2023
2056
  }
2024
- const TILE_KEYS = ["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"];
2025
- function serializeTile(tile) {
2026
- const ordered = {};
2027
- for (const key of TILE_KEYS) {
2028
- if (tile[key] !== void 0) ordered[key] = tile[key];
2029
- }
2030
- for (const key of Object.keys(tile)) {
2031
- if (!(key in ordered) && tile[key] !== void 0) ordered[key] = tile[key];
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).
2066
+ constructor(message) {
2067
+ super(message);
2068
+ this.name = "ArtError";
2032
2069
  }
2033
- return JSON.stringify(ordered);
2034
2070
  }
2035
- function formatMap(next, previous) {
2036
- const lines = [];
2037
- let changed = 0;
2038
- for (const tile of next.tiles) {
2039
- {
2040
- lines.push(serializeTile(tile));
2041
- changed += 1;
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;
2042
2083
  }
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);
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}/`);
2043
2096
  }
2044
- const rest = {};
2045
- for (const key of Object.keys(next)) {
2046
- if (key !== "width" && key !== "height" && key !== "tiles") rest[key] = next[key];
2047
- }
2048
- const trailer = Object.keys(rest).length === 0 ? "" : `,${JSON.stringify(rest).slice(1, -1)}`;
2049
- const head = `{"width":${JSON.stringify(next.width)},"height":${JSON.stringify(next.height)},"tiles":[`;
2050
- const body = lines.length === 0 ? "" : `
2051
- ${lines.join(",\n")}
2052
- `;
2053
- return { text: `${head}${body}]${trailer}}
2054
- `, changed };
2097
+ return boundary.readBytes(join(artDirectory, REFERENCE_DIRECTORY, name));
2055
2098
  }
2099
+ const MANIFEST_DIRECTORY = "manifest";
2100
+ const OUT_DIRECTORY = "out";
2101
+ const ID_MAP_FILE = "id-map.json";
2056
2102
  const MAX_SIDE$1 = 65535;
2057
2103
  const encoder$1 = new TextEncoder();
2058
- function isRecord(value) {
2104
+ function isRecord$1(value) {
2059
2105
  return typeof value === "object" && value !== null && !Array.isArray(value);
2060
2106
  }
2061
2107
  function metadataOf(entry) {
2062
- const raw = isRecord(entry.meta) && entry.meta.kind === void 0 ? { kind: "tile", ...entry.meta } : entry.meta;
2108
+ const raw = isRecord$1(entry.meta) && entry.meta.kind === void 0 ? { kind: "tile", ...entry.meta } : entry.meta;
2063
2109
  try {
2064
2110
  const parsed2 = parseAssetMetadata(JSON.stringify({ version: METADATA_VERSION, assets: { [entry.name]: raw } }));
2065
2111
  return parsed2.assets.get(entry.name);
@@ -2109,115 +2155,737 @@ function sealProjectArt(art) {
2109
2155
  }
2110
2156
  return { container: sealContainer([...entries, ...pages]), pictures: pictures.length, described: described.size };
2111
2157
  }
2112
- const GROUND_KINDS = /* @__PURE__ */ new Set(["ground", "floor", "wall", "cliff", "interior", "grass-stone"]);
2113
- function isGroundName(name) {
2114
- const { inner } = splitTileset(name);
2115
- const kind = inner.split("/")[1] ?? "";
2116
- 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("/");
2164
+ }
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
+ `;
2117
2710
  }
2118
- const MAX_ASSEMBLED_PX = 4096;
2119
- const WINDOW_PX = { width: 1536, height: 1024 };
2120
- const SCALES = [TILE_SIZE, TILE_SIZE / 2, TILE_SIZE / 4, TILE_SIZE / 8, TILE_SIZE / 16];
2121
- function scaleFor(tilesWide, tilesHigh) {
2122
- if (tilesWide <= 0 || tilesHigh <= 0) return null;
2123
- for (const scale of SCALES) {
2124
- if (tilesWide * scale <= WINDOW_PX.width && tilesHigh * scale <= WINDOW_PX.height) return scale;
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;
2125
2725
  }
2126
- return null;
2127
2726
  }
2128
- ({
2129
- width: Math.floor(WINDOW_PX.width / SCALES.at(-1)),
2130
- height: Math.floor(WINDOW_PX.height / SCALES.at(-1))
2131
- });
2132
- Array.from({ length: 16 }, (_value, index) => index);
2133
- const CONTAINER_FILE = join("build", "assets.eac");
2134
- const MANIFEST_DIRECTORY = "manifest";
2135
- const OUT_DIRECTORY = "out";
2136
- const ID_MAP_FILE = "id-map.json";
2137
- const PROJECT_SEAL_STEP = "project:seal";
2138
- const MAX_PICTURE_BYTES = 64 * 1024 * 1024;
2139
- const MAX_TEXT_BYTES = 16 * 1024 * 1024;
2140
- const MAX_PICTURES = 2e4;
2141
- function shown(artDirectory, relative2) {
2142
- return [basename(artDirectory), ...relative2.split(/[\\/]/)].join("/");
2727
+ async function writeRecord(local, record) {
2728
+ await artBoundary(local).writeFileReplacing(recordPath(local), writePulled(record));
2143
2729
  }
2144
- async function sized(boundary, path, most, what) {
2145
- const { size } = await stat(await boundary.resolve(path));
2146
- 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);
2147
2733
  }
2148
- async function readPictures(boundary, artDirectory) {
2149
- const pictures = [];
2150
- const walk = async (directory, prefix) => {
2151
- let entries;
2152
- try {
2153
- entries = await readdir(await boundary.resolve(directory), { withFileTypes: true });
2154
- } catch {
2155
- return;
2156
- }
2157
- for (const entry of entries) {
2158
- const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
2159
- if (entry.isDirectory()) await walk(join(directory, entry.name), name);
2160
- else if (entry.isFile() && entry.name.endsWith(".png")) {
2161
- if (pictures.length >= MAX_PICTURES) throw new ProjectError(`${shown(artDirectory, OUT_DIRECTORY)} holds more than ${MAX_PICTURES} pictures`);
2162
- const path = join(directory, entry.name);
2163
- await sized(boundary, path, MAX_PICTURE_BYTES, `${shown(artDirectory, OUT_DIRECTORY)}/${name}`);
2164
- pictures.push({ name: name.slice(0, -".png".length), bytes: await boundary.readBytes(path) });
2165
- }
2166
- }
2167
- };
2168
- await walk(join(artDirectory, OUT_DIRECTORY), "");
2169
- return pictures;
2734
+ function namesOf(name, count) {
2735
+ return count === 1 ? [name] : Array.from({ length: count }, (_, index) => `${name}/${index}`);
2170
2736
  }
2171
- async function readEntries(boundary, artDirectory) {
2172
- const directory = join(artDirectory, MANIFEST_DIRECTORY);
2173
- const entries = [];
2174
- for (const file of (await boundary.list(directory)).filter((name) => name.endsWith(".json")).sort()) {
2175
- let manifest2;
2176
- await sized(boundary, join(directory, file), MAX_TEXT_BYTES, `${MANIFEST_DIRECTORY}/${file}`);
2177
- try {
2178
- manifest2 = JSON.parse(await boundary.readText(join(directory, file)));
2179
- } catch (error) {
2180
- 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
+ );
2181
2772
  }
2182
- if (typeof manifest2 !== "object" || manifest2 === null || Array.isArray(manifest2)) throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not an object`);
2183
- for (const [name, entry] of Object.entries(manifest2)) {
2184
- if (name.startsWith("$")) continue;
2185
- 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 } });
2186
2785
  }
2187
2786
  }
2188
- return entries;
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 };
2189
2790
  }
2190
- async function readIdMap(boundary, artDirectory) {
2191
- const path = join(artDirectory, ID_MAP_FILE);
2192
- if (!await boundary.exists(path)) return null;
2193
- await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, ID_MAP_FILE));
2194
- return boundary.readText(path);
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}.`;
2195
2794
  }
2196
- async function readProjectArt(boundary, artDirectory) {
2197
- return {
2198
- pictures: await readPictures(boundary, artDirectory),
2199
- entries: await readEntries(boundary, artDirectory),
2200
- idMap: await readIdMap(boundary, artDirectory)
2201
- };
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");
2202
2801
  }
2203
- async function readProjectMap(boundary, artDirectory) {
2204
- const path = join(artDirectory, "map.json");
2205
- if (!await boundary.exists(path)) return null;
2206
- await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, "map.json"));
2207
- return boundary.readText(path);
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
+ }
2208
2845
  }
2209
- 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 = [];
2210
2867
  try {
2211
- const sealed = sealProjectArt(await readProjectArt(boundary, artDirectory));
2212
- await boundary.writeFileReplacing(join(artDirectory, CONTAINER_FILE), sealed.container);
2213
- const pictures = `${sealed.pictures} ${sealed.pictures === 1 ? "picture" : "pictures"}`;
2214
- const described = `${sealed.described} ${sealed.described === 1 ? "asset" : "assets"} described`;
2215
- return [{ step: PROJECT_SEAL_STEP, code: 0, output: `sealed ${pictures} into ${shown(artDirectory, CONTAINER_FILE)}, ${described}` }];
2216
- } catch (error) {
2217
- if (!(error instanceof ProjectError)) throw error;
2218
- 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;
2219
2871
  }
2872
+ const newest = found.at(-1);
2873
+ return newest === void 0 ? null : { command: join(applications, newest), args: [] };
2220
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
+ };
2221
2889
  const DRAW_LAYERS = ["ground", "object", "foreground"];
2222
2890
  function bandOfAsset(facts) {
2223
2891
  if (facts.name === null) return "ground";
@@ -2740,14 +3408,14 @@ const paeth = (a, b, c) => {
2740
3408
  const pc = Math.abs(p - c);
2741
3409
  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
2742
3410
  };
2743
- function unfilter(inflated, header) {
3411
+ function unfilter(inflated2, header) {
2744
3412
  const bpp = CHANNELS[header.colorType] * (header.depth / 8);
2745
3413
  const stride = header.width * bpp;
2746
3414
  const out = new Uint8Array(header.height * stride);
2747
3415
  for (let y = 0; y < header.height; y += 1) {
2748
- const filter = inflated[y * (stride + 1)];
3416
+ const filter = inflated2[y * (stride + 1)];
2749
3417
  if (filter > 4) throw new PngError(`a row has filter type ${filter}, which PNG does not define`);
2750
- const row = inflated.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
3418
+ const row = inflated2.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
2751
3419
  const at = y * stride;
2752
3420
  for (let x = 0; x < stride; x += 1) {
2753
3421
  const a = x >= bpp ? out[at + x - bpp] : 0;
@@ -2792,14 +3460,14 @@ function decodeWith(zlib, bytes) {
2792
3460
  if (header === null) throw new PngError("the PNG has no IHDR chunk");
2793
3461
  const bpp = CHANNELS[header.colorType] * (header.depth / 8);
2794
3462
  const expected = header.height * (1 + header.width * bpp);
2795
- let inflated;
3463
+ let inflated2;
2796
3464
  try {
2797
- inflated = zlib.inflate(concat(data), expected);
3465
+ inflated2 = zlib.inflate(concat(data), expected);
2798
3466
  } catch {
2799
3467
  throw new PngError("the PNG's image data could not be inflated to the size its header promises");
2800
3468
  }
2801
- if (inflated.length !== expected) throw new PngError("the PNG's image data is not the size its header promises");
2802
- return toRgba(unfilter(inflated, header), header, palette, transparency);
3469
+ if (inflated2.length !== expected) throw new PngError("the PNG's image data is not the size its header promises");
3470
+ return toRgba(unfilter(inflated2, header), header, palette, transparency);
2803
3471
  }
2804
3472
  function toRgba(samples, header, palette, transparency) {
2805
3473
  const { width, height, depth, colorType } = header;
@@ -2928,10 +3596,12 @@ async function writeWhole(out, files, engine) {
2928
3596
  const exportCommand = {
2929
3597
  name: "export",
2930
3598
  summary: "Export the local project's art and map for an engine: default, pixi, phaser or godot",
2931
- usage: "<default|pixi|phaser|godot> [--out <dir>] [--godot-root <res://…>]",
3599
+ usage: "<default|pixi|phaser|godot> [--out <dir>] [--godot-root <res://…>] [--offline] [--aside]",
2932
3600
  options: {
2933
3601
  out: { type: "string", value: "<dir>", description: "where to write it; pixelvibing/export/<engine>/ unless given" },
2934
- "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" }
2935
3605
  },
2936
3606
  positionals: [1, 1],
2937
3607
  run: async (context, args) => {
@@ -2942,6 +3612,7 @@ const exportCommand = {
2942
3612
  throw new CliError("--godot-root is a res:// path of letters, digits and . _ - /, with no ..");
2943
3613
  }
2944
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") });
2945
3616
  const art = artOf(local);
2946
3617
  const out = resolve(context.io.cwd, stringOf(args, "out") ?? join(art, "export", engine));
2947
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`);
@@ -2949,7 +3620,8 @@ const exportCommand = {
2949
3620
  try {
2950
3621
  const boundary = new Boundary([art]);
2951
3622
  const read = await readProjectArt(boundary, art);
2952
- const mapText = await readProjectMap(boundary, art);
3623
+ const mirrored2 = await readMirroredProjectMap(boundary, art);
3624
+ const mapText = mirrored2 === null ? await readProjectMap(boundary, art) : null;
2953
3625
  checkExportable(
2954
3626
  read.pictures.map((picture) => {
2955
3627
  if (!isPng(picture.bytes)) throw new CliError(`the picture ${picture.name} cannot be read: it is not a PNG`);
@@ -2967,7 +3639,7 @@ const exportCommand = {
2967
3639
  {
2968
3640
  pictures,
2969
3641
  entries: read.entries,
2970
- map: mapText === null ? null : parseMapText(mapText, "pixelvibing/map.json").document,
3642
+ map: mirrored2 ?? (mapText === null ? null : parseMapText(mapText, "pixelvibing/map.json").document),
2971
3643
  idMap: read.idMap === null ? null : parseIdMap(JSON.parse(read.idMap))
2972
3644
  },
2973
3645
  engine,
@@ -2975,7 +3647,7 @@ const exportCommand = {
2975
3647
  godotRoot === void 0 ? {} : { godotRoot }
2976
3648
  );
2977
3649
  } catch (error) {
2978
- const refusals = [ProjectError, PackError, PngError, MapTextError, IdMapError, SyntaxError, CliError];
3650
+ const refusals = [ProjectError, PackError, PngError, MapTextError, MirrorError, IdMapError, SyntaxError, CliError];
2979
3651
  if (refusals.some((kind) => error instanceof kind)) throw new CliError(`nothing was exported: ${error.message}`);
2980
3652
  throw error;
2981
3653
  }
@@ -3430,7 +4102,8 @@ const generate = {
3430
4102
  reference: { type: "string", multiple: true, value: "<png>", description: "an image to draw from" },
3431
4103
  quality: { type: "string", value: "<quality>", description: `${EDIT_QUALITIES.join(", ")}; ${DEFAULT_QUALITY} unless given` },
3432
4104
  yes: { type: "boolean", short: "y", description: "start it without asking, once the cost is said" },
3433
- 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" }
3434
4107
  },
3435
4108
  positionals: [1, 1],
3436
4109
  run: async (context, args) => {
@@ -3438,6 +4111,7 @@ const generate = {
3438
4111
  const project = linkedOf(local);
3439
4112
  const kind = args.positionals[0];
3440
4113
  const request = await requestOf(context, kind, args);
4114
+ await checkFolder(local, flagOf(args, "aside"));
3441
4115
  const platform = await signedIn(context);
3442
4116
  const cost = costOf(await platform.web.prices(), request);
3443
4117
  await confirmed(context, platform, request.words, cost, flagOf(args, "yes"));
@@ -3446,7 +4120,7 @@ const generate = {
3446
4120
  `);
3447
4121
  const job = await followed(context, platform, jobId);
3448
4122
  if (job.state === "failed") throw new CliError(`the generation failed: ${job.reason ?? "the platform gave no reason"}`);
3449
- 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") });
3450
4124
  answer(context, { job: jobId, cost, ...pulled }, pulledWords(pulled));
3451
4125
  }
3452
4126
  };
@@ -3754,15 +4428,156 @@ const link = {
3754
4428
  const seal = {
3755
4429
  name: "seal",
3756
4430
  summary: "Seal the local project's art into its container, as the editor does",
3757
- 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) => {
3758
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") });
3759
4439
  const art = artOf(local);
3760
4440
  const [result] = await sealProject(new Boundary([art]), art);
3761
4441
  if (result === void 0 || result.code !== 0) throw new CliError(result?.output ?? "the container was not rebuilt");
3762
4442
  answer(context, { sealed: true, output: result.output }, result.output);
3763
4443
  }
3764
4444
  };
3765
- const version = "0.9.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";
3766
4581
  const manifest = {
3767
4582
  version
3768
4583
  };
@@ -4209,6 +5024,7 @@ const COMMANDS = [
4209
5024
  whoami,
4210
5025
  init,
4211
5026
  link,
5027
+ move,
4212
5028
  generate,
4213
5029
  list,
4214
5030
  pull,