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
4 changes: 3 additions & 1 deletion docs/domain/seasons-ranks-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ reprojection query sums `achievements.earned_points` for the user's unlocked
achievements matching the rank's ban list name as a label
(`ACHIEVEMENT_POINTS_QUERY`, same file, lines 30-33), and both the profile and
leaderboard queries attach the matching achievement objects
(`UserStatsPostgresRepository.ts:41-59`, `171-185`).
(`UserStatsPostgresRepository.ts:41-59`, `171-185`). Each achievement object
carries `code`, the stable key of a gameplay achievement, or `null` for
tournament trophies.

### Player of the week

Expand Down
37 changes: 37 additions & 0 deletions src/modules/achievements/application/GetAchievementCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AchievementCatalogRepository } from "../domain/AchievementCatalogRepository";
import { AchievementRequirement, requirementFromCode } from "../domain/AchievementRequirement";
import { catalogVersion } from "../domain/catalogVersion";

export type AchievementCatalog = {
version: string;
items: Array<{
id: number;
code: string;
icon: string;
name: string;
description: string;
earnedPoints: number;
requirement: AchievementRequirement | null;
}>;
};

export class GetAchievementCatalog {
constructor(private readonly repository: AchievementCatalogRepository) {}

async run(): Promise<AchievementCatalog> {
const rows = await this.repository.findGameplay();

return {
version: catalogVersion(rows),
items: rows.map((row) => ({
id: row.id,
code: row.code,
icon: row.icon,
name: row.name,
description: row.description,
earnedPoints: row.earnedPoints,
requirement: requirementFromCode(row.code),
})),
};
}
}
65 changes: 65 additions & 0 deletions src/modules/achievements/application/GetUserAchievementRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NotFoundError } from "../../../shared/errors/NotFoundError";
import { currentStreak } from "../domain/currentStreak";
import { Ladder, ladderWins } from "../domain/ladderWins";
import { UserAchievementRecordRepository } from "../domain/UserAchievementRecordRepository";

export type UserAchievementRecord = {
userId: string;
asOf: string;
scope: "lifetime-ranked";
achievements: Array<{
awardId: string;
id: number;
code: string | null;
icon: string;
name: string;
description: string;
labels: string[];
unlockedAt: string;
season: number;
earnedPoints: number;
}>;
progress: {
rankedWins: number;
rankedMatches: number;
currentStreak: number;
hasRankedWinByLadder: Record<Ladder, boolean>;
};
};

export class GetUserAchievementRecord {
constructor(
private readonly repository: UserAchievementRecordRepository,
private readonly now: () => Date = () => new Date(),
) {}

async run(userId: string): Promise<UserAchievementRecord> {
if (!(await this.repository.userExists(userId))) {
throw new NotFoundError(`User with id ${userId} not found.`);
}

const [awards, totals, winningBanLists, outcomes, groups] = await Promise.all([
this.repository.findAwards(userId),
this.repository.findTotals(userId),
this.repository.findWinningBanLists(userId),
this.repository.findLatestOutcomes(userId),
this.repository.findLadderGroups(),
]);

return {
userId,
asOf: this.now().toISOString(),
scope: "lifetime-ranked",
achievements: awards.map((award) => ({
...award,
unlockedAt: award.unlockedAt.toISOString(),
})),
progress: {
rankedWins: totals.rankedWins,
rankedMatches: totals.rankedMatches,
currentStreak: currentStreak(outcomes),
hasRankedWinByLadder: ladderWins(winningBanLists, groups),
},
};
}
}
14 changes: 14 additions & 0 deletions src/modules/achievements/domain/AchievementCatalogRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type CatalogRow = {
id: number;
code: string;
icon: string;
name: string;
description: string;
earnedPoints: number;
updatedAt: Date;
};

export interface AchievementCatalogRepository {
/** Gameplay achievements (those with a code), ordered by id. */
findGameplay(): Promise<CatalogRow[]>;
}
41 changes: 41 additions & 0 deletions src/modules/achievements/domain/AchievementRequirement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type RequirementMetric = "rankedWins" | "rankedMatches" | "currentStreak" | "ladderFirstWin";

export type AchievementRequirement = {
metric: RequirementMetric;
target: number;
ladder?: string;
};

const COUNTER_CODES: Array<{ pattern: RegExp; metric: RequirementMetric }> = [
{ pattern: /^wins_(\d+)$/, metric: "rankedWins" },
{ pattern: /^streak_(\d+)$/, metric: "currentStreak" },
{ pattern: /^matches_(\d+)$/, metric: "rankedMatches" },
];

const FORMAT_FIRST_WIN_PREFIX = "format_first_win:";

/**
* The requirement a gameplay achievement code stands for, or null for a code
* this API does not know how to measure.
*/
export function requirementFromCode(code: string): AchievementRequirement | null {
if (code === "first_win") {
return { metric: "rankedWins", target: 1 };
}

if (code.startsWith(FORMAT_FIRST_WIN_PREFIX)) {
const ladder = code.slice(FORMAT_FIRST_WIN_PREFIX.length);

return ladder === "" ? null : { metric: "ladderFirstWin", target: 1, ladder };
}

for (const { pattern, metric } of COUNTER_CODES) {
const target = Number(pattern.exec(code)?.[1]);

if (target > 0) {
return { metric, target };
}
}

return null;
}
26 changes: 26 additions & 0 deletions src/modules/achievements/domain/UserAchievementRecordRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { LadderGroup } from "./ladderWins";

export type UserAward = {
awardId: string;
id: number;
code: string | null;
icon: string;
name: string;
description: string;
labels: string[];
unlockedAt: Date;
season: number;
earnedPoints: number;
};

export interface UserAchievementRecordRepository {
userExists(userId: string): Promise<boolean>;
findAwards(userId: string): Promise<UserAward[]>;
/** Lifetime totals over eligible ranked matches (not annulled, not deleted). */
findTotals(userId: string): Promise<{ rankedMatches: number; rankedWins: number }>;
/** Distinct ban list names the user has won an eligible match on. */
findWinningBanLists(userId: string): Promise<string[]>;
/** Outcomes of the latest eligible matches, most recent first, bounded. */
findLatestOutcomes(userId: string): Promise<boolean[]>;
findLadderGroups(): Promise<LadderGroup[]>;
}
14 changes: 14 additions & 0 deletions src/modules/achievements/domain/catalogVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createHash } from "node:crypto";

type VersionedRow = { id: number; code: string; updatedAt: Date | string };

/** Stable digest of the ordered `id:code:updated_at` tuples of a catalog. */
export function catalogVersion(rows: VersionedRow[]): string {
const hash = createHash("sha256");

for (const row of rows) {
hash.update(`${row.id}:${row.code}:${new Date(row.updatedAt).toISOString()}\n`);
}

return hash.digest("hex");
}
6 changes: 6 additions & 0 deletions src/modules/achievements/domain/currentStreak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Wins at the head of outcomes ordered from the most recent match to the oldest. */
export function currentStreak(winnersLatestFirst: boolean[]): number {
const firstLoss = winnersLatestFirst.indexOf(false);

return firstLoss === -1 ? winnersLatestFirst.length : firstLoss;
}
35 changes: 35 additions & 0 deletions src/modules/achievements/domain/ladderWins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { RankMemberPatterns } from "../../../shared/ranks/RankMemberPatterns";

export const LADDERS = ["TCG", "OCG", "Edison", "JTP", "Genesys"] as const;

export type Ladder = (typeof LADDERS)[number];

/** An enabled group rank and the ban list patterns it aggregates. */
export type LadderGroup = { name: string; patterns: string[] };

/**
* Which ladders the player has won in. A played ban list feeds its own rank
* (JTP and Genesys are ban lists) and every group whose patterns match it
* (a dated "2026.09 TCG" list feeds the TCG group).
*/
export function ladderWins(
winningBanListNames: string[],
groups: LadderGroup[],
): Record<Ladder, boolean> {
const reached = new Set<string>();

for (const banListName of winningBanListNames) {
reached.add(banListName);

for (const group of groups) {
if (group.patterns.some((pattern) => RankMemberPatterns.matches(pattern, banListName))) {
reached.add(group.name);
}
}
}

return Object.fromEntries(LADDERS.map((ladder) => [ladder, reached.has(ladder)])) as Record<
Ladder,
boolean
>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { dataSource } from "../../../evolution-types/src/data-source";
import { AchievementCatalogRepository, CatalogRow } from "../domain/AchievementCatalogRepository";

export class AchievementCatalogPostgresRepository implements AchievementCatalogRepository {
async findGameplay(): Promise<CatalogRow[]> {
const rows: Array<{
id: number;
code: string;
icon: string;
name: string;
description: string;
earned_points: number;
updated_at: Date;
}> = await dataSource.query(
`SELECT id, code, icon, name, description, earned_points, updated_at
FROM achievements
WHERE code IS NOT NULL AND deleted_at IS NULL
ORDER BY id`,
);

return rows.map((row) => ({
id: Number(row.id),
code: row.code,
icon: row.icon,
name: row.name,
description: row.description,
earnedPoints: Number(row.earned_points),
updatedAt: new Date(row.updated_at),
}));
}
}
67 changes: 67 additions & 0 deletions src/modules/achievements/infrastructure/AchievementsSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { t } from "elysia";

export const AchievementRequirementSchema = t.Object({
metric: t.Union([
t.Literal("rankedWins"),
t.Literal("rankedMatches"),
t.Literal("currentStreak"),
t.Literal("ladderFirstWin"),
]),
target: t.Number({ description: "Counter value (or 1 for a first win) that unlocks the award" }),
ladder: t.Optional(t.String({ description: "Format ladder of a ladderFirstWin requirement" })),
});

export const AchievementCatalogSchema = t.Object({
version: t.String({ description: "Stable digest of the catalog; also sent as the ETag" }),
items: t.Array(
t.Object({
id: t.Number(),
code: t.String({ description: "Stable key of the gameplay achievement" }),
icon: t.String(),
name: t.String(),
description: t.String(),
earnedPoints: t.Number(),
requirement: t.Nullable(AchievementRequirementSchema, {
description: "Null when the code is not one this API can measure",
}),
}),
),
});

const LadderFlagsSchema = t.Object({
TCG: t.Boolean(),
OCG: t.Boolean(),
Edison: t.Boolean(),
JTP: t.Boolean(),
Genesys: t.Boolean(),
});

export const UserAchievementRecordSchema = t.Object({
userId: t.String(),
asOf: t.String({ description: "ISO 8601 timestamp of the progress snapshot" }),
scope: t.Literal("lifetime-ranked"),
achievements: t.Array(
t.Object({
awardId: t.String({ description: "Identity of this award (user_achievements.id)" }),
id: t.Number({ description: "Catalog id of the achievement" }),
code: t.Nullable(
t.String({
description: "Stable key of a gameplay achievement; null for tournament trophies",
}),
),
icon: t.String(),
name: t.String(),
description: t.String(),
labels: t.Array(t.String()),
unlockedAt: t.String({ description: "ISO 8601 timestamp" }),
season: t.Number(),
earnedPoints: t.Number(),
}),
),
progress: t.Object({
rankedWins: t.Number({ description: "Lifetime ranked wins" }),
rankedMatches: t.Number({ description: "Lifetime ranked matches" }),
currentStreak: t.Number({ description: "Consecutive ranked wins since the last loss" }),
hasRankedWinByLadder: LadderFlagsSchema,
}),
});
Loading