@kradle/challenges-sdk 0.10.0 → 0.12.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/LLM_README.md +235 -2333
  3. package/README.md +150 -570
  4. package/dist/index.d.ts +1 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/runtime/examples/capture-the-flag.d.ts +33 -0
  9. package/dist/runtime/examples/capture-the-flag.d.ts.map +1 -0
  10. package/dist/runtime/examples/capture-the-flag.js +79 -0
  11. package/dist/runtime/examples/capture-the-flag.js.map +1 -0
  12. package/dist/runtime/examples/climb.d.ts +34 -0
  13. package/dist/runtime/examples/climb.d.ts.map +1 -0
  14. package/dist/runtime/examples/climb.js +54 -0
  15. package/dist/runtime/examples/climb.js.map +1 -0
  16. package/dist/runtime/examples/four-bridges.d.ts +107 -0
  17. package/dist/runtime/examples/four-bridges.d.ts.map +1 -0
  18. package/dist/runtime/examples/four-bridges.js +294 -0
  19. package/dist/runtime/examples/four-bridges.js.map +1 -0
  20. package/dist/runtime/examples/herding.d.ts +59 -0
  21. package/dist/runtime/examples/herding.d.ts.map +1 -0
  22. package/dist/runtime/examples/herding.js +181 -0
  23. package/dist/runtime/examples/herding.js.map +1 -0
  24. package/dist/runtime/examples/skywars.d.ts +116 -0
  25. package/dist/runtime/examples/skywars.d.ts.map +1 -0
  26. package/dist/runtime/examples/skywars.js +253 -0
  27. package/dist/runtime/examples/skywars.js.map +1 -0
  28. package/dist/runtime/sandbox-registry.d.ts +7 -0
  29. package/dist/runtime/sandbox-registry.d.ts.map +1 -0
  30. package/dist/runtime/sandbox-registry.js +16 -0
  31. package/dist/runtime/sandbox-registry.js.map +1 -0
  32. package/dist/runtime/sandbox.d.ts +29 -0
  33. package/dist/runtime/sandbox.d.ts.map +1 -0
  34. package/dist/runtime/sandbox.js +70 -0
  35. package/dist/runtime/sandbox.js.map +1 -0
  36. package/dist/runtime/sdk.d.ts +1038 -0
  37. package/dist/runtime/sdk.d.ts.map +1 -0
  38. package/dist/runtime/sdk.js +1271 -0
  39. package/dist/runtime/sdk.js.map +1 -0
  40. package/dist/runtime/world.d.ts +141 -0
  41. package/dist/runtime/world.d.ts.map +1 -0
  42. package/dist/runtime/world.js +344 -0
  43. package/dist/runtime/world.js.map +1 -0
  44. package/dist/test/harness.d.ts +176 -0
  45. package/dist/test/harness.d.ts.map +1 -0
  46. package/dist/test/harness.js +441 -0
  47. package/dist/test/harness.js.map +1 -0
  48. package/dist/test/helpers.d.ts +7 -0
  49. package/dist/test/helpers.d.ts.map +1 -0
  50. package/dist/test/helpers.js +12 -0
  51. package/dist/test/helpers.js.map +1 -0
  52. package/dist/test/index.d.ts +3 -0
  53. package/dist/test/index.d.ts.map +1 -0
  54. package/dist/test/index.js +20 -0
  55. package/dist/test/index.js.map +1 -0
  56. package/package.json +69 -40
  57. package/dist/actions.d.ts +0 -901
  58. package/dist/actions.d.ts.map +0 -1
  59. package/dist/actions.js +0 -1155
  60. package/dist/actions.js.map +0 -1
  61. package/dist/api_utils.d.ts +0 -25
  62. package/dist/api_utils.d.ts.map +0 -1
  63. package/dist/api_utils.js +0 -48
  64. package/dist/api_utils.js.map +0 -1
  65. package/dist/challenge.d.ts +0 -58
  66. package/dist/challenge.d.ts.map +0 -1
  67. package/dist/challenge.js +0 -582
  68. package/dist/challenge.js.map +0 -1
  69. package/dist/commands.d.ts +0 -57
  70. package/dist/commands.d.ts.map +0 -1
  71. package/dist/commands.js +0 -200
  72. package/dist/commands.js.map +0 -1
  73. package/dist/constants.d.ts +0 -78
  74. package/dist/constants.d.ts.map +0 -1
  75. package/dist/constants.js +0 -109
  76. package/dist/constants.js.map +0 -1
  77. package/dist/testmode.d.ts +0 -3
  78. package/dist/testmode.d.ts.map +0 -1
  79. package/dist/testmode.js +0 -19
  80. package/dist/testmode.js.map +0 -1
  81. package/dist/types.d.ts +0 -195
  82. package/dist/types.d.ts.map +0 -1
  83. package/dist/types.js +0 -38
  84. package/dist/types.js.map +0 -1
  85. package/dist/utils.d.ts +0 -17
  86. package/dist/utils.d.ts.map +0 -1
  87. package/dist/utils.js +0 -22
  88. package/dist/utils.js.map +0 -1
package/README.md CHANGED
@@ -2,589 +2,169 @@
2
2
 
3
3
  # @kradle/challenges-sdk
4
4
 
5
- A TypeScript framework for creating Minecraft datapack-based challenges with event-driven game logic, score tracking, and player role management. Built on top of [Sandstone](https://sandstone.dev/).
5
+ Write a Minecraft challenge for Kradle as a reducer: a pure TypeScript function
6
+ that reads the world and the players, updates a JSON state, and asks the server
7
+ for effects. The arena runs it during the game inside a sandbox; the
8
+ `KradleEngine` server plugin reports the facts and applies the effects.
6
9
 
7
10
  ## Getting started
8
11
 
9
- We strongly recommend using [Kradle's CLI](https://github.com/kradle-ai/cli) to create challenges with the CLI. Make sure to read the CLI's README
12
+ Use [Kradle's CLI](https://github.com/kradle-ai/cli) to create challenges. Read
13
+ the CLI's README first.
10
14
 
11
- If you still want to perform a manual installation, you also need to install the Sandstone peer-dependency:
12
15
  ```bash
13
- npm install @kradle/challenges-sdk sandstone@0.14.0-alpha.13
16
+ kradle init
17
+ kradle challenge create my-challenge # challenges/my-challenge/{challenge.ts,config.ts,challenge.test.ts}
18
+ kradle challenge test my-challenge # the tests, inside the arena's sandbox
19
+ kradle challenge build my-challenge # the bundle, uploaded and activated
20
+ kradle challenge run my-challenge # a run in the cloud
14
21
  ```
15
22
 
16
- Add `"type": "module"` to your project's `package.json`. Challenge files use ES module syntax (`import …`) and Node otherwise reparses them as ES modules at every CI run with a `MODULE_TYPELESS_PACKAGE_JSON` warning + per-file overhead.
23
+ A manual installation is one dependency: `npm install @kradle/challenges-sdk@next`
24
+ (0.11 stays under the npm tag `next` until the CLI release installs it).
25
+ Add `"type": "module"` to the project's `package.json`.
17
26
 
18
- ## Quick Start
27
+ ## Local development
19
28
 
20
- Like for installation, we strongly recommend using Kradle's CLI to bootstrap a challenge:
29
+ Working on the SDK and want to test it against a real challenge project (for
30
+ example `team-kradle-challenges`)? Use `npm pack` and install the tarball. Do
31
+ not use `npm link`: the harness checks the challenge objects by identity, and a
32
+ linked package can load two copies of the SDK.
21
33
 
22
34
  ```bash
23
- # Will create a challenge in challenges/my-challenge/
24
- kradle challenge create my-challenge
25
- ```
26
-
27
- ## Local Development
28
-
29
- Working on the SDK locally and want to test against a real challenge project (e.g. `team-kradle-challenges`)?
30
-
31
- **Use `npm pack` + tarball install — not `npm link`.**
32
-
33
- ```bash
34
- # 1. In the SDK repo
35
- npm run build
36
- npm pack # produces kradle-challenges-sdk-<version>.tgz
37
-
38
- # 2. In the consumer project
39
- npm install /absolute/path/to/kradle-challenges-sdk-<version>.tgz
40
- ```
41
-
42
- After every SDK change, repeat both steps (a one-line script is fine).
43
-
44
- ### Why not `npm link`?
45
-
46
- `sandstone` is a peer dependency. `npm link` keeps the SDK's own `node_modules` in play, so the SDK ends up resolving `sandstone` from one location while the consumer resolves it from another. You end up with **two `sandstone` instances loaded in the same process**, which produces some confusing failure modes:
47
-
48
- - `instanceof SelectorClass` returns `false` for selectors created by the consumer (they're instances of the *other* `SelectorClass`).
49
- - The runtime `Flow` registry gets split, so `_.if(...)` calls inside `forEveryPlayer` throw `Entering child function without registering a root function`.
50
-
51
- `npm pack` + install gives the consumer a single flat `sandstone` install, which is what production users get. No duplication, no `instanceof` surprises, no flow-registry split.
52
-
53
- ## API Overview
54
-
55
- ### `createChallenge(config)`
56
-
57
- Creates a new challenge with the specified configuration.
58
-
59
- ```typescript
60
- createChallenge({
61
- name: string; // Challenge name (used for datapack)
62
- roles: readonly string[]; // Player roles (e.g., ["attacker", "defender"])
63
- GAME_DURATION?: number; // Duration in ticks (optional, no time limit if unset)
64
- custom_variables: Record<string, VariableDefinition>;
65
- spawnPoint?: { x, y, z } | SelectorClass; // World spawn point (coordinates or location marker)
66
- })
67
- ```
68
-
69
- ### Fluent Builder Methods
70
-
71
- The challenge builder uses a fluent API:
72
-
73
- ```typescript
74
- createChallenge(config)
75
- .events(callback) // Lifecycle events; call Actions.setEndStates from on_tick
76
- .custom_events(callback) // OPTIONAL — score-threshold side effects (announce, sound, etc.)
77
- .end_condition(callback) // OPTIONAL — when the game should end (auto-includes GAME_DURATION timer)
78
- .win_conditions(callback) // Per-role win conditions
79
- ```
80
-
81
- > For the standard "win / loss / timeout" pattern, call `Actions.setEndStates({ ... })` from inside `events.on_tick` and feed its return value into `.end_condition()` so the game ends as soon as any end-state fires. See the API reference for the full signature.
82
-
83
- ## Variables
84
-
85
- Variables are the core mechanism for tracking game state. All variables are automatically updated each tick.
86
-
87
- ### Built-in Variables
88
-
89
- These variables are always available:
90
-
91
- | Variable | Type | Description |
92
- |----------|------|-------------|
93
- | `death_count` | individual | Number of times the player has died |
94
- | `has_never_died` | individual | 1 if player hasn't died, 0 otherwise |
95
- | `alive_players` | global | Count of living participants |
96
- | `main_score` | individual | Primary score displayed on sidebar |
97
- | `game_timer` | global | Ticks elapsed since game start |
98
- | `game_state` | global | Current state (0=CREATED, 1=OFF, 2=ON) |
99
- | `player_count` | global | Total number of participants |
100
- | `player_number` | individual | Unique player ID (1 to N) |
101
-
102
- ### Custom Variable Types
103
-
104
- #### Individual Variables (per-player)
105
-
106
- Track values for each player separately.
107
-
108
- **Objective-based** (uses Minecraft statistics):
109
- ```typescript
110
- pigs_killed: {
111
- type: "individual",
112
- objective_type: "minecraft.killed:minecraft.pig",
113
- default: 0,
114
- }
115
- ```
116
-
117
- **Dummy** (computed values):
118
- ```typescript
119
- current_height: {
120
- type: "individual",
121
- objective_type: "dummy",
122
- updater: (value) => {
123
- value.set(Actions.getPlayerPosition().y)
124
- },
35
+ # In this repo
36
+ npm run build && npm pack
37
+ # In the consumer project
38
+ npm install --no-save /absolute/path/to/kradle-challenges-sdk-<version>.tgz
39
+ ```
40
+
41
+ Then run `kradle challenge test <slug>` in the consumer project.
42
+
43
+ ## Writing a challenge
44
+
45
+ One call. Every type is inferred from the initializers. Helper functions get
46
+ their context type from the state object with `ContextOf`, before the
47
+ challenge exists.
48
+
49
+ ```ts
50
+ import { type ChallengeConfig, type ContextOf, defineChallenge, minutes, type PlayerIn, Win } from "@kradle/challenges-sdk";
51
+
52
+ export const config = {
53
+ name: "Hide and Seek",
54
+ challengeConfig: {},
55
+ roles: { hider: { description: "hides" }, seeker: { description: "seeks" } },
56
+ } as const satisfies ChallengeConfig;
57
+
58
+ const state = {
59
+ game: () => ({ found: [] as string[] }),
60
+ // State per role; `player` instead of `players` gives every role the same fields.
61
+ players: { hider: () => ({ hiddenAt: null as number | null }), seeker: () => ({ finds: 0 }) },
62
+ zones: { base: { from: { x: -5, y: 60, z: -5 }, to: { x: 5, y: 70, z: 5 } } },
63
+ };
64
+ type G = ContextOf<typeof config, typeof state>;
65
+
66
+ function tag(g: G, seeker: PlayerIn<G>, hider: PlayerIn<G>): void {
67
+ if (seeker.role !== "seeker" || hider.role !== "hider") return; // the role narrows the fields
68
+ seeker.finds += 1;
69
+ seeker.score = seeker.finds;
70
+ g.game.found.push(hider.name);
71
+ g.eliminate(hider);
125
72
  }
126
- ```
127
-
128
- #### Global Variables (shared)
129
-
130
- Track values across all players.
131
-
132
- ```typescript
133
- max_score: {
134
- type: "global",
135
- hidden: false, // optional: hide from scoreboard
136
- default: 0,
137
- updater: (value, { main_score }) => {
138
- value.set(0);
139
- forEveryPlayer(() => {
140
- _.if(main_score.greaterThan(value), () => {
141
- value.set(main_score);
142
- });
143
- });
144
- },
145
- }
146
- ```
147
-
148
- ### Updater Functions
149
-
150
- Updaters run every tick and receive `(currentValue, allVariables)`:
151
-
152
- ```typescript
153
- updater: (value, { main_score, death_count, game_timer }) => {
154
- // Set value based on other variables
155
- value.set(0);
156
- _.if(main_score.greaterThan(10), () => {
157
- value.set(1);
158
- });
159
- }
160
- ```
161
-
162
- If you track a Minecraft objective, you do not need an updater!
163
-
164
- ## Events
165
-
166
- ### Lifecycle Events
167
-
168
- Lifecycle events are triggered once on specific occasions.
169
-
170
- ```typescript
171
- .events((variables, roles) => ({
172
- start_challenge: () => {
173
- // Runs once when the challenge starts. All setup goes here — every
174
- // participant is already connected, all variables are reset to their
175
- // defaults, and player_number is assigned before this runs.
176
- Actions.setTime({ time: "day" });
177
- Actions.announce({ message: "Game starting!" });
178
- Actions.give({ target: "all", item: "minecraft:diamond_sword", count: 1 });
179
- Actions.setAttribute({ target: "all", attribute_: "generic.max_health", value: 40 });
180
- },
181
-
182
- // init_participants is DEPRECATED: it runs in the same tick as
183
- // start_challenge, immediately after it. Move its code to start_challenge.
184
-
185
- on_tick: () => {
186
- // Runs once every tick
187
- },
188
-
189
- end_challenge: () => {
190
- // Runs when the challenge ends
191
- Actions.announce({ message: "Game over!" });
192
- },
193
- }))
194
- ```
195
-
196
- ### Custom Events
197
-
198
- Custom events trigger actions based on score thresholds or Minecraft advancements.
199
-
200
- #### Score-based events
201
-
202
- Score events watch a variable and trigger when it reaches a target value.
203
-
204
- - **`score`**: The variable to watch.
205
- - **`target`** (optional): The target value. If omitted, triggers on any score change
206
- - **`mode`**:
207
- - `"fire_once"`: Triggers once when the score reaches the target (per player for individual variables)
208
- - `"repeatable"`: Triggers every tick while the score is at target
209
-
210
- For individual variables, events fire per-player; for global variables, events fire globally.
211
-
212
- ```typescript
213
- .custom_events((variables, roles) => [
214
- {
215
- score: variables.diamonds,
216
- target: 5,
217
- mode: "fire_once",
218
- actions: () => {
219
- Actions.announce({ message: "Someone collected 5 diamonds!" });
220
- },
221
- },
222
- ])
223
- ```
224
-
225
- If `target` is omitted, the event triggers whenever the score changes (useful for reacting to any increment).
226
-
227
- #### Advancement-based events
228
-
229
- Advancement events trigger when a Minecraft advancement criterion is met (e.g., player attacks, item picked up). The criteria follow the [Minecraft Advancement JSON format](https://minecraft.fandom.com/wiki/Advancement/JSON_format).
230
-
231
- Advancement-based events always fire per-player.
232
-
233
- - **`criteria`**: Array of advancement triggers with optional conditions
234
- - **`mode`**:
235
- - `"fire_once"`: Triggers once per player when the advancement is granted
236
- - `"repeatable"`: Triggers every time the advancement criterion is met (advancement is auto-revoked to allow re-triggering)
237
-
238
- ```typescript
239
- .custom_events((variables, roles) => [
240
- {
241
- criteria: [
242
- {
243
- trigger: "minecraft:player_hurt_entity",
244
- conditions: {
245
- entity: { type: "minecraft:player" } // Only PvP hits
246
- }
247
- }
248
- ],
249
- mode: "repeatable",
250
- actions: () => {
251
- Actions.increment({ variable: variables.pvp_hits });
252
- },
253
- },
254
- ])
255
- ```
256
73
 
257
- ## End Conditions
258
-
259
- Define when the game ends:
260
-
261
- ```typescript
262
- .end_condition(({ objective_complete }) => objective_complete.equalTo(1))
263
- ```
264
-
265
- Multiple conditions can be combined:
266
- ```typescript
267
- .end_condition(({ alive_players, objective_complete }) =>
268
- _.or(
269
- alive_players.equalTo(1),
270
- objective_complete.equalTo(1)
271
- )
272
- )
273
- ```
274
-
275
- Ending conditions are evaluated once per tick, at the global level (not individually). It means you should not check for individual variables here - instead, you should aggregate these individual variables into a global custom variable, that you then check in the ending condition.
276
-
277
- ## Win Conditions
278
-
279
- Define how winners are determined per role:
280
-
281
- ```typescript
282
- .win_conditions((variables, { attacker, defender }) => ({
283
- [attacker]: variables.kills.greaterOrEqualThan(5),
284
- [defender]: variables.survived.equalTo(1),
285
- }))
286
- ```
287
-
288
- ## Actions
289
-
290
- Actions are higher-level functions that wrap common Minecraft operations, designed to work seamlessly with Kradle's challenge system. They handle target mapping, formatting, and integration with Kradle's interface automatically.
291
-
292
- For more advanced use cases, you can always fall back to Sandstone's lower-level functions directly (e.g., `give`, `tellraw`, `effect`, `kill`, `execute`). See the [Sandstone Integration](#sandstone-integration) section below.
293
-
294
- ### Communication
295
- ```typescript
296
- // Simple string message
297
- Actions.announce({ message: "Hello everyone!" });
298
-
299
- // Formatted JSONTextComponent message
300
- Actions.announce({
301
- message: [
302
- { text: "Player ", color: "white" },
303
- { selector: "@s", color: "gold", bold: true },
304
- { text: " scored!", color: "green" }
305
- ]
74
+ export default defineChallenge(config, {
75
+ ...state,
76
+ duration: minutes(3),
77
+ life: "respawn",
78
+ start(g) {
79
+ for (const h of g.role("hider")) g.tell(h, "Hide! The seekers are released in 30 seconds.");
80
+ },
81
+ on: {
82
+ zoneLeft(g, { player, zone }) {
83
+ if (zone === "base" && player.role === "hider") player.hiddenAt = g.elapsed;
84
+ },
85
+ damaged(g, { player, attacker }) {
86
+ if (attacker) tag(g, attacker, player);
87
+ },
88
+ },
89
+ end: (g) => g.role("hider").every((h) => h.eliminated),
90
+ winners: Win.topScore({ min: 1 }),
306
91
  });
307
-
308
- // Send to specific target (only visible in-game, not in Kradle's interface)
309
- Actions.tellraw({ target: "all", message: ["Hello ", { text: "world", color: "gold" }] });
310
- Actions.tellraw({ target: "self", message: { text: "You win!", color: "green", bold: true } });
311
- ```
312
-
313
- ### Items & Inventory
314
- ```typescript
315
- Actions.give({ target: "self", item: "minecraft:diamond_sword", count: 1 });
316
- Actions.giveLoot({
317
- target: "self",
318
- items: [
319
- { name: "minecraft:diamond", count: 5, weight: 1 },
320
- { name: "minecraft:iron_ingot", count: 10, weight: 3 }
321
- ]
322
- });
323
-
324
- Actions.clear({ target: "self" });
325
-
326
- // Count items - returns a Score variable
327
- const count = Actions.countItems({ target: "self", item: "minecraft:diamond" });
328
- // Use in conditions or set to custom variables
329
- _.if(count.greaterThan(5), () => { /* ... */ });
330
-
331
- // Get player position - returns { x, y, z } Score variables
332
- const pos = Actions.getCurrentPlayerPosition();
333
- _.if(pos.y.greaterThan(100), () => { /* player is high up */ });
334
- ```
335
-
336
- ### Entities
337
- ```typescript
338
- Actions.summonMultiple({ entity: "minecraft:zombie", count: 5, x: 0, y: 64, z: 0, absolute: true });
339
- Actions.kill({ selector: Selector("@e", { type: "minecraft:zombie" }) });
340
- Actions.teleport({ target: "self", x: 0, y: 100, z: 0, absolute: true });
341
-
342
- // Teleport to a location marker entity
343
- const spawn = Actions.getLocation({ name: "spawn" });
344
- Actions.teleport({ target: "all", toEntity: spawn });
345
-
346
- // Force player(s) to interrupt their code and WALK to coordinates (a real
347
- // pathfind — never teleports, even in cheat mode). Server-initiated, absolute
348
- // coords. "self" only resolves inside forEveryPlayer; raw "@..." is rejected.
349
- Actions.forceMoveTo({ target: "all", x: 0, y: -60, z: 0 });
350
- ```
351
-
352
- ### Selectors
353
- ```typescript
354
- // "Is @s standing on the block at (x, y, z)?" — returns a Selector usable
355
- // inside _.if(...). Use this instead of Selector("@s", { x, y, z }) which
356
- // silently passes for everyone (Minecraft selector x/y/z without dx/dy/dz
357
- // is the origin for distance math, not a position constraint).
358
- _.if(Actions.standingOnBlock({ x: PAD.x, y: PAD.y, z: PAD.z, role: "alice" }), () => {
359
- // Alice is on the pad above (PAD.x, PAD.y, PAD.z). Run trigger logic here.
360
- });
361
- ```
362
-
363
- ### Cross-player score aggregation
364
- ```typescript
365
- // Compute the maximum across all players' per-player scores. Returns an
366
- // anonymous Score the caller can write into a global custom_variable.
367
- // Lets `Actions.setEndStates` / `.end_condition` reference "any player has X"
368
- // semantics correctly (those checks run as the server entity, so per-player
369
- // scores read 0 there).
370
- max_score: {
371
- type: "global",
372
- default: 0,
373
- updater: (value, { score }) => {
374
- value.set(Actions.maxPlayerScore({ score }));
375
- // Pass `min: -1000` if your per-player score can go below 0.
376
- },
377
- }
378
- ```
379
-
380
- ### Locations
381
- ```typescript
382
- // Get a location marker by name (must be defined in config.ts locations)
383
- const spawn = Actions.getLocation({ name: "spawn" });
384
-
385
- // Use as spawn point in challenge config
386
- createChallenge({
387
- // ...
388
- spawnPoint: Actions.getLocation({ name: "spawn" }),
389
- });
390
- ```
391
-
392
- ### World
393
- ```typescript
394
- Actions.setBlock({ block: "minecraft:diamond_block", x: 0, y: 64, z: 0, absolute: true });
395
- Actions.fill({
396
- block: "minecraft:stone",
397
- x1: 0, y1: 64, z1: 0,
398
- x2: 10, y2: 64, z2: 10,
399
- absolute: true,
400
- mode: "fill" // "fill", "line", or "pyramid"
401
- });
402
- Actions.setTime({ time: "day" }); // or "night" or specific tick value
403
- Actions.gamerule({ rule: "doDaylightCycle", value: false });
404
- ```
405
-
406
- ### Scores
407
- ```typescript
408
- Actions.set({ variable: variables.main_score, value: 10 });
409
- Actions.set({ variable: variables.main_score, value: variables.diamonds });
410
- Actions.increment({ variable: variables.counter });
411
- Actions.decrement({ variable: variables.counter });
412
- ```
413
-
414
- ### Voting
415
-
416
- Declare votes in `config.ts` (`challengeConfig.votingOptions`, e.g. `{ selectroom: ["red", "green", "blue"] }`). Agents cast a vote with `skills.voteForOption(voteId, option)`; the arena records it as player tags the actions below read. See LLM_README.md → "Voting" for the full flow.
417
-
418
- ```typescript
419
- // Announce the vote to all agents.
420
- Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: ["red", "green", "blue"] });
421
-
422
- // Condition: did this player vote (for a specific option)?
423
- Actions.votedFor("selectroom"); // voted at all
424
- Actions.votedFor("selectroom", "red"); // voted red
425
-
426
- // Resolve when everyone's in (or a 30s timer fires), defaulting non-voters, then move by choice.
427
- // votedFor is a per-player condition, so check it inside forEveryPlayer (makes @s each participant):
428
- _.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
429
- Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
430
- forEveryPlayer(() => {
431
- _.if(Actions.votedFor("selectroom", "red"), () => {
432
- Actions.teleport({ target: "self", x: 10, y: -60, z: 10, absolute: true });
433
- });
434
- });
435
- });
436
- ```
437
-
438
- ### Player Attributes
439
- ```typescript
440
- Actions.setAttribute({ target: "self", attribute_: "generic.max_health", value: 40 });
441
- Actions.setAttribute({ target: "self", attribute_: "generic.movement_speed", value: 0.2 });
442
- ```
443
-
444
- ### Custom Commands
445
-
446
- When no dedicated Action covers what you need, call Sandstone commands directly inside any handler — no wrapper required:
447
-
448
- ```typescript
449
- // Any Sandstone code here
450
- execute.as("@a").run.effect.give("@s", "speed", 10, 1);
451
- ```
452
-
453
- ## Utilities
454
-
455
- ### `forEveryPlayer(callback)`
456
-
457
- Execute code for each participant at their location:
458
-
459
- ```typescript
460
- import { forEveryPlayer } from "@kradle/challenges-sdk";
461
-
462
- forEveryPlayer(() => {
463
- // Runs as each player, at their position
464
- execute.run.particle("minecraft:flame", rel(0, 1, 0));
465
- });
466
- ```
467
-
468
- ## Example: Battle Royale
469
-
470
- ```typescript
471
- import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
472
- import { _, Selector } from "sandstone";
473
-
474
- createChallenge({
475
- name: "battle-royale",
476
- roles: ["fighter"] as const,
477
- GAME_DURATION: 5 * 60 * 20,
478
- custom_variables: {
479
- kills: {
480
- type: "individual",
481
- objective_type: "playerKillCount",
482
- default: 0,
483
- },
484
- sole_survivor: {
485
- type: "individual",
486
- updater: (value, { alive_players, has_never_died }) => {
487
- value.set(0);
488
- _.if(_.and(
489
- alive_players.equalTo(1),
490
- has_never_died.equalTo(1)
491
- ), () => {
492
- value.set(1);
493
- });
494
- },
495
- },
496
- },
497
- })
498
- .events(() => ({
499
- start_challenge: () => {
500
- Actions.setTime({ time: "day" });
501
- Actions.announce({ message: "Last player standing wins!" });
502
- Actions.give({ target: "all", item: "minecraft:stone_sword", count: 1 });
503
- Actions.give({ target: "all", item: "minecraft:leather_chestplate", count: 1 });
504
- },
505
- }))
506
- .custom_events(({ kills }) => [
507
- {
508
- score: kills,
509
- target: 1,
510
- mode: "fire_once",
511
- actions: () => {
512
- Actions.announce({ message: "First blood!" });
513
- },
514
- },
515
- ])
516
- .end_condition(({ alive_players }) => alive_players.equalTo(1))
517
- .win_conditions(({ sole_survivor }, { fighter }) => ({
518
- [fighter]: sole_survivor.equalTo(1),
519
- }));
520
- ```
521
-
522
- ## Example: Capture the Flag
523
-
524
- ```typescript
525
- import { createChallenge, Actions } from "@kradle/challenges-sdk";
526
- import { _, Selector, rel } from "sandstone";
527
- import type { Score } from "sandstone";
528
-
529
- createChallenge({
530
- name: "capture-the-flag",
531
- roles: ["red_team", "blue_team"] as const,
532
- GAME_DURATION: 5 * 60 * 20,
533
- custom_variables: {
534
- holds_enemy_flag: {
535
- type: "individual",
536
- updater: (value: Score) => {
537
- value.set(0);
538
- // Check if player holds the enemy team's banner
539
- _.if(Selector("@s", {
540
- nbt: { Inventory: [{ id: "minecraft:red_banner" }] }
541
- }), () => {
542
- value.set(1);
543
- });
544
- },
545
- },
546
- at_home_base: {
547
- type: "individual",
548
- updater: (value: Score) => {
549
- value.set(0);
550
- _.if(_.block(rel(0, -1, 0), "minecraft:blue_wool"), () => {
551
- value.set(1);
552
- });
553
- },
554
- },
555
- captured_flag: {
556
- type: "individual",
557
- updater: (value, { holds_enemy_flag, at_home_base }) => {
558
- value.set(0);
559
- _.if(_.and(
560
- holds_enemy_flag.equalTo(1),
561
- at_home_base.equalTo(1)
562
- ), () => {
563
- value.set(1);
564
- });
565
- },
566
- },
567
- },
568
- })
569
- .events(() => ({
570
- start_challenge: () => {
571
- Actions.announce({ message: "Capture the enemy flag!" });
572
- },
573
- }))
574
- .custom_events(({ captured_flag }) => [
575
- {
576
- score: captured_flag,
577
- target: 1,
578
- mode: "fire_once",
579
- actions: () => {
580
- Actions.announce({ message: "Flag captured!" });
581
- },
582
- },
583
- ])
584
- .end_condition(({ captured_flag }) => captured_flag.equalTo(1))
585
- .win_conditions(({ captured_flag }, { red_team, blue_team }) => ({
586
- [red_team]: captured_flag.equalTo(1),
587
- [blue_team]: captured_flag.equalTo(0),
588
- }));
589
92
  ```
590
93
 
94
+ `@kradle/challenges-sdk` is the API. The concepts: roles, phases, timers, votes, end states, a judge. The state
95
+ is plain JSON, and a run replays from its event log.
96
+
97
+ ## Reading the world
98
+
99
+ The rules read the world through a mirror the engine keeps up to date: every
100
+ player (`g.players`, `g.others`, with `pos`, `yaw`, `pitch`, `headYaw`,
101
+ `entityId`, `uuid`, `equipment`, `invisible`), every entity
102
+ (`g.entities({ box, type, tag })`), every loaded chunk (`g.blockAt` for the id,
103
+ `g.blockStateAt` for the full state, `g.blockCount`, `g.findBlocks`), the
104
+ world time (`g.worldTime`), and each participant's inventory (`p.inventory`,
105
+ `p.slots`, `g.count(p, item)`). Nothing is declared to the engine for that,
106
+ except the chests a challenge reads: `containers: { delivery: { x, y, z } }` in
107
+ the spec makes the engine report their contents; `g.contents("delivery")` gives
108
+ the item counts and `on.container(g, { at, block, items, changes, by })` fires
109
+ on every change. A report for an undeclared position is ignored.
110
+ Zones are named boxes the SDK evaluates from the positions every tick;
111
+ `g.inZone(p, box)` takes any box too. The `mirror` option narrows what the
112
+ engine streams when a world is large. `src/runtime/examples/herding.ts` scores
113
+ sheep inside a pen from the entity mirror and watches a fence gap through the
114
+ block mirror.
115
+
116
+ `typeof x.ctx` on a challenge is the same type; use it in tests. In the file that
117
+ defines the challenge it works only for helpers called from inside a rule
118
+ (`endState: (g) => classify(g)`): a helper passed as the property value
119
+ (`endState: classify`) would make the challenge's type circular.
120
+ `onOver(g, result)` runs once the result is final: `result.winners` (names), `result.winnerPlayers`, `result.isWinner(player)` and `result.endState`.
121
+
122
+ ## Testing
123
+
124
+ Test without Minecraft with `@kradle/challenges-sdk/test`. It exports `Game`, `Fake`, `sandboxed`, and two helpers: `scores(game)` (the score per player) and `near(a, b, blocks)` (a distance check on each axis, x and z):
125
+
126
+ ```js
127
+ const game = new Game(climb).begin(1, { alice: "climber", bob: "climber" });
128
+ game.player("alice").moveTo({ y: -40 });
129
+ game.at("2m");
130
+ assert.deepEqual(game.state.winners, ["alice"]);
131
+ game.replay();
132
+ ```
133
+
134
+ The arena runs the reducer inside a SES compartment: no clock, no random, no
135
+ timers, no `process`, no network. The harness alone does not apply those limits.
136
+ `kradle challenge test <slug>` builds the challenge and runs its tests against the
137
+ built bundle inside that sandbox. Wrap the import with `sandboxed()`: the test
138
+ then uses the bundle under the command, and the plain import elsewhere.
139
+
140
+ ```js
141
+ import { Game, sandboxed } from "@kradle/challenges-sdk/test";
142
+ import climbSource from "./challenge.ts";
143
+
144
+ const climb = await sandboxed(climbSource);
145
+ ```
146
+
147
+ `sandboxed()` reads `KRADLE_CHALLENGE_BUNDLE`, the bundle path the command sets,
148
+ and refuses a bundle that holds a different challenge name. It is async (`ses`
149
+ loads as an ES module), so await it at the top of the test file. A rule that calls
150
+ `Date.now()` or `Math.random()` passes in the plain harness and fails under the
151
+ command, as it fails in the arena; use `g.tick`, `g.elapsed`, `g.rand()` and
152
+ `g.schedule()` instead. The loader itself is
153
+ `@kradle/challenges-sdk/runtime/sandbox` (`lockdownOnce()`, `loadBundle()`,
154
+ `loadBundleFile()`) for a test runner of your own. Call it in a dedicated
155
+ process: the lockdown changes the intrinsics of the whole process. Under the
156
+ command a `Game` on the imported source throws: the command wants the bundle,
157
+ and it counts the Games built on it (`KRADLE_CHALLENGE_EVIDENCE`) as its proof
158
+ that the rules ran inside the sandbox.
159
+
160
+ ## Build and run
161
+
162
+ `kradle challenge build <slug>` bundles `challenge.ts` into one file, loads it
163
+ once inside the sandbox, uploads it and activates it with the challenge's
164
+ configuration. The server runs the `KradleEngine` plugin; the arena connects
165
+ the reducer to it. The run's event log is uploaded as the `runtime` recording.
166
+
167
+ Reference challenges: `src/runtime/examples/` (Climb, Capture the Flag, Four
168
+ Bridges, Skywars, Sheep Herding). `src/runtime/type-checks.ts` shows what the
169
+ types catch. The engine contract (`Event`, `Effect`, `Action`) is in
170
+ `src/runtime/sdk.ts`; the server side is `backend/engine-plugin`.