# `@xmachines/play`

> Core protocol layer for the Universal Player Architecture. It defines `PlayEvent`, `PlayError`, and the contracts that keep the business logic loosely coupled to the runtime adapters.

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/badge/version-7.0.0-blue)](https://www.npmjs.com/package/@xmachines/play)

---

## Installation

```bash
pnpm add @xmachines/play @xmachines/core
```

**Peer dependencies.** Install them with the package:

- [`@xmachines/core`](../core/README.md) — the base of the errors. `PlayError` extends `BaseError` of its `errors` subpath.

> **This package requires Node.js `>= 24.0.0`.** Every package is an ES module (`"type": "module"`).

---

## Overview

`@xmachines/play` is the protocol package of the XMachines ecosystem. It defines the smallest set of types and utilities that every other Play package builds on:

- **`PlayEvent<TPayload>`** — the universal event contract for Actor ↔ Infrastructure communication
- **`PlayError`** — the typed base class for the runtime errors of every Play package. It extends `BaseError` of [`@xmachines/core`](../core/README.md)
- **`NonNullableError`** — the error that a package throws when a required value is `null` or `undefined`
- **`assertNonNullable()`** — the assertion utility that narrows `T | null | undefined` to `T`

These protocols implement the architectural invariants that the Play RFC defines:

| #      | Invariant                  | Description                                                             |
| ------ | -------------------------- | ----------------------------------------------------------------------- |
| INV-01 | **Actor Authority**        | The Actor is the final authority. Guards decide all transitions         |
| INV-02 | **Strict Separation**      | The business logic never imports a UI framework or a routing library    |
| INV-03 | **Atom-Only Reactivity**   | Atoms are the only medium that crosses a boundary                       |
| INV-04 | **Passive Infrastructure** | The infrastructure observes the Actor atoms. It never enforces a guard  |
| INV-05 | **State-Driven Reset**     | An invalid external navigation is overwritten by the state of the Actor |

---

## Usage

### `PlayEvent<TPayload>`

The minimal event contract is any object with a `type: string` property. The contract is framework-agnostic. It works with XState, with Robot, and with every other state machine library.

```typescript
import type { PlayEvent } from "@xmachines/play";

// Flexible (accepts any additional fields):
const event: PlayEvent = { type: "auth.login", userId: "user123" };

// Type-safe (with generic payload):
type LoginEvent = PlayEvent<{ userId: string; timestamp: number }>;

const loginEvent: LoginEvent = {
	type: "auth.login",
	userId: "user123",
	timestamp: Date.now(),
};

// TypeScript error: missing required field
// @ts-expect-error -- `userId` and `timestamp` are missing
const invalid: LoginEvent = { type: "auth.login" };
```

### `PlayError`

Base class for the runtime errors of every Play package. It extends `BaseError` of [`@xmachines/core/errors`](../core/README.md). Each error has a stable `scope` (the class or the module that throws it) and a stable `code` (a machine-readable identifier). Always branch on `.code` or on the subclass. Never branch on `.message`.

```typescript
import { assertNonNullable } from "@xmachines/play";
import { NonNullableError, PlayError } from "@xmachines/play/errors";

const settings = new Map<string, string>([["apiUrl", "https://api.example.com"]]);

try {
	assertNonNullable(settings.get("timeout"), "timeout");
} catch (err) {
	if (err instanceof NonNullableError) {
		// err.scope === "assertNonNullable"
		// err.code  === "PLAY_NON_NULLABLE"
		console.error(`Missing value: ${err.message}`);
	} else if (err instanceof PlayError) {
		// Every other error of a Play package
		console.error(`[${err.scope}:${err.code}] ${err.message}`);
	} else {
		throw err;
	}
}
```

Extend `PlayError` in your own `@xmachines/*`-compatible packages:

```typescript
import { PlayError } from "@xmachines/play/errors";

export class MyPackageError extends PlayError {
	constructor(message: string, options?: ErrorOptions) {
		super("MyScope", "MY_PACKAGE_ERROR_CODE", message, options);
		this.name = "MyPackageError";
	}
}
```

### `assertNonNullable(value, name?)`

The assertion utility returns `value` with the type `NonNullable<V>`, or it throws `NonNullableError`. It removes the unsafe `!` non-null assertion.

```typescript
import { assertNonNullable } from "@xmachines/play";

const settings = new Map<string, string>([["apiUrl", "https://api.example.com"]]);

// A lookup that can miss — no intermediate variable or `!` needed:
const apiUrl = assertNonNullable(settings.get("apiUrl"), "apiUrl");
```

### The release protocol

The release protocol moved to [`@xmachines/core/utils`](../core/README.md). A subscription of the ecosystem hands back a plain release, and `asDisposable` of that package publishes the release for a `using` scope.

---

## API Summary

### Exported from `@xmachines/play`

| Export                | Kind       | Description                                                       |
| --------------------- | ---------- | ----------------------------------------------------------------- |
| `PlayEvent<TPayload>` | `type`     | Universal event contract — `{ type: string } & TPayload`          |
| `assertNonNullable`   | `function` | Asserts that the value is not null. It returns the narrowed value |

### Exported from `@xmachines/play/errors`

| Export             | Kind    | Description                                               |
| ------------------ | ------- | --------------------------------------------------------- |
| `PlayError`        | `class` | The base class of every typed error of a Play package     |
| `NonNullableError` | `class` | `scope: "assertNonNullable"`, `code: "PLAY_NON_NULLABLE"` |

---

## Error Codes

| Code                | Class              | Thrown When                                          |
| ------------------- | ------------------ | ---------------------------------------------------- |
| `PLAY_NON_NULLABLE` | `NonNullableError` | `assertNonNullable()` receives `null` or `undefined` |

Every other Play package exports its own error subclasses from its `./errors` subpath. [`@xmachines/core/errors`](../core/README.md) holds `BaseError`, the base of `PlayError`:

| Package                                                      | Import path                                                         |
| ------------------------------------------------------------ | ------------------------------------------------------------------- |
| `@xmachines/play`                                            | `@xmachines/play/errors`                                            |
| [`@xmachines/play-router`](../play-router/README.md)         | [`@xmachines/play-router/errors`](../play-router/README.md)         |
| [`@xmachines/play-url`](../play-url/README.md)               | [`@xmachines/play-url/errors`](../play-url/README.md)               |
| [`@xmachines/play-view`](../play-view/README.md)             | [`@xmachines/play-view/errors`](../play-view/README.md)             |
| [`@xmachines/play-xstate`](../play-xstate/README.md)         | [`@xmachines/play-xstate/errors`](../play-xstate/README.md)         |
| [`@xmachines/play-vue-router`](../play-vue-router/README.md) | [`@xmachines/play-vue-router/errors`](../play-vue-router/README.md) |

---

## License

MIT © [Mikael Karon](mailto:mikael@karon.se)

See [LICENSE](./LICENSE) for details.
