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..fe71664 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 3f0c29df7f4d2a007cf6d0d88144ecde2606cc07 +Subproject commit fe71664495abb8311c28bc34c86b884671039b42 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); });