Skip to content

Commit 56e7130

Browse files
committed
daily rank
1 parent f814a96 commit 56e7130

File tree

6 files changed

+196
-23
lines changed

6 files changed

+196
-23
lines changed

backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,12 +311,44 @@ describe("Daily Leaderboards", () => {
311311
it("gets rank", async () => {
312312
//GIVEN
313313
const user1 = await givenResult({ wpm: 50 });
314-
const _user2 = await givenResult({ wpm: 60 });
314+
const user2 = await givenResult({ wpm: 60 });
315315

316-
//WHEN
317-
const rank = await lb.getRank(user1.uid, dailyLeaderboardsConfig);
318-
//THEN
319-
expect(rank).toEqual({ rank: 2, ...user1 });
316+
//WHEN / THEN
317+
expect(await lb.getRank(user1.uid, dailyLeaderboardsConfig)).toEqual({
318+
rank: 2,
319+
...user1,
320+
});
321+
expect(await lb.getRank(user2.uid, dailyLeaderboardsConfig)).toEqual({
322+
rank: 1,
323+
...user2,
324+
});
325+
});
326+
327+
it("should return null for unknown user", async () => {
328+
expect(await lb.getRank("decoy", dailyLeaderboardsConfig)).toBeNull();
329+
expect(
330+
await lb.getRank("decoy", dailyLeaderboardsConfig, [
331+
"unknown",
332+
"unknown2",
333+
])
334+
).toBeNull();
335+
});
336+
337+
it("gets rank for friends", async () => {
338+
//GIVEN
339+
const user1 = await givenResult({ wpm: 50 });
340+
const user2 = await givenResult({ wpm: 60 });
341+
const _user3 = await givenResult({ wpm: 70 });
342+
const friends = [user1.uid, user2.uid, "decoy"];
343+
344+
//WHEN / THEN
345+
expect(
346+
await lb.getRank(user2.uid, dailyLeaderboardsConfig, friends)
347+
).toEqual({ rank: 1, ...user2 });
348+
349+
expect(
350+
await lb.getRank(user1.uid, dailyLeaderboardsConfig, friends)
351+
).toEqual({ rank: 2, ...user1 });
320352
});
321353
});
322354

backend/__tests__/api/controllers/leaderboard.spec.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,11 @@ describe("Loaderboard Controller", () => {
294294

295295
describe("get rank", () => {
296296
const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank");
297+
const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids");
297298

298299
afterEach(() => {
299300
getLeaderboardRankMock.mockClear();
301+
getFriendsUidsMock.mockClear();
300302
});
301303

302304
it("fails withouth authentication", async () => {
@@ -308,7 +310,6 @@ describe("Loaderboard Controller", () => {
308310

309311
it("should get for english time 60", async () => {
310312
//GIVEN
311-
312313
const entryId = new ObjectId();
313314
const resultEntry = {
314315
_id: entryId,
@@ -323,7 +324,6 @@ describe("Loaderboard Controller", () => {
323324
getLeaderboardRankMock.mockResolvedValue(resultEntry);
324325

325326
//WHEN
326-
327327
const { body } = await mockApp
328328
.get("/leaderboards/rank")
329329
.query({ language: "english", mode: "time", mode2: "60" })
@@ -344,6 +344,37 @@ describe("Loaderboard Controller", () => {
344344
undefined
345345
);
346346
});
347+
348+
it("should get for english time 60 friends only", async () => {
349+
//GIVEN
350+
await enableFriendsFeature(true);
351+
const friends = ["friendOne", "friendTwo"];
352+
getFriendsUidsMock.mockResolvedValue(friends);
353+
getLeaderboardRankMock.mockResolvedValue({} as any);
354+
355+
//WHEN
356+
await mockApp
357+
.get("/leaderboards/rank")
358+
.query({
359+
language: "english",
360+
mode: "time",
361+
mode2: "60",
362+
friendsOnly: true,
363+
})
364+
.set("Authorization", `Bearer ${uid}`)
365+
.expect(200);
366+
367+
//THEN
368+
expect(getLeaderboardRankMock).toHaveBeenCalledWith(
369+
"time",
370+
"60",
371+
"english",
372+
uid,
373+
friends
374+
);
375+
expect(getFriendsUidsMock).toHaveBeenCalledWith(uid);
376+
});
377+
347378
it("should get with ape key", async () => {
348379
await acceptApeKeys(true);
349380
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
@@ -795,10 +826,12 @@ describe("Loaderboard Controller", () => {
795826
);
796827

797828
const getRankMock = vi.fn();
829+
const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids");
798830

799831
beforeEach(async () => {
800832
getDailyLeaderboardMock.mockClear();
801833
getRankMock.mockClear();
834+
getFriendsUidsMock.mockClear();
802835

803836
getDailyLeaderboardMock.mockReturnValue({
804837
getRank: getRankMock,
@@ -820,6 +853,7 @@ describe("Loaderboard Controller", () => {
820853
.query({ language: "english", mode: "time", mode2: "60" })
821854
.expect(401);
822855
});
856+
823857
it("should get for english time 60", async () => {
824858
//GIVEN
825859
const lbConf = (await configuration).dailyLeaderboards;
@@ -862,8 +896,42 @@ describe("Loaderboard Controller", () => {
862896
-1
863897
);
864898

865-
expect(getRankMock).toHaveBeenCalledWith(uid, lbConf);
899+
expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, undefined);
866900
});
901+
902+
it("should get for english time 60 friends only", async () => {
903+
//GIVEN
904+
const lbConf = (await configuration).dailyLeaderboards;
905+
getRankMock.mockResolvedValue({});
906+
const friends = ["friendOne", "friendTwo"];
907+
getFriendsUidsMock.mockResolvedValue(friends);
908+
909+
//WHEN
910+
await mockApp
911+
.get("/leaderboards/daily/rank")
912+
.set("Authorization", `Bearer ${uid}`)
913+
.query({
914+
language: "english",
915+
mode: "time",
916+
mode2: "60",
917+
friendsOnly: true,
918+
})
919+
.expect(200);
920+
921+
//THEN
922+
923+
expect(getDailyLeaderboardMock).toHaveBeenCalledWith(
924+
"english",
925+
"time",
926+
"60",
927+
lbConf,
928+
-1
929+
);
930+
931+
expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, friends);
932+
expect(getFriendsUidsMock).toHaveBeenCalledWith(uid);
933+
});
934+
867935
it("fails if daily leaderboards are disabled", async () => {
868936
await dailyLeaderboardEnabled(false);
869937

@@ -876,6 +944,7 @@ describe("Loaderboard Controller", () => {
876944
"Daily leaderboards are not available at this time."
877945
);
878946
});
947+
879948
it("should get for mode", async () => {
880949
for (const mode of ["time", "words", "quote", "zen", "custom"]) {
881950
const response = await mockApp
@@ -885,6 +954,7 @@ describe("Loaderboard Controller", () => {
885954
expect(response.status, "for mode " + mode).toEqual(200);
886955
}
887956
});
957+
888958
it("should get for mode2", async () => {
889959
for (const mode2 of allModes) {
890960
const response = await mockApp

backend/redis-scripts/get-rank.lua

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
-- Helper to split CSV string into a list
2+
local function split_csv(csv)
3+
local result = {}
4+
for user_id in string.gmatch(csv, '([^,]+)') do
5+
table.insert(result, user_id)
6+
end
7+
return result
8+
end
9+
10+
local redis_call = redis.call
11+
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
12+
13+
local user_id = ARGV[1]
14+
local include_scores = ARGV[2]
15+
local user_ids_csv = ARGV[3]
16+
17+
local rank = nil
18+
local result = {}
19+
local score = ''
20+
21+
22+
-- filtered leaderboard
23+
if user_ids_csv ~= "" then
24+
25+
local filtered_user_ids = split_csv(user_ids_csv)
26+
local scored_users = {}
27+
for _, user_id in ipairs(filtered_user_ids) do
28+
local score = redis_call('ZSCORE', leaderboard_scores_key, user_id)
29+
if score then
30+
local number_score = tonumber(score)
31+
table.insert(scored_users, {user_id = user_id, score = number_score})
32+
end
33+
end
34+
table.sort(scored_users, function(a, b) return a.score > b.score end)
35+
36+
for i = 1, #scored_users do
37+
if scored_users[i].user_id == user_id then
38+
rank = i - 1
39+
end
40+
end
41+
42+
else
43+
-- global leaderboarc
44+
45+
rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id)
46+
47+
end
48+
49+
if (include_scores == "true") then
50+
score = redis_call('ZSCORE', leaderboard_scores_key, user_id)
51+
end
52+
result = redis_call('HGET', leaderboard_results_key, user_id)
53+
54+
return {rank, score, result}

backend/src/api/controllers/leaderboard.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,15 @@ export async function getDailyLeaderboard(
171171
export async function getDailyLeaderboardRank(
172172
req: MonkeyRequest<GetDailyLeaderboardRankQuery>
173173
): Promise<GetLeaderboardDailyRankResponse> {
174+
const { friendsOnly } = req.query;
174175
const { uid } = req.ctx.decodedToken;
176+
const friendConfig = req.ctx.configuration.friends;
177+
178+
const friendUids = await getFriendsUids(
179+
uid,
180+
friendsOnly === true,
181+
friendConfig
182+
);
175183

176184
const dailyLeaderboard = getDailyLeaderboardWithError(
177185
req.query,
@@ -180,7 +188,8 @@ export async function getDailyLeaderboardRank(
180188

181189
const rank = await dailyLeaderboard.getRank(
182190
uid,
183-
req.ctx.configuration.dailyLeaderboards
191+
req.ctx.configuration.dailyLeaderboards,
192+
friendUids
184193
);
185194

186195
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);

backend/src/init/redis.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export type RedisConnectionWithCustomMethods = Redis & {
3636
withScores: string,
3737
userIds: string
3838
) => Promise<[string[], string[], string, string, string[]]>; //entries, scores(optional), count, min_score(optiona), ranks(optional)
39+
getRank: (
40+
keyCount: number,
41+
scoresKey: string,
42+
resultsKey: string,
43+
uid: string,
44+
withScores: string,
45+
userIds: string
46+
) => Promise<[number, string, string]>; //rank, score(optional), entry json
3947
purgeResults: (
4048
keyCount: number,
4149
uid: string,

backend/src/utils/daily-leaderboards.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,30 +195,30 @@ export class DailyLeaderboard {
195195

196196
public async getRank(
197197
uid: string,
198-
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
198+
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
199+
userIds?: string[]
199200
): Promise<LeaderboardEntry | null> {
200201
const connection = RedisClient.getConnection();
201202
if (!connection || !dailyLeaderboardsConfig.enabled) {
202203
throw new Error("Redis connection is unavailable");
203204
}
205+
if (userIds?.length === 0) {
206+
return null;
207+
}
204208

205209
const { leaderboardScoresKey, leaderboardResultsKey } =
206210
this.getTodaysLeaderboardKeys();
207211

208-
const redisExecResult = (await connection
209-
.multi()
210-
.zrevrank(leaderboardScoresKey, uid)
211-
.zcard(leaderboardScoresKey)
212-
.hget(leaderboardResultsKey, uid)
213-
.exec()) as [
214-
[null, number | null],
215-
[null, number | null],
216-
[null, string | null]
217-
];
218-
219-
const [[, rank], [, _count], [, result]] = redisExecResult;
212+
const [rank, _score, result] = await connection.getRank(
213+
2,
214+
leaderboardScoresKey,
215+
leaderboardResultsKey,
216+
uid,
217+
"false",
218+
userIds?.join(",") ?? ""
219+
);
220220

221-
if (rank === null) {
221+
if (rank === null || rank === undefined) {
222222
return null;
223223
}
224224

0 commit comments

Comments
 (0)