Commit Graph
4 Commits
Author SHA1 Message Date
7e073dda0b fix(server): keep matchmaking games out of lobby reports; salvage invalid entries (#4641)
## Problem

Prod masters log `Invalid IPC message from worker` for every ranked
1v1/2v2 match created:

1. The matchmaking poll loop creates the match game with the 1v1/2v2
playlist config (`gameType: Public`) but no `publicGameType` —
correctly, since ranked games are invite-only and should never be
advertised.
2. While that game sits in its ~7s lobby window,
`GameManager.publicLobbies()` includes it (it only checks phase +
`isPublic()`), and `WorkerLobbyService.sendMyLobbiesToMaster()` reports
it with `publicGameType: gi.publicGameType!` — the non-null assertion
hides the `undefined`, and IPC serialization drops the key.
3. The master's `WorkerMessageSchema` parse fails on the missing
required field and **discards the worker's entire lobby report**.

So for the lobby window of every ranked match, the master's view of that
worker freezes: stale player counts and countdowns broadcast to all
clients, `maybeScheduleLobby` repeatedly resetting the next lobby's
countdown, and occasional duplicate scheduled lobbies (with no cleanup
path for scheduled types). With matchmaking running continuously, some
worker is blocked at any given moment on a busy server.

## Fix

- **Worker** (`WorkerLobbyService`): filter games without a
`publicGameType` out of the report. Matchmaking games are never
advertised — which also guarantees they can't leak into the public lobby
browser.
- **Master** (`MasterLobbyService` + `IPCBridgeSchema`): validate lobby
entries individually instead of failing the whole message. Malformed
entries are logged and dropped; valid lobbies survive. A future bug of
this class now degrades to one missing entry instead of a frozen worker
view. The send side keeps compile-time safety via the existing per-entry
`satisfies` annotations.

## Testing

- New test: a matchmaking-style game (Public, `allowedPublicIds`, no
`publicGameType`) is excluded from the worker's `lobbyList` report while
a normal FFA lobby still goes through.
- New test: a report containing a malformed entry keeps its valid
lobbies in the next broadcast instead of being rejected wholesale.
- Full `tests/server` suite passes (173 tests); `tsc --noEmit` and
eslint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:08:59 -07:00
46b9fe88bb fix: survive host modal close during listed-lobby auto-start (#4514)
Follow-up to the listed-lobby auto-start feature (`2a82b01e3`, on main).

## Bug

When a listed lobby auto-started (and in some paths on manual start),
the game began server-side but a player got ejected back to the menu
before it loaded.

Root cause: `BaseModal.close()` navigates via `showPage()`, which
**force-closes whichever page-modal is currently visible**. During the
game-start transition that is the *other* lobby modal — closing the join
modal first cascade-closed the host's modal with `leaveLobbyOnClose`
still armed (host disconnected → host-left teardown killed the game);
reversing the order just moved the victim to the joiner. No close order
fixes both sides.

## Fix

- **Disarm both lobby modals before closing either** in the prestart
transition (`disarmLeaveOnClose()` on `HostLobbyModal` and
`JoinLobbyModal`), so no cascade order can disconnect anyone mid
game-start.
- Both modals re-arm `leaveLobbyOnClose` in `onOpen` instead of
`onClose`'s state reset, so once disarmed no later cascade close can
re-arm it until the modal is reopened.
- `HostLobbyModal.closeWithoutLeaving()` (pattern `JoinLobbyModal`
already used), called explicitly instead of the generic modal-close
loop.
- **Server hardening:** `handleClientDisconnect` bails on `hasStarted()`
(which includes prestart) instead of raw `_hasStarted`, so host socket
churn during the lobby → game transition can never tear down a starting
game regardless of client behavior. Regression test included.

## Verification

Headless end-to-end (two browser contexts, host + joiner, `leave-lobby`
stack-trace tripwire armed on both pages):

- **Manual start:** host and joiner both receive prestart/start and
enter the game; zero `leave-lobby` dispatches.
- **Auto-start:** same, with nobody interacting after listing.

Also confirmed manually in a real browser. The auto-start deadline was
temporarily 30s during testing and is restored to 5 minutes in the final
commit. Full suite green (1843 + 161), tsc/eslint/prettier clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:55:16 -07:00
evanpelleandClaude Fable 5 2a82b01e30 feat: auto-start listed lobbies after 5 minutes
Hosts can't sit on a public listing: setListed(true) records listedAt,
and once listedAt + HOSTED_LOBBY_AUTO_START_MS passes, GameManager's
tick arms the normal start countdown (same path as the host's Start
button, honoring startDelay). Cancelling the countdown re-arms on the
next tick; unlisting cancels the deadline and relisting starts a fresh
one. Duplicate setListed(true) calls don't extend it.

The deadline rides to the host as autoStartAt in lobby info, rendered
as an amber countdown next to the Public/Private switch in the host
modal header (hidden once the real start countdown takes over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:11:13 -07:00
5d21be826d feat: subscriber-hosted public lobby listing (#4480)
Part of #4040 (v1 scope: listing + browser + per-subscriber limit;
custom lobby name/description left for a follow-up).

## What

Subscribers can toggle their **private lobby** to be **publicly
listed**; a browsable **"Open Lobbies"** list appears in the Join Lobby
modal. Hard limit of **one listed lobby per subscriber**, enforced
cluster-wide.

## How

**Semantics** — a listed lobby stays `GameType.Private`: the host keeps
full control and starts the game manually; the toggle only controls
visibility. The `listed` flag lives on `GameServer` (not `GameConfig`),
so it cannot be smuggled in through `update_game_config` and never
touches core/sim/records.

**Distribution** — reuses the existing public-lobby pipeline end to end:
a new `"hosted"` `PublicGameType` bucket flows worker → master IPC →
`/lobbies` websocket → `PublicLobbySocket`. Master scheduling now
iterates only `SCHEDULED_PUBLIC_GAME_TYPES` (`ffa`/`team`/`special`), so
it never sets countdowns on or schedules replacements for hosted
lobbies. Lobbies delist automatically when the game starts/fills/dies
(phase change). The broadcast fingerprint now includes browser-visible
config, so host edits (map/mode) refresh the list even though the gameID
doesn't change.

**Gating** — new authenticated endpoint `POST /api/game/:id/listing`:
- creator-only (403), private + not-started only (409)
- fresh subscription check via server-side `getUserMe` using the shared
`hasActiveSubscription()` helper (`active`/`trialing`); skipped in
`GameEnv.Dev` (same precedent as Turnstile) so it's testable locally
- one-lobby-per-creator (409): a SHA-256 hash of the creator's
persistentID rides worker↔master IPC (`PublicGameInfo.creatorID`); the
master dedupes as a race backstop. The hash — and host-only config
(whitelist, name reveals) — are **stripped from every client payload**
(broadcast + primed snapshot).

**Client** — subscriber-gated "List lobby publicly" toggle in the host
modal (server rejection reverts the toggle and shows a translated
message); "Open Lobbies" rows (map, mode, player count) in the Join
Lobby modal that reuse the existing private-join flow.

**Compat** — `PublicGames.games` is now a `partialRecord`, so newer
clients tolerate servers that don't send every bucket. Note:
already-open old clients will fail to parse broadcasts containing the
new `hosted` key until refreshed (closed Zod enum) — same class of break
as previous wire-schema changes.

## Testing

- `tests/server/HostedLobbyListing.test.ts` (15 tests): listed-lobby
filtering, flag not settable via config intent, master aggregation +
creator dedupe + no scheduling of hosted, creatorID stripping (broadcast
+ primed snapshot), `creatorHasListedLobby` (broadcast + local),
fingerprint refresh on config change
- `hasActiveSubscription` cases in `ApiSchemas.test.ts`; hosted
counts-delta patch in `LobbySocket.test.ts`
- Full suite green (1723 + 141 tests), tsc/eslint/prettier clean
- **E2E in the real app** (headless Chromium, two browser contexts):
host lists lobby → appears in second browser's Join Lobby list
(creatorID absent from payload) → join succeeds (2 players in lobby) →
same creator's second lobby rejected 409 with toggle revert → unlist
removes it from a fresh browser's list. Curl negatives: missing auth
400, bad token 401, non-creator 403, missing game 404, bad body 400.

## Known follow-ups

- Custom lobby name/description in the browser (needs the censor
pipeline) — rest of #4040
- A listed lobby whose host closes the tab stays advertised indefinitely
(an empty private lobby never leaves the Lobby phase) — pre-existing
lifecycle, now more visible; consider delisting on creator disconnect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:25:56 -07:00