refactor: initial moves

This commit is contained in:
syntaxbullet
2026-01-08 16:09:26 +01:00
parent 53a2f1ff0c
commit 88b266f81b
164 changed files with 529 additions and 280 deletions

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test";
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { users, userTimers } from "@db/schema";
// Mock dependencies
const mockFindFirst = mock();
const mockUpdate = mock();
const mockSet = mock();
const mockWhere = mock();
const mockReturning = mock();
const mockInsert = mock();
const mockValues = mock();
const mockOnConflictDoUpdate = mock();
// Chain setup
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate });
mockOnConflictDoUpdate.mockResolvedValue({});
mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
update: mockUpdate,
insert: mockInsert,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
mock.module("@/lib/config", () => ({
config: {
leveling: {
base: 100,
exponent: 1.5,
chat: {
minXp: 10,
maxXp: 20,
cooldownMs: 60000
}
}
}
}));
describe("levelingService", () => {
let originalRandom: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockUpdate.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockReturning.mockClear();
mockInsert.mockClear();
mockValues.mockClear();
mockOnConflictDoUpdate.mockClear();
originalRandom = Math.random;
});
afterEach(() => {
Math.random = originalRandom;
});
describe("getXpForLevel", () => {
it("should calculate correct XP", () => {
// base 100, exp 1.5
// lvl 1: 100 * 1^1.5 = 100
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
expect(levelingService.getXpForNextLevel(1)).toBe(100);
expect(levelingService.getXpForNextLevel(2)).toBe(282);
});
});
describe("addXp", () => {
it("should add XP without level up", async () => {
// User current: level 1, xp 0
// Add 50
// Next level (1) needed: 100. (Note: Logic in service seems to use currentLevel for calculation of next step.
// Service implementation:
// let xpForNextLevel = ... getXpForLevel(currentLevel)
// wait, if I am level 1, I need X XP to reach level 2?
// Service code:
// while (newXp >= xpForNextLevel) { ... currentLevel++ }
// So if I am level 1, calling getXpForLevel(1) returns 100.
// If I have 100 XP, I level up to 2.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 50n, level: 1 }]);
const result = await levelingService.addXp("1", 50n);
expect(result.levelUp).toBe(false);
expect(result.currentLevel).toBe(1);
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockSet).toHaveBeenCalledWith({ xp: 50n, level: 1 });
});
it("should level up if XP sufficient", async () => {
// Current: Lvl 1, XP 0. Next Lvl needed: 100.
// Add 120.
// newXp = 120.
// 120 >= 100.
// newXp -= 100 -> 20.
// currentLevel -> 2.
// Next needed for Lvl 2 -> 282.
// 20 < 282. Loop ends.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 20n, level: 2 }]);
const result = await levelingService.addXp("1", 120n);
expect(result.levelUp).toBe(true);
expect(result.currentLevel).toBe(2);
expect(mockSet).toHaveBeenCalledWith({ xp: 120n, level: 2 });
});
it("should handle multiple level ups", async () => {
// Lvl 1 (100 needed). Lvl 2 (282 needed). Total for Lvl 3 = 100 + 282 = 382.
// Add 400.
// 400 >= 100 -> rem 300, Lvl 2.
// 300 >= 282 -> rem 18, Lvl 3.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 18n, level: 3 }]);
const result = await levelingService.addXp("1", 400n);
expect(result.currentLevel).toBe(3);
});
it("should throw if user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(levelingService.addXp("1", 50n)).rejects.toThrow("User not found");
});
});
describe("processChatXp", () => {
beforeEach(() => {
setSystemTime(new Date("2023-01-01T12:00:00Z"));
});
it("should award XP if no cooldown", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown check
.mockResolvedValueOnce(undefined) // XP Boost check
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // addXp -> getUser
mockReturning.mockResolvedValue([{ xp: 15n, level: 1 }]); // addXp -> update
Math.random = () => 0.5; // mid range? 10-20.
// floor(0.5 * (20 - 10 + 1)) + 10 = floor(0.5 * 11) + 10 = floor(5.5) + 10 = 15.
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(true);
expect((result as any).amount).toBe(15n);
expect(mockInsert).toHaveBeenCalledWith(userTimers); // Cooldown set
});
it("should respect cooldown", async () => {
const future = new Date("2023-01-01T12:00:10Z");
mockFindFirst.mockResolvedValue({ expiresAt: future });
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(false);
expect(result.reason).toBe("cooldown");
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should apply XP boost", async () => {
const now = new Date();
const future = new Date(now.getTime() + 10000);
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown
.mockResolvedValueOnce({ expiresAt: future, metadata: { multiplier: 2.0 } }) // Boost
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // User
Math.random = () => 0.0; // Min value = 10.
// Boost 2x -> 20.
mockReturning.mockResolvedValue([{ xp: 20n, level: 1 }]);
const result = await levelingService.processChatXp("1");
// Check if amount passed to addXp was boosted
// Wait, result.amount is the returned amount from addXp ??
// processChatXp returns { awarded: true, amount, ...resultFromAddXp }
// So result.amount is the calculated amount.
expect((result as any).amount).toBe(20n);
// Implementation: amount = floor(amount * multiplier)
// min 10 * 2 = 20.
});
});
});

View File

@@ -0,0 +1,130 @@
import { users, userTimers } from "@db/schema";
import { eq, sql, and } from "drizzle-orm";
import { withTransaction } from "@/lib/db";
import { config } from "@/lib/config";
import type { Transaction } from "@shared/lib/types";
import { TimerType } from "@shared/lib/constants";
export const levelingService = {
// Calculate total XP required to REACH a specific level (Cumulative)
// Level 1 = 0 XP
// Level 2 = Base * (1^Exp)
// Level 3 = Level 2 + Base * (2^Exp)
// ...
getXpToReachLevel: (level: number) => {
let total = 0;
for (let l = 1; l < level; l++) {
total += Math.floor(config.leveling.base * Math.pow(l, config.leveling.exponent));
}
return total;
},
// Calculate level from Total XP
getLevelFromXp: (totalXp: bigint) => {
let level = 1;
let xp = Number(totalXp);
while (true) {
// XP needed to complete current level and reach next
const xpForNext = Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
if (xp < xpForNext) {
return level;
}
xp -= xpForNext;
level++;
}
},
// Get XP needed to complete the current level (for calculating next level threshold in isolation)
// Used internally or for display
getXpForNextLevel: (currentLevel: number) => {
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
},
// Cumulative XP addition
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Get current state
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
});
if (!user) throw new Error("User not found");
const currentXp = user.xp ?? 0n;
const newXp = currentXp + amount;
// Calculate new level based on TOTAL accumulated XP
const newLevel = levelingService.getLevelFromXp(newXp);
const currentLevel = user.level ?? 1;
const levelUp = newLevel > currentLevel;
// Update user
const [updatedUser] = await txFn.update(users)
.set({
xp: newXp,
level: newLevel,
})
.where(eq(users.id, BigInt(id)))
.returning();
return { user: updatedUser, levelUp, currentLevel: newLevel };
}, tx);
},
// Handle chat XP with cooldowns
processChatXp: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// check if an xp cooldown is in place
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'chat_xp')
),
});
const now = new Date();
if (cooldown && cooldown.expiresAt > now) {
return { awarded: false, reason: 'cooldown' };
}
// Calculate random XP
let amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp);
// Check for XP Boost
const xpBoost = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, TimerType.EFFECT),
eq(userTimers.key, 'xp_boost')
)
});
if (xpBoost && xpBoost.expiresAt > now) {
const multiplier = (xpBoost.metadata as any)?.multiplier || 1;
amount = BigInt(Math.floor(Number(amount) * multiplier));
}
// Add XP
const result = await levelingService.addXp(id, amount, txFn);
// Update/Set Cooldown
const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs);
await txFn.insert(userTimers)
.values({
userId: BigInt(id),
type: TimerType.COOLDOWN,
key: 'chat_xp',
expiresAt: nextReadyAt,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: nextReadyAt },
});
return { awarded: true, amount, ...result };
}, tx);
}
};