Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/apps/lobby/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
70 changes: 70 additions & 0 deletions src/apps/lobby/catalog.ts
Original file line number Diff line number Diff line change
@@ -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`
}
28 changes: 28 additions & 0 deletions src/apps/lobby/components/LobbyPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
57 changes: 31 additions & 26 deletions src/apps/lobby/components/LobbyPanel.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 (
<div className={styles.stack}>
<div className={styles.picker}>
<select aria-label="Game" value={game} onChange={event => setGame(catalogEntry(event.target.value as typeof game).game)} disabled={disabled}>
{FAMILIES.map(family => (
<optgroup key={family} label={family}>
{CATALOG.filter(option => option.family === family).map(option => (
<option key={option.game} value={option.game}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
<button type="button" className={styles.primary} onClick={tableFor(lobby, game).createTable} disabled={disabled}>
Open a {game} table
</button>
</div>
<p className={styles.muted}>{entry.blurb}</p>
<p className={styles.muted}>{seatsLine(entry)}</p>
</div>
)
}

// The command menu has no button of its own: this is where it is told.
Expand Down Expand Up @@ -169,26 +193,7 @@ const LobbyPanel = ({ lobby, roomCodeRef }: LobbyPanelProps) => {
})}
</ul>
)}
{/* One offer per game, and the room hosts them all: none is the
house game, so none is the louder button. */}
<div className={styles.stack}>
<button type="button" className={styles.primary} onClick={lobby.castle.createTable} disabled={!connected || busy}>
Open a castle table
</button>
<p className={styles.muted}>{GAME_BLURB.castle}</p>
<button type="button" className={styles.primary} onClick={lobby.golf.createTable} disabled={!connected || busy}>
Open a golf table
</button>
<p className={styles.muted}>{GAME_BLURB.golf}</p>
<button type="button" className={styles.primary} onClick={lobby.rummy.createTable} disabled={!connected || busy}>
Open a rummy table
</button>
<p className={styles.muted}>{GAME_BLURB.rummy}</p>
<button type="button" className={styles.primary} onClick={lobby.chess.createTable} disabled={!connected || busy}>
Open a chess table
</button>
<p className={styles.muted}>{GAME_BLURB.chess}</p>
</div>
<NewTable lobby={lobby} disabled={!connected || busy} />
</section>
{COMMAND_HINT}
</aside>
Expand Down
55 changes: 32 additions & 23 deletions src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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: [
Expand All @@ -122,18 +105,44 @@ 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(<LobbyPanel lobby={hook} />)
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', () => {
const seated = room()
seated.players[0] = { ...seated.players[0], table: { game: 'castle', gameId: 'G1' } }
render(<LobbyPanel lobby={lobby({ room: seated })} />)
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)
})
Expand Down
9 changes: 5 additions & 4 deletions src/apps/lobby/lobbyCommands.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 3 additions & 6 deletions src/apps/lobby/offers.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
Loading