# Inter-plugin interfaces

Plugins talk through typed, versioned interfaces — methods as natives, events as forwards. Opt in to **interface protocol 2** for verified typed methods, notifications, hooks, and transforms. Protocol 1 remains available while a producer and its callers migrate together.

A producer `publish`es an implementation. Hard-dep consumers use name-inferred `use(name)`, producer-as-import named methods, producer-as-import `on`, or `bindForwards`. Optional consumers use `watchOptional`. Protocol 2 `on(...)` — including `import { on } from '@demo/…'` — returns a consumer-owned `Subscription` (`dispose()` is idempotent; ignoring the return remains valid). The host also ledgers subscriptions and drops them on unload.

CJS module evaluation runs **before** the load window. Put `publish`, `on`, `bindForwards`, `watchOptional`, and command registration inside `OnPluginStart`, not at module top level. Imports and exported handler definitions may stay top-level.

Declare deps under `s2script.pluginDependencies` or `optionalPluginDependencies`. `publish` / `use` / `tryUse` / `watchOptional` / `bindForwards` / producer-as-import `on(...)` are load-window only. Per-frame forwards go on `OnGameFrame` (before simulation), not `hook.server`.

See the [interfaces module](https://s2script.com/docs/api/modules/interfaces) and the [plugin module](https://s2script.com/docs/api/modules/plugin).

## Protocol 2

Set `s2script.interfaceProtocol` to `2` on both producer and consumer. A producer publishes one declaration, selected by its package `types` field.

```json
{
	"name": "@demo/counter",
	"version": "1.0.0",
	"types": "api.d.ts",
	"main": "src/plugin.ts",
	"s2script": { "interfaceProtocol": 2, "publishes": "self" }
}
```

```ts
// api.d.ts
import type { Notification } from '@s2script/sdk/interfaces';

export interface Contract {
	methods: { getCount(): number; setCount(count: number): void };
	forwards: { OnCountChanged: Notification<{ count: number }> };
}
```

```ts
import { publish } from '@s2script/sdk/plugin';

let count = 0;

export function OnPluginStart(): void {
	const counter = publish('@demo/counter', {
		getCount: () => count,
		setCount(next: number): void {
			count = next;
			counter.emit('OnCountChanged', { count });
		}
	});
}
```

```ts
import { use } from '@s2script/sdk/plugin';
import { getCount, on } from '@demo/counter';

export function OnPluginStart(): void {
	const counter = use('@demo/counter');
	counter.on('OnCountChanged', (event) => console.log(event.count));
	on('OnCountChanged', (event) => console.log(event.count));
	console.log(getCount());
}
```

`publish(name, impl)` and `use(name)` infer types from the provider name. Do not pass caller-selected generics. Authored `InterfaceContracts` augmentations do not authorize protocol 2 builds.

The CLI generates `.s2script/interfaces.d.ts` for name inference. New scaffolds include it in `tsconfig.json`; add it to existing projects' `include` list. Refresh with `s2s build` after changing declarations.

`s2s add` for a plugin dep downloads **types only** (registry metadata + types artifact), not the producer binary. Workspace siblings use the producer declaration directly.

Producer method agreement is checked even without an explicit implementation annotation. Each implementation must accept every declared input and return the declared synchronous result; async methods, value-returning implementations of void methods, and narrowed input types are rejected.

Contract **value** exports must agree with `Contract.methods`; extra functions, constants, enums, and aliases to runtime values are rejected. Type aliases, interfaces, and explicit type-only exports remain fine.

## Named forward bindings

Use `.on` for an inline callback: the selected provider contract infers the payload, and the returned `Subscription` controls that exact registration. Use `bindForwards(name, handlers)` when local handler names should differ from the provider's forward names:

```ts
import { bindForwards, command } from '@s2script/sdk';

type RaceResult = { elapsedMs: number };
type ParkourResult = { checkpoints: number };

export function OnRaceFinished(event: RaceResult): void {
	console.log(event.elapsedMs);
}
export function OnParkourFinished(event: ParkourResult): void {
	console.log(event.checkpoints);
}

export function OnPluginStart(): void {
	const racing = bindForwards('@demo/racing', {
		OnRunFinished: OnRaceFinished
	});
	const parkour = bindForwards('@demo/parkour', {
		OnRunFinished: OnParkourFinished
	});

	command.server('sm_disable_integrations', () => {
		racing.dispose();
		parkour.dispose();
	});
}
```

Forward keys stay qualified by the provider passed to `bindForwards`, so the same `OnRunFinished` name can have a different payload in each map. Binding is **explicit**: exported functions are never discovered or matched automatically. The whole map registers during the load window, rolls back if any entry fails, and the returned `Subscription` owns every registration from that call (dispose is idempotent). Types reject unknown forward keys, swapped same-name provider handlers, and async decision handlers.

`bindForwards` requires a hard `pluginDependencies` entry and does **not** open the optional attachment window. Optional integrations keep `watchOptional`, then pass named local functions to the supplied `service.on(...)` inside the attach callback.

## Wire values

Supported: null, boolean, finite number, string, literal enums, arrays, finite object records with optional fields, discriminated object unions, and `EntityRef`. Method results may also be void. All domain types must be declared in the entry file.

Contracts may import SDK `Notification`, `Hook`, `Transform`, `EntityRef`, and `HookResultValue` (from `@s2script/sdk/events` or the SDK barrel). External domain imports, re-exports, reference directives, unbounded dictionaries, recursive types, arbitrary classes, functions, BigInt, Promise, `any`, and `unknown` are rejected. This is the documented wire algebra, not arbitrary TypeScript.

Values cross by **copy**. Unpaired UTF-16 surrogates in strings or property names are rejected (`InterfaceValueNotSerializable`) before delivery; valid BMP and non-BMP text is preserved. Every field is checked before a producer method or notification listener receives the value; method results are checked before returning to the consumer. Unknown fields, present-but-undefined fields, nonfinite numbers, sparse arrays, symbols, proxies, accessors, and `toJSON` coercions do not pass. **Omit** an optional field or argument instead of passing `undefined`.

After a method call (including void), both original participant generations and the interface owner must still be live or the consumer gets `InterfaceUnavailable`.

`__s2ref` is reserved for the entity-reference wire envelope. Carry persistent player identity as copied identifiers and a map generation; a bare retained client slot is not a stable identity. Entity refs must use the original SDK prototype with exactly `index` and `id` as numeric primitives in range.

Nested method/notification crossings share a limit of 32 active calls; call 33 throws `InterfaceRecursionLimit`. Exactly one provider may own an interface. Notification identity is the interface name plus forward name.

## Notifications, hooks, and transforms

`emit` accepts notification names. `dispatch` accepts hook and transform names. Listeners run in registration order from a snapshot; every listener gets a fresh copy. Exceptions are logged and do not stop later listeners. Thenable returns are observed as synchronous-contract errors.

A **hook** asks listeners for a decision. A **transform** lets them return a shallow patch to declared writable fields. Both reuse `HookResult` from `@s2script/sdk/events` (`Continue = 0`, `Changed = 1`, `Handled = 2`, `Stop = 3`).

```ts
import type { Hook, Transform } from '@s2script/sdk/interfaces';

export interface Contract {
	methods: {};
	forwards: {
		OnRequest: Hook<{ identity: string }>;
		OnFormat: Transform<{ identity: string; text: string }, 'text'>;
	};
}
```

```ts
import { use } from '@s2script/sdk/plugin';
import { HookResult } from '@s2script/sdk/events';

export function OnPluginStart(): void {
	const service = use('@demo/formatter');
	service.on('OnRequest', (event) =>
		event.identity === 'blocked' ? HookResult.Handled : HookResult.Continue
	);
	service.on('OnFormat', (event) => ({
		result: HookResult.Changed,
		patch: { text: event.text.trim() }
	}));
}
```

The producer can dispatch later from a registered command or method:

```ts
const decision = formatter.dispatch('OnRequest', { identity: 'guest' });
const formatted = formatter.dispatch('OnFormat', { identity: 'guest', text: ' hello ' });
// formatted: { result: HookResultValue; payload: { identity: string; text: string } }
```

Hooks return the highest listener result, defaulting to Continue. Changed alone does not mutate. Handled still runs later listeners; Stop ends the snapshot immediately.

Transforms return `{ result, patch? }`. A patch is allowed only with Changed and may contain only the contract's writable keys. The host validates the entire response before applying anything. Patches replace whole field values — no deep merge. `undefined` does not remove a field. An empty listener set returns Continue and a copy of the original payload.

Handlers must finish synchronously. A throwing listener, invalid result, invalid patch, or thenable contributes Continue and no patch (fail-open). Security-sensitive producers must account for that. Forward kind and, for transforms, sorted writable keys are part of canonical metadata; changing either requires compatible contracts even if payload fields stay identical.

## Ownership and optional providers

Every protocol 2 `on`, including a direct producer import, returns an idempotent `Subscription` with `dispose(): void`. Disposal removes that exact registration. Disposing before arming cancels buffered registration. The host also ledgers subscriptions to the consumer.

`tryUse` is a **one-time** optional lookup and does not track availability. To follow an optional provider across load and reload, declare it under `s2script.optionalPluginDependencies`, acquire types with `s2s add`, then register a watch from `OnPluginStart`:

```ts
import { watchOptional } from '@s2script/sdk/plugin';

export function OnPluginStart(): void {
	const watch = watchOptional('@demo/counter', (counter, scope) => {
		scope.own(counter.on('OnCountChanged', (event) => console.log(event.count)));
		scope.own({
			dispose() {
				console.log('counter attachment ended');
			}
		});
	});
	// watch.dispose() from a later callback removes the watch and its current attachment
}
```

The host calls `attach` after both plugins are Active, once per compatible provider identity and generation. A watch stays pending while the provider is absent. An incompatible provider logs and does not attach; a later compatible generation can. Attachment callbacks must be synchronous.

`scope.own(resource)` returns its input and retains its disposer for this attachment. Forward `on` via the supplied service is automatically attachment-owned. Service `on` is allowed only during that synchronous attach callback; saved handles cannot reopen registration later. Methods on a retained service throw `InterfaceUnavailable` once the attachment ends (including after provider reload).

Manually unloading **one** provider leaves hard consumers running and removes bindings to that provider; bindings to other providers remain. After restarting the provider, reload the hard consumer to re-register those forward bindings. Use `watchOptional` for automatic reattachment across provider generations. Unloading a full set still walks reverse-dependency order.

## Host API 3 and migration

SDK builds stamp host API **3.x**. Protocol 2 needs API 3 plus contract metadata digests (RFC 8785 JCS). Protocol 2 archives carry `interfaceProtocol: 2` and hashed declaration bytes. An archive cannot evade that by declaring API 2.

The API 3 host still loads API 2 protocol 1 archives (legacy generic types and permissive wire). Old API 2 hosts reject API 3 archives. Rebuild and deploy both ends for protocol 2; a protocol 1 consumer cannot silently bind to a protocol 2 provider.

## Zones

`@s2script/zones` **1.0.0** is the first base plugin on protocol 2. Seven methods and five notifications (`enter` / `leave` / `stay` / `created` / `deleted`) keep the same names and payload fields. Consumers migrate atomically: `interfaceProtocol: 2`, refresh the verified declaration, depend on `^1.0.0`, and drop caller-selected generics.

Hard dep:

```ts
import { on, getZones } from '@s2script/zones';
import { Player } from '@s2script/cs2';

export function OnPluginStart(): void {
	on('enter', (event) => {
		const player = Player.fromUserId(event.userId);
	});
	getZones();
}
```

Optional dep — own the five subscriptions and call `getZones()` after subscribing on **every** attachment (including provider reload). Notifications do not replay history; initial map DB loads precede publication and are never replayed:

```ts
import { watchOptional } from '@s2script/sdk/plugin';

export function OnPluginStart(): void {
	watchOptional('@s2script/zones', (zones, scope) => {
		scope.own(zones.on('enter', onEnter));
		scope.own(zones.on('leave', onLeave));
		scope.own(zones.on('stay', onStay));
		scope.own(zones.on('created', onCreated));
		scope.own(zones.on('deleted', onDeleted));
		zones.getZones();
	});
}
```

`enter` / `leave` are engine boundary crossings. `stay` fires every eighth game frame for the same connection that entered. Disconnect/map cleanup does not invent leave events. Occupancy is keyed by connection `userId`, not slot alone — resolve `Player.fromUserId(event.userId)` when acting on an event. Delayed previous-map DB loads cannot repopulate a newer map.

Protocol selection is whole-plugin. Cookbook econ/workshop recipes stay protocol 1 in [`examples/legacy-contracts`](https://github.com/s2script/s2script/tree/main/examples/legacy-contracts) because those community contracts are asynchronous.

See the [zones plugin](https://s2script.com/plugins/zones).

## BaseComm

`@s2script/basecomm` **1.0.0** publishes mute and gag policy. Methods: `isMuted`, `isGagged`, `setMuted`, `setGagged` — SteamID-keyed (canonical nonzero decimal u64 strings). Notifications: `OnClientMuteChanged` / `OnClientGagChanged` with `{ steamId: string; state: boolean }`, fired only on real policy transitions.

Queries return false for invalid identities. Setters return false for invalid identity or nonboolean state; otherwise the return is whether current policy equals the requested value when the call returns (including an unchanged request). Offline policy is allowed; reconnect restores voice mute and the scoreboard indicator. Mute result describes BaseComm **policy** — it does not claim audio delivery if the host voice descriptor is degraded.

Command and menu paths keep permissions, immunity, targeting, and translated replies. Direct service callers are **trusted plugins** and bypass those checks.

The [`interop-observer`](https://github.com/s2script/s2script/tree/main/examples/interop-observer) example watches BaseComm as optional, owns subscriptions in the attachment scope, and queries current state after subscribe. It builds from a types-only copy (`s2s add @s2script/basecomm`); move the generated entry to `optionalPluginDependencies`. The operator installs the provider archive separately.

See the [basecomm plugin](https://s2script.com/plugins/basecomm).

## BaseBans

`@s2script/basebans` **1.0.0** publishes `ban(request)` → `{ recorded, result }` and `unban({ steamId })` → boolean, plus `OnBanRequested` (advisory Hook) and `OnBanRecorded` / `OnBanRemoved` (notifications).

`BanRequest` is `{ steamId, minutes, reason, source: 'command' | 'menu' | 'plugin', actorSteamId }`. SteamIDs (including non-null actors) must be canonical nonzero decimal u64 strings. `minutes` is a nonnegative safe integer (`0` = permanent). Invalid domain inputs return `{ recorded: false, result: Continue }` before hooks, store, or effects.

Plugin callers are trusted; `source` / `actorSteamId` are caller-supplied context, not auth proof. Command/menu wrappers keep permission, targeting, and immunity checks.

`OnBanRequested`: Handled/Stop suppress default record+kick (`recorded` false). Changed alone does not mutate. Fail-open Continue on exceptions. This hook is **not** an authorization boundary.

`recorded` means verified immediate host-cache readback after `Bans.add` (exact reason + expiry consistent with minutes/wall clock) — **not** disk durability. Persistence failure can leave `recorded` true and lose the record on restart. Missing/mismatched readback → false, no notification or kick.

Online ban snapshots target+actor identity before callbacks and re-resolves the same target before kick. Reconnect enforcement never emits `OnBanRecorded`. `sm_addban` stays record-only even if that SteamID is connected. `unban` returns whether a cache key existed; `OnBanRemoved` fires only on true — no disk ACK.

The same interop-observer example also `watchOptional('@s2script/basebans', …)`, owns the three forwards, and returns Continue (it never bans).

SDK `Bans.add` returns `void`: cache update first, then attempt persistence; failure is not acknowledged and does not roll back the cache. `Bans.get()` after add confirms cache visibility only. `Bans.remove` returns whether a cache key existed, not disk ACK.

See the [basebans plugin](https://s2script.com/plugins/basebans) and the [bans module](https://s2script.com/docs/api/modules/bans).

## Protocol 1

Protocol 1 interfaces remain with generic handles and permissive wire. A hard dep (`use` / producer-as-import) is a proxy that throws `InterfaceUnavailable` while the producer is unloaded; `tryUse` resolves to `T | null` and does not follow later availability.

```ts
import { tryUse } from '@s2script/sdk';
import type { Zones } from '@s2script/zones';

export function OnPluginStart(): void {
	const handle = tryUse<Zones>('@s2script/zones');
	if (!handle) return;
	handle.on('enter', ({ slot, zone }) => {
		/* … */
	});
}
```

Rebuild both ends before switching a pair to protocol 2.

Opt-in acceptance fixtures for the protocol 2 stack live under [`tools/interop-acceptance`](https://github.com/s2script/s2script/tree/main/tools/interop-acceptance) (test plugins and live probes). There is no public SDK diagnostic API for those runs.
