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
8 changes: 6 additions & 2 deletions apps/api/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { createDb, createLogger, createRedis, TRACE_ID_HEADER, type loadApiEnv } from '@meldrank/shared/server';
import { DEFAULT_LEADERBOARD_ELIGIBILITY } from '@meldrank/rating';
import { createDb, createLeaderboardStore, createLogger, createRedis, TRACE_ID_HEADER, type loadApiEnv } from '@meldrank/shared/server';
import { createClerkAuth } from './identity';
import { createCasualTableStore } from './lobby/store';
import { createTicketMinter } from './lobby/tickets';
Expand Down Expand Up @@ -49,8 +50,11 @@ export function createApiRuntime(env: ApiEnv): ApiRuntime {
// through the Redis-cached resolve-or-create over the db.
const auth = createClerkAuth({ secretKey: env.CLERK_SECRET_KEY, authorizedParties: [env.WEB_APP_ORIGIN] });
const players = createPlayerResolver({ store: createPlayerStore(db), redis });
// The leaderboard read path over the shared db, gated by the ruled eligibility
// knobs (leaderboard design D1/D4 — the API is the constants' sole consumer).
const leaderboard = createLeaderboardStore(db, DEFAULT_LEADERBOARD_ELIGIBILITY);

return { deps: { variants: variantCatalog, store, spawn, tickets, auth, players, log }, db, redis };
return { deps: { variants: variantCatalog, store, spawn, tickets, auth, players, leaderboard, log }, db, redis };
}

/**
Expand Down
90 changes: 88 additions & 2 deletions apps/api/src/routers/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { SINGLE_DECK_PARTNERS, type RoomSpawnRequest } from '@meldrank/shared';
import { createLogger, verifySeatTicket } from '@meldrank/shared/server';
import { SINGLE_DECK_PARTNERS, type LeaderboardStanding, type RoomSpawnRequest } from '@meldrank/shared';
import { createLogger, LeaderboardCursorError, verifySeatTicket, type LeaderboardStore } from '@meldrank/shared/server';
import { ApiError, type ApiContext } from '../trpc';
import { createCasualTableStore } from '../lobby/store';
import { FakeLobbyRedis } from '../lobby/store-fake';
Expand Down Expand Up @@ -39,6 +39,36 @@ function fakeSpawn(): FakeSpawn {
return spawn;
}

/** An in-memory leaderboard store the tests parameterize per case. */
interface FakeLeaderboard extends LeaderboardStore {
page: Awaited<ReturnType<LeaderboardStore['listPage']>>;
standing: LeaderboardStanding | null;
/** When true, `listPage` raises the typed cursor failure. */
badCursor: boolean;
readonly listCalls: Parameters<LeaderboardStore['listPage']>[0][];
readonly standingCalls: Parameters<LeaderboardStore['getStanding']>[0][];
}

function fakeLeaderboard(): FakeLeaderboard {
const store: FakeLeaderboard = {
page: { items: [], nextCursor: null },
standing: null,
badCursor: false,
listCalls: [],
standingCalls: [],
listPage(input) {
store.listCalls.push(input);
if (store.badCursor) return Promise.reject(new LeaderboardCursorError());
return Promise.resolve(store.page);
},
getStanding(input) {
store.standingCalls.push(input);
return Promise.resolve(store.standing);
},
};
return store;
}

/** A shared test harness: one Redis/store/spawn, with per-player callers. */
function harness(spawnOverride?: SpawnClient) {
const redis = new FakeLobbyRedis();
Expand All @@ -58,19 +88,22 @@ function harness(spawnOverride?: SpawnClient) {
upsert: () => Promise.resolve('unused'),
anonymize: () => Promise.resolve(),
};
const leaderboard = fakeLeaderboard();
const deps: Omit<ApiContext, 'playerId'> = {
variants: variantCatalog,
store,
spawn: spawnOverride ?? spawn,
tickets,
auth,
players,
leaderboard,
log,
traceId: 'test-trace',
};
return {
store,
spawn,
leaderboard,
caller: (playerId: string | null) => createCaller({ ...deps, playerId }),
};
}
Expand Down Expand Up @@ -102,6 +135,59 @@ describe('account + variant reference procedures', () => {
});
});

describe('leaderboard', () => {
const ROW = { rank: 1, playerId: 'p7', displayName: 'Placed', avatar: null, rating: 1650, gamesPlayed: 12 };

it('list delegates to the store and passes the page through', async () => {
const h = harness();
h.leaderboard.page = { items: [ROW], nextCursor: 'cursor-2' };
const page = await h.caller(null).leaderboard.list({ variantId: PARTNERS, limit: 20 });
expect(page).toEqual({ items: [ROW], nextCursor: 'cursor-2' });
expect(h.leaderboard.listCalls[0]).toMatchObject({ variantId: PARTNERS, limit: 20 });
});

it('list rejects an unknown variant with a typed not-found before touching the store', async () => {
const h = harness();
await expect(h.caller(null).leaderboard.list({ variantId: 'nope' })).rejects.toMatchObject({
apiErrorCode: 'not-found',
});
expect(h.leaderboard.listCalls).toHaveLength(0);
});

it('list maps the typed cursor failure to a validation error', async () => {
const h = harness();
h.leaderboard.badCursor = true;
await expect(h.caller(null).leaderboard.list({ variantId: PARTNERS, cursor: 'forged' })).rejects.toMatchObject({
apiErrorCode: 'validation',
});
});

it('myStanding rejects an unauthenticated caller with a typed unauthorized', async () => {
await expect(harness().caller(null).leaderboard.myStanding({ variantId: PARTNERS })).rejects.toMatchObject({
apiErrorCode: 'unauthorized',
});
});

it('myStanding queries the store for the caller and passes the standing through', async () => {
const h = harness();
h.leaderboard.standing = { rating: 1600, ratingDeviation: 80, gamesPlayed: 15, eligible: true, rank: 3, gamesRemaining: 0 };
const standing = await h.caller('p1').leaderboard.myStanding({ variantId: PARTNERS });
expect(standing).toEqual(h.leaderboard.standing);
expect(h.leaderboard.standingCalls).toEqual([{ variantId: PARTNERS, playerId: 'p1' }]);
});

it('myStanding passes a null (never played ranked) standing through', async () => {
const h = harness();
expect(await h.caller('p1').leaderboard.myStanding({ variantId: PARTNERS })).toBeNull();
});

it('myStanding rejects an unknown variant with a typed not-found', async () => {
await expect(harness().caller('p1').leaderboard.myStanding({ variantId: 'nope' })).rejects.toMatchObject({
apiErrorCode: 'not-found',
});
});
});

describe('casual.createTable', () => {
it('seats the creator, opens the table, and lists it', async () => {
const h = harness();
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { healthy } from '@meldrank/shared';
import { createCallerFactory, publicProcedure, router } from '../trpc';
import { accountRouter } from './account';
import { casualRouter } from './casual';
import { leaderboardRouter } from './leaderboard';
import { matchRouter } from './match';
import { variantRouter } from './variant';

Expand All @@ -17,6 +18,7 @@ export const appRouter = router({
account: accountRouter,
variant: variantRouter,
casual: casualRouter,
leaderboard: leaderboardRouter,
match: matchRouter,
});

Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/routers/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
LeaderboardListInputSchema,
LeaderboardListOutputSchema,
LeaderboardMyStandingInputSchema,
LeaderboardMyStandingOutputSchema,
} from '@meldrank/shared';
import { LeaderboardCursorError } from '@meldrank/shared/server';
import { apiError, protectedProcedure, publicProcedure, router } from '../trpc';

/**
* The public ranked-standings surface (capability `leaderboard-api`): the
* conservative-sorted, eligibility-gated board page and the signed-in viewer's
* own standing. Ranking semantics live in the injected {@link LeaderboardStore}
* (leaderboard design D1–D3); the router owns only the catalog check and the
* cursor-failure → `validation` mapping.
*/
export const leaderboardRouter = router({
list: publicProcedure
.input(LeaderboardListInputSchema)
.output(LeaderboardListOutputSchema)
.query(async ({ ctx, input }) => {
if (ctx.variants.get(input.variantId) === null) {
throw apiError('not-found', `unknown variant: ${input.variantId}`);
}
try {
return await ctx.leaderboard.listPage(input);
} catch (error) {
if (error instanceof LeaderboardCursorError) {
throw apiError('validation', 'invalid leaderboard cursor');
}
throw error;
}
}),

myStanding: protectedProcedure
.input(LeaderboardMyStandingInputSchema)
.output(LeaderboardMyStandingOutputSchema)
.query(({ ctx, input }) => {
if (ctx.variants.get(input.variantId) === null) {
throw apiError('not-found', `unknown variant: ${input.variantId}`);
}
return ctx.leaderboard.getStanding({ variantId: input.variantId, playerId: ctx.playerId });
}),
});
4 changes: 3 additions & 1 deletion apps/api/src/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { initTRPC, TRPCError } from '@trpc/server';
import type { ApiErrorCode } from '@meldrank/shared';
import type { Logger } from '@meldrank/shared/server';
import type { LeaderboardStore, Logger } from '@meldrank/shared/server';
import type { ClerkAuth } from './identity';
import type { CasualTableStore } from './lobby/store';
import type { TicketMinter } from './lobby/tickets';
Expand All @@ -26,6 +26,8 @@ export interface ApiDeps {
readonly auth: ClerkAuth;
/** Resolves the verified Clerk identity to the internal `players.id` (design D6). */
readonly players: PlayerResolver;
/** The leaderboard read path (capability `leaderboard-api`) — injected as an interface so tests fake it. */
readonly leaderboard: LeaderboardStore;
/** The service base logger (capability `structured-logging`, design D3); `buildContext` derives the per-request child. */
readonly log: Logger;
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/_next-typesafe-url_.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare module "@@@next-typesafe-url" {
}

interface StaticRouter {
"/leaderboard": StaticRoute;
"/": StaticRoute;
"/sign-in": StaticRoute;
"/sign-up": StaticRoute;
Expand Down
21 changes: 21 additions & 0 deletions apps/web/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { LeaderboardView } from '@/components/leaderboard/leaderboard-view';
import { PageShell } from '@/components/ui/page-shell';
import { SiteHeader } from '@/components/ui/site-header';

/**
* The `/leaderboard` route (capability `leaderboard-web`): a thin server shell
* — the shared header and page chrome — around the client-side
* {@link LeaderboardView}, which owns the board query, the own-standing strip,
* and pagination. A static route: no dynamic params or search params in v1
* (the variant is fixed to Single-Deck Partners, design D7).
*/
export default function LeaderboardPage() {
return (
<>
<SiteHeader />
<PageShell gap="6" justify="start">
<LeaderboardView />
</PageShell>
</>
);
}
23 changes: 10 additions & 13 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { UserButton } from '@clerk/nextjs';

import { CasualHall } from '@/components/hall/casual-hall';
import { PageShell } from '@/components/ui/page-shell';
import { SiteHeader } from '@/components/ui/site-header';

/**
* The landing route — the casual hall (capability `casual-hall-web`). The page is a
* thin shell: the sign-out affordance (Clerk's account menu) and the page chrome,
* with the hall itself (identity, Quick Play / Create Table, Rejoin, and the
* open-table browse list) owned by {@link CasualHall}.
* thin shell: the shared site header (home / Leaderboard / the Clerk user control)
* and the page chrome, with the hall itself (identity, Quick Play / Create Table,
* Rejoin, and the open-table browse list) owned by {@link CasualHall}.
*/
export default function Home() {
return (
<PageShell gap="6">
{/* Sign-out affordance: Clerk's account menu, which includes Sign out. */}
<header className="absolute top-4 right-4">
<UserButton />
</header>

<CasualHall />
</PageShell>
<>
<SiteHeader />
<PageShell gap="6">
<CasualHall />
</PageShell>
</>
);
}
Loading
Loading