Skip to content

Commit

Permalink
User events (leaderboard ordering) (#5788)
Browse files Browse the repository at this point in the history
  • Loading branch information
gc authored Mar 18, 2024
1 parent 46cc4e0 commit 4f0542b
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@octokit/graphql": "^4.8.0",
"@oldschoolgg/toolkit": "^0.0.24",
"@prisma/client": "^5.10.2",
"@sapphire/snowflake": "^3.5.3",
"@sapphire/stopwatch": "^1.4.0",
"@sapphire/time-utilities": "^1.6.0",
"@sentry/node": "^7.102.0",
Expand Down
20 changes: 20 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1121,3 +1121,23 @@ model ItemMetadata {
@@map("item_metadata")
}

enum UserEventType {
MaxXP
MaxTotalXP
MaxLevel
MaxTotalLevel
CLCompletion
}

model UserEvent {
id String @id @default(uuid()) @db.Uuid
date DateTime @default(now()) @db.Timestamp(6)
user_id String @db.VarChar(19)
type UserEventType
skill xp_gains_skill_enum?
collection_log_name String? @db.VarChar(19)
@@map("user_event")
}
8 changes: 8 additions & 0 deletions src/lib/addXP.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatOrdinal, toTitleCase } from '@oldschoolgg/toolkit';
import { UserEventType } from '@prisma/client';
import { bold } from 'discord.js';
import { noOp, Time } from 'e';
import { convertXPtoLVL, toKMB } from 'oldschooljs/dist/util/util';
Expand All @@ -9,6 +10,7 @@ import { skillEmoji } from './data/emojis';
import { AddXpParams } from './minions/types';
import { prisma } from './settings/prisma';
import Skills from './skilling/skills';
import { insertUserEvent } from './util/userEvents';
import { sendToChannelID } from './util/webhook';

const skillsVals = Object.values(Skills);
Expand Down Expand Up @@ -105,8 +107,13 @@ export async function addXP(user: MUser, params: AddXpParams): Promise<string> {
}
}

if (currentXP < MAX_XP && newXP >= MAX_XP) {
await insertUserEvent({ userID: user.id, type: UserEventType.MaxXP, skill: skill.id });
}

// If they just reached 99, send a server notification.
if (currentLevel < 99 && newLevel >= 99) {
await insertUserEvent({ userID: user.id, type: UserEventType.MaxLevel, skill: skill.id });
const skillNameCased = toTitleCase(params.skillName);
const [usersWith] = await prisma.$queryRawUnsafe<
{
Expand Down Expand Up @@ -144,6 +151,7 @@ export async function addXP(user: MUser, params: AddXpParams): Promise<string> {
`🎉 ${skill.emoji} **${user.badgedUsername}'s** minion, ${user.minionName}, just achieved the maximum possible total XP!`
)
);
await insertUserEvent({ userID: user.id, type: UserEventType.MaxTotalXP });
}

let str = '';
Expand Down
11 changes: 9 additions & 2 deletions src/lib/handleNewCLItems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { formatOrdinal, roboChimpCLRankQuery } from '@oldschoolgg/toolkit';
import { Prisma } from '@prisma/client';
import { Prisma, UserEventType } from '@prisma/client';
import { roll, sumArr } from 'e';
import { Bank } from 'oldschooljs';

Expand All @@ -11,6 +11,7 @@ import { prisma } from './settings/prisma';
import { MUserStats } from './structures/MUserStats';
import { fetchStatsForCL } from './util';
import { fetchCLLeaderboard } from './util/clLeaderboard';
import { insertUserEvent } from './util/userEvents';

export async function createHistoricalData(user: MUser): Promise<Prisma.HistoricalDataUncheckedCreateInput> {
const clStats = calcCLDetails(user);
Expand Down Expand Up @@ -112,6 +113,11 @@ export async function handleNewCLItems({
});

for (const finishedCL of newlyCompletedCLs) {
await insertUserEvent({
userID: user.id,
type: UserEventType.CLCompletion,
collectionLogName: finishedCL.name
});
const kcString = finishedCL.fmtProg
? `They finished after... ${await finishedCL.fmtProg({
getKC: (id: number) => user.getKC(id),
Expand All @@ -126,7 +132,8 @@ export async function handleNewCLItems({
ironmenOnly: false,
items: finishedCL.items,
resultLimit: 100_000,
method: 'raw_cl'
method: 'raw_cl',
userEvents: null
})
).filter(u => u.qty === finishedCL.items.length).length;

Expand Down
65 changes: 51 additions & 14 deletions src/lib/util/clLeaderboard.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,66 @@
import { UserEvent } from '@prisma/client';

import { prisma } from '../settings/prisma';
import { userEventsToMap } from './userEvents';

export async function fetchCLLeaderboard({
ironmenOnly,
items,
resultLimit,
method = 'cl_array'
method = 'cl_array',
userEvents
}: {
ironmenOnly: boolean;
items: number[];
resultLimit: number;
method?: 'cl_array' | 'raw_cl';
userEvents: UserEvent[] | null;
}) {
const userEventMap = userEventsToMap(userEvents);
const userIds = Array.from(userEventMap.keys());
if (method === 'cl_array') {
const users = (
await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(`
SELECT user_id::text AS id, CARDINALITY(cl_array) - CARDINALITY(cl_array - array[${items
const [specificUsers, generalUsers] = await prisma.$transaction([
prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(`
SELECT user_id::text AS id, CARDINALITY(cl_array) - CARDINALITY(cl_array - array[${items
.map(i => `${i}`)
.join(', ')}]) AS qty
FROM user_stats
WHERE user_id::text in (${userIds.map(i => `'${i}'`).join(', ')})
`),
prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(`
SELECT user_id::text AS id, CARDINALITY(cl_array) - CARDINALITY(cl_array - array[${items
.map(i => `${i}`)
.join(', ')}]) AS qty
FROM user_stats
${ironmenOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''}
WHERE cl_array && array[${items.map(i => `${i}`).join(', ')}]
${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}
ORDER BY qty DESC
LIMIT ${resultLimit};
`)
).filter(i => i.qty > 0);
FROM user_stats
${ironmenOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''}
WHERE (cl_array && array[${items.map(i => `${i}`).join(', ')}]
${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''})
AND user_id::text NOT IN (${userIds.map(i => `'${i}'`).join(', ')})
ORDER BY qty DESC
LIMIT ${resultLimit}
`)
]);

const users = [...specificUsers, ...generalUsers]
.sort((a, b) => {
const valueDifference = b.qty - a.qty;
if (valueDifference !== 0) {
return valueDifference;
}
const dateA = userEventMap.get(a.id);
const dateB = userEventMap.get(b.id);
if (dateA && dateB) {
return dateA - dateB;
}
if (dateA) {
return -1;
}
if (dateB) {
return 1;
}
return 0;
})
.filter(i => i.qty > 0);
return users;
}
const users = (
Expand All @@ -37,8 +73,9 @@ SELECT id, (cardinality(u.cl_keys) - u.inverse_length) as qty
.map(i => `'${i}'`)
.join(', ')}]))) "inverse_length"
FROM users
WHERE "collectionLogBank" ?| array[${items.map(i => `'${i}'`).join(', ')}]
${ironmenOnly ? 'AND "minion.ironman" = true' : ''}
WHERE ("collectionLogBank" ?| array[${items.map(i => `'${i}'`).join(', ')}]
${ironmenOnly ? 'AND "minion.ironman" = true' : ''})
OR user_id::text in (${userIds.map(i => `'${i}'`).join(', ')})
) u
ORDER BY qty DESC
LIMIT ${resultLimit};
Expand Down
70 changes: 70 additions & 0 deletions src/lib/util/userEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Prisma, UserEvent, UserEventType, xp_gains_skill_enum } from '@prisma/client';

import { MAX_LEVEL, MAX_TOTAL_LEVEL } from '../constants';
import { allCollectionLogsFlat } from '../data/Collections';
import { prisma } from '../settings/prisma';
import { dateFm } from './smallUtils';

export function userEventsToMap(_events: UserEvent[] | null) {
if (_events === null) return new Map<string, number>();
const events = _events.sort((a, b) => a.date.getTime() - b.date.getTime());
const map = new Map<string, number>();
for (const event of events) {
map.set(event.user_id, event.date.getTime());
}
return map;
}

export async function insertUserEvent({
userID,
type,
skill,
collectionLogName,
date
}: {
userID: string;
type: UserEventType;
skill?: xp_gains_skill_enum;
collectionLogName?: string;
date?: Date;
}) {
const data: Prisma.UserEventUncheckedCreateInput = {
user_id: userID,
type,
skill,
collection_log_name: collectionLogName?.toLowerCase(),
date
};
if (
(([UserEventType.MaxTotalLevel, UserEventType.MaxTotalXP] as UserEventType[]).includes(type) &&
skill !== undefined) ||
(([UserEventType.MaxXP, UserEventType.MaxLevel] as UserEventType[]).includes(type) && skill === undefined) ||
(type === UserEventType.CLCompletion && !collectionLogName) ||
(collectionLogName &&
!allCollectionLogsFlat.some(cl => cl.name.toLowerCase() === collectionLogName.toLowerCase()))
) {
throw new Error(`Invalid user event: ${JSON.stringify(data)}`);
}

await prisma.userEvent.create({ data });
}

export function userEventToStr(event: UserEvent) {
switch (event.type) {
case 'CLCompletion': {
return `Completed the ${event.collection_log_name} collection log at ${dateFm(event.date)}.`;
}
case 'MaxLevel': {
return `Reached level ${MAX_LEVEL} ${event.skill} at ${dateFm(event.date)}.`;
}
case 'MaxTotalLevel': {
return `Reached the maximum total level of ${MAX_TOTAL_LEVEL} at ${dateFm(event.date)}.`;
}
case 'MaxTotalXP': {
return `Reached the maximum total xp at ${dateFm(event.date)}.`;
}
case 'MaxXP': {
return `Reached the maximum ${event.skill} xp at ${dateFm(event.date)}.`;
}
}
}
77 changes: 74 additions & 3 deletions src/mahoji/commands/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ApplicationCommandOptionType, CommandRunOptions } from 'mahoji';

import { ClueTier, ClueTiers } from '../../lib/clues/clueTiers';
import { badges, badgesCache, Emoji, masteryKey, usernameCache } from '../../lib/constants';
import { allClNames, getCollectionItems } from '../../lib/data/Collections';
import { allClNames, allCollectionLogsFlat, getCollectionItems } from '../../lib/data/Collections';
import { effectiveMonsters } from '../../lib/minions/data/killableMonsters';
import { allOpenables } from '../../lib/openables';
import { Minigames } from '../../lib/settings/minigames';
Expand All @@ -28,6 +28,7 @@ import {
} from '../../lib/util';
import { fetchCLLeaderboard } from '../../lib/util/clLeaderboard';
import { deferInteraction } from '../../lib/util/interactionReply';
import { userEventsToMap } from '../../lib/util/userEvents';
import { sendToChannelID } from '../../lib/util/webhook';
import { OSBMahojiCommand } from '../lib/util';

Expand Down Expand Up @@ -273,8 +274,21 @@ async function clLb(
if (!items || items.length === 0) {

Check warning on line 274 in src/mahoji/commands/leaderboard.ts

View workflow job for this annotation

GitHub Actions / Node v20 - ubuntu-latest

Unexpected object value in conditional. The condition is always true
return "That's not a valid collection log category. Check +cl for all possible logs.";
}
const users = await fetchCLLeaderboard({ ironmenOnly, items, resultLimit: 200 });

const clName = allCollectionLogsFlat.find(c => stringMatches(c.name, inputType))?.name;
const userEventOrders = clName
? await prisma.userEvent.findMany({
where: {
type: 'CLCompletion',
collection_log_name: clName.toLowerCase()
},
orderBy: {
date: 'asc'
}
})
: null;

const users = await fetchCLLeaderboard({ ironmenOnly, items, resultLimit: 200, userEvents: userEventOrders });
inputType = toTitleCase(inputType.toLowerCase());
doMenu(
interaction,
Expand Down Expand Up @@ -445,6 +459,15 @@ async function skillsLb(
const skill = skillsVals.find(_skill => _skill.aliases.some(name => stringMatches(name, inputSkill)));

if (inputSkill === 'overall') {
const events = await prisma.userEvent.findMany({
where: {
type: 'MaxTotalLevel'
},
orderBy: {
date: 'asc'
}
});
const userEventMap = userEventsToMap(events);
const query = `SELECT
u.id,
${skillsVals.map(s => `"skills.${s.id}"`)},
Expand All @@ -469,7 +492,24 @@ async function skillsLb(
};
});
if (type !== 'xp') {
overallUsers.sort((a, b) => b.totalLevel - a.totalLevel);
overallUsers.sort((a, b) => {
const valueDifference = b.totalLevel - a.totalLevel;
if (valueDifference !== 0) {
return valueDifference;
}
const dateA = userEventMap.get(a.id);
const dateB = userEventMap.get(b.id);
if (dateA && dateB) {
return dateA - dateB;
}
if (dateA) {
return -1;
}
if (dateB) {
return 1;
}
return 0;
});
}
overallUsers.slice(0, 100);
} else {
Expand All @@ -484,6 +524,37 @@ async function skillsLb(
1 DESC
LIMIT 2000;`;
res = await prisma.$queryRawUnsafe<Record<string, any>[]>(query);

const events = await prisma.userEvent.findMany({
where: {
type: 'MaxLevel',
skill: skill.id
},
orderBy: {
date: 'asc'
}
});
const userEventMap = userEventsToMap(events);
res.sort((a, b) => {
const aXP = Number(a[`skills.${skill.id}`]);
const bXP = Number(b[`skills.${skill.id}`]);
const valueDifference = bXP - aXP;
if (valueDifference !== 0) {
return valueDifference;
}
const dateA = userEventMap.get(a.id);
const dateB = userEventMap.get(b.id);
if (dateA && dateB) {
return dateA - dateB;
}
if (dateA) {
return -1;
}
if (dateB) {
return 1;
}
return 0;
});
}

if (inputSkill === 'overall') {
Expand Down
Loading

0 comments on commit 4f0542b

Please sign in to comment.