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
33 changes: 33 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Product

## Register

brand

## Users

Straif players and prospective players visiting from desktop or mobile. They want to understand the game quickly, watch the official trailer, see representative maps, and inspect global records across movement, target, and aim disciplines without learning internal API terminology.

## Product Purpose

Present Straif as a precise, movement-focused 3D platforming shooter while making every leaderboard entry accessible. Success means the homepage communicates the game through real footage and map imagery, and the leaderboard lets visitors reach, filter, restore, and paginate every supported ranking.

## Brand Personality

Cinematic, precise, restrained. The voice is factual and confident, with the game and its records carrying the experience rather than slogans or ornamental gaming language.

## Anti-references

Avoid neon esports styling, gaming HUD ornament, decorative gradients, glow effects, glass surfaces, heavy shadows, rounded dashboard cards, pill controls, generic SaaS landing-page sections, dense contact-sheet galleries, and unsupported marketing claims.

## Design Principles

1. Show the game first through the official trailer and real map imagery.
2. Treat records as primary content, with quiet presentation and complete access.
3. Use factual copy and remove any word that does not help visitors understand the game or data.
4. Preserve a cinematic editorial rhythm without compromising semantic structure or navigation.
5. Keep route state durable so links, refreshes, and browser history reproduce the same leaderboard.

## Accessibility & Inclusion

Use semantic landmarks, one route-level main region, meaningful heading order, real tables with captions and scoped headers, labeled navigation and controls, keyboard-accessible overflow, visible focus, explicit loading/empty/error states, and reduced-motion behavior. Maintain readable contrast and preserve image-then-caption order on narrow screens.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@ It is heavily inspired by source engine games such as Counter Strike: Source, le
- [Steam Deployment](#steam-deployment)

## Website
The straif leaderboard can be viewed at [straif.pumped.software](https://straif.pumped.software/).

The Straif website includes a trailer-led game overview and complete Movement,
Target, Aim, and Overall leaderboards at
[straif.pumped.software](https://straif.pumped.software/).

Run it locally:

```bash
cd website
pnpm install
pnpm dev
```

## Web Api
The straif [web api](https://straifapi.pumped.software) is public and [documentation](https://straifapi.pumped.software/docs) can be viewed as well.
Expand Down
76 changes: 76 additions & 0 deletions server/src/leaderboard_pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LeaderboardPaginationQuery,
get_leaderboard_offset,
paginate_leaderboard,
} from './leaderboard_pagination';

void test('leaderboard pagination preserves the compatibility defaults', () => {
assert.deepEqual(LeaderboardPaginationQuery.parse({}), {
page: 0,
limit: 10,
});
});

void test('leaderboard pagination coerces valid query strings', () => {
assert.deepEqual(
LeaderboardPaginationQuery.parse({ page: '2', limit: '25' }),
{ page: 2, limit: 25 }
);
});

void test('leaderboard pagination rejects invalid and excessive values', () => {
assert.equal(
LeaderboardPaginationQuery.safeParse({ page: '-1', limit: '25' }).success,
false
);
assert.equal(
LeaderboardPaginationQuery.safeParse({ page: '0', limit: '0' }).success,
false
);
assert.equal(
LeaderboardPaginationQuery.safeParse({ page: '0', limit: '101' }).success,
false
);
});

void test('get_leaderboard_offset uses zero-based pages', () => {
assert.equal(get_leaderboard_offset({ page: 3, limit: 25 }), 75);
});

void test('leaderboard pagination accepts the website page size', () => {
const parsed = LeaderboardPaginationQuery.parse({
page: '1',
limit: '25',
});

assert.equal(parsed.limit, 25);
assert.equal(get_leaderboard_offset(parsed), 25);
});

void test('paginate_leaderboard preserves order and reports the unsliced total', () => {
const result = paginate_leaderboard(
['first', 'second', 'third', 'fourth', 'fifth'],
{ page: 1, limit: 2 }
);

assert.deepEqual(result, {
rows: ['third', 'fourth'],
total: 5,
});
});

void test('paginate_leaderboard can expose every overall entry across pages', () => {
const entries = Array.from({ length: 53 }, (_, index) => ({
rank: index + 1,
}));

const first = paginate_leaderboard(entries, { page: 0, limit: 25 });
const last = paginate_leaderboard(entries, { page: 2, limit: 25 });

assert.equal(first.rows.length, 25);
assert.equal(last.rows.length, 3);
assert.equal(last.rows[0].rank, 51);
assert.equal(last.total, 53);
});
63 changes: 63 additions & 0 deletions server/src/leaderboard_pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { DescribeRouteOptions } from 'hono-openapi';
import { z } from 'zod';

type OpenApiParameter = Exclude<
NonNullable<DescribeRouteOptions['parameters']>[number],
{ $ref: string }
>;

export const LEADERBOARD_DEFAULT_LIMIT = 10;
export const LEADERBOARD_MAX_LIMIT = 100;

export const LeaderboardPaginationQuery = z.object({
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce
.number()
.int()
.min(1)
.max(LEADERBOARD_MAX_LIMIT)
.default(LEADERBOARD_DEFAULT_LIMIT),
});

export type LeaderboardPagination = z.infer<typeof LeaderboardPaginationQuery>;

export const LeaderboardPaginationParameters = [
{
name: 'page',
in: 'query',
required: false,
schema: {
type: 'integer',
minimum: 0,
default: 0,
},
description: 'Zero-based leaderboard page number.',
},
{
name: 'limit',
in: 'query',
required: false,
schema: {
type: 'integer',
minimum: 1,
maximum: LEADERBOARD_MAX_LIMIT,
default: LEADERBOARD_DEFAULT_LIMIT,
},
description: 'Rows per page.',
},
] satisfies OpenApiParameter[];

export function get_leaderboard_offset({ page, limit }: LeaderboardPagination) {
return page * limit;
}

export function paginate_leaderboard<T>(
entries: readonly T[],
pagination: LeaderboardPagination
) {
const offset = get_leaderboard_offset(pagination);
return {
rows: entries.slice(offset, offset + pagination.limit),
total: entries.length,
};
}
89 changes: 46 additions & 43 deletions server/src/routes/aim_leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {
parseAimScenario,
type AimScenario,
} from '../aim_leaderboard';
import {
get_leaderboard_offset,
LeaderboardPaginationParameters,
LeaderboardPaginationQuery,
} from '../leaderboard_pagination';

const app = new Hono<{ Variables: Variables }>();

Expand All @@ -41,10 +46,6 @@ const AimScoreInput = z.object({
username: z.string().trim().min(1).max(64),
});

const PaginationQuery = z.object({
page: z.coerce.number().int().min(0).default(0),
});

const ScenarioPathParameter = {
name: 'scenario',
in: 'path',
Expand All @@ -56,18 +57,6 @@ const ScenarioPathParameter = {
description: 'The aim scenario to operate on.',
} satisfies OpenApiParameter;

const PageQueryParameter = {
name: 'page',
in: 'query',
required: false,
schema: {
type: 'integer',
minimum: 0,
default: 0,
},
description: 'Zero-based leaderboard page number.',
} satisfies OpenApiParameter;

const AimScoreRequestBody = {
required: true,
content: {
Expand Down Expand Up @@ -155,10 +144,13 @@ const AimOverallScore = z.object({
const AimOverallLeaderboardResponse = z.object({
data: z.object({
scores: z.array(AimOverallScore),
total: z.number().int().min(0),
}),
});

const CountAll = sql<number>`count(*)`.mapWith(Number);
const DistinctPlayerCount =
sql<number>`count(distinct ${aim_scores.steam_id})`.mapWith(Number);
const TotalScoreExpression = sql`sum(${aim_scores.score})`;
const TotalScore = TotalScoreExpression.mapWith(Number).as('total_score');
const ScenariosCompletedExpression = sql`count(*)`;
Expand Down Expand Up @@ -346,19 +338,18 @@ app.get(
'Fetches a paginated leaderboard for a single aim scenario ordered by score descending, then accuracy descending, then average reaction time ascending.',
AimScenarioLeaderboardResponse,
{
parameters: [ScenarioPathParameter, PageQueryParameter],
parameters: [ScenarioPathParameter, ...LeaderboardPaginationParameters],
}
),
zValidator('param', ScenarioParamInput),
zValidator('query', PaginationQuery),
zValidator('query', LeaderboardPaginationQuery),
async (c) => {
const parsedScenario = getValidatedScenario(c.req.valid('param').scenario);
if (!parsedScenario) {
return c.json({ error: 'Invalid aim scenario.' }, 400);
}

const { page } = c.req.valid('query');
const offset = page * 10;
const pagination = c.req.valid('query');

try {
const [scores, totalRows] = await Promise.all([
Expand All @@ -381,8 +372,8 @@ app.get(
asc(aim_scores.avg_reaction_ms),
asc(aim_scores.steam_id)
)
.limit(10)
.offset(offset),
.limit(pagination.limit)
.offset(get_leaderboard_offset(pagination)),
db
.select({
count: CountAll,
Expand All @@ -394,7 +385,10 @@ app.get(
return c.json({
data: {
scores: scores.map((score, index) =>
formatAimScoreRow(score, offset + index + 1)
formatAimScoreRow(
score,
get_leaderboard_offset(pagination) + index + 1
)
),
total: totalRows[0].count,
},
Expand All @@ -410,33 +404,42 @@ app.get(
'/overall',
describe_leaderboard_route(
'Fetches the overall aim leaderboard by aggregating each player’s best score from every completed scenario.',
AimOverallLeaderboardResponse
AimOverallLeaderboardResponse,
{ parameters: LeaderboardPaginationParameters }
),
zValidator('query', LeaderboardPaginationQuery),
async (c) => {
const pagination = c.req.valid('query');

try {
const scores = await db
.select({
steam_id: aim_scores.steam_id,
username: DeterministicUsername,
total_score: TotalScore,
scenarios_completed: ScenariosCompleted,
accuracy: AccuracyAverage,
avg_reaction_ms: AvgReaction,
})
.from(aim_scores)
.groupBy(aim_scores.steam_id)
.orderBy(
desc(TotalScoreExpression),
desc(ScenariosCompletedExpression),
desc(AccuracyAverageExpression),
asc(AvgReactionExpression),
asc(aim_scores.steam_id)
)
.limit(10);
const [scores, totals] = await Promise.all([
db
.select({
steam_id: aim_scores.steam_id,
username: DeterministicUsername,
total_score: TotalScore,
scenarios_completed: ScenariosCompleted,
accuracy: AccuracyAverage,
avg_reaction_ms: AvgReaction,
})
.from(aim_scores)
.groupBy(aim_scores.steam_id)
.orderBy(
desc(TotalScoreExpression),
desc(ScenariosCompletedExpression),
desc(AccuracyAverageExpression),
asc(AvgReactionExpression),
asc(aim_scores.steam_id)
)
.limit(pagination.limit)
.offset(get_leaderboard_offset(pagination)),
db.select({ count: DistinctPlayerCount }).from(aim_scores),
]);

return c.json({
data: {
scores,
total: totals[0].count,
},
});
} catch (e) {
Expand Down
Loading
Loading