rhythia-api 229.0.0 → 231.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,426 +1,449 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi, validUser } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
5
- import { decryptString } from "../utils/security";
6
- import { isEqual } from "lodash";
7
- import { getUserBySession } from "../utils/getUserBySession";
8
- import { User } from "@supabase/supabase-js";
9
- import { invalidateCachePrefix } from "../utils/cache";
10
- import { createClient, RedisClientType } from "redis";
11
- import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
12
-
13
- const s3Client = new S3Client({
14
- region: "auto",
15
- endpoint: "https://s3.eu-central-003.backblazeb2.com",
16
- credentials: {
17
- secretAccessKey: process.env.SECRET_BUCKET || "",
18
- accessKeyId: process.env.ACCESS_BUCKET || "",
19
- },
20
- requestChecksumCalculation: "WHEN_REQUIRED",
21
- });
22
-
23
- s3Client.middlewareStack.add(
24
- (next) =>
25
- async (args): Promise<any> => {
26
- const request = args.request as RequestInit;
27
- const headers = (request.headers || {}) as Record<string, string>;
28
-
29
- delete headers["x-amz-checksum-crc32"];
30
- delete headers["x-amz-checksum-crc32c"];
31
- delete headers["x-amz-checksum-sha1"];
32
- delete headers["x-amz-checksum-sha256"];
33
-
34
- request.headers = headers;
35
- return next(args);
36
- },
37
- { step: "build", name: "stripBackblazeChecksumHeaders" }
38
- );
39
-
40
- export const Schema = {
41
- input: z.strictObject({
42
- session: z.string(),
43
- secret: z.string(),
44
- token: z.string(),
45
- data: z.strictObject({
46
- onlineMapId: z.number(),
47
- misses: z.number(),
48
- hits: z.number(),
49
- speed: z.number(),
50
- mods: z.array(z.string()),
51
- spin: z.boolean(),
52
- replayBytes: z.string().nullable().optional(),
53
- pauses: z.number().nullable().optional(),
54
- failTime: z.number().nullable().optional(),
55
- }),
56
- }),
57
- output: z.object({
58
- error: z.string().optional(),
59
- }),
60
- };
61
-
62
- function easeInExpoDeqHard(x: number, star: number) {
63
- let exponent = 100 - 12 * star;
64
- if (exponent < 5) exponent = 5;
65
- return x === 0 ? 0 : Math.pow(2, exponent * x - exponent);
66
- }
67
-
68
- export function calculatePerformancePoints(
69
- starRating: number,
70
- accuracy: number
71
- ) {
72
- return (
73
- Math.round(
74
- Math.pow(
75
- (starRating * easeInExpoDeqHard(accuracy, starRating) * 100) / 2,
76
- 2
77
- ) / 1000
78
- ) * 2
79
- );
80
- }
81
-
82
- export async function POST(request: Request): Promise<NextResponse> {
83
- console.log("Received Request -", JSON.stringify(request, null, 2));
84
- return protectedApi({
85
- request,
86
- schema: Schema,
87
- authorization: validUser,
88
- activity: handler,
89
- });
90
- }
91
-
92
- export async function handler({
93
- data,
94
- token,
95
- session,
96
- secret,
97
- }: (typeof Schema)["input"]["_type"]): Promise<
98
- NextResponse<(typeof Schema)["output"]["_type"]>
99
- > {
100
- if (secret !== "testing-1") {
101
- return NextResponse.json({
102
- error: "Internal usage only",
103
- });
104
- }
105
- const user = (await getUserBySession(session)) as User;
106
-
107
- let { data: leversData } = await supabase
108
- .from("levers")
109
- .select("*")
110
- .eq("id", 1)
111
- .single();
112
-
113
- if (leversData && leversData.disable_scores) {
114
- return NextResponse.json({
115
- error: "Scores are temporarily disabled",
116
- });
117
- }
118
-
119
- if (data.failTime) {
120
- return NextResponse.json({
121
- error: "Fail time scores aren't saved for now",
122
- });
123
- }
124
-
125
- let { data: userData, error: userError } = await supabase
126
- .from("profiles")
127
- .select("*")
128
- .eq("uid", user.id)
129
- .single();
130
-
131
- if (!userData)
132
- return NextResponse.json(
133
- {
134
- error: "User doesn't exist",
135
- },
136
- { status: 400 }
137
- );
138
-
139
- console.log(userData);
140
-
141
- if (userData.ban == "excluded" || userData.ban == "restricted") {
142
- return NextResponse.json(
143
- {
144
- error: "Silenced, restricted or excluded players can't submit scores.",
145
- },
146
- { status: 400 }
147
- );
148
- }
149
-
150
- let { data: beatmapPages, error: bpError } = await supabase
151
- .from("beatmapPages")
152
- .select("*")
153
- .eq("id", data.onlineMapId)
154
- .single();
155
-
156
- let { data: beatmaps, error: bpErr } = await supabase
157
- .from("beatmaps")
158
- .select("*")
159
- .eq("beatmapHash", beatmapPages!.latestBeatmapHash!)
160
- .single();
161
-
162
- if (!beatmaps) {
163
- return NextResponse.json(
164
- {
165
- error: "Map not submitted",
166
- },
167
- { status: 400 }
168
- );
169
- }
170
-
171
- if (!beatmapPages) {
172
- return NextResponse.json(
173
- {
174
- error: "Map not submitted",
175
- },
176
- { status: 400 }
177
- );
178
- }
179
-
180
- await supabase.from("beatmaps").upsert({
181
- beatmapHash: beatmapPages.latestBeatmapHash!,
182
- playcount: (beatmaps!.playcount || 1) + 1,
183
- });
184
-
185
- let passed = true;
186
-
187
- // Pass invalidation
188
- if (data.misses + data.hits !== beatmaps.noteCount) {
189
- passed = false;
190
- }
191
-
192
- const accurracy = data.hits / beatmaps.noteCount!;
193
- let awarded_sp = 0;
194
-
195
- console.log(
196
- data.misses + data.hits == beatmaps.noteCount,
197
- data.misses + data.hits,
198
- beatmaps.noteCount
199
- );
200
-
201
- let multiplierMod = 1;
202
- if (data.mods.includes("mod_hardrock")) {
203
- multiplierMod *= 1; //todo: change here
204
- }
205
-
206
- if (data.mods.includes("mod_nofail")) {
207
- multiplierMod *= Math.pow(0.95, data.misses);
208
- }
209
-
210
- awarded_sp = calculatePerformancePoints(
211
- data.speed * beatmaps.starRating! * multiplierMod,
212
- accurracy
213
- );
214
-
215
- if (beatmapPages.status == "UNRANKED") {
216
- awarded_sp = 0;
217
- }
218
-
219
- console.log("p1");
220
-
221
- let client: RedisClientType | undefined;
222
- let tokenId = -1;
223
-
224
- try {
225
- if (Buffer.from(token, "base64").length != 4096) {
226
- console.log("token length check failed");
227
- return NextResponse.json(
228
- {
229
- error: "",
230
- },
231
- { status: 400 }
232
- );
233
- }
234
-
235
- client = createClient({
236
- username: process.env.REDIS_USERNAME,
237
- password: process.env.REDIS_PASSWORD,
238
- socket: {
239
- host: process.env.REDIS_HOST,
240
- port: process.env.REDIS_PORT as unknown as number,
241
- },
242
- });
243
-
244
- client.on("error", (err) => console.log("Redis Client Error", err));
245
- await client.connect();
246
-
247
- const { data, error } = await supabase
248
- .from("tokens")
249
- .insert({})
250
- .select()
251
- .single();
252
-
253
- if (error !== null) {
254
- console.log(error);
255
- throw error;
256
- }
257
-
258
- tokenId = data.id;
259
-
260
- await client.xAdd("score:tokens", "*", {
261
- token: JSON.stringify({
262
- userId: userData.id,
263
- tokenId: tokenId,
264
- token: token,
265
- }),
266
- });
267
- } catch (exception) {
268
- console.log(exception);
269
- console.log("failed to send redis request", token, userData.id);
270
- } finally {
271
- client?.destroy();
272
- }
273
-
274
- // auto-exclude: if a newly-created account (>600 RP) submits a score
275
- try {
276
- const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
277
- if (
278
- awarded_sp > 600 &&
279
- userData?.created_at &&
280
- Date.now() - userData.created_at < ONE_WEEK
281
- ) {
282
- await supabase
283
- .from("profiles")
284
- .upsert({ id: userData.id, ban: "excluded", bannedAt: Date.now() });
285
-
286
- return NextResponse.json(
287
- { error: "User excluded due to suspicious activity." },
288
- { status: 400 }
289
- );
290
- }
291
- } catch (e) {
292
- console.error("safen/ auto-exclude check failed:", e);
293
- }
294
- let replayUrl: string | null = null;
295
-
296
- if (data.replayBytes) {
297
- try {
298
- const replayBuffer = Buffer.from(data.replayBytes, "base64");
299
- if (replayBuffer.length > 0) {
300
- const replayKey = `score-replay-${Date.now()}-${user.id}.rhr`;
301
- await s3Client.send(
302
- new PutObjectCommand({
303
- Bucket: "rhthia-avatars",
304
- Key: replayKey,
305
- Body: replayBuffer,
306
- ContentType: "application/octet-stream",
307
- })
308
- );
309
- replayUrl = `https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/${replayKey}`;
310
- }
311
- } catch (error) {
312
- console.error("Replay upload failed:", error);
313
- }
314
- }
315
-
316
- await supabase.from("scores").upsert({
317
- beatmapHash: beatmaps.beatmapHash,
318
- replayHwid: "",
319
- replay_url: replayUrl,
320
- songId: beatmaps.beatmapHash,
321
- userId: userData.id,
322
- passed,
323
- misses: data.misses,
324
- awarded_sp: Math.round(awarded_sp * 100) / 100,
325
- speed: data.speed,
326
- mods: data.mods,
327
- additional_data: "",
328
- spin: data.spin || false,
329
- token: tokenId === -1 ? undefined : tokenId,
330
- });
331
-
332
- console.log("p2");
333
-
334
- let { data: scores2, error: errorsp } = await supabase
335
- .from("scores")
336
- .select(`awarded_sp,beatmapHash,spin`)
337
- .eq("userId", userData.id)
338
- .neq("awarded_sp", 0)
339
- .eq("passed", true)
340
- .order("awarded_sp", { ascending: false });
341
-
342
- if (scores2 == null) return NextResponse.json({ error: "No scores" });
343
-
344
- let allHashMap: Record<string, number> = {};
345
- let spinHashMap: Record<string, number> = {};
346
-
347
- for (const score of scores2) {
348
- const { beatmapHash, awarded_sp } = score;
349
-
350
- if (!beatmapHash || !awarded_sp) continue;
351
-
352
- // Normal Scores
353
- if (!allHashMap[beatmapHash] || allHashMap[beatmapHash] < awarded_sp) {
354
- allHashMap[beatmapHash] = awarded_sp;
355
- }
356
-
357
- // Spin Scores
358
- if (score.spin) {
359
- if (!spinHashMap[beatmapHash] || spinHashMap[beatmapHash] < awarded_sp) {
360
- spinHashMap[beatmapHash] = awarded_sp;
361
- }
362
- }
363
- }
364
- // All scores
365
- const totalSp = weightCalculate(allHashMap);
366
-
367
- // Only spin scores
368
- const spinTotalSp = weightCalculate(spinHashMap);
369
-
370
- await supabase.from("profiles").upsert({
371
- id: userData.id,
372
- play_count: (userData.play_count || 0) + 1,
373
- skill_points: Math.round(totalSp * 100) / 100,
374
- spin_skill_points: Math.round(spinTotalSp * 100) / 100,
375
- squares_hit: (userData.squares_hit || 0) + data.hits,
376
- });
377
- console.log("p3");
378
-
379
- await invalidateCachePrefix(`userscore:${userData.id}`);
380
- const beatmapIsRanked =
381
- beatmapPages?.status === "RANKED" || beatmapPages?.status === "APPROVED";
382
- if (beatmapIsRanked) {
383
- await invalidateCachePrefix(`beatmap-scores:${beatmaps.beatmapHash}`);
384
- }
385
-
386
- // Grant special badges if applicable
387
- if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
388
- try {
389
- const { data: badgeResult, error: badgeError } = await supabase.rpc(
390
- "grant_special_badges",
391
- {
392
- p_user_id: userData.id,
393
- p_beatmap_id: beatmapPages.id,
394
- p_spin: data.spin || false,
395
- p_passed: passed,
396
- }
397
- );
398
-
399
- const result = badgeResult as { granted: boolean; badge: string | null };
400
- if (result && result.granted) {
401
- console.log(`Badge granted: ${result.badge} to user ${userData.id}`);
402
- }
403
- } catch (error) {
404
- console.error("Error granting badge:", error);
405
- }
406
- }
407
- return NextResponse.json({});
408
- }
409
-
410
- export function weightCalculate(hashMap: Record<string, number>) {
411
- let totalSp = 0;
412
- let weight = 100;
413
-
414
- const values = Object.values(hashMap);
415
- values.sort((a, b) => b - a);
416
-
417
- for (const score of values) {
418
- totalSp += ((score || 0) * weight) / 100;
419
- weight = weight * 0.97;
420
-
421
- if (weight < 5) {
422
- break;
423
- }
424
- }
425
- return totalSp;
426
- }
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { decryptString } from "../utils/security";
6
+ import { isEqual } from "lodash";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
+ import { User } from "@supabase/supabase-js";
9
+ import { invalidateCachePrefix } from "../utils/cache";
10
+ import { createClient, RedisClientType } from "redis";
11
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
12
+
13
+ const s3Client = new S3Client({
14
+ region: "auto",
15
+ endpoint: "https://s3.eu-central-003.backblazeb2.com",
16
+ credentials: {
17
+ secretAccessKey: process.env.SECRET_BUCKET || "",
18
+ accessKeyId: process.env.ACCESS_BUCKET || "",
19
+ },
20
+ requestChecksumCalculation: "WHEN_REQUIRED",
21
+ });
22
+
23
+ s3Client.middlewareStack.add(
24
+ (next) =>
25
+ async (args): Promise<any> => {
26
+ const request = args.request as RequestInit;
27
+ const headers = (request.headers || {}) as Record<string, string>;
28
+
29
+ delete headers["x-amz-checksum-crc32"];
30
+ delete headers["x-amz-checksum-crc32c"];
31
+ delete headers["x-amz-checksum-sha1"];
32
+ delete headers["x-amz-checksum-sha256"];
33
+
34
+ request.headers = headers;
35
+ return next(args);
36
+ },
37
+ { step: "build", name: "stripBackblazeChecksumHeaders" }
38
+ );
39
+
40
+ export const Schema = {
41
+ input: z.strictObject({
42
+ session: z.string(),
43
+ secret: z.string(),
44
+ token: z.string(),
45
+ data: z.strictObject({
46
+ onlineMapId: z.number(),
47
+ misses: z.number(),
48
+ hits: z.number(),
49
+ speed: z.number(),
50
+ mods: z.array(z.string()),
51
+ spin: z.boolean(),
52
+ replayBytes: z.string().nullable().optional(),
53
+ pauses: z.number().nullable().optional(),
54
+ failTime: z.number().nullable().optional(),
55
+ }),
56
+ }),
57
+ output: z.object({
58
+ error: z.string().optional(),
59
+ }),
60
+ };
61
+
62
+ function easeInExpoDeqHard(x: number, star: number) {
63
+ let exponent = 100 - 12 * star;
64
+ if (exponent < 5) exponent = 5;
65
+ return x === 0 ? 0 : Math.pow(2, exponent * x - exponent);
66
+ }
67
+
68
+ export function calculatePerformancePoints(
69
+ starRating: number,
70
+ accuracy: number
71
+ ) {
72
+ return (
73
+ Math.round(
74
+ Math.pow(
75
+ (starRating * easeInExpoDeqHard(accuracy, starRating) * 100) / 2,
76
+ 2
77
+ ) / 1000
78
+ ) * 2
79
+ );
80
+ }
81
+
82
+ export async function POST(request: Request): Promise<NextResponse> {
83
+ console.log("Received submitScoreInternal request");
84
+ return protectedApi({
85
+ request,
86
+ schema: Schema,
87
+ authorization: validUser,
88
+ activity: handler,
89
+ });
90
+ }
91
+
92
+ export async function handler({
93
+ data,
94
+ token,
95
+ session,
96
+ secret,
97
+ }: (typeof Schema)["input"]["_type"]): Promise<
98
+ NextResponse<(typeof Schema)["output"]["_type"]>
99
+ > {
100
+ if (secret !== "testing-1") {
101
+ console.log("Internal usage only");
102
+ return NextResponse.json({
103
+ error: "Internal usage only",
104
+ });
105
+ }
106
+ const user = (await getUserBySession(session)) as User;
107
+
108
+ if (!user) {
109
+ console.log("token-expired");
110
+ return NextResponse.json(
111
+ {
112
+ error: "token-expired",
113
+ },
114
+ { status: 401 }
115
+ );
116
+ }
117
+
118
+ let { data: leversData } = await supabase
119
+ .from("levers")
120
+ .select("*")
121
+ .eq("id", 1)
122
+ .single();
123
+
124
+ if (leversData && leversData.disable_scores) {
125
+ console.log("Lever pulled - blocked score");
126
+ return NextResponse.json({
127
+ error: "Scores are temporarily disabled",
128
+ });
129
+ }
130
+
131
+ if (data.failTime) {
132
+ console.log("Fail Score don't save");
133
+ return NextResponse.json({
134
+ error: "Fail time scores aren't saved for now",
135
+ });
136
+ }
137
+
138
+ let { data: userData, error: userError } = await supabase
139
+ .from("profiles")
140
+ .select("*")
141
+ .eq("uid", user.id)
142
+ .single();
143
+
144
+ if (!userData) {
145
+ console.log("User doesn't exist");
146
+ return NextResponse.json(
147
+ {
148
+ error: "User doesn't exist",
149
+ },
150
+ { status: 400 }
151
+ );
152
+ }
153
+
154
+ console.log(userData);
155
+
156
+ if (userData.ban == "excluded" || userData.ban == "restricted") {
157
+ console.log(
158
+ "Silenced, restricted or excluded players can't submit scores."
159
+ );
160
+ return NextResponse.json(
161
+ {
162
+ error: "Silenced, restricted or excluded players can't submit scores.",
163
+ },
164
+ { status: 400 }
165
+ );
166
+ }
167
+
168
+ let { data: beatmapPages, error: bpError } = await supabase
169
+ .from("beatmapPages")
170
+ .select("*")
171
+ .eq("id", data.onlineMapId)
172
+ .single();
173
+
174
+ let { data: beatmaps, error: bpErr } = await supabase
175
+ .from("beatmaps")
176
+ .select("*")
177
+ .eq("beatmapHash", beatmapPages!.latestBeatmapHash!)
178
+ .single();
179
+
180
+ if (!beatmaps) {
181
+ console.log("Map not submitted");
182
+ return NextResponse.json(
183
+ {
184
+ error: "Map not submitted",
185
+ },
186
+ { status: 400 }
187
+ );
188
+ }
189
+
190
+ if (!beatmapPages) {
191
+ console.log("Map page not submitted");
192
+ return NextResponse.json(
193
+ {
194
+ error: "Map not submitted",
195
+ },
196
+ { status: 400 }
197
+ );
198
+ }
199
+
200
+ await supabase.from("beatmaps").upsert({
201
+ beatmapHash: beatmapPages.latestBeatmapHash!,
202
+ playcount: (beatmaps!.playcount || 1) + 1,
203
+ });
204
+
205
+ let passed = true;
206
+
207
+ // Pass invalidation
208
+ if (data.misses + data.hits !== beatmaps.noteCount) {
209
+ passed = false;
210
+ }
211
+
212
+ const accurracy = data.hits / beatmaps.noteCount!;
213
+ let awarded_sp = 0;
214
+
215
+ console.log(
216
+ data.misses + data.hits == beatmaps.noteCount,
217
+ data.misses + data.hits,
218
+ beatmaps.noteCount
219
+ );
220
+
221
+ let multiplierMod = 1;
222
+ if (data.mods.includes("mod_hardrock")) {
223
+ multiplierMod *= 1; //todo: change here
224
+ }
225
+
226
+ if (data.mods.includes("mod_nofail")) {
227
+ multiplierMod *= Math.pow(0.95, data.misses);
228
+ }
229
+
230
+ awarded_sp = calculatePerformancePoints(
231
+ data.speed * beatmaps.starRating! * multiplierMod,
232
+ accurracy
233
+ );
234
+
235
+ if (beatmapPages.status == "UNRANKED") {
236
+ awarded_sp = 0;
237
+ }
238
+
239
+ console.log("p1");
240
+
241
+ let client: RedisClientType | undefined;
242
+ let tokenId = -1;
243
+
244
+ try {
245
+ if (Buffer.from(token, "base64").length != 4096) {
246
+ console.log("token length check failed");
247
+ return NextResponse.json(
248
+ {
249
+ error: "",
250
+ },
251
+ { status: 400 }
252
+ );
253
+ }
254
+
255
+ client = createClient({
256
+ username: process.env.REDIS_USERNAME,
257
+ password: process.env.REDIS_PASSWORD,
258
+ socket: {
259
+ host: process.env.REDIS_HOST,
260
+ port: process.env.REDIS_PORT as unknown as number,
261
+ },
262
+ });
263
+
264
+ client.on("error", (err) => console.log("Redis Client Error", err));
265
+ await client.connect();
266
+
267
+ const { data, error } = await supabase
268
+ .from("tokens")
269
+ .insert({})
270
+ .select()
271
+ .single();
272
+
273
+ if (error !== null) {
274
+ console.log(error);
275
+ throw error;
276
+ }
277
+
278
+ tokenId = data.id;
279
+
280
+ await client.xAdd("score:tokens", "*", {
281
+ token: JSON.stringify({
282
+ userId: userData.id,
283
+ tokenId: tokenId,
284
+ token: token,
285
+ }),
286
+ });
287
+ } catch (exception) {
288
+ console.log(exception);
289
+ console.log("failed to send redis request", {
290
+ token: "<Long>",
291
+ userId: userData.id,
292
+ });
293
+ } finally {
294
+ client?.destroy();
295
+ }
296
+
297
+ // auto-exclude: if a newly-created account (>600 RP) submits a score
298
+ try {
299
+ const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
300
+ if (
301
+ awarded_sp > 600 &&
302
+ userData?.created_at &&
303
+ Date.now() - userData.created_at < ONE_WEEK
304
+ ) {
305
+ await supabase
306
+ .from("profiles")
307
+ .upsert({ id: userData.id, ban: "excluded", bannedAt: Date.now() });
308
+
309
+ return NextResponse.json(
310
+ { error: "User excluded due to suspicious activity." },
311
+ { status: 400 }
312
+ );
313
+ }
314
+ } catch (e) {
315
+ console.error("safen/ auto-exclude check failed:", e);
316
+ }
317
+ let replayUrl: string | null = null;
318
+
319
+ if (data.replayBytes) {
320
+ try {
321
+ const replayBuffer = Buffer.from(data.replayBytes, "base64");
322
+ if (replayBuffer.length > 0) {
323
+ const replayKey = `score-replay-${Date.now()}-${user.id}.rhr`;
324
+ await s3Client.send(
325
+ new PutObjectCommand({
326
+ Bucket: "rhthia-avatars",
327
+ Key: replayKey,
328
+ Body: replayBuffer,
329
+ ContentType: "application/octet-stream",
330
+ })
331
+ );
332
+ replayUrl = `https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/${replayKey}`;
333
+ }
334
+ } catch (error) {
335
+ console.error("Replay upload failed:", error);
336
+ }
337
+ }
338
+
339
+ await supabase.from("scores").upsert({
340
+ beatmapHash: beatmaps.beatmapHash,
341
+ replayHwid: "",
342
+ replay_url: replayUrl,
343
+ songId: beatmaps.beatmapHash,
344
+ userId: userData.id,
345
+ passed,
346
+ misses: data.misses,
347
+ awarded_sp: Math.round(awarded_sp * 100) / 100,
348
+ speed: data.speed,
349
+ mods: data.mods,
350
+ additional_data: "",
351
+ spin: data.spin || false,
352
+ token: tokenId === -1 ? undefined : tokenId,
353
+ });
354
+
355
+ console.log("p2");
356
+
357
+ let { data: scores2, error: errorsp } = await supabase
358
+ .from("scores")
359
+ .select(`awarded_sp,beatmapHash,spin`)
360
+ .eq("userId", userData.id)
361
+ .neq("awarded_sp", 0)
362
+ .eq("passed", true)
363
+ .order("awarded_sp", { ascending: false });
364
+
365
+ if (scores2 == null) return NextResponse.json({ error: "No scores" });
366
+
367
+ let allHashMap: Record<string, number> = {};
368
+ let spinHashMap: Record<string, number> = {};
369
+
370
+ for (const score of scores2) {
371
+ const { beatmapHash, awarded_sp } = score;
372
+
373
+ if (!beatmapHash || !awarded_sp) continue;
374
+
375
+ // Normal Scores
376
+ if (!allHashMap[beatmapHash] || allHashMap[beatmapHash] < awarded_sp) {
377
+ allHashMap[beatmapHash] = awarded_sp;
378
+ }
379
+
380
+ // Spin Scores
381
+ if (score.spin) {
382
+ if (!spinHashMap[beatmapHash] || spinHashMap[beatmapHash] < awarded_sp) {
383
+ spinHashMap[beatmapHash] = awarded_sp;
384
+ }
385
+ }
386
+ }
387
+ // All scores
388
+ const totalSp = weightCalculate(allHashMap);
389
+
390
+ // Only spin scores
391
+ const spinTotalSp = weightCalculate(spinHashMap);
392
+
393
+ await supabase.from("profiles").upsert({
394
+ id: userData.id,
395
+ play_count: (userData.play_count || 0) + 1,
396
+ skill_points: Math.round(totalSp * 100) / 100,
397
+ spin_skill_points: Math.round(spinTotalSp * 100) / 100,
398
+ squares_hit: (userData.squares_hit || 0) + data.hits,
399
+ });
400
+ console.log("p3");
401
+
402
+ await invalidateCachePrefix(`userscore:${userData.id}`);
403
+ const beatmapIsRanked =
404
+ beatmapPages?.status === "RANKED" || beatmapPages?.status === "APPROVED";
405
+ if (beatmapIsRanked) {
406
+ await invalidateCachePrefix(`beatmap-scores:${beatmaps.beatmapHash}`);
407
+ }
408
+
409
+ // Grant special badges if applicable
410
+ if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
411
+ try {
412
+ const { data: badgeResult, error: badgeError } = await supabase.rpc(
413
+ "grant_special_badges",
414
+ {
415
+ p_user_id: userData.id,
416
+ p_beatmap_id: beatmapPages.id,
417
+ p_spin: data.spin || false,
418
+ p_passed: passed,
419
+ }
420
+ );
421
+
422
+ const result = badgeResult as { granted: boolean; badge: string | null };
423
+ if (result && result.granted) {
424
+ console.log(`Badge granted: ${result.badge} to user ${userData.id}`);
425
+ }
426
+ } catch (error) {
427
+ console.error("Error granting badge:", error);
428
+ }
429
+ }
430
+ return NextResponse.json({});
431
+ }
432
+
433
+ export function weightCalculate(hashMap: Record<string, number>) {
434
+ let totalSp = 0;
435
+ let weight = 100;
436
+
437
+ const values = Object.values(hashMap);
438
+ values.sort((a, b) => b - a);
439
+
440
+ for (const score of values) {
441
+ totalSp += ((score || 0) * weight) / 100;
442
+ weight = weight * 0.97;
443
+
444
+ if (weight < 5) {
445
+ break;
446
+ }
447
+ }
448
+ return totalSp;
449
+ }