many many many many changes
This commit is contained in:
parent
6acf6e8635
commit
a031451fa3
@ -1,3 +1,5 @@
|
|||||||
TRIGGER_API_KEY=set me
|
TRIGGER_API_KEY=set me
|
||||||
TRIGGER_API_URL=https://trigger.example.com
|
TRIGGER_API_URL=https://trigger.example.com
|
||||||
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=set me
|
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=set me
|
||||||
|
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/ssr
|
21
.gitea/workflows/deploy.yml
Normal file
21
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
name: "deploy"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Cloning repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to dokku
|
||||||
|
uses: dokku/github-action@master
|
||||||
|
with:
|
||||||
|
git_remote_url: "ssh://dokku@10.0.3.39:22/scoresaber-reloadedv2"
|
||||||
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
FROM fascinated/docker-images:node-latest AS base
|
||||||
|
|
||||||
|
# Install depends
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json* package-lock.yaml* ./
|
||||||
|
RUN npm install --production --frozen-lockfile --quiet
|
||||||
|
|
||||||
|
# Build from source
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
RUN npm build
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 80
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
ENV PORT 80
|
||||||
|
CMD ["npm", "start"]
|
@ -1,5 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
generateEtags: true,
|
||||||
|
compress: true,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@ -8,6 +10,12 @@ const nextConfig = {
|
|||||||
port: "",
|
port: "",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "cdn.scoresaber.com",
|
||||||
|
port: "",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
1217
package-lock.json
generated
1217
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -10,16 +10,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
|
"@trigger.dev/nextjs": "^2.2.0",
|
||||||
|
"@trigger.dev/react": "^2.2.0",
|
||||||
|
"@trigger.dev/sdk": "^2.2.0",
|
||||||
|
"bluebird": "^3.7.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"encoding": "^0.1.13",
|
||||||
|
"mongoose": "^7.6.3",
|
||||||
"next": "13.5.5",
|
"next": "13.5.5",
|
||||||
|
"node-fetch-cache": "^3.1.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"@trigger.dev/sdk": "^2.2.0",
|
"winston": "^3.11.0"
|
||||||
"@trigger.dev/nextjs": "^2.2.0",
|
|
||||||
"@trigger.dev/react": "^2.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/node-fetch-cache": "^3.0.3",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
@ -34,4 +40,4 @@
|
|||||||
"trigger.dev": {
|
"trigger.dev": {
|
||||||
"endpointId": "scoresaber-reloaded-3SPH"
|
"endpointId": "scoresaber-reloaded-3SPH"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,38 @@
|
|||||||
|
import { connectMongo } from "@/database/mongo";
|
||||||
|
import { PlayerSchema } from "@/database/schemas/player";
|
||||||
import { triggerClient } from "@/trigger";
|
import { triggerClient } from "@/trigger";
|
||||||
|
import * as Utils from "@/utils/numberUtils";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return Response.json({ message: "No player provided" });
|
// Checks if there was an account provided
|
||||||
|
return Response.json({ error: true, message: "No player provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple account id validation
|
||||||
|
const isNumber = Utils.isNumber(id);
|
||||||
|
if (!isNumber) {
|
||||||
|
return Response.json({
|
||||||
|
error: true,
|
||||||
|
message: "Provided account id is not a number",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we're connected to the database
|
||||||
|
await connectMongo();
|
||||||
|
|
||||||
|
// Checks if the player is already in the database
|
||||||
|
const player = await PlayerSchema.findById(id);
|
||||||
|
if (player !== null) {
|
||||||
|
return Response.json({
|
||||||
|
error: true,
|
||||||
|
message: "Account already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the event to Trigger to setup the user
|
||||||
triggerClient.sendEvent({
|
triggerClient.sendEvent({
|
||||||
name: "user.add",
|
name: "user.add",
|
||||||
payload: {
|
payload: {
|
||||||
@ -14,5 +40,8 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({ message: "Hello from Next.js!" });
|
return Response.json({
|
||||||
|
error: false,
|
||||||
|
message: "We're setting up your account",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
19
src/app/api/player/search/route.ts
Normal file
19
src/app/api/player/search/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { searchByName } from "@/utils/scoresaber/api";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const name = searchParams.get("name");
|
||||||
|
if (!name) {
|
||||||
|
return Response.json({ error: true, message: "No player provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = await searchByName(name);
|
||||||
|
if (players === undefined) {
|
||||||
|
return Response.json({
|
||||||
|
error: true,
|
||||||
|
message: "No players with that name were found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: false, players: players });
|
||||||
|
}
|
@ -1,8 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import Image from "next/image";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const font = Inter({ subsets: ["latin-ext"], weight: "500" });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@ -18,7 +19,20 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>{children}</body>
|
<body className={font.className}>
|
||||||
|
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
|
||||||
|
<Image
|
||||||
|
alt="Background image"
|
||||||
|
src={"https://cdn.fascinated.cc/W9jC5MLf.jpg"}
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
objectPosition="center"
|
||||||
|
quality={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
|
|
||||||
|
|
||||||
|
import SearchPlayer from "@/components/SearchPlayer";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -12,7 +12,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mt-2 bg-neutral-800 w-full flex flex-col items-center justify-center rounded-sm">
|
<div className="mt-2 flex w-full flex-col items-center justify-center rounded-sm bg-neutral-800">
|
||||||
<Avatar
|
<Avatar
|
||||||
className="m-6"
|
className="m-6"
|
||||||
label="Player Avatar"
|
label="Player Avatar"
|
||||||
@ -22,20 +22,7 @@ export default function Home() {
|
|||||||
<p className="text-xl">Stranger</p>
|
<p className="text-xl">Stranger</p>
|
||||||
<p className="text mt-2">Find a player profile</p>
|
<p className="text mt-2">Find a player profile</p>
|
||||||
|
|
||||||
<form className="mt-6 flex gap-2">
|
<SearchPlayer />
|
||||||
<input
|
|
||||||
className="bg-transparent text-xs outline-none min-w-[14rem] border-b"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter a name or ScoreSaber profile..."
|
|
||||||
/>
|
|
||||||
<button className="bg-blue-600 hover:opacity-80 transition-all transform-gpu rounded-md p-1">
|
|
||||||
<MagnifyingGlassIcon
|
|
||||||
className="font-black"
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mb-6"></div>
|
<div className="mb-6"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,12 +4,14 @@ import Image from "next/image";
|
|||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
className: string;
|
size?: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Avatar({
|
export default function Avatar({
|
||||||
label = "Avatar",
|
label = "Avatar",
|
||||||
url,
|
url,
|
||||||
|
size = 150,
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
return (
|
return (
|
||||||
@ -18,8 +20,8 @@ export default function Avatar({
|
|||||||
className={clsx("rounded-full", className)}
|
className={clsx("rounded-full", className)}
|
||||||
alt={label}
|
alt={label}
|
||||||
src={url}
|
src={url}
|
||||||
width={150}
|
width={size}
|
||||||
height={150}
|
height={size}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -3,7 +3,7 @@ import Navbar from "./Navbar";
|
|||||||
export default function Container({ children }: { children: React.ReactNode }) {
|
export default function Container({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="md:max-w-[1200px] m-auto flex flex-col items-center justify-center">
|
<div className="m-auto flex flex-col items-center justify-center opacity-90 md:max-w-[1200px]">
|
||||||
<Navbar></Navbar>
|
<Navbar></Navbar>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
75
src/components/SearchPlayer.tsx
Normal file
75
src/components/SearchPlayer.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
|
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
|
export default function SearchPlayer() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't search if the query is too short
|
||||||
|
if (search.length < 4) {
|
||||||
|
setPlayers([]); // Clear players
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchPlayer(search);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
function searchPlayer(search: string) {
|
||||||
|
fetch(`/api/player/search?name=${search}`).then(async (reponse) => {
|
||||||
|
const json = await reponse.json();
|
||||||
|
|
||||||
|
if (json.error || !json.players) {
|
||||||
|
setPlayers([]); // Clear players
|
||||||
|
}
|
||||||
|
setPlayers(json.players); // Set players
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Take the user to the first account
|
||||||
|
if (players.length > 0) {
|
||||||
|
window.location.href = `/player/${players[0].id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="mt-6 flex gap-2" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
className="min-w-[14rem] border-b bg-transparent text-xs outline-none"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter a name or ScoreSaber profile..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="transform-gpu rounded-md bg-blue-600 p-1 transition-all hover:opacity-80">
|
||||||
|
<MagnifyingGlassIcon className="font-black" width={18} height={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute z-20 mt-7 flex min-w-[14rem] flex-col divide-y rounded-sm bg-neutral-700 shadow-sm",
|
||||||
|
players.length > 0 ? "flex" : "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{players.map((player: ScoresaberPlayer) => (
|
||||||
|
<a
|
||||||
|
key={player.id}
|
||||||
|
className="flex min-w-[14rem] items-center gap-2 rounded-sm p-2 transition-all hover:bg-neutral-600"
|
||||||
|
href={`/player/${player.id}`}
|
||||||
|
>
|
||||||
|
<Avatar label="Account" size={40} url={player.profilePicture} />
|
||||||
|
|
||||||
|
<p className="truncate">{player.name}</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
21
src/database/mongo.ts
Normal file
21
src/database/mongo.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a connection to Mongo
|
||||||
|
*/
|
||||||
|
export function connectMongo() {
|
||||||
|
const mongoUri = process.env.MONGODB_URI;
|
||||||
|
|
||||||
|
// Validate the mongo connection string
|
||||||
|
if (!mongoUri || typeof mongoUri !== "string") {
|
||||||
|
throw new Error("MONGO_URI is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mongoose is already connected
|
||||||
|
if (mongoose.connection.readyState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to mongo
|
||||||
|
return mongoose.connect(mongoUri);
|
||||||
|
}
|
15
src/database/schemas/player.ts
Normal file
15
src/database/schemas/player.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
import { ScoresaberSchema } from "./scoresaberAccount";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const playerSchema = new Schema({
|
||||||
|
_id: String,
|
||||||
|
avatar: String,
|
||||||
|
name: String,
|
||||||
|
country: String,
|
||||||
|
|
||||||
|
scoresaber: ScoresaberSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlayerSchema =
|
||||||
|
mongoose.models.Player || mongoose.model("Player", playerSchema);
|
29
src/database/schemas/scoresaberAccount.ts
Normal file
29
src/database/schemas/scoresaberAccount.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const badgeSchema = new Schema({
|
||||||
|
image: String,
|
||||||
|
description: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scoreStatsSchema = new Schema({
|
||||||
|
totalScore: Number,
|
||||||
|
totalRankedScore: Number,
|
||||||
|
averageRankedAccuracy: Number,
|
||||||
|
totalPlayCount: Number,
|
||||||
|
rankedPlayCount: Number,
|
||||||
|
replaysWatched: Number,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScoresaberSchema = new Schema({
|
||||||
|
pp: Number,
|
||||||
|
rank: Number,
|
||||||
|
countryRank: Number,
|
||||||
|
role: String,
|
||||||
|
badges: [badgeSchema],
|
||||||
|
histories: String,
|
||||||
|
scoreStats: scoreStatsSchema,
|
||||||
|
permissions: Number,
|
||||||
|
banned: Boolean,
|
||||||
|
inactive: Boolean,
|
||||||
|
});
|
38
src/database/schemas/scoresaberLeaderboard.ts
Normal file
38
src/database/schemas/scoresaberLeaderboard.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const scoresaberLeaderboardDifficulty = new Schema({
|
||||||
|
leaderboardId: Number,
|
||||||
|
difficulty: Number,
|
||||||
|
gameMode: String,
|
||||||
|
difficultyRaw: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scoresaberLeaderboard = new Schema({
|
||||||
|
_id: String,
|
||||||
|
songHash: String,
|
||||||
|
songName: String,
|
||||||
|
songSubName: String,
|
||||||
|
songAuthorName: String,
|
||||||
|
levelAuthorName: String,
|
||||||
|
difficulty: scoresaberLeaderboardDifficulty,
|
||||||
|
maxScore: Number,
|
||||||
|
createdDate: String,
|
||||||
|
rankedDate: [String],
|
||||||
|
qualifiedDate: [String],
|
||||||
|
lovedDate: [String],
|
||||||
|
ranked: Boolean,
|
||||||
|
qualified: Boolean,
|
||||||
|
loved: Boolean,
|
||||||
|
maxPP: Number,
|
||||||
|
stars: Number,
|
||||||
|
positiveModifiers: Boolean,
|
||||||
|
plays: Number,
|
||||||
|
dailyPlays: Number,
|
||||||
|
coverImage: String,
|
||||||
|
difficulties: [scoresaberLeaderboardDifficulty],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScoreSaberLeaderboard =
|
||||||
|
mongoose.models.ScoreSaberLeaderboard ||
|
||||||
|
mongoose.model("ScoreSaberLeaderboard", scoresaberLeaderboard);
|
26
src/database/schemas/scoresaberScore.ts
Normal file
26
src/database/schemas/scoresaberScore.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const scoresaberScore = new Schema({
|
||||||
|
_id: String,
|
||||||
|
playerId: String,
|
||||||
|
leaderboardId: String,
|
||||||
|
rank: Number,
|
||||||
|
baseScore: Number,
|
||||||
|
modifiedScore: Number,
|
||||||
|
pp: Number,
|
||||||
|
weight: Number,
|
||||||
|
modifiers: String,
|
||||||
|
multiplier: Number,
|
||||||
|
badCuts: Number,
|
||||||
|
missedNotes: Number,
|
||||||
|
maxCombo: Number,
|
||||||
|
fullCombo: Boolean,
|
||||||
|
hmd: Number,
|
||||||
|
hasReply: Boolean,
|
||||||
|
timeSet: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScoresaberScore =
|
||||||
|
mongoose.models.ScoreSaberScores ||
|
||||||
|
mongoose.model("ScoreSaberScores", scoresaberScore);
|
90
src/jobs/fetchNewScores.ts
Normal file
90
src/jobs/fetchNewScores.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { connectMongo } from "@/database/mongo";
|
||||||
|
import { PlayerSchema } from "@/database/schemas/player";
|
||||||
|
import { ScoresaberScore } from "@/database/schemas/scoresaberScore";
|
||||||
|
import { triggerClient } from "@/trigger";
|
||||||
|
import { fetchScores } from "@/utils/scoresaber/api";
|
||||||
|
import { createScore, updateScore } from "@/utils/scoresaber/db";
|
||||||
|
import { cronTrigger } from "@trigger.dev/sdk";
|
||||||
|
|
||||||
|
triggerClient.defineJob({
|
||||||
|
id: "fetch-new-scores",
|
||||||
|
name: "Scores: Fetch all new scores for players",
|
||||||
|
version: "0.0.1",
|
||||||
|
trigger: cronTrigger({
|
||||||
|
cron: "*/15 * * * *", // Fetch new scores every 15 minutes
|
||||||
|
}),
|
||||||
|
// trigger: eventTrigger({
|
||||||
|
// name: "user.add",
|
||||||
|
// }),
|
||||||
|
run: async (payload, io, ctx) => {
|
||||||
|
await io.logger.info("Scores: Fetching all new scores for players");
|
||||||
|
|
||||||
|
// Ensure we're connected to the database
|
||||||
|
await connectMongo();
|
||||||
|
|
||||||
|
const players = await PlayerSchema.find().select("_id"); // Get all players
|
||||||
|
for (const player of players) {
|
||||||
|
// Loop through all players
|
||||||
|
await io.logger.info(
|
||||||
|
`Scores: Fetching new scores for player: "${player._id}"`,
|
||||||
|
);
|
||||||
|
// Get the old scores for the player
|
||||||
|
const oldScores = await ScoresaberScore.find({ playerId: player._id })
|
||||||
|
.select("_id")
|
||||||
|
.select("timeSet")
|
||||||
|
.sort("-timeSet")
|
||||||
|
.limit(100) // Limit to 100 scores so we don't violate the db
|
||||||
|
.exec();
|
||||||
|
const mostRecentScore = oldScores[0];
|
||||||
|
console.log(mostRecentScore);
|
||||||
|
let search = true;
|
||||||
|
|
||||||
|
let page = 0;
|
||||||
|
let newScoresCount = 0;
|
||||||
|
while (search === true) {
|
||||||
|
const newScores = await fetchScores(player._id, page++);
|
||||||
|
if (newScores === undefined) {
|
||||||
|
search = false;
|
||||||
|
io.logger.warn(
|
||||||
|
`Scores: Failed to fetch scores for player: "${player._id}"`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any scores were returned
|
||||||
|
if (newScores.length === 0) {
|
||||||
|
search = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through the page of scores
|
||||||
|
for (const scoreData of newScores) {
|
||||||
|
const score = scoreData.score;
|
||||||
|
const leaderboard = scoreData.leaderboard;
|
||||||
|
|
||||||
|
// Check if the latest score is the same as the most recent score
|
||||||
|
// If it is, we've reached the end of the new scores
|
||||||
|
if (score.id == mostRecentScore._id) {
|
||||||
|
search = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasScoreOnLeaderboard = await ScoresaberScore.exists({
|
||||||
|
leaderboardId: leaderboard.id,
|
||||||
|
});
|
||||||
|
if (!hasScoreOnLeaderboard) {
|
||||||
|
await createScore(player.id, scoreData);
|
||||||
|
} else {
|
||||||
|
await updateScore(player.id, scoreData);
|
||||||
|
}
|
||||||
|
|
||||||
|
newScoresCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io.logger.info(
|
||||||
|
`Scores: Fetched ${newScoresCount} new scores for player: "${player._id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@ -1,3 +1,4 @@
|
|||||||
// export all your job files here
|
// export all your job files here
|
||||||
|
|
||||||
|
export * from "./fetchNewScores";
|
||||||
export * from "./setupUser";
|
export * from "./setupUser";
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
|
import { connectMongo } from "@/database/mongo";
|
||||||
|
import { PlayerSchema } from "@/database/schemas/player";
|
||||||
|
import { ScoresaberError } from "@/schemas/scoresaber/error";
|
||||||
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
import { triggerClient } from "@/trigger";
|
import { triggerClient } from "@/trigger";
|
||||||
|
import * as Utils from "@/utils/numberUtils";
|
||||||
|
import { fetchAllScores } from "@/utils/scoresaber/api";
|
||||||
|
import { createScore } from "@/utils/scoresaber/db";
|
||||||
import { eventTrigger } from "@trigger.dev/sdk";
|
import { eventTrigger } from "@trigger.dev/sdk";
|
||||||
|
|
||||||
triggerClient.defineJob({
|
triggerClient.defineJob({
|
||||||
@ -10,7 +17,69 @@ triggerClient.defineJob({
|
|||||||
}),
|
}),
|
||||||
run: async (payload, io, ctx) => {
|
run: async (payload, io, ctx) => {
|
||||||
const { id } = payload;
|
const { id } = payload;
|
||||||
|
const isNumber = Utils.isNumber(id);
|
||||||
|
if (!isNumber) {
|
||||||
|
await io.logger.warn(`Setup User: Failed - Invalid account id: "${id}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await io.logger.info(`Setup User: Running for ${id}`);
|
await io.logger.info(`Setup User: Running for account: "${id}"`);
|
||||||
|
|
||||||
|
const resposnse = await io.backgroundFetch<
|
||||||
|
ScoresaberPlayer | ScoresaberError
|
||||||
|
>("fetch-user-data", `https://scoresaber.com/api/player/${id}/full`);
|
||||||
|
|
||||||
|
// Check if there was an error fetching the user data
|
||||||
|
const error = resposnse as ScoresaberError;
|
||||||
|
if (error.message !== undefined) {
|
||||||
|
await io.logger.error(
|
||||||
|
`Setup User: Failed - Error fetching user data: "${error.message}"`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = resposnse as ScoresaberPlayer;
|
||||||
|
|
||||||
|
await connectMongo(); // Ensure we're connected to the database
|
||||||
|
const player = await PlayerSchema.findOne({ id: user.id });
|
||||||
|
if (player !== null) {
|
||||||
|
await io.logger.info(
|
||||||
|
`Setup User: Failed - Player already exists: "${player.id}"`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await io.logger.info(`Setup User: Creating player: "${user.id}"`);
|
||||||
|
const newPlayer = await PlayerSchema.create({
|
||||||
|
_id: user.id,
|
||||||
|
avatar: user.profilePicture,
|
||||||
|
name: user.name,
|
||||||
|
country: user.country,
|
||||||
|
|
||||||
|
scoresaber: {
|
||||||
|
pp: user.pp,
|
||||||
|
rank: user.rank,
|
||||||
|
countryRank: user.countryRank,
|
||||||
|
role: user.role,
|
||||||
|
badges: user.badges,
|
||||||
|
histories: user.histories,
|
||||||
|
scoreStats: user.scoreStats,
|
||||||
|
permissions: user.permissions,
|
||||||
|
inactive: user.inactive,
|
||||||
|
},
|
||||||
|
}); // Save the player to the database
|
||||||
|
io.logger.info(`Setup User: Created player: "${user.id}"`);
|
||||||
|
|
||||||
|
io.logger.info(`Setup User: Fetching scores for player: "${user.id}"`);
|
||||||
|
const scores = await fetchAllScores(newPlayer.id, "recent");
|
||||||
|
if (scores == undefined) {
|
||||||
|
await io.logger.error(`Setup User: Failed - Error fetching scores`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scoreSaberScore of scores) {
|
||||||
|
createScore(user.id, scoreSaberScore);
|
||||||
|
}
|
||||||
|
io.logger.info(`Setup User: Fetched scores for player: "${user.id}"`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
5
src/logger.ts
Normal file
5
src/logger.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import winston from "winston";
|
||||||
|
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
transports: [new winston.transports.Console()],
|
||||||
|
});
|
4
src/schemas/scoresaber/badge.ts
Normal file
4
src/schemas/scoresaber/badge.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type ScoresaberBadge = {
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
};
|
6
src/schemas/scoresaber/difficulty.ts
Normal file
6
src/schemas/scoresaber/difficulty.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type ScoresaberDifficulty = {
|
||||||
|
leaderboardId: number;
|
||||||
|
difficulty: number;
|
||||||
|
gameMode: string;
|
||||||
|
difficultyRaw: string;
|
||||||
|
};
|
3
src/schemas/scoresaber/error.ts
Normal file
3
src/schemas/scoresaber/error.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type ScoresaberError = {
|
||||||
|
message: string;
|
||||||
|
};
|
28
src/schemas/scoresaber/leaderboard.ts
Normal file
28
src/schemas/scoresaber/leaderboard.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { ScoresaberDifficulty } from "./difficulty";
|
||||||
|
import { ScoresaberScore } from "./score";
|
||||||
|
|
||||||
|
export type ScoresaberLeaderboardInfo = {
|
||||||
|
id: string;
|
||||||
|
songHash: string;
|
||||||
|
songName: string;
|
||||||
|
songSubName: string;
|
||||||
|
songAuthorName: string;
|
||||||
|
levelAuthorName: string;
|
||||||
|
difficulty: ScoresaberDifficulty;
|
||||||
|
maxScore: number;
|
||||||
|
createdDate: string;
|
||||||
|
rankedDate: string[];
|
||||||
|
qualifiedDate: string[];
|
||||||
|
lovedDate: string[];
|
||||||
|
ranked: boolean;
|
||||||
|
qualified: boolean;
|
||||||
|
loved: boolean;
|
||||||
|
maxPP: number;
|
||||||
|
stars: number;
|
||||||
|
positiveModifiers: boolean;
|
||||||
|
plays: number;
|
||||||
|
dailyPlays: number;
|
||||||
|
coverImage: string;
|
||||||
|
playerScore: ScoresaberScore[];
|
||||||
|
difficulties: ScoresaberDifficulty[];
|
||||||
|
};
|
19
src/schemas/scoresaber/player.ts
Normal file
19
src/schemas/scoresaber/player.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ScoresaberBadge } from "./badge";
|
||||||
|
import { ScoresaberScoreStats } from "./scoreStats";
|
||||||
|
|
||||||
|
export type ScoresaberPlayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
profilePicture: string;
|
||||||
|
country: string;
|
||||||
|
pp: number;
|
||||||
|
rank: number;
|
||||||
|
countryRank: number;
|
||||||
|
role: string;
|
||||||
|
badges: ScoresaberBadge[];
|
||||||
|
histories: string;
|
||||||
|
scoreStats: ScoresaberScoreStats[];
|
||||||
|
permissions: number;
|
||||||
|
banned: boolean;
|
||||||
|
inactive: boolean;
|
||||||
|
};
|
7
src/schemas/scoresaber/playerScore.ts
Normal file
7
src/schemas/scoresaber/playerScore.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { ScoresaberLeaderboardInfo } from "./leaderboard";
|
||||||
|
import { ScoresaberScore } from "./score";
|
||||||
|
|
||||||
|
export type ScoresaberPlayerScore = {
|
||||||
|
score: ScoresaberScore;
|
||||||
|
leaderboard: ScoresaberLeaderboardInfo;
|
||||||
|
};
|
18
src/schemas/scoresaber/score.ts
Normal file
18
src/schemas/scoresaber/score.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export type ScoresaberScore = {
|
||||||
|
id: number;
|
||||||
|
leaderboardPlayerInfo: string;
|
||||||
|
rank: number;
|
||||||
|
baseScore: number;
|
||||||
|
modifiedScore: number;
|
||||||
|
pp: number;
|
||||||
|
weight: number;
|
||||||
|
modifiers: string;
|
||||||
|
multiplier: number;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
maxCombo: number;
|
||||||
|
fullCombo: boolean;
|
||||||
|
hmd: number;
|
||||||
|
hasReply: boolean;
|
||||||
|
timeSet: string;
|
||||||
|
};
|
8
src/schemas/scoresaber/scoreStats.ts
Normal file
8
src/schemas/scoresaber/scoreStats.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type ScoresaberScoreStats = {
|
||||||
|
totalScore: number;
|
||||||
|
totalRankedScore: number;
|
||||||
|
averageRankedAccuracy: number;
|
||||||
|
totalPlayCount: number;
|
||||||
|
rankedPlayCount: number;
|
||||||
|
replaysWatched: number;
|
||||||
|
};
|
59
src/utils/fetchWithQueue.ts
Normal file
59
src/utils/fetchWithQueue.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { fetchBuilder, MemoryCache } from "node-fetch-cache";
|
||||||
|
|
||||||
|
export class FetchQueue {
|
||||||
|
private _fetch;
|
||||||
|
private _queue: string[];
|
||||||
|
private _rateLimitReset: number;
|
||||||
|
|
||||||
|
constructor(ttl: number) {
|
||||||
|
this._fetch = fetchBuilder.withCache(
|
||||||
|
new MemoryCache({
|
||||||
|
ttl: ttl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this._queue = [];
|
||||||
|
this._rateLimitReset = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the given url, and handles rate limiting
|
||||||
|
* re-requesting if the rate limit is exceeded.
|
||||||
|
*
|
||||||
|
* @param url the url to fetch
|
||||||
|
* @returns the response
|
||||||
|
*/
|
||||||
|
public async fetch(url: string): Promise<any> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now < this._rateLimitReset) {
|
||||||
|
this._queue.push(url);
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
setTimeout(resolve, this._rateLimitReset - now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this._fetch(url);
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = Number(response.headers.get("retry-after")) * 1000;
|
||||||
|
this._queue.push(url);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, retryAfter));
|
||||||
|
return this.fetch(this._queue.shift() as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.headers.has("x-ratelimit-remaining")) {
|
||||||
|
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
||||||
|
if (remaining === 0) {
|
||||||
|
const reset = Number(response.headers.get("x-ratelimit-reset")) * 1000;
|
||||||
|
this._queue.push(url);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, reset - now));
|
||||||
|
return this.fetch(this._queue.shift() as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._queue.length > 0) {
|
||||||
|
const nextUrl = this._queue.shift();
|
||||||
|
return this.fetch(nextUrl as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
9
src/utils/numberUtils.ts
Normal file
9
src/utils/numberUtils.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Checks if the given value is an number.
|
||||||
|
*
|
||||||
|
* @param value the number
|
||||||
|
* @returns true if value is a number, otherwise false
|
||||||
|
*/
|
||||||
|
export function isNumber(value: any): boolean {
|
||||||
|
return !isNaN(value);
|
||||||
|
}
|
90
src/utils/scoresaber/api.ts
Normal file
90
src/utils/scoresaber/api.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { logger } from "@/logger";
|
||||||
|
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
|
||||||
|
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
||||||
|
import { fetchBuilder, MemoryCache } from "node-fetch-cache";
|
||||||
|
import { formatString } from "../string";
|
||||||
|
|
||||||
|
// Create a fetch instance with a cache
|
||||||
|
const fetch = fetchBuilder.withCache(
|
||||||
|
new MemoryCache({
|
||||||
|
ttl: 15 * 60 * 1000, // 15 minutes
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Api endpoints
|
||||||
|
const API_URL = "https://scoresaber.com/api";
|
||||||
|
const SEARCH_PLAYER_URL =
|
||||||
|
API_URL + "/players?search={}&page=1&withMetadata=false";
|
||||||
|
const PLAYER_SCORES =
|
||||||
|
API_URL + "/player/{}/scores?limit={}&sort={}&page={}&withMetadata=true";
|
||||||
|
|
||||||
|
const SearchType = {
|
||||||
|
RECENT: "recent",
|
||||||
|
TOP: "top",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a list of players by name
|
||||||
|
*
|
||||||
|
* @param name the name to search
|
||||||
|
* @returns a list of players
|
||||||
|
*/
|
||||||
|
export async function searchByName(
|
||||||
|
name: string,
|
||||||
|
): Promise<ScoresaberPlayer[] | undefined> {
|
||||||
|
const response = await fetch(formatString(SEARCH_PLAYER_URL, name));
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
// Check if there was an error fetching the user data
|
||||||
|
if (json.errorMessage) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.players as ScoresaberPlayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScores(
|
||||||
|
playerId: string,
|
||||||
|
page: number = 1,
|
||||||
|
searchType: string = SearchType.RECENT,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<ScoresaberPlayerScore[] | undefined> {
|
||||||
|
if (limit > 100) {
|
||||||
|
logger.warn(
|
||||||
|
"Scoresaber API only allows a limit of 100 scores per request, limiting to 100.",
|
||||||
|
);
|
||||||
|
limit = 100;
|
||||||
|
}
|
||||||
|
const response = await fetch(
|
||||||
|
formatString(PLAYER_SCORES, playerId, limit, searchType, page),
|
||||||
|
);
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
// Check if there was an error fetching the user data
|
||||||
|
if (json.errorMessage) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.playerScores as ScoresaberPlayerScore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllScores(
|
||||||
|
playerId: string,
|
||||||
|
searchType: string,
|
||||||
|
): Promise<ScoresaberPlayerScore[] | undefined> {
|
||||||
|
const scores = new Array();
|
||||||
|
|
||||||
|
let done = false,
|
||||||
|
page = 1;
|
||||||
|
do {
|
||||||
|
const response = await fetchScores(playerId, page, searchType);
|
||||||
|
if (response == undefined || response.length === 0) {
|
||||||
|
done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
scores.push(...response);
|
||||||
|
page++;
|
||||||
|
} while (!done);
|
||||||
|
|
||||||
|
return scores as ScoresaberPlayerScore[];
|
||||||
|
}
|
92
src/utils/scoresaber/db.ts
Normal file
92
src/utils/scoresaber/db.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { ScoreSaberLeaderboard } from "@/database/schemas/scoresaberLeaderboard";
|
||||||
|
import { ScoresaberScore } from "@/database/schemas/scoresaberScore";
|
||||||
|
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
|
||||||
|
|
||||||
|
export async function createScore(
|
||||||
|
playerId: string,
|
||||||
|
scoreSaberScore: ScoresaberPlayerScore,
|
||||||
|
) {
|
||||||
|
const score = scoreSaberScore.score;
|
||||||
|
const leaderboard = scoreSaberScore.leaderboard;
|
||||||
|
|
||||||
|
await ScoresaberScore.create({
|
||||||
|
_id: score.id,
|
||||||
|
playerId: playerId,
|
||||||
|
leaderboardId: leaderboard.id,
|
||||||
|
rank: score.rank,
|
||||||
|
baseScore: score.baseScore,
|
||||||
|
modifiedScore: score.modifiedScore,
|
||||||
|
pp: score.pp,
|
||||||
|
weight: score.weight,
|
||||||
|
modifiers: score.modifiers,
|
||||||
|
multiplier: score.multiplier,
|
||||||
|
badCuts: score.badCuts,
|
||||||
|
missedNotes: score.missedNotes,
|
||||||
|
maxCombo: score.maxCombo,
|
||||||
|
fullCombo: score.fullCombo,
|
||||||
|
hmd: score.hmd,
|
||||||
|
hasReply: score.hasReply,
|
||||||
|
timeSet: new Date(score.timeSet).getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ScoreSaberLeaderboard.updateOne(
|
||||||
|
{ _id: leaderboard.id },
|
||||||
|
{
|
||||||
|
_id: leaderboard.id,
|
||||||
|
songHash: leaderboard.songHash,
|
||||||
|
songName: leaderboard.songName,
|
||||||
|
songSubName: leaderboard.songSubName,
|
||||||
|
songAuthorName: leaderboard.songAuthorName,
|
||||||
|
levelAuthorName: leaderboard.levelAuthorName,
|
||||||
|
difficulty: leaderboard.difficulty,
|
||||||
|
maxScore: leaderboard.maxScore,
|
||||||
|
createdDate: leaderboard.createdDate,
|
||||||
|
rankedDate: leaderboard.rankedDate,
|
||||||
|
qualifiedDate: leaderboard.qualifiedDate,
|
||||||
|
lovedDate: leaderboard.lovedDate,
|
||||||
|
ranked: leaderboard.ranked,
|
||||||
|
qualified: leaderboard.qualified,
|
||||||
|
loved: leaderboard.loved,
|
||||||
|
maxPP: leaderboard.maxPP,
|
||||||
|
stars: leaderboard.stars,
|
||||||
|
positiveModifiers: leaderboard.positiveModifiers,
|
||||||
|
plays: leaderboard.plays,
|
||||||
|
dailyPlays: leaderboard.dailyPlays,
|
||||||
|
coverImage: leaderboard.coverImage,
|
||||||
|
difficulties: leaderboard.difficulties,
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateScore(
|
||||||
|
playerId: string,
|
||||||
|
scoreSaberScore: ScoresaberPlayerScore,
|
||||||
|
) {
|
||||||
|
const score = scoreSaberScore.score;
|
||||||
|
const leaderboard = scoreSaberScore.leaderboard;
|
||||||
|
|
||||||
|
// Delete the old score
|
||||||
|
await ScoresaberScore.deleteOne({ _id: score.id });
|
||||||
|
|
||||||
|
// Create the new score
|
||||||
|
await ScoresaberScore.create({
|
||||||
|
_id: score.id,
|
||||||
|
playerId: playerId,
|
||||||
|
leaderboardId: leaderboard.id,
|
||||||
|
rank: score.rank,
|
||||||
|
baseScore: score.baseScore,
|
||||||
|
modifiedScore: score.modifiedScore,
|
||||||
|
pp: score.pp,
|
||||||
|
weight: score.weight,
|
||||||
|
modifiers: score.modifiers,
|
||||||
|
multiplier: score.multiplier,
|
||||||
|
badCuts: score.badCuts,
|
||||||
|
missedNotes: score.missedNotes,
|
||||||
|
maxCombo: score.maxCombo,
|
||||||
|
fullCombo: score.fullCombo,
|
||||||
|
hmd: score.hmd,
|
||||||
|
hasReply: score.hasReply,
|
||||||
|
timeSet: score.timeSet,
|
||||||
|
});
|
||||||
|
}
|
18
src/utils/string.ts
Normal file
18
src/utils/string.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Formats a string with the given arguments.
|
||||||
|
*
|
||||||
|
* @param str the string to check
|
||||||
|
* @param args the arguments to replace
|
||||||
|
* @returns the formatted string
|
||||||
|
*/
|
||||||
|
export function formatString(str: string, ...args: any[]): string {
|
||||||
|
return str.replace(/{}/g, (match) => {
|
||||||
|
// If there are no arguments, return the match
|
||||||
|
if (args.length === 0) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return the next argument
|
||||||
|
return String(args.shift());
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user