Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions __tests__/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,6 +66,7 @@ const achievementsFixture = [
eventType: AchievementEventType.ProfileImageUpdate,
criteria: {},
points: 5,
xp: 10,
rarity: 0.8,
createdAt: new Date('2024-01-01'),
},
Expand All @@ -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'),
},
Expand All @@ -89,13 +92,19 @@ const achievementsFixture = [
eventType: AchievementEventType.SquadJoin,
criteria: {},
points: 5,
xp: 10,
rarity: 0.6,
createdAt: new Date('2024-01-03'),
},
];

beforeEach(async () => {
loggedUser = null;
await con
.getRepository(UserQuestProfile)
.createQueryBuilder()
.delete()
.execute();
await con
.getRepository(UserAchievement)
.createQueryBuilder()
Expand Down Expand Up @@ -133,6 +142,8 @@ const USER_ACHIEVEMENTS_QUERY = /* GraphQL */ `
achievement {
id
name
points
xp
}
progress
unlockedAt
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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);
});
});
60 changes: 60 additions & 0 deletions bin/backfillAchievementXp.ts
Original file line number Diff line number Diff line change
@@ -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();
})();
11 changes: 11 additions & 0 deletions src/common/achievement/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,6 +60,7 @@ export async function updateUserAchievementProgress(
achievementId: string,
progress: number,
targetCount: number,
achievementXp?: number,
): Promise<boolean> {
const userAchievement = await getOrCreateUserAchievement(
con,
Expand Down Expand Up @@ -106,6 +108,10 @@ export async function updateUserAchievementProgress(
{ userId, showAchievementUnlock: achievementId },
{ conflictPaths: ['userId'] },
);

if (achievementXp) {
await awardXp({ con: manager, userId, amount: achievementXp });
}
}
});

Expand All @@ -119,6 +125,7 @@ export async function incrementUserAchievementProgress(
achievementId: string,
targetCount: number,
incrementBy: number = 1,
achievementXp?: number,
): Promise<boolean> {
const userAchievement = await getOrCreateUserAchievement(
con,
Expand All @@ -139,6 +146,7 @@ export async function incrementUserAchievementProgress(
achievementId,
newProgress,
targetCount,
achievementXp,
);
}

Expand All @@ -157,6 +165,7 @@ async function evaluateInstantAchievement(
achievement.id,
1, // Instant achievements are always complete with 1 action
targetCount,
achievement.xp,
);

if (wasUnlocked) {
Expand Down Expand Up @@ -188,6 +197,7 @@ async function evaluateMilestoneAchievement(
achievement.id,
targetCount,
incrementBy,
achievement.xp,
);

if (wasUnlocked) {
Expand Down Expand Up @@ -219,6 +229,7 @@ async function evaluateAbsoluteValueAchievement(
achievement.id,
currentValue,
targetCount,
achievement.xp,
);

if (wasUnlocked) {
Expand Down
1 change: 1 addition & 0 deletions src/common/achievement/retroactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ export const syncUsersRetroactiveAchievements = async ({
achievement.id,
progress,
targetCount,
achievement.xp,
);

if (wasUnlocked) {
Expand Down
38 changes: 38 additions & 0 deletions src/common/xp.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
};
3 changes: 3 additions & 0 deletions src/entity/Achievement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
34 changes: 34 additions & 0 deletions src/migration/1774561451165-AchievementXpColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AchievementXpColumn1774561451165 implements MigrationInterface {
name = 'AchievementXpColumn1774561451165';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "achievement"
DROP COLUMN "xp"`,
);
}
}
4 changes: 4 additions & 0 deletions src/schema/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading