--- id: invitations title: Nobody is put in a match without being asked status: open repos: [headquarters, lexicons] dependsOn: [lobby] exitCriterion: > A player is challenged, decides for themselves whether to play, and the match only exists because they said yes. --- # invitations [lobby](lobby.md) built the mechanism for getting several humans into one container. It did not build the part where anybody is asked first: today an invited player's seat is filled at launch and the match simply appears in their list. That is the wrong default, and it is why this sits high in the order rather than with the other multiplayer work. A challenge somebody accepts is also PDS-first state, which is the other half of the reason it is its own epic. ## Consent - [ ] **Nothing asks an invited player.** `plan_players` resolves a handle to a DID and seats them. There is no accept step, no decline, and no way to not be seated. The lobby's own `invite_handle` (below) now does the identical thing, so this is the live default in two places, not one — fixing it here should fix both. - [ ] **Decide what a decline does to the lobby.** A seat that empties after the fact is a state the lobby's seat map already handles (a scenario change can orphan a seat), but the launch path has no equivalent. - [ ] **Decide whether a stranger may challenge you at all.** This is where `user-safety` and this epic meet: an invite from an account you have never heard of is the first unsolicited contact the system permits. ## Reaching somebody who is not there - [ ] **`OpenInvite`** — a challenge that names a condition for who may accept rather than naming a person. - [ ] **`Matchmaking`.** - [ ] **Notify somebody who is not on the site.** An invitation nobody sees is not an invitation. What that means without email is an open question, and it may be that the answer is a record in their repo and nothing more. - [ ] **Say on the masthead that somebody is waiting.** The same sentence applies to a player who *is* on the site: nothing in the chrome says a challenge arrived, so the only way to find out is to open Play and look. A count on the account control is the cheap version — it is on every page already, it reads the session already, and it costs no new label on a bar that has none to spare (see [site-a11y](site-a11y.md)). Two things to decide with it. What the count counts once challenges can be declined or expire, because a badge that outlives what it points at is worse than none. And whether it is only a count: the alternative is a small panel under the account control listing who challenged you, which is the difference between the bar telling you to go somewhere and the bar being where you answer. ## Lexicons A challenge names slots rather than an opponent, because matches are N-player and team-based. - [ ] **A challenge record.** It either names invitees directly or names a condition for who may accept, such as membership of a list. Membership is resolved when somebody accepts, not when the challenge is written, so a challenge to a group still works for people who join the group later. - [ ] **An acceptance record.** Acceptances are claims, like everything else here. If several people race for one open slot there is no ordering to appeal to, so the match record settles which acceptances took. - [ ] **A challenge may name the sanctioners the challenger would like.** That is a request, not a binding — a sanctioner writes its own record or does not. See [match-records](match-records.md). - [ ] **Reference a challenge by bare at-uri, not a strongRef.** It changes by design as it moves from open to accepted, and a CID would go stale on the first write. ## Also here - [ ] **Tighten the lobby socket's auth.** Connecting to a lobby needs a valid session and nothing else — no seat check — so any signed-in player can connect to any lobby and see or change its scenario. Deliberately permissive while nothing exposed was sensitive; a challenge that names people makes a lobby something worth closing. - [ ] **"Any seated player may deploy."** Deploy is owner-only today, which was the first slice's scope cut, not a decision. ## Done - [x] **Inviting a handle who is not connected.** `Lobbies::invite_handle` — the same `Atproto::resolve_player` lookup `plan_players` uses, spawned off the socket's own `tokio::select!` loop rather than awaited inline so a slow resolution does not block that connection's other messages. This is only the mechanism: it seats the resolved DID the moment resolution succeeds, with no accept step, which is the "Consent" section above, still open.