From b33a2eb541104718172fd9f5b59714afe8829deb Mon Sep 17 00:00:00 2001 From: Diango Date: Fri, 9 Oct 2026 14:24:17 -0400 Subject: [PATCH 1/4] feat(stats): expose the achievement code on profile and leaderboard rows --- docs/domain/seasons-ranks-points.md | 4 +++- src/evolution-types | 2 +- src/modules/stats/domain/UserStats.ts | 1 + .../stats/infrastructure/StatsSchemas.ts | 3 +++ .../UserStatsPostgresRepository.ts | 2 ++ .../domain/UserAchievement.ts | 12 ++++++++++++ .../stats/infrastructure/StatsSchemas.test.ts | 19 ++++++++++++++++++- 7 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/domain/seasons-ranks-points.md b/docs/domain/seasons-ranks-points.md index d0b964b..9e61fbb 100644 --- a/docs/domain/seasons-ranks-points.md +++ b/docs/domain/seasons-ranks-points.md @@ -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 diff --git a/src/evolution-types b/src/evolution-types index 3f0c29d..3a2d015 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 3f0c29df7f4d2a007cf6d0d88144ecde2606cc07 +Subproject commit 3a2d0156f126be01f55e8b6cbb6574a58096d1d9 diff --git a/src/modules/stats/domain/UserStats.ts b/src/modules/stats/domain/UserStats.ts index 8be3fbe..77c25ce 100644 --- a/src/modules/stats/domain/UserStats.ts +++ b/src/modules/stats/domain/UserStats.ts @@ -8,6 +8,7 @@ type UserAchievementParams = { unlockedAt: string; description: string; earnedPoints: number; + code?: string | null; }; export type RankType = "banlist" | "group" | "global"; diff --git a/src/modules/stats/infrastructure/StatsSchemas.ts b/src/modules/stats/infrastructure/StatsSchemas.ts index 2467c49..59d2337 100644 --- a/src/modules/stats/infrastructure/StatsSchemas.ts +++ b/src/modules/stats/infrastructure/StatsSchemas.ts @@ -19,6 +19,9 @@ export const UserAchievementSchema = t.Object({ unlockedAt: t.String({ description: "ISO 8601 timestamp" }), description: t.String(), earnedPoints: t.Number(), + code: t.Nullable( + t.String({ description: "Stable key of a gameplay achievement; null for tournament trophies" }), + ), }); export const RatingSummarySchema = t.Object({ diff --git a/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts b/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts index 12c3720..db11aa8 100644 --- a/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts +++ b/src/modules/stats/infrastructure/UserStatsPostgresRepository.ts @@ -47,6 +47,7 @@ export class UserStatsPostgresRepository implements UserStatsRepository { 'description', a.description, 'icon', a.icon, 'earnedPoints', a.earned_points, + 'code', a.code, 'unlockedAt', ua.unlocked_at, 'labels', ua.labels ) @@ -177,6 +178,7 @@ export class UserStatsPostgresRepository implements UserStatsRepository { 'description', a.description, 'icon', a.icon, 'earnedPoints', a.earned_points, + 'code', a.code, 'unlockedAt', ua.unlocked_at, 'labels', ua.labels ) diff --git a/src/modules/stats/user-achievement/domain/UserAchievement.ts b/src/modules/stats/user-achievement/domain/UserAchievement.ts index 010e06d..efe805f 100644 --- a/src/modules/stats/user-achievement/domain/UserAchievement.ts +++ b/src/modules/stats/user-achievement/domain/UserAchievement.ts @@ -6,6 +6,7 @@ export class UserAchievement { public readonly unlockedAt: Date; public readonly description: string; public readonly earnedPoints: number; + public readonly code: string | null; private constructor({ id, @@ -15,6 +16,7 @@ export class UserAchievement { unlockedAt, description, earnedPoints, + code = null, }: { id: number; icon: string; @@ -23,6 +25,7 @@ export class UserAchievement { unlockedAt: Date; description: string; earnedPoints: number; + code?: string | null; }) { this.id = id; this.icon = icon; @@ -31,6 +34,7 @@ export class UserAchievement { this.unlockedAt = new Date(unlockedAt); this.description = description; this.earnedPoints = earnedPoints; + this.code = code; } static create({ @@ -41,6 +45,7 @@ export class UserAchievement { unlockedAt, description, earnedPoints, + code = null, }: { id: number; icon: string; @@ -49,6 +54,7 @@ export class UserAchievement { unlockedAt: Date | string; description: string; earnedPoints: number; + code?: string | null; }): UserAchievement { return new UserAchievement({ id, @@ -58,6 +64,7 @@ export class UserAchievement { unlockedAt: new Date(unlockedAt), description, earnedPoints, + code, }); } @@ -69,6 +76,7 @@ export class UserAchievement { unlockedAt, description, earnedPoints, + code = null, }: { id: number; icon: string; @@ -77,6 +85,7 @@ export class UserAchievement { unlockedAt: Date | string; description: string; earnedPoints: number; + code?: string | null; }): UserAchievement { return new UserAchievement({ id, @@ -86,6 +95,7 @@ export class UserAchievement { unlockedAt: new Date(unlockedAt), description, earnedPoints, + code, }); } @@ -97,6 +107,7 @@ export class UserAchievement { unlockedAt: string; description: string; earnedPoints: number; + code: string | null; } { return { id: this.id, @@ -106,6 +117,7 @@ export class UserAchievement { unlockedAt: this.unlockedAt.toISOString(), description: this.description, earnedPoints: this.earnedPoints, + code: this.code, }; } } diff --git a/tests/unit/modules/stats/infrastructure/StatsSchemas.test.ts b/tests/unit/modules/stats/infrastructure/StatsSchemas.test.ts index 1d20672..230f584 100644 --- a/tests/unit/modules/stats/infrastructure/StatsSchemas.test.ts +++ b/tests/unit/modules/stats/infrastructure/StatsSchemas.test.ts @@ -50,8 +50,11 @@ const achievement = { unlockedAt: "2026-09-01T10:00:00.000Z", description: "Win a ranked duel", earnedPoints: 5, + code: null as string | null, }; +const gameplayAchievement = { ...achievement, id: 8, code: "first_win" }; + const profile = UserStats.from({ userId: "user-1", username: "player1", @@ -60,7 +63,7 @@ const profile = UserStats.from({ losses: 21, winRate: pgFloat(61.82), position: pgBigint(12), - achievements: [achievement], + achievements: [achievement, gameplayAchievement], ratings: RatingMembers.attach( [ { @@ -129,6 +132,20 @@ describe("UserStatsSchema", () => { expect(Value.Check(UserStatsSchema, stats)).toBe(true); }); + it("carries the achievement code, null for tournament trophies, and rejects a non-string code", async () => { + const stats = wire( + await new UserStatsFinder(statsRepository, tierLookup).find({ userId: "user-1", season: 7 }), + ) as { achievements: { id: number; code: string | null }[] }; + + expect(stats.achievements.map((a) => [a.id, a.code])).toEqual([ + [7, null], + [8, "first_win"], + ]); + expect(Value.Check(UserStatsSchema, stats)).toBe(true); + const broken = { ...stats, achievements: [{ ...stats.achievements[0], code: 5 }] }; + expect(Value.Check(UserStatsSchema, broken)).toBe(false); + }); + it("rejects a rating without its tier key", () => { expect(Value.Check(UserStatsSchema, wire(profile.toJson()))).toBe(false); }); From 8ad44c1bcf873e22fc0b6794eeb1843fb73387b8 Mon Sep 17 00:00:00 2001 From: Diango Date: Fri, 9 Oct 2026 14:46:02 -0400 Subject: [PATCH 2/4] chore(types): take the achievements and history index migrations --- src/evolution-types | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evolution-types b/src/evolution-types index 3a2d015..fe71664 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 3a2d0156f126be01f55e8b6cbb6574a58096d1d9 +Subproject commit fe71664495abb8311c28bc34c86b884671039b42 From bc4e9973913e8bad1542128e7a805cbf9968299f Mon Sep 17 00:00:00 2001 From: Diango Date: Fri, 9 Oct 2026 15:18:15 -0400 Subject: [PATCH 3/4] feat(achievements): expose the gameplay achievement catalog --- .../application/GetAchievementCatalog.ts | 37 +++++++++++ .../domain/AchievementCatalogRepository.ts | 14 ++++ .../domain/AchievementRequirement.ts | 41 ++++++++++++ .../achievements/domain/catalogVersion.ts | 14 ++++ .../AchievementCatalogPostgresRepository.ts | 31 +++++++++ .../infrastructure/AchievementsSchemas.ts | 29 +++++++++ src/server/routes/achievements-router.ts | 64 +++++++++++++++++++ src/server/server.ts | 2 + src/server/swagger.ts | 3 +- .../application/GetAchievementCatalog.test.ts | 64 +++++++++++++++++++ .../domain/AchievementRequirement.test.ts | 38 +++++++++++ .../domain/catalogVersion.test.ts | 24 +++++++ .../server/routes/achievements-router.test.ts | 52 +++++++++++++++ 13 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 src/modules/achievements/application/GetAchievementCatalog.ts create mode 100644 src/modules/achievements/domain/AchievementCatalogRepository.ts create mode 100644 src/modules/achievements/domain/AchievementRequirement.ts create mode 100644 src/modules/achievements/domain/catalogVersion.ts create mode 100644 src/modules/achievements/infrastructure/AchievementCatalogPostgresRepository.ts create mode 100644 src/modules/achievements/infrastructure/AchievementsSchemas.ts create mode 100644 src/server/routes/achievements-router.ts create mode 100644 tests/unit/modules/achievements/application/GetAchievementCatalog.test.ts create mode 100644 tests/unit/modules/achievements/domain/AchievementRequirement.test.ts create mode 100644 tests/unit/modules/achievements/domain/catalogVersion.test.ts create mode 100644 tests/unit/server/routes/achievements-router.test.ts diff --git a/src/modules/achievements/application/GetAchievementCatalog.ts b/src/modules/achievements/application/GetAchievementCatalog.ts new file mode 100644 index 0000000..c2a8e90 --- /dev/null +++ b/src/modules/achievements/application/GetAchievementCatalog.ts @@ -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 { + 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), + })), + }; + } +} diff --git a/src/modules/achievements/domain/AchievementCatalogRepository.ts b/src/modules/achievements/domain/AchievementCatalogRepository.ts new file mode 100644 index 0000000..587cbf0 --- /dev/null +++ b/src/modules/achievements/domain/AchievementCatalogRepository.ts @@ -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; +} diff --git a/src/modules/achievements/domain/AchievementRequirement.ts b/src/modules/achievements/domain/AchievementRequirement.ts new file mode 100644 index 0000000..6995b9a --- /dev/null +++ b/src/modules/achievements/domain/AchievementRequirement.ts @@ -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; +} diff --git a/src/modules/achievements/domain/catalogVersion.ts b/src/modules/achievements/domain/catalogVersion.ts new file mode 100644 index 0000000..50e0cde --- /dev/null +++ b/src/modules/achievements/domain/catalogVersion.ts @@ -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"); +} diff --git a/src/modules/achievements/infrastructure/AchievementCatalogPostgresRepository.ts b/src/modules/achievements/infrastructure/AchievementCatalogPostgresRepository.ts new file mode 100644 index 0000000..66b674e --- /dev/null +++ b/src/modules/achievements/infrastructure/AchievementCatalogPostgresRepository.ts @@ -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 { + 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), + })); + } +} diff --git a/src/modules/achievements/infrastructure/AchievementsSchemas.ts b/src/modules/achievements/infrastructure/AchievementsSchemas.ts new file mode 100644 index 0000000..2d328db --- /dev/null +++ b/src/modules/achievements/infrastructure/AchievementsSchemas.ts @@ -0,0 +1,29 @@ +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", + }), + }), + ), +}); diff --git a/src/server/routes/achievements-router.ts b/src/server/routes/achievements-router.ts new file mode 100644 index 0000000..1f6ffad --- /dev/null +++ b/src/server/routes/achievements-router.ts @@ -0,0 +1,64 @@ +import { Elysia } from "elysia"; + +import { GetAchievementCatalog } from "../../modules/achievements/application/GetAchievementCatalog"; +import { AchievementCatalogPostgresRepository } from "../../modules/achievements/infrastructure/AchievementCatalogPostgresRepository"; +import { AchievementCatalogSchema } from "../../modules/achievements/infrastructure/AchievementsSchemas"; +import { emptyOk, jsonOk } from "../openapi/responses"; + +type Dependencies = { getCatalog: GetAchievementCatalog }; + +const matchesEtag = (header: string | null, etag: string): boolean => + header !== null && + header + .split(",") + .map((candidate) => candidate.trim().replace(/^W\//, "")) + .some((candidate) => candidate === "*" || candidate === etag); + +export const createAchievementsRouter = ({ getCatalog }: Dependencies) => + new Elysia({ prefix: "/achievements" }).get( + "/", + async ({ request, set }) => { + const catalog = await getCatalog.run(); + const etag = `"${catalog.version}"`; + + set.headers.etag = etag; + set.headers["cache-control"] = "public, no-cache"; + + if (matchesEtag(request.headers.get("if-none-match"), etag)) { + set.status = 304; + + return ""; + } + + return catalog; + }, + { + detail: { + tags: ["Achievements"], + summary: "Get the gameplay achievement catalog", + description: + "Lists the gameplay achievements with the requirement each one measures. The version is a digest of the catalog and doubles as the ETag: send it back in If-None-Match to receive 304 while nothing changed. Public: no credentials required.", + responses: { + 200: jsonOk(AchievementCatalogSchema, "Catalog retrieved successfully", { + version: "3f1c0a5e0d7b4c9a8e2f6b1d4a7c9e0f2b5d8a1c4e7f0a3b6c9d2e5f8a1b4c7d", + items: [ + { + id: 1001, + code: "first_win", + icon: "sword", + name: "First Blood", + description: "Win your first ranked match", + earnedPoints: 0, + requirement: { metric: "rankedWins", target: 1 }, + }, + ], + }), + 304: emptyOk("The catalog still matches the version in If-None-Match"), + }, + }, + }, + ); + +export const achievementsRouter = createAchievementsRouter({ + getCatalog: new GetAchievementCatalog(new AchievementCatalogPostgresRepository()), +}); diff --git a/src/server/server.ts b/src/server/server.ts index 569f07b..e88add6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -8,6 +8,7 @@ import { InvalidArgumentError } from "../shared/errors/InvalidArgumentError"; import { NotFoundError } from "../shared/errors/NotFoundError"; import { Logger } from "../shared/logger/domain/Logger"; +import { achievementsRouter } from "./routes/achievements-router"; import { banListRouter } from "./routes/ban-list-router"; import { cosmeticsRouter } from "./routes/cosmetics-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; @@ -69,6 +70,7 @@ export function mountApiV1Routes(app: Elysia) { return app.group("/api/v1", (group: Elysia) => { return group .use(userRouter) + .use(achievementsRouter) .use(leaderboardRouter) .use(rankedTiersRouter) .use(banListRouter) diff --git a/src/server/swagger.ts b/src/server/swagger.ts index 1d88e01..f30ff97 100644 --- a/src/server/swagger.ts +++ b/src/server/swagger.ts @@ -27,6 +27,7 @@ const TAGS = [ description: "Season rankings, player of the week and the ranked tier catalog", }, { name: "Ranked", description: "Tickets that admit authenticated players into ranked games" }, + { name: "Achievements", description: "Gameplay achievement catalog and per-player records" }, { name: "Statistics", description: "Global and historical duel statistics" }, { name: "Ban Lists", description: "Ban lists played during each season" }, { name: "Cosmetics", description: "Cosmetics catalog, entitlements and player loadouts" }, @@ -43,7 +44,7 @@ const TAGS = [ const TAG_GROUPS = [ { name: "Account", tags: ["Authentication", "User Management"] }, - { name: "Ranked", tags: ["Leaderboard", "Ranked", "Statistics", "Ban Lists"] }, + { name: "Ranked", tags: ["Leaderboard", "Ranked", "Statistics", "Ban Lists", "Achievements"] }, { name: "Cosmetics", tags: ["Cosmetics"] }, { name: "Administration", tags: ["User Bans", "Match Moderation", "Cosmetics Admin"] }, ]; diff --git a/tests/unit/modules/achievements/application/GetAchievementCatalog.test.ts b/tests/unit/modules/achievements/application/GetAchievementCatalog.test.ts new file mode 100644 index 0000000..51fd01b --- /dev/null +++ b/tests/unit/modules/achievements/application/GetAchievementCatalog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; + +import { GetAchievementCatalog } from "../../../../../src/modules/achievements/application/GetAchievementCatalog"; +import { AchievementCatalogRepository } from "../../../../../src/modules/achievements/domain/AchievementCatalogRepository"; + +const repositoryWith = ( + rows: Awaited>, +): AchievementCatalogRepository => ({ findGameplay: async () => rows }); + +const base = { + icon: "sword", + description: "d", + earnedPoints: 0, + updatedAt: new Date("2026-10-09T00:00:00.000Z"), +}; + +describe("GetAchievementCatalog", () => { + it("returns the version and the items with their derived requirement", async () => { + const catalog = await new GetAchievementCatalog( + repositoryWith([ + { ...base, id: 1001, code: "first_win", name: "First Blood" }, + { ...base, id: 1005, code: "format_first_win:TCG", name: "TCG" }, + ]), + ).run(); + + expect(catalog.version).toMatch(/^[0-9a-f]{64}$/); + expect(catalog.items).toEqual([ + { + id: 1001, + code: "first_win", + icon: "sword", + name: "First Blood", + description: "d", + earnedPoints: 0, + requirement: { metric: "rankedWins", target: 1 }, + }, + { + id: 1005, + code: "format_first_win:TCG", + icon: "sword", + name: "TCG", + description: "d", + earnedPoints: 0, + requirement: { metric: "ladderFirstWin", target: 1, ladder: "TCG" }, + }, + ]); + }); + + it("keeps an unknown code with a null requirement instead of failing", async () => { + const catalog = await new GetAchievementCatalog( + repositoryWith([{ ...base, id: 9, code: "future_code", name: "Future" }]), + ).run(); + + expect(catalog.items[0].requirement).toBeNull(); + }); + + it("returns an empty catalog with a stable version", async () => { + const first = await new GetAchievementCatalog(repositoryWith([])).run(); + const second = await new GetAchievementCatalog(repositoryWith([])).run(); + + expect(first.items).toEqual([]); + expect(first.version).toBe(second.version); + }); +}); diff --git a/tests/unit/modules/achievements/domain/AchievementRequirement.test.ts b/tests/unit/modules/achievements/domain/AchievementRequirement.test.ts new file mode 100644 index 0000000..976852d --- /dev/null +++ b/tests/unit/modules/achievements/domain/AchievementRequirement.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; + +import { + type AchievementRequirement, + requirementFromCode, +} from "../../../../../src/modules/achievements/domain/AchievementRequirement"; + +describe("requirementFromCode", () => { + it.each<[string, AchievementRequirement]>([ + ["first_win", { metric: "rankedWins", target: 1 }], + ["wins_10", { metric: "rankedWins", target: 10 }], + ["wins_50", { metric: "rankedWins", target: 50 }], + ["wins_100", { metric: "rankedWins", target: 100 }], + ["streak_3", { metric: "currentStreak", target: 3 }], + ["streak_5", { metric: "currentStreak", target: 5 }], + ["streak_10", { metric: "currentStreak", target: 10 }], + ["matches_100", { metric: "rankedMatches", target: 100 }], + ["format_first_win:TCG", { metric: "ladderFirstWin", target: 1, ladder: "TCG" }], + ["format_first_win:OCG", { metric: "ladderFirstWin", target: 1, ladder: "OCG" }], + ["format_first_win:Edison", { metric: "ladderFirstWin", target: 1, ladder: "Edison" }], + ["format_first_win:JTP", { metric: "ladderFirstWin", target: 1, ladder: "JTP" }], + ["format_first_win:Genesys", { metric: "ladderFirstWin", target: 1, ladder: "Genesys" }], + ])("derives the requirement of %s", (code, expected) => { + expect(requirementFromCode(code)).toEqual(expected); + }); + + it.each([ + "tournament_champion", + "wins_", + "wins_abc", + "wins_0", + "streak_-1", + "format_first_win:", + "", + ])("returns null for the unknown code %p", (code) => { + expect(requirementFromCode(code)).toBeNull(); + }); +}); diff --git a/tests/unit/modules/achievements/domain/catalogVersion.test.ts b/tests/unit/modules/achievements/domain/catalogVersion.test.ts new file mode 100644 index 0000000..4bb88d8 --- /dev/null +++ b/tests/unit/modules/achievements/domain/catalogVersion.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; + +import { catalogVersion } from "../../../../../src/modules/achievements/domain/catalogVersion"; + +const row = (id: number, code: string, updatedAt: string) => ({ id, code, updatedAt }); + +describe("catalogVersion", () => { + it("is a stable sha256 hex digest of the ordered id, code and updated_at tuples", () => { + const rows = [row(1001, "first_win", "2026-10-09T00:00:00.000Z")]; + + expect(catalogVersion(rows)).toMatch(/^[0-9a-f]{64}$/); + expect(catalogVersion(rows)).toBe(catalogVersion([...rows])); + }); + + it("changes when a row is renamed, retimed or reordered", () => { + const a = row(1, "first_win", "2026-10-09T00:00:00.000Z"); + const b = row(2, "wins_10", "2026-10-09T00:00:00.000Z"); + const base = catalogVersion([a, b]); + + expect(catalogVersion([a, row(2, "wins_50", b.updatedAt)])).not.toBe(base); + expect(catalogVersion([a, row(2, "wins_10", "2026-10-10T00:00:00.000Z")])).not.toBe(base); + expect(catalogVersion([b, a])).not.toBe(base); + }); +}); diff --git a/tests/unit/server/routes/achievements-router.test.ts b/tests/unit/server/routes/achievements-router.test.ts new file mode 100644 index 0000000..453061c --- /dev/null +++ b/tests/unit/server/routes/achievements-router.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { Value } from "@sinclair/typebox/value"; + +import { GetAchievementCatalog } from "../../../../src/modules/achievements/application/GetAchievementCatalog"; +import { AchievementCatalogSchema } from "../../../../src/modules/achievements/infrastructure/AchievementsSchemas"; +import { createAchievementsRouter } from "../../../../src/server/routes/achievements-router"; + +const router = createAchievementsRouter({ + getCatalog: new GetAchievementCatalog({ + findGameplay: async () => [ + { + id: 1001, + code: "first_win", + icon: "sword", + name: "First Blood", + description: "Win your first ranked match", + earnedPoints: 0, + updatedAt: new Date("2026-10-09T00:00:00.000Z"), + }, + ], + }), +}); + +const get = (headers: Record = {}) => + router.handle(new Request("http://localhost/achievements", { headers })); + +describe("GET /achievements", () => { + it("serves the catalog without credentials, with an ETag equal to the version", async () => { + const response = await get(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(Value.Check(AchievementCatalogSchema, body)).toBe(true); + expect(body.items[0].requirement).toEqual({ metric: "rankedWins", target: 1 }); + expect(response.headers.get("etag")).toBe(`"${body.version}"`); + }); + + it("answers 304 with no body when If-None-Match carries the current version", async () => { + const { version } = await (await get()).json(); + const response = await get({ "if-none-match": `"${version}"` }); + + expect(response.status).toBe(304); + expect(await response.text()).toBe(""); + expect(response.headers.get("etag")).toBe(`"${version}"`); + }); + + it("answers 200 when If-None-Match is stale", async () => { + const response = await get({ "if-none-match": '"stale"' }); + + expect(response.status).toBe(200); + }); +}); From f80067b21f5fe7209dd43ededb8a354dbeb255d3 Mon Sep 17 00:00:00 2001 From: Diango Date: Fri, 9 Oct 2026 15:19:14 -0400 Subject: [PATCH 4/4] feat(achievements): expose a user's achievement record with lifetime progress --- .../application/GetUserAchievementRecord.ts | 65 +++++++++++ .../domain/UserAchievementRecordRepository.ts | 26 +++++ .../achievements/domain/currentStreak.ts | 6 + src/modules/achievements/domain/ladderWins.ts | 35 ++++++ .../infrastructure/AchievementsSchemas.ts | 38 +++++++ ...UserAchievementRecordPostgresRepository.ts | 105 ++++++++++++++++++ src/server/routes/achievements-router.ts | 69 +++++++++++- src/server/server.ts | 3 +- .../GetUserAchievementRecord.test.ts | 105 ++++++++++++++++++ .../achievements/domain/currentStreak.test.ts | 18 +++ .../achievements/domain/ladderWins.test.ts | 46 ++++++++ .../server/routes/achievements-router.test.ts | 49 +++++++- 12 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 src/modules/achievements/application/GetUserAchievementRecord.ts create mode 100644 src/modules/achievements/domain/UserAchievementRecordRepository.ts create mode 100644 src/modules/achievements/domain/currentStreak.ts create mode 100644 src/modules/achievements/domain/ladderWins.ts create mode 100644 src/modules/achievements/infrastructure/UserAchievementRecordPostgresRepository.ts create mode 100644 tests/unit/modules/achievements/application/GetUserAchievementRecord.test.ts create mode 100644 tests/unit/modules/achievements/domain/currentStreak.test.ts create mode 100644 tests/unit/modules/achievements/domain/ladderWins.test.ts diff --git a/src/modules/achievements/application/GetUserAchievementRecord.ts b/src/modules/achievements/application/GetUserAchievementRecord.ts new file mode 100644 index 0000000..7c3d2e3 --- /dev/null +++ b/src/modules/achievements/application/GetUserAchievementRecord.ts @@ -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; + }; +}; + +export class GetUserAchievementRecord { + constructor( + private readonly repository: UserAchievementRecordRepository, + private readonly now: () => Date = () => new Date(), + ) {} + + async run(userId: string): Promise { + 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), + }, + }; + } +} diff --git a/src/modules/achievements/domain/UserAchievementRecordRepository.ts b/src/modules/achievements/domain/UserAchievementRecordRepository.ts new file mode 100644 index 0000000..aa24907 --- /dev/null +++ b/src/modules/achievements/domain/UserAchievementRecordRepository.ts @@ -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; + findAwards(userId: string): Promise; + /** 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; + /** Outcomes of the latest eligible matches, most recent first, bounded. */ + findLatestOutcomes(userId: string): Promise; + findLadderGroups(): Promise; +} diff --git a/src/modules/achievements/domain/currentStreak.ts b/src/modules/achievements/domain/currentStreak.ts new file mode 100644 index 0000000..49ddf8d --- /dev/null +++ b/src/modules/achievements/domain/currentStreak.ts @@ -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; +} diff --git a/src/modules/achievements/domain/ladderWins.ts b/src/modules/achievements/domain/ladderWins.ts new file mode 100644 index 0000000..1c6964d --- /dev/null +++ b/src/modules/achievements/domain/ladderWins.ts @@ -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 { + const reached = new Set(); + + 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 + >; +} diff --git a/src/modules/achievements/infrastructure/AchievementsSchemas.ts b/src/modules/achievements/infrastructure/AchievementsSchemas.ts index 2d328db..49b0099 100644 --- a/src/modules/achievements/infrastructure/AchievementsSchemas.ts +++ b/src/modules/achievements/infrastructure/AchievementsSchemas.ts @@ -27,3 +27,41 @@ export const AchievementCatalogSchema = t.Object({ }), ), }); + +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, + }), +}); diff --git a/src/modules/achievements/infrastructure/UserAchievementRecordPostgresRepository.ts b/src/modules/achievements/infrastructure/UserAchievementRecordPostgresRepository.ts new file mode 100644 index 0000000..ec59350 --- /dev/null +++ b/src/modules/achievements/infrastructure/UserAchievementRecordPostgresRepository.ts @@ -0,0 +1,105 @@ +import { dataSource } from "../../../evolution-types/src/data-source"; +import { LadderGroup } from "../domain/ladderWins"; +import { + UserAchievementRecordRepository, + UserAward, +} from "../domain/UserAchievementRecordRepository"; + +// Same eligibility as the game server awarder; matches the partial index +// (user_id, date DESC) WHERE anulled = false AND deleted_at IS NULL. +const ELIGIBLE = "user_id = $1 AND anulled = false AND deleted_at IS NULL"; + +const STREAK_WINDOW = 200; + +export class UserAchievementRecordPostgresRepository implements UserAchievementRecordRepository { + async userExists(userId: string): Promise { + const rows: unknown[] = await dataSource.query( + "SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL LIMIT 1", + [userId], + ); + + return rows.length > 0; + } + + async findAwards(userId: string): Promise { + const rows: Array<{ + award_id: string; + id: number; + code: string | null; + icon: string; + name: string; + description: string; + labels: string[] | null; + unlocked_at: Date; + season: number; + earned_points: number; + }> = await dataSource.query( + `SELECT ua.id AS award_id, a.id, a.code, a.icon, a.name, a.description, + ua.labels, ua.unlocked_at, ua.season, a.earned_points + FROM user_achievements ua + JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = $1 + ORDER BY ua.unlocked_at DESC, ua.id`, + [userId], + ); + + return rows.map((row) => ({ + awardId: row.award_id, + id: Number(row.id), + code: row.code, + icon: row.icon, + name: row.name, + description: row.description, + labels: row.labels ?? [], + unlockedAt: new Date(row.unlocked_at), + season: Number(row.season), + earnedPoints: Number(row.earned_points), + })); + } + + async findTotals(userId: string): Promise<{ rankedMatches: number; rankedWins: number }> { + const [row]: Array<{ matches: string; wins: string }> = await dataSource.query( + `SELECT count(*) AS matches, count(*) FILTER (WHERE winner) AS wins + FROM matches WHERE ${ELIGIBLE}`, + [userId], + ); + + return { rankedMatches: Number(row.matches), rankedWins: Number(row.wins) }; + } + + async findWinningBanLists(userId: string): Promise { + const rows: Array<{ ban_list_name: string }> = await dataSource.query( + `SELECT DISTINCT ban_list_name FROM matches WHERE ${ELIGIBLE} AND winner = true`, + [userId], + ); + + return rows.map((row) => row.ban_list_name); + } + + async findLatestOutcomes(userId: string): Promise { + const rows: Array<{ winner: boolean }> = await dataSource.query( + `SELECT winner FROM matches WHERE ${ELIGIBLE} + ORDER BY date DESC, created_at DESC, id DESC LIMIT ${STREAK_WINDOW}`, + [userId], + ); + + return rows.map((row) => row.winner); + } + + async findLadderGroups(): Promise { + const rows: Array<{ name: string; pattern: string }> = await dataSource.query( + `SELECT ranks.name, rank_members.pattern + FROM ranks + JOIN rank_members ON rank_members.rank_id = ranks.id + WHERE ranks.type = 'group' AND ranks.enabled = true`, + ); + + const byName = new Map(); + + for (const { name, pattern } of rows) { + byName.set(name, [...(byName.get(name) ?? []), pattern]); + } + + return [...byName].map(([name, patterns]) => ({ name, patterns })); + } +} diff --git a/src/server/routes/achievements-router.ts b/src/server/routes/achievements-router.ts index 1f6ffad..9d2d5ff 100644 --- a/src/server/routes/achievements-router.ts +++ b/src/server/routes/achievements-router.ts @@ -1,9 +1,14 @@ -import { Elysia } from "elysia"; +import { Elysia, t } from "elysia"; import { GetAchievementCatalog } from "../../modules/achievements/application/GetAchievementCatalog"; import { AchievementCatalogPostgresRepository } from "../../modules/achievements/infrastructure/AchievementCatalogPostgresRepository"; -import { AchievementCatalogSchema } from "../../modules/achievements/infrastructure/AchievementsSchemas"; -import { emptyOk, jsonOk } from "../openapi/responses"; +import { UserAchievementRecordPostgresRepository } from "../../modules/achievements/infrastructure/UserAchievementRecordPostgresRepository"; +import { GetUserAchievementRecord } from "../../modules/achievements/application/GetUserAchievementRecord"; +import { + AchievementCatalogSchema, + UserAchievementRecordSchema, +} from "../../modules/achievements/infrastructure/AchievementsSchemas"; +import { emptyOk, errorResponses, jsonOk } from "../openapi/responses"; type Dependencies = { getCatalog: GetAchievementCatalog }; @@ -62,3 +67,61 @@ export const createAchievementsRouter = ({ getCatalog }: Dependencies) => export const achievementsRouter = createAchievementsRouter({ getCatalog: new GetAchievementCatalog(new AchievementCatalogPostgresRepository()), }); + +export const createUserAchievementsRouter = ({ + getRecord, +}: { + getRecord: GetUserAchievementRecord; +}) => + new Elysia({ prefix: "/users" }).get( + "/:userId/achievements", + ({ params }) => getRecord.run(params.userId), + { + detail: { + tags: ["Achievements"], + summary: "Get a user's achievement record", + description: + "Returns every award the user holds plus lifetime ranked progress (wins, matches, current win streak and a first-win flag per format ladder) computed from non-annulled, non-deleted ranked matches across all seasons. Season counters from the stats endpoint are not lifetime progress. Public, like the user stats.", + responses: { + 200: jsonOk(UserAchievementRecordSchema, "Record retrieved successfully", { + userId: "user-123", + asOf: "2026-10-09T20:00:00.000Z", + scope: "lifetime-ranked", + achievements: [ + { + awardId: "5b2c0e9e-7a4f-4c3b-9d1e-2f6a8b0c4d3e", + id: 1001, + code: "first_win", + icon: "sword", + name: "First Blood", + description: "Win your first ranked match", + labels: [], + unlockedAt: "2026-09-01T10:00:00.000Z", + season: 7, + earnedPoints: 0, + }, + ], + progress: { + rankedWins: 34, + rankedMatches: 55, + currentStreak: 2, + hasRankedWinByLadder: { + TCG: true, + OCG: false, + Edison: true, + JTP: false, + Genesys: false, + }, + }, + }), + ...errorResponses(404, 422), + }, + }, + params: t.Object({ userId: t.String() }), + response: { 200: UserAchievementRecordSchema }, + }, + ); + +export const userAchievementsRouter = createUserAchievementsRouter({ + getRecord: new GetUserAchievementRecord(new UserAchievementRecordPostgresRepository()), +}); diff --git a/src/server/server.ts b/src/server/server.ts index e88add6..a00e088 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -8,7 +8,7 @@ import { InvalidArgumentError } from "../shared/errors/InvalidArgumentError"; import { NotFoundError } from "../shared/errors/NotFoundError"; import { Logger } from "../shared/logger/domain/Logger"; -import { achievementsRouter } from "./routes/achievements-router"; +import { achievementsRouter, userAchievementsRouter } from "./routes/achievements-router"; import { banListRouter } from "./routes/ban-list-router"; import { cosmeticsRouter } from "./routes/cosmetics-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; @@ -71,6 +71,7 @@ export function mountApiV1Routes(app: Elysia) { return group .use(userRouter) .use(achievementsRouter) + .use(userAchievementsRouter) .use(leaderboardRouter) .use(rankedTiersRouter) .use(banListRouter) diff --git a/tests/unit/modules/achievements/application/GetUserAchievementRecord.test.ts b/tests/unit/modules/achievements/application/GetUserAchievementRecord.test.ts new file mode 100644 index 0000000..cbab390 --- /dev/null +++ b/tests/unit/modules/achievements/application/GetUserAchievementRecord.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; + +import { GetUserAchievementRecord } from "../../../../../src/modules/achievements/application/GetUserAchievementRecord"; +import { + UserAchievementRecordRepository, + UserAward, +} from "../../../../../src/modules/achievements/domain/UserAchievementRecordRepository"; +import { NotFoundError } from "../../../../../src/shared/errors/NotFoundError"; + +const NOW = new Date("2026-10-09T20:00:00.000Z"); + +const award: UserAward = { + awardId: "5b2c0e9e-7a4f-4c3b-9d1e-2f6a8b0c4d3e", + id: 1001, + code: "first_win", + icon: "sword", + name: "First Blood", + description: "Win your first ranked match", + labels: [], + unlockedAt: new Date("2026-09-01T10:00:00.000Z"), + season: 7, + earnedPoints: 0, +}; + +const repository = ( + overrides: Partial = {}, +): UserAchievementRecordRepository => ({ + userExists: async () => true, + findAwards: async () => [], + findTotals: async () => ({ rankedMatches: 0, rankedWins: 0 }), + findWinningBanLists: async () => [], + findLatestOutcomes: async () => [], + findLadderGroups: async () => [], + ...overrides, +}); + +const run = (overrides?: Partial) => + new GetUserAchievementRecord(repository(overrides), () => NOW).run("user-1"); + +describe("GetUserAchievementRecord", () => { + it("returns zero counters, all-false ladders and no awards for a user without matches", async () => { + expect(await run()).toEqual({ + userId: "user-1", + asOf: "2026-10-09T20:00:00.000Z", + scope: "lifetime-ranked", + achievements: [], + progress: { + rankedWins: 0, + rankedMatches: 0, + currentStreak: 0, + hasRankedWinByLadder: { TCG: false, OCG: false, Edison: false, JTP: false, Genesys: false }, + }, + }); + }); + + it("shapes every award with its award-instance id and ISO unlock date", async () => { + const record = await run({ findAwards: async () => [award, { ...award, awardId: "other" }] }); + + expect(record.achievements).toHaveLength(2); + expect(record.achievements[0]).toEqual({ ...award, unlockedAt: "2026-09-01T10:00:00.000Z" }); + expect(record.achievements[1].awardId).toBe("other"); + }); + + it("takes the totals and computes the streak from the latest outcomes", async () => { + const record = await run({ + findTotals: async () => ({ rankedMatches: 120, rankedWins: 61 }), + findLatestOutcomes: async () => [true, true, true, false, true], + }); + + expect(record.progress).toMatchObject({ + rankedMatches: 120, + rankedWins: 61, + currentStreak: 3, + }); + }); + + it("resolves ladders from the winning ban lists through the group patterns", async () => { + const record = await run({ + findWinningBanLists: async () => ["2026.09 TCG", "JTP"], + findLadderGroups: async () => [{ name: "TCG", patterns: ["* TCG"] }], + }); + + expect(record.progress.hasRankedWinByLadder).toEqual({ + TCG: true, + OCG: false, + Edison: false, + JTP: true, + Genesys: false, + }); + }); + + it("throws NotFoundError for an unknown user without reading anything else", async () => { + let reads = 0; + const counted = async () => { + reads += 1; + + return []; + }; + + await expect( + run({ userExists: async () => false, findAwards: counted }), + ).rejects.toBeInstanceOf(NotFoundError); + expect(reads).toBe(0); + }); +}); diff --git a/tests/unit/modules/achievements/domain/currentStreak.test.ts b/tests/unit/modules/achievements/domain/currentStreak.test.ts new file mode 100644 index 0000000..7cc57f0 --- /dev/null +++ b/tests/unit/modules/achievements/domain/currentStreak.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; + +import { currentStreak } from "../../../../../src/modules/achievements/domain/currentStreak"; + +describe("currentStreak", () => { + it("counts the wins at the head of the latest-first outcomes", () => { + expect(currentStreak([true, true, false, true])).toBe(2); + }); + + it("is zero when the latest match is a loss or there are no matches", () => { + expect(currentStreak([false, true, true])).toBe(0); + expect(currentStreak([])).toBe(0); + }); + + it("is the whole window when there is no loss in it", () => { + expect(currentStreak([true, true, true])).toBe(3); + }); +}); diff --git a/tests/unit/modules/achievements/domain/ladderWins.test.ts b/tests/unit/modules/achievements/domain/ladderWins.test.ts new file mode 100644 index 0000000..7157153 --- /dev/null +++ b/tests/unit/modules/achievements/domain/ladderWins.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; + +import { ladderWins } from "../../../../../src/modules/achievements/domain/ladderWins"; + +const groups = [ + { name: "TCG", patterns: ["* TCG"] }, + { name: "OCG", patterns: ["* OCG"] }, + { name: "Edison", patterns: ["* Edison", "Edison"] }, +]; + +describe("ladderWins", () => { + it("is all false without winning ban lists", () => { + expect(ladderWins([], groups)).toEqual({ + TCG: false, + OCG: false, + Edison: false, + JTP: false, + Genesys: false, + }); + }); + + it("counts a win on a dated list for the group whose pattern matches it", () => { + const result = ladderWins(["2026.09 TCG"], groups); + + expect(result.TCG).toBe(true); + expect(result.OCG).toBe(false); + }); + + it("counts a win on a ban list that is itself a ladder name", () => { + const result = ladderWins(["JTP", "Genesys"], groups); + + expect(result.JTP).toBe(true); + expect(result.Genesys).toBe(true); + expect(result.Edison).toBe(false); + }); + + it("feeds every group a list belongs to and ignores unrelated names", () => { + const result = ladderWins(["2010.03 Edison", "Casual Lounge", "N/A"], groups); + + expect(result).toEqual({ TCG: false, OCG: false, Edison: true, JTP: false, Genesys: false }); + }); + + it("does not let a suffix pattern match a name that merely contains it", () => { + expect(ladderWins(["TCG Fun"], groups).TCG).toBe(false); + }); +}); diff --git a/tests/unit/server/routes/achievements-router.test.ts b/tests/unit/server/routes/achievements-router.test.ts index 453061c..0b61ca2 100644 --- a/tests/unit/server/routes/achievements-router.test.ts +++ b/tests/unit/server/routes/achievements-router.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "bun:test"; +import { Elysia } from "elysia"; import { Value } from "@sinclair/typebox/value"; import { GetAchievementCatalog } from "../../../../src/modules/achievements/application/GetAchievementCatalog"; import { AchievementCatalogSchema } from "../../../../src/modules/achievements/infrastructure/AchievementsSchemas"; -import { createAchievementsRouter } from "../../../../src/server/routes/achievements-router"; +import { GetUserAchievementRecord } from "../../../../src/modules/achievements/application/GetUserAchievementRecord"; +import { UserAchievementRecordSchema } from "../../../../src/modules/achievements/infrastructure/AchievementsSchemas"; +import { NotFoundError } from "../../../../src/shared/errors/NotFoundError"; +import { + createAchievementsRouter, + createUserAchievementsRouter, +} from "../../../../src/server/routes/achievements-router"; +import { mapDomainErrorStatus } from "../../../../src/server/server"; const router = createAchievementsRouter({ getCatalog: new GetAchievementCatalog({ @@ -50,3 +58,42 @@ describe("GET /achievements", () => { expect(response.status).toBe(200); }); }); + +describe("GET /users/:userId/achievements", () => { + const record = { + userId: "user-1", + asOf: "2026-10-09T20:00:00.000Z", + scope: "lifetime-ranked" as const, + achievements: [], + progress: { + rankedWins: 0, + rankedMatches: 0, + currentStreak: 0, + hasRankedWinByLadder: { TCG: false, OCG: false, Edison: false, JTP: false, Genesys: false }, + }, + }; + + const userRouter = (run: () => Promise) => + new Elysia() + .onError(mapDomainErrorStatus) + .use( + createUserAchievementsRouter({ getRecord: { run } as unknown as GetUserAchievementRecord }), + ); + + it("serves the record unauthenticated and matching the response schema", async () => { + const response = await userRouter(async () => record).handle( + new Request("http://localhost/users/user-1/achievements"), + ); + + expect(response.status).toBe(200); + expect(Value.Check(UserAchievementRecordSchema, await response.json())).toBe(true); + }); + + it("answers 404 when the user does not exist", async () => { + const response = await userRouter(async () => { + throw new NotFoundError("User not found"); + }).handle(new Request("http://localhost/users/ghost/achievements")); + + expect(response.status).toBe(404); + }); +});