diff --git a/apps/front/src/components/GameContext/useGameSetup.test.tsx b/apps/front/src/components/GameContext/useGameSetup.test.tsx
index 5ce51a30..762200ca 100644
--- a/apps/front/src/components/GameContext/useGameSetup.test.tsx
+++ b/apps/front/src/components/GameContext/useGameSetup.test.tsx
@@ -75,11 +75,9 @@ vi.mock('../../utils/api', () => ({
}
},
createWebSocketTicket: vi.fn(),
- deleteDisplayName: vi.fn(),
initGame: vi.fn(),
play: vi.fn(),
reportClientProtocolDiagnostic: vi.fn(),
- updateDisplayName: vi.fn(),
voteRematch: vi.fn()
}))
vi.mock('../../utils/playerIdentity', async (importOriginal) => ({
diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts
index bc533f32..73d0c5eb 100644
--- a/apps/front/src/components/GameContext/useGameSetup.ts
+++ b/apps/front/src/components/GameContext/useGameSetup.ts
@@ -9,7 +9,6 @@ import {
GameState,
getGameStateMessagePayload,
type IGameState,
- isEmptyOrBlank,
PROTOCOL_VERSION,
type GameSettings
} from '@knucklebones/common'
@@ -18,8 +17,6 @@ import { useRoomKey } from '../../hooks/useRoomKey'
import {
ApiRequestError,
createWebSocketTicket,
- deleteDisplayName,
- updateDisplayName,
initGame,
play,
reportClientProtocolDiagnostic,
@@ -354,23 +351,6 @@ export function useGameSetup() {
})
}
- async function _updateDisplayName(newDisplayName: string) {
- if (isEmptyOrBlank(newDisplayName)) {
- await deleteDisplayName({ roomKey, playerId: playerId! }).catch(
- (error) => {
- setErrorMessage(error.message)
- }
- )
- } else {
- await updateDisplayName(
- { roomKey, playerId: playerId! },
- { displayName: newDisplayName }
- ).catch((error) => {
- setErrorMessage(error.message)
- })
- }
- }
-
// Easy way to do a type guard
if (identityError !== null) {
return {
@@ -401,7 +381,6 @@ export function useGameSetup() {
voteContinueBo,
voteContinueIndefinitely,
voteRematch: _voteRematch,
- resign,
- updateDisplayName: _updateDisplayName
+ resign
}
}
diff --git a/apps/front/src/components/GameOutcome.test.tsx b/apps/front/src/components/GameOutcome.test.tsx
index 2eefa0f0..8cd5add7 100644
--- a/apps/front/src/components/GameOutcome.test.tsx
+++ b/apps/front/src/components/GameOutcome.test.tsx
@@ -93,6 +93,7 @@ describe('GameOutcome ranked rating', () => {
vi.mocked(getRankedProfile).mockReset()
vi.mocked(getRankedProfile).mockResolvedValue({
playerId: '11111111-1111-4111-8111-111111111111',
+ displayName: 'Player One',
ratingPool: 'classic',
rating: 1184,
gamesPlayed: 1,
diff --git a/apps/front/src/components/HomePage.tsx b/apps/front/src/components/HomePage.tsx
index d54a7354..53f1185e 100644
--- a/apps/front/src/components/HomePage.tsx
+++ b/apps/front/src/components/HomePage.tsx
@@ -1,7 +1,7 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
-import { UserCircleIcon } from '@heroicons/react/24/outline'
+import { TrophyIcon, UserCircleIcon } from '@heroicons/react/24/outline'
import { type PlayerType } from '@knucklebones/common'
import { useLocalizedPath } from '../hooks/useLocalizedPath'
import KnucklebonesLogo from '../svgs/logo.svg'
@@ -70,6 +70,15 @@ export function HomePage() {
>
+
diff --git a/apps/front/src/components/Leaderboard.test.tsx b/apps/front/src/components/Leaderboard.test.tsx
new file mode 100644
index 00000000..ec7175ae
--- /dev/null
+++ b/apps/front/src/components/Leaderboard.test.tsx
@@ -0,0 +1,70 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { getRankedLeaderboard } from '../utils/api'
+import { LeaderboardPage } from './Leaderboard'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: { language: 'en' }
+ })
+}))
+vi.mock('../utils/api', () => ({ getRankedLeaderboard: vi.fn() }))
+
+const topPlayers = Array.from({ length: 10 }, (_, index) => ({
+ rank: index + 1,
+ playerId: `${String(index + 1).padStart(8, '0')}-1111-4111-8111-111111111111`,
+ displayName: index === 0 ? 'Champion 🧙' : `Player ${index + 1}`,
+ rating: 1500 - index * 20
+}))
+
+describe('LeaderboardPage', () => {
+ beforeEach(() => {
+ vi.mocked(getRankedLeaderboard)
+ .mockReset()
+ .mockResolvedValue({
+ topPlayers,
+ currentPlayer: {
+ rank: 24,
+ playerId: '99999999-1111-4111-8111-111111111111',
+ displayName: 'Current Player',
+ rating: 1160
+ }
+ })
+ })
+
+ it('shows the podium, top ten, and current player position', async () => {
+ render(
)
+
+ expect(await screen.findByText('leaderboard.title')).toBeVisible()
+ expect(screen.getAllByText('Champion 🧙')).toHaveLength(2)
+ expect(screen.getByText('Player 10')).toBeVisible()
+ expect(screen.getByText('Current Player')).toBeVisible()
+ expect(screen.getByText('#24')).toBeVisible()
+ })
+
+ it('offers a retry after a load failure', async () => {
+ vi.mocked(getRankedLeaderboard)
+ .mockRejectedValueOnce(new Error('Unavailable'))
+ .mockResolvedValueOnce({
+ topPlayers: [],
+ currentPlayer: {
+ rank: null,
+ playerId: '99999999-1111-4111-8111-111111111111',
+ displayName: 'New Player',
+ rating: 1200
+ }
+ })
+ const user = userEvent.setup()
+ render(
)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'leaderboard.error'
+ )
+ await user.click(screen.getByRole('button', { name: 'leaderboard.retry' }))
+
+ expect(await screen.findByText('leaderboard.empty')).toBeVisible()
+ expect(screen.getByText('leaderboard.unranked')).toBeVisible()
+ })
+})
diff --git a/apps/front/src/components/Leaderboard.tsx b/apps/front/src/components/Leaderboard.tsx
new file mode 100644
index 00000000..63b0b31b
--- /dev/null
+++ b/apps/front/src/components/Leaderboard.tsx
@@ -0,0 +1,194 @@
+import * as React from 'react'
+import { useTranslation } from 'react-i18next'
+import { type RankedLeaderboardEntry } from '@knucklebones/common'
+import { useNoIndex } from '../hooks/useNoIndex'
+import { getRankedLeaderboard } from '../utils/api'
+import { Button } from './Button'
+
+interface PodiumPlaceProps {
+ player: RankedLeaderboardEntry
+ formatNumber(value: number): string
+}
+
+function PodiumPlace({ player, formatNumber }: PodiumPlaceProps) {
+ const height =
+ player.rank === 1 ? 'h-48 md:h-56' : player.rank === 2 ? 'h-40' : 'h-32'
+ const color =
+ player.rank === 1
+ ? 'bg-amber-300 dark:bg-amber-500'
+ : player.rank === 2
+ ? 'bg-slate-300 dark:bg-slate-500'
+ : 'bg-orange-300 dark:bg-orange-700'
+
+ return (
+
+ #{player.rank}
+
+ {player.displayName}
+
+
+ {formatNumber(player.rating)} Elo
+
+
+ )
+}
+
+export function LeaderboardPage() {
+ const { t, i18n } = useTranslation()
+ const [leaderboard, setLeaderboard] =
+ React.useState
>>()
+ const [hasError, setHasError] = React.useState(false)
+ const [loadAttempt, setLoadAttempt] = React.useState(0)
+ useNoIndex()
+
+ React.useEffect(() => {
+ let disposed = false
+
+ async function loadLeaderboard() {
+ setHasError(false)
+ try {
+ const nextLeaderboard = await getRankedLeaderboard()
+ if (!disposed) {
+ setLeaderboard(nextLeaderboard)
+ }
+ } catch {
+ if (!disposed) {
+ setHasError(true)
+ }
+ }
+ }
+
+ void loadLeaderboard()
+ return () => {
+ disposed = true
+ }
+ }, [loadAttempt])
+
+ if (leaderboard === undefined) {
+ return (
+
+ {hasError ? (
+
+
+ {t('leaderboard.error')}
+
+
+
+ ) : (
+
+ {t('leaderboard.loading')}
+
+ )}
+
+ )
+ }
+
+ const numberFormatter = new Intl.NumberFormat(i18n.language)
+ const formatNumber = (value: number) => numberFormatter.format(value)
+ const podiumPlayers = [
+ leaderboard.topPlayers[1],
+ leaderboard.topPlayers[0],
+ leaderboard.topPlayers[2]
+ ]
+
+ return (
+
+
+ {t('leaderboard.title')}
+
+
+ {leaderboard.topPlayers.length === 0 ? (
+ {t('leaderboard.empty')}
+ ) : (
+ <>
+
+ {podiumPlayers.map((player, index) =>
+ player === undefined ? (
+
+ ) : (
+
+ )
+ )}
+
+
+
+
+
+
+ | {t('leaderboard.rank')} |
+ {t('leaderboard.player')} |
+
+ {t('leaderboard.elo')}
+ |
+
+
+
+ {leaderboard.topPlayers.map((player) => {
+ const isCurrentPlayer =
+ player.playerId === leaderboard.currentPlayer.playerId
+ return (
+
+ | #{player.rank} |
+
+ {player.displayName}
+ |
+
+ {formatNumber(player.rating)}
+ |
+
+ )
+ })}
+
+
+
+ >
+ )}
+
+
+
+ {t('leaderboard.your-position')}
+
+
+
+ {leaderboard.currentPlayer.rank === null
+ ? t('leaderboard.unranked')
+ : `#${leaderboard.currentPlayer.rank}`}
+
+
+ {leaderboard.currentPlayer.displayName}
+
+
+ {formatNumber(leaderboard.currentPlayer.rating)}
+
+
+
+
+ )
+}
diff --git a/apps/front/src/components/PlayerBoard/Board.tsx b/apps/front/src/components/PlayerBoard/Board.tsx
index 567c48f2..591c86dd 100644
--- a/apps/front/src/components/PlayerBoard/Board.tsx
+++ b/apps/front/src/components/PlayerBoard/Board.tsx
@@ -24,8 +24,7 @@ interface BoardProps {
interface PlayerBoardProps extends IPlayer, BoardProps {
isNextPlayer: boolean
outcome: Outcome
- isDisplayNameEditable?: boolean
- updateDisplayName?(displayName: string): void
+ isCurrentPlayer?: boolean
}
const MAX_COLUMNS = 3
@@ -100,8 +99,7 @@ export function PlayerBoard({
canPlay,
isNextPlayer,
onColumnClick,
- updateDisplayName,
- isDisplayNameEditable = false,
+ isCurrentPlayer = false,
outcome
}: PlayerBoardProps) {
const { t } = useTranslation()
@@ -117,9 +115,7 @@ export function PlayerBoard({
id={id}
difficulty={difficulty}
displayName={displayName}
- isPlayerOne={isPlayerOne}
- updateDisplayName={updateDisplayName}
- isEditable={isDisplayNameEditable}
+ isCurrentPlayer={isCurrentPlayer}
/>
({
- useTranslation: () => ({ t: (key: string) => key })
-}))
-
-describe('Name', () => {
- it('generates a friendly replacement instead of exposing the UUID', async () => {
- const updateDisplayName = vi.fn()
- const playerId = '22222222-2222-4222-8222-222222222222'
-
- render(
-
- )
-
- await userEvent.click(screen.getByRole('button'))
- const input = screen.getByRole('textbox')
- await userEvent.clear(input)
- await userEvent.keyboard('{Enter}')
-
- expect(updateDisplayName).toHaveBeenCalledOnce()
- const generatedName = updateDisplayName.mock.calls[0][0] as string
- expect(generatedName).not.toBe(playerId)
- expect(generatedName).toMatch(/^[A-Z][A-Za-z]+$/)
- expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
- generatedName
- )
- expect(screen.getByText(new RegExp(generatedName))).toBeInTheDocument()
- })
-})
diff --git a/apps/front/src/components/PlayerBoard/Name.tsx b/apps/front/src/components/PlayerBoard/Name.tsx
index 5bfa3136..327bf0b9 100644
--- a/apps/front/src/components/PlayerBoard/Name.tsx
+++ b/apps/front/src/components/PlayerBoard/Name.tsx
@@ -1,123 +1,16 @@
-import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import {
- PencilSquareIcon,
- CheckIcon,
- XMarkIcon
-} from '@heroicons/react/24/outline'
-import { isEmptyOrBlank } from '@knucklebones/common'
-import { storeDisplayName } from '../../utils/identityStorage'
-import {
- MAX_NAME_LENGTH,
- type PlayerNameProps,
- getName,
- randomName
-} from '../../utils/name'
-import { IconButton } from '../IconButton'
+import { type PlayerNameProps, getName } from '../../utils/name'
interface NameProps extends PlayerNameProps {
- isPlayerOne: boolean
- updateDisplayName?(displayName: string): void
- isEditable: boolean
+ isCurrentPlayer: boolean
}
-export function Name({
- isPlayerOne,
- updateDisplayName,
- isEditable,
- ...player
-}: NameProps) {
- const computedName = getName(player)
- const { id, displayName } = player
- const [isBeingEdited, setIsBeingEdited] = React.useState(false)
- const [name, setName] = React.useState(computedName)
+export function Name({ isCurrentPlayer, ...player }: NameProps) {
const { t } = useTranslation()
-
- React.useEffect(() => {
- setName(computedName)
- }, [computedName])
-
- function handleOnKeyDown(e: React.KeyboardEvent
) {
- if (e.key === 'Enter') {
- onDisplayNameSubmit()
- } else if (e.key === 'Escape') {
- onDisplayNameCancel()
- }
- }
-
- function handleOnChange(e: React.ChangeEvent) {
- // Avoid players using ridiculously long names
- setName(e.target.value.substring(0, MAX_NAME_LENGTH))
- }
-
- function handleOnFocus(e: React.FocusEvent) {
- // Select all input when focused
- e.target.select()
- }
-
- function onDisplayNameSubmit() {
- setIsBeingEdited(false)
-
- if (isEmptyOrBlank(name)) {
- const generatedName = randomName()
- storeDisplayName(generatedName)
- setName(generatedName)
- updateDisplayName!(generatedName)
- } else {
- if (computedName === id) {
- if (name !== id) {
- // If the name displayed was the id, and the new name
- // is different from the id the player is trying to set a
- // displayName, so set it in local storage and send it to the backend
- storeDisplayName(name)
- updateDisplayName!(name)
- }
- } else {
- if (name !== displayName) {
- // Same case as above, but the player is trying to update their displayName
- storeDisplayName(name)
- updateDisplayName!(name)
- }
- }
- }
- }
-
- function onDisplayNameCancel() {
- setIsBeingEdited(false)
- setName(computedName)
- }
-
- function onEditClick() {
- setIsBeingEdited(true)
- }
-
- if (isBeingEdited) {
- return (
-
-
- } onClick={onDisplayNameSubmit} />
- } onClick={onDisplayNameCancel} />
-
- )
- } else {
- return (
-
-
- {name}
- {isPlayerOne && isEditable && ` (${t('game.you')})`}
-
- {isEditable && (
-
} onClick={onEditClick} />
- )}
-
- )
- }
+ return (
+
+ {getName(player)}
+ {isCurrentPlayer && ` (${t('game.you')})`}
+
+ )
}
diff --git a/apps/front/src/components/PlayerBoard/PlayerOneBoard.tsx b/apps/front/src/components/PlayerBoard/PlayerOneBoard.tsx
index d240d13b..7eddb7dc 100644
--- a/apps/front/src/components/PlayerBoard/PlayerOneBoard.tsx
+++ b/apps/front/src/components/PlayerBoard/PlayerOneBoard.tsx
@@ -2,15 +2,8 @@ import { useGame } from '../GameContext'
import { PlayerBoard } from './Board'
export function PlayerOneBoard() {
- const {
- outcome,
- nextPlayer,
- playerOne,
- isLoading,
- playerSide,
- sendPlay,
- updateDisplayName
- } = useGame()
+ const { outcome, nextPlayer, playerOne, isLoading, playerSide, sendPlay } =
+ useGame()
const isSpectator = playerSide === 'spectator'
const canPlay = !isLoading && outcome === 'ongoing' && !isSpectator
@@ -29,10 +22,7 @@ export function PlayerOneBoard() {
: undefined
}
canPlay={canPlayerOnePlay}
- updateDisplayName={(displayName) => {
- void updateDisplayName(displayName)
- }}
- isDisplayNameEditable={!isSpectator}
+ isCurrentPlayer={!isSpectator}
outcome={outcome}
/>
)
diff --git a/apps/front/src/components/Profile.test.tsx b/apps/front/src/components/Profile.test.tsx
index 66568fb5..71d594ed 100644
--- a/apps/front/src/components/Profile.test.tsx
+++ b/apps/front/src/components/Profile.test.tsx
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { getRankedProfile } from '../utils/api'
+import { getRankedProfile, updateRankedProfile } from '../utils/api'
import { ensurePlayerIdentity } from '../utils/playerIdentity'
import { ProfilePage } from './Profile'
@@ -11,7 +11,10 @@ vi.mock('react-i18next', () => ({
i18n: { language: 'en' }
})
}))
-vi.mock('../utils/api', () => ({ getRankedProfile: vi.fn() }))
+vi.mock('../utils/api', () => ({
+ getRankedProfile: vi.fn(),
+ updateRankedProfile: vi.fn()
+}))
vi.mock('../utils/playerIdentity', () => ({
ensurePlayerIdentity: vi.fn()
}))
@@ -24,6 +27,7 @@ describe('ProfilePage', () => {
})
vi.mocked(getRankedProfile).mockReset().mockResolvedValue({
playerId: '22222222-2222-4222-8222-222222222222',
+ displayName: 'Current Name',
ratingPool: 'classic',
rating: 1248,
gamesPlayed: 14,
@@ -31,7 +35,7 @@ describe('ProfilePage', () => {
losses: 4,
draws: 2
})
- localStorage.setItem('knucklebones.identity.v1.displayName', 'Current Name')
+ vi.mocked(updateRankedProfile).mockReset().mockResolvedValue()
})
it('shows ranked statistics and saves a new display name', async () => {
@@ -50,9 +54,7 @@ describe('ProfilePage', () => {
await user.type(nameInput, 'New Name')
await user.click(screen.getByRole('button', { name: 'profile.name.save' }))
- expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
- 'New Name'
- )
+ expect(updateRankedProfile).toHaveBeenCalledWith('New Name')
expect(
screen.getByRole('button', { name: 'profile.name.save' })
).toBeDisabled()
@@ -63,6 +65,7 @@ describe('ProfilePage', () => {
.mockRejectedValueOnce(new Error('Unavailable'))
.mockResolvedValueOnce({
playerId: '22222222-2222-4222-8222-222222222222',
+ displayName: 'Current Name',
ratingPool: 'classic',
rating: 1200,
gamesPlayed: 0,
diff --git a/apps/front/src/components/Profile.tsx b/apps/front/src/components/Profile.tsx
index 236e3e15..674704c5 100644
--- a/apps/front/src/components/Profile.tsx
+++ b/apps/front/src/components/Profile.tsx
@@ -2,11 +2,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { type RankedProfile } from '@knucklebones/common'
import { useNoIndex } from '../hooks/useNoIndex'
-import { getRankedProfile } from '../utils/api'
-import {
- getStoredDisplayName,
- storeDisplayName
-} from '../utils/identityStorage'
+import { getRankedProfile, updateRankedProfile } from '../utils/api'
import { MAX_NAME_LENGTH } from '../utils/name'
import { ensurePlayerIdentity } from '../utils/playerIdentity'
import { Button } from './Button'
@@ -33,6 +29,8 @@ export function ProfilePage() {
const [displayName, setDisplayName] = React.useState('')
const [savedDisplayName, setSavedDisplayName] = React.useState('')
const [hasError, setHasError] = React.useState(false)
+ const [saveError, setSaveError] = React.useState(false)
+ const [isSaving, setIsSaving] = React.useState(false)
const [loadAttempt, setLoadAttempt] = React.useState(0)
useNoIndex()
@@ -45,10 +43,9 @@ export function ProfilePage() {
await ensurePlayerIdentity()
const nextProfile = await getRankedProfile()
if (!disposed) {
- const storedDisplayName = getStoredDisplayName() ?? ''
setProfile(nextProfile)
- setDisplayName(storedDisplayName)
- setSavedDisplayName(storedDisplayName)
+ setDisplayName(nextProfile.displayName)
+ setSavedDisplayName(nextProfile.displayName)
}
} catch {
if (!disposed) {
@@ -63,16 +60,24 @@ export function ProfilePage() {
}
}, [loadAttempt])
- function saveName(event: React.FormEvent) {
+ async function saveName(event: React.FormEvent) {
event.preventDefault()
const nextDisplayName = displayName.trim()
if (nextDisplayName.length === 0) {
return
}
- storeDisplayName(nextDisplayName)
- setDisplayName(nextDisplayName)
- setSavedDisplayName(nextDisplayName)
+ setIsSaving(true)
+ setSaveError(false)
+ try {
+ await updateRankedProfile(nextDisplayName)
+ setDisplayName(nextDisplayName)
+ setSavedDisplayName(nextDisplayName)
+ } catch {
+ setSaveError(true)
+ } finally {
+ setIsSaving(false)
+ }
}
if (profile === undefined) {
@@ -114,7 +119,7 @@ export function ProfilePage() {
+ {saveError && (
+
+ {t('profile.name.error')}
+
+ )}
diff --git a/apps/front/src/components/RankedMatchmaking.test.tsx b/apps/front/src/components/RankedMatchmaking.test.tsx
index 6518902f..190fd025 100644
--- a/apps/front/src/components/RankedMatchmaking.test.tsx
+++ b/apps/front/src/components/RankedMatchmaking.test.tsx
@@ -81,6 +81,7 @@ describe('RankedMatchmaking', () => {
sessionStorage.clear()
vi.mocked(getRankedProfile).mockResolvedValue({
playerId: playerOneId,
+ displayName: 'Player One',
ratingPool: 'classic',
rating: 1200,
gamesPlayed: 0,
diff --git a/apps/front/src/components/RankedStats.test.tsx b/apps/front/src/components/RankedStats.test.tsx
index cb596c70..fbef357b 100644
--- a/apps/front/src/components/RankedStats.test.tsx
+++ b/apps/front/src/components/RankedStats.test.tsx
@@ -16,6 +16,8 @@ describe('RankedStatsPage', () => {
wins: 42,
draws: 12,
losses: 42,
+ forfeits: 9,
+ noContests: 3,
averageEloGain: 14.5
},
current: { activePlayers: 8, queuedPlayers: 3 },
@@ -38,9 +40,17 @@ describe('RankedStatsPage', () => {
expect(await screen.findByText('ranked.stats.title')).toBeVisible()
expect(screen.getByText('125')).toBeVisible()
expect(screen.getByText('48')).toBeVisible()
+ expect(
+ screen.getByText('ranked.stats.indicators.forfeits').parentElement
+ ).toHaveTextContent('9')
+ expect(
+ screen.getByText('ranked.stats.indicators.no-contests').parentElement
+ ).toHaveTextContent('3')
expect(screen.getByText('14.5')).toBeVisible()
expect(screen.getByText('8')).toBeVisible()
- expect(screen.getByText('3')).toBeVisible()
+ expect(
+ screen.getByText('ranked.stats.indicators.queued-players').parentElement
+ ).toHaveTextContent('3')
expect(document.head.querySelector('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex'
diff --git a/apps/front/src/components/RankedStats.tsx b/apps/front/src/components/RankedStats.tsx
index a9b238bb..fd08c5a7 100644
--- a/apps/front/src/components/RankedStats.tsx
+++ b/apps/front/src/components/RankedStats.tsx
@@ -170,6 +170,8 @@ export function RankedStatsPage() {
['wins', stats.totals.wins],
['draws', stats.totals.draws],
['losses', stats.totals.losses],
+ ['forfeits', stats.totals.forfeits],
+ ['no-contests', stats.totals.noContests],
['average-elo-gain', stats.totals.averageEloGain],
['active-players', stats.current.activePlayers],
['queued-players', stats.current.queuedPlayers]
diff --git a/apps/front/src/components/Router.tsx b/apps/front/src/components/Router.tsx
index cb798bef..b4576a5f 100644
--- a/apps/front/src/components/Router.tsx
+++ b/apps/front/src/components/Router.tsx
@@ -5,6 +5,7 @@ import { Game } from './Game'
import { GameProvider } from './GameContext'
import { HomePage } from './HomePage'
import { HowToPlayPage } from './HowToPlay'
+import { LeaderboardPage } from './Leaderboard'
import { ProfilePage } from './Profile'
import { RankedMatchmaking } from './RankedMatchmaking'
@@ -46,6 +47,7 @@ export function Router() {
} />
} />
} />
+ } />
} />
} />
}>
@@ -53,6 +55,7 @@ export function Router() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json
index 49ff37fb..1dda876b 100644
--- a/apps/front/src/translations/resources/en.json
+++ b/apps/front/src/translations/resources/en.json
@@ -16,7 +16,8 @@
"retry": "Try again",
"name": {
"label": "Display name",
- "save": "Save"
+ "save": "Save",
+ "error": "Your display name could not be saved."
},
"statistics": {
"elo": "Elo",
@@ -25,6 +26,20 @@
"draws": "Draws"
}
},
+ "leaderboard": {
+ "label": "Leaderboard",
+ "title": "Leaderboard",
+ "loading": "Loading the leaderboard…",
+ "error": "The leaderboard could not be loaded.",
+ "retry": "Try again",
+ "empty": "No players are ranked yet.",
+ "podium": "Top three ranked players",
+ "rank": "Rank",
+ "player": "Player",
+ "elo": "Elo",
+ "your-position": "Your position",
+ "unranked": "Unranked"
+ },
"ranked": {
"identity-warning": "Ranked progress belongs to this browser identity. Save your recovery phrase before clearing browser data.",
"rating": "Rating: {{rating}}",
@@ -95,6 +110,8 @@
"wins": "Player wins",
"draws": "Player draws",
"losses": "Player losses",
+ "forfeits": "Matches ended by forfeit",
+ "no-contests": "No-contest matches",
"average-elo-gain": "Average Elo gain",
"active-players": "Playing now",
"queued-players": "In queue now"
diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json
index 020fdd7a..bd422ec0 100644
--- a/apps/front/src/translations/resources/fr.json
+++ b/apps/front/src/translations/resources/fr.json
@@ -16,7 +16,8 @@
"retry": "Réessayer",
"name": {
"label": "Nom d'affichage",
- "save": "Enregistrer"
+ "save": "Enregistrer",
+ "error": "Impossible d'enregistrer votre nom d'affichage."
},
"statistics": {
"elo": "Elo",
@@ -25,6 +26,20 @@
"draws": "Égalités"
}
},
+ "leaderboard": {
+ "label": "Classement",
+ "title": "Classement",
+ "loading": "Chargement du classement…",
+ "error": "Impossible de charger le classement.",
+ "retry": "Réessayer",
+ "empty": "Aucun joueur n'est encore classé.",
+ "podium": "Les trois meilleurs joueurs classés",
+ "rank": "Rang",
+ "player": "Joueur",
+ "elo": "Elo",
+ "your-position": "Votre position",
+ "unranked": "Non classé"
+ },
"ranked": {
"identity-warning": "Votre progression classée appartient à l'identité de ce navigateur. Sauvegardez votre phrase de récupération avant d'effacer ses données.",
"rating": "Classement : {{rating}}",
@@ -95,6 +110,8 @@
"wins": "Victoires des joueurs",
"draws": "Égalités des joueurs",
"losses": "Défaites des joueurs",
+ "forfeits": "Parties terminées par forfait",
+ "no-contests": "Parties sans résultat",
"average-elo-gain": "Gain Elo moyen",
"active-players": "En train de jouer",
"queued-players": "Dans la file"
diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json
index b7405102..934f9443 100644
--- a/apps/front/src/translations/resources/zh-tw.json
+++ b/apps/front/src/translations/resources/zh-tw.json
@@ -16,7 +16,8 @@
"retry": "再試一次",
"name": {
"label": "顯示名稱",
- "save": "儲存"
+ "save": "儲存",
+ "error": "無法儲存您的顯示名稱。"
},
"statistics": {
"elo": "Elo",
@@ -25,6 +26,20 @@
"draws": "平手"
}
},
+ "leaderboard": {
+ "label": "排行榜",
+ "title": "排行榜",
+ "loading": "正在載入排行榜…",
+ "error": "無法載入排行榜。",
+ "retry": "再試一次",
+ "empty": "目前沒有已排名的玩家。",
+ "podium": "排名前三的玩家",
+ "rank": "排名",
+ "player": "玩家",
+ "elo": "Elo",
+ "your-position": "您的排名",
+ "unranked": "未排名"
+ },
"ranked": {
"identity-warning": "排名進度綁定於此瀏覽器的玩家身分。清除瀏覽器資料前,請先保存復原短語。",
"rating": "評分:{{rating}}",
@@ -95,6 +110,8 @@
"wins": "玩家勝場",
"draws": "玩家平手",
"losses": "玩家敗場",
+ "forfeits": "因棄權結束的對局",
+ "no-contests": "無結果對局",
"average-elo-gain": "平均 Elo 增益",
"active-players": "目前遊玩中",
"queued-players": "目前排隊中"
diff --git a/apps/front/src/utils/api.test.ts b/apps/front/src/utils/api.test.ts
index 8f4a56a6..8d6ba105 100644
--- a/apps/front/src/utils/api.test.ts
+++ b/apps/front/src/utils/api.test.ts
@@ -11,7 +11,6 @@ import {
play,
resignGame,
requestRankedRematch,
- updateDisplayName,
voteRematch
} from './api'
@@ -71,25 +70,20 @@ describe('mutation requests', () => {
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }))
- .mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
Response.json({
ticket: 'a'.repeat(64),
expiresAt: Date.now() + 30_000
})
)
- localStorage.setItem('displayName', 'Dice Friend')
-
await initGame(room, { playerType: 'human', boType: 1 })
await voteRematch(room, { boType: 3 })
- await updateDisplayName(room, { displayName: 'A/B ? Player' })
await resignGame(room)
await createWebSocketTicket(room)
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
expect.stringContaining(`/v1/rooms/${room.roomKey}/init`),
expect.stringContaining(`/v1/rooms/${room.roomKey}/rematch`),
- expect.stringContaining(`/v1/rooms/${room.roomKey}/display-name`),
expect.stringContaining(`/v1/rooms/${room.roomKey}/resign`),
expect.stringContaining(`/v1/rooms/${room.roomKey}/websocket-ticket`)
])
@@ -99,13 +93,10 @@ describe('mutation requests', () => {
)
).toBe(true)
expect(fetchMock.mock.calls[0][1]?.body).toBe(
- '{"playerType":"human","boType":1,"displayName":"Dice Friend"}'
+ '{"playerType":"human","boType":1}'
)
expect(fetchMock.mock.calls[1][1]?.body).toBe('{"boType":3}')
- expect(fetchMock.mock.calls[2][1]?.body).toBe(
- '{"displayName":"A/B ? Player"}'
- )
- expect(fetchMock.mock.calls[3][1]?.body).toBeUndefined()
+ expect(fetchMock.mock.calls[2][1]?.body).toBeUndefined()
})
it('reports a validated API error code and message', async () => {
@@ -166,6 +157,7 @@ describe('mutation requests', () => {
.mockResolvedValueOnce(
Response.json({
playerId: room.playerId,
+ displayName: 'Ranked Player',
ratingPool: 'classic',
rating: 1200,
gamesPlayed: 0,
diff --git a/apps/front/src/utils/api.ts b/apps/front/src/utils/api.ts
index 3b7541f0..050d6c62 100644
--- a/apps/front/src/utils/api.ts
+++ b/apps/front/src/utils/api.ts
@@ -14,6 +14,8 @@ import {
playerCredentialsSchema,
type PlayerIdentityBootstrap,
playerIdentityBootstrapSchema,
+ type RankedLeaderboard,
+ rankedLeaderboardSchema,
type RankedProfile,
type RankedStats,
type RankedRematchStatus,
@@ -23,10 +25,7 @@ import {
type WebSocketTicket,
webSocketTicketSchema
} from '@knucklebones/common'
-import {
- getStoredDeviceCredential,
- getStoredDisplayName
-} from './identityStorage'
+import { getStoredDeviceCredential } from './identityStorage'
import { ensurePlayerIdentity } from './playerIdentity'
type Method = 'GET' | 'POST' | 'DELETE'
@@ -48,8 +47,15 @@ interface IdentificationParams {
playerId: string
}
-export async function createPlayer(): Promise {
- const response = await sendApiRequest('/players', 'POST', undefined, null)
+export async function createPlayer(
+ displayName: string
+): Promise {
+ const response = await sendApiRequest(
+ '/players',
+ 'POST',
+ { displayName },
+ null
+ )
const result = playerIdentityBootstrapSchema.safeParse(await response.json())
if (!result.success) {
@@ -158,6 +164,21 @@ export async function getRankedProfile(): Promise {
return result.data
}
+export async function updateRankedProfile(displayName: string): Promise {
+ await sendApiRequest('/v1/ranked/profile', 'POST', { displayName })
+}
+
+export async function getRankedLeaderboard(): Promise {
+ const response = await sendApiRequest('/v1/ranked/leaderboard', 'GET')
+ const result = rankedLeaderboardSchema.safeParse(await response.json())
+
+ if (!result.success) {
+ throw new Error('The server returned an invalid ranked leaderboard.')
+ }
+
+ return result.data
+}
+
export async function getRankedStats(): Promise {
const response = await sendApiRequest(
'/v1/ranked/stats',
@@ -252,15 +273,10 @@ export async function initGame(
{ roomKey }: IdentificationParams,
{ boType, difficulty, playerType }: InitGameRequestParams
) {
- const displayName = getStoredDisplayName()
const body =
playerType === 'ai'
? { playerType, difficulty, boType }
- : {
- playerType,
- boType,
- ...(displayName !== null && { displayName })
- }
+ : { playerType, boType }
await sendMutationRequest(`/v1/rooms/${roomKey}/init`, 'POST', body)
}
@@ -294,22 +310,6 @@ export async function resignGame({
await sendMutationRequest(`/v1/rooms/${roomKey}/resign`, 'POST')
}
-interface UpdateDisplayNameRequestParams {
- displayName: string
-}
-export async function updateDisplayName(
- { roomKey }: IdentificationParams,
- { displayName }: UpdateDisplayNameRequestParams
-) {
- await sendMutationRequest(`/v1/rooms/${roomKey}/display-name`, 'POST', {
- displayName
- })
-}
-
-export async function deleteDisplayName({ roomKey }: IdentificationParams) {
- await sendMutationRequest(`/v1/rooms/${roomKey}/display-name`, 'DELETE')
-}
-
async function sendMutationRequest(
path: string,
method: 'POST' | 'DELETE',
diff --git a/apps/front/src/utils/identityStorage.ts b/apps/front/src/utils/identityStorage.ts
index 8ea7060b..5b221a82 100644
--- a/apps/front/src/utils/identityStorage.ts
+++ b/apps/front/src/utils/identityStorage.ts
@@ -1,18 +1,15 @@
const PLAYER_ID_KEY = 'knucklebones.identity.v1.playerId'
const PLAYER_CREDENTIAL_KEY = 'knucklebones.identity.v1.deviceCredential'
-const DISPLAY_NAME_KEY = 'knucklebones.identity.v1.displayName'
const PENDING_RECOVERY_PHRASE_KEY =
'knucklebones.identity.v1.pendingRecoveryPhrase'
const RECOVERY_CONFIRMED_KEY = 'knucklebones.identity.v1.recoveryConfirmed'
const LEGACY_PLAYER_ID_KEY = 'playerId'
const LEGACY_PLAYER_CREDENTIAL_KEY = 'playerCredential'
-const LEGACY_DISPLAY_NAME_KEY = 'displayName'
export function migrateLegacyIdentityStorage(): void {
migrateKey(LEGACY_PLAYER_ID_KEY, PLAYER_ID_KEY)
migrateKey(LEGACY_PLAYER_CREDENTIAL_KEY, PLAYER_CREDENTIAL_KEY)
- migrateKey(LEGACY_DISPLAY_NAME_KEY, DISPLAY_NAME_KEY)
}
export function getStoredPlayerId(): string | null {
@@ -41,16 +38,6 @@ export function clearStoredIdentity(): void {
localStorage.removeItem(LEGACY_PLAYER_CREDENTIAL_KEY)
}
-export function getStoredDisplayName(): string | null {
- migrateLegacyIdentityStorage()
- return localStorage.getItem(DISPLAY_NAME_KEY)
-}
-
-export function storeDisplayName(displayName: string): void {
- localStorage.setItem(DISPLAY_NAME_KEY, displayName)
- localStorage.removeItem(LEGACY_DISPLAY_NAME_KEY)
-}
-
export function getPendingRecoveryPhrase(): string | undefined {
return localStorage.getItem(PENDING_RECOVERY_PHRASE_KEY) ?? undefined
}
diff --git a/apps/front/src/utils/playerIdentity.test.ts b/apps/front/src/utils/playerIdentity.test.ts
index 554511a0..b40bfb01 100644
--- a/apps/front/src/utils/playerIdentity.test.ts
+++ b/apps/front/src/utils/playerIdentity.test.ts
@@ -36,7 +36,7 @@ describe('ensurePlayerIdentity', () => {
vi.mocked(verifyPlayer).mockResolvedValue()
})
- it('creates credentials and a friendly name for a new browser', async () => {
+ it('creates credentials with a friendly public name for a new browser', async () => {
vi.mocked(createPlayer).mockResolvedValue(bootstrap)
await expect(ensurePlayerIdentity()).resolves.toEqual(bootstrap)
@@ -44,37 +44,20 @@ describe('ensurePlayerIdentity', () => {
expect(
localStorage.getItem('knucklebones.identity.v1.pendingRecoveryPhrase')
).toBe(bootstrap.recoveryPhrase)
- expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
- 'BraveBlueFox'
- )
+ expect(createPlayer).toHaveBeenCalledWith('BraveBlueFox')
})
- it('adds a friendly name to an existing UUID identity', async () => {
+ it('uses existing credentials without creating another public profile', async () => {
localStorage.setItem('playerId', credentials.playerId)
localStorage.setItem('playerCredential', credentials.credential)
await ensurePlayerIdentity()
expect(createPlayer).not.toHaveBeenCalled()
- expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
- 'BraveBlueFox'
- )
expect(localStorage.getItem('playerId')).toBeNull()
expect(localStorage.getItem('playerCredential')).toBeNull()
})
- it('preserves a user-selected display name', async () => {
- localStorage.setItem('playerId', credentials.playerId)
- localStorage.setItem('playerCredential', credentials.credential)
- localStorage.setItem('displayName', 'Custom Name')
-
- await ensurePlayerIdentity()
-
- expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
- 'Custom Name'
- )
- })
-
it('replaces a stale local credential after the development database resets', async () => {
localStorage.setItem(
'knucklebones.identity.v1.playerId',
diff --git a/apps/front/src/utils/playerIdentity.ts b/apps/front/src/utils/playerIdentity.ts
index 25fc22ad..c3773b2b 100644
--- a/apps/front/src/utils/playerIdentity.ts
+++ b/apps/front/src/utils/playerIdentity.ts
@@ -6,9 +6,7 @@ import { ApiRequestError, createPlayer, verifyPlayer } from './api'
import {
clearStoredIdentity,
getStoredDeviceCredential,
- getStoredDisplayName,
getStoredPlayerId,
- storeDisplayName,
storeIdentity,
storePendingRecoveryPhrase
} from './identityStorage'
@@ -60,7 +58,6 @@ async function initializePlayerIdentity(): Promise {
return await createAndStorePlayerIdentity()
}
}
- ensurePlayerDisplayName()
return storedCredentials
}
@@ -68,15 +65,8 @@ async function initializePlayerIdentity(): Promise {
}
async function createAndStorePlayerIdentity(): Promise {
- const credentials = await createPlayer()
+ const credentials = await createPlayer(randomName())
storePlayerCredentials(credentials)
storePendingRecoveryPhrase(credentials.recoveryPhrase)
- ensurePlayerDisplayName()
return credentials
}
-
-function ensurePlayerDisplayName(): void {
- if (getStoredDisplayName() === null) {
- storeDisplayName(randomName())
- }
-}
diff --git a/apps/worker/migrations/0009_add_player_display_names.sql b/apps/worker/migrations/0009_add_player_display_names.sql
new file mode 100644
index 00000000..b2924a29
--- /dev/null
+++ b/apps/worker/migrations/0009_add_player_display_names.sql
@@ -0,0 +1,5 @@
+ALTER TABLE players
+ ADD COLUMN display_name TEXT NOT NULL DEFAULT 'Player';
+
+UPDATE players
+SET display_name = 'Player ' || substr(player_id, 1, 8);
diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts
index 5ef01dcf..334d76d9 100644
--- a/apps/worker/src/durable-objects/GameStateDurableObject.ts
+++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts
@@ -36,10 +36,7 @@ import {
rankedMatchAssignmentSchema,
roomKeySchema,
toGameReconnectDeadlineMessage,
- toGameStateMessage,
- type UpdateDisplayNameResult,
- updateDisplayNameCommandSchema,
- updateDisplayNameResultSchema
+ toGameStateMessage
} from '@knucklebones/common'
import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
import { type IttyDurableObjectNamespace } from '../types/itty'
@@ -920,46 +917,6 @@ export class GameStateDurableObject extends createDurable({
return { status: 'updated', gameState: this.commitGameState(gameState) }
}
- updateDisplayName(
- mutationId: string,
- playerId: string,
- displayName?: string
- ): IdempotentMutationResult {
- const command = updateDisplayNameCommandSchema.parse({
- mutationId,
- playerId,
- displayName
- })
-
- return this.runIdempotently(
- command.mutationId,
- 'update-display-name',
- { playerId: command.playerId, displayName: command.displayName },
- updateDisplayNameResultSchema,
- () => this.applyDisplayNameUpdate(command.playerId, command.displayName)
- )
- }
-
- private applyDisplayNameUpdate(
- playerId: string,
- displayName?: string
- ): UpdateDisplayNameResult {
- const gameState = this.getInitializedGameState()
-
- if (gameState.playerOne.id === playerId) {
- gameState.playerOne.displayName = displayName
- } else if (gameState.playerTwo.id === playerId) {
- gameState.playerTwo.displayName = displayName
- } else {
- return { status: 'unknown-player' }
- }
-
- return {
- status: 'updated',
- gameState: this.commitGameState(gameState)
- }
- }
-
private readPlayerGameState(playerId: string): GameState | undefined {
if (this.gameState === undefined) {
return
diff --git a/apps/worker/src/endpoints/createPlayer.ts b/apps/worker/src/endpoints/createPlayer.ts
index cf606713..e40592bd 100644
--- a/apps/worker/src/endpoints/createPlayer.ts
+++ b/apps/worker/src/endpoints/createPlayer.ts
@@ -1,4 +1,7 @@
-import { type PlayerIdentityBootstrap } from '@knucklebones/common'
+import {
+ createPlayerRequestSchema,
+ type PlayerIdentityBootstrap
+} from '@knucklebones/common'
import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
import { type RequestWithId } from '../types/itty'
import {
@@ -6,6 +9,7 @@ import {
createRecoveryPhrase,
hashCredential
} from '../utils/credentials'
+import { apiError } from '../utils/http'
import { enforceRateLimit } from '../utils/rateLimit'
export async function createPlayer(
@@ -21,6 +25,18 @@ export async function createPlayer(
return rateLimit
}
+ const body = createPlayerRequestSchema.safeParse(
+ await request.json().catch(() => undefined)
+ )
+ if (!body.success) {
+ return apiError({
+ status: 400,
+ code: 'INVALID_CREATE_PLAYER_REQUEST',
+ message: 'The player creation request is invalid.',
+ requestId: request.requestId
+ })
+ }
+
const playerId = crypto.randomUUID()
const { credentialId, credential, secretHash } =
await createDeviceCredential()
@@ -29,8 +45,10 @@ export async function createPlayer(
await cloudflareEnvironment.PLAYERS_DB.batch([
cloudflareEnvironment.PLAYERS_DB.prepare(
- 'INSERT INTO players (player_id, credential_hash, created_at) VALUES (?, ?, ?)'
- ).bind(playerId, secretHash, createdAt),
+ `INSERT INTO players
+ (player_id, credential_hash, display_name, created_at)
+ VALUES (?, ?, ?, ?)`
+ ).bind(playerId, secretHash, body.data.displayName, createdAt),
cloudflareEnvironment.PLAYERS_DB.prepare(
`INSERT INTO device_credentials
(credential_id, player_id, secret_hash, created_at)
diff --git a/apps/worker/src/endpoints/deleteDisplayName.ts b/apps/worker/src/endpoints/deleteDisplayName.ts
deleted file mode 100644
index b342c0c8..00000000
--- a/apps/worker/src/endpoints/deleteDisplayName.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
-import {
- type AuthenticatedMutationRoomRequestWithProps,
- type MutationRequestWithProps
-} from '../types/itty'
-import { executeDisplayNameUpdate } from './displayName'
-
-export async function deleteDisplayName(
- request: MutationRequestWithProps,
- cloudflareEnvironment: CloudflareEnvironment,
- context: ExecutionContext
-) {
- return await executeDisplayNameUpdate(
- request,
- undefined,
- cloudflareEnvironment,
- context
- )
-}
-
-export async function deleteRoomDisplayName(
- request: Request & AuthenticatedMutationRoomRequestWithProps,
- cloudflareEnvironment: CloudflareEnvironment,
- context: ExecutionContext
-) {
- return await executeDisplayNameUpdate(
- request,
- undefined,
- cloudflareEnvironment,
- context
- )
-}
diff --git a/apps/worker/src/endpoints/displayName.ts b/apps/worker/src/endpoints/displayName.ts
deleted file mode 100644
index 7154ce22..00000000
--- a/apps/worker/src/endpoints/displayName.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { status } from 'itty-router'
-import {
- displayNameRouteParamsSchema,
- GameState,
- idempotentUpdateDisplayNameResultSchema,
- updateDisplayNameSchema
-} from '@knucklebones/common'
-import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
-import {
- type AuthenticatedMutationRoomRequestWithProps,
- type MutationRequestWithProps
-} from '../types/itty'
-import { makeAiPlay } from '../utils/ai'
-import {
- broadcastGameState,
- getGameStateDurableObject
-} from '../utils/endpoints'
-import { apiError } from '../utils/http'
-import { idempotencyConflict } from '../utils/idempotency'
-import { invalidRouteParameters } from '../utils/validation'
-
-interface DisplayNameRequest extends MutationRequestWithProps {
- displayName?: string
-}
-
-export async function displayName(
- request: DisplayNameRequest,
- cloudflareEnvironment: CloudflareEnvironment,
- context: ExecutionContext
-) {
- const params = displayNameRouteParamsSchema.safeParse({
- roomKey: request.roomKey,
- playerId: request.playerId,
- displayName: request.displayName
- })
- if (!params.success) {
- return invalidRouteParameters(request.requestId)
- }
-
- return await executeDisplayNameUpdate(
- request,
- params.data.displayName,
- cloudflareEnvironment,
- context
- )
-}
-
-export async function updateRoomDisplayName(
- request: Request & AuthenticatedMutationRoomRequestWithProps,
- cloudflareEnvironment: CloudflareEnvironment,
- context: ExecutionContext
-) {
- const body = updateDisplayNameSchema.safeParse(
- await request.json().catch(() => undefined)
- )
- if (!body.success) {
- return apiError({
- status: 400,
- code: 'INVALID_DISPLAY_NAME_REQUEST',
- message: 'The display-name request is invalid.',
- requestId: request.requestId
- })
- }
-
- return await executeDisplayNameUpdate(
- request,
- body.data.displayName,
- cloudflareEnvironment,
- context
- )
-}
-
-export async function executeDisplayNameUpdate(
- request: AuthenticatedMutationRoomRequestWithProps,
- displayName: string | undefined,
- cloudflareEnvironment: CloudflareEnvironment,
- context: ExecutionContext
-) {
- const result = idempotentUpdateDisplayNameResultSchema.parse(
- await getGameStateDurableObject(request).updateDisplayName(
- request.mutationId,
- request.principal.playerId,
- displayName
- )
- )
-
- if (result.idempotencyStatus === 'conflict') {
- return idempotencyConflict(request.requestId)
- }
-
- const mutation = result.value
-
- if (mutation.status === 'unknown-player') {
- return apiError({
- status: 403,
- code: 'NOT_A_PLAYER',
- message: 'Only a player in this game can change their display name.',
- requestId: request.requestId
- })
- }
-
- await broadcastGameState(mutation.gameState, request, cloudflareEnvironment)
-
- const gameState = GameState.fromJson(mutation.gameState)
- if (
- gameState.outcome === 'ongoing' &&
- gameState.playerTwo.isAi() &&
- gameState.nextPlayer.equals(gameState.playerTwo)
- ) {
- makeAiPlay(gameState, request, cloudflareEnvironment, context)
- }
-
- return status(200)
-}
diff --git a/apps/worker/src/endpoints/getRankedLeaderboard.ts b/apps/worker/src/endpoints/getRankedLeaderboard.ts
new file mode 100644
index 00000000..794b02f4
--- /dev/null
+++ b/apps/worker/src/endpoints/getRankedLeaderboard.ts
@@ -0,0 +1,87 @@
+import {
+ DEFAULT_RATING_POOL,
+ rankedLeaderboardSchema
+} from '@knucklebones/common'
+import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
+import { type AuthenticatedRequestWithProps } from '../types/itty'
+
+interface LeaderboardRow {
+ rank: number | null
+ player_id: string
+ display_name: string
+ rating: number
+}
+
+export async function getRankedLeaderboard(
+ request: AuthenticatedRequestWithProps,
+ cloudflareEnvironment: CloudflareEnvironment
+): Promise {
+ const database = cloudflareEnvironment.PLAYERS_DB
+ const [topPlayers, currentPlayer] = await Promise.all([
+ database
+ .prepare(
+ `SELECT
+ 1 + (
+ SELECT COUNT(*)
+ FROM player_ratings AS higher
+ WHERE higher.rating_pool = ratings.rating_pool
+ AND higher.games_played > 0
+ AND higher.rating > ratings.rating
+ ) AS rank,
+ ratings.player_id,
+ players.display_name,
+ ratings.rating
+ FROM player_ratings AS ratings
+ INNER JOIN players ON players.player_id = ratings.player_id
+ WHERE ratings.rating_pool = ? AND ratings.games_played > 0
+ ORDER BY ratings.rating DESC, ratings.player_id
+ LIMIT 10`
+ )
+ .bind(DEFAULT_RATING_POOL)
+ .all(),
+ database
+ .prepare(
+ `SELECT
+ CASE
+ WHEN ratings.games_played = 0 THEN NULL
+ ELSE 1 + (
+ SELECT COUNT(*)
+ FROM player_ratings AS higher
+ WHERE higher.rating_pool = ratings.rating_pool
+ AND higher.games_played > 0
+ AND higher.rating > ratings.rating
+ )
+ END AS rank,
+ ratings.player_id,
+ players.display_name,
+ ratings.rating
+ FROM player_ratings AS ratings
+ INNER JOIN players ON players.player_id = ratings.player_id
+ WHERE ratings.player_id = ? AND ratings.rating_pool = ?`
+ )
+ .bind(request.principal.playerId, DEFAULT_RATING_POOL)
+ .first()
+ ])
+
+ if (currentPlayer === null) {
+ throw new Error('The current ranked player profile was not found.')
+ }
+
+ const response = rankedLeaderboardSchema.parse({
+ topPlayers: topPlayers.results.map(toLeaderboardEntry),
+ currentPlayer: toLeaderboardEntry(currentPlayer)
+ })
+
+ return Response.json(response, {
+ headers: { 'Cache-Control': 'no-store' }
+ })
+}
+
+function toLeaderboardEntry(row: LeaderboardRow) {
+ return {
+ rank: row.rank,
+ playerId: row.player_id,
+ displayName: row.display_name,
+ rating: row.rating
+ }
+}
diff --git a/apps/worker/src/endpoints/getRankedProfile.ts b/apps/worker/src/endpoints/getRankedProfile.ts
index 0833b0fe..10bebdcc 100644
--- a/apps/worker/src/endpoints/getRankedProfile.ts
+++ b/apps/worker/src/endpoints/getRankedProfile.ts
@@ -5,6 +5,7 @@ import { apiError } from '../utils/http'
interface RankedProfileRow {
player_id: string
+ display_name: string
rating_pool: string
rating: number
games_played: number
@@ -19,9 +20,12 @@ export async function getRankedProfile(
): Promise {
const playerId = request.principal.playerId
const profile = await cloudflareEnvironment.PLAYERS_DB.prepare(
- `SELECT player_id, rating_pool, rating, games_played, wins, draws, losses
- FROM player_ratings
- WHERE player_id = ? AND rating_pool = ?`
+ `SELECT ratings.player_id, players.display_name, ratings.rating_pool,
+ ratings.rating, ratings.games_played, ratings.wins,
+ ratings.draws, ratings.losses
+ FROM player_ratings AS ratings
+ INNER JOIN players ON players.player_id = ratings.player_id
+ WHERE ratings.player_id = ? AND ratings.rating_pool = ?`
)
.bind(playerId, DEFAULT_RATING_POOL)
.first()
@@ -37,6 +41,7 @@ export async function getRankedProfile(
const response = rankedProfileSchema.parse({
playerId: profile.player_id,
+ displayName: profile.display_name,
ratingPool: DEFAULT_RATING_POOL,
rating: profile.rating,
gamesPlayed: profile.games_played,
diff --git a/apps/worker/src/endpoints/getRankedStats.ts b/apps/worker/src/endpoints/getRankedStats.ts
index bb4eb5bc..5c674cc7 100644
--- a/apps/worker/src/endpoints/getRankedStats.ts
+++ b/apps/worker/src/endpoints/getRankedStats.ts
@@ -21,6 +21,8 @@ interface ProfileTotalsRow {
interface MatchTotalsRow {
matches: number
+ forfeits: number
+ no_contests: number
average_elo_gain: number
}
@@ -71,6 +73,10 @@ export async function getRankedStats(
`SELECT
COALESCE(SUM(CASE WHEN result <> 'no-contest' THEN 1 ELSE 0 END), 0)
AS matches,
+ COALESCE(SUM(CASE WHEN finish_reason = 'forfeit' THEN 1 ELSE 0 END), 0)
+ AS forfeits,
+ COALESCE(SUM(CASE WHEN finish_reason = 'no-contest' THEN 1 ELSE 0 END), 0)
+ AS no_contests,
COALESCE(AVG(CASE WHEN result <> 'no-contest' AND rating_delta <> 0
THEN ABS(rating_delta) END), 0) AS average_elo_gain
FROM rated_matches
@@ -123,6 +129,8 @@ export async function getRankedStats(
wins: profileTotals.wins,
draws: profileTotals.draws,
losses: profileTotals.losses,
+ forfeits: matchTotals.forfeits,
+ noContests: matchTotals.no_contests,
averageEloGain: matchTotals.average_elo_gain
},
current: population,
diff --git a/apps/worker/src/endpoints/index.ts b/apps/worker/src/endpoints/index.ts
index f980f703..01153163 100644
--- a/apps/worker/src/endpoints/index.ts
+++ b/apps/worker/src/endpoints/index.ts
@@ -1,7 +1,7 @@
export * from './clientProtocolDiagnostic'
export * from './createPlayer'
-export * from './displayName'
export * from './getRankedProfile'
+export * from './getRankedLeaderboard'
export * from './getRankedStats'
export * from './init'
export * from './matchmaking'
@@ -11,7 +11,7 @@ export * from './resign'
export * from './webSocket'
export * from './webSocketTicket'
export * from './verifyPlayer'
-export * from './deleteDisplayName'
+export * from './updateRankedProfile'
export * from './deviceCredentials'
export * from './identityTransfers'
export * from './identityRecovery'
diff --git a/apps/worker/src/endpoints/init.ts b/apps/worker/src/endpoints/init.ts
index c75cdf34..1b45cfea 100644
--- a/apps/worker/src/endpoints/init.ts
+++ b/apps/worker/src/endpoints/init.ts
@@ -26,7 +26,7 @@ import { apiError } from '../utils/http'
import { idempotencyConflict } from '../utils/idempotency'
export interface InitRequest extends MutationRequestWithProps {
- query?: GameSettingsQuery & { displayName?: string }
+ query?: GameSettingsQuery
}
export async function init(
@@ -59,7 +59,10 @@ export async function init(
request,
{
playerId: request.playerId,
- displayName: query.data.displayName,
+ displayName: await getPlayerDisplayName(
+ request.playerId,
+ cloudflareEnvironment
+ ),
difficulty: gameSettings.value.difficulty,
boType: gameSettings.value.boType
},
@@ -93,7 +96,12 @@ export async function initializeRoom(
? AI_PLAYER_ID
: request.principal.playerId,
displayName:
- body.data.playerType === 'human' ? body.data.displayName : undefined,
+ body.data.playerType === 'human'
+ ? await getPlayerDisplayName(
+ request.principal.playerId,
+ cloudflareEnvironment
+ )
+ : undefined,
difficulty:
body.data.playerType === 'ai' ? body.data.difficulty : undefined,
boType: body.data.boType
@@ -103,6 +111,31 @@ export async function initializeRoom(
)
}
+interface PlayerDisplayNameRow {
+ display_name: string
+}
+
+async function getPlayerDisplayName(
+ playerId: string,
+ cloudflareEnvironment: CloudflareEnvironment
+): Promise {
+ if (playerId === AI_PLAYER_ID) {
+ return undefined
+ }
+
+ const player = await cloudflareEnvironment.PLAYERS_DB.prepare(
+ 'SELECT display_name FROM players WHERE player_id = ?'
+ )
+ .bind(playerId)
+ .first()
+
+ if (player === null) {
+ throw new Error('The game player profile was not found.')
+ }
+
+ return player.display_name
+}
+
async function executeInitializeGame(
request: AuthenticatedMutationRoomRequestWithProps,
player: {
diff --git a/apps/worker/src/endpoints/updateRankedProfile.ts b/apps/worker/src/endpoints/updateRankedProfile.ts
new file mode 100644
index 00000000..1022614d
--- /dev/null
+++ b/apps/worker/src/endpoints/updateRankedProfile.ts
@@ -0,0 +1,34 @@
+import { status } from 'itty-router'
+import { updateDisplayNameSchema } from '@knucklebones/common'
+import { type CloudflareEnvironment } from '../types/cloudflareEnvironment'
+import { type AuthenticatedRequestWithProps } from '../types/itty'
+import { apiError } from '../utils/http'
+
+export async function updateRankedProfile(
+ request: Request & AuthenticatedRequestWithProps,
+ cloudflareEnvironment: CloudflareEnvironment
+): Promise {
+ const body = updateDisplayNameSchema.safeParse(
+ await request.json().catch(() => undefined)
+ )
+ if (!body.success) {
+ return apiError({
+ status: 400,
+ code: 'INVALID_DISPLAY_NAME_REQUEST',
+ message: 'The display-name request is invalid.',
+ requestId: request.requestId
+ })
+ }
+
+ const result = await cloudflareEnvironment.PLAYERS_DB.prepare(
+ 'UPDATE players SET display_name = ? WHERE player_id = ?'
+ )
+ .bind(body.data.displayName, request.principal.playerId)
+ .run()
+
+ if (result.meta.changes !== 1) {
+ throw new Error('The player profile update changed an invalid row count.')
+ }
+
+ return status(204)
+}
diff --git a/apps/worker/src/utils/validation.ts b/apps/worker/src/utils/validation.ts
index 85afd984..3a74f9a0 100644
--- a/apps/worker/src/utils/validation.ts
+++ b/apps/worker/src/utils/validation.ts
@@ -1,6 +1,5 @@
import {
credentialIdSchema,
- displayNameSchema,
gamePlayerIdSchema,
playerIdSchema,
roomKeySchema
@@ -68,14 +67,6 @@ export function validateRequestPath(
if (!isValid) {
return invalidRouteParameters(request.requestId)
}
-
- if (
- request.method === 'POST' &&
- segments[2] === 'displayName' &&
- !displayNameSchema.safeParse(segments[3]).success
- ) {
- return invalidRouteParameters(request.requestId)
- }
}
}
diff --git a/apps/worker/src/workers/index.ts b/apps/worker/src/workers/index.ts
index 592f09aa..37e118cc 100644
--- a/apps/worker/src/workers/index.ts
+++ b/apps/worker/src/workers/index.ts
@@ -5,9 +5,7 @@ import {
acceptMatchmaking,
createPlayer,
createWebSocketTicket,
- deleteRoomDisplayName,
- deleteDisplayName,
- displayName,
+ getRankedLeaderboard,
getRankedProfile,
getRankedStats,
getRankedRematchStatus,
@@ -31,7 +29,7 @@ import {
revokeOtherDeviceCredentials,
rotateDeviceCredential,
rotateIdentityRecovery,
- updateRoomDisplayName,
+ updateRankedProfile,
verifyPlayer,
webSocket
} from '../endpoints'
@@ -75,7 +73,9 @@ router
.delete('/v1/identity/credentials/:credentialId', revokeDeviceCredential)
.get('/v1/ranked/stats', getRankedStats)
.all('/v1/ranked/*', authenticatePlayerRequest)
+ .get('/v1/ranked/leaderboard', getRankedLeaderboard)
.get('/v1/ranked/profile', getRankedProfile)
+ .post('/v1/ranked/profile', updateRankedProfile)
.all('/v1/matchmaking/*', authenticatePlayerRequest)
.post('/v1/matchmaking/join', joinMatchmaking)
.get('/v1/matchmaking/status', getMatchmakingStatus)
@@ -105,16 +105,6 @@ router
requestRankedRematch
)
.post('/v1/rooms/:roomKey/resign', withAuthenticatedMutationId, resignRoom)
- .post(
- '/v1/rooms/:roomKey/display-name',
- withAuthenticatedMutationId,
- updateRoomDisplayName
- )
- .delete(
- '/v1/rooms/:roomKey/display-name',
- withAuthenticatedMutationId,
- deleteRoomDisplayName
- )
.all(
'/:roomKey/:playerId/*',
withDurables({ parse: true }),
@@ -124,14 +114,6 @@ router
.post('/:roomKey/:playerId/init', withMutationId, init)
.post('/:roomKey/:playerId/play/:column/:dice', withMutationId, play)
.post('/:roomKey/:playerId/rematch', withMutationId, rematch)
- .post(
- '/:roomKey/:playerId/displayName/:displayName',
- withMutationId,
- displayName
- )
-
- .delete('/:roomKey/:playerId/displayName', withMutationId, deleteDisplayName)
-
.all('*', (request: RequestWithId) =>
apiError({
status: 404,
diff --git a/apps/worker/test/observability.test.ts b/apps/worker/test/observability.test.ts
index 7510d520..681f1027 100644
--- a/apps/worker/test/observability.test.ts
+++ b/apps/worker/test/observability.test.ts
@@ -6,10 +6,8 @@ describe('operational events', () => {
it('normalizes public identifiers out of route labels', () => {
expect(
- classifyRoute(
- '/v1/rooms/11111111-1111-4111-8111-111111111111/display-name'
- )
- ).toBe('/v1/rooms/:roomKey/display-name')
+ classifyRoute('/v1/rooms/11111111-1111-4111-8111-111111111111/play')
+ ).toBe('/v1/rooms/:roomKey/play')
expect(
classifyRoute(
'/v1/identity/credentials/22222222-2222-4222-8222-222222222222'
diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts
index 88c459c6..6d4d9807 100644
--- a/apps/worker/test/worker.integration.test.ts
+++ b/apps/worker/test/worker.integration.test.ts
@@ -1,4 +1,12 @@
-import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi
+} from 'vitest'
import { createTestHarness, type TestHarness } from 'wrangler'
import {
apiErrorBodySchema,
@@ -12,6 +20,7 @@ import {
playerIdentityBootstrapSchema,
type PresenceUpdateResult,
rankedMatchSettlementResultSchema,
+ rankedLeaderboardSchema,
rankedProfileSchema,
rankedStatsSchema,
webSocketTicketSchema,
@@ -52,11 +61,22 @@ afterAll(async () => {
})
async function request(path: string, init?: RequestInit) {
- return await server.getWorker().fetch(path, init)
+ const response = await server.getWorker().fetch(path, init)
+ return response.clone()
+}
+
+async function readJson(response: Response): Promise {
+ return await response.json()
}
-async function createPlayer(): Promise {
- const response = await request('/players', { method: 'POST' })
+async function createPlayer(
+ displayName = 'Test Player'
+): Promise {
+ const response = await request('/players', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ displayName })
+ })
expect(response.status).toBe(201)
return playerCredentialsSchema.parse(await response.json())
}
@@ -71,6 +91,21 @@ async function setRating(playerId: string, rating: number): Promise {
expect(result.meta.changes).toBe(1)
}
+async function setRankedRecord(
+ playerId: string,
+ rating: number
+): Promise {
+ const environment = await server.getWorker().getEnv()
+ const result = await environment.PLAYERS_DB.prepare(
+ `UPDATE player_ratings
+ SET rating = ?, games_played = 1
+ WHERE player_id = ?`
+ )
+ .bind(rating, playerId)
+ .run()
+ expect(result.meta.changes).toBe(1)
+}
+
function authorization({ credential }: PlayerCredentials): HeadersInit {
return { Authorization: `Bearer ${credential}` }
}
@@ -135,6 +170,98 @@ describe('client protocol diagnostics', () => {
})
describe('player identities and ranked profiles', () => {
+ it('stores and updates the public display name on the player profile', async () => {
+ const player = await createPlayer('Original 🧙')
+ const original = rankedProfileSchema.parse(
+ await readJson(
+ await request('/v1/ranked/profile', {
+ headers: authorization(player)
+ })
+ )
+ )
+
+ const updated = await request('/v1/ranked/profile', {
+ method: 'POST',
+ headers: {
+ ...authorization(player),
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ displayName: '𝓟𝓸𝓮𝓵 🧙♂️' })
+ })
+ const next = rankedProfileSchema.parse(
+ await readJson(
+ await request('/v1/ranked/profile', {
+ headers: authorization(player)
+ })
+ )
+ )
+
+ expect(original.displayName).toBe('Original 🧙')
+ expect(updated.status).toBe(204)
+ expect(next.displayName).toBe('𝓟𝓸𝓮𝓵 🧙♂️')
+ })
+
+ it('shows new players as unranked on an empty leaderboard', async () => {
+ const player = await createPlayer('New Player')
+ const leaderboard = rankedLeaderboardSchema.parse(
+ await readJson(
+ await request('/v1/ranked/leaderboard', {
+ headers: authorization(player)
+ })
+ )
+ )
+
+ expect(leaderboard.topPlayers).toEqual([])
+ expect(leaderboard.currentPlayer).toEqual({
+ rank: null,
+ playerId: player.playerId,
+ displayName: 'New Player',
+ rating: 1200
+ })
+ })
+
+ it('returns the top ten and a current player outside the table', async () => {
+ const players: PlayerCredentials[] = []
+ for (let index = 0; index < 12; index++) {
+ const player = await createPlayer(`Player ${index + 1}`)
+ players.push(player)
+ await setRankedRecord(
+ player.playerId,
+ index < 2 ? 1500 : 1500 - index * 10
+ )
+ }
+
+ const leaderboard = rankedLeaderboardSchema.parse(
+ await readJson(
+ await request('/v1/ranked/leaderboard', {
+ headers: authorization(players[11])
+ })
+ )
+ )
+
+ expect(leaderboard.topPlayers).toHaveLength(10)
+ expect(leaderboard.topPlayers.slice(0, 2)).toMatchObject([
+ { rank: 1, rating: 1500 },
+ { rank: 1, rating: 1500 }
+ ])
+ expect(
+ leaderboard.topPlayers
+ .slice(0, 2)
+ .map(({ displayName }) => displayName)
+ .sort()
+ ).toEqual(['Player 1', 'Player 2'])
+ expect(leaderboard.topPlayers[2]).toMatchObject({
+ rank: 3,
+ displayName: 'Player 3',
+ rating: 1480
+ })
+ expect(leaderboard.currentPlayer).toMatchObject({
+ rank: 12,
+ displayName: 'Player 12',
+ rating: 1390
+ })
+ })
+
it('rejects missing and cross-player credentials without mutating a room', async () => {
const playerOne = await createPlayer()
const playerTwo = await createPlayer()
@@ -356,7 +483,7 @@ describe('one-time identity transfers', () => {
}
it('issues a separate device credential for the same player', async () => {
- const source = await createPlayer()
+ const source = await createPlayer('Transferred Name')
const transfer = await issueTransfer(source)
const response = await redeemTransfer(transfer.transferToken)
@@ -377,6 +504,14 @@ describe('one-time identity transfers', () => {
])
expect(sourceVerification.status).toBe(204)
expect(importedVerification.status).toBe(204)
+ const profile = rankedProfileSchema.parse(
+ await readJson(
+ await request('/v1/ranked/profile', {
+ headers: authorization(imported)
+ })
+ )
+ )
+ expect(profile.displayName).toBe('Transferred Name')
})
it('allows only one of two concurrent redemptions', async () => {
@@ -432,7 +567,11 @@ describe('one-time identity transfers', () => {
describe('identity recovery', () => {
async function createRecoveryIdentity() {
- const response = await request('/players', { method: 'POST' })
+ const response = await request('/players', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ displayName: 'Recovery Player' })
+ })
expect(response.status).toBe(201)
return playerIdentityBootstrapSchema.parse(await response.json())
}
@@ -607,16 +746,15 @@ describe('versioned authenticated room API', () => {
}
}
- it('derives room actors from credentials and accepts settings in JSON', async () => {
- const playerOne = await createPlayer()
- const playerTwo = await createPlayer()
+ it('derives room actors and names from profiles and accepts settings in JSON', async () => {
+ const playerOne = await createPlayer('Player / One?')
+ const playerTwo = await createPlayer('Player Two')
const roomKey = crypto.randomUUID()
const first = await request(
`/v1/rooms/${roomKey}/init`,
mutationRequest(playerOne, {
playerType: 'human',
- displayName: 'Player / One?',
boType: 1
})
)
@@ -624,22 +762,50 @@ describe('versioned authenticated room API', () => {
`/v1/rooms/${roomKey}/init`,
mutationRequest(playerTwo, { playerType: 'human', boType: 1 })
)
- const renamed = await request(
- `/v1/rooms/${roomKey}/display-name`,
- mutationRequest(playerOne, { displayName: 'Updated / Name?' })
- )
const ticket = await request(
`/v1/rooms/${roomKey}/websocket-ticket`,
mutationRequest(playerOne)
)
+ const environment = await server.getWorker().getEnv()
+ const game = environment.GAME_STATE_DURABLE_OBJECT.get(
+ environment.GAME_STATE_DURABLE_OBJECT.idFromName(roomKey)
+ )
+ const stateResponse = await game.fetch(
+ 'https://itty-durable/do/call/initializeGame',
+ {
+ headers: {
+ 'do-name': roomKey,
+ 'do-content': JSON.stringify([
+ {
+ mutationId: crypto.randomUUID(),
+ playerId: playerOne.playerId,
+ boType: 1
+ }
+ ])
+ }
+ }
+ )
+ const state = idempotentInitializeGameResultSchema.parse(
+ await stateResponse.json()
+ )
+
expect(first.status).toBe(200)
expect(second.status).toBe(200)
- expect(renamed.status).toBe(200)
expect(ticket.status).toBe(201)
expect(webSocketTicketSchema.safeParse(await ticket.json()).success).toBe(
true
)
+ expect(state).toMatchObject({
+ idempotencyStatus: 'applied',
+ value: {
+ status: 'existing',
+ gameState: {
+ playerOne: { displayName: 'Player / One?' },
+ playerTwo: { displayName: 'Player Two' }
+ }
+ }
+ })
})
it('rejects malformed or identity-bearing room bodies', async () => {
@@ -648,6 +814,7 @@ describe('versioned authenticated room API', () => {
for (const body of [
{ playerType: 'human', playerId: player.playerId },
+ { playerType: 'human', displayName: 'Room Name' },
{ playerType: 'ai' },
{ playerType: 'human', boType: 2 }
]) {
@@ -707,20 +874,10 @@ describe('versioned authenticated room API', () => {
`/v1/rooms/${roomKey}/rematch`,
mutationRequest(spectator, {})
)
- const displayName = await request(
- `/v1/rooms/${roomKey}/display-name`,
- mutationRequest(spectator, { displayName: 'Intruder' })
- )
-
expect(rematch.status).toBe(403)
await expect(rematch.json()).resolves.toMatchObject({
error: { code: 'NOT_A_PLAYER' }
})
- expect(displayName.status).toBe(403)
- await expect(displayName.json()).resolves.toMatchObject({
- error: { code: 'NOT_A_PLAYER' }
- })
-
const firstVote = await request(
`/v1/rooms/${roomKey}/rematch`,
mutationRequest(playerOne, {})
@@ -940,11 +1097,6 @@ describe('runtime request validation', () => {
'invalid move',
`/${roomKey}/${player.playerId}/play/3/4.5`,
'INVALID_ROUTE_PARAMETERS'
- ],
- [
- 'blank display name',
- `/${roomKey}/${player.playerId}/displayName/%20`,
- 'INVALID_ROUTE_PARAMETERS'
]
] as const) {
const response = await request(path, { method: 'POST', headers })
@@ -1334,25 +1486,28 @@ describe('ranked matchmaking', () => {
matchmakingStatusSchema.parse(await playerTwoJoin.json()).status
).toBe('waiting')
- await new Promise((resolve) => setTimeout(resolve, 650))
-
const environment = await server.getWorker().getEnv()
- const activeMatch = await environment.PLAYERS_DB.prepare(
- `SELECT player_one_id, player_two_id
- FROM active_ranked_matches
- WHERE player_one_id IN (?, ?) OR player_two_id IN (?, ?)`
+ await vi.waitFor(
+ async () => {
+ const activeMatch = await environment.PLAYERS_DB.prepare(
+ `SELECT player_one_id, player_two_id
+ FROM active_ranked_matches
+ WHERE player_one_id IN (?, ?) OR player_two_id IN (?, ?)`
+ )
+ .bind(
+ playerOne.playerId,
+ playerTwo.playerId,
+ playerOne.playerId,
+ playerTwo.playerId
+ )
+ .first<{ player_one_id: string; player_two_id: string }>()
+ expect(activeMatch).toEqual({
+ player_one_id: playerOne.playerId,
+ player_two_id: playerTwo.playerId
+ })
+ },
+ { timeout: 3_000, interval: 50 }
)
- .bind(
- playerOne.playerId,
- playerTwo.playerId,
- playerOne.playerId,
- playerTwo.playerId
- )
- .first<{ player_one_id: string; player_two_id: string }>()
- expect(activeMatch).toEqual({
- player_one_id: playerOne.playerId,
- player_two_id: playerTwo.playerId
- })
})
it('does not reset the selection window when a player joins twice', async () => {
@@ -1515,29 +1670,31 @@ describe('ranked matchmaking', () => {
playerType: 'human',
boType: 1
})
+
+ expect(outsiderClaim.status).toBe(403)
+ await expect(outsiderClaim.json()).resolves.toMatchObject({
+ error: { code: 'NOT_ASSIGNED_TO_RANKED_MATCH' }
+ })
+
const wrongSettings = await initialize(playerOne, {
playerType: 'human',
boType: 3
})
+
+ expect(wrongSettings.status).toBe(409)
+ await expect(wrongSettings.json()).resolves.toMatchObject({
+ error: { code: 'RANKED_SETTINGS_LOCKED' }
+ })
+
const playerTwoClaim = await initialize(playerTwo, {
playerType: 'human',
- displayName: 'Player Two',
boType: 1
})
const playerOneClaim = await initialize(playerOne, {
playerType: 'human',
- displayName: 'Player One',
boType: 1
})
- expect(outsiderClaim.status).toBe(403)
- await expect(outsiderClaim.json()).resolves.toMatchObject({
- error: { code: 'NOT_ASSIGNED_TO_RANKED_MATCH' }
- })
- expect(wrongSettings.status).toBe(409)
- await expect(wrongSettings.json()).resolves.toMatchObject({
- error: { code: 'RANKED_SETTINGS_LOCKED' }
- })
expect(playerTwoClaim.status).toBe(200)
expect(playerOneClaim.status).toBe(200)
@@ -2402,6 +2559,8 @@ describe('public ranked statistics', () => {
wins: 0,
draws: 0,
losses: 0,
+ forfeits: 0,
+ noContests: 0,
averageEloGain: 0
})
expect(stats.current).toEqual({ activePlayers: 0, queuedPlayers: 1 })
diff --git a/packages/common/src/schemas/api.ts b/packages/common/src/schemas/api.ts
index c32b3a6d..a89bb79f 100644
--- a/packages/common/src/schemas/api.ts
+++ b/packages/common/src/schemas/api.ts
@@ -32,12 +32,6 @@ export const roomRouteParamsSchema = z.object({
playerId: playerIdSchema
})
-export const displayNameRouteParamsSchema = z.object({
- roomKey: roomKeySchema,
- playerId: playerIdSchema,
- displayName: displayNameSchema
-})
-
export const playRouteParamsSchema = z.object({
roomKey: roomKeySchema,
playerId: playerIdSchema,
@@ -52,7 +46,6 @@ export const playIntentSchema = z.strictObject({
export const initializeRoomSchema = z.union([
z.strictObject({
playerType: z.literal('human'),
- displayName: z.optional(displayNameSchema),
boType: z.optional(boTypeSchema)
}),
z.strictObject({
@@ -76,6 +69,4 @@ export const gameSettingsQuerySchema = z.object({
difficulty: z.optional(z.enum(['easy', 'medium', 'hard']))
})
-export const initGameQuerySchema = z.extend(gameSettingsQuerySchema, {
- displayName: z.optional(displayNameSchema)
-})
+export const initGameQuerySchema = gameSettingsQuerySchema
diff --git a/packages/common/src/schemas/durableObject.ts b/packages/common/src/schemas/durableObject.ts
index 9af5b9cd..4a19b35d 100644
--- a/packages/common/src/schemas/durableObject.ts
+++ b/packages/common/src/schemas/durableObject.ts
@@ -6,8 +6,7 @@ import {
type PlayGameResult,
type PresenceUpdateResult,
type RematchGameResult,
- type ResignGameResult,
- type UpdateDisplayNameResult
+ type ResignGameResult
} from '../types'
import { gameStateSchema } from './gameState'
import { boTypeSchema, difficultySchema } from './gameState'
@@ -49,12 +48,6 @@ export const resignGameCommandSchema = z.object({
playerId: playerIdSchema
})
-export const updateDisplayNameCommandSchema = z.object({
- mutationId: mutationIdSchema,
- playerId: playerIdSchema,
- displayName: z.optional(displayNameSchema)
-})
-
const updatedGameStateResultSchema = z.object({
status: z.literal('updated'),
gameState: gameStateSchema
@@ -99,11 +92,6 @@ export const resignGameResultSchema = z.union([
updatedGameStateResultSchema
]) satisfies z.ZodMiniType
-export const updateDisplayNameResultSchema = z.union([
- z.object({ status: z.literal('unknown-player') }),
- updatedGameStateResultSchema
-]) satisfies z.ZodMiniType
-
export const playGameResultSchema = z.union([
z.object({
status: z.literal('rejected'),
@@ -139,7 +127,6 @@ export const gameStateMutationResultSchema = z.union([
initializeGameResultSchema,
rematchGameResultSchema,
resignGameResultSchema,
- updateDisplayNameResultSchema,
playGameResultSchema
]) satisfies z.ZodMiniType
@@ -162,8 +149,5 @@ export const idempotentRematchGameResultSchema = idempotentResultSchema(
export const idempotentResignGameResultSchema = idempotentResultSchema(
resignGameResultSchema
)
-export const idempotentUpdateDisplayNameResultSchema = idempotentResultSchema(
- updateDisplayNameResultSchema
-)
export const idempotentPlayGameResultSchema =
idempotentResultSchema(playGameResultSchema)
diff --git a/packages/common/src/schemas/playerIdentity.ts b/packages/common/src/schemas/playerIdentity.ts
index 5aa1b59a..0f7610b4 100644
--- a/packages/common/src/schemas/playerIdentity.ts
+++ b/packages/common/src/schemas/playerIdentity.ts
@@ -1,5 +1,6 @@
import { z } from 'zod/mini'
import {
+ type CreatePlayerRequest,
type IdentityTransfer,
type IdentityRecovery,
type IdentityRecoveryPhrase,
@@ -12,11 +13,16 @@ import {
import {
credentialIdSchema,
credentialSchema,
+ displayNameSchema,
playerCredentialSchema,
playerIdSchema,
recoveryPhraseSchema
} from './identifiers'
+export const createPlayerRequestSchema = z.strictObject({
+ displayName: displayNameSchema
+}) satisfies z.ZodMiniType
+
export const playerCredentialsSchema = z.object({
playerId: playerIdSchema,
credential: playerCredentialSchema
diff --git a/packages/common/src/schemas/rankedStats.ts b/packages/common/src/schemas/rankedStats.ts
index 29282dea..e04351fc 100644
--- a/packages/common/src/schemas/rankedStats.ts
+++ b/packages/common/src/schemas/rankedStats.ts
@@ -15,6 +15,8 @@ export const rankedStatsSchema = z.object({
wins: nonNegativeIntegerSchema,
draws: nonNegativeIntegerSchema,
losses: nonNegativeIntegerSchema,
+ forfeits: nonNegativeIntegerSchema,
+ noContests: nonNegativeIntegerSchema,
averageEloGain: z.number().check(z.minimum(0))
}),
current: z.object({
diff --git a/packages/common/src/schemas/ranking.ts b/packages/common/src/schemas/ranking.ts
index 346ce1f0..a8914e70 100644
--- a/packages/common/src/schemas/ranking.ts
+++ b/packages/common/src/schemas/ranking.ts
@@ -1,9 +1,14 @@
import { z } from 'zod/mini'
-import { DEFAULT_RATING_POOL, type RankedProfile } from '../types'
-import { playerIdSchema } from './identifiers'
+import {
+ DEFAULT_RATING_POOL,
+ type RankedLeaderboard,
+ type RankedProfile
+} from '../types'
+import { displayNameSchema, playerIdSchema } from './identifiers'
export const rankedProfileSchema = z.object({
playerId: playerIdSchema,
+ displayName: displayNameSchema,
ratingPool: z.literal(DEFAULT_RATING_POOL),
rating: z.int(),
gamesPlayed: z.int().check(z.minimum(0)),
@@ -11,3 +16,25 @@ export const rankedProfileSchema = z.object({
draws: z.int().check(z.minimum(0)),
losses: z.int().check(z.minimum(0))
}) satisfies z.ZodMiniType
+
+const rankedLeaderboardPlayerSchema = z.object({
+ playerId: playerIdSchema,
+ displayName: displayNameSchema,
+ rating: z.int()
+})
+
+const rankedLeaderboardEntrySchema = z.extend(rankedLeaderboardPlayerSchema, {
+ rank: z.int().check(z.minimum(1))
+})
+
+const currentRankedLeaderboardEntrySchema = z.extend(
+ rankedLeaderboardPlayerSchema,
+ {
+ rank: z.nullable(z.int().check(z.minimum(1)))
+ }
+)
+
+export const rankedLeaderboardSchema = z.object({
+ topPlayers: z.array(rankedLeaderboardEntrySchema).check(z.maxLength(10)),
+ currentPlayer: currentRankedLeaderboardEntrySchema
+}) satisfies z.ZodMiniType
diff --git a/packages/common/src/types/durableObject.ts b/packages/common/src/types/durableObject.ts
index 99bc0484..352cf33c 100644
--- a/packages/common/src/types/durableObject.ts
+++ b/packages/common/src/types/durableObject.ts
@@ -28,12 +28,6 @@ export interface ResignGameCommand {
playerId: string
}
-export interface UpdateDisplayNameCommand {
- mutationId: string
- playerId: string
- displayName?: string
-}
-
export type PresenceUpdateResult =
| { status: 'disabled' | 'ignored' | 'unchanged' }
| {
@@ -71,19 +65,12 @@ export type ResignGameResult =
}
| { status: 'updated'; gameState: IGameState }
-export type UpdateDisplayNameResult =
- { status: 'unknown-player' } | { status: 'updated'; gameState: IGameState }
-
export type PlayGameResult =
| { status: 'rejected'; reason: PlayIntentRejectionReason }
| { status: 'updated'; gameState: IGameState }
export type GameStateMutationResult =
- | InitializeGameResult
- | RematchGameResult
- | ResignGameResult
- | UpdateDisplayNameResult
- | PlayGameResult
+ InitializeGameResult | RematchGameResult | ResignGameResult | PlayGameResult
export type IdempotentMutationResult =
| { idempotencyStatus: 'applied' | 'replayed'; value: T }
diff --git a/packages/common/src/types/playerIdentity.ts b/packages/common/src/types/playerIdentity.ts
index 5047d3f6..76a21080 100644
--- a/packages/common/src/types/playerIdentity.ts
+++ b/packages/common/src/types/playerIdentity.ts
@@ -1,5 +1,9 @@
export const AI_PLAYER_ID = 'beep-boop'
+export interface CreatePlayerRequest {
+ displayName: string
+}
+
export interface PlayerCredentials {
playerId: string
credential: string
diff --git a/packages/common/src/types/rankedStats.ts b/packages/common/src/types/rankedStats.ts
index 83f4ff7b..1da27b99 100644
--- a/packages/common/src/types/rankedStats.ts
+++ b/packages/common/src/types/rankedStats.ts
@@ -11,6 +11,8 @@ export interface RankedStats {
wins: number
draws: number
losses: number
+ forfeits: number
+ noContests: number
averageEloGain: number
}
current: {
diff --git a/packages/common/src/types/ranking.ts b/packages/common/src/types/ranking.ts
index 712d6466..5d402b73 100644
--- a/packages/common/src/types/ranking.ts
+++ b/packages/common/src/types/ranking.ts
@@ -18,6 +18,7 @@ export interface EloRatingUpdate {
export interface RankedProfile {
playerId: string
+ displayName: string
ratingPool: RatingPool
rating: number
gamesPlayed: number
@@ -25,3 +26,22 @@ export interface RankedProfile {
draws: number
losses: number
}
+
+export interface RankedLeaderboardEntry {
+ rank: number
+ playerId: string
+ displayName: string
+ rating: number
+}
+
+export interface CurrentRankedLeaderboardEntry {
+ rank: number | null
+ playerId: string
+ displayName: string
+ rating: number
+}
+
+export interface RankedLeaderboard {
+ topPlayers: RankedLeaderboardEntry[]
+ currentPlayer: CurrentRankedLeaderboardEntry
+}
diff --git a/tests/e2e/websocket.spec.ts b/tests/e2e/websocket.spec.ts
index 91d4e4a5..1898f41f 100644
--- a/tests/e2e/websocket.spec.ts
+++ b/tests/e2e/websocket.spec.ts
@@ -21,7 +21,9 @@ async function createPlayer(
request: APIRequestContext
): Promise {
return (await (
- await request.post(`${workerUrl}/players`)
+ await request.post(`${workerUrl}/players`, {
+ data: { displayName: 'E2E Player' }
+ })
).json()) as PlayerCredentials
}
@@ -58,9 +60,7 @@ async function closeSocket(socket: WebSocket): Promise {
test('WebSocket tickets are authenticated, one-time, and reject client messages', async ({
request
}) => {
- const player = (await (
- await request.post(`${workerUrl}/players`)
- ).json()) as { playerId: string; credential: string }
+ const player = await createPlayer(request)
expect(player.playerId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)