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
2 changes: 1 addition & 1 deletion src/apps/lobby/components/LobbyGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const LobbyGame = (props: UseLobbyProps) => {
<LobbyPanel lobby={lobby} />
</div>
)}
{chat.available && (
{room !== null && (
<div className={styles.chatHost}>
<RoomChat
messages={chat.messages}
Expand Down
14 changes: 13 additions & 1 deletion src/apps/lobby/components/__tests__/LobbyGame.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const state = {
connected: true,
lost: null,
room: null,
chat: { messages: [], available: false, replayUpTo: 0, rejection: null },
chat: { messages: [], replayUpTo: 0, rejection: null },
notice: '',
roomCode: '',
setRoomCode: vi.fn(),
Expand All @@ -32,6 +32,7 @@ vi.mock('@/hooks/useLobby', async importOriginal => ({
}))
vi.mock('@/apps/thoughts/components/ThoughtsGame', () => ({ default: () => <div>world</div> }))
vi.mock('@/apps/castle/components/CastleTable', () => ({ default: () => <div>table</div> }))
vi.mock('../RoomChat', () => ({ default: () => <div>chat</div> }))
vi.mock('@/apps/golf/components/GolfTable', () => ({
default: ({ shareUrl }: { shareUrl: string | null }) => <div>golf table {shareUrl}</div>
}))
Expand Down Expand Up @@ -70,6 +71,17 @@ describe('LobbyGame', () => {
expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy()
})

it('chat is up whenever the session is in a room, before anyone has spoken', () => {
const { rerender } = render(<LobbyGame />)
expect(screen.queryByText('chat')).toBeNull()
state.room = { roomId: 'R1', players: [], games: [] }
rerender(<LobbyGame />)
expect(screen.getByText('chat')).toBeTruthy()
state.room = null
rerender(<LobbyGame />)
expect(screen.queryByText('chat')).toBeNull()
})

it('a lost hub is said, not hidden', () => {
state.lost = 'Lost connection to the games hub'
render(<LobbyGame />)
Expand Down
2 changes: 1 addition & 1 deletion src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const lobby = (over: Partial<UseLobby> = {}): UseLobby =>
connected: true,
lost: null,
room: null,
chat: { messages: [], available: false, replayUpTo: 0, rejection: null },
chat: { messages: [], replayUpTo: 0, rejection: null },
notice: '',
roomCode: '',
setRoomCode: vi.fn(),
Expand Down
70 changes: 66 additions & 4 deletions src/hooks/__tests__/useLobby.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,11 @@ describe('useLobby', () => {
expect(result.current.castle.opening).toBe(false)
})

it('chat appears only once the wire delivers it, merged by id, and is the room\'s', async () => {
it("chat is merged by id, and is the room's", async () => {
const { result, ws } = await open()
act(() => ws.receive('roomState', roomState('R1')))
expect(result.current.chat.available).toBe(false)
const message = { messageId: 3, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 }
act(() => ws.receive('roomChatHistory', { messages: [message] }))
expect(result.current.chat.available).toBe(true)
expect(result.current.chat.replayUpTo).toBe(3)
act(() => ws.receive('roomChat', message))
expect(result.current.chat.messages).toHaveLength(1)
Expand All @@ -339,7 +337,7 @@ describe('useLobby', () => {
act(() => ws.receive('commandRejected', { reason: 'slow down' }))
expect(result.current.chat.rejection).toEqual({ seq: 1, reason: 'slow down' })
act(() => ws.receive('roomState', roomState('R2')))
expect(result.current.chat).toEqual({ messages: [], available: false, replayUpTo: 0, rejection: null })
expect(result.current.chat).toEqual({ messages: [], replayUpTo: 0, rejection: null })
act(() => ws.receive('roomChat', message))
act(() => ws.receive('roomLeft', { roomId: 'R2' }))
expect(result.current.chat.messages).toEqual([])
Expand Down Expand Up @@ -379,6 +377,70 @@ describe('useLobby', () => {
}
})

it('a reconnect that lands outside the room drops the room it was showing', async () => {
vi.useFakeTimers()
try {
const { result, pathname } = mount({ permalinkRoomId: 'R1' }, '/games/room/R1')
let ws!: FakeWebSocket
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
ws = FakeWebSocket.instances[0]
ws.open()
ws.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' })
})
act(() => ws.receive('roomState', roomState('R1')))
act(() => ws.receive('roomChat', { messageId: 1, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 }))
expect(result.current.room?.roomId).toBe('R1')
act(() => ws.close())
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
// The room was reaped while the seat was away: the hub admits the
// session into the plaza, and the link's room is refused.
const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
act(() => {
next.open()
next.receive('sessionReady', { playerId: 'alice', resumed: true })
})
expect(result.current.room).toBeNull()
expect(result.current.chat.messages).toEqual([])
expect(next.lastSent()).toEqual({ event: 'joinRoom', payload: { roomId: 'R1' } })
act(() => next.receive('commandRejected', { reason: 'room not found' }))
expect(result.current.room).toBeNull()
expect(result.current.notice).toBe('Room R1 is gone')
expect(pathname()).toBe('/games')
} finally {
vi.useRealTimers()
}
})

it('a reconnect back into the same room keeps the room on screen', async () => {
vi.useFakeTimers()
try {
const { result } = mount()
let ws!: FakeWebSocket
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
ws = FakeWebSocket.instances[0]
ws.open()
ws.receive('sessionReady', { playerId: 'alice', resumed: false })
})
act(() => ws.receive('roomState', roomState('R1')))
act(() => ws.close())
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
act(() => {
next.open()
next.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' })
})
expect(result.current.room?.roomId).toBe('R1')
} finally {
vi.useRealTimers()
}
})

it('a link refused while leaving the resumed room gives up rather than looping', async () => {
const { result, ws, pathname } = await open({ permalinkRoomId: 'R2' }, '/games/room/R2', 'R1')
expect(ws.lastSent()).toEqual({ event: 'leaveRoom', payload: {} })
Expand Down
23 changes: 15 additions & 8 deletions src/hooks/useLobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ export const lobbyTablePath = (roomId: string, gameId: string) =>

const NOTICE_MS = 3000

// The room's chat; it is open the whole time the session is in a room.
// A fresh room has no history to replay, so the wire says nothing
// until someone speaks.
export interface LobbyChat {
messages: ChatMessage[]
// True once the room's wire has delivered chat (the join replay or a
// live message); a UI ahead of its server renders no composer.
available: boolean
replayUpTo: number
rejection: { seq: number; reason: string } | null
}
Expand Down Expand Up @@ -87,7 +87,7 @@ export const useLobby = ({
const [room, setRoom] = useState<HubRoom | null>(null)
const [notice, setNotice] = useState('')
const [roomCode, setRoomCode] = useState('')
const [chat, setChat] = useState<LobbyChat>({ messages: [], available: false, replayUpTo: 0, rejection: null })
const [chat, setChat] = useState<LobbyChat>({ messages: [], replayUpTo: 0, rejection: null })

const streamRef = useRef<HubStream | null>(null)
const noticeTimeoutRef = useRef<number | null>(null)
Expand Down Expand Up @@ -118,7 +118,7 @@ export const useLobby = ({
}, [])

const resetChat = useCallback(() => {
setChat({ messages: [], available: false, replayUpTo: 0, rejection: null })
setChat({ messages: [], replayUpTo: 0, rejection: null })
}, [])

// One link for the life of the hook; the renderer attaches to it when
Expand Down Expand Up @@ -209,6 +209,14 @@ export const useLobby = ({
switchRef.current = null
tablePendingRef.current = null
const here = ready.roomId ?? null
// A reconnect that lands somewhere other than the room this
// session was showing — the room reaped while it was away, or a
// fresh seat — leaves nothing of that room behind: a panel still
// showing it offers tables the hub refuses as "not in a room".
if (here !== roomIdRef.current) {
setRoom(null)
resetChat()
}
roomIdRef.current = here
const wanted = permalinkRef.current
if (wanted.roomId && wanted.roomId !== here) {
Expand All @@ -224,7 +232,7 @@ export const useLobby = ({
if (here !== null && !wanted.roomId) navigate(lobbyRoomPath(here), { replace: true })
enterWorld(here)
},
[clearTables, enterWorld, navigate, onPlayerIdChange, world]
[clearTables, enterWorld, navigate, onPlayerIdChange, resetChat, world]
)

const handleRoom = useCallback(
Expand Down Expand Up @@ -326,11 +334,10 @@ export const useLobby = ({
onRoom: handleRoom,
onRoomLeft: handleRoomLeft,
onChat: message =>
setChat(prev => ({ ...prev, available: true, messages: mergeChatMessages(prev.messages, [message]) })),
setChat(prev => ({ ...prev, messages: mergeChatMessages(prev.messages, [message]) })),
onChatHistory: messages =>
setChat(prev => ({
...prev,
available: true,
messages: mergeChatMessages(prev.messages, messages),
replayUpTo: Math.max(prev.replayUpTo, ...messages.map(m => m.messageId))
})),
Expand Down
Loading