<!-- This file is for human consumption. If you are an LLM or any AI Agent, make sure to read LLM_README.md for an exhaustive explanation of this package. -->

# @kradle/challenges-sdk

A TypeScript framework for creating Minecraft datapack-based challenges with event-driven game logic, score tracking, and player role management. Built on top of [Sandstone](https://sandstone.dev/).

## Getting started

We strongly recommend using [Kradle's CLI](https://github.com/kradle-ai/cli) to create challenges with the CLI. Make sure to read the CLI's README

If you still want to perform a manual installation, you also need to install the Sandstone peer-dependency:
```bash
npm install @kradle/challenges-sdk sandstone@0.14.0-alpha.13
```

Add `"type": "module"` to your project's `package.json`. Challenge files use ES module syntax (`import …`) and Node otherwise reparses them as ES modules at every CI run with a `MODULE_TYPELESS_PACKAGE_JSON` warning + per-file overhead.

## Quick Start

Like for installation, we strongly recommend using Kradle's CLI to bootstrap a challenge:

```bash
# Will create a challenge in challenges/my-challenge/
kradle challenge create my-challenge
```

## Local Development

Working on the SDK locally and want to test against a real challenge project (e.g. `team-kradle-challenges`)?

**Use `npm pack` + tarball install — not `npm link`.**

```bash
# 1. In the SDK repo
npm run build
npm pack    # produces kradle-challenges-sdk-<version>.tgz

# 2. In the consumer project
npm install /absolute/path/to/kradle-challenges-sdk-<version>.tgz
```

After every SDK change, repeat both steps (a one-line script is fine).

### Why not `npm link`?

`sandstone` is a peer dependency. `npm link` keeps the SDK's own `node_modules` in play, so the SDK ends up resolving `sandstone` from one location while the consumer resolves it from another. You end up with **two `sandstone` instances loaded in the same process**, which produces some confusing failure modes:

- `instanceof SelectorClass` returns `false` for selectors created by the consumer (they're instances of the *other* `SelectorClass`).
- The runtime `Flow` registry gets split, so `_.if(...)` calls inside `forEveryPlayer` throw `Entering child function without registering a root function`.

`npm pack` + install gives the consumer a single flat `sandstone` install, which is what production users get. No duplication, no `instanceof` surprises, no flow-registry split.

## API Overview

### `createChallenge(config)`

Creates a new challenge with the specified configuration.

```typescript
createChallenge({
  name: string;                    // Challenge name (used for datapack)
  kradle_challenge_path: string;   // Output directory for generated datapack
  roles: readonly string[];        // Player roles (e.g., ["attacker", "defender"])
  GAME_DURATION?: number;          // Duration in ticks (optional, no time limit if unset)
  custom_variables: Record<string, VariableDefinition>;
  spawnPoint?: { x, y, z } | SelectorClass;  // World spawn point (coordinates or location marker)
})
```

### Fluent Builder Methods

The challenge builder uses a fluent API:

```typescript
createChallenge(config)
  .events(callback)               // Lifecycle events; call Actions.setEndStates from on_tick
  .custom_events(callback)        // OPTIONAL — score-threshold side effects (announce, sound, etc.)
  .end_condition(callback)        // OPTIONAL — when the game should end (auto-includes GAME_DURATION timer)
  .win_conditions(callback)       // Per-role win conditions
```

> For the standard "win / loss / timeout" pattern, call `Actions.setEndStates({ ... })` from inside `events.on_tick` and feed its return value into `.end_condition()` so the game ends as soon as any end-state fires. See the API reference for the full signature.

## Variables

Variables are the core mechanism for tracking game state. All variables are automatically updated each tick.

### Built-in Variables

These variables are always available:

| Variable | Type | Description |
|----------|------|-------------|
| `death_count` | individual | Number of times the player has died |
| `has_never_died` | individual | 1 if player hasn't died, 0 otherwise |
| `alive_players` | global | Count of living participants |
| `main_score` | individual | Primary score displayed on sidebar |
| `game_timer` | global | Ticks elapsed since game start |
| `game_state` | global | Current state (0=CREATED, 1=OFF, 2=ON) |
| `player_count` | global | Total number of participants |
| `player_number` | individual | Unique player ID (1 to N) |

### Custom Variable Types

#### Individual Variables (per-player)

Track values for each player separately.

**Objective-based** (uses Minecraft statistics):
```typescript
pigs_killed: {
  type: "individual",
  objective_type: "minecraft.killed:minecraft.pig",
  default: 0,
}
```

**Dummy** (computed values):
```typescript
current_height: {
  type: "individual",
  objective_type: "dummy",
  updater: (value) => {
    value.set(Actions.getPlayerPosition().y)
  },
}
```

#### Global Variables (shared)

Track values across all players.

```typescript
max_score: {
  type: "global",
  hidden: false,  // optional: hide from scoreboard
  default: 0,
  updater: (value, { main_score }) => {
    value.set(0);
    forEveryPlayer(() => {
      _.if(main_score.greaterThan(value), () => {
        value.set(main_score);
      });
    });
  },
}
```

### Updater Functions

Updaters run every tick and receive `(currentValue, allVariables)`:

```typescript
updater: (value, { main_score, death_count, game_timer }) => {
  // Set value based on other variables
  value.set(0);
  _.if(main_score.greaterThan(10), () => {
    value.set(1);
  });
}
```

If you track a Minecraft objective, you do not need an updater!

## Events

### Lifecycle Events

Lifecycle events are triggered once on specific occasions.

```typescript
.events((variables, roles) => ({
  start_challenge: () => {
    // Runs once when the challenge starts
    Actions.setTime({ time: "day" });
    Actions.announce({ message: "Game starting!" });
  },

  init_participants: () => {
    // Runs 1s after the challenge starts
    Actions.give({ target: "all", item: "minecraft:diamond_sword", count: 1 });
    Actions.setAttribute({ target: "all", attribute_: "generic.max_health", value: 40 });
  },

  on_tick: () => {
    // Runs once every tick
  },

  end_challenge: () => {
    // Runs when the challenge ends
    Actions.announce({ message: "Game over!" });
  },
}))
```

### Custom Events

Custom events trigger actions based on score thresholds or Minecraft advancements.

#### Score-based events

Score events watch a variable and trigger when it reaches a target value. 

- **`score`**: The variable to watch.
- **`target`** (optional): The target value. If omitted, triggers on any score change
- **`mode`**:
  - `"fire_once"`: Triggers once when the score reaches the target (per player for individual variables)
  - `"repeatable"`: Triggers every tick while the score is at target

For individual variables, events fire per-player; for global variables, events fire globally.

```typescript
.custom_events((variables, roles) => [
  {
    score: variables.diamonds,
    target: 5,
    mode: "fire_once",
    actions: () => {
      Actions.announce({ message: "Someone collected 5 diamonds!" });
    },
  },
])
```

If `target` is omitted, the event triggers whenever the score changes (useful for reacting to any increment).

#### Advancement-based events

Advancement events trigger when a Minecraft advancement criterion is met (e.g., player attacks, item picked up). The criteria follow the [Minecraft Advancement JSON format](https://minecraft.fandom.com/wiki/Advancement/JSON_format).

Advancement-based events always fire per-player.

- **`criteria`**: Array of advancement triggers with optional conditions
- **`mode`**:
  - `"fire_once"`: Triggers once per player when the advancement is granted
  - `"repeatable"`: Triggers every time the advancement criterion is met (advancement is auto-revoked to allow re-triggering)

```typescript
.custom_events((variables, roles) => [
  {
    criteria: [
      {
        trigger: "minecraft:player_hurt_entity",
        conditions: {
          entity: { type: "minecraft:player" }  // Only PvP hits
        }
      }
    ],
    mode: "repeatable",
    actions: () => {
      Actions.increment({ variable: variables.pvp_hits });
    },
  },
])
```

## End Conditions

Define when the game ends:

```typescript
.end_condition(({ objective_complete }) => objective_complete.equalTo(1))
```

Multiple conditions can be combined:
```typescript
.end_condition(({ alive_players, objective_complete }) =>
  _.or(
    alive_players.equalTo(1),
    objective_complete.equalTo(1)
  )
)
```

Ending conditions are evaluated once per tick, at the global level (not individually). It means you should not check for individual variables here - instead, you should aggregate these individual variables into a global custom variable, that you then check in the ending condition.

## Win Conditions

Define how winners are determined per role:

```typescript
.win_conditions((variables, { attacker, defender }) => ({
  [attacker]: variables.kills.greaterOrEqualThan(5),
  [defender]: variables.survived.equalTo(1),
}))
```

## Actions

Actions are higher-level functions that wrap common Minecraft operations, designed to work seamlessly with Kradle's challenge system. They handle target mapping, formatting, and integration with Kradle's interface automatically.

For more advanced use cases, you can always fall back to Sandstone's lower-level functions directly (e.g., `give`, `tellraw`, `effect`, `kill`, `execute`). See the [Sandstone Integration](#sandstone-integration) section below.

### Communication
```typescript
// Simple string message
Actions.announce({ message: "Hello everyone!" });

// Formatted JSONTextComponent message
Actions.announce({
  message: [
    { text: "Player ", color: "white" },
    { selector: "@s", color: "gold", bold: true },
    { text: " scored!", color: "green" }
  ]
});

// Send to specific target (only visible in-game, not in Kradle's interface)
Actions.tellraw({ target: "all", message: ["Hello ", { text: "world", color: "gold" }] });
Actions.tellraw({ target: "self", message: { text: "You win!", color: "green", bold: true } });
```

### Items & Inventory
```typescript
Actions.give({ target: "self", item: "minecraft:diamond_sword", count: 1 });
Actions.giveLoot({
  target: "self",
  items: [
    { name: "minecraft:diamond", count: 5, weight: 1 },
    { name: "minecraft:iron_ingot", count: 10, weight: 3 }
  ]
});

Actions.clear({ target: "self" });

// Count items - returns a Score variable
const count = Actions.countItems({ target: "self", item: "minecraft:diamond" });
// Use in conditions or set to custom variables
_.if(count.greaterThan(5), () => { /* ... */ });

// Get player position - returns { x, y, z } Score variables
const pos = Actions.getCurrentPlayerPosition();
_.if(pos.y.greaterThan(100), () => { /* player is high up */ });
```

### Entities
```typescript
Actions.summonMultiple({ entity: "minecraft:zombie", count: 5, x: 0, y: 64, z: 0, absolute: true });
Actions.kill({ selector: Selector("@e", { type: "minecraft:zombie" }) });
Actions.teleport({ target: "self", x: 0, y: 100, z: 0, absolute: true });

// Teleport to a location marker entity
const spawn = Actions.getLocation({ name: "spawn" });
Actions.teleport({ target: "all", toEntity: spawn });

// Force player(s) to interrupt their code and WALK to coordinates (a real
// pathfind — never teleports, even in cheat mode). Server-initiated, absolute
// coords. "self" only resolves inside forEveryPlayer; raw "@..." is rejected.
Actions.forceMoveTo({ target: "all", x: 0, y: -60, z: 0 });
```

### Selectors
```typescript
// "Is @s standing on the block at (x, y, z)?" — returns a Selector usable
// inside _.if(...). Use this instead of Selector("@s", { x, y, z }) which
// silently passes for everyone (Minecraft selector x/y/z without dx/dy/dz
// is the origin for distance math, not a position constraint).
_.if(Actions.standingOnBlock({ x: PAD.x, y: PAD.y, z: PAD.z, role: "alice" }), () => {
  // Alice is on the pad above (PAD.x, PAD.y, PAD.z). Run trigger logic here.
});
```

### Cross-player score aggregation
```typescript
// Compute the maximum across all players' per-player scores. Returns an
// anonymous Score the caller can write into a global custom_variable.
// Lets `Actions.setEndStates` / `.end_condition` reference "any player has X"
// semantics correctly (those checks run as the server entity, so per-player
// scores read 0 there).
max_score: {
  type: "global",
  default: 0,
  updater: (value, { score }) => {
    value.set(Actions.maxPlayerScore({ score }));
    // Pass `min: -1000` if your per-player score can go below 0.
  },
}
```

### Locations
```typescript
// Get a location marker by name (must be defined in config.ts locations)
const spawn = Actions.getLocation({ name: "spawn" });

// Use as spawn point in challenge config
createChallenge({
  // ...
  spawnPoint: Actions.getLocation({ name: "spawn" }),
});
```

### World
```typescript
Actions.setBlock({ block: "minecraft:diamond_block", x: 0, y: 64, z: 0, absolute: true });
Actions.fill({
  block: "minecraft:stone",
  x1: 0, y1: 64, z1: 0,
  x2: 10, y2: 64, z2: 10,
  absolute: true,
  mode: "fill"  // "fill", "line", or "pyramid"
});
Actions.setTime({ time: "day" });  // or "night" or specific tick value
Actions.gamerule({ rule: "doDaylightCycle", value: false });
```

### Scores
```typescript
Actions.set({ variable: variables.main_score, value: 10 });
Actions.set({ variable: variables.main_score, value: variables.diamonds });
Actions.increment({ variable: variables.counter });
Actions.decrement({ variable: variables.counter });
```

### Voting

Declare votes in `config.ts` (`challengeConfig.votingOptions`, e.g. `{ selectroom: ["red", "green", "blue"] }`). Agents cast a vote with `skills.voteForOption(voteId, option)`; the arena records it as player tags the actions below read. See LLM_README.md → "Voting" for the full flow.

```typescript
// Announce the vote to all agents.
Actions.promptVote({ voteId: "selectroom", prompt: "Pick a room!", options: ["red", "green", "blue"] });

// Condition: did this player vote (for a specific option)?
Actions.votedFor("selectroom");          // voted at all
Actions.votedFor("selectroom", "red");   // voted red

// Resolve when everyone's in (or a 30s timer fires), defaulting non-voters, then move by choice.
// votedFor is a per-player condition, so check it inside forEveryPlayer (makes @s each participant):
_.if(_.or(Actions.allVotesIn("selectroom"), game_timer.greaterOrEqualThan(600)), () => {
  Actions.assignDefaultVotes({ voteId: "selectroom", default: "red" });
  forEveryPlayer(() => {
    _.if(Actions.votedFor("selectroom", "red"), () => {
      Actions.teleport({ target: "self", x: 10, y: -60, z: 10, absolute: true });
    });
  });
});
```

### Player Attributes
```typescript
Actions.setAttribute({ target: "self", attribute_: "generic.max_health", value: 40 });
Actions.setAttribute({ target: "self", attribute_: "generic.movement_speed", value: 0.2 });
```

### Custom Commands
```typescript
Actions.custom(() => {
  // Any Sandstone code here
  execute.as("@a").run.effect.give("@s", "speed", 10, 1);
});
```

## Utilities

### `forEveryPlayer(callback)`

Execute code for each participant at their location:

```typescript
import { forEveryPlayer } from "@kradle/challenges-sdk";

forEveryPlayer(() => {
  // Runs as each player, at their position
  execute.run.particle("minecraft:flame", rel(0, 1, 0));
});
```

## Example: Battle Royale

```typescript
import { createChallenge, Actions, forEveryPlayer } from "@kradle/challenges-sdk";
import { _, Selector } from "sandstone";

createChallenge({
  name: "battle-royale",
  kradle_challenge_path: "./output",
  roles: ["fighter"] as const,
  GAME_DURATION: 5 * 60 * 20,
  custom_variables: {
    kills: {
      type: "individual",
      objective_type: "playerKillCount",
      default: 0,
    },
    sole_survivor: {
      type: "individual",
      updater: (value, { alive_players, has_never_died }) => {
        value.set(0);
        _.if(_.and(
          alive_players.equalTo(1),
          has_never_died.equalTo(1)
        ), () => {
          value.set(1);
        });
      },
    },
  },
})
  .events(() => ({
    start_challenge: () => {
      Actions.setTime({ time: "day" });
      Actions.announce({ message: "Last player standing wins!" });
    },
    init_participants: () => {
      Actions.give({ target: "all", item: "minecraft:stone_sword", count: 1 });
      Actions.give({ target: "all", item: "minecraft:leather_chestplate", count: 1 });
    },
  }))
  .custom_events(({ kills }) => [
    {
      score: kills,
      target: 1,
      mode: "fire_once",
      actions: () => {
        Actions.announce({ message: "First blood!" });
      },
    },
  ])
  .end_condition(({ alive_players }) => alive_players.equalTo(1))
  .win_conditions(({ sole_survivor }, { fighter }) => ({
    [fighter]: sole_survivor.equalTo(1),
  }));
```

## Example: Capture the Flag

```typescript
import { createChallenge, Actions } from "@kradle/challenges-sdk";
import { _, Selector, rel } from "sandstone";
import type { Score } from "sandstone";

createChallenge({
  name: "capture-the-flag",
  kradle_challenge_path: "./output",
  roles: ["red_team", "blue_team"] as const,
  GAME_DURATION: 5 * 60 * 20,
  custom_variables: {
    holds_enemy_flag: {
      type: "individual",
      updater: (value: Score) => {
        value.set(0);
        // Check if player holds the enemy team's banner
        _.if(Selector("@s", {
          nbt: { Inventory: [{ id: "minecraft:red_banner" }] }
        }), () => {
          value.set(1);
        });
      },
    },
    at_home_base: {
      type: "individual",
      updater: (value: Score) => {
        value.set(0);
        _.if(_.block(rel(0, -1, 0), "minecraft:blue_wool"), () => {
          value.set(1);
        });
      },
    },
    captured_flag: {
      type: "individual",
      updater: (value, { holds_enemy_flag, at_home_base }) => {
        value.set(0);
        _.if(_.and(
          holds_enemy_flag.equalTo(1),
          at_home_base.equalTo(1)
        ), () => {
          value.set(1);
        });
      },
    },
  },
})
  .events(() => ({
    start_challenge: () => {
      Actions.announce({ message: "Capture the enemy flag!" });
    },
  }))
  .custom_events(({ captured_flag }) => [
    {
      score: captured_flag,
      target: 1,
      mode: "fire_once",
      actions: () => {
        Actions.announce({ message: "Flag captured!" });
      },
    },
  ])
  .end_condition(({ captured_flag }) => captured_flag.equalTo(1))
  .win_conditions(({ captured_flag }, { red_team, blue_team }) => ({
    [red_team]: captured_flag.equalTo(1),
    [blue_team]: captured_flag.equalTo(0),
  }));
```

