# Game channels

> A channel per game for its author and everyone with an accepted change, with moderation and announcements.

Every game has a channel where the people who built it talk. Its members are the game's **author** and every player with an **accepted change** in it (the game's credits). Nobody else can read or write it. Players appear by their tag ("Player 1F7D", `handleFor`), never a name.

The author is the moderator: they can remove any message, mute a member (who then reads but can't write), and send **announcements**, which show as such in the channel and reach every other member as a [notification](https://docs.moonspell.app/moonspell/notifications.md).

## How it works

```text
 App ── POST /api/app/games/:id/channel/messages ──▶ Server: checks membership, mute, rate
                                                     insert game_channel_messages
                                                     POST /rt/runs/game:<id>/events ──▶ Edge RunChannel DO "game:<id>"
 App ◀── wss /rt/runs/game:<id>?token=crt1 ────────────────────────────────────────────┘  (everyone connected)
```

- **Rows are the truth** (`db/migrations/0014_…sql`: `game_channel_messages`, `_mutes`, `_reads`, `_reports`, `user_blocks`); `src/lib/channel.ts` has every rule.
- **The edge only fans out.** A channel uses the same Durable Object class as a run's live progress ([Realtime](https://docs.moonspell.app/moonspell/realtime.md)), under the id `game:<gameId>` (`gameChannelId`), with the same `crt1` subscribe token (15 minutes, in `channel.live.url`). So the edge Worker has no channel code. Without the edge configured, `live` is null and an app refetches instead.
- **The socket is one-way.** Apps send messages over HTTP, never the socket.

## API

All with the app's bearer session. `:id` is the game's id.

| Route | What |
|---|---|
| `GET /api/app/channels` | `{channels: ChannelSummary[], unread}`: the channels the player is in, most recently active first. A game of their own is listed once someone else has a change in it or something was said. |
| `GET /api/app/games/:id/channel[?before=<createdAt>]` | The `Channel` (below). 403 `{code: "not_a_member"}` for anyone else. |
| `POST /api/app/games/:id/channel/messages` | `{text}` or, from the author, `{text, announcement: true}` → 201 `{message}`. Errors carry `{error, code}`: `forbidden` 403, `muted` 403, `empty` 400, `too_fast` 429 (12 a minute), `too_many_announcements` 429 (3 a day). |
| `DELETE /api/app/games/:id/channel/messages/:messageId` | Its writer or the author removes it. |
| `POST /api/app/games/:id/channel/messages/:messageId/report` | `{reason?}`. Any member. |
| `POST /api/app/games/:id/channel/members/:memberId` | `{muted: boolean}` (the author) → `{muted}`; `{blocked: boolean}` (anyone) → `{blocked: [memberId…]}`. A blocked member's messages aren't shown to that player in any channel. |
| `POST /api/app/games/:id/channel/read` | Marks the channel read. |

```ts
type ChannelAuthor  = { id: string /* memberId, not the account id */; handle: string; role: "author" | "member"; isYou: boolean };
type ChannelMessage = { id: string; kind: "message" | "announcement"; text: string; author: ChannelAuthor; createdAt: string };
type Channel = {
  game: { id: string; title: string; coverUrl: string | null };
  role: "author" | "member";
  muted: boolean;                       // this player was muted
  members: (ChannelAuthor & { muted: boolean; changes: number })[];   // the author first
  messages: ChannelMessage[];           // oldest first, at most 60
  more: boolean;                        // older ones exist: ?before=<messages[0].createdAt>
  blocked: string[];                    // memberIds this player blocked
  live: { url: string } | null;         // the WebSocket
};
type ChannelSummary = {
  game: { id: string; title: string; coverUrl: string | null };
  role: "author" | "member"; members: number; unread: number;
  last: { text: string; handle: string; kind: "message" | "announcement"; createdAt: string } | null;
};
```

Messages are plain text: at most 1000 characters (an announcement 280), control characters removed.

## The live socket

Open `channel.live.url`. The first frame is `{"type":"snapshot","events":[…]}` (the last 50 events at most), then one event per frame. Send exactly `{"type":"ping"}` every 20 to 30 s; the answer is `{"type":"pong"}`.

```ts
{ type: "message", at, message: ChannelMessage }   // author has no isYou: compare author.id with your own member id
{ type: "removed", at, id }                         // drop that message
{ type: "muted",   at, member, muted }              // member is a memberId; when it is you, flip `muted`
```

What an app does:

1. `GET …/channel`, show `messages`, remember your own member id (`members.find(isYou).id`).
2. Open the socket. Apply the snapshot's events in order, then every event: a `message` whose id is already shown is ignored (your own posts come back this way too), one whose `author.id` is in `blocked` is dropped, `removed` deletes.
3. When the socket drops, or closes with 1008 or an HTTP 401: `GET …/channel` again (fresh `live.url`, and the messages missed) and reconnect, with backoff (1, 2, 4, up to 15 s).
4. `POST …/read` when the channel is opened and when a message arrives while it is on screen.

## Limits

- Members only, so a player whose change is cancelled stays a member only while another accepted change of theirs remains.
- Reports are stored (`game_channel_reports`).
- No typing or "who's here" yet: the Durable Object would need to know who is connected to say so.
