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
1 change: 1 addition & 0 deletions src/modules/stats/domain/UserStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type UserAchievementParams = {
unlockedAt: string;
description: string;
earnedPoints: number;
code?: string | null;
};

export type RankType = "banlist" | "group" | "global";
Expand Down
3 changes: 3 additions & 0 deletions src/modules/stats/infrastructure/StatsSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down
12 changes: 12 additions & 0 deletions src/modules/stats/user-achievement/domain/UserAchievement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +16,7 @@ export class UserAchievement {
unlockedAt,
description,
earnedPoints,
code = null,
}: {
id: number;
icon: string;
Expand All @@ -23,6 +25,7 @@ export class UserAchievement {
unlockedAt: Date;
description: string;
earnedPoints: number;
code?: string | null;
}) {
this.id = id;
this.icon = icon;
Expand All @@ -31,6 +34,7 @@ export class UserAchievement {
this.unlockedAt = new Date(unlockedAt);
this.description = description;
this.earnedPoints = earnedPoints;
this.code = code;
}

static create({
Expand All @@ -41,6 +45,7 @@ export class UserAchievement {
unlockedAt,
description,
earnedPoints,
code = null,
}: {
id: number;
icon: string;
Expand All @@ -49,6 +54,7 @@ export class UserAchievement {
unlockedAt: Date | string;
description: string;
earnedPoints: number;
code?: string | null;
}): UserAchievement {
return new UserAchievement({
id,
Expand All @@ -58,6 +64,7 @@ export class UserAchievement {
unlockedAt: new Date(unlockedAt),
description,
earnedPoints,
code,
});
}

Expand All @@ -69,6 +76,7 @@ export class UserAchievement {
unlockedAt,
description,
earnedPoints,
code = null,
}: {
id: number;
icon: string;
Expand All @@ -77,6 +85,7 @@ export class UserAchievement {
unlockedAt: Date | string;
description: string;
earnedPoints: number;
code?: string | null;
}): UserAchievement {
return new UserAchievement({
id,
Expand All @@ -86,6 +95,7 @@ export class UserAchievement {
unlockedAt: new Date(unlockedAt),
description,
earnedPoints,
code,
});
}

Expand All @@ -97,6 +107,7 @@ export class UserAchievement {
unlockedAt: string;
description: string;
earnedPoints: number;
code: string | null;
} {
return {
id: this.id,
Expand All @@ -106,6 +117,7 @@ export class UserAchievement {
unlockedAt: this.unlockedAt.toISOString(),
description: this.description,
earnedPoints: this.earnedPoints,
code: this.code,
};
}
}
19 changes: 18 additions & 1 deletion tests/unit/modules/stats/infrastructure/StatsSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
[
{
Expand Down Expand Up @@ -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);
});
Expand Down
Loading