@kradle/challenges-sdk 0.11.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.
- package/CHANGELOG.md +71 -1
- package/LLM_README.md +286 -193
- package/MIGRATION-0.13.md +109 -0
- package/README.md +98 -90
- package/dist/engine.d.ts +8 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +17 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -16
- package/dist/index.js.map +1 -1
- package/dist/runtime/builder.d.ts +33 -0
- package/dist/runtime/builder.d.ts.map +1 -0
- package/dist/runtime/builder.js +357 -0
- package/dist/runtime/builder.js.map +1 -0
- package/dist/runtime/errors.d.ts +61 -0
- package/dist/runtime/errors.d.ts.map +1 -0
- package/dist/runtime/errors.js +71 -0
- package/dist/runtime/errors.js.map +1 -0
- package/dist/runtime/reducer.d.ts +205 -0
- package/dist/runtime/reducer.d.ts.map +1 -0
- package/dist/runtime/reducer.js +2025 -0
- package/dist/runtime/reducer.js.map +1 -0
- package/dist/runtime/rules.d.ts +29 -0
- package/dist/runtime/rules.d.ts.map +1 -0
- package/dist/runtime/rules.js +23 -0
- package/dist/runtime/rules.js.map +1 -0
- package/dist/runtime/sandbox.d.ts +2 -1
- package/dist/runtime/sandbox.d.ts.map +1 -1
- package/dist/runtime/sandbox.js +7 -4
- package/dist/runtime/sandbox.js.map +1 -1
- package/dist/runtime/time.d.ts +33 -0
- package/dist/runtime/time.d.ts.map +1 -0
- package/dist/runtime/time.js +53 -0
- package/dist/runtime/time.js.map +1 -0
- package/dist/runtime/types.d.ts +963 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +25 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/runtime/wire.d.ts +858 -0
- package/dist/runtime/wire.d.ts.map +1 -0
- package/dist/runtime/wire.js +28 -0
- package/dist/runtime/wire.js.map +1 -0
- package/dist/runtime/world.d.ts +58 -3
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +12 -0
- package/dist/runtime/world.js.map +1 -1
- package/dist/structures.d.ts +119 -0
- package/dist/structures.d.ts.map +1 -0
- package/dist/structures.js +279 -0
- package/dist/structures.js.map +1 -0
- package/dist/test/harness.d.ts +280 -0
- package/dist/test/harness.d.ts.map +1 -0
- package/dist/{runtime → test}/harness.js +193 -68
- package/dist/test/harness.js.map +1 -0
- package/dist/test/helpers.d.ts +8 -0
- package/dist/test/helpers.d.ts.map +1 -0
- package/dist/test/helpers.js +12 -0
- package/dist/test/helpers.js.map +1 -0
- package/dist/test/index.d.ts +3 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/{runtime → test}/index.js +3 -1
- package/dist/test/index.js.map +1 -0
- package/package.json +31 -16
- package/dist/runtime/examples/capture-the-flag.d.ts +0 -33
- package/dist/runtime/examples/capture-the-flag.d.ts.map +0 -1
- package/dist/runtime/examples/capture-the-flag.js +0 -79
- package/dist/runtime/examples/capture-the-flag.js.map +0 -1
- package/dist/runtime/examples/climb.d.ts +0 -34
- package/dist/runtime/examples/climb.d.ts.map +0 -1
- package/dist/runtime/examples/climb.js +0 -54
- package/dist/runtime/examples/climb.js.map +0 -1
- package/dist/runtime/examples/four-bridges.d.ts +0 -107
- package/dist/runtime/examples/four-bridges.d.ts.map +0 -1
- package/dist/runtime/examples/four-bridges.js +0 -294
- package/dist/runtime/examples/four-bridges.js.map +0 -1
- package/dist/runtime/examples/herding.d.ts +0 -59
- package/dist/runtime/examples/herding.d.ts.map +0 -1
- package/dist/runtime/examples/herding.js +0 -181
- package/dist/runtime/examples/herding.js.map +0 -1
- package/dist/runtime/examples/skywars.d.ts +0 -116
- package/dist/runtime/examples/skywars.d.ts.map +0 -1
- package/dist/runtime/examples/skywars.js +0 -253
- package/dist/runtime/examples/skywars.js.map +0 -1
- package/dist/runtime/harness.d.ts +0 -176
- package/dist/runtime/harness.d.ts.map +0 -1
- package/dist/runtime/harness.js.map +0 -1
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.d.ts.map +0 -1
- package/dist/runtime/index.js.map +0 -1
- package/dist/runtime/sdk.d.ts +0 -1038
- package/dist/runtime/sdk.d.ts.map +0 -1
- package/dist/runtime/sdk.js +0 -1271
- 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
|
|
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
|
|
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. [
|
|
16
|
-
10. [
|
|
17
|
-
11. [
|
|
18
|
-
12. [
|
|
19
|
-
13. [
|
|
20
|
-
14. [Common
|
|
21
|
-
15. [
|
|
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,
|
|
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`
|
|
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
|
|
|
@@ -38,15 +39,15 @@ Use [Kradle's CLI](https://github.com/kradle-ai/cli):
|
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
41
|
kradle init # a project with the SDK installed
|
|
41
|
-
kradle challenge create my-challenge # challenges/my-challenge/{challenge.ts,config.ts
|
|
42
|
+
kradle challenge create my-challenge # challenges/my-challenge/{challenge.ts,config.ts,challenge.test.ts}
|
|
42
43
|
kradle challenge test my-challenge # the tests, inside the arena's sandbox
|
|
43
44
|
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`
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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 {
|
|
75
|
+
import { defineChallenge, type CtxOf, type ReadCtxOf, winBy } from "@kradle/challenges-sdk";
|
|
76
|
+
import { config } from "./config";
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
+
## The Steps
|
|
117
121
|
|
|
118
|
-
|
|
|
119
|
-
|
|
120
|
-
| `
|
|
121
|
-
|
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
|
|
|
125
|
-
| `
|
|
126
|
-
|
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
| `joined`, `left
|
|
154
|
-
| `move` | `{ player, from, to
|
|
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
|
-
| `
|
|
158
|
-
| `entityRemoved` | `{ entity, cause: "died" \| "removed", killer? }` |
|
|
155
|
+
| `respawned` | `{ player }` |
|
|
159
156
|
| `pickup` | `{ player, item, count, at }` |
|
|
160
|
-
| `inventory` | `{ player, item, count }`: a
|
|
161
|
-
| `blockBroken`, `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
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
|
|
168
|
-
| `gamemode` | `{ player, gamemode }
|
|
169
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
216
|
-
-
|
|
217
|
-
-
|
|
218
|
-
- A
|
|
219
|
-
-
|
|
220
|
-
- The
|
|
221
|
-
-
|
|
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/
|
|
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/
|
|
232
|
-
import
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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>`
|
|
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
|
-
`
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
A complete small challenge, Climb:
|
|
306
|
+
A one-round game:
|
|
266
307
|
|
|
267
308
|
```ts
|
|
268
|
-
import {
|
|
269
|
-
import { config } from "./config";
|
|
309
|
+
import { type ChallengeConfig, defineChallenge, winBy } from "@kradle/challenges-sdk";
|
|
270
310
|
|
|
271
|
-
const
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
348
|
+
```ts
|
|
349
|
+
import { type ChallengeConfig, type CtxOf, defineChallenge, type PlayerIn, type ReadCtxOf, winBy } from "@kradle/challenges-sdk";
|
|
301
350
|
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
+
---
|
|
309
403
|
|
|
310
|
-
|
|
404
|
+
## Common Patterns
|
|
311
405
|
|
|
312
|
-
**
|
|
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
|
-
**
|
|
408
|
+
**A vote that ends the phase early.** `until: (g) => g.activePlayers.every((p) => g.vote(p, "room") !== undefined)`.
|
|
315
409
|
|
|
316
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
-
**
|
|
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
|
-
- `
|
|
329
|
-
- A
|
|
330
|
-
-
|
|
331
|
-
-
|
|
332
|
-
- `
|
|
333
|
-
- `
|
|
334
|
-
- A
|
|
335
|
-
- `
|
|
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
|
|
343
|
-
- Put
|
|
344
|
-
-
|
|
345
|
-
-
|
|
346
|
-
-
|
|
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.
|