# SDK helpers: match, host, bots

Three small modules for the parts of a multiplayer game that are easy to get wrong: running a match from the host's page, deciding
things there that nobody else can forge, and filling empty seats with bots. They work with any way of drawing a game (canvas,
Phaser, three.js, the DOM) and need nothing but the room from `onceworlds.rooms.join()`. They are optional: the SDK every game gets
doesn't include them.

```js
import { createMatch, defaultView } from '/__onceworlds/v1/match.js';
import { createHost, t } from '/__onceworlds/v1/host.js';
import { createBots } from '/__onceworlds/v1/bots.js';
```

Games import them from their own address, as above, on Onceworlds and under `onceworlds serve`. With a bundler, install
`@onceworlds/sdk` and import `@onceworlds/sdk/match`, `/host` and `/bots`. `v1` keeps its API: new versions add to it.

Every page runs the same code. The host's page also runs the rules. Nothing in the helpers throws into your game: a bad message is
refused, and an error in one of your functions is printed in the console and the game carries on.

## host: what only the host decides

```js
const room = await onceworlds.rooms.join({ maxPlayers: 2, lobby: 'card' });
const host = createHost(room);

const board = host.state('board', {
  schema: { cells: t.array(t.oneOf('', 'x', 'o'), 9) },
  initial: { cells: Array(9).fill('') },
});

const move = host.rpc('move', {
  args: { cell: t.int(0, 8) },
  rate: 4, // calls a second per player
  validate: (from, { cell }) => board.get().cells[cell] === '' || 'taken',
  run: (from, { cell }) => board.update((b) => { b.cells[cell] = from.id === room.host ? 'x' : 'o'; }),
});

canvas.addEventListener('pointerdown', (e) => move({ cell: cellAt(e) })); // any page: the host runs it
board.on(draw);

// First come, first served, by the room's clock: two players reaching one chair.
const chairs = host.state('chairs', { schema: t.record(t.id(), 4), initial: {} });
const sit = host.claim('sit', {
  args: { chair: t.int(0, 3) },
  key: (a) => String(a.chair),
  validate: (c) => !chairs.get()[c.args.chair] || 'taken',
  apply: (winner) => chairs.update((c) => { c[winner.args.chair] = winner.from.id; }),
});
const result = await sit({ chair: 2 }); // 'won' | 'lost' | 'rejected' | 'timeout'

// The same shuffle on every page, and for the next host.
const deck = host.random(`deck:${room.match.id}`).shuffle(CARDS);
```

- `createHost(room, { onReject? })`. `host.isHost`, `host.dispose()`.
- `host.state(name, { schema?, initial? })` → `{ get(), set(value), update(fn), on(fn) }`: room state under `host:<name>`, up to 15 KB.
- `host.rpc(name, { args?, validate?, run, rate = 10, maxBytes = 2048, spectators = false })` → `call(args)`.
- `host.claim(name, { args?, key?, window = 150, validate?, apply, rate, spectators })` → `call(args)`, a promise of its result.
- `host.callAs(bot, name, args)`: a bot uses an rpc or claim (see bots). `host.random(name)`: seeded by the match.
- One `createHost` per room. Its messages carry a `$ow` field: a game's own `room.on('message')` handler can skip those.
- Schemas: `t.int(min, max)`, `t.number`, `t.string(max)`, `t.boolean()`, `t.oneOf(...)`, `t.id()`, `t.array(item, max)`,
  `t.object({...})` (a plain `{...}` is one), `t.record(value, maxKeys)`, `t.optional`, `t.nullable`, `t.any(maxBytes)`, or a
  function returning true, false or a reason. `check(schema, value)` says what is wrong, or null.

What it prevents:

- **Another player writing the score.** Host state lives under a `host:` key: the room refuses anyone else's write.
- **Believing a message.** Every call and claim is checked before your code sees it: its size, its shape (unknown fields are refused),
  who sent it (in the room; during a match, one of its players unless `spectators: true`) and how often. `validate` gets checked
  arguments only.
- **A buggy or hostile host breaking everyone's screen.** Reads are checked against the schema too: a value that doesn't fit is
  ignored and the last good one stays.
- **"Who was first" by whichever message the host heard first.** Claims are settled by when the room took them in.
- **`Math.random()` in shared things.** A new host deals a different deck. `host.random(name)` is the same everywhere.

## match: lobby to results

```js
const room = await onceworlds.rooms.join({
  maxPlayers: 6,
  minPlayers: 1, // one player can start a match with bots
  lobby: 'card', // the platform draws the lobby and its one countdown
  settings: [{ id: 'rounds', label: 'Rounds', options: [3, 5], default: 3 }],
});

const match = createMatch(room, {
  rounds: 'rounds', // a lobby setting's id, or a number
  roundSeconds: 30,
  startRound: (m) => ({ target: m.random(`target:${m.roundId}`).int(100), hits: {} }), // the round's shared data
  onTick: (m, dt) => { /* host: move things, m.setData(...), m.endRound() when it's decided */ },
  score: (m) => m.data.hits, // { [seatId]: points }, or [[first], [second, tied]], or ['winner']
});

match.on('change', draw); // or read match.* in your frame loop
match.on('results', (standings, mine) => {
  if (mine?.place === 1) onceworlds.badges.award('winner');
});
defaultView(match); // a plain text panel to start with: replace it with your own screens
```

Read: `match.phase` (`'lobby' | 'countdown' | 'intro' | 'round' | 'roundEnd' | 'results'`), `paused`, `round`, `rounds`, `roundId`,
`seats` (players then bots, each `{ id, name, bot, me, away, left, skill }`), `me`, `spectating`, `scores`, `standings`, `last` (the
round just played), `results`, `turn`, `data`, `secondsLeft`, `countdown`, `settings`, and `lobby` (`players`, `canStart`,
`waitingFor`) for a game that draws its own lobby. Do: `ready(on)`, `start()`, `endTurn()`; the host: `setData(value or fn)`,
`endRound(outcome?)`, `skip()`, `endMatch()`.

Rules: `rounds`, `maxRounds`, `roundSeconds`, `introSeconds` (0), `roundEndSeconds` (3), `resultsSeconds` (10; 0 waits for the
host's `endMatch()`), `points` ([3, 2, 1] by place), `admit` (`'next'` or `'round'`), `data` (a schema), `turns`, and the host's
`startRound`, `onTick`, `score`, `over` (end early), `final` (final placings), `resume`, `onTurn`, `turnTimeout`.

Turn-based: `turns: { seconds: 30, order: 'seat' | 'random', perRound, awaySeconds: 10 }`. The player on turn moves with your rpc,
whose `run` calls `match.endTurn()`; `turnTimeout(m, seat)` makes a default move for a player who ran out of time.

What it prevents:

- **Two countdowns.** The room counts down once and the helper adds none. The platform draws it, unless the game joined with
  `ownCountdown: true`: then draw `match.countdown` yourself.
- **A match starting when someone joins.** It never starts by itself: the host starts once everyone else is ready (a public server
  starts when everyone is).
- **A match that dies with its host.** Everything is in one record the host writes (`host:ow.match`), with deadlines on the match
  clock. When the host reloads, drops or leaves, the next host carries on from it and `resume(m)` is called on its page. Keep what
  the host decides in `match.data` or `host.state`, never only in a variable.
- **Timers running through a pause.** Deadlines are in match time, which stands still while the match waits.
- **Latecomers thrown into a round.** They watch (`match.spectating`); with `admit: 'round'` they join at the next round, in a bot's
  seat if there is one.
- **Forged results, and quitting to dodge a loss.** Only the host writes results; each player's page reports a ranked result itself
  and the platform rates the match when the reports agree. Players who leave keep their seat in the standings. `results` fires once
  per match on each page: a reload during the results doesn't fire it again.

## bots: filling empty seats

```js
createBots(match, {
  host, // bots act through the game's own rpcs and claims
  fill: 4, // seats up to 4, players first
  every: 600, // ms between thoughts, varied per bot (less skilled bots are slower)
  think(view) {
    if (!view.myTurn) return null;
    const free = board.get().cells.flatMap((c, i) => (c ? [] : [i]));
    return { call: 'move', args: { cell: view.random.pick(free) } };
  },
});
```

`view` has `bot` (its seat), `skill` (0 to 1), `match`, `data`, `state` (the room's) and `myTurn`, and `random`, a stream of its own.
`think` returns `{ call, args }`, a list of them, or nothing. Without a match, `createBots(room, {...})` keeps a world at `fill`
seats as players come and go.

What it prevents:

- **Bots that cheat.** Their actions go through the same rpc checks and rate limits as a player's.
- **Bots on every page, or none after the host leaves.** Only the host's page thinks for them, and a new host takes them over.
- **Bots faster than people.** Each thinks at its own pace, and a moment after its turn begins.
- **Bots keeping players out.** Players always come first, and a player let in later takes a bot's seat.

## An example

`packages/sdk/test/fixtures/gem-grab/game.js` is a whole game on the three: 2-6 players race for gems on a canvas, a chest is a
claim, bots fill the seats. Its tests play whole matches between pages while the host reloads, players drop, the host changes during
a pause, a latecomer arrives and a page sends garbage.
