feat: redesign match play and add broadcast tooling
This commit is contained in:
517
scripts/seed-dev.ts
Normal file
517
scripts/seed-dev.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Small fixture set for the dev database: players, teams, real ranked maps,
|
||||
* a few finished matches, one open tournament, and one player waiting in the 1v1 queue.
|
||||
*
|
||||
* Refuses to run unless the database name is vash_dev.
|
||||
* bun scripts/seed-dev.ts
|
||||
*/
|
||||
import { SQL } from 'bun';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || process.env.DB_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL or DB_URL is not set');
|
||||
|
||||
const sql = new SQL(databaseUrl, { max: 1 });
|
||||
|
||||
const PLAYERS = [
|
||||
{
|
||||
id: 'dev-player-01',
|
||||
name: 'saltine',
|
||||
country: 'US',
|
||||
osuId: 9100001,
|
||||
elo: 2100,
|
||||
wins: 40,
|
||||
losses: 12,
|
||||
rank: 800
|
||||
},
|
||||
{
|
||||
id: 'dev-player-02',
|
||||
name: 'ricebowl',
|
||||
country: 'KR',
|
||||
osuId: 9100002,
|
||||
elo: 1950,
|
||||
wins: 32,
|
||||
losses: 14,
|
||||
rank: 2500
|
||||
},
|
||||
{
|
||||
id: 'dev-player-03',
|
||||
name: 'pebble',
|
||||
country: 'NL',
|
||||
osuId: 9100003,
|
||||
elo: 1800,
|
||||
wins: 28,
|
||||
losses: 16,
|
||||
rank: 6000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-04',
|
||||
name: 'nightbus',
|
||||
country: 'GB',
|
||||
osuId: 9100004,
|
||||
elo: 1700,
|
||||
wins: 22,
|
||||
losses: 18,
|
||||
rank: 12000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-05',
|
||||
name: 'teacup',
|
||||
country: 'DE',
|
||||
osuId: 9100005,
|
||||
elo: 1600,
|
||||
wins: 18,
|
||||
losses: 15,
|
||||
rank: 20000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-06',
|
||||
name: 'lantern',
|
||||
country: 'JP',
|
||||
osuId: 9100006,
|
||||
elo: 1500,
|
||||
wins: 16,
|
||||
losses: 16,
|
||||
rank: 35000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-07',
|
||||
name: 'moss',
|
||||
country: 'CA',
|
||||
osuId: 9100007,
|
||||
elo: 1400,
|
||||
wins: 14,
|
||||
losses: 17,
|
||||
rank: 50000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-08',
|
||||
name: 'keypad',
|
||||
country: 'FR',
|
||||
osuId: 9100008,
|
||||
elo: 1300,
|
||||
wins: 11,
|
||||
losses: 14,
|
||||
rank: 80000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-09',
|
||||
name: 'river',
|
||||
country: 'AU',
|
||||
osuId: 9100009,
|
||||
elo: 1200,
|
||||
wins: 9,
|
||||
losses: 12,
|
||||
rank: 110000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-10',
|
||||
name: 'comet',
|
||||
country: 'PL',
|
||||
osuId: 9100010,
|
||||
elo: 1100,
|
||||
wins: 7,
|
||||
losses: 11,
|
||||
rank: 150000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-11',
|
||||
name: 'soda',
|
||||
country: 'BR',
|
||||
osuId: 9100011,
|
||||
elo: 1050,
|
||||
wins: 5,
|
||||
losses: 9,
|
||||
rank: 200000
|
||||
},
|
||||
{
|
||||
id: 'dev-player-12',
|
||||
name: 'wool',
|
||||
country: 'SE',
|
||||
osuId: 9100012,
|
||||
elo: 1000,
|
||||
wins: 3,
|
||||
losses: 6,
|
||||
rank: 280000
|
||||
}
|
||||
] as const;
|
||||
|
||||
type Beatmap = {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
version: string;
|
||||
sr: number;
|
||||
bpm: number;
|
||||
length: number;
|
||||
cover: string | null;
|
||||
list: string | null;
|
||||
};
|
||||
|
||||
const FALLBACK: Beatmap[] = [
|
||||
['75', 'DISCO PRINCE', 'Kenji Ninuma', 'Normal', 2.4],
|
||||
['163', 'Crazy Noisy Bizarre Town', 'The DU', 'Normal', 3.1],
|
||||
['250', 'Sakura Kagetsu', 'Mutsuhiko Izumi', 'Normal', 3.6],
|
||||
['306', 'Airman ga Taosenai', 'Team.NEKOKAN', 'Normal', 4.4],
|
||||
['390', 'Kira Kira Days', 'MOSAIC.WAV', 'Normal', 4.2],
|
||||
['455', 'FREEDOM DiVE', 'xi', 'FOUR DIMENSIONS', 6.2],
|
||||
['500', 'Blue Zenith', 'xi', 'FOUR DIMENSIONS', 6.6],
|
||||
['1001', 'Pretender', 'Official HIGE DANdism', 'Insane', 5.1],
|
||||
['1002', 'Highscore', 'Panda Eyes', 'Extra', 5.4],
|
||||
['1003', 'Everything will freeze', 'UNDEAD CORPORATION', 'Extra', 5.8],
|
||||
['1004', 'Sidetracked Day', 'namirin', 'Insane', 4.8],
|
||||
['1005', 'Hitorigoto', 'ClariS', 'Insane', 4.6]
|
||||
].map(([id, title, artist, version, sr]) => ({
|
||||
id,
|
||||
title,
|
||||
artist,
|
||||
version,
|
||||
sr: Number(sr),
|
||||
bpm: 180,
|
||||
length: 180,
|
||||
cover: null,
|
||||
list: null
|
||||
}));
|
||||
|
||||
const DEV_LINKS = [
|
||||
{ label: 'Discord', url: 'https://discord.gg/n3mZgWk' },
|
||||
{ label: 'Main sheet', url: 'https://docs.google.com/spreadsheets/' },
|
||||
{ label: 'Livestream', url: 'https://twitch.tv/' }
|
||||
];
|
||||
const DEV_PRIZES = [
|
||||
{ place: 'Gold crown', prize: 'Profile banner, $130 (or 5 years of osu!supporter)' },
|
||||
{ place: 'Silver crown', prize: 'Profile banner, $60' },
|
||||
{ place: 'Bronze crown', prize: 'Profile banner, $30' }
|
||||
];
|
||||
|
||||
const SLOT_PLAN = [
|
||||
['NM', 1],
|
||||
['NM', 2],
|
||||
['NM', 3],
|
||||
['NM', 4],
|
||||
['HD', 1],
|
||||
['HD', 2],
|
||||
['HR', 1],
|
||||
['HR', 2],
|
||||
['DT', 1],
|
||||
['DT', 2],
|
||||
['FM', 1],
|
||||
['TB', 1]
|
||||
] as const;
|
||||
|
||||
async function main() {
|
||||
const [{ name }] = await sql`SELECT current_database() AS name`;
|
||||
// SEED_TEST_DATABASE lets a throwaway database be seeded too, for testing this script.
|
||||
if (name !== 'vash_dev' && name !== process.env.SEED_TEST_DATABASE) {
|
||||
throw new Error(`Refusing to seed ${name}. This script only runs against vash_dev.`);
|
||||
}
|
||||
|
||||
await sql.begin(async (tx) => {
|
||||
// Matches anyone played with the seeded players or pools go too; they would
|
||||
// block deleting what they reference. Deleting a match cascades to its rows.
|
||||
await tx`UPDATE tournament_match SET match_id = NULL WHERE match_id IN (SELECT id FROM match WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed') OR id IN (SELECT match_id FROM match_participant WHERE team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%')))`;
|
||||
await tx`DELETE FROM match WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed') OR id IN (SELECT match_id FROM match_participant WHERE team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%'))`;
|
||||
await tx`DELETE FROM match_game_score WHERE match_game_id IN (SELECT id FROM match_game WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed'))`;
|
||||
await tx`DELETE FROM match_game WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM match_participant_player WHERE participant_id IN (SELECT id FROM match_participant WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed'))`;
|
||||
await tx`DELETE FROM match_participant WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM match_event WHERE match_id IN (SELECT id FROM match WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM match_invite WHERE created_by = 'dev-seed' OR created_by LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM match WHERE created_by = 'dev-seed'`;
|
||||
await tx`DELETE FROM tournament_registration WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM tournament_staff WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM tournament_round WHERE tournament_id IN (SELECT id FROM tournament WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM tournament WHERE created_by = 'dev-seed'`;
|
||||
await tx`DELETE FROM mappool_slot WHERE mappool_id IN (SELECT id FROM mappool WHERE created_by = 'dev-seed')`;
|
||||
await tx`DELETE FROM mappool WHERE created_by = 'dev-seed'`;
|
||||
await tx`DELETE FROM match_queue WHERE user_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM notification WHERE user_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM player_rating WHERE user_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM team_member WHERE user_id LIKE 'dev-player-%' OR team_id IN (SELECT id FROM team WHERE owner_id LIKE 'dev-player-%')`;
|
||||
await tx`DELETE FROM team WHERE owner_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM session WHERE user_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM account WHERE user_id LIKE 'dev-player-%'`;
|
||||
await tx`DELETE FROM "user" WHERE id LIKE 'dev-player-%'`;
|
||||
});
|
||||
|
||||
for (const player of PLAYERS) {
|
||||
await sql`
|
||||
INSERT INTO "user" (id, name, email, email_verified, role, country_code, osu_id, created_at, updated_at)
|
||||
VALUES (
|
||||
${player.id},
|
||||
${player.name},
|
||||
${`${player.osuId}@osu.local`},
|
||||
true,
|
||||
'player',
|
||||
${player.country},
|
||||
${player.osuId},
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO account (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||
VALUES (${`dev-account-${player.osuId}`}, ${String(player.osuId)}, 'osu', ${player.id}, now(), now())
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO player_rating (user_id, elo, wins, losses, initial_elo, osu_rank_at_seed, provisional, updated_at)
|
||||
VALUES (${player.id}, ${player.elo}, ${player.wins}, ${player.losses}, ${player.elo}, ${player.rank}, false, now())
|
||||
`;
|
||||
const [personal] = await sql`
|
||||
INSERT INTO team (name, is_personal, owner_id)
|
||||
VALUES (${player.name}, true, ${player.id})
|
||||
RETURNING id
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO team_member (team_id, user_id, role)
|
||||
VALUES (${personal.id}, ${player.id}, 'owner')
|
||||
`;
|
||||
}
|
||||
|
||||
const teams = [
|
||||
{
|
||||
name: 'Night Shift',
|
||||
members: ['dev-player-01', 'dev-player-02', 'dev-player-03', 'dev-player-04']
|
||||
},
|
||||
{ name: 'Late Entry', members: ['dev-player-05', 'dev-player-06'] }
|
||||
];
|
||||
for (const group of teams) {
|
||||
const [team] = await sql`
|
||||
INSERT INTO team (name, is_personal, owner_id)
|
||||
VALUES (${group.name}, false, ${group.members[0]})
|
||||
RETURNING id
|
||||
`;
|
||||
for (const [index, member] of group.members.entries()) {
|
||||
await sql`
|
||||
INSERT INTO team_member (team_id, user_id, role)
|
||||
VALUES (${team.id}, ${member}, ${index === 0 ? 'owner' : 'member'})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const maps = await loadBeatmaps();
|
||||
const pools = [
|
||||
{ name: 'Dev Cup', min: 4.2, max: 5.2 },
|
||||
{ name: 'Dev Finals', min: 5.4, max: 6.6 },
|
||||
{ name: 'Dev Qualifiers', min: 3.2, max: 4.4 }
|
||||
];
|
||||
const poolIds: string[] = [];
|
||||
for (const pool of pools) {
|
||||
const chosen = pickBand(maps, pool.min, pool.max);
|
||||
const [row] = await sql`
|
||||
INSERT INTO mappool (name, created_by, verified_at, origin, rating)
|
||||
VALUES (${pool.name}, 'dev-seed', now(), 'Vash dev fixtures', ${(pool.min + pool.max) / 2})
|
||||
RETURNING id
|
||||
`;
|
||||
poolIds.push(row.id);
|
||||
for (let i = 0; i < SLOT_PLAN.length; i++) {
|
||||
const [category, order] = SLOT_PLAN[i];
|
||||
const map = chosen[i % chosen.length];
|
||||
const mods =
|
||||
category === 'NM' || category === 'TB' ? [] : [category === 'FM' ? 'FM' : category];
|
||||
await sql`
|
||||
INSERT INTO mappool_slot (
|
||||
mappool_id, category, order_in_category, beatmap_id, star_rating, bpm, total_length,
|
||||
mods, title, artist, version, cover_url, list_cover_url
|
||||
) VALUES (
|
||||
${row.id}, ${category}, ${order}, ${map.id}, ${map.sr}, ${map.bpm}, ${map.length},
|
||||
${sql.array(mods, 'TEXT')}, ${map.title}, ${map.artist}, ${map.version}, ${map.cover}, ${map.list}
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const personalTeams = await sql`
|
||||
SELECT id, owner_id, name FROM team WHERE owner_id LIKE 'dev-player-%' AND is_personal
|
||||
ORDER BY owner_id
|
||||
`;
|
||||
for (let i = 0; i < personalTeams.length - 1; i += 2) {
|
||||
const left = personalTeams[i];
|
||||
const right = personalTeams[i + 1];
|
||||
const leftPlayer = PLAYERS.find((player) => player.id === left.owner_id)!;
|
||||
const rightPlayer = PLAYERS.find((player) => player.id === right.owner_id)!;
|
||||
const leftWins = leftPlayer.elo >= rightPlayer.elo;
|
||||
const [created] = await sql`
|
||||
INSERT INTO match (name, state, config, mappool_id, winner_id, created_by, finished_at, kind, game, elo_applied)
|
||||
VALUES (
|
||||
${`${left.name} vs ${right.name}`},
|
||||
'FINISHED',
|
||||
${{ bestOf: 7, teamSize: 1, scoringType: 'score_v2', bans: 1, kind: 'quick' }},
|
||||
${poolIds[i % poolIds.length]},
|
||||
${leftWins ? left.id : right.id},
|
||||
'dev-seed',
|
||||
now() - make_interval(hours => ${i}),
|
||||
'quick',
|
||||
'osu',
|
||||
true
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
await addSide(created.id, left.id, left.owner_id, 0, leftWins ? 4 : 2);
|
||||
await addSide(created.id, right.id, right.owner_id, 1, leftWins ? 2 : 4);
|
||||
}
|
||||
|
||||
const [cup] =
|
||||
await sql`SELECT id FROM mappool WHERE name = 'Dev Cup' AND created_by = 'dev-seed'`;
|
||||
const [tournament] = await sql`
|
||||
INSERT INTO tournament (
|
||||
name, description, format, state, config, max_slots, created_by, registration_open_at, registration_close_at,
|
||||
tagline, accent_color, banner_url, forum_post, links, prizes
|
||||
) VALUES (
|
||||
'Dev Weekly',
|
||||
'A small open 1v1 you can sign up for while trying the site.',
|
||||
'single_elim',
|
||||
'REGISTRATION',
|
||||
${{ teamSize: 1, scoringType: 'score_v2', bans: 1, bestOf: 7 }},
|
||||
16,
|
||||
'dev-seed',
|
||||
now(),
|
||||
now() + interval '7 days',
|
||||
'The weekly 1v1 for trying out Vash Esports',
|
||||
'#ff66aa',
|
||||
'https://assets.ppy.sh/beatmaps/2566536/covers/cover@2x.jpg',
|
||||
${await Bun.file(new URL('./fixtures/dev-forum-post.bbcode', import.meta.url)).text()},
|
||||
${DEV_LINKS},
|
||||
${DEV_PRIZES}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO tournament_round (tournament_id, name, abbreviation, round_order, best_of, mappool_id, bracket_type)
|
||||
VALUES (${tournament.id}, 'Round of 16', 'RO16', 1, 7, ${cup.id}, 'winners')
|
||||
`;
|
||||
const nightShift =
|
||||
await sql`SELECT id FROM team WHERE name = 'Night Shift' AND owner_id = 'dev-player-01'`;
|
||||
for (const player of PLAYERS.slice(0, 6)) {
|
||||
const [team] = await sql`
|
||||
SELECT id FROM team WHERE owner_id = ${player.id} AND is_personal
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO tournament_registration (tournament_id, team_id, user_id, status, seed)
|
||||
VALUES (${tournament.id}, ${team.id}, ${player.id}, 'registered', ${PLAYERS.indexOf(player) + 1})
|
||||
`;
|
||||
}
|
||||
void nightShift;
|
||||
|
||||
const [waitingTeam] = await sql`
|
||||
SELECT id FROM team WHERE owner_id = 'dev-player-06' AND is_personal
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO match_queue (user_id, team_id, elo, format)
|
||||
VALUES ('dev-player-06', ${waitingTeam.id}, 1500, 1)
|
||||
`;
|
||||
|
||||
const counts = await sql`
|
||||
SELECT
|
||||
(SELECT count(*) FROM "user" WHERE id LIKE 'dev-player-%') AS players,
|
||||
(SELECT count(*) FROM team WHERE owner_id LIKE 'dev-player-%') AS teams,
|
||||
(SELECT count(*) FROM mappool WHERE created_by = 'dev-seed') AS pools,
|
||||
(SELECT count(*) FROM match WHERE created_by = 'dev-seed') AS matches
|
||||
`;
|
||||
console.log(counts[0]);
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
async function addSide(
|
||||
matchId: string,
|
||||
teamId: string,
|
||||
userId: string,
|
||||
slot: number,
|
||||
score: number
|
||||
) {
|
||||
const [participant] = await sql`
|
||||
INSERT INTO match_participant (match_id, team_id, slot, score)
|
||||
VALUES (${matchId}, ${teamId}, ${slot}, ${score})
|
||||
RETURNING id
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO match_participant_player (participant_id, user_id)
|
||||
VALUES (${participant.id}, ${userId})
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadBeatmaps(): Promise<Beatmap[]> {
|
||||
const clientId = process.env.OSU_CLIENT_ID;
|
||||
const clientSecret = process.env.OSU_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return expand(FALLBACK);
|
||||
const tokenResponse = await fetch('https://osu.ppy.sh/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: Number(clientId),
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'public'
|
||||
})
|
||||
});
|
||||
if (!tokenResponse.ok) return expand(FALLBACK);
|
||||
const token = (await tokenResponse.json()) as { access_token?: string };
|
||||
if (!token.access_token) return expand(FALLBACK);
|
||||
const maps: Beatmap[] = [];
|
||||
for (let page = 1; page <= 2 && maps.length < 80; page++) {
|
||||
const response = await fetch(
|
||||
`https://osu.ppy.sh/api/v2/beatmapsets/search?m=0&s=ranked&page=${page}`,
|
||||
{ headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json' } }
|
||||
);
|
||||
if (!response.ok) break;
|
||||
const body = (await response.json()) as {
|
||||
beatmapsets?: {
|
||||
title: string;
|
||||
artist: string;
|
||||
covers?: { cover?: string; list?: string };
|
||||
beatmaps?: {
|
||||
id: number;
|
||||
mode: string;
|
||||
version: string;
|
||||
difficulty_rating: number;
|
||||
bpm: number;
|
||||
total_length: number;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
for (const set of body.beatmapsets ?? []) {
|
||||
for (const beatmap of set.beatmaps ?? []) {
|
||||
if (beatmap.mode !== 'osu') continue;
|
||||
maps.push({
|
||||
id: String(beatmap.id),
|
||||
title: set.title,
|
||||
artist: set.artist,
|
||||
version: beatmap.version,
|
||||
sr: beatmap.difficulty_rating,
|
||||
bpm: beatmap.bpm,
|
||||
length: beatmap.total_length,
|
||||
cover: set.covers?.cover ?? null,
|
||||
list: set.covers?.list ?? null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return maps.length >= 12 ? maps : expand(FALLBACK);
|
||||
}
|
||||
|
||||
function expand(maps: Beatmap[]): Beatmap[] {
|
||||
const copy = [...maps];
|
||||
while (copy.length < 36)
|
||||
copy.push({
|
||||
...maps[copy.length % maps.length],
|
||||
sr: maps[copy.length % maps.length].sr + 0.15
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function pickBand(maps: Beatmap[], min: number, max: number): Beatmap[] {
|
||||
const inBand = maps.filter((map) => map.sr >= min && map.sr <= max);
|
||||
const source = inBand.length >= 8 ? inBand : [...maps].sort((a, b) => a.sr - b.sr);
|
||||
const picked: Beatmap[] = [];
|
||||
for (let i = 0; i < SLOT_PLAN.length; i++) {
|
||||
picked.push(
|
||||
source[Math.min(source.length - 1, Math.floor((i / SLOT_PLAN.length) * source.length))]
|
||||
);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
await sql.end();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user