diff --git a/src/apps/lobby/__tests__/catalog.test.ts b/src/apps/lobby/__tests__/catalog.test.ts
new file mode 100644
index 0000000..b79f292
--- /dev/null
+++ b/src/apps/lobby/__tests__/catalog.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { CATALOG, catalogEntry, seatsOf, seatsLine } from '../catalog'
+
+// One list says what the room can play: the panel's picker, the command
+// menu and the tables' seat counts all read it.
+
+describe('the game catalog', () => {
+ it('names each game once, families in the order the picker shows them', () => {
+ expect(CATALOG.map(entry => entry.game)).toEqual(['castle', 'golf', 'rummy', 'chess'])
+ expect(CATALOG.map(entry => entry.family)).toEqual(['Cards', 'Cards', 'Cards', 'Board'])
+ })
+
+ it('reads each game’s seats; a summary from before games were named is golf’s', () => {
+ expect(seatsOf('chess')).toBe(2)
+ expect(seatsOf('rummy')).toBe(4)
+ expect(seatsOf(undefined)).toBe(4)
+ expect(catalogEntry(undefined).game).toBe('golf')
+ })
+
+ it('says how many play, and what is chosen at the table', () => {
+ expect(seatsLine(catalogEntry('chess'))).toBe('2 players')
+ expect(seatsLine(catalogEntry('castle'))).toBe('2–4 players')
+ expect(seatsLine(catalogEntry('rummy'))).toBe('2–4 players · 7-card, 10-card or gin, chosen at the table')
+ })
+})
diff --git a/src/apps/lobby/catalog.ts b/src/apps/lobby/catalog.ts
new file mode 100644
index 0000000..875cfbc
--- /dev/null
+++ b/src/apps/lobby/catalog.ts
@@ -0,0 +1,70 @@
+import type { HubGameName } from '@/utils/hubStream'
+
+// What a room can play, in the order the lobby offers it: the panel's
+// picker, the command menu and every table's seat count read this list,
+// so a new game is one entry here and its table hook.
+
+export type GameFamily = 'Cards' | 'Board'
+
+export interface CatalogEntry {
+ game: HubGameName
+ label: string
+ family: GameFamily
+ seats: { min: number; max: number }
+ blurb: string
+ // Chosen at the table, between deals, rather than here.
+ variants?: string
+}
+
+export const CATALOG: readonly CatalogEntry[] = [
+ {
+ game: 'castle',
+ label: 'Castle',
+ family: 'Cards',
+ seats: { min: 2, max: 4 },
+ blurb: 'Shed every card first. 2s reset the deck, 10s clear it, four of a kind counts as a 10.'
+ },
+ {
+ game: 'golf',
+ label: 'Golf',
+ family: 'Cards',
+ seats: { min: 2, max: 4 },
+ blurb: 'Lowest hand wins. Peek at two, then draw and swap; knock to call the last round.'
+ },
+ {
+ game: 'rummy',
+ label: 'Rummy',
+ family: 'Cards',
+ seats: { min: 2, max: 4 },
+ blurb: 'Draw, lay down sets and runs, discard. First to empty their hand wins.',
+ variants: '7-card, 10-card or gin'
+ },
+ {
+ game: 'chess',
+ label: 'Chess',
+ family: 'Board',
+ seats: { min: 2, max: 2 },
+ blurb: 'King and pawn against king, from a random position, on the clock. Promote and mate, or hold the draw.'
+ }
+]
+
+export const FAMILIES: readonly GameFamily[] = [...new Set(CATALOG.map(entry => entry.family))]
+
+// A summary from before the game was named is golf's.
+export function catalogEntry(game: HubGameName | undefined): CatalogEntry {
+ const named = game ?? 'golf'
+ const entry = CATALOG.find(candidate => candidate.game === named)
+ if (entry === undefined) throw new Error(`not in the catalog: ${named}`)
+ return entry
+}
+
+export function seatsOf(game: HubGameName | undefined): number {
+ return catalogEntry(game).seats.max
+}
+
+// "2 players", "2–4 players", and what the table chooses, if anything.
+export function seatsLine(entry: CatalogEntry): string {
+ const { min, max } = entry.seats
+ const players = min === max ? `${min} players` : `${min}–${max} players`
+ return entry.variants === undefined ? players : `${players} · ${entry.variants}, chosen at the table`
+}
diff --git a/src/apps/lobby/components/LobbyPanel.module.css b/src/apps/lobby/components/LobbyPanel.module.css
index 3755a46..70e20af 100644
--- a/src/apps/lobby/components/LobbyPanel.module.css
+++ b/src/apps/lobby/components/LobbyPanel.module.css
@@ -140,3 +140,31 @@
border-top-right-radius: 0.75rem;
}
}
+
+/* The new-table picker: the game and its button on one line, the button
+ taking what the select leaves. */
+.picker {
+ display: flex;
+ gap: 0.5rem;
+ align-items: stretch;
+}
+
+.picker select {
+ min-width: 0;
+ padding: 0.6rem 0.5rem;
+ border-radius: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ background: rgba(0, 0, 0, 0.2);
+ color: white;
+ font-size: 1rem;
+}
+
+/* The open list is the platform's: dark text on its own light menu. */
+.picker option,
+.picker optgroup {
+ color: #111;
+}
+
+.picker button {
+ flex: 1;
+}
diff --git a/src/apps/lobby/components/LobbyPanel.tsx b/src/apps/lobby/components/LobbyPanel.tsx
index 598dfd2..803b2be 100644
--- a/src/apps/lobby/components/LobbyPanel.tsx
+++ b/src/apps/lobby/components/LobbyPanel.tsx
@@ -1,9 +1,10 @@
-import { useSyncExternalStore, type Ref } from 'react'
+import { useState, useSyncExternalStore, type Ref } from 'react'
import PermalinkDisplay from './PermalinkDisplay'
import type { UseLobby } from '@/hooks/useLobby'
import { lobbyRoomPath } from '@/hooks/useLobby'
import type { HubRoomPlayer } from '@/utils/hubStream'
import type { VoiceMesh, VoiceView } from '@/utils/voiceMesh'
+import { CATALOG, FAMILIES, catalogEntry, seatsLine } from '../catalog'
import { atTable, seatsOf, tableFor, tableOffer } from '../offers'
import styles from './LobbyPanel.module.css'
@@ -19,11 +20,34 @@ const presence = (player: HubRoomPlayer): string => {
// The room's running record, kept by the hub across its tables.
const record = (player: HubRoomPlayer): string => `${player.gamesWon}/${player.gamesPlayed} won`
-const GAME_BLURB = {
- castle: 'Shed every card first. 2s reset the deck, 10s clear it, four of a kind counts as a 10.',
- golf: 'Lowest hand wins. Peek at two, then draw and swap; knock to call the last round.',
- rummy: 'Draw, lay down sets and runs, discard. First to empty their hand wins.',
- chess: 'King and pawn against king, from a random position, on the clock. Promote and mate, or hold the draw.'
+// A new table: one picker of every game, grouped by family, one button
+// that opens the picked one, and what that game is and who it seats. A
+// game more is an entry in the catalog, never a button more here.
+const NewTable = ({ lobby, disabled }: { lobby: UseLobby; disabled: boolean }) => {
+ const [game, setGame] = useState(CATALOG[0].game)
+ const entry = catalogEntry(game)
+ return (
+
+
+
+
+
+
{entry.blurb}
+
{seatsLine(entry)}
+
+ )
}
// The command menu has no button of its own: this is where it is told.
@@ -169,26 +193,7 @@ const LobbyPanel = ({ lobby, roomCodeRef }: LobbyPanelProps) => {
})}
)}
- {/* One offer per game, and the room hosts them all: none is the
- house game, so none is the louder button. */}
-
-
-
{GAME_BLURB.castle}
-
-
{GAME_BLURB.golf}
-
-
{GAME_BLURB.rummy}
-
-
{GAME_BLURB.chess}
-
+
{COMMAND_HINT}
diff --git a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
index 4aca42e..55b8e9b 100644
--- a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
+++ b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
@@ -75,8 +75,6 @@ describe('LobbyPanel', () => {
expect(players.getByText('free · 0/0 won')).toBeTruthy()
expect(players.getByText('at castle G1 · 1/3 won')).toBeTruthy()
expect(players.getByText('away · 0/0 won')).toBeTruthy()
- expect(screen.getByText(/Shed every card first/)).toBeTruthy()
- expect(screen.getByText(/Lowest hand wins/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Join castle G1' }))
expect(hook.castle.joinTable).toHaveBeenCalledWith('G1')
@@ -88,27 +86,12 @@ describe('LobbyPanel', () => {
expect(hook.rummy.joinTable).toHaveBeenCalledWith('M1')
expect(hook.castle.joinTable).toHaveBeenCalledTimes(1)
expect(hook.golf.joinTable).toHaveBeenCalledTimes(1)
- expect(screen.getByText(/First to empty their hand wins/)).toBeTruthy()
-
- const openCastle = screen.getByRole('button', { name: 'Open a castle table' })
- const openGolf = screen.getByRole('button', { name: 'Open a golf table' })
- // The same offer for either game: whichever the room plays, the
- // button for it looks the same.
- expect(openGolf.className).toBe(openCastle.className)
- fireEvent.click(openCastle)
- expect(hook.castle.createTable).toHaveBeenCalled()
- fireEvent.click(openGolf)
- expect(hook.golf.createTable).toHaveBeenCalled()
- const openRummy = screen.getByRole('button', { name: 'Open a rummy table' })
- expect(openRummy.className).toBe(openCastle.className)
- fireEvent.click(openRummy)
- expect(hook.rummy.createTable).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Leave room' }))
expect(hook.leaveRoom).toHaveBeenCalled()
})
// Chess seats two, where every other game seats four.
- it('a chess table reads its own seats, is full at two, and opens from its own button', () => {
+ it('a chess table reads its own seats and is full at two', () => {
const hook = lobby({
room: room({
games: [
@@ -122,8 +105,37 @@ describe('LobbyPanel', () => {
expect(screen.getByRole('button', { name: 'Full chess K2' })).toHaveProperty('disabled', true)
fireEvent.click(screen.getByRole('button', { name: 'Join chess K1' }))
expect(hook.chess.joinTable).toHaveBeenCalledWith('K1')
- fireEvent.click(screen.getByRole('button', { name: 'Open a chess table' }))
- expect(hook.chess.createTable).toHaveBeenCalled()
+ })
+
+ // One picker and one button however many games there are: the games
+ // grouped by family, and the chosen one described under it.
+ it('opens a table of whichever game is picked, and says what that game is', () => {
+ const hook = lobby({ room: room() })
+ render()
+ const picker = screen.getByRole('combobox', { name: 'Game' })
+ expect(within(picker).getAllByRole('group').map(group => group.getAttribute('label'))).toEqual(['Cards', 'Board'])
+ expect(within(picker).getAllByRole('option').map(option => option.textContent)).toEqual(['Castle', 'Golf', 'Rummy', 'Chess'])
+ expect(screen.getAllByRole('button', { name: /^Open a .* table$/ })).toHaveLength(1)
+
+ expect(screen.getByText(/Shed every card first/)).toBeTruthy()
+ expect(screen.getByText('2–4 players')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'Open a castle table' }))
+ expect(hook.castle.createTable).toHaveBeenCalledTimes(1)
+
+ for (const [game, blurb] of [
+ ['golf', /Lowest hand wins/],
+ ['rummy', /First to empty their hand wins/],
+ ['chess', /King and pawn against king/]
+ ] as const) {
+ fireEvent.change(picker, { target: { value: game } })
+ expect(screen.getByText(blurb)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: `Open a ${game} table` }))
+ expect(hook[game].createTable).toHaveBeenCalledTimes(1)
+ }
+ expect(screen.getByText('2 players')).toBeTruthy()
+ fireEvent.change(picker, { target: { value: 'rummy' } })
+ expect(screen.getByText('2–4 players · 7-card, 10-card or gin, chosen at the table')).toBeTruthy()
+ expect(hook.castle.createTable).toHaveBeenCalledTimes(1)
})
it('a member already at a table is offered no other', () => {
@@ -131,9 +143,6 @@ describe('LobbyPanel', () => {
seated.players[0] = { ...seated.players[0], table: { game: 'castle', gameId: 'G1' } }
render()
expect(screen.getByRole('button', { name: 'Open a castle table' })).toHaveProperty('disabled', true)
- expect(screen.getByRole('button', { name: 'Open a golf table' })).toHaveProperty('disabled', true)
- expect(screen.getByRole('button', { name: 'Open a rummy table' })).toHaveProperty('disabled', true)
- expect(screen.getByRole('button', { name: 'Open a chess table' })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Join castle G1' })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Join golf G4' })).toHaveProperty('disabled', true)
})
diff --git a/src/apps/lobby/lobbyCommands.ts b/src/apps/lobby/lobbyCommands.ts
index ff1812e..b212ed4 100644
--- a/src/apps/lobby/lobbyCommands.ts
+++ b/src/apps/lobby/lobbyCommands.ts
@@ -1,6 +1,7 @@
import type { UseLobby } from '@/hooks/useLobby'
import { lobbyRoomPath } from '@/hooks/useLobby'
import type { Command } from '@/utils/commandRegistry'
+import { CATALOG } from './catalog'
import { atTable, seatsOf, tableFor, tableOffer } from './offers'
// What the page around the lobby does for these entries: the panel and
@@ -55,10 +56,10 @@ export function lobbyCommands(lobby: UseLobby, ui: LobbyUi): Command[] {
}
}
if (!atTable(room, playerId)) {
- commands.push({ id: 'open-castle', label: 'Open a castle table', run: lobby.castle.createTable })
- commands.push({ id: 'open-golf', label: 'Open a golf table', run: lobby.golf.createTable })
- commands.push({ id: 'open-rummy', label: 'Open a rummy table', run: lobby.rummy.createTable })
- commands.push({ id: 'open-chess', label: 'Open a chess table', run: lobby.chess.createTable })
+ // The menu is searched, so it lists every game where the panel picks one.
+ for (const { game } of CATALOG) {
+ commands.push({ id: `open-${game}`, label: `Open a ${game} table`, run: tableFor(lobby, game).createTable })
+ }
for (const table of room.games) {
if (!tableOffer(table).open) continue
const game = table.game ?? 'golf'
diff --git a/src/apps/lobby/offers.ts b/src/apps/lobby/offers.ts
index b8760e6..6836b11 100644
--- a/src/apps/lobby/offers.ts
+++ b/src/apps/lobby/offers.ts
@@ -1,15 +1,12 @@
import type { UseLobby } from '@/hooks/useLobby'
import type { HubGameName, HubGameSummary, HubRoom } from '@/utils/hubStream'
+import { seatsOf } from './catalog'
+
+export { seatsOf }
// What the lobby offers and when, read the same way by the panel's
// buttons and the command menu's entries.
-// Chess seats two; every other game, four. A summary from before the
-// game was named is golf's.
-export function seatsOf(game: HubGameName | undefined): number {
- return game === 'chess' ? 2 : 4
-}
-
// How a table reads: open to join, or why not.
export function tableOffer(table: HubGameSummary): { label: string; open: boolean } {
if (table.status !== 'waiting') return { label: 'In play', open: false }