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 }