kradle 0.6.6 → 0.6.8

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
@@ -81,23 +81,24 @@ This creates a `challenges/<challenge-name>/` folder with:
81
81
 
82
82
  ### Build Challenge
83
83
 
84
- Build, validate, and upload challenge datapack:
84
+ Build and validate challenge datapacks locally, with optional upload:
85
85
 
86
86
  ```bash
87
87
  kradle challenge build <challenge-name>
88
+ kradle challenge build <challenge-name> --no-upload # Build locally only (no cloud upload)
88
89
  kradle challenge build <challenge-name> --no-validate # Skip validation
89
90
  kradle challenge build <challenge-name> --public # Upload as public
90
91
  kradle challenge build --all # Build all local challenges
91
92
  ```
92
93
 
93
94
  This command:
94
- 1. Creates the challenge in the cloud (if it doesn't already exist)
95
- 2. Builds the datapack by executing `challenge.ts`
96
- 3. Validates the datapack using Spyglass (blocks upload if errors found)
97
- 4. Uploads `config.ts` to cloud
98
- 5. Uploads the datapack to GCS
95
+ 1. Builds the datapack by executing `challenge.ts`
96
+ 2. Validates the datapack using Spyglass (unless `--no-validate`)
97
+ 3. If upload is enabled (default), creates the challenge in the cloud if needed
98
+ 4. If upload is enabled (default), uploads `config.ts` and datapack
99
99
 
100
- Validation checks `.mcfunction` files for syntax errors, invalid commands, and other issues. Use `--no-validate` to skip this step.
100
+ Use `--no-upload` to build locally only. Use `--no-validate` to skip validation.
101
+ `--public` is only valid when upload is enabled (default).
101
102
 
102
103
  ### Delete Challenge
103
104
 
@@ -176,6 +177,39 @@ When no agents are specified, the command enters interactive mode, fetching the
176
177
 
177
178
  By default, the command opens the run URL in your browser and polls until the run completes, then displays the outcome.
178
179
 
180
+ ### Run Challenge Locally
181
+
182
+ Run a challenge locally using Docker (requires Docker Desktop):
183
+
184
+ ```bash
185
+ # Run locally with inline agents
186
+ kradle challenge run <challenge-name> --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast
187
+
188
+ # Run locally with interactive agent selection
189
+ kradle challenge run <challenge-name> --local
190
+
191
+ # Use a dev/staging arena image instead of production
192
+ kradle challenge run <challenge-name> --local \
193
+ --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234
194
+ ```
195
+
196
+ The `--local` flag spins up a Minecraft server and arena-minecraft as Docker containers on your machine. This is useful for testing challenges during development without going through the cloud backend.
197
+
198
+ **Requirements:**
199
+ - Docker Desktop must be running
200
+ - First run will pull container images (may take a few minutes)
201
+ - The challenge must exist locally (with `challenge.ts` and `config.ts`)
202
+
203
+ **What happens:**
204
+ 1. Builds the challenge datapack locally
205
+ 2. Downloads the world from the cloud
206
+ 3. Starts a Minecraft server container (port 25565)
207
+ 4. Starts an arena-minecraft container
208
+ 5. Streams arena logs to your terminal
209
+ 6. Press Ctrl+C for graceful shutdown
210
+
211
+ **Arena Image Override:** By default, the production arena image is used. Set `--arena-image` or the `KRADLE_ARENA_IMAGE` environment variable to test dev or staging builds.
212
+
179
213
  ### List Runs
180
214
 
181
215
  List your recent runs:
@@ -478,7 +512,7 @@ Each challenge is a folder in `challenges/<slug>/` containing:
478
512
  1. `kradle challenge create <slug>` creates the folder with `challenge.ts`
479
513
  2. The create command automatically builds, uploads, and downloads the config from the cloud API
480
514
  3. The downloaded JSON is converted into a typed TypeScript `config.ts` file
481
- 4. `kradle challenge build <slug>` automatically uploads `config.ts` (if it exists) before building the datapack
515
+ 4. `kradle challenge build <slug>` builds datapacks locally and uploads `config.ts` + datapack by default (use `--no-upload` for local-only build)
482
516
  5. You can modify `config.ts` locally and run `build` to sync changes to the cloud
483
517
 
484
518
  ## Architecture
@@ -507,6 +541,8 @@ kradle-cli/
507
541
  │ │ │ └── runs/ # Run listing and logs commands
508
542
  │ │ ├── experiment/ # Experiment commands
509
543
  │ │ └── world/ # World management commands
544
+ │ ├── config/
545
+ │ │ └── releases/ # Release tags (updated by CI)
510
546
  │ └── lib/ # Core libraries
511
547
  │ └── experiment/ # Experiment system
512
548
  ├── tests/ # Integration tests
@@ -6,13 +6,15 @@ export default class Build extends Command {
6
6
  challengeSlug: import("@oclif/core/interfaces").Arg<string | undefined>;
7
7
  };
8
8
  static flags: {
9
- "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
- "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
9
  "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
10
  all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
11
  public: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
12
  "no-validate": import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ "no-upload": import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  };
16
17
  static strict: boolean;
17
18
  run(): Promise<void>;
19
+ private requireApiKey;
18
20
  }
@@ -5,7 +5,7 @@ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.
5
5
  import { Challenge } from "../../lib/challenge.js";
6
6
  import { getConfigFlags } from "../../lib/flags.js";
7
7
  export default class Build extends Command {
8
- static description = "Build and upload challenge datapack and config";
8
+ static description = "Build challenge datapack locally, with optional upload";
9
9
  static examples = [
10
10
  "<%= config.bin %> <%= command.id %> my-challenge",
11
11
  "<%= config.bin %> <%= command.id %> my-challenge my-other-challenge",
@@ -13,7 +13,7 @@ export default class Build extends Command {
13
13
  ];
14
14
  static args = {
15
15
  challengeSlug: getChallengeSlugArgument({
16
- description: "Challenge slug to build and upload. Can be used multiple times to build and upload multiple challenges at once. Incompatible with --all flag.",
16
+ description: "Challenge slug to build. Can be used multiple times to build multiple challenges at once. Incompatible with --all flag.",
17
17
  required: false,
18
18
  }),
19
19
  };
@@ -21,10 +21,23 @@ export default class Build extends Command {
21
21
  all: Flags.boolean({ char: "a", description: "Build all challenges in the challenges directory", default: false }),
22
22
  public: Flags.boolean({ char: "p", description: "Upload challenges as public.", default: false }),
23
23
  "no-validate": Flags.boolean({
24
- description: "Skip datapack validation before upload",
24
+ description: "Skip datapack validation",
25
25
  default: false,
26
26
  }),
27
- ...getConfigFlags("api-key", "api-url", "challenges-path"),
27
+ "no-upload": Flags.boolean({
28
+ description: "Build datapack locally only (skip cloud config/datapack upload)",
29
+ default: false,
30
+ }),
31
+ "api-url": Flags.string({
32
+ description: "Kradle Web API URL",
33
+ env: "KRADLE_API_URL",
34
+ default: "https://api.kradle.ai/v0",
35
+ }),
36
+ "api-key": Flags.string({
37
+ description: "Kradle API key",
38
+ env: "KRADLE_API_KEY",
39
+ }),
40
+ ...getConfigFlags("challenges-path"),
28
41
  };
29
42
  static strict = false;
30
43
  async run() {
@@ -32,6 +45,9 @@ export default class Build extends Command {
32
45
  if (flags.all && argv.length > 0) {
33
46
  this.error(pc.red("Cannot use --all flag with challenge slugs"));
34
47
  }
48
+ if (flags["no-upload"] && flags.public) {
49
+ this.error(pc.red("Cannot use --public with --no-upload"));
50
+ }
35
51
  if (!flags.all && argv.length === 0) {
36
52
  // Show help if no challenge slugs are provided - https://github.com/oclif/oclif/issues/183#issuecomment-1933104981
37
53
  await new (await loadHelpClass(this.config))(this.config).showHelp([Build.id]);
@@ -41,7 +57,8 @@ export default class Build extends Command {
41
57
  this.log(pc.blue("Building all challenges"));
42
58
  }
43
59
  const challengeSlugs = flags.all ? await Challenge.getLocalChallenges() : argv;
44
- const api = new ApiClient(flags["api-url"], flags["api-key"]);
60
+ const shouldUpload = !flags["no-upload"];
61
+ const api = shouldUpload ? new ApiClient(flags["api-url"], this.requireApiKey(flags["api-key"])) : null;
45
62
  for (const challengeSlug of challengeSlugs) {
46
63
  // Validate that the challenge exists locally
47
64
  const validation = await Challenge.validateForLocalOperation(challengeSlug, flags["challenges-path"]);
@@ -51,10 +68,17 @@ export default class Build extends Command {
51
68
  const challenge = new Challenge(extractShortSlug(challengeSlug), flags["challenges-path"]);
52
69
  this.log(pc.blue(`==== Building challenge: ${challenge.shortSlug} ====`));
53
70
  try {
54
- await challenge.buildAndUpload(api, {
55
- asPublic: flags.public,
56
- validate: !flags["no-validate"],
57
- });
71
+ if (shouldUpload && api) {
72
+ await challenge.buildAndUpload(api, {
73
+ asPublic: flags.public,
74
+ validate: !flags["no-validate"],
75
+ });
76
+ }
77
+ else {
78
+ await challenge.buildLocal({
79
+ validate: !flags["no-validate"],
80
+ });
81
+ }
58
82
  }
59
83
  catch (error) {
60
84
  this.error(pc.red(`Build failed: ${error instanceof Error ? error.message : String(error)}`));
@@ -62,4 +86,11 @@ export default class Build extends Command {
62
86
  this.log();
63
87
  }
64
88
  }
89
+ requireApiKey(apiKey) {
90
+ if (apiKey) {
91
+ return apiKey;
92
+ }
93
+ this.error(pc.red("Missing required flag --api-key (or KRADLE_API_KEY) unless using --no-upload"));
94
+ throw new Error("Unreachable");
95
+ }
65
96
  }
@@ -12,8 +12,11 @@ export default class Run extends Command {
12
12
  "web-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
13
  "studio-api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
14
  "studio-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
16
  studio: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  record: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ "arena-image": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
20
  "no-open": import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
21
  "no-wait": import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
22
  "no-summary": import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -34,5 +37,9 @@ export default class Run extends Command {
34
37
  * Fetches challenge config from cloud and prompts user to select agents for each role.
35
38
  */
36
39
  private selectAgentsInteractively;
40
+ /**
41
+ * Run a challenge locally using Docker (MC server + arena-minecraft).
42
+ */
43
+ private runLocally;
37
44
  run(): Promise<void>;
38
45
  }
@@ -2,7 +2,8 @@ import { Command, Flags } from "@oclif/core";
2
2
  import enquirer from "enquirer";
3
3
  import pc from "picocolors";
4
4
  import { ApiClient } from "../../lib/api-client.js";
5
- import { getChallengeSlugArgument } from "../../lib/arguments.js";
5
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
6
+ import { Challenge } from "../../lib/challenge.js";
6
7
  import { getConfigFlags } from "../../lib/flags.js";
7
8
  import { clearScreen, formatDuration, fuzzyHighlight, getRunStatusDisplay, openInBrowser } from "../../lib/utils.js";
8
9
  const POLL_INTERVAL_MS = 2000;
@@ -21,6 +22,9 @@ export default class Run extends Command {
21
22
  "<%= config.bin %> <%= command.id %> my-challenge --no-wait",
22
23
  "<%= config.bin %> <%= command.id %> my-challenge team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
23
24
  "<%= config.bin %> <%= command.id %> capture-the-flag red=team-kradle:gemini-3-flash blue=team-kradle:grok-4-1-fast",
25
+ "<%= config.bin %> <%= command.id %> my-challenge --local",
26
+ "<%= config.bin %> <%= command.id %> my-challenge --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
27
+ "<%= config.bin %> <%= command.id %> my-challenge --local --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234",
24
28
  ];
25
29
  static args = {
26
30
  challengeSlug: getChallengeSlugArgument({
@@ -30,6 +34,15 @@ export default class Run extends Command {
30
34
  static flags = {
31
35
  studio: Flags.boolean({ char: "s", description: "Run in studio environment", default: false }),
32
36
  record: Flags.boolean({ char: "r", description: "Record the run (enables video recording)", default: false }),
37
+ local: Flags.boolean({
38
+ char: "l",
39
+ description: "Run the challenge locally using Docker (spins up MC server + arena-minecraft)",
40
+ default: false,
41
+ }),
42
+ "arena-image": Flags.string({
43
+ description: "Override arena-minecraft Docker image URL (for testing dev/staging builds)",
44
+ env: "KRADLE_ARENA_IMAGE",
45
+ }),
33
46
  "no-open": Flags.boolean({
34
47
  description: "Don't open the run URL in the browser",
35
48
  default: false,
@@ -46,7 +59,7 @@ export default class Run extends Command {
46
59
  description: "Open the run URL with screenshots mode enabled",
47
60
  default: false,
48
61
  }),
49
- ...getConfigFlags("api-key", "api-url", "web-url", "studio-url", "studio-api-url"),
62
+ ...getConfigFlags("api-key", "api-url", "web-url", "studio-url", "studio-api-url", "challenges-path"),
50
63
  };
51
64
  async pollForCompletion(api, runId, waitForSummary) {
52
65
  let lastStatus = "";
@@ -256,13 +269,108 @@ export default class Run extends Command {
256
269
  }
257
270
  return participants;
258
271
  }
272
+ /**
273
+ * Run a challenge locally using Docker (MC server + arena-minecraft).
274
+ */
275
+ async runLocally(challengeSlug,
276
+ // biome-ignore lint/suspicious/noExplicitAny: oclif flag types are complex
277
+ flags, agentArgs) {
278
+ // Validate incompatible flags
279
+ const incompatible = ["studio", "no-open", "no-wait", "screenshots", "record"];
280
+ for (const flag of incompatible) {
281
+ if (flags[flag]) {
282
+ this.error(`--${flag} is not compatible with --local`);
283
+ }
284
+ }
285
+ const apiUrl = flags["api-url"];
286
+ const api = new ApiClient(apiUrl, flags["api-key"]);
287
+ // Parse participants
288
+ let participants;
289
+ if (agentArgs.length > 0) {
290
+ participants = this.parseInlineAgents(agentArgs);
291
+ }
292
+ else {
293
+ this.log(pc.blue(">> Fetching challenge configuration..."));
294
+ const [challenge, availableAgents] = await Promise.all([api.getChallenge(challengeSlug), api.listKradleAgents()]);
295
+ this.log(pc.dim(`Challenge: ${challenge.name}`));
296
+ this.log(pc.dim(`Roles: ${Object.keys(challenge.roles).join(", ")}`));
297
+ this.log("");
298
+ participants = await this.selectAgentsInteractively(challenge, availableAgents);
299
+ }
300
+ if (participants.length === 0) {
301
+ this.error("No participants specified. Use inline syntax or select agents interactively.");
302
+ }
303
+ // Fetch challenge data from API
304
+ this.log(pc.blue(">> Fetching challenge configuration..."));
305
+ const challengeData = await api.getChallenge(challengeSlug);
306
+ // Build datapack locally
307
+ this.log(pc.blue(">> Building challenge datapack..."));
308
+ const shortSlug = extractShortSlug(challengeSlug);
309
+ const challenge = new Challenge(shortSlug, flags["challenges-path"]);
310
+ const { config } = await challenge.buildLocal({ validate: true });
311
+ // Create a real job in the backend (env: "studio" skips remote arena creation)
312
+ // Get the FULL raw response — it's passed directly to the arena (same as Studio)
313
+ this.log(pc.blue(">> Creating run in backend..."));
314
+ const jobPayload = await api.runChallengeRaw({
315
+ challenge: challengeSlug,
316
+ participants,
317
+ jobType: "foreground",
318
+ env: "studio",
319
+ });
320
+ const jobId = jobPayload.id;
321
+ const runIds = jobPayload.runIds;
322
+ if (!jobId || !runIds?.length) {
323
+ this.error("Backend did not return job ID or run IDs. Check your API key and challenge slug.");
324
+ }
325
+ const runId = runIds[0];
326
+ this.log(pc.dim(`Job: ${jobId}`));
327
+ this.log(pc.dim(`Run: ${runId}`));
328
+ // Lazy import to avoid Docker dependency when not using --local
329
+ const { LocalRunner } = await import("../../lib/local-runner.js");
330
+ const runner = new LocalRunner({
331
+ challengeData,
332
+ challengeConfig: config,
333
+ datapackPath: challenge.builtDatapackPath,
334
+ arenaImageOverride: flags["arena-image"],
335
+ apiUrl,
336
+ apiKey: flags["api-key"],
337
+ jobPayload,
338
+ jobId,
339
+ runId,
340
+ });
341
+ // Handle graceful shutdown
342
+ const cleanup = async () => {
343
+ this.log(pc.yellow("\n>> Shutting down..."));
344
+ await runner.cleanup();
345
+ process.exit(0);
346
+ };
347
+ process.on("SIGINT", cleanup);
348
+ process.on("SIGTERM", cleanup);
349
+ try {
350
+ this.log(pc.blue(`\n>> Running challenge locally: ${challengeSlug}\n`));
351
+ await runner.run((msg) => this.log(msg));
352
+ this.log(pc.green("\n=== Local run complete ==="));
353
+ }
354
+ catch (error) {
355
+ this.error(pc.red(`Local run failed: ${error instanceof Error ? error.message : String(error)}`));
356
+ }
357
+ finally {
358
+ process.removeListener("SIGINT", cleanup);
359
+ process.removeListener("SIGTERM", cleanup);
360
+ await runner.cleanup();
361
+ }
362
+ }
259
363
  async run() {
260
364
  const { args, flags, argv } = await this.parse(Run);
261
- const apiUrl = flags.studio ? flags["studio-api-url"] : flags["api-url"];
262
- const api = new ApiClient(apiUrl, flags["api-key"]);
263
365
  const challengeSlug = args.challengeSlug;
264
366
  // Get extra arguments (after the challenge slug) for inline agent specification
265
367
  const agentArgs = argv.filter((arg) => arg !== challengeSlug);
368
+ // Local mode: run via Docker
369
+ if (flags.local) {
370
+ return this.runLocally(challengeSlug, flags, agentArgs);
371
+ }
372
+ const apiUrl = flags.studio ? flags["studio-api-url"] : flags["api-url"];
373
+ const api = new ApiClient(apiUrl, flags["api-key"]);
266
374
  let participants;
267
375
  if (agentArgs.length > 0) {
268
376
  // Parse inline agent specification
@@ -0,0 +1 @@
1
+ export declare const MINECRAFT_ARENA_MANAGER_TAG = "8534933";
@@ -0,0 +1,2 @@
1
+ // Managed by https://github.com/Kradle-ai/arena-minecraft/actions/workflows/release.yaml
2
+ export const MINECRAFT_ARENA_MANAGER_TAG = "8534933";
@@ -76,6 +76,7 @@ export declare class ApiClient {
76
76
  challenge: string;
77
77
  participants: unknown[];
78
78
  jobType: "background" | "foreground" | "foreground_with_recording";
79
+ env?: "cloud" | "studio";
79
80
  }): Promise<{
80
81
  runIds?: string[] | undefined;
81
82
  participants?: Record<string, {
@@ -84,7 +85,19 @@ export declare class ApiClient {
84
85
  inputOrder: number;
85
86
  }> | undefined;
86
87
  id?: string | undefined;
88
+ jobApiKey?: string | undefined;
87
89
  }>;
90
+ /**
91
+ * Create a job and return the full unvalidated response.
92
+ * Used for local runs where the full job object is passed directly to the arena container
93
+ * (same approach as Studio).
94
+ */
95
+ runChallengeRaw(runData: {
96
+ challenge: string;
97
+ participants: unknown[];
98
+ jobType: "background" | "foreground" | "foreground_with_recording";
99
+ env?: "cloud" | "studio";
100
+ }): Promise<Record<string, unknown>>;
88
101
  deleteChallenge(challengeId: string): Promise<void>;
89
102
  /**
90
103
  * Get the status of a run.
@@ -247,6 +247,18 @@ export class ApiClient {
247
247
  body: JSON.stringify(runData),
248
248
  }, JobResponseSchema);
249
249
  }
250
+ /**
251
+ * Create a job and return the full unvalidated response.
252
+ * Used for local runs where the full job object is passed directly to the arena container
253
+ * (same approach as Studio).
254
+ */
255
+ async runChallengeRaw(runData) {
256
+ const response = await this.request("jobs", {
257
+ method: "POST",
258
+ body: JSON.stringify(runData),
259
+ });
260
+ return response.json();
261
+ }
250
262
  async deleteChallenge(challengeId) {
251
263
  const url = `challenges/${challengeId}`;
252
264
  await this.delete(url);
@@ -51,6 +51,18 @@ export declare class Challenge {
51
51
  */
52
52
  build(silent?: boolean, config?: ChallengeConfigSchemaType): Promise<void>;
53
53
  loadConfig(): Promise<ChallengeConfigSchemaType>;
54
+ /**
55
+ * Build the challenge datapack locally.
56
+ * @param options - Build options
57
+ * @param options.validate - Whether to validate the datapack after build (default: true).
58
+ * @returns The loaded config and datapack hash.
59
+ */
60
+ buildLocal(options?: {
61
+ validate?: boolean;
62
+ }): Promise<{
63
+ config: ChallengeConfigSchemaType;
64
+ datapackHash: string;
65
+ }>;
54
66
  /**
55
67
  * Build the challenge datapack and upload it to the cloud.
56
68
  * @param api - The API client to use.
@@ -162,17 +162,15 @@ export class Challenge {
162
162
  }
163
163
  }
164
164
  /**
165
- * Build the challenge datapack and upload it to the cloud.
166
- * @param api - The API client to use.
165
+ * Build the challenge datapack locally.
167
166
  * @param options - Build options
168
- * @param options.asPublic - Whether the challenge should be uploaded as public.
169
- * @param options.validate - Whether to validate the datapack before upload (default: true).
170
- * @returns The config and datapack hash.
167
+ * @param options.validate - Whether to validate the datapack after build (default: true).
168
+ * @returns The loaded config and datapack hash.
171
169
  */
172
- async buildAndUpload(api, options = { asPublic: false, validate: true }) {
173
- const { asPublic, validate = true } = options;
170
+ async buildLocal(options = { validate: true }) {
171
+ const { validate = true } = options;
174
172
  // Start validation service initialization in parallel with build (if validation enabled)
175
- // This overlaps the ~1.5s init time with the build process
173
+ // This overlaps the init time with the build process.
176
174
  let validationService = null;
177
175
  if (validate) {
178
176
  validationService = ValidationService.create(this.builtDatapackPath);
@@ -182,19 +180,12 @@ export class Challenge {
182
180
  await validationService?.close();
183
181
  throw new Error(`Challenge "${this.shortSlug}" does not exist locally. Make sure both the challenge.ts file and the config.ts file exist.`);
184
182
  }
185
- // Ensure challenge exists in the cloud
186
- if (!(await api.challengeExists(this.shortSlug))) {
187
- console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
188
- console.log(pc.yellow(`Creating challenge: ${this.shortSlug}`));
189
- await api.createChallenge(this.shortSlug);
190
- console.log(pc.green(`✓ Challenge created in cloud`));
191
- }
192
183
  const config = await this.loadConfig();
193
184
  // Build datapack (validation service initializes in parallel)
194
185
  console.log(pc.blue(`>> Building datapack: ${this.shortSlug}`));
195
186
  await this.build(false, config);
196
187
  console.log(pc.green(`✓ Datapack built\n`));
197
- // Validate datapack before uploading (if enabled)
188
+ // Validate datapack after building (if enabled)
198
189
  if (validate && validationService) {
199
190
  console.log(pc.blue(`>> Validating datapack: ${this.shortSlug}`));
200
191
  try {
@@ -202,13 +193,34 @@ export class Challenge {
202
193
  console.log(formatValidationResult(validationResult));
203
194
  console.log();
204
195
  if (validationResult.hasErrors) {
205
- throw new Error(`Validation failed with ${validationResult.errors.length} error(s). Upload aborted.`);
196
+ throw new Error(`Validation failed with ${validationResult.errors.length} error(s). Build aborted.`);
206
197
  }
207
198
  }
208
199
  finally {
209
200
  await validationService.close();
210
201
  }
211
202
  }
203
+ const datapackHash = await this.getDatapackHash();
204
+ return { config, datapackHash };
205
+ }
206
+ /**
207
+ * Build the challenge datapack and upload it to the cloud.
208
+ * @param api - The API client to use.
209
+ * @param options - Build options
210
+ * @param options.asPublic - Whether the challenge should be uploaded as public.
211
+ * @param options.validate - Whether to validate the datapack before upload (default: true).
212
+ * @returns The config and datapack hash.
213
+ */
214
+ async buildAndUpload(api, options = { asPublic: false, validate: true }) {
215
+ const { asPublic, validate = true } = options;
216
+ const { config, datapackHash } = await this.buildLocal({ validate });
217
+ // Ensure challenge exists in the cloud
218
+ if (!(await api.challengeExists(this.shortSlug))) {
219
+ console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
220
+ console.log(pc.yellow(`Creating challenge: ${this.shortSlug}`));
221
+ await api.createChallenge(this.shortSlug);
222
+ console.log(pc.green(`✓ Challenge created in cloud`));
223
+ }
212
224
  // Ensure challenge's visibility is set to private - else, temporarily set it to private
213
225
  const cloudChallengeVisibility = (await api.getChallenge(this.shortSlug, ["visibility"])).visibility;
214
226
  if (cloudChallengeVisibility === "public") {
@@ -230,7 +242,6 @@ export class Challenge {
230
242
  await api.updateChallengeVisibility(this.shortSlug, "public");
231
243
  console.log(pc.green(`✓ Challenge visibility set to public\n`));
232
244
  }
233
- const datapackHash = await this.getDatapackHash();
234
245
  return { config, datapackHash };
235
246
  }
236
247
  /**
@@ -0,0 +1,62 @@
1
+ import type { ChallengeConfigSchemaType, ChallengeSchemaType } from "./schemas.js";
2
+ export interface LocalRunnerConfig {
3
+ challengeData: ChallengeSchemaType;
4
+ challengeConfig: ChallengeConfigSchemaType;
5
+ datapackPath: string;
6
+ arenaImageOverride?: string;
7
+ apiUrl: string;
8
+ apiKey: string;
9
+ /** Full job response from backend — passed directly to the arena (same as Studio) */
10
+ jobPayload: Record<string, unknown>;
11
+ jobId: string;
12
+ runId: string;
13
+ }
14
+ type LogFn = (msg: string) => void;
15
+ export declare class LocalRunner {
16
+ private config;
17
+ private mcContainerId;
18
+ private arenaContainerId;
19
+ private logProcess;
20
+ private tempDir;
21
+ private shutdownInProgress;
22
+ constructor(config: LocalRunnerConfig);
23
+ /**
24
+ * Main orchestration — starts MC server + arena, streams logs, waits for completion.
25
+ */
26
+ run(log: LogFn): Promise<void>;
27
+ /**
28
+ * Gracefully shut down all containers and clean up temp files.
29
+ */
30
+ cleanup(): Promise<void>;
31
+ private ensureDockerAvailable;
32
+ private resolveArenaImage;
33
+ private pullImageIfNeeded;
34
+ private downloadWorld;
35
+ private findLevelDat;
36
+ private startMinecraftServer;
37
+ private waitForServerReady;
38
+ private startArena;
39
+ /**
40
+ * Write a small datapack that auto-ops any player who joins.
41
+ * Uses a tick function with function-permission-level=4 to run /op on new players.
42
+ */
43
+ private writeAutoOpDatapack;
44
+ /**
45
+ * Generate an offline-mode UUID from a Minecraft username.
46
+ * Replicates the Java algorithm: UUID.nameUUIDFromBytes("OfflinePlayer:" + name)
47
+ */
48
+ private generateOfflineUUID;
49
+ private streamLogs;
50
+ private waitForCompletion;
51
+ private stopContainer;
52
+ /**
53
+ * Find and remove orphaned containers from previous local runs.
54
+ */
55
+ static cleanupOrphans(log?: LogFn): void;
56
+ /**
57
+ * Run a Docker command with PATH augmented for macOS Docker Desktop.
58
+ */
59
+ private docker;
60
+ private getDockerEnv;
61
+ }
62
+ export {};