@bf6mods/cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Create Battlefield 6 mods quickly with a vite like development experience.
4
4
 
5
5
  ## Getting Started
6
6
 
7
- To create a new project, simply run the following command and answer the questions, ensure that you are using Node v22 and above.
7
+ To create a new project, simply run the following command and answer the questions, ensure that you are using Node v22 and above, and if on Linux install `libsecret-1-0`.
8
8
 
9
9
  ```
10
10
  npx @bf6mods/cli init
@@ -20,20 +20,9 @@ Already have a project? Just export your currently existing project in [portal.b
20
20
  npx @bf6mods/cli import <export file> <output directory>
21
21
  ```
22
22
 
23
- ## Deploying Project to Portal
23
+ ## Full Documentation
24
24
 
25
- There are two different ways of deploying a project to portal.
26
-
27
- ### Manually Import
28
-
29
- Just run `npm run build` in your project dir, open [portal.battlefield.com](https://portal.battlefield.com), click import, and select the `dist/mod.json` file.
30
-
31
- ### `npx @bf6mods/cli deploy`
32
-
33
- To use this, you must first install puppeteer via `npm -g i puppeteer`, but after doing so you can just run this command, and you will
34
- have your project deploy automatically for you.
35
-
36
- Important note on this. The [portal.battlefield.com](https://portal.battlefield.com) will not update showing the changes from the deployed code. This is due to the browsers cache.
25
+ The full documentation can be found [here](https://bf6mods.github.io/bf6mods/guide/getting-started.html)!
37
26
 
38
27
  ## Features
39
28
 
@@ -43,47 +32,16 @@ Important note on this. The [portal.battlefield.com](https://portal.battlefield.
43
32
  - Extended standard library (still in progress)
44
33
  - Hot reload
45
34
  - Automatic string injection
35
+ - **Automatic thumbnail resizing** - Add any image to your project and it will be automatically resized and optimized to meet BF6 Portal requirements (352x248, max 78KB)
46
36
  - Logging from BF6! (Only when in hosting locally)
47
37
 
48
- ## Structure
49
-
50
- `bf6mods` is very configurable, but the following structure is what can be expected from any mod.
51
-
52
- ```
53
- my-mod/
54
- ├─ src/
55
- │ ├─ index.ts
56
- │ ├─ scenes/
57
- │ │ └─ MyMap.spatial.json
58
- ├─ bf6.config.ts
59
- ├─ package.json
60
- └─ dist/
61
- └─ mod.json
62
- ```
63
-
64
- ## `bf6.config.ts`
65
-
66
- Here is a short example of a `bf6.config.ts`.
67
-
68
- ```ts
69
- export default defineBf6Config({
70
- name: "AcePursuit",
71
- description: "A fast-paced race mod",
72
- outDir: "dist",
73
- entrypoint: "src/index.ts",
74
- scenes: [[MapId.LiberationPeak, "src/scenes/AcePursuit.spatial.json"]],
75
- game: {
76
- mutators: {
77
- // ...
78
- },
79
- },
80
- });
81
- ````
82
-
83
38
  ## @bf6mods/sdk
84
39
 
85
40
  This is a seperate library that exports the `PortalSdk`'s `mod` and `modlib`. Additionally it exports some stdlib helper functions and classes to help accelerate development.
86
41
 
87
- ## Join the discord with fellow modders!
42
+ ## Links
88
43
 
89
- You can join the discord by clicking this [link](https://discord.gg/2gJ9fheYYK)!
44
+ - [Documentation](https://bf6mods.github.io/bf6mods/)
45
+ - [Contributing Guide](https://bf6mods.github.io/bf6mods/contributing/how-to-contribute.html)
46
+ - [Discord](https://discord.gg/2gJ9fheYYK)
47
+ - [Portal](https://portal.battlefield.com/bf6/en-gb/)
package/dist/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { AttachmentType } from "@bf6mods/sdk";
8
8
  import { createJiti } from "jiti";
9
9
  import { rolldown } from "rolldown";
10
10
  import stripAnsi from "strip-ansi";
11
+ import sharp from "sharp";
11
12
  import { Clients, Generated_pb } from "@bf6mods/portal";
12
13
  import os from "node:os";
13
14
  import keytar from "keytar";
@@ -19,7 +20,7 @@ import * as prompts from "@clack/prompts";
19
20
  import { genExport, genInlineTypeImport } from "knitwork";
20
21
 
21
22
  //#region package.json
22
- var version$1 = "1.6.0";
23
+ var version$1 = "1.7.0";
23
24
  var description = "CLI and library for bundling BF6 mods";
24
25
  var bin = { "bf6mods": "./dist/cli/index.js" };
25
26
 
@@ -68,6 +69,49 @@ const readableList = (initialValues) => {
68
69
  return values[0];
69
70
  };
70
71
 
72
+ //#endregion
73
+ //#region src/cli/build/any-params.ts
74
+ function addAnyToParams() {
75
+ return {
76
+ name: "add-any-to-params",
77
+ generateBundle(_options, bundle) {
78
+ for (const [_file, output] of Object.entries(bundle)) {
79
+ if (output.type !== "chunk") continue;
80
+ const program$1 = this.parse(output.code, {
81
+ lang: "js",
82
+ astType: "js",
83
+ range: true
84
+ });
85
+ const edits = [];
86
+ function walk(node) {
87
+ if (!node || typeof node !== "object") return;
88
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") for (const param of node.params) {
89
+ if (param.type === "Identifier" && param.range) edits.push({
90
+ pos: param.range[1],
91
+ insert: ": any"
92
+ });
93
+ if (param.type === "AssignmentPattern" && param.left?.type === "Identifier" && param.left.range) edits.push({
94
+ pos: param.left.range[1],
95
+ insert: ": any"
96
+ });
97
+ if (param.type === "RestElement" && param.argument?.type === "Identifier" && param.argument.range) edits.push({
98
+ pos: param.argument.range[1],
99
+ insert: ": any"
100
+ });
101
+ }
102
+ for (const val of Object.values(node)) if (Array.isArray(val)) val.forEach(walk);
103
+ else if (val && typeof val === "object" && "type" in val) walk(val);
104
+ }
105
+ for (const item of program$1.body) walk(item);
106
+ edits.sort((a, b) => b.pos - a.pos);
107
+ let result = output.code;
108
+ for (const edit of edits) result = result.slice(0, edit.pos) + edit.insert + result.slice(edit.pos);
109
+ output.code = result;
110
+ }
111
+ }
112
+ };
113
+ }
114
+
71
115
  //#endregion
72
116
  //#region src/cli/build/generated-strings.ts
73
117
  function extractBf6Strings(bf6Strings, generateFromLiterals) {
@@ -166,6 +210,131 @@ function extractBf6Strings(bf6Strings, generateFromLiterals) {
166
210
  };
167
211
  }
168
212
 
213
+ //#endregion
214
+ //#region src/cli/build/thumbnail.ts
215
+ /**
216
+ * BF6 Portal thumbnail requirements:
217
+ * - Max file size: 78KB
218
+ * - Dimensions: 352x248 pixels
219
+ * - Format: JPEG or PNG
220
+ */
221
+ const THUMBNAIL_REQUIREMENTS = {
222
+ maxSizeBytes: 78 * 1024,
223
+ width: 352,
224
+ height: 248,
225
+ supportedFormats: [
226
+ ".jpg",
227
+ ".jpeg",
228
+ ".png"
229
+ ]
230
+ };
231
+ /**
232
+ * Validates that an image buffer meets BF6 Portal requirements
233
+ */
234
+ function validateThumbnail(buffer, filename) {
235
+ const errors = [];
236
+ if (buffer.length > THUMBNAIL_REQUIREMENTS.maxSizeBytes) errors.push(`File size is ${(buffer.length / 1024).toFixed(2)}KB, must be less than 78KB`);
237
+ const ext = path.extname(filename).toLowerCase();
238
+ if (!THUMBNAIL_REQUIREMENTS.supportedFormats.includes(ext)) errors.push(`File format ${ext} is not supported. Must be ${THUMBNAIL_REQUIREMENTS.supportedFormats.join(" or ")}`);
239
+ const dimensions = getImageDimensions(buffer, ext);
240
+ if (dimensions) {
241
+ if (dimensions.width !== THUMBNAIL_REQUIREMENTS.width || dimensions.height !== THUMBNAIL_REQUIREMENTS.height) errors.push(`Image dimensions are ${dimensions.width}x${dimensions.height}, must be ${THUMBNAIL_REQUIREMENTS.width}x${THUMBNAIL_REQUIREMENTS.height}`);
242
+ }
243
+ return {
244
+ valid: errors.length === 0,
245
+ errors
246
+ };
247
+ }
248
+ /**
249
+ * Gets image dimensions from buffer by reading image headers
250
+ */
251
+ function getImageDimensions(buffer, ext) {
252
+ try {
253
+ if (ext === ".png") {
254
+ if (buffer.length >= 24 && buffer.toString("ascii", 1, 4) === "PNG") return {
255
+ width: buffer.readUInt32BE(16),
256
+ height: buffer.readUInt32BE(20)
257
+ };
258
+ } else if (ext === ".jpg" || ext === ".jpeg") {
259
+ let offset = 2;
260
+ while (offset < buffer.length) {
261
+ if (buffer[offset] !== 255) break;
262
+ const marker = buffer[offset + 1];
263
+ offset += 2;
264
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
265
+ const height = buffer.readUInt16BE(offset + 3);
266
+ return {
267
+ width: buffer.readUInt16BE(offset + 5),
268
+ height
269
+ };
270
+ }
271
+ const segmentLength = buffer.readUInt16BE(offset);
272
+ offset += segmentLength;
273
+ }
274
+ }
275
+ } catch (_error) {}
276
+ return null;
277
+ }
278
+ /**
279
+ * Processes a thumbnail file for BF6 Portal
280
+ * Saves the resized thumbnail to the output directory
281
+ */
282
+ async function processThumbnail(thumbnailPath, workingDir, outDir) {
283
+ const fullPath = path.resolve(workingDir, thumbnailPath);
284
+ if (!fs.existsSync(fullPath)) {
285
+ printToConsole(colors.yellow(`⚠️ Thumbnail file not found: ${thumbnailPath}`));
286
+ return false;
287
+ }
288
+ let buffer = await fs.promises.readFile(fullPath);
289
+ const filename = path.basename(fullPath);
290
+ const ext = path.extname(fullPath);
291
+ if (!validateThumbnail(buffer, filename).valid) {
292
+ printToConsole(colors.yellow(`⚠️ Thumbnail doesn't meet requirements, attempting to resize and optimize...`));
293
+ try {
294
+ buffer = Buffer.from(await resizeThumbnail(buffer, fullPath));
295
+ printToConsole(colors.green(`✓ Thumbnail resized and optimized: ${filename} (${(buffer.length / 1024).toFixed(2)}KB)`));
296
+ } catch (error) {
297
+ printToConsole(colors.red(`✗ Failed to resize thumbnail: ${error instanceof Error ? error.message : "Unknown error"}`));
298
+ printToConsole(colors.yellow(`\nThumbnail requirements:\n - Size: max 78KB\n - Dimensions: 352x248 pixels\n - Format: JPEG or PNG`));
299
+ return false;
300
+ }
301
+ } else printToConsole(`${colors.green.bold("✓")} Thumbnail validated: ${filename} (${(buffer.length / 1024).toFixed(2)}KB)`);
302
+ const outputPath = path.resolve(outDir, `thumbnail${ext}`);
303
+ await fs.promises.writeFile(outputPath, buffer);
304
+ printToConsole(colors.green(`✓ Thumbnail saved to: ${path.relative(workingDir, outputPath)}`));
305
+ return true;
306
+ }
307
+ /**
308
+ * Resizes and optimizes an image to meet BF6 Portal thumbnail requirements
309
+ */
310
+ async function resizeThumbnail(buffer, originalPath) {
311
+ const ext = path.extname(originalPath).toLowerCase();
312
+ const isJpeg = ext === ".jpg" || ext === ".jpeg";
313
+ let result = await sharp(buffer).resize(THUMBNAIL_REQUIREMENTS.width, THUMBNAIL_REQUIREMENTS.height, {
314
+ fit: "cover",
315
+ position: "center"
316
+ }).toBuffer();
317
+ if (isJpeg || result.length > THUMBNAIL_REQUIREMENTS.maxSizeBytes) for (let quality = 90; quality >= 60; quality -= 10) {
318
+ result = await sharp(buffer).resize(THUMBNAIL_REQUIREMENTS.width, THUMBNAIL_REQUIREMENTS.height, {
319
+ fit: "cover",
320
+ position: "center"
321
+ }).jpeg({
322
+ quality,
323
+ progressive: true
324
+ }).toBuffer();
325
+ if (result.length <= THUMBNAIL_REQUIREMENTS.maxSizeBytes) break;
326
+ }
327
+ else result = await sharp(buffer).resize(THUMBNAIL_REQUIREMENTS.width, THUMBNAIL_REQUIREMENTS.height, {
328
+ fit: "cover",
329
+ position: "center"
330
+ }).png({
331
+ compressionLevel: 9,
332
+ quality: 90
333
+ }).toBuffer();
334
+ if (result.length > THUMBNAIL_REQUIREMENTS.maxSizeBytes) throw new Error(`Unable to compress image below 78KB (current: ${(result.length / 1024).toFixed(2)}KB). Try using a simpler image or pre-compressing it.`);
335
+ return result;
336
+ }
337
+
169
338
  //#endregion
170
339
  //#region src/cli/build/index.ts
171
340
  /**
@@ -204,6 +373,7 @@ async function build() {
204
373
  tsAttachment = createTsAttachment(entryAbs, await buildEntrypoint(entryAbs, generatedStrings, config.generateStrings ?? true));
205
374
  }
206
375
  const { attachments, mapRotation } = await collectAttachments(config, workingDir, tsAttachment, generatedStrings);
376
+ if (config.thumbnail) await processThumbnail(config.thumbnail, workingDir, outDir);
207
377
  await writeModJson(config, outDir, attachments, mapRotation, minifyJson);
208
378
  printToConsole(`${colors.green.bold("✓")} Built mod: ${config.name}`);
209
379
  }
@@ -213,7 +383,7 @@ async function build() {
213
383
  async function buildEntrypoint(entry, bf6Strings, generateStringsFromLiterals) {
214
384
  return (await (await rolldown({
215
385
  input: entry,
216
- plugins: [extractBf6Strings(bf6Strings, generateStringsFromLiterals)],
386
+ plugins: [extractBf6Strings(bf6Strings, generateStringsFromLiterals), addAnyToParams()],
217
387
  logLevel: "debug",
218
388
  resolve: { alias: { modlib: "@bf6mods/sdk" } }
219
389
  })).generate({
@@ -291,7 +461,7 @@ function createTsAttachment(filePath, compiled) {
291
461
  return {
292
462
  id: crypto.randomUUID(),
293
463
  version: "1.0",
294
- filename: `${path.parse(filePath).name}.js`,
464
+ filename: `${path.parse(filePath).name}.ts`,
295
465
  isProcessable: true,
296
466
  processingStatus: 2,
297
467
  attachmentType: AttachmentType.TypeScript,
@@ -347,7 +517,7 @@ function toBase64(input) {
347
517
  async function getSessionIdFromCookies() {
348
518
  let puppeteer;
349
519
  try {
350
- puppeteer = await import("../puppeteer-u5zMrFnK.js");
520
+ puppeteer = await import("../puppeteer-BzRJX4NM.js");
351
521
  } catch (_error) {
352
522
  printToConsole(`${colors.red.bold("✗")} Puppeteer could not be loaded.`);
353
523
  console.error("It looks like Puppeteer is not installed in this environment.");
@@ -371,7 +541,7 @@ async function getSessionIdFromCookies() {
371
541
  return document.cookie.includes("bf6sessionId");
372
542
  }, { timeout: 0 });
373
543
  console.log(`${colors.green.bold("✓")} Login detected!`);
374
- const cookies = await page.cookies();
544
+ const cookies = await browser.cookies();
375
545
  await browser.close();
376
546
  return cookies.find((cookie) => cookie.name === "bf6sessionId")?.value;
377
547
  }
@@ -413,15 +583,14 @@ async function authenticate(sessionIdParam) {
413
583
  printToConsole(`${colors.red("✗")} Authentication failed. Session may have expired immediately.`, true);
414
584
  return false;
415
585
  }
416
- async function alwaysAuthenticatedRequest(method, request, sessionId, options) {
417
- const fn = clients.play[method];
586
+ async function alwaysAuthenticatedRequest(method, sessionId) {
418
587
  try {
419
- return await fn(request, options);
588
+ return await method();
420
589
  } catch (error) {
421
590
  if (error instanceof Error && error.message === "[unauthenticated]") {
422
591
  printToConsole("🔄 Session expired. Re-authenticating…");
423
592
  if (!await authenticate(sessionId)) throw error;
424
- return await fn(request, options);
593
+ return await method();
425
594
  }
426
595
  throw error;
427
596
  }
@@ -431,30 +600,28 @@ async function alwaysAuthenticatedRequest(method, request, sessionId, options) {
431
600
  //#region src/cli/deploy/index.ts
432
601
  const clients = new Clients();
433
602
  async function deploy({ input, sessionIdParam, modId }) {
434
- printToConsole("To be implemented in a future release");
435
- process.exit(0);
436
603
  const rootDir = path.resolve(".");
437
604
  if (!input) input = path.resolve(rootDir, "dist", "mod.json");
438
605
  printToConsole(`🚀 Starting deploy for ${colors.cyan(input)}…`);
439
606
  if (!fs.existsSync(input)) return printToConsole(`${colors.red.bold("✗")} File ${colors.cyan(input)} does not exist!`, true);
440
607
  const mod = JSON.parse(fs.readFileSync(input, { encoding: "utf-8" }));
441
- const blueprints = await alwaysAuthenticatedRequest("getScheduledBlueprints", {}, sessionIdParam);
608
+ const blueprints = await alwaysAuthenticatedRequest(() => clients.play.getScheduledBlueprints({}), sessionIdParam);
442
609
  console.log("blueprints", blueprints.blueprintIds);
443
- await alwaysAuthenticatedRequest("getBlueprintsById", { blueprintIds: blueprints.blueprintIds }, sessionIdParam);
610
+ await alwaysAuthenticatedRequest(() => clients.play.getBlueprintsById({ blueprintIds: blueprints.blueprintIds }), sessionIdParam);
444
611
  const config = await getBf6Config(rootDir);
445
612
  let id = modId;
446
613
  if (!config) printToConsole(`Cannot find bf6mods config in dir ${rootDir}, proceeding without it.`);
447
614
  else if (config?.id) id = config.id;
448
615
  else printToConsole(`No id specified in bf6.config.ts! Defaulting to experience with same name!`);
449
616
  if (!id) {
450
- const owned = await alwaysAuthenticatedRequest("getOwnedPlayElementsV2", {
617
+ const owned = await alwaysAuthenticatedRequest(() => clients.play.getOwnedPlayElementsV2({
451
618
  includeDenied: true,
452
619
  publishStates: [
453
620
  Generated_pb.PublishStateType.Draft,
454
621
  Generated_pb.PublishStateType.Published,
455
622
  Generated_pb.PublishStateType.Error
456
623
  ]
457
- }, sessionIdParam);
624
+ }), sessionIdParam);
458
625
  console.log("owned:", owned.playElements.map((owned$1) => owned$1.playElement));
459
626
  const found = owned.playElements.find((element) => element?.playElement?.name === mod.name);
460
627
  if (found?.playElement?.id) id = found.playElement.id;
@@ -463,13 +630,43 @@ async function deploy({ input, sessionIdParam, modId }) {
463
630
  printToConsole(`${colors.red.bold("✗")} Cannot find an id for your mod to deploy with! Please specify the id explicitly with \`--id\` or by having a mod with the same name!`);
464
631
  process.exit(0);
465
632
  }
466
- const playElementResponse = await alwaysAuthenticatedRequest("getPlayElement", {
633
+ const playElementResponse = await alwaysAuthenticatedRequest(() => clients.play.getPlayElement({
467
634
  id,
468
635
  includeDenied: true
469
- }, sessionIdParam);
636
+ }), sessionIdParam);
470
637
  if (!playElementResponse.playElement || !playElementResponse.playElementDesign || !playElementResponse.progressionMode) throw new Error("Cannot find essential attribute in mod, please report to GitHub!");
471
638
  if (!mod.mapRotation) throw new Error("You must specify at least one map!");
472
639
  console.log("playElementResponse:", playElementResponse);
640
+ const thumbnailExtensions = [
641
+ ".jpg",
642
+ ".jpeg",
643
+ ".png"
644
+ ];
645
+ let thumbnailPath;
646
+ let thumbnailExt;
647
+ for (const ext of thumbnailExtensions) {
648
+ const testPath = path.resolve(rootDir, "dist", `thumbnail${ext}`);
649
+ if (fs.existsSync(testPath)) {
650
+ thumbnailPath = testPath;
651
+ thumbnailExt = ext;
652
+ break;
653
+ }
654
+ }
655
+ if (thumbnailPath && thumbnailExt) try {
656
+ printToConsole(colors.blue("📸 Uploading thumbnail..."));
657
+ const thumbnailBuffer = await fs.promises.readFile(thumbnailPath);
658
+ const mimeType = thumbnailExt === ".png" ? "image/png" : "image/jpeg";
659
+ const uploadResponse = await alwaysAuthenticatedRequest(() => clients.play.uploadExperienceThumbnail({
660
+ image: thumbnailBuffer,
661
+ mimeType
662
+ }), sessionIdParam);
663
+ printToConsole(colors.green(`✓ Thumbnail uploaded successfully: ${uploadResponse.assetId}`));
664
+ printToConsole(colors.dim(` URL: ${uploadResponse.url}`));
665
+ } catch (error) {
666
+ printToConsole(colors.yellow(`⚠ Failed to upload thumbnail: ${error instanceof Error ? error.message : "Unknown error"}`));
667
+ printToConsole(colors.dim(" Continuing deployment without thumbnail"));
668
+ }
669
+ else printToConsole(colors.dim(" No thumbnail found in dist folder, skipping upload"));
473
670
  }
474
671
 
475
672
  //#endregion
@@ -628,7 +825,7 @@ async function dev() {
628
825
 
629
826
  //#endregion
630
827
  //#region ../sdk/package.json
631
- var version = "1.6.0";
828
+ var version = "1.7.0";
632
829
 
633
830
  //#endregion
634
831
  //#region src/cli/init.ts
@@ -665,14 +862,12 @@ const templates = [
665
862
  "Basic",
666
863
  "Complete",
667
864
  "AcePursuit",
668
- "BombSquad",
669
865
  "Exfil",
670
866
  "Vertigo"
671
867
  ];
672
868
  async function startProject(destination, template, name) {
673
869
  if ([
674
870
  "AcePursuit",
675
- "BombSquad",
676
871
  "Exfil",
677
872
  "Vertigo"
678
873
  ].includes(template)) await importFile(path.resolve(templatesDir, `${template}.json`), destination, name);