diff --git a/src/__tests__/dailyTasks/clanTaskProgress.test.ts b/src/__tests__/dailyTasks/clanTaskProgress.test.ts index 0b2e90ee..571454c8 100644 --- a/src/__tests__/dailyTasks/clanTaskProgress.test.ts +++ b/src/__tests__/dailyTasks/clanTaskProgress.test.ts @@ -18,6 +18,7 @@ describe('DailyTasksService.updateClanTask', () => { }), } as any, {} as any, + {} as any, ); const basicService = { readOne: jest.fn().mockResolvedValue([ diff --git a/src/__tests__/dailyTasks/dailyTaskCancellation.test.ts b/src/__tests__/dailyTasks/dailyTaskCancellation.test.ts new file mode 100644 index 00000000..d5c5e69e --- /dev/null +++ b/src/__tests__/dailyTasks/dailyTaskCancellation.test.ts @@ -0,0 +1,266 @@ +import { DailyTasksService } from '../../dailyTasks/dailyTasks.service'; +import DailyTasksCommonModule from './modules/dailyTasksCommon.module'; +import DailyTaskBuilderFactory from './data/dailyTaskBuilderFactory'; +import PlayerModule from '../player/modules/player.module'; +import LoggedUser from '../test_utils/const/loggedUser'; +import { ObjectId } from 'mongodb'; +import ServiceError from '../../common/service/basicService/ServiceError'; +import { SEReason } from '../../common/service/basicService/SEReason'; + +describe('Daily task cancellation penalties', () => { + let dailyTasksService: DailyTasksService; + const playerModel = PlayerModule.getPlayerModel(); + const taskBuilder = DailyTaskBuilderFactory.getBuilder('DailyTask'); + + beforeEach(async () => { + const module = await DailyTasksCommonModule.getModule(); + dailyTasksService = module.get(DailyTasksService); + }); + + async function createReservedTask(options?: { + clanId?: string; + playerId?: string; + amount?: number; + amountLeft?: number; + progress?: object; + }) { + return dailyTasksService.model.create( + taskBuilder + .setClanId(options?.clanId ?? new ObjectId().toString()) + .setPlayerId( + options && 'playerId' in options + ? options.playerId + : LoggedUser.getPlayer()._id, + ) + .setAmount(options?.amount ?? 5) + .setAmountLeft(options?.amountLeft ?? 2) + .build(), + ); + } + + it('unreserves a task as fresh and deducts the cancellation penalty', async () => { + const playerId = LoggedUser.getPlayer()._id; + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const task = await createReservedTask({ + playerId, + amount: 5, + amountLeft: 2, + }); + await dailyTasksService.model.updateOne( + { _id: task._id }, + { $set: { progress: { key: 'partial-progress', steps: [1] } } }, + ); + + const [result, errors] = await dailyTasksService.unreserveTask(playerId); + + expect(errors).toBeNull(); + expect(result).toBe(true); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 5, + }); + expect( + await dailyTasksService.model.findById(task._id).lean(), + ).toMatchObject({ + player_id: null, + startedAt: null, + amount: 5, + amountLeft: 5, + progress: {}, + }); + }); + + it('clamps cancellation points at zero', async () => { + const playerId = LoggedUser.getPlayer()._id; + await playerModel.updateOne({ _id: playerId }, { $set: { points: 5 } }); + await createReservedTask({ playerId }); + + const [, errors] = await dailyTasksService.unreserveTask(playerId); + + expect(errors).toBeNull(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 0, + }); + }); + + it('deducts once and resets the old task when switching to a new task', async () => { + const playerId = LoggedUser.getPlayer()._id; + const clanId = new ObjectId().toString(); + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const oldTask = await createReservedTask({ + clanId, + playerId, + amount: 5, + amountLeft: 2, + }); + await dailyTasksService.model.updateOne( + { _id: oldTask._id }, + { $set: { progress: { key: 'partial-progress', steps: [1] } } }, + ); + const newTask = await createReservedTask({ clanId, playerId: null }); + + const [, errors] = await dailyTasksService.reserveTask( + playerId, + newTask._id.toString(), + clanId, + ); + + expect(errors).toBeNull(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 5, + }); + expect( + await dailyTasksService.model.findById(oldTask._id).lean(), + ).toMatchObject({ + player_id: null, + amountLeft: 5, + progress: {}, + }); + expect( + await dailyTasksService.model.findById(newTask._id).lean(), + ).toMatchObject({ + player_id: new ObjectId(playerId), + }); + }); + + it('replaces an owned task and deducts the cancellation penalty', async () => { + const playerId = LoggedUser.getPlayer()._id; + const clanId = new ObjectId().toString(); + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const task = await createReservedTask({ clanId, playerId }); + + const [result, errors] = await dailyTasksService.relinquishTaskById( + task._id.toString(), + clanId, + playerId, + ); + + expect(errors).toBeNull(); + expect(result).toBe(true); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 5, + }); + expect( + await dailyTasksService.model.findById(task._id).lean(), + ).toMatchObject({ + player_id: null, + startedAt: null, + progress: {}, + }); + }); + + it('does not deduct points when the caller does not own the task', async () => { + const playerId = LoggedUser.getPlayer()._id; + const clanId = new ObjectId().toString(); + const otherPlayerId = new ObjectId().toString(); + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const task = await createReservedTask({ clanId, playerId: otherPlayerId }); + + const [result, errors] = await dailyTasksService.relinquishTaskById( + task._id.toString(), + clanId, + playerId, + ); + + expect(result).toBeNull(); + expect(errors).toContainSE_NOT_FOUND(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 15, + }); + expect( + await dailyTasksService.model.findById(task._id).lean(), + ).toMatchObject({ + player_id: new ObjectId(otherPlayerId), + }); + }); + + it('does not deduct points for a repeated unreserve request', async () => { + const playerId = LoggedUser.getPlayer()._id; + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + await createReservedTask({ playerId }); + + const [, firstErrors] = await dailyTasksService.unreserveTask(playerId); + const [secondResult, secondErrors] = + await dailyTasksService.unreserveTask(playerId); + + expect(firstErrors).toBeNull(); + expect(secondResult).toBeNull(); + expect(secondErrors).toContainSE_NOT_FOUND(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 5, + }); + }); + + it('deducts points only once for concurrent unreserve requests', async () => { + const playerId = LoggedUser.getPlayer()._id; + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + await createReservedTask({ playerId }); + + const results = await Promise.all([ + dailyTasksService.unreserveTask(playerId), + dailyTasksService.unreserveTask(playerId), + ]); + + expect(results.filter(([, errors]) => !errors)).toHaveLength(1); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 5, + }); + }); + + it('does not charge a cancellation penalty when replacing a completed task', async () => { + const playerId = LoggedUser.getPlayer()._id; + const clanId = new ObjectId().toString(); + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const task = await createReservedTask({ clanId, playerId }); + + const [, errors] = await dailyTasksService.deleteTask( + task._id.toString(), + clanId, + playerId, + ); + + expect(errors).toBeNull(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 15, + }); + }); + + it('rolls back the task reset when point deduction fails', async () => { + const playerId = LoggedUser.getPlayer()._id; + await playerModel.updateOne({ _id: playerId }, { $set: { points: 15 } }); + const task = await createReservedTask({ + playerId, + amount: 5, + amountLeft: 2, + }); + await dailyTasksService.model.updateOne( + { _id: task._id }, + { $set: { progress: { key: 'partial-progress', steps: [1] } } }, + ); + jest + .spyOn((dailyTasksService as any).playerRewarder, 'deductPlayerPoints') + .mockResolvedValue([ + null, + [ + new ServiceError({ + reason: SEReason.UNEXPECTED, + message: 'Point deduction failed', + }), + ], + ]); + + const [result, errors] = await dailyTasksService.unreserveTask(playerId); + + expect(result).toBeNull(); + expect(errors).toContainSE_UNEXPECTED(); + expect(await playerModel.findById(playerId).lean()).toMatchObject({ + points: 15, + }); + expect( + await dailyTasksService.model.findById(task._id).lean(), + ).toMatchObject({ + player_id: new ObjectId(playerId), + amountLeft: 2, + progress: { key: 'partial-progress', steps: [1] }, + }); + }); +}); diff --git a/src/__tests__/dailyTasks/formAnInnerConnection.test.ts b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts index c409ea8f..6a3e75ff 100644 --- a/src/__tests__/dailyTasks/formAnInnerConnection.test.ts +++ b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts @@ -9,6 +9,7 @@ import { DailyTaskProgressService } from '../../dailyTasks/dailyTaskProgress.ser import { ServerTaskName } from '../../dailyTasks/enum/serverTaskName.enum'; import { ChatEmotion } from '../../chat/enum/chatEmotion.enum'; import { ChatResponseType } from '../../chat/enum/chatResponseType.enum'; +import { PlayerRewarder } from '../../rewarder/playerRewarder/playerRewarder.service'; describe('FORM_AN_INNER_CONNECTION Daily Task', () => { let service: DailyTasksService; @@ -60,6 +61,10 @@ describe('FORM_AN_INNER_CONNECTION Daily Task', () => { handleProgress: jest.fn().mockResolvedValue([true, null]), }, }, + { + provide: PlayerRewarder, + useValue: { deductPlayerPoints: jest.fn() }, + }, ], }).compile(); diff --git a/src/__tests__/dailyTasks/playWithEmotions.test.ts b/src/__tests__/dailyTasks/playWithEmotions.test.ts index a6974ec2..31a02537 100644 --- a/src/__tests__/dailyTasks/playWithEmotions.test.ts +++ b/src/__tests__/dailyTasks/playWithEmotions.test.ts @@ -28,6 +28,7 @@ describe('PLAY_WITH_EMOTIONS daily task', () => { }), } as any, progressService as any, + {} as any, ); const basicService = { updateOne: jest.fn().mockResolvedValue([true, null]), diff --git a/src/__tests__/dailyTasks/strongerSoldier.test.ts b/src/__tests__/dailyTasks/strongerSoldier.test.ts index bb1e6408..bde45c3c 100644 --- a/src/__tests__/dailyTasks/strongerSoldier.test.ts +++ b/src/__tests__/dailyTasks/strongerSoldier.test.ts @@ -27,6 +27,7 @@ describe('STRONGER_SOLDIER daily task', () => { }), } as any, progressService as any, + {} as any, ); const basicService = { updateOne: jest.fn().mockResolvedValue([true, null]), diff --git a/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerTask.test.ts b/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerTask.test.ts index add68d67..c5be6e12 100644 --- a/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerTask.test.ts +++ b/src/__tests__/rewarder/PlayerRewarder/rewardForPlayerTask.test.ts @@ -39,6 +39,23 @@ describe('PlayerRewarder.rewardForPlayerTask() test suite', () => { expect(playerAfter.points).toBe(playerBefore.points + pointsToAdd); }); + it('Should deduct regular points and clamp the result at zero', async () => { + await playerModel.updateOne( + { _id: existingPlayer._id }, + { $set: { points: 5 } }, + ); + + const [isSuccess, errors] = await rewarder.deductPlayerPoints( + existingPlayer._id, + 10, + ); + + const playerAfter = await playerModel.findById(existingPlayer._id); + expect(errors).toBeNull(); + expect(isSuccess).toBeTruthy(); + expect(playerAfter.points).toBe(0); + }); + it('Should not update points amount if the specified amount is a negative number and return LESS_THAN_MIN ServiceError', async () => { const pointsToAdd = -10; const playerBefore = await playerModel.findById(existingPlayer._id); diff --git a/src/dailyTasks/dailyTasks.controller.ts b/src/dailyTasks/dailyTasks.controller.ts index e088e084..8518224f 100644 --- a/src/dailyTasks/dailyTasks.controller.ts +++ b/src/dailyTasks/dailyTasks.controller.ts @@ -210,7 +210,7 @@ export class DailyTasksController { @UniformResponse(ModelName.DAILY_TASK, DailyTaskDto) async deleteTask(@Param() param: _idDto, @LoggedUser() user: User) { const clanId = await this.playerService.getPlayerClanId(user.player_id); - const [_result, errors] = await this.dailyTasksService.deleteTask( + const [_result, errors] = await this.dailyTasksService.relinquishTaskById( param._id, clanId, user.player_id, diff --git a/src/dailyTasks/dailyTasks.service.ts b/src/dailyTasks/dailyTasks.service.ts index 2e7c2bca..6563b4fb 100644 --- a/src/dailyTasks/dailyTasks.service.ts +++ b/src/dailyTasks/dailyTasks.service.ts @@ -32,6 +32,8 @@ import { ChatEmotion } from '../chat/enum/chatEmotion.enum'; import { ChatResponseType } from '../chat/enum/chatResponseType.enum'; import { StrongerSoldierStep } from './enum/strongerSoldierStep.enum'; import ServiceError from '../common/service/basicService/ServiceError'; +import { PlayerRewarder } from '../rewarder/playerRewarder/playerRewarder.service'; +import { TASK_CONSTS } from './consts/taskConstants'; const isChatEmotion = (emotion: unknown): emotion is ChatEmotion => typeof emotion === 'number' && Object.values(ChatEmotion).includes(emotion); @@ -55,6 +57,7 @@ export class DailyTasksService { private readonly taskQueue: DailyTaskQueue, private readonly taskGenerator: TaskGeneratorService, private readonly progressService: DailyTaskProgressService, + private readonly playerRewarder: PlayerRewarder, ) { this.basicService = new BasicService(model); this.modelName = ModelName.DAILY_TASK; @@ -109,13 +112,14 @@ export class DailyTasksService { if (error) throw error; if (task.player_id && task.player_id !== playerId) return [null, taskReservedError]; + if (task.player_id === playerId) return [task, null]; const [session, initErrors] = await initializeSession(this.connection); if (initErrors) return [null, initErrors]; const [, unreserveError] = await this.unreserveTask(playerId, session); if (unreserveError && unreserveError[0].reason !== SEReason.NOT_FOUND) - await cancelTransaction(session, unreserveError); + return await cancelTransaction(session, unreserveError); const startedAt = new Date(); task.player_id = playerId; @@ -126,14 +130,15 @@ export class DailyTasksService { task, { session }, ); - if (updateError) await cancelTransaction(session, updateError); + if (updateError) return await cancelTransaction(session, updateError); - await endTransaction(session, task); + const [reservedTask, endErrors] = await endTransaction(session, task); + if (endErrors) return [null, endErrors]; - await this.taskQueue.addDailyTask(task); - this.notifier.taskReceived(playerId, task); + await this.taskQueue.addDailyTask(reservedTask); + this.notifier.taskReceived(playerId, reservedTask); - return [task, null]; + return [reservedTask, null]; } /** @@ -144,10 +149,88 @@ export class DailyTasksService { * @returns A promise that resolves with the result of the update operation. */ async unreserveTask(playerId: string, session?: ClientSession) { - return this.basicService.updateOne( - { $unset: { player_id: '', startedAt: '' } }, + if (session) return this.relinquishActiveTask(playerId, session); + + const [newSession, initErrors] = await initializeSession(this.connection); + if (!newSession) return [null, initErrors]; + + const [wasUnreserved, unreserveErrors] = await this.relinquishActiveTask( + playerId, + newSession, + ); + if (unreserveErrors) + return await cancelTransaction(newSession, unreserveErrors); + + return await endTransaction(newSession, wasUnreserved); + } + + /** + * Releases the player's active task back to the pool in a fresh state and + * applies one cancellation penalty. The caller owns the transaction. + */ + private async relinquishActiveTask(playerId: string, session: ClientSession) { + const [wasUnreserved, unreserveErrors] = await this.basicService.updateOne( + [ + { + $set: { + player_id: null, + startedAt: null, + amountLeft: '$amount', + progress: {}, + }, + }, + ], { filter: { player_id: playerId }, session }, ); + if (unreserveErrors) return [null, unreserveErrors] as const; + + const [, deductionErrors] = await this.playerRewarder.deductPlayerPoints( + playerId, + TASK_CONSTS.POINTS.DAILY_TASK.CANCEL_PENALTY, + session, + ); + if (deductionErrors) return [null, deductionErrors] as const; + + return [wasUnreserved, null] as const; + } + + /** + * Replaces an active player-owned task and applies one cancellation + * penalty. This is intentionally separate from deleteTask, which is also + * used by task-completion flows and must not charge a penalty. + */ + async relinquishTaskById(taskId: string, clanId: string, playerId: string) { + const [session, initErrors] = await initializeSession(this.connection); + if (!session) return [null, initErrors]; + + const newValues = this.taskGenerator.createTaskRandomValues(); + const [, replacementErrors] = await this.basicService.updateOne( + { + $set: { + ...newValues, + amountLeft: newValues.amount, + progress: {}, + player_id: null, + startedAt: null, + }, + }, + { + filter: { _id: taskId, clan_id: clanId, player_id: playerId }, + session, + }, + ); + if (replacementErrors) + return await cancelTransaction(session, replacementErrors); + + const [, deductionErrors] = await this.playerRewarder.deductPlayerPoints( + playerId, + TASK_CONSTS.POINTS.DAILY_TASK.CANCEL_PENALTY, + session, + ); + if (deductionErrors) + return await cancelTransaction(session, deductionErrors); + + return await endTransaction(session, true); } /** diff --git a/src/rewarder/playerRewarder/playerRewarder.service.ts b/src/rewarder/playerRewarder/playerRewarder.service.ts index 20d6fb9c..2f3209c3 100644 --- a/src/rewarder/playerRewarder/playerRewarder.service.ts +++ b/src/rewarder/playerRewarder/playerRewarder.service.ts @@ -77,6 +77,51 @@ export class PlayerRewarder { return this.increasePlayerPoints(player_id, points, session); } + /** + * Deducts regular player points without allowing the stored value to fall + * below zero. + * + * @param player_id player _id to update + * @param points positive amount of regular points to deduct + * @param session optional client session for transaction + */ + async deductPlayerPoints( + player_id: string, + points: number, + session?: ClientSession, + ): Promise> { + if (points < 0) + return [ + null, + [ + new ServiceError({ + reason: SEReason.LESS_THAN_MIN, + field: 'points', + value: points, + message: 'Points amount can not be less than 0', + }), + ], + ]; + + const [, errors] = await this.playerService.updateOneById( + player_id, + [ + { + $set: { + points: { + $max: [0, { $subtract: ['$points', points] }], + }, + }, + }, + ], + { session }, + ); + + if (errors) return [null, errors]; + + return [true, null]; + } + /** * Increases specified player's regular points amount. * @param player_id player _id