@kradle/challenges-sdk 0.12.0 → 0.13.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 (85) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/LLM_README.md +285 -192
  3. package/MIGRATION-0.13.md +109 -0
  4. package/README.md +97 -88
  5. package/dist/engine.d.ts +8 -0
  6. package/dist/engine.d.ts.map +1 -0
  7. package/dist/engine.js +17 -0
  8. package/dist/engine.js.map +1 -0
  9. package/dist/index.d.ts +19 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +42 -16
  12. package/dist/index.js.map +1 -1
  13. package/dist/runtime/builder.d.ts +33 -0
  14. package/dist/runtime/builder.d.ts.map +1 -0
  15. package/dist/runtime/builder.js +357 -0
  16. package/dist/runtime/builder.js.map +1 -0
  17. package/dist/runtime/errors.d.ts +61 -0
  18. package/dist/runtime/errors.d.ts.map +1 -0
  19. package/dist/runtime/errors.js +71 -0
  20. package/dist/runtime/errors.js.map +1 -0
  21. package/dist/runtime/reducer.d.ts +205 -0
  22. package/dist/runtime/reducer.d.ts.map +1 -0
  23. package/dist/runtime/reducer.js +2025 -0
  24. package/dist/runtime/reducer.js.map +1 -0
  25. package/dist/runtime/rules.d.ts +29 -0
  26. package/dist/runtime/rules.d.ts.map +1 -0
  27. package/dist/runtime/rules.js +23 -0
  28. package/dist/runtime/rules.js.map +1 -0
  29. package/dist/runtime/sandbox.d.ts +2 -1
  30. package/dist/runtime/sandbox.d.ts.map +1 -1
  31. package/dist/runtime/sandbox.js +7 -4
  32. package/dist/runtime/sandbox.js.map +1 -1
  33. package/dist/runtime/time.d.ts +33 -0
  34. package/dist/runtime/time.d.ts.map +1 -0
  35. package/dist/runtime/time.js +53 -0
  36. package/dist/runtime/time.js.map +1 -0
  37. package/dist/runtime/types.d.ts +963 -0
  38. package/dist/runtime/types.d.ts.map +1 -0
  39. package/dist/runtime/types.js +25 -0
  40. package/dist/runtime/types.js.map +1 -0
  41. package/dist/runtime/wire.d.ts +858 -0
  42. package/dist/runtime/wire.d.ts.map +1 -0
  43. package/dist/runtime/wire.js +28 -0
  44. package/dist/runtime/wire.js.map +1 -0
  45. package/dist/runtime/world.d.ts +58 -3
  46. package/dist/runtime/world.d.ts.map +1 -1
  47. package/dist/runtime/world.js +12 -0
  48. package/dist/runtime/world.js.map +1 -1
  49. package/dist/structures.d.ts +119 -0
  50. package/dist/structures.d.ts.map +1 -0
  51. package/dist/structures.js +279 -0
  52. package/dist/structures.js.map +1 -0
  53. package/dist/test/harness.d.ts +133 -29
  54. package/dist/test/harness.d.ts.map +1 -1
  55. package/dist/test/harness.js +191 -66
  56. package/dist/test/harness.js.map +1 -1
  57. package/dist/test/helpers.d.ts +3 -2
  58. package/dist/test/helpers.d.ts.map +1 -1
  59. package/dist/test/helpers.js +2 -2
  60. package/dist/test/helpers.js.map +1 -1
  61. package/package.json +28 -6
  62. package/dist/runtime/examples/capture-the-flag.d.ts +0 -33
  63. package/dist/runtime/examples/capture-the-flag.d.ts.map +0 -1
  64. package/dist/runtime/examples/capture-the-flag.js +0 -79
  65. package/dist/runtime/examples/capture-the-flag.js.map +0 -1
  66. package/dist/runtime/examples/climb.d.ts +0 -34
  67. package/dist/runtime/examples/climb.d.ts.map +0 -1
  68. package/dist/runtime/examples/climb.js +0 -54
  69. package/dist/runtime/examples/climb.js.map +0 -1
  70. package/dist/runtime/examples/four-bridges.d.ts +0 -107
  71. package/dist/runtime/examples/four-bridges.d.ts.map +0 -1
  72. package/dist/runtime/examples/four-bridges.js +0 -294
  73. package/dist/runtime/examples/four-bridges.js.map +0 -1
  74. package/dist/runtime/examples/herding.d.ts +0 -59
  75. package/dist/runtime/examples/herding.d.ts.map +0 -1
  76. package/dist/runtime/examples/herding.js +0 -181
  77. package/dist/runtime/examples/herding.js.map +0 -1
  78. package/dist/runtime/examples/skywars.d.ts +0 -116
  79. package/dist/runtime/examples/skywars.d.ts.map +0 -1
  80. package/dist/runtime/examples/skywars.js +0 -253
  81. package/dist/runtime/examples/skywars.js.map +0 -1
  82. package/dist/runtime/sdk.d.ts +0 -1038
  83. package/dist/runtime/sdk.d.ts.map +0 -1
  84. package/dist/runtime/sdk.js +0 -1271
  85. package/dist/runtime/sdk.js.map +0 -1
package/LLM_README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # LLM_README.md - @kradle/challenges-sdk API Reference
2
2
 
3
- This document is the exhaustive API reference for AI agents that write Kradle challenges with `@kradle/challenges-sdk`. A challenge is a reducer: a pure function that the arena runs during the game inside a sandbox. Read this file before you write a challenge.
3
+ This document is the API reference for AI agents that write Kradle challenges with `@kradle/challenges-sdk`. A challenge is a reducer: a pure function that the arena runs during the game inside a sandbox. Read this file before you write a challenge. The declarations (`.d.ts`) carry the full JSDoc of every member; this file is the tutorial and the index.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -8,27 +8,28 @@ This document is the exhaustive API reference for AI agents that write Kradle ch
8
8
  2. [Installation](#installation)
9
9
  3. [Core Concepts](#core-concepts)
10
10
  4. [defineChallenge API](#definechallenge-api)
11
- 5. [The Spec](#the-spec)
11
+ 5. [The Steps](#the-steps)
12
12
  6. [Handlers](#handlers)
13
13
  7. [The Context](#the-context)
14
14
  8. [Players and Entities](#players-and-entities)
15
- 9. [Rules of the Reducer](#rules-of-the-reducer)
16
- 10. [Testing](#testing)
17
- 11. [Build and Run](#build-and-run)
18
- 12. [Complete Examples](#complete-examples)
19
- 13. [Common Patterns](#common-patterns)
20
- 14. [Common Gotchas](#common-gotchas)
21
- 15. [Tips for LLMs](#tips-for-llms)
15
+ 9. [Structures](#structures)
16
+ 10. [Rules of the Reducer](#rules-of-the-reducer)
17
+ 11. [Testing](#testing)
18
+ 12. [Build and Run](#build-and-run)
19
+ 13. [Complete Examples](#complete-examples)
20
+ 14. [Common Patterns](#common-patterns)
21
+ 15. [Common Gotchas](#common-gotchas)
22
+ 16. [Tips for LLMs](#tips-for-llms)
22
23
 
23
24
  ---
24
25
 
25
26
  ## Overview
26
27
 
27
- `@kradle/challenges-sdk` is a TypeScript SDK for Minecraft challenges that AI agents play on Kradle. A challenge is one call, `defineChallenge(config, spec)`, that returns a reducer: `reduce(state, event) -> { state, actions }`. The arena feeds it the facts the `KradleEngine` server plugin reports (players, entities, loaded chunks, block changes, chat, deaths, pickups) and applies the effects it asks for (give, fill, teleport, titles, votes, the end of the game). The state is plain JSON, and a run replays from its event log.
28
+ `@kradle/challenges-sdk` is a TypeScript SDK for Minecraft challenges that AI agents play on Kradle. A challenge is one root call, `defineChallenge(config, decl)`, plus steps that register the behavior: `.setup(...)`, `.start(...)`, `.phase(name, {...})`, `.on({...})`, `.outcome({...})`. The result is a reducer: `reduce(state, event) -> { state, actions }`. The arena feeds it the facts the `KradleEngine` server plugin reports (players, entities, loaded chunks, block changes, containers, chat, deaths, pickups, votes), every server tick, and applies the effects it asks for (give, fill, teleport, titles, votes, the end of the game). The state is plain JSON, and a run replays from its event log.
28
29
 
29
30
  There is no datapack and no mcfunction. The rules are TypeScript, the tests run without Minecraft, and `kradle challenge test` runs them inside the same sandbox as the arena.
30
31
 
31
- `@kradle/challenges-sdk` is the API. The test kit is `@kradle/challenges-sdk/test`; the sandbox loader is `@kradle/challenges-sdk/runtime/sandbox`.
32
+ The package's entries: `@kradle/challenges-sdk` (the author API), `@kradle/challenges-sdk/test` (the test kit), `@kradle/challenges-sdk/engine` (the wire protocol, for the arena and tools).
32
33
 
33
34
  ---
34
35
 
@@ -44,9 +45,9 @@ kradle challenge build my-challenge # the bundle, uploaded and activated
44
45
  kradle challenge run my-challenge # a run in the cloud
45
46
  ```
46
47
 
47
- A manual installation is `npm install @kradle/challenges-sdk@next` (0.11 stays under the npm tag `next` until the CLI release installs it) with `"type": "module"` in `package.json`. Node 22 or later.
48
+ A manual installation is `npm install @kradle/challenges-sdk@next` with `"type": "module"` in `package.json`. Node 22 or later.
48
49
 
49
- A challenge folder holds `challenge.ts` (the rules, `export default defineChallenge(config, ...)`), `config.ts` (the metadata and the prompts, `export const config`), and its test, `challenge.test.ts`. `kradle ai-docs challenge-config` documents `config.ts`.
50
+ A challenge folder holds `challenge.ts` (the rules), `config.ts` (the metadata and the prompts, `export const config`), and `challenge.test.ts`. `kradle ai-docs challenge-config` documents `config.ts`.
50
51
 
51
52
  ---
52
53
 
@@ -54,293 +55,385 @@ A challenge folder holds `challenge.ts` (the rules, `export default defineChalle
54
55
 
55
56
  **The reducer.** Every event from the engine goes through `reduce(state, event)`. The reducer returns the next state and a list of actions. It never keeps state outside the returned state, never mutates its input, and never touches the clock, the network or the file system. The arena runs it inside a SES compartment: `Date.now()`, `Math.random()`, timers, `process` and `fetch` do not exist there.
56
57
 
57
- **The state.** `g.game` (shared) and one object per player. Both come from the initializers in the spec (`game: () => ({...})`, `player: () => ({...})`). The SDK adds its own fields to every player (`score`, `alive`, `pos`, `inventory`, ...). The whole state is JSON.
58
+ **The state.** `g.state` (shared) and `p.state` (per player). Both come from the initializers in the root declaration (`state: { game: (init) => ({...}), player: (p, init) => ({...}) }`). The SDK keeps its own fields beside them: `p.score`, `p.pos`, `p.inventory`, `p.isActive`... The whole state is JSON.
58
59
 
59
- **The world mirror.** The engine streams the players, the entities, the loaded chunks and the block changes. The SDK keeps them in the state and answers `g.blockAt`, `g.entities`, `g.players` from that mirror. One read is declared: the `containers` of the spec, which the plugin samples; every other read comes from the mirror.
60
+ **The stages.** `CONNECTING` until every participant is connected and has a position; then the initializers run, then `setup` (the world build, confirmed by the engine), then `PLAYING` (`start`, the phases, the handlers), then `OVER`.
60
61
 
61
- **Effects.** `g.give`, `g.fill`, `g.teleport`, `g.say`, `g.end`, ... queue effects. The arena sends them to the plugin after the reduction. A failed effect comes back as the `effectFailed` event.
62
+ **The world mirror.** The engine streams the players, the entities, every loaded chunk, the block changes and the containers. The SDK keeps them in the state and answers `g.blockAt`, `g.entities`, `g.containerContents`, `g.players` from that mirror. Nothing is declared: the mirror holds everything the server has loaded.
62
63
 
63
- **Time.** Durations are ticks (20 per second). `seconds(30)`, `minutes(3)`, `ticks(40)`, or the strings `"30s"`, `"2m"`, `"40t"`. `g.tick`, `g.elapsed` and the timers (`g.schedule`) are the only clocks.
64
+ **Effects.** `g.give`, `g.fill`, `g.teleport`, `g.say`... queue commands for the engine. They are applied on the next server tick and reported back as facts (a placed block comes back through the mirror). A refused effect arrives as `on.effectFailed`.
64
65
 
65
- **Determinism.** `g.rand`, `g.pick` and `g.shuffle` are seeded from the run. The same event log gives the same state: `game.replay()` in a test reduces the log again and checks it.
66
+ **Phases.** Named stages of the game with their own `duration`, `until`, `enter`, `exit`, timed steps and handlers. A phase or end request from a callback applies after the callback returns: exit once, enter once.
67
+
68
+ **The result.** `outcome.scores` derives the scores after every reduction, `outcome.endWhen` ends the game, `outcome.winners` and `outcome.endState` classify it. The result is frozen before `onOver` and `judgeContext` see it.
66
69
 
67
70
  ---
68
71
 
69
72
  ## defineChallenge API
70
73
 
71
74
  ```ts
72
- import { type ChallengeConfig, type ContextOf, defineChallenge, minutes, type PlayerIn, Win } from "@kradle/challenges-sdk";
75
+ import { defineChallenge, type CtxOf, type ReadCtxOf, winBy } from "@kradle/challenges-sdk";
76
+ import { config } from "./config";
73
77
 
74
- export const config = {
75
- name: "Sheep Herding",
76
- challengeConfig: {},
77
- roles: { herder: { description: "Herd sheep into your pen.", minParticipants: 2 } },
78
- endStates: { all_penned: "every sheep is in a pen", time_up: "the time ran out" },
79
- } as const satisfies ChallengeConfig;
78
+ const challenge = defineChallenge(config, {
79
+ state: {
80
+ game: (init) => ({ round: 0 }), // g.state
81
+ player: (p, init) => ({ startY: Math.floor(p.pos.y) }), // p.state, every role
82
+ players: { seeker: (p, init) => ({ found: 0 }) }, // p.state for one role
83
+ },
84
+ life: "respawn", // "single": one death eliminates. Required.
85
+ duration: "5m", // the game's length; omit for none
86
+ world: { time: "day" }, // spawn, time, daylightCycle, mobSpawning, naturalRegeneration, maxHealth; false = none
87
+ zones: { pit: { from: { x: 0, y: -64, z: 0 }, to: { x: 9, y: -50, z: 9 } } },
88
+ timers: ["raid"],
89
+ votes: { room: { options: ["red", "blue"], final: true }, player: { final: true } },
90
+ phases: ["BUILD", "FIGHT"], // the names; each gets a .phase() step
91
+ initialPhase: "BUILD",
92
+ markers: { tags: ["kradle.spawn"] },
93
+ });
80
94
 
81
- const state = {
82
- game: () => ({ sheep: 0 }),
83
- player: () => ({ closed: false }),
84
- };
85
- type G = ContextOf<typeof config, typeof state>;
95
+ type G = CtxOf<typeof challenge>; // the gameplay context, for helpers
96
+ type R = ReadCtxOf<typeof challenge>; // the read-only context, for pure helpers
97
+
98
+ export default challenge
99
+ .setup((g) => { /* the world build */ })
100
+ .start((g) => { /* the first tick of the game */ })
101
+ .phase("BUILD", { duration: "2m", next: "FIGHT", enter(g) {}, on: {} })
102
+ .phase("FIGHT", { next: "END", until: (g) => g.activePlayers.length <= 1 })
103
+ .on({ died(g, { player, killer }) {} })
104
+ .outcome({ scores: (g, p) => (p.role === "seeker" ? p.state.found : 0), winners: winBy.highestScore() })
105
+ .onOver((g, result) => g.say("Game over"));
106
+ ```
86
107
 
87
- function penned(g: G, p: PlayerIn<G>): number {
88
- return g.entities({ box: penOf(p), type: "minecraft:sheep" }).length;
89
- }
108
+ The root declaration holds data, the state initializers and pure predicates (a vote's `accept`). No event handler lives in it. Every literal type comes from it: the phase names, the zone names, the timer names, the vote ids and options, the roles and end states of `config`. A typo in any of them does not compile.
90
109
 
91
- export default defineChallenge(config, {
92
- ...state,
93
- duration: minutes(3),
94
- life: "respawn",
95
- world: { spawn: { x: 0, y: -60, z: 0 }, time: "day" },
96
- setup(g) { /* fill, setblock, summon: one batch before the game */ },
97
- start(g) { for (const p of g.players) g.give(p, "minecraft:wheat", 16); },
98
- on: {
99
- tick: (g) => { for (const p of g.players) p.score = penned(g, p); },
100
- blocksChanged(g, { changes }) { /* the fence gap closed? */ },
101
- },
102
- end: (g) => g.game.sheep > 0 && g.players.reduce((n, p) => n + penned(g, p), 0) >= g.game.sheep,
103
- endState: (g) => (g.game.sheep > 0 && g.players.reduce((n, p) => n + penned(g, p), 0) >= g.game.sheep ? "all_penned" : "time_up"),
104
- winners: Win.topScore({ min: 1 }),
105
- });
106
- ```
110
+ The steps register behavior and return the same challenge object, so a definition reads as one chain or as separate statements (`challenge.on({...})` after `export default challenge`). The definition locks on first use (`init()`, or the manifest read); a step after the lock throws.
107
111
 
108
- `defineChallenge(config, spec)` is the whole API. Every type comes from the initializers: `game()` gives `g.game`, `player()` or `players: { role: () => ({...}) }` gives the per-player fields, `zones` gives the zone names, `timers` gives the timer names, `config.roles` gives the role names, `config.endStates` gives the end states, `config.challengeConfig.votingOptions` gives the vote ids and options, `config.challengeConfig.locations` gives the location names, `config.params` gives `g.params`.
112
+ **Helpers.** Name the root call in a const and type helpers from it: `CtxOf<typeof challenge>` (handlers), `ReadCtxOf<typeof challenge>` (pure callbacks), `SetupCtxOf<typeof challenge>` (world builders), `PlayerIn<G>` (a player). The chain hangs off the const, so the helper's type never depends on the steps. A helper called by a root predicate is typed independently.
109
113
 
110
- `ContextOf<typeof config, typeof state>` is the context type for helper functions, declared before the challenge. A helper typed that way can be a rule's value (`endState: classify`) or be called from a rule. Only a helper whose type comes from the challenge value itself (`typeof challenge.ctx`) makes the challenge's type circular when it is passed as a rule value. `PlayerIn<G>` is the player type of a context. `ContextOfChallenge<typeof challenge>` is the context type of a built challenge, for tests.
114
+ **State initializers.** They run once, when the last participant is connected and positioned, before `setup`. `player(p, init)` receives the observed player (position included, no state yet); `game(init)` and `player` receive `init = { params, random, players, locations }`. `init.random` is the run's seeded generator. The return value is JSON: no functions, no class instances, no player objects (store names).
111
115
 
112
- Exports: `defineChallenge`, `End` (`End.allEliminated`), `Win` (`Win.topScore({ min?, eligible? })`, `Win.scoreAtLeast(n)`), `ticks`, `seconds`, `minutes`, `toTicks`, `addDuration`, `subtractDuration`, `asSeconds`, `asMinutes`, `TICKS_PER_SECOND`, `WORLD_DEFAULTS`, `ALL_CAPABILITIES`, the world helpers `bounds`, `inBox`, `blockIdOf`, `applyEquipment`, and the types `ChallengeConfig`, `ContextOf`, `ContextOfChallenge`, `PlayerIn`, `Vec3`, `Box`, `Block`, `Item`, `EntityType`, `Duration`, `Event`, `Effect`, `Action`, `Marker`, `Entity`. The result types: `OverResult`, `SdkEndState`. Also `PLAYER_VOTE` (the reserved vote id), `OptionFor`, `Observed`, `DeepReadonly`, `StatePlayer`.
116
+ **Params.** `config.params` declares per-run parameters with defaults (`{ limit: { type: "number", default: 3 } }`); a job may override them; `g.params.limit` reads the value.
113
117
 
114
118
  ---
115
119
 
116
- ## The Spec
120
+ ## The Steps
117
121
 
118
- | Field | Meaning |
119
- |---|---|
120
- | `game: () => G` | The shared state. Read and write it as `g.game`. |
121
- | `player: () => P` / `players: { [role]: () => P }` | Per-player state, the same for all roles or one shape per role. Read it on the player objects. |
122
- | `duration` | Time from game start to the end. A `Duration`: `seconds(30)`, `minutes(3)`, `ticks(40)`, or `"30s"`, `"2m"`, `"40t"`. Omit for no limit. Arithmetic: `addDuration(a, b)`, `subtractDuration(a, b)` (clamped at zero) (ticks); for messages `asSeconds(d)`, `asMinutes(d)`. A bare number is not a duration; a negative or non-finite value throws. |
123
- | `life` | `"single"`: one death eliminates (spectator). `"respawn"`: deaths count, the player plays on. |
124
- | `world` | Setup before the game: `spawn`, `time`, `daylightCycle`, `mobSpawning`, `naturalRegeneration`, `maxHealth`. Players start with an empty inventory; nothing clears it. `false` skips all of it. Defaults: `WORLD_DEFAULTS`. |
125
- | `announce` | `{ deaths?, gameOver? }`: the SDK's own announcements. |
126
- | `mirror` | Cost knobs: `players: "all" \| "participants"`, `entities: boolean \| { within }`, `blocks: boolean \| { within }`. Everything is on by default. |
127
- | `zones` | Named boxes. The SDK evaluates them every tick: `on.zoneEntered`, `on.zoneLeft`, `g.inZone`, `player.zones`. |
128
- | `timers` | Timer names for `g.schedule` and `on.timer`. |
129
- | `markers` | `{ tags, within? }`: tagged entities the engine reads before the game, for `g.markers(tag)`. |
130
- | `containers` | `{ [name]: Vec3 }`: chests or barrels whose contents the engine reports. `g.contents(name)` or `g.contents(at)` gives the item counts (undefined before the first report); `on.container` fires on every change; a report for a block that is not declared is ignored. Each half of a double chest is its own container (27 slots); a container that is broken reports its slots as emptied. A delivery rule reads what is in the chest, not what left a hand. Needs the `containers` capability. |
131
- | `setup(g)` | The world build. Its effects go out as one batch; the game starts after the engine confirms it. |
132
- | `start(g)` | The first tick of `PLAYING`, after the setup batch. |
133
- | `phases` | `{ [name]: { duration?, enter?, exit?, next?, at?, every?, on? } }`. `g.goTo(name)` moves; `next` is a name, `"END"`, or a function. |
134
- | `at` | `[{ at: Duration, run }]`: one-shot steps after game start. |
135
- | `every` | `[{ interval: Duration, run }]`: repeating steps. After a gap they run once. |
136
- | `votes` | `{ [voteId]: { final?, phases?, open?(g, player, option) } }`. Ids and options come from `config.challengeConfig.votingOptions`. The id `player` is the vote `skills.voteForPlayer` casts in: its options are the other participants, and it needs no `votingOptions` entry. A vote is final by default, also without a rule: one vote per participant per round; `final: false` lets a new vote replace the old one. |
137
- | `on` | The handlers (next section). |
138
- | `end(g)` | An extra end condition, checked after every reduction. |
139
- | `winners(g)` | The winners. `Win.topScore({ min?, eligible? })` (default `min` 1, ties win together), `Win.scoreAtLeast(n)`, or your own function. |
140
- | `endState(g)` | The end state to report, one of `config.endStates`. |
141
- | `onOver(g, result)` | Runs once the result is final, before `game_over`: result titles, a last announcement. `result.winners` are the names, `result.winnerPlayers` the same players, `result.isWinner(p)` a membership check, `result.endState` a declared end state, an SDK error state (`error_setup`, `error_challenge`, `error_unsupported_facts`) or `""`. |
142
- | `judgeContext(g)` | An object sent with `game_over` for the judge (the backend takes an object, not another JSON value). |
122
+ | Step | Context | When |
123
+ |---|---|---|
124
+ | `.setup(fn)` | setup: reads, messages, effects, state writes | Once, before the game. The effects go out as batches; the game starts after the engine confirms them all. |
125
+ | `.start(fn)` | gameplay | The first tick of PLAYING, before the initial phase enters. |
126
+ | `.phase(name, spec)` | gameplay | The behavior of one declared phase: `duration?`, `next` (required: a name, `"END"`, or `(g) => ...`), `until?`, `enter?`, `exit?`, `at?`, `every?`, `on?`. |
127
+ | `.at([{ at, run }])` | gameplay | One-shot steps after game start, `>=` semantics. |
128
+ | `.every([{ interval, run }])` | gameplay | Repeating steps; after a gap they run once. |
129
+ | `.on({...})` | gameplay | Event handlers. Several calls add up, in call order. |
130
+ | `.outcome({ scores?, endWhen?, winners, endState? })` | read-only | The result rules. Required. |
131
+ | `.onOver(fn)` | notification: messages and metrics | Once the result is frozen. |
132
+ | `.judgeContext(fn)` | read-only | An object sent with `game_over` for the LLM judge. |
133
+
134
+ **Phases.** A phase without `duration` and without `until` lasts one tick: a pass-through, such as a resolution. `until` ends the phase as soon as it holds; it is evaluated after the handlers of every event. `duration` may be a function of the read-only context, evaluated once at entry. A name declared in `phases` with no `.phase()` step is a manual phase: only `g.startPhase` leaves it. A transition to the current phase is a normal transition with a fresh entry, so cycles go through a function `next`.
135
+
136
+ **Requests.** `g.startPhase(name)`, `g.endPhase()` and `g.endGame(endState?)` record a request; the SDK applies it after the callback returns. One phase transition per event; a game end replaces a phase request; a request inside `exit` or a pure callback throws. `endGame()` is not a `return`: the current callback continues, the later callbacks of the event are skipped.
137
+
138
+ **The checkpoint.** After the handlers of every event: the derived scores, the phase's `until`, the phase timeout, the game duration, `outcome.endWhen`. A game end wins over a staged phase change.
139
+
140
+ **Finalization.** The phase's `exit` (full context), the final scores, `outcome.winners`, the end state (`endGame(endState)` or `outcome.endState`), the frozen result, `onOver`, `judgeContext`, `game_over`.
143
141
 
144
142
  ---
145
143
 
146
144
  ## Handlers
147
145
 
148
- All handlers get `(g, event)`. Players in the events are resolved player objects. Events for names that are not participants never reach the handlers.
146
+ `.on({...})` and a phase's `on` take the same handlers. Every handler receives the gameplay context `g` and one event; events for names that are not participants never reach them.
149
147
 
150
148
  | Handler | Event |
151
149
  |---|---|
152
- | `tick` | Every reducer tick (every 2 server ticks). |
153
- | `joined`, `left`, `respawned` | `{ player }` |
154
- | `move` | `{ player, from, to, maxY, minY }` |
150
+ | `tick(g)` | Every server tick. |
151
+ | `joined`, `left` | `{ player }` |
152
+ | `move` | `{ player, from, to }`: one per tick in which the player moved. |
155
153
  | `damaged` | `{ player, amount, cause, attacker? }` |
156
154
  | `died` | `{ player, cause, killer? }` |
157
- | `entitySpawned` | `{ entity }` |
158
- | `entityRemoved` | `{ entity, cause: "died" \| "removed", killer? }` |
155
+ | `respawned` | `{ player }` |
159
156
  | `pickup` | `{ player, item, count, at }` |
160
- | `inventory` | `{ player, item, count }`: a participant's count of one item changed. |
161
- | `blockBroken`, `blockPlaced` | `{ player, at, block }` |
162
- | `blockUsed` | `{ player, at, block, item? }` |
163
- | `blocksChanged` | `{ changes: [{ x, y, z, block, was?, cause?, by? }] }`: every block that changed this tick, whatever the cause. |
164
- | `container` | `{ at, block, items, changes: [{ item, delta }], by? }`: a declared container's contents changed. `items` is the count per item after the change; `block` is the block at that position when the engine reports it (after a break, the replacement block, `minecraft:air`); `by` is the player who had it open, when the engine knows. The first report (the initial content) fires no handler; a report for a position that is not in `containers` is ignored. |
157
+ | `inventory` | `{ player, item, count }`: a count changed. |
158
+ | `blockPlaced`, `blockBroken`, `blockUsed` | `{ player, at, block: { id, props } }`; the mirror already shows the new state. |
159
+ | `blocksChanged` | `{ changes }`: every block that changed this tick; a fill is one entry with its `box`. |
160
+ | `container` | `{ at, block, items, changes, by?, removed }`: a container's contents changed. |
165
161
  | `zoneEntered`, `zoneLeft` | `{ player, zone }` |
166
162
  | `chat` | `{ player, text }` |
167
- | `animation` | `{ player, animation: "swing" \| "swing_off" }`: an arm swing, a mining or attack attempt. |
168
- | `gamemode` | `{ player, gamemode }`: a participant's game mode changed. |
169
- | `vote` | `{ player, voteId, option }`: accepted votes only. `option` is a string until a check on `voteId` narrows it to that vote's own options (the vote `player` is open to every challenge, its options are names). |
163
+ | `animation` | `{ player, animation }`: `swing` or `swing_off`. |
164
+ | `gamemode` | `{ player, gamemode }` |
165
+ | `entitySpawned`, `entityChanged`, `entityRemoved` | `{ entity }`, `{ entity, was }`, `{ entity, cause, killer? }` |
166
+ | `vote` | `{ player, voteId, option }`: accepted votes only; the option is typed by the vote id after `if (voteId === "room")`. |
170
167
  | `walkDone` | `{ player, ok }` |
171
168
  | `timer` | `{ name }` |
172
- | `effectFailed` | `{ kind, detail }` |
169
+ | `batchDone` | `{ name, ok, failed }` |
170
+ | `effectFailed` | `{ kind, detail, effect?, batch? }` |
173
171
  | `custom` | `{ name, player?, data }` |
174
172
 
175
- Phases carry their own `on`; a phase handler runs after the global one for the same event.
176
-
177
173
  ---
178
174
 
179
175
  ## The Context
180
176
 
181
- `g` is the context every rule receives.
177
+ `g` is the gameplay context. The read-only context (pure callbacks) has the reads only; the notification context (`onOver`) adds the messages and metrics; the setup context has everything but the phase, end, timer, vote, walk and batch calls.
178
+
179
+ **Reads.** `g.state`, `g.params`, `g.players`, `g.activePlayers`, `g.player(name)`, `g.playersWithRole(role)`, `g.playerWithRole(role)` (throws unless exactly one), `g.tick`, `g.elapsed` (ticks since start), `g.status`, `g.phase` (`null` without phases), `g.phaseEntry`, `g.phaseElapsed`, `g.phaseRemaining` (`null` without a duration), `g.location(name)`, `g.markers(tag?)`, `g.inZone(player, zone | box)`, `g.countItems(player, item)`, `g.worldTime`.
180
+
181
+ **The mirror.** `g.blockAt(at)` (the id, `undefined` outside the loaded chunks), `g.blockStateAt(at)` (`{ id, props }`), `g.blockMatches(at, match)` (throws `block_unknown` outside the loaded chunks), `g.blockCount(box, match, { partial? })` and `g.findBlocks(box, match, { limit?, partial? })` (they throw when a chunk of the box is not loaded, unless `partial`), `g.regionLoaded(box)`, `g.entities({ box?, type?, tag? })`, `g.entity(id)`, `g.containerContents(at)` (any container the mirror holds). A `match` is an id (any state), `{ id, props }` (the given props must match), or a function over the state.
182
+
183
+ **Votes.** `g.votes(id)` (voter to option), `g.vote(player, id)`, `g.voteOpen(id)`, `g.tally(id)` (`{ counts, leaders, abstaining }`); `g.openVote(id)` (a new round; throws `vote_open` on an open round), `g.closeVote(id)`, `g.resetVote(id)`. A round is closed until opened. An eliminated player's vote is refused unless `allowEliminated`; the `player` vote (`skills.voteForPlayer`) excludes the voter unless `allowSelf`.
182
184
 
183
- Reads:
185
+ **Messages and metrics.** `g.say(message)`, `g.tell(to, message)`, `g.title(to, { title, subtitle?, fadeIn?, stay?, fadeOut? })`, `g.log(message, { player?, data? })`, `g.setMetric(name, value, { player? })` (the last value per name reaches the run result), `g.achievement(player, id)`.
184
186
 
185
- - `g.game`, `g.params`, `g.players`, `g.active` (still in the game), `g.player(name)`, `g.role(role)` (typed with that role's fields), `g.one(role)`.
186
- - `g.tick`, `g.dt`, `g.elapsed`, `g.phase`, `g.phaseElapsed`, `g.phaseRemaining` (all in ticks).
187
- - `g.location(name)`, `g.markers(tag?)`, `g.contents(name | at)` (a declared container's item counts).
188
- - The world mirror: `g.blockAt(at)` (the id, `minecraft:oak_fence`), `g.blockStateAt(at)` (the state, `minecraft:oak_fence[north=true,...]`), `g.blockCount(box, match)`, `g.findBlocks(box, match, limit?)`, `g.entities({ box?, type?, tag? })`, `g.entity(id)`, `g.others` (players online who are not participants), `g.worldTime` (0..24000). A `match` is an id (any state) or a state string (that state only). Outside the loaded chunks `blockAt` is `undefined`.
189
- - `g.inZone(player, zone | box)`, `g.count(player, item)`.
190
- - `g.votes(voteId)` (a copy: voter name to option, typed by the vote), `g.vote(player, voteId)`.
191
- - `g.rand(min, max)`, `g.pick(items)`, `g.shuffle(items)`: seeded, deterministic.
187
+ **Scores and roster.** `g.setScore(player, n)`, `g.addScore(player, delta)` (refused when `outcome.scores` exists), `g.eliminate(player)`, `g.random.int(lo, hi)`, `g.random.pick(items)`, `g.random.shuffle(items)`, `g.random.weighted(items)`.
192
188
 
193
- Writes (effects the engine applies):
189
+ **Effects.** `g.give(player, item, count?)` (`item` is an id or `{ id, count?, name?, lore?, enchantments?, damage?, snbt? }`), `g.clear(player, item?, count?)`, `g.effect(player, { id, duration, amplifier? })`, `g.teleport(player, to)`, `g.kill(player)`, `g.gamemode(player, mode)`, `g.attribute(player, attribute, value)`, `g.fill(from, to, block)`, `g.setBlock(at, block)` (`block` is an id or `{ id, props?, blockEntity? }`), `g.summon(entity, at, { nbt? })`, `g.killEntities(types?)`, `g.time(value)`, `g.gamerule(rule, value)`, `g.setSpawn(at)`, `g.worldBorder({ center?, size?, over? })`, `g.container(at, items)`, `g.command(cmd)` (the escape hatch: gameplay commands only).
194
190
 
195
- - `g.say(text)`, `g.tell(to, text)`, `g.title(to, title, subtitle?, times?)`, `g.log(player | null, message, data?)`, `g.achievement(player, id)`, `g.store(name, value, player?)` (a stored variable: the last value per name reaches the run result and the `runs get` columns; global without a player).
196
- - `g.give(player, item, count?, nbt?)`, `g.clear(player, item?, count?)`, `g.effect(player, id, seconds, amplifier?)`, `g.teleport(player, to)`, `g.walk(player, to, { fallback? })` (the arena walks the bot; `on.walkDone` reports), `g.kill(player)`, `g.gamemode(player, mode)`, `g.attribute(player, name, value)`, `g.eliminate(player)`.
197
- - `g.fill(from, to, block)`, `g.setblock(at, block, nbt?)`, `g.summon(type, at, nbt?)`, `g.killEntities(types?)`, `g.container(at, items)`, `g.time(value)`, `g.gamerule(rule, value)`, `g.setSpawn(at)`, `g.worldBorder({ center?, size?, seconds? })`.
198
- - `g.command(cmd)`: the escape hatch, logged. The arena runs only the gameplay commands on its allowlist and checks the word after every `execute ... run`. Server administration is not on it, and neither are the world-mutation commands `fill`, `setblock`, `clone`, `place`, `fillbiome`, `data` and `forceload` (a command fires no block event, so the mirror would miss it: use the typed effects). A refused command comes back as `effectFailed`.
199
- - `g.goTo(phase)` (a phase that is not declared throws, which ends the run as `error_challenge`), `g.end(endState?)`, `g.schedule(timer, after)`, `g.cancel(timer)`, `g.openVote(voteId)`.
191
+ **Flow.** `g.startPhase(name)`, `g.endPhase()`, `g.endGame(endState?)`, `g.schedule(timer, after)`, `g.cancel(timer)`, `g.walk(player, to, { fallback? })`, `g.batch(name, fn)` (the effects inside `fn` go out as one named batch; `on.batchDone` reports).
192
+
193
+ Durations are `"35s"`, `"2m"`, `"40t"` or `seconds(35)` / `minutes(2)` / `ticks(40)`; a bare number is refused. Elapsed values are ticks (20 per second).
200
194
 
201
195
  ---
202
196
 
203
197
  ## Players and Entities
204
198
 
205
- Player objects (`g.players[i]`) carry the SDK fields plus your per-player state: `name`, `role`, `number`, `alive`, `eliminated`, `deaths`, `kills`, `score` (reported to the run result when it changes), `pos`, `yaw`, `pitch`, `headYaw`, `entityId`, `uuid`, `health`, `food`, `gamemode`, `held`, `slots` (by slot: 0-35 main, 36-39 armor, 40 offhand), `inventory` (counts by item), `equipment` (main, off, head, chest, legs, feet), `invisible`, `zones`, `human`. Your state may not reuse these names. Every SDK field except `score` is an observation: read-only for a rule (`p.pos = null` and `p.zones.push(...)` do not compile). Write `score`, your own fields, and use `g.eliminate(p)`.
199
+ A player (`g.players[i]`) carries the roster (`name`, `role`, `seatNumber`, `human`), the observations (`connected`, `alive`, `eliminated`, `isActive`, `deaths`, `kills`, `score`, `pos`, `yaw`, `pitch`, `headYaw`, `health`, `food`, `gamemode`, `held`, `slots`, `inventory`, `equipment`, `invisible`, `zones`) and its own `state`. The observations are read-only for a rule; `p.state` is the challenge's; the score changes through `g.setScore` / `g.addScore` or `outcome.scores`. `p.pos` is set from the initializers onward: a disconnected player keeps the last position.
200
+
201
+ `p.role === "seeker"` narrows the player to that role's state. `g.playersWithRole("seeker")` is typed the same way.
206
202
 
207
203
  Entities (`g.entities()`) carry `id`, `type`, `pos`, `tags`, `name?`, `health?`, `item?`, `count?`, `uuid?`, `yaw?`, `pitch?`, `headYaw?`, `equipment?`, `invisible?`, `villager?`. They are read-only through every level; copy what you keep (`{ ...e.pos }`).
208
204
 
209
- `g.others` lists the players online who are not participants (the watcher, spectators, humans in the lobby): `name`, `pos`, `gamemode`.
205
+ ---
206
+
207
+ ## Structures
208
+
209
+ `@kradle/challenges-sdk/structures` is a template of blocks: text layers plus a palette. `defineStructure` builds it once, at module level. `placeStructure(g, s, origin)` queues one `g.setBlock` per cell (setup or gameplay). `compareStructure(g, s, origin)` reads the mirror and reports `{ complete, matched, mismatched, unknown, rotation }`. `structureBox(s, origin)` is the bounding box, for a zone or a `blockCount`.
210
+
211
+ The layers go from the bottom up; each layer is a list of rows (z from the origin outward); each row is a string of symbols (x from the origin outward). `"."` is a cell that must be air; `"?"` is a cell the comparison ignores and the placement leaves alone. A symbol maps to `{ place, match? }`: `place` is what `placeStructure` sets; `match` is how the cell must look (default: the id of `place` with the properties `place` names). A block state string (`"minecraft:oak_stairs[facing=east]"`) reads as `{ id, props }`, so its properties rotate and match one by one. With `rotations: true`, `{ rotation: 90 | 180 | 270 }` turns the structure clockwise (the table turns `facing`, `axis`, `rotation`, a jigsaw's `orientation`, the rail shapes and the fence sides), and `compareStructure(g, s, origin, { rotation: "any" })` finds the rotation the players built. The rotated footprint keeps its minimum x/z corner at `origin`: the origin is the anchor corner, not a pivot, so the same `origin` compares every rotation of a build that starts there.
212
+
213
+ An unloaded chunk makes a cell `unknown`, never a mismatch, so `complete` is false until the chunk loads. Compare in `blocksChanged` (every cause) or in `outcome.endWhen`.
214
+
215
+ ```ts
216
+ import { defineChallenge } from "@kradle/challenges-sdk";
217
+ import { compareStructure, defineStructure, placeStructure } from "@kradle/challenges-sdk/structures";
218
+ import { config } from "./config";
219
+
220
+ // A 3x3 ring of cobblestone with a stair at one corner, two blocks high.
221
+ const FORT = defineStructure({
222
+ layers: [
223
+ ["#S#", "#.#", "###"],
224
+ ["#?#", "?.?", "#?#"],
225
+ ],
226
+ palette: {
227
+ "#": { place: "minecraft:cobblestone" },
228
+ S: { place: { id: "minecraft:oak_stairs", props: { facing: "east" } } },
229
+ },
230
+ rotations: true,
231
+ });
232
+ const MODEL = { x: 40, y: -60, z: 10 };
233
+ const SITE = { x: 10, y: -60, z: 10 };
234
+
235
+ const challenge = defineChallenge(config, { life: "respawn", duration: "10m", state: { game: () => ({ built: false }) } });
236
+ export default challenge
237
+ .setup((g) => placeStructure(g, FORT, MODEL))
238
+ .on({
239
+ blocksChanged: (g) => {
240
+ const report = compareStructure(g, FORT, SITE, { rotation: "any" });
241
+ if (report.complete) g.state.built = true;
242
+ else g.setMetric("mismatched", report.mismatched.length);
243
+ },
244
+ })
245
+ .outcome({ endWhen: (g) => g.state.built, winners: (g) => (g.state.built ? g.players : []), endState: (g) => (g.state.built ? "done" : "time_up") });
246
+ ```
210
247
 
211
248
  ---
212
249
 
213
250
  ## Rules of the Reducer
214
251
 
215
- - Write no side effects. Read `g`, write `g.game` and the player fields, call the effect methods. Everything else is ignored or fails the reduction.
216
- - The state is JSON: no functions, no class instances, no cycles, no shared objects, no `NaN`.
217
- - No clock, no random, no timers, no network. `Date.now()` and `Math.random()` throw in the arena. Use `g.tick`, `g.elapsed`, `g.schedule`, `g.rand`.
218
- - A reduction has a deadline (2 s) and a memory limit. An exception or a timeout ends the run as `error_challenge`.
219
- - Durations are in ticks. `toTicks("1.5s")` is 30; a fraction rounds to the nearest tick. A negative, non-finite or malformed duration throws, also from `seconds()`, `minutes()` and `ticks()`. `addDuration`, `subtractDuration` (clamped at zero), `asSeconds` and `asMinutes` combine and convert them.
220
- - The reducer sees the world at the reducer's cadence (every 2 server ticks). A zone edge is detected on the next sample, not on the exact server tick.
221
- - Scores live in the state (`player.score`). There is no Minecraft scoreboard by default.
252
+ - Every callback is synchronous. A callback that returns a Promise is an error that ends the run.
253
+ - A phase request (`g.startPhase`, `g.endPhase`) applies once the event's handler group returned: every global `on` in order, then the phase's. A later handler of the same event still sees the current phase; a second request in the group throws `phase_twice`. `g.endGame` skips the later handlers of the event; the current callback continues.
254
+ - The state is JSON: plain objects and arrays, finite numbers, strings, booleans, null. Store player names, not player objects.
255
+ - A thrown error in a callback ends the run as `error_challenge`: the reduction's state and effects are dropped, the run log carries the error's `code`, `category` and `path`.
256
+ - Randomness comes from `g.random` and `init.random`: a run replays from its seed.
257
+ - The published state is frozen through: a reference kept from a callback cannot change it, and a context method after the reduction throws `ctx_closed`.
258
+ - The observations are read-only everywhere: `p.pos.y = 1`, `p.score = 3` or `g.params.x = 1` throws `read_only` (the state is written through `p.state` and `g.state`; the score through `g.setScore` / `g.addScore`).
259
+ - The pure callbacks (`outcome.*`, `judgeContext`, `until`, a functional `duration` or `next`, a vote's `accept`) read only: a write or an effect does not compile, and throws `read_only` at run time; so does a write in `onOver`.
260
+ - Derived scores (`outcome.scores`) commit once per reduction, after the handlers and after the transition the checkpoint made; every evaluation reads the previous committed scores, so `p.score + 1` adds one. `until` and `endWhen` read the vector under evaluation; `exit` and `enter` read the committed one.
261
+ - A vote request carries the round the arena saw (`vote_round`); a request without a round, or with an earlier one, is refused with feedback. The harness's `Fake.vote` stamps the current round.
262
+ - The engine confirms the setup batches in order; an acknowledgement that is not for the pending batch is logged and ignored.
222
263
 
223
264
  ---
224
265
 
225
266
  ## Testing
226
267
 
227
- `@kradle/challenges-sdk/test` runs the reducer with a fake engine, without Minecraft. It also exports `scores(game)` (the score per player), `near(a, b, blocks)` (a distance check on each axis, x and z, not a radius) and the roster type `RosterEntry`:
268
+ `@kradle/challenges-sdk/test` is a stand-in arena. No test framework dependency: it throws plain errors.
228
269
 
229
270
  ```ts
230
271
  import { describe, expect, test } from "bun:test";
231
- import { Game, sandboxed } from "@kradle/challenges-sdk/test";
232
- import herding from "./challenge";
233
-
234
- const challenge = await sandboxed(herding);
235
-
236
- test("the sheep in a pen score", () => {
237
- const game = new Game(challenge).begin(1, { alice: "herder", bob: "herder" });
238
- game.terrain({ from: { x: -8, y: -61, z: -8 }, to: { x: 8, y: -61, z: 8 } }, "minecraft:grass_block");
239
- game.spawnEntity("s1", "minecraft:sheep", { x: 0.5, y: -60, z: 0.5 });
240
- game.player("alice").moveTo({ x: 24, y: -60, z: 0 });
241
- game.at("3m");
242
- expect(game.state.winners).toEqual(["alice"]);
243
- game.replay(); // reduces the log again and checks the final state is identical
272
+ import { Game, sandboxed, scores } from "@kradle/challenges-sdk/test";
273
+ import source from "./challenge";
274
+
275
+ // Under `kradle challenge test`, the built bundle inside the arena's sandbox; the source otherwise.
276
+ const challenge = await sandboxed(source);
277
+
278
+ test("a kill scores", () => {
279
+ const game = new Game(challenge).begin(1, { Alice: "farmer", Bob: { role: "farmer", at: { x: 8, y: -60, z: 0 } } });
280
+ game.spawnEntity("p0", "minecraft:pig", { x: 1, y: -60, z: 0 });
281
+ game.removeEntity("p0", "died", "Alice");
282
+ expect(scores(game)).toEqual({ Alice: 1, Bob: 0 });
283
+ expect(game.result()).toBeUndefined();
284
+ game.at("2m");
285
+ expect(game.result()?.winners).toEqual(["Alice"]);
286
+ game.replay();
244
287
  });
245
288
  ```
246
289
 
247
- `Game` has `begin(seed, roster, opts?)` (a roster entry is a role, or `{ role, at, ...sample }` so the first tick carries the position and `start` sees it), `tick(n?)`, `after(duration)` and `at(duration)` (they land on the exact tick), `sample(players, entities?)` (one tick with several players and entity moves), `dispatch(event)`, `terrain(box, block)`, `setBlocks`, `spawnEntity`, `moveEntities`, `removeEntity`, `setGamemode`, `container(at, items, { by?, initial?, block? })` (a declared container's contents, as the engine reports them), `other(name, sample)`, `custom(name, data, player?)`, `walkResult(ok, detail?, { player? | id? })`, `state`, `actions`, `last`, `of(type)`, `all(type)`, `effects(kind)`, `effectsAll(kind)`, `announcements(to?)`, `said()`, `checkpoint()`, `Game.resume(challenge, json)`, `replay()` (from the state the game started with, also after `resume`). A `Fake` player (`game.player(name)`) has `moveTo(pos, extrema?)`, `enters(zone)`, `leaves(zone)`, `has(item, count)` (one slot per item, per game), `picksUp(item, count?, at?)`, `uses(block, at, item?)`, `breaks(block, at)`, `places(block, at)`, `says(text)`, `swings(animation?)`, `vote(voteId, option)`, `hurt(amount, cause, attacker?)`, `dies(cause?, killer?)`, `respawns()`.
290
+ `Game.begin(seed, roster, opts)` sends `run_started`, the joins, one tick with every participant's position (a default row unless `at`), and confirms the setup batches. `game.player(name)` is a scripted player: `moveTo`, `says`, `vote(id, option)`, `hurt`, `dies`, `respawns`, `picksUp`, `setInventory`, `enters(zone)`, `exits(zone)`, `uses`, `breaks`, `places`, `joins`, `leaves`. The game feeds the mirror: `terrain`, `setBlocks`, `fillBlocks`, `spawnEntity`, `changeEntity`, `moveEntities`, `removeEntity`, `container`, `sample`, `other`; and answers the engine's side: `walkResult`, `batchResult`, `effectFailed`, `confirmSetup`. Time: `tick(n)`, `after(duration)`, `at(duration)`.
248
291
 
249
- The harness alone does not sandbox the reducer. `kradle challenge test <slug>` builds the challenge and runs its tests against the built bundle inside the arena's SES sandbox. `sandboxed(source)` returns the bundle under the command (`KRADLE_CHALLENGE_BUNDLE` set) and the plain import elsewhere; it is async, so await it at the top of the test file. Under the command a `Game` on the imported source throws, and the command counts the Games built on the bundle as its proof that the rules ran inside the sandbox. A test file is `challenges/<slug>/challenge.test.ts`, next to the rules (`kradle challenge create` writes it). The command also finds `tests/<slug>.test.ts` and `tests/<slug>/` under the project root.
292
+ Reads: `game.state` (the shared data), `game.phase`, `game.status`, `game.participant(name)`, `game.reducerState` (everything), `game.of(type)` / `game.all(type)` (actions), `game.effects(kind)` / `game.effectsAll(kind)`, `game.said()`, `game.announcements(to?)`, `game.result()`, `game.logs()`, `game.metrics()`; `scores(g)` and `near(a, b, blocks)` from the same entry. `game.replay()` re-runs the log and compares; `game.checkpoint()` / `Game.resume(challenge, json)` cross a JSON round trip.
250
293
 
251
294
  ---
252
295
 
253
296
  ## Build and Run
254
297
 
255
- `kradle challenge build <slug>` bundles `challenge.ts` into one file (`kradle-runtime/challenge.js`), loads it once inside the sandbox (module code and `init()`), uploads it and activates it with the challenge's configuration. A challenge runs only after that activation. The server runs the `KradleEngine` plugin; the arena connects the reducer to it. The run's event log is uploaded as the `runtime` recording.
298
+ `kradle challenge build <slug>` type-checks the project, bundles the challenge, loads the bundle in the arena's sandbox, reads its manifest and runs one synthetic start (a definition or API error fails the build; a throw from the challenge's own code that needs the world is a warning), then uploads and activates it. `kradle challenge run <slug>` starts a run in the cloud.
256
299
 
257
- `kradle challenge run <slug>` starts a run in the cloud. `kradle challenge run --local` (internal) needs the plugin jar in the local server.
300
+ The manifest the bundle exports (`runtime: 2`) carries the name, the life, the roles, the locations, the markers, the votes and the params. The backend checks the roles against the challenge's configuration at activation and stores the votes with the artifact.
258
301
 
259
302
  ---
260
303
 
261
304
  ## Complete Examples
262
305
 
263
- The SDK ships five reference challenges in `src/runtime/examples/`: Climb (scores by height, `life: "single"`), Capture the Flag (two roles, zones, a flag carried in the inventory), Four Bridges (phases, votes, a judge context), Skywars (markers, chests, the last one standing), Sheep Herding (the entity mirror, a fence watched through the block mirror). They ship in the package under `dist/runtime/examples/` (sources in `src/runtime/examples/`).
264
-
265
- A complete small challenge, Climb:
306
+ A one-round game:
266
307
 
267
308
  ```ts
268
- import { defineChallenge, End, minutes, Win } from "@kradle/challenges-sdk";
269
- import { config } from "./config";
309
+ import { type ChallengeConfig, defineChallenge, winBy } from "@kradle/challenges-sdk";
270
310
 
271
- const FLOOR_Y = -60;
311
+ export const config = {
312
+ name: "Pig Farming",
313
+ challengeConfig: {},
314
+ roles: { farmer: { description: "Farmer", minParticipants: 1 } },
315
+ endStates: { two_pigs: "A farmer reached two pigs", time_up: "Nobody did" },
316
+ } as const satisfies ChallengeConfig;
272
317
 
273
318
  export default defineChallenge(config, {
274
- duration: minutes(2),
275
- life: "single",
276
- world: { time: "day" },
277
- player: () => ({ maxY: FLOOR_Y }),
278
- start(g) {
279
- for (const p of g.players) g.give(p, "minecraft:cobblestone", 64);
280
- g.say("The Climb Challenge has begun!");
281
- },
282
- on: {
283
- move(g, { player: p, maxY }) {
284
- const y = Math.floor(maxY);
285
- if (p.eliminated || y <= p.maxY) return;
286
- p.maxY = y;
287
- p.score = y - FLOOR_Y;
288
- },
289
- died(g, { player, killer }) {
290
- if (killer) g.say(`${player.name} was killed by ${killer.name}`);
291
- },
292
- },
293
- end: End.allEliminated,
294
- winners: Win.topScore({ min: 1 }),
295
- });
319
+ state: { player: () => ({ pigs: 0 }) },
320
+ duration: "2m",
321
+ life: "respawn",
322
+ world: { time: "day" },
323
+ })
324
+ .start((g) => {
325
+ g.say("The Pig Farming Challenge has begun!");
326
+ for (const p of g.players) for (const dx of [2, -2]) g.summon("minecraft:pig", { x: p.pos.x + dx, y: p.pos.y, z: p.pos.z });
327
+ })
328
+ .on({
329
+ entityRemoved(g, { entity, cause, killer }) {
330
+ if (entity.type !== "minecraft:pig" || cause !== "died" || !killer) return;
331
+ killer.state.pigs += 1;
332
+ if (killer.state.pigs === 1) g.achievement(killer, "farmed_a_pig");
333
+ },
334
+ })
335
+ .outcome({
336
+ scores: (_g, p) => p.state.pigs,
337
+ endWhen: (g) => g.players.some((p) => p.state.pigs >= 2),
338
+ winners: winBy.scoreAtLeast(2),
339
+ endState: (g) => (g.players.some((p) => p.state.pigs >= 2) ? "two_pigs" : "time_up"),
340
+ })
341
+ .onOver((g, result) => {
342
+ for (const p of g.players) g.title(p, { title: result.isWinner(p) ? "VICTORY" : "DEFEAT", stay: "5s" });
343
+ });
296
344
  ```
297
345
 
298
- ---
346
+ A phased game with a vote, helpers and a loop:
299
347
 
300
- ## Common Patterns
348
+ ```ts
349
+ import { type ChallengeConfig, type CtxOf, defineChallenge, type PlayerIn, type ReadCtxOf, winBy } from "@kradle/challenges-sdk";
301
350
 
302
- **Score every tick from the mirror.** `on.tick: (g) => { for (const p of g.players) p.score = g.entities({ box: penOf(p), type: "minecraft:sheep" }).length; }`. The score reaches the run result when it changes.
351
+ export const config = {
352
+ name: "Iterated Prisoner's Dilemma",
353
+ challengeConfig: {},
354
+ roles: { prisoner: { description: "Prisoner", minParticipants: 2 } },
355
+ endStates: { mutual_cooperation: "", mutual_defection: "", mixed: "" },
356
+ } as const satisfies ChallengeConfig;
303
357
 
304
- **Phases.** `phases: { build: { duration: "2m", next: "fight" }, fight: { duration: "3m", next: "END", on: { died(g, { player }) { g.eliminate(player); } } } }`. `g.goTo("fight")` moves early; `enter`/`exit` run on the transition.
358
+ type Choice = "cooperate" | "defect";
359
+ const PAYOFF: Record<Choice, Record<Choice, number>> = { cooperate: { cooperate: 3, defect: 0 }, defect: { cooperate: 5, defect: 1 } };
360
+ const ROUNDS = 5;
305
361
 
306
- **Timers.** `timers: ["release"]`, `g.schedule("release", "30s")` in `start`, `on.timer(g, { name })` fires once; scheduling again resets it.
362
+ const challenge = defineChallenge(config, {
363
+ state: { game: () => ({ history: [] as Array<Record<string, Choice>> }) },
364
+ life: "respawn",
365
+ votes: { choice: { options: ["cooperate", "defect"], final: true } },
366
+ phases: ["DISCUSSION", "VOTE", "RESOLUTION"],
367
+ initialPhase: "DISCUSSION",
368
+ });
369
+ type R = ReadCtxOf<typeof challenge>;
370
+ type Pl = PlayerIn<R>;
371
+ const opponentOf = (g: R, p: Pl): Pl => g.players.find((o) => o.name !== p.name) ?? p;
372
+ const totalOf = (g: R, p: Pl): number =>
373
+ g.state.history.reduce((sum, round) => sum + PAYOFF[round[p.name] ?? "defect"][round[opponentOf(g, p).name] ?? "defect"], 0);
374
+
375
+ export default challenge
376
+ .phase("DISCUSSION", { duration: "20s", next: "VOTE", enter: (g) => g.say(`Round ${g.state.history.length + 1}: talk.`) })
377
+ .phase("VOTE", {
378
+ duration: "15s",
379
+ next: "RESOLUTION",
380
+ until: (g) => g.players.every((p) => g.vote(p, "choice") !== undefined),
381
+ enter: (g) => g.openVote("choice"),
382
+ exit: (g) => g.closeVote("choice"),
383
+ })
384
+ .phase("RESOLUTION", {
385
+ next: (g) => (g.state.history.length < ROUNDS ? "DISCUSSION" : "END"),
386
+ enter(g) {
387
+ const round = Object.fromEntries(g.players.map((p) => [p.name, g.vote(p, "choice") ?? "defect"]));
388
+ g.state.history.push(round);
389
+ for (const p of g.players) g.say(`${p.name} chose ${round[p.name]}: +${PAYOFF[round[p.name] as Choice][round[opponentOf(g, p).name] as Choice]}.`);
390
+ },
391
+ })
392
+ .outcome({
393
+ scores: (g, p) => totalOf(g, p),
394
+ winners: winBy.highestScore(),
395
+ endState: (g) => {
396
+ const all = g.state.history.flatMap((r) => Object.values(r));
397
+ return all.every((c) => c === "cooperate") ? "mutual_cooperation" : all.every((c) => c === "defect") ? "mutual_defection" : "mixed";
398
+ },
399
+ });
400
+ ```
307
401
 
308
- **Deliveries.** Declare the chest: `containers: { delivery: { x: 8, y: -60, z: 0 } }`. The engine reports its contents; the rule reads them: `container(g, { at, items }) { if (items["minecraft:white_wool"] >= 1) g.game.delivered = true; }`, or `g.contents("delivery")?.["minecraft:bread"] ?? 0` in an end condition. Do not infer a deposit from an inventory decrease: an item dropped or eaten leaves the inventory too. In a test: `game.container(CHEST, { "minecraft:white_wool": 1 }, { by: "Solo" })`.
402
+ ---
309
403
 
310
- **Votes.** `config.challengeConfig.votingOptions: { bridge: ["north", "south"] }`, `votes: { bridge: { final: true } }`, `on.vote(g, { player, voteId, option })` for accepted votes, `g.votes("bridge")` for the tally, `g.openVote("bridge")` for a new round. A vote on players: `votes: { player: { final: true } }`, then `g.votes("player")` maps each voter to the name it voted for (`skills.voteForPlayer` on the agent side). In `on.vote`, check `voteId` first: `if (voteId === "bridge")` narrows `option` to `"north" | "south"`; without the check it is a string, because the `player` vote's options are names.
404
+ ## Common Patterns
311
405
 
312
- **Zones.** `zones: { goal: { from, to } }`, `on.zoneEntered(g, { player, zone })`, `player.zones` for the current zones, `g.inZone(player, box)` for any box.
406
+ **A record height.** Initialize from the observed player: `player: (p) => ({ startY: Math.floor(p.pos.y), recordY: Math.floor(p.pos.y) })`; in `move`, `if (y > p.state.recordY) p.state.recordY = y`; `scores: (_g, p) => p.state.recordY - p.state.startY`.
313
407
 
314
- **Markers.** Tag entities in the world (`kradle.chest.*`), declare `markers: { tags: ["kradle.chest."] }`, read `g.markers("kradle.chest.")` in `setup` to place blocks or chests at their positions.
408
+ **A vote that ends the phase early.** `until: (g) => g.activePlayers.every((p) => g.vote(p, "room") !== undefined)`.
315
409
 
316
- **Items and inventories.** `g.give(p, "minecraft:wheat", 16)`, `g.count(p, "minecraft:wheat")`, `on.inventory(g, { player, item, count })` when a count changes, `on.pickup` when an item is picked up.
410
+ **A round loop.** Copy the round's votes into `g.state` in the resolution's `enter` (the next `openVote` clears them), and loop with `next: (g) => (g.state.round < N ? "DISCUSSION" : "END")`.
317
411
 
318
- **Trades between agents.** The agents trade through their skills; the reducer sees the result as inventory changes. Nothing to declare.
412
+ **A structure check.** `g.blockCount(box, "minecraft:cobblestone")` counts; `g.blockMatches(at, { id: "minecraft:oak_stairs", props: { facing: "east" } })` checks one block with its properties; read them from `blocksChanged`, which fires for every cause.
319
413
 
320
- **A judge.** `judgeContext: (g) => ({ bridges: g.game.bridges })` ships JSON with `game_over` for the LLM judge (`config.judging`).
414
+ **A chest.** `g.container(at, [{ id: "minecraft:golden_apple", count: 2 }])` fills it; `on.container` reports every change with the deltas; `g.containerContents(at)` reads it anywhere.
321
415
 
322
- **Stored variables.** `g.store("round", g.game.round)` and `g.store("coins", p.coins, p)` keep the last value per name; they reach the run result (`customVars`) and the `runs get` table, like the datapack SDK's `log_variable(store: true)` did.
416
+ **Private information.** `g.tell(player, message)` reaches one player; a `[PRIVATE]` prefix tells the agent it is private.
323
417
 
324
418
  ---
325
419
 
326
420
  ## Common Gotchas
327
421
 
328
- - `Date.now()` or `Math.random()` in a rule passes the plain harness and fails under `kradle challenge test` and in the arena. Use `g.tick`, `g.elapsed`, `g.rand`.
329
- - A helper typed from the challenge value (`typeof challenge.ctx`) and passed as a rule value makes the challenge's type circular. Type helpers with `ContextOf<typeof config, typeof state>` instead; then `endState: classify` is fine.
330
- - The state is JSON. A `Set`, a `Map`, a class instance or a function in `g.game` fails the reduction.
331
- - Your per-player fields may not reuse the SDK's names (`score`, `pos`, `alive`, ...).
332
- - `g.blockAt` is `undefined` outside the loaded chunks. Narrow `mirror.blocks` only when you know the box.
333
- - `g.command("fill ...")` is refused: the mirror would miss the change. Use `g.fill`.
334
- - A zone edge or a block change is seen on the next reducer tick (2 server ticks), not on the server tick itself.
335
- - `setup(g)` effects go out as one batch and the game starts after the engine confirms them; do not read the built blocks in `setup`.
336
- - Under `kradle challenge test`, `new Game(source)` on the imported source throws. Pass the result of `await sandboxed(source)`.
422
+ - `endGame()` does not return from the callback. Write `return` after it when the rest must not run.
423
+ - A phase step with `remaining` needs a `duration` on the same phase.
424
+ - `openVote` on an open round throws: close it or reset it first.
425
+ - `setScore` throws `score_derived` when `outcome.scores` exists. Pick one mode.
426
+ - `blockCount` and `findBlocks` throw when a chunk of the box is not loaded; pass `{ partial: true }` to count what is loaded.
427
+ - A helper typed from the challenge (`CtxOf<typeof challenge>`) takes its type from the root const, not from the full chain: keep `const challenge = defineChallenge(...)` and hang the steps off it.
428
+ - A root predicate (`votes.x.accept`) cannot call a helper typed from the challenge.
429
+ - Every player has `p.pos` from the initializers onward; before that, the initializers are the only code that runs, and they receive the position.
337
430
 
338
431
  ---
339
432
 
340
433
  ## Tips for LLMs
341
434
 
342
- - Start from the closest reference challenge and keep its structure: `config`, `state`, `type G`, helpers, `defineChallenge`.
343
- - Put every fact you need in the state; recompute from the mirror instead of caching what the engine already keeps.
344
- - Write the tests first with the harness: one test per rule, `game.replay()` at the end of each.
345
- - Run `kradle challenge test <slug>` before `kradle challenge build`: the sandbox catches the clock, the random and the timers.
346
- - Keep `config.ts` as the single source of the roles, the end states, the locations and the vote options; the types come from it.
435
+ - Start from `config.ts`: the roles, the end states and the params make the types. Write `as const satisfies ChallengeConfig`.
436
+ - Put the names in the root declaration first (phases, zones, timers, votes), then write the steps. The compiler then checks every name you use.
437
+ - Prefer `outcome.scores` over manual score writes: the score is then a function of the state, computed after every event.
438
+ - Keep `p.state` small and JSON. Copy what you keep from an event (`{ ...e.pos }`).
439
+ - Test with `Game` before you build: a test is faster than a run, and `kradle challenge test` runs the same file under the arena's sandbox.