diff --git a/__tests__/achievements.ts b/__tests__/achievements.ts index 5259abccf8..9ec75f6ea5 100644 --- a/__tests__/achievements.ts +++ b/__tests__/achievements.ts @@ -17,6 +17,7 @@ import { } from '../src/entity/user/UserTransaction'; import { UserCompany } from '../src/entity/UserCompany'; import { UserAchievement } from '../src/entity/user/UserAchievement'; +import { UserQuestProfile } from '../src/entity/user/UserQuestProfile'; import { updateUserAchievementProgress } from '../src/common/achievement'; import { createMockLogger, @@ -65,6 +66,7 @@ const achievementsFixture = [ eventType: AchievementEventType.ProfileImageUpdate, criteria: {}, points: 5, + xp: 10, rarity: 0.8, createdAt: new Date('2024-01-01'), }, @@ -77,6 +79,7 @@ const achievementsFixture = [ eventType: AchievementEventType.BookmarkPost, criteria: { targetCount: 10 }, points: 10, + xp: 25, rarity: 0.5, createdAt: new Date('2024-01-02'), }, @@ -89,6 +92,7 @@ const achievementsFixture = [ eventType: AchievementEventType.SquadJoin, criteria: {}, points: 5, + xp: 10, rarity: 0.6, createdAt: new Date('2024-01-03'), }, @@ -96,6 +100,11 @@ const achievementsFixture = [ beforeEach(async () => { loggedUser = null; + await con + .getRepository(UserQuestProfile) + .createQueryBuilder() + .delete() + .execute(); await con .getRepository(UserAchievement) .createQueryBuilder() @@ -133,6 +142,8 @@ const USER_ACHIEVEMENTS_QUERY = /* GraphQL */ ` achievement { id name + points + xp } progress unlockedAt @@ -209,14 +220,20 @@ describe('query userAchievements', () => { const [first, second, third] = res.data.userAchievements; expect(first.achievement.id).toBe(achievementIds.a1); + expect(first.achievement.points).toBe(5); + expect(first.achievement.xp).toBe(10); expect(first.progress).toBe(1); expect(first.unlockedAt).toBeTruthy(); expect(second.achievement.id).toBe(achievementIds.a2); + expect(second.achievement.points).toBe(10); + expect(second.achievement.xp).toBe(25); expect(second.progress).toBe(5); expect(second.unlockedAt).toBeNull(); expect(third.achievement.id).toBe(achievementIds.a3); + expect(third.achievement.points).toBe(5); + expect(third.achievement.xp).toBe(10); expect(third.progress).toBe(0); expect(third.unlockedAt).toBeNull(); }); @@ -641,3 +658,72 @@ describe('tracked achievement', () => { expect(user?.flags?.trackedAchievementId).toBeNull(); }); }); + +describe('achievement XP reward', () => { + it('should award XP when an achievement is unlocked', async () => { + const wasUnlocked = await updateUserAchievementProgress( + con, + createMockLogger(), + '1', + achievementIds.a2, + 10, + 10, + 25, + ); + + expect(wasUnlocked).toBe(true); + + const profile = await con + .getRepository(UserQuestProfile) + .findOneBy({ userId: '1' }); + expect(profile).toBeTruthy(); + expect(profile!.totalXp).toBe(25); + }); + + it('should not award XP when achievement is not unlocked', async () => { + const wasUnlocked = await updateUserAchievementProgress( + con, + createMockLogger(), + '1', + achievementIds.a2, + 5, + 10, + 25, + ); + + expect(wasUnlocked).toBe(false); + + const profile = await con + .getRepository(UserQuestProfile) + .findOneBy({ userId: '1' }); + expect(profile).toBeNull(); + }); + + it('should accumulate XP from multiple achievement unlocks', async () => { + await updateUserAchievementProgress( + con, + createMockLogger(), + '1', + achievementIds.a1, + 1, + 1, + 10, + ); + + await updateUserAchievementProgress( + con, + createMockLogger(), + '1', + achievementIds.a2, + 10, + 10, + 25, + ); + + const profile = await con + .getRepository(UserQuestProfile) + .findOneBy({ userId: '1' }); + expect(profile).toBeTruthy(); + expect(profile!.totalXp).toBe(35); + }); +}); diff --git a/bin/backfillAchievementXp.ts b/bin/backfillAchievementXp.ts new file mode 100644 index 0000000000..1cbb65ed72 --- /dev/null +++ b/bin/backfillAchievementXp.ts @@ -0,0 +1,60 @@ +import '../src/config'; +import createOrGetConnection from '../src/db'; +import z from 'zod'; +import { zodToParseArgs } from './common'; + +const argsSchema = z.object({ + 'dry-run': z.boolean().default(false), +}); + +(async () => { + const args = zodToParseArgs(argsSchema); + const dryRun = args['dry-run']; + + const con = await createOrGetConnection(); + + const [stats] = await con.query( + `SELECT + COUNT(DISTINCT ua."userId") AS user_count, + COALESCE(SUM(a.xp), 0)::int AS total_xp + FROM user_achievement ua + JOIN achievement a ON ua."achievementId" = a.id + WHERE ua."unlockedAt" IS NOT NULL`, + ); + + console.log(`Users with unlocked achievements: ${stats.user_count}`); + console.log(`Total XP to award: ${stats.total_xp}`); + + if (dryRun) { + console.log('Dry run — no changes made'); + process.exit(); + } + + const insertResult = await con.query( + `INSERT INTO user_quest_profile ("userId", "totalXp") + SELECT DISTINCT ua."userId", 0 + FROM user_achievement ua + WHERE ua."unlockedAt" IS NOT NULL + ON CONFLICT DO NOTHING`, + ); + console.log(`New user_quest_profile rows created: ${insertResult[1]}`); + + const updateResult = await con.query( + `UPDATE user_quest_profile uqp + SET + "totalXp" = uqp."totalXp" + agg.xp, + "updatedAt" = NOW() + FROM ( + SELECT ua."userId", SUM(a.xp)::int AS xp + FROM user_achievement ua + JOIN achievement a ON ua."achievementId" = a.id + WHERE ua."unlockedAt" IS NOT NULL + GROUP BY ua."userId" + ) agg + WHERE uqp."userId" = agg."userId"`, + ); + console.log(`user_quest_profile rows updated: ${updateResult[1]}`); + + console.log('Backfill complete'); + process.exit(); +})(); diff --git a/src/common/achievement/index.ts b/src/common/achievement/index.ts index 2d6c3718c5..e086bda780 100644 --- a/src/common/achievement/index.ts +++ b/src/common/achievement/index.ts @@ -10,6 +10,7 @@ import { User } from '../../entity/user/User'; import { UserAchievement } from '../../entity/user/UserAchievement'; import { updateFlagsStatement } from '../utils'; import { triggerTypedEvent } from '../typedPubsub'; +import { awardXp } from '../xp'; export { AchievementEventType, @@ -59,6 +60,7 @@ export async function updateUserAchievementProgress( achievementId: string, progress: number, targetCount: number, + achievementXp?: number, ): Promise { const userAchievement = await getOrCreateUserAchievement( con, @@ -106,6 +108,10 @@ export async function updateUserAchievementProgress( { userId, showAchievementUnlock: achievementId }, { conflictPaths: ['userId'] }, ); + + if (achievementXp) { + await awardXp({ con: manager, userId, amount: achievementXp }); + } } }); @@ -119,6 +125,7 @@ export async function incrementUserAchievementProgress( achievementId: string, targetCount: number, incrementBy: number = 1, + achievementXp?: number, ): Promise { const userAchievement = await getOrCreateUserAchievement( con, @@ -139,6 +146,7 @@ export async function incrementUserAchievementProgress( achievementId, newProgress, targetCount, + achievementXp, ); } @@ -157,6 +165,7 @@ async function evaluateInstantAchievement( achievement.id, 1, // Instant achievements are always complete with 1 action targetCount, + achievement.xp, ); if (wasUnlocked) { @@ -188,6 +197,7 @@ async function evaluateMilestoneAchievement( achievement.id, targetCount, incrementBy, + achievement.xp, ); if (wasUnlocked) { @@ -219,6 +229,7 @@ async function evaluateAbsoluteValueAchievement( achievement.id, currentValue, targetCount, + achievement.xp, ); if (wasUnlocked) { diff --git a/src/common/achievement/retroactive.ts b/src/common/achievement/retroactive.ts index 63bb089226..da0780c343 100644 --- a/src/common/achievement/retroactive.ts +++ b/src/common/achievement/retroactive.ts @@ -616,6 +616,7 @@ export const syncUsersRetroactiveAchievements = async ({ achievement.id, progress, targetCount, + achievement.xp, ); if (wasUnlocked) { diff --git a/src/common/xp.ts b/src/common/xp.ts new file mode 100644 index 0000000000..cd77211269 --- /dev/null +++ b/src/common/xp.ts @@ -0,0 +1,38 @@ +import type { DataSource, EntityManager } from 'typeorm'; +import { UserQuestProfile } from '../entity/user/UserQuestProfile'; + +export const awardXp = async ({ + con, + userId, + amount, +}: { + con: DataSource | EntityManager; + userId: string; + amount: number; +}): Promise => { + if (amount <= 0) { + return; + } + + await con + .createQueryBuilder() + .insert() + .into(UserQuestProfile) + .values({ + userId, + totalXp: 0, + }) + .orIgnore() + .execute(); + + await con + .createQueryBuilder() + .update(UserQuestProfile) + .set({ + totalXp: () => `"totalXp" + ${amount}`, + }) + .where({ + userId, + }) + .execute(); +}; diff --git a/src/entity/Achievement.ts b/src/entity/Achievement.ts index 1b6c4ebe97..c8a79b3205 100644 --- a/src/entity/Achievement.ts +++ b/src/entity/Achievement.ts @@ -101,6 +101,9 @@ export class Achievement { @Column({ type: 'smallint', default: 5 }) points: number; + @Column({ type: 'smallint', default: 0 }) + xp: number; + @Column({ type: 'real', nullable: true, default: null }) rarity: number | null; diff --git a/src/migration/1774561451165-AchievementXpColumn.ts b/src/migration/1774561451165-AchievementXpColumn.ts new file mode 100644 index 0000000000..838c8b8f72 --- /dev/null +++ b/src/migration/1774561451165-AchievementXpColumn.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AchievementXpColumn1774561451165 implements MigrationInterface { + name = 'AchievementXpColumn1774561451165'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "achievement" + ADD "xp" smallint NOT NULL DEFAULT '0'`, + ); + + await queryRunner.query( + `UPDATE "achievement" + SET "xp" = CASE "points" + WHEN 5 THEN 10 + WHEN 10 THEN 25 + WHEN 15 THEN 40 + WHEN 20 THEN 60 + WHEN 25 THEN 100 + WHEN 30 THEN 150 + WHEN 40 THEN 250 + WHEN 50 THEN 350 + ELSE "points" * 2 + END`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "achievement" + DROP COLUMN "xp"`, + ); + } +} diff --git a/src/schema/achievements.ts b/src/schema/achievements.ts index 55f3840eec..3e61190f53 100644 --- a/src/schema/achievements.ts +++ b/src/schema/achievements.ts @@ -195,6 +195,10 @@ export const typeDefs = /* GraphQL */ ` """ points: Int! """ + XP awarded for unlocking this achievement + """ + xp: Int! + """ Percentage of active users who unlocked this (null if not yet calculated) """ rarity: Float diff --git a/src/schema/quests.ts b/src/schema/quests.ts index 983f1c1aeb..02387882cc 100644 --- a/src/schema/quests.ts +++ b/src/schema/quests.ts @@ -13,6 +13,7 @@ import { } from '../common/quest'; import { transferCores } from '../common/njord'; import { systemUser } from '../common/utils'; +import { awardXp } from '../common/xp'; import { Quest, QuestEventType, @@ -353,27 +354,7 @@ const applyQuestRewards = async ({ const rewardTotals = toQuestRewardTotals(rewards); if (rewardTotals.xp > 0) { - await con - .createQueryBuilder() - .insert() - .into(UserQuestProfile) - .values({ - userId: ctx.userId, - totalXp: 0, - }) - .orIgnore() - .execute(); - - await con - .createQueryBuilder() - .update(UserQuestProfile) - .set({ - totalXp: () => `"totalXp" + ${rewardTotals.xp}`, - }) - .where({ - userId: ctx.userId, - }) - .execute(); + await awardXp({ con, userId: ctx.userId, amount: rewardTotals.xp }); } if (rewardTotals.reputation > 0) {