From db8b4d45db5bb4bfa9dff40aaf3ac52e466e59e2 Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Sat, 12 Sep 2026 18:13:12 +0300 Subject: [PATCH 1/5] feat(daily-tasks): generate balanced server task pools, feat(clans): complete INNER_VOICE when clan phrase changes --- .../ClanController/phraseDailyTask.test.ts | 152 ++++++++++++++++++ .../MqttNotification.contract.test.ts | 10 ++ .../startupRefresh.test.ts | 28 +++- .../dailyTasks/clanTaskProgress.test.ts | 71 ++++++++ .../dailyTaskProgress.service.test.ts | 30 ++++ .../dailyTasks/dailyTaskScoringValues.test.ts | 42 ++++- .../sessionStarter/sessionStarter.service.ts | 1 - src/clan/clan.controller.ts | 80 ++++++++- src/clan/clan.module.ts | 2 + src/clan/clan.notifier.ts | 20 ++- src/clan/clan.service.ts | 3 +- .../EventEmitter.service.ts | 16 ++ .../service/basicService/BasicService.ts | 8 +- .../enum/MqttNotificationType.enum.ts | 1 + .../enum/NotificationResource.enum.ts | 4 + src/dailyTasks/dailyTaskProgress.service.ts | 73 +++++++++ src/dailyTasks/dailyTasks.service.ts | 132 ++++++++++++--- .../dailyTasksStartupRefresh.service.ts | 64 ++------ src/dailyTasks/taskGenerator.service.ts | 150 ++++++++++++----- .../clanRewarder/clanRewarder.service.ts | 4 +- 20 files changed, 762 insertions(+), 129 deletions(-) create mode 100644 src/__tests__/clan/ClanController/phraseDailyTask.test.ts create mode 100644 src/__tests__/dailyTasks/clanTaskProgress.test.ts diff --git a/src/__tests__/clan/ClanController/phraseDailyTask.test.ts b/src/__tests__/clan/ClanController/phraseDailyTask.test.ts new file mode 100644 index 000000000..1b24a67bf --- /dev/null +++ b/src/__tests__/clan/ClanController/phraseDailyTask.test.ts @@ -0,0 +1,152 @@ +import { ClanController } from '../../../clan/clan.controller'; +import ClanNotifier from '../../../clan/clan.notifier'; +import { SEReason } from '../../../common/service/basicService/SEReason'; +import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; + +describe('ClanController phrase daily task', () => { + const clanId = '67fe4e2d8a54d4cc39266a43'; + + const createController = ({ + currentPhrase = 'Together we rise', + updateResult = [true, null], + taskErrors = null, + }: { + currentPhrase?: string; + updateResult?: [boolean | null, any[] | null]; + taskErrors?: any[] | null; + } = {}) => { + const clanService = { + readOneById: jest + .fn() + .mockResolvedValue([{ phrase: currentPhrase }, null]), + updateOneById: jest.fn().mockResolvedValue(updateResult), + }; + const progressResult = { + status: 'completed', + task: { + type: ServerTaskName.INNER_VOICE, + points: 10, + coins: 5, + clan_id: clanId, + }, + completedByPlayerId: 'player-1', + clanId, + completedAmount: 1, + previousAmountLeft: 1, + currentAmountLeft: 0, + }; + const dailyTasksService = { + updateClanTask: jest + .fn() + .mockResolvedValue([taskErrors ? null : progressResult, taskErrors]), + }; + const dailyTaskProgressService = { + handleClanTaskCompletion: jest + .fn() + .mockResolvedValue([progressResult, null]), + notifyClanTaskCompletion: jest.fn(), + }; + const session = { + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + endSession: jest.fn(), + inTransaction: jest.fn().mockReturnValue(true), + abortTransaction: jest.fn(), + }; + const connection = { + startSession: jest.fn().mockResolvedValue(session), + }; + const notifier = jest + .spyOn(ClanNotifier.prototype, 'phraseUpdated') + .mockImplementation(); + const controller = new ClanController( + clanService as any, + undefined as any, + undefined as any, + undefined as any, + undefined as any, + undefined as any, + undefined as any, + dailyTasksService as any, + dailyTaskProgressService as any, + connection as any, + ); + + return { + clanService, + controller, + dailyTasksService, + dailyTaskProgressService, + notifier, + session, + }; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('completes the clan task and publishes a clan update after saving a changed phrase', async () => { + const { + clanService, + controller, + dailyTasksService, + dailyTaskProgressService, + notifier, + session, + } = createController(); + const body = { _id: clanId, phrase: 'Victory or nothing!' } as any; + const user = { clan_id: clanId, player_id: 'player-1' } as any; + + await controller.update(body, user); + + expect(clanService.updateOneById).toHaveBeenCalledWith(clanId, body, { + session, + }); + expect(dailyTasksService.updateClanTask).toHaveBeenCalledWith( + clanId, + 'player-1', + ServerTaskName.INNER_VOICE, + session, + ); + expect( + dailyTaskProgressService.handleClanTaskCompletion, + ).toHaveBeenCalledWith( + expect.any(Object), + session, + false, + ); + expect(session.commitTransaction).toHaveBeenCalled(); + expect(dailyTaskProgressService.notifyClanTaskCompletion).toHaveBeenCalled(); + expect(notifier).toHaveBeenCalledWith(clanId, 'Victory or nothing!'); + }); + + it('does not progress the task or publish MQTT when the phrase is unchanged', async () => { + const { controller, dailyTasksService, notifier } = createController(); + const body = { _id: clanId, phrase: 'Together we rise' } as any; + const user = { clan_id: clanId, player_id: 'player-1' } as any; + + await controller.update(body, user); + + expect(dailyTasksService.updateClanTask).not.toHaveBeenCalled(); + expect(notifier).not.toHaveBeenCalled(); + }); + + it('rolls back the phrase update and publishes no notifications when clan-task progression fails', async () => { + const { controller, dailyTaskProgressService, notifier, session } = + createController({ + taskErrors: [{ reason: SEReason.UNEXPECTED }], + }); + const body = { _id: clanId, phrase: 'Victory or nothing!' } as any; + const user = { clan_id: clanId, player_id: 'player-1' } as any; + + await controller.update(body, user); + + expect(session.abortTransaction).toHaveBeenCalled(); + expect(session.commitTransaction).not.toHaveBeenCalled(); + expect( + dailyTaskProgressService.notifyClanTaskCompletion, + ).not.toHaveBeenCalled(); + expect(notifier).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts b/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts index 02f3ceb55..733587669 100644 --- a/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts +++ b/src/__tests__/common/service/notificator/MqttNotification.contract.test.ts @@ -233,6 +233,16 @@ describe('MQTT notification contract', () => { new ClanNotifier().memberLeave('clan-1', 'player-1'); expectLastPayloadToMatchEnvelope('clan', MqttNotificationType.MEMBER_LEFT); + new ClanNotifier().phraseUpdated('clan-1', 'Together we rise'); + expect(publishMock).toHaveBeenLastCalledWith( + `/clan/clan-1/${NotificationResource.CLAN}/phrase/${NotificationStatus.UPDATE}`, + JSON.stringify({ + topic: 'clan', + type: MqttNotificationType.CLAN_UPDATED, + payload: { clan_id: 'clan-1', phrase: 'Together we rise' }, + }), + ); + const friendshipNotifier = new FriendshipNotifier({ findOne: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ diff --git a/src/__tests__/dailyTasks/DailyTasksStartupRefreshService/startupRefresh.test.ts b/src/__tests__/dailyTasks/DailyTasksStartupRefreshService/startupRefresh.test.ts index 726575228..c821743b7 100644 --- a/src/__tests__/dailyTasks/DailyTasksStartupRefreshService/startupRefresh.test.ts +++ b/src/__tests__/dailyTasks/DailyTasksStartupRefreshService/startupRefresh.test.ts @@ -3,6 +3,11 @@ import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; import { OldTaskName } from '../../../dailyTasks/enum/oldTaskNames.enum'; import { uiDailyTasks } from '../../../dailyTasks/uiDailyTasks/uiDailyTasks'; import { DailyTasksStartupRefreshService } from '../../../dailyTasks/dailyTasksStartupRefresh.service'; +import { + ACTIVE_SERVER_TASK_DEFINITIONS, + MIN_OCCURRENCES_PER_TASK_TYPE, + TaskGeneratorService, +} from '../../../dailyTasks/taskGenerator.service'; describe('DailyTasksStartupRefreshService', () => { const createService = ({ @@ -53,7 +58,10 @@ describe('DailyTasksStartupRefreshService', () => { }, startSession: jest.fn(async () => session), }; - const service = new DailyTasksStartupRefreshService(connection as any); + const service = new DailyTasksStartupRefreshService( + connection as any, + new TaskGeneratorService(), + ); const ownerId = (service as any).ownerId; if (!lockOwnerId) { @@ -168,6 +176,7 @@ describe('DailyTasksStartupRefreshService', () => { ServerTaskName.BANISH_THE_EARWORM, ServerTaskName.GO_TO_BATTLE, ServerTaskName.FORM_AN_INNER_CONNECTION, + ServerTaskName.INNER_VOICE, ].includes(task.type), ), ).toBe(true); @@ -186,4 +195,21 @@ describe('DailyTasksStartupRefreshService', () => { ownerId, }); }); + + it('creates a balanced server-task pool including INNER_VOICE', () => { + const { service } = createService({ oldTaskFindResults: [] }); + const tasks = (service as any).createServerTasksForClan({ + _id: 'clan-1', + }); + + expect(tasks).toHaveLength(11); + expect( + tasks.filter((task) => task.type === ServerTaskName.INNER_VOICE).length, + ).toBeGreaterThanOrEqual(MIN_OCCURRENCES_PER_TASK_TYPE); + for (const { type } of ACTIVE_SERVER_TASK_DEFINITIONS) { + expect( + tasks.filter((task) => task.type === type).length, + ).toBeGreaterThanOrEqual(MIN_OCCURRENCES_PER_TASK_TYPE); + } + }); }); diff --git a/src/__tests__/dailyTasks/clanTaskProgress.test.ts b/src/__tests__/dailyTasks/clanTaskProgress.test.ts new file mode 100644 index 000000000..0b2e90eef --- /dev/null +++ b/src/__tests__/dailyTasks/clanTaskProgress.test.ts @@ -0,0 +1,71 @@ +import { DailyTasksService } from '../../dailyTasks/dailyTasks.service'; +import { ServerTaskName } from '../../dailyTasks/enum/serverTaskName.enum'; + +describe('DailyTasksService.updateClanTask', () => { + it('progresses INNER_VOICE by clan without a player reservation', async () => { + const service = new DailyTasksService( + {} as any, + {} as any, + {} as any, + {} as any, + { + createTaskRandomValues: jest.fn().mockReturnValue({ + type: ServerTaskName.GO_TO_BATTLE, + amount: 2, + points: 10, + coins: 5, + title: { fi: 'Pelaa 2 taistelua' }, + }), + } as any, + {} as any, + ); + const basicService = { + readOne: jest.fn().mockResolvedValue([ + { + _id: 'task-1', + clan_id: 'clan-1', + player_id: null, + type: ServerTaskName.INNER_VOICE, + amount: 1, + amountLeft: 1, + points: 10, + coins: 5, + }, + null, + ]), + updateOne: jest.fn().mockResolvedValue([true, null]), + }; + (service as any).basicService = basicService; + + const [result, errors] = await service.updateClanTask( + 'clan-1', + 'player-1', + ServerTaskName.INNER_VOICE, + {} as any, + ); + + expect(errors).toBeNull(); + expect(result).toMatchObject({ + status: 'completed', + completedByPlayerId: 'player-1', + clanId: 'clan-1', + currentAmountLeft: 0, + }); + expect(basicService.readOne).toHaveBeenCalledWith({ + filter: { + clan_id: 'clan-1', + type: ServerTaskName.INNER_VOICE, + amountLeft: { $gt: 0 }, + }, + session: expect.anything(), + }); + expect(basicService.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + $unset: { player_id: '', startedAt: '' }, + }), + expect.objectContaining({ + filter: { _id: 'task-1', clan_id: 'clan-1' }, + }), + ); + }); +}); diff --git a/src/__tests__/dailyTasks/dailyTaskProgress.service.test.ts b/src/__tests__/dailyTasks/dailyTaskProgress.service.test.ts index fc0f86911..f6a4b76a8 100644 --- a/src/__tests__/dailyTasks/dailyTaskProgress.service.test.ts +++ b/src/__tests__/dailyTasks/dailyTaskProgress.service.test.ts @@ -128,6 +128,36 @@ describe('DailyTaskProgressService', () => { ); }); + it('should complete a clan task without rewarding or notifying a player', async () => { + const result = makeResult('completed', 'inner_voice'); + clanProgression.handleClanProgression.mockResolvedValue([ + { reachedMilestones: [100] }, + null, + ]); + + const [handled, error] = await service.handleClanTaskCompletion( + result, + session, + ); + + expect(error).toBeNull(); + expect(handled.reachedMilestones).toEqual([100]); + expect(playerRewarder.rewardForPlayerTask).not.toHaveBeenCalled(); + expect(notifier.taskUpdated).not.toHaveBeenCalled(); + expect(notifier.taskCompleted).not.toHaveBeenCalled(); + expect(clanRewarder.rewardClanForPlayerTask).toHaveBeenCalledWith( + 'clan-1', + result.task.points, + result.task.coins, + session, + ); + expect(notifier.taskCompletedForClan).toHaveBeenCalledWith( + 'clan-1', + result.task, + 'player-1', + ); + }); + it('should complete player-only task without rewarding or notifying clan', async () => { const result = { ...makeResult('completed', 'banish_the_earworm'), diff --git a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts index edef48db1..6aa154c0a 100644 --- a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts +++ b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts @@ -1,6 +1,11 @@ import { defaultPredefinedDailyTasks } from '../../box/dailyTask/defaultPredefinedDailyTasks'; import { Score } from '../../common/values/scoring.values'; -import { TaskGeneratorService } from '../../dailyTasks/taskGenerator.service'; +import { + ACTIVE_SERVER_TASK_DEFINITIONS, + MIN_OCCURRENCES_PER_TASK_TYPE, + TaskGeneratorService, +} from '../../dailyTasks/taskGenerator.service'; +import { ServerTaskName } from '../../dailyTasks/enum/serverTaskName.enum'; import { uiDailyTasks } from '../../dailyTasks/uiDailyTasks/uiDailyTasks'; describe('daily task scoring values', () => { @@ -12,6 +17,41 @@ describe('daily task scoring values', () => { expect(task.points).toBe(Score.DAILY_TASK.COMPLETED); }); + it('configures INNER_VOICE as a one-step clan motto task', () => { + const generator = new TaskGeneratorService(); + jest + .spyOn(generator, 'getRandomTaskType') + .mockReturnValue(ServerTaskName.INNER_VOICE); + + expect(generator.createTaskRandomValues()).toMatchObject({ + type: ServerTaskName.INNER_VOICE, + amount: 1, + title: { + fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', + }, + }); + }); + + it('creates a balanced, shuffled server-task bag', () => { + const generator = new TaskGeneratorService(); + const taskTypes = generator.createBalancedTaskTypes(); + + expect(taskTypes).toHaveLength(11); + for (const { type } of ACTIVE_SERVER_TASK_DEFINITIONS) { + expect( + taskTypes.filter((taskType) => taskType === type).length, + ).toBeGreaterThanOrEqual(MIN_OCCURRENCES_PER_TASK_TYPE); + } + }); + + it('fails clearly when the requested task count cannot satisfy the minimum occurrence guarantee', () => { + const generator = new TaskGeneratorService(); + + expect(() => generator.createBalancedTaskTypes(1)).toThrow( + 'Cannot generate 1 server tasks', + ); + }); + it('uses the shared completed daily task score for default predefined tasks', () => { expect(defaultPredefinedDailyTasks).toEqual( expect.arrayContaining([ diff --git a/src/box/sessionStarter/sessionStarter.service.ts b/src/box/sessionStarter/sessionStarter.service.ts index 07d91475f..8588536e2 100644 --- a/src/box/sessionStarter/sessionStarter.service.ts +++ b/src/box/sessionStarter/sessionStarter.service.ts @@ -200,7 +200,6 @@ export default class SessionStarterService { }, undefined, { - filter: {}, session, }, ); diff --git a/src/clan/clan.controller.ts b/src/clan/clan.controller.ts index 5b27df1c5..e50cc84fd 100644 --- a/src/clan/clan.controller.ts +++ b/src/clan/clan.controller.ts @@ -9,6 +9,8 @@ import { Post, Put, } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection } from 'mongoose'; import { CreateClanDto } from './dto/createClan.dto'; import { UpdateClanDto } from './dto/updateClan.dto'; import { ClanDto } from './dto/clan.dto'; @@ -47,9 +49,21 @@ import { ApiExtraModels } from '@nestjs/swagger'; import { ItemDto } from '../clanInventory/item/dto/item.dto'; import { ClanChatService } from '../chat/service/clanChat.service'; import { PasswordGenerator } from '../common/function/passwordGenerator'; +import { + cancelTransaction, + endTransaction, + initializeSession, +} from '../common/function/Transactions'; +import { SEReason } from '../common/service/basicService/SEReason'; +import { DailyTaskProgressService } from '../dailyTasks/dailyTaskProgress.service'; +import { DailyTasksService } from '../dailyTasks/dailyTasks.service'; +import { ServerTaskName } from '../dailyTasks/enum/serverTaskName.enum'; +import ClanNotifier from './clan.notifier'; @Controller('clan') export class ClanController { + private readonly clanNotifier = new ClanNotifier(); + public constructor( private readonly service: ClanService, private readonly joinService: JoinService, @@ -58,6 +72,9 @@ export class ClanController { private readonly playerService: PlayerService, private readonly clanChatService: ClanChatService, private readonly passwordGenerator: PasswordGenerator, + private readonly dailyTasksService: DailyTasksService, + private readonly dailyTaskProgressService: DailyTaskProgressService, + @InjectConnection() private readonly connection: Connection, ) {} /** @@ -195,7 +212,6 @@ export class ClanController { @HasClanRights([ClanBasicRight.EDIT_CLAN_DATA]) @UniformResponse() public async update( - @Param('_id') _id: string, @Body() body: UpdateClanDto, @LoggedUser() user: User, ) { @@ -218,8 +234,66 @@ export class ClanController { ) { body.password = this.passwordGenerator.generatePassword('fi'); } - const [, errors] = await this.service.updateOneById(body._id, body); - if (errors) return [null, errors]; + if (typeof body.phrase !== 'string') { + const [, errors] = await this.service.updateOneById(body._id, body); + if (errors) return [null, errors]; + return; + } + + const [session, initErrors] = await initializeSession(this.connection); + if (!session) return [null, initErrors]; + + const [clan, clanErrors] = await this.service.readOneById(body._id, { + session, + }); + if (clanErrors) return cancelTransaction(session, clanErrors); + + const phraseChanged = clan.phrase !== body.phrase; + const [wasUpdated, updateErrors] = await this.service.updateOneById( + body._id, + body, + { session }, + ); + if (updateErrors) return cancelTransaction(session, updateErrors); + + let completedClanTask = null; + if (phraseChanged && wasUpdated) { + const [progressResult, taskErrors] = + await this.dailyTasksService.updateClanTask( + body._id, + user.player_id, + ServerTaskName.INNER_VOICE, + session, + ); + + // A clan can have no active INNER_VOICE task after its daily instances + // have been completed and replaced. Saving its phrase must still work. + const hasNoActiveInnerVoiceTask = + taskErrors?.every((error) => error.reason === SEReason.NOT_FOUND) ?? + false; + if (taskErrors && !hasNoActiveInnerVoiceTask) + return cancelTransaction(session, taskErrors); + + if (progressResult) { + const [, progressErrors] = + await this.dailyTaskProgressService.handleClanTaskCompletion( + progressResult, + session, + false, + ); + if (progressErrors) return cancelTransaction(session, progressErrors); + + completedClanTask = progressResult; + } + } + + const [, commitErrors] = await endTransaction(session); + if (commitErrors) return [null, commitErrors]; + + if (completedClanTask) + this.dailyTaskProgressService.notifyClanTaskCompletion(completedClanTask); + if (phraseChanged && wasUpdated) + this.clanNotifier.phraseUpdated(body._id, body.phrase); } /** diff --git a/src/clan/clan.module.ts b/src/clan/clan.module.ts index 6ec0e5703..b3f776203 100644 --- a/src/clan/clan.module.ts +++ b/src/clan/clan.module.ts @@ -20,6 +20,7 @@ import { ClanRoleVotingProcessor } from './role/clanRole.processor'; import { ChatModule } from '../chat/chat.module'; import { PasswordGenerator } from '../common/function/passwordGenerator'; import { EventEmitterCommonModule } from '../common/service/EventEmitterService/EventEmitterCommon.module'; +import { DailyTasksModule } from '../dailyTasks/dailyTasks.module'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { EventEmitterCommonModule } from '../common/service/EventEmitterService/ forwardRef(() => VotingModule), ChatModule, EventEmitterCommonModule, + DailyTasksModule, ], controllers: [ClanController, ClanRoleController], providers: [ diff --git a/src/clan/clan.notifier.ts b/src/clan/clan.notifier.ts index b01df1748..5daaf7a12 100644 --- a/src/clan/clan.notifier.ts +++ b/src/clan/clan.notifier.ts @@ -7,7 +7,8 @@ import { buildMqttNotification } from '../common/service/notificator/type/MqttNo export default class ClanNotifier { private readonly group = NotificationGroup.CLAN; - private readonly resource = NotificationResource.MEMBER; + private readonly memberResource = NotificationResource.MEMBER; + private readonly clanResource = NotificationResource.CLAN; memberJoin(clanId: string, playerId: string) { const topic = `/clan/${clanId}/member/join`; @@ -24,7 +25,7 @@ export default class ClanNotifier { NotificationSender.buildNotification() .addGroup(this.group, clanId) - .addResource(this.resource, 'join') + .addResource(this.memberResource, 'join') .send(NotificationStatus.NEW, payload); } @@ -43,7 +44,20 @@ export default class ClanNotifier { NotificationSender.buildNotification() .addGroup(this.group, clanId) - .addResource(this.resource, 'leave') + .addResource(this.memberResource, 'leave') + .send(NotificationStatus.UPDATE, payload); + } + + phraseUpdated(clanId: string, phrase: string) { + const payload = buildMqttNotification( + 'clan', + MqttNotificationType.CLAN_UPDATED, + { clan_id: clanId, phrase }, + ); + + NotificationSender.buildNotification() + .addGroup(this.group, clanId) + .addResource(this.clanResource, 'phrase') .send(NotificationStatus.UPDATE, payload); } } diff --git a/src/clan/clan.service.ts b/src/clan/clan.service.ts index d77171ae1..5b9a28818 100644 --- a/src/clan/clan.service.ts +++ b/src/clan/clan.service.ts @@ -17,6 +17,7 @@ import { IServiceReturn, TIServiceReadManyOptions, TReadByIdOptions, + TIServiceUpdateByIdOptions, TIServiceUpdateOneOptions, } from '../common/service/basicService/IService'; import { ModelName } from '../common/enum/modelName.enum'; @@ -266,7 +267,7 @@ export class ClanService { public async updateOneById( idOrBody: string | UpdateClanDto, body?: UpdateClanDto, - options?: TIServiceUpdateOneOptions, + options?: TIServiceUpdateByIdOptions, ): Promise> { const id = typeof idOrBody === 'string' ? idOrBody : idOrBody._id; const updateData = diff --git a/src/common/service/EventEmitterService/EventEmitter.service.ts b/src/common/service/EventEmitterService/EventEmitter.service.ts index f887d899a..d39813b99 100644 --- a/src/common/service/EventEmitterService/EventEmitter.service.ts +++ b/src/common/service/EventEmitterService/EventEmitter.service.ts @@ -27,6 +27,22 @@ export default class EventEmitterService { }); } + /** + * Emit a clan-level daily task event. Clan tasks are progressed without + * requiring a player reservation and do not grant an individual reward. + */ + public async EmitNewClanDailyTaskEvent( + clanId: string, + completedByPlayerId: string, + serverTaskName: ServerTaskName, + ) { + await this.eventEmitter.emitAsync('newClanDailyTaskEvent', { + clanId, + completedByPlayerId, + serverTaskName, + }); + } + /** * Emit a player created event * @param playerId of the created player diff --git a/src/common/service/basicService/BasicService.ts b/src/common/service/basicService/BasicService.ts index dc7402168..384767eff 100644 --- a/src/common/service/basicService/BasicService.ts +++ b/src/common/service/basicService/BasicService.ts @@ -73,11 +73,13 @@ export default class BasicService implements IService { options?: TReadByIdOptions, ): Promise> { try { - const { select, includeRefs } = options + const { select, includeRefs, ...settings } = options ? options - : { select: undefined, includeRefs: [] }; + : { select: undefined, includeRefs: [], session: undefined }; - const resp = await this.model.findById(_id, select).populate(includeRefs); + const resp = await this.model + .findById(_id, select, settings) + .populate(includeRefs); if (!resp) return [ diff --git a/src/common/service/notificator/enum/MqttNotificationType.enum.ts b/src/common/service/notificator/enum/MqttNotificationType.enum.ts index c6c7b672c..47c78a813 100644 --- a/src/common/service/notificator/enum/MqttNotificationType.enum.ts +++ b/src/common/service/notificator/enum/MqttNotificationType.enum.ts @@ -24,6 +24,7 @@ export enum MqttNotificationType { MEMBER_JOINED = 'MEMBER_JOINED', MEMBER_LEFT = 'MEMBER_LEFT', + CLAN_UPDATED = 'CLAN_UPDATED', FRIEND_REQUEST_CREATED = 'FRIEND_REQUEST_CREATED', FRIEND_REQUEST_ACCEPTED = 'FRIEND_REQUEST_ACCEPTED', diff --git a/src/common/service/notificator/enum/NotificationResource.enum.ts b/src/common/service/notificator/enum/NotificationResource.enum.ts index 9c87b2ce7..d82e37cda 100644 --- a/src/common/service/notificator/enum/NotificationResource.enum.ts +++ b/src/common/service/notificator/enum/NotificationResource.enum.ts @@ -33,6 +33,10 @@ export enum NotificationResource { * Notification about clan member join/leave events */ MEMBER = 'member', + /** + * Notifications about clan metadata changes + */ + CLAN = 'clan', /** * Notification about soulhome activation & deactivation and layout changes */ diff --git a/src/dailyTasks/dailyTaskProgress.service.ts b/src/dailyTasks/dailyTaskProgress.service.ts index bee9dafec..b76c772e2 100644 --- a/src/dailyTasks/dailyTaskProgress.service.ts +++ b/src/dailyTasks/dailyTaskProgress.service.ts @@ -52,6 +52,29 @@ export class DailyTaskProgressService { return endTransaction(newSession, handledResult); } + /** + * Completes a clan-level task without rewarding the acting player. + */ + async handleClanTaskCompletion( + result: DailyTaskProgressResult, + session?: ClientSession, + notify = true, + ): Promise>> { + if (session) return this.handleClanCompletion(result, session, notify); + + const [newSession, initErrors] = await initializeSession(this.connection); + if (!newSession) return [null, initErrors]; + + const [handledResult, errors] = await this.handleClanCompletion( + result, + newSession, + notify, + ); + if (errors) return cancelTransaction(newSession, errors); + + return endTransaction(newSession, handledResult); + } + private async handleCompletion( result: DailyTaskProgressResult, session: ClientSession, @@ -104,4 +127,54 @@ export class DailyTaskProgressService { return [result, null]; } + + private async handleClanCompletion( + result: DailyTaskProgressResult, + session: ClientSession, + notify: boolean, + ): Promise>> { + const { task } = result; + + const [updatedClan, clanRewardErrors] = + await this.clanRewarder.rewardClanForPlayerTask( + result.clanId, + task.points, + task.coins, + session, + ); + if (clanRewardErrors) return [null, clanRewardErrors]; + + const [progressionResult, clanProgressionErrors] = + await this.clanProgression.handleClanProgression(updatedClan, session); + if (clanProgressionErrors) return [null, clanProgressionErrors]; + + result.reachedMilestones = progressionResult.reachedMilestones; + + if (notify) this.notifyClanTaskCompletion(result); + + return [result, null]; + } + + /** + * Publishes clan-task notifications after the transaction containing the + * task and reward changes has committed. + */ + notifyClanTaskCompletion( + result: DailyTaskProgressResult, + ) { + this.notifier.taskCompletedForClan( + result.clanId, + result.task, + result.completedByPlayerId, + ); + + if (result.reachedMilestones?.length) { + this.notifier.milestoneReached( + result.clanId, + result.task, + result.completedByPlayerId, + result.reachedMilestones, + ); + } + } } diff --git a/src/dailyTasks/dailyTasks.service.ts b/src/dailyTasks/dailyTasks.service.ts index f88959e46..64bb38e7e 100644 --- a/src/dailyTasks/dailyTasks.service.ts +++ b/src/dailyTasks/dailyTasks.service.ts @@ -8,7 +8,10 @@ import { DailyTask } from './dailyTasks.schema'; import { DailyTaskDto } from './dto/dailyTask.dto'; import { DailyTaskQueue } from './dailyTask.queue'; import { taskReservedError } from './errors/taskReserved.error'; -import { TaskGeneratorService } from './taskGenerator.service'; +import { + SERVER_TASKS_PER_CLAN, + TaskGeneratorService, +} from './taskGenerator.service'; import { IServiceReturn, TIServiceReadManyOptions, @@ -47,8 +50,8 @@ export class DailyTasksService { /** * Generates a set of tasks for a new clan. * - * This method creates 20 tasks with random values and assigns them to the specified clan. - * Each task is created by calling `createTaskRandomValues` and then adding the `clanId`. + * Each active server task type occurs at least twice, with the remaining + * slots selected randomly and the final list shuffled. * * @param clanId - The ID of the clan for which tasks are being generated. * @returns generated random tasks. @@ -56,24 +59,17 @@ export class DailyTasksService { generateServerTasksForNewClan( clanId: string, ): IServiceReturn[]> { - const tasks: Omit[] = []; - - // There will be 10 server tasks only, at the moment there are - // some of the old ones, too. In total 20. - // so this number (11) will change! - for (let i = 0; i < 11; i++) { - const partial = this.taskGenerator.createTaskRandomValues(); - const timeLimitMinutes = partial.amount * 2; - const task: Omit = { - ...partial, - amountLeft: partial.amount, - timeLimitMinutes, - clan_id: clanId, - player_id: null, - startedAt: null, - }; - tasks.push(task); - } + const tasks = this.taskGenerator + .createBalancedTaskValues(SERVER_TASKS_PER_CLAN) + .map( + (task): Omit => ({ + ...task, + amountLeft: task.amount, + clan_id: clanId, + player_id: null, + startedAt: null, + }), + ); return [tasks, null]; } @@ -227,6 +223,70 @@ export class DailyTasksService { ]; } + /** + * Progresses a shared clan task. Unlike updateTask, this never requires a + * player reservation and does not make the task an individual player task. + */ + async updateClanTask( + clanId: string, + completedByPlayerId: string, + serverTaskName: ServerTaskName, + session?: ClientSession, + ): Promise>> { + const filter = { + clan_id: clanId, + type: serverTaskName, + amountLeft: { $gt: 0 }, + }; + const [task, error] = await this.basicService.readOne({ + filter, + session, + }); + if (error) return [null, error]; + + const previousAmountLeft = task.amountLeft; + const completedAmount = Math.min(1, previousAmountLeft); + const currentAmountLeft = Math.max(previousAmountLeft - completedAmount, 0); + task.amountLeft = currentAmountLeft; + + if (currentAmountLeft <= 0) { + const newValues = this.taskGenerator.createTaskRandomValues(); + const [, replacementErrors] = await this.basicService.updateOne( + { + $set: { + ...newValues, + amountLeft: newValues.amount, + }, + $unset: { + player_id: '', + startedAt: '', + }, + }, + { filter: { _id: task._id, clan_id: clanId }, session }, + ); + if (replacementErrors) return [null, replacementErrors]; + } else { + const [, updateError] = await this.basicService.updateOne( + { $set: { amountLeft: currentAmountLeft } }, + { filter: { _id: task._id, clan_id: clanId }, session }, + ); + if (updateError) return [null, updateError]; + } + + return [ + { + status: currentAmountLeft <= 0 ? 'completed' : 'advanced', + task, + completedByPlayerId, + clanId: task.clan_id.toString(), + completedAmount, + previousAmountLeft, + currentAmountLeft, + }, + null, + ]; + } + /** * Reads a DailyTask by its _id in DB. * @@ -322,6 +382,36 @@ export class DailyTasksService { return endTransaction(session, progressResult); } + /** + * Handles a shared clan task event without granting player-task rewards. + */ + @OnEvent('newClanDailyTaskEvent') + async handleClanDailyTaskEvent(payload: { + clanId: string; + completedByPlayerId: string; + serverTaskName: ServerTaskName; + }): Promise>> { + const [session, initErrors] = await initializeSession(this.connection); + if (!session) return [null, initErrors]; + + const [progressResult, updateErrors] = await this.updateClanTask( + payload.clanId, + payload.completedByPlayerId, + payload.serverTaskName, + session, + ); + if (updateErrors) return cancelTransaction(session, updateErrors); + + const [, progressErrors] = + await this.progressService.handleClanTaskCompletion( + progressResult, + session, + ); + if (progressErrors) return cancelTransaction(session, progressErrors); + + return endTransaction(session, progressResult); + } + async getRewards() { return { prizePool: prizePool }; } diff --git a/src/dailyTasks/dailyTasksStartupRefresh.service.ts b/src/dailyTasks/dailyTasksStartupRefresh.service.ts index 9788055c0..32faaa820 100644 --- a/src/dailyTasks/dailyTasksStartupRefresh.service.ts +++ b/src/dailyTasks/dailyTasksStartupRefresh.service.ts @@ -4,20 +4,15 @@ import { Connection } from 'mongoose'; import { randomUUID } from 'node:crypto'; import { hostname } from 'node:os'; import { ModelName } from '../common/enum/modelName.enum'; -import { Score } from '../common/values/scoring.values'; -import { TASK_CONSTS } from './consts/taskConstants'; import { OldTaskName } from './enum/oldTaskNames.enum'; -import { ServerTaskName } from './enum/serverTaskName.enum'; +import { + SERVER_TASKS_PER_CLAN, + TaskGeneratorService, +} from './taskGenerator.service'; import { uiDailyTasks } from './uiDailyTasks/uiDailyTasks'; const LOCK_ID = 'daily-tasks-startup-refresh'; const LOCK_TTL_MS = 30 * 60 * 1000; -const SERVER_TASKS_PER_CLAN = 11; -const TEMP_SERVER_TASK_TYPES = [ - ServerTaskName.BANISH_THE_EARWORM, - ServerTaskName.GO_TO_BATTLE, - ServerTaskName.FORM_AN_INNER_CONNECTION, -]; type MaintenanceLock = { _id: string; @@ -48,7 +43,10 @@ export class DailyTasksStartupRefreshService implements OnApplicationBootstrap { private readonly logger = new Logger(DailyTasksStartupRefreshService.name); private readonly ownerId = `${hostname()}-${process.pid}-${randomUUID()}`; - constructor(@InjectConnection() private readonly connection: Connection) {} + constructor( + @InjectConnection() private readonly connection: Connection, + private readonly taskGenerator: TaskGeneratorService, + ) {} async onApplicationBootstrap() { try { @@ -190,52 +188,14 @@ export class DailyTasksStartupRefreshService implements OnApplicationBootstrap { } private createServerTasksForClan(clan: ClanDocument): DailyTaskDocument[] { - const tasks: DailyTaskDocument[] = []; - - for (let i = 0; i < SERVER_TASKS_PER_CLAN; i++) { - const generated = this.createServerTask(); - - tasks.push({ + return this.taskGenerator + .createBalancedTaskValues(SERVER_TASKS_PER_CLAN) + .map((generated) => ({ ...generated, clan_id: clan._id, player_id: null, amountLeft: generated.amount, startedAt: null, - timeLimitMinutes: generated.amount * 2, - }); - } - - return tasks; - } - - private createServerTask() { - const type = - TEMP_SERVER_TASK_TYPES[ - Math.floor(Math.random() * TEMP_SERVER_TASK_TYPES.length) - ]; - const amount = - Math.floor( - Math.random() * (TASK_CONSTS.AMOUNT.MAX - TASK_CONSTS.AMOUNT.MIN + 1), - ) + TASK_CONSTS.AMOUNT.MIN; - const points = Score.DAILY_TASK.COMPLETED; - - return { - title: this.getServerTaskTitle(type, amount), - type, - points, - coins: Math.floor(points * TASK_CONSTS.COINS.FACTOR), - amount, - }; - } - - private getServerTaskTitle(type: ServerTaskName, amount: number) { - switch (type) { - case ServerTaskName.BANISH_THE_EARWORM: - return { fi: `Karkoita korvamato ${amount} kertaa` }; - case ServerTaskName.GO_TO_BATTLE: - return { fi: `Pelaa ${amount} taistelua` }; - case ServerTaskName.FORM_AN_INNER_CONNECTION: - return { fi: `Lähetä ${amount} viesti klaanichattiin` }; - } + })); } } diff --git a/src/dailyTasks/taskGenerator.service.ts b/src/dailyTasks/taskGenerator.service.ts index 8e1606b85..cec5a613b 100644 --- a/src/dailyTasks/taskGenerator.service.ts +++ b/src/dailyTasks/taskGenerator.service.ts @@ -1,8 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { ServerTaskName } from './enum/serverTaskName.enum'; +import { Score } from '../common/values/scoring.values'; import { TASK_CONSTS } from './consts/taskConstants'; +import { ServerTaskName } from './enum/serverTaskName.enum'; import { TaskTitle } from './type/taskTitle.type'; -import { Score } from '../common/values/scoring.values'; + +export const SERVER_TASKS_PER_CLAN = 11; +export const MIN_OCCURRENCES_PER_TASK_TYPE = 2; + +type ServerTaskDefinition = { + type: ServerTaskName; + createAmount: () => number; + createTitle: (amount: number) => TaskTitle; +}; type TaskInfo = { title: TaskTitle; @@ -10,72 +19,129 @@ type TaskInfo = { points: number; coins: number; amount: number; + timeLimitMinutes: number; }; -const GENERATED_SERVER_TASK_TYPES = [ - ServerTaskName.BANISH_THE_EARWORM, - ServerTaskName.GO_TO_BATTLE, - ServerTaskName.FORM_AN_INNER_CONNECTION, +const createRandomAmount = () => + Math.floor( + Math.random() * (TASK_CONSTS.AMOUNT.MAX - TASK_CONSTS.AMOUNT.MIN + 1), + ) + TASK_CONSTS.AMOUNT.MIN; + +export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ + { + type: ServerTaskName.BANISH_THE_EARWORM, + createAmount: createRandomAmount, + createTitle: (amount) => ({ fi: `Karkoita korvamato ${amount} kertaa` }), + }, + { + type: ServerTaskName.GO_TO_BATTLE, + createAmount: createRandomAmount, + createTitle: (amount) => ({ fi: `Pelaa ${amount} taistelua` }), + }, + { + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + createAmount: createRandomAmount, + createTitle: (amount) => ({ + fi: `Lähetä ${amount} viesti klaanichattiin`, + }), + }, + { + type: ServerTaskName.INNER_VOICE, + createAmount: () => 1, + createTitle: () => ({ + fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', + }), + }, ]; @Injectable() export class TaskGeneratorService { - constructor() {} - /** - * Retrieves a random task type from the available task names enum. - * - * @returns A randomly selected task name. + * Retrieves a random active task type for task replacements. */ getRandomTaskType(): ServerTaskName { const randomIndex = Math.floor( - Math.random() * GENERATED_SERVER_TASK_TYPES.length, + Math.random() * ACTIVE_SERVER_TASK_DEFINITIONS.length, ); - return GENERATED_SERVER_TASK_TYPES[randomIndex]; + return ACTIVE_SERVER_TASK_DEFINITIONS[randomIndex].type; } /** - * Generates a task title based on the task type and amount. - * - * @param type - The type of the task. - * @param amount - The number associated with the task. - * @returns The generated task title as a string. - * @throws Will throw an error if the task type is unknown. + * Builds a shuffled task bag where every active type occurs at least the + * configured minimum. Remaining slots retain the existing random behavior. */ - getTaskTitle(type: ServerTaskName, amount: number): TaskTitle { - switch (type) { - case ServerTaskName.BANISH_THE_EARWORM: - return { fi: `Karkoita korvamato ${amount} kertaa` }; - case ServerTaskName.GO_TO_BATTLE: - return { fi: `Pelaa ${amount} taistelua` }; - case ServerTaskName.FORM_AN_INNER_CONNECTION: - return { fi: `Lähetä ${amount} viesti klaanichattiin` }; - default: - throw new Error('Unknown task type'); + createBalancedTaskTypes(taskCount = SERVER_TASKS_PER_CLAN): ServerTaskName[] { + const minimumTaskCount = + ACTIVE_SERVER_TASK_DEFINITIONS.length * MIN_OCCURRENCES_PER_TASK_TYPE; + if (taskCount < minimumTaskCount) { + throw new Error( + `Cannot generate ${taskCount} server tasks: ${minimumTaskCount} are required to include each active task type ${MIN_OCCURRENCES_PER_TASK_TYPE} times.`, + ); } + + const taskTypes = ACTIVE_SERVER_TASK_DEFINITIONS.flatMap(({ type }) => + Array(MIN_OCCURRENCES_PER_TASK_TYPE).fill(type), + ); + + while (taskTypes.length < taskCount) { + taskTypes.push(this.getRandomTaskType()); + } + + return this.shuffle(taskTypes); } /** - * Generates a random task with random values for amount, points, coins, type, and title. - * - * @returns A partial Task missing the ids and startedAt fields and object containing randomly generated values. + * Creates task values for a balanced clan task pool. + */ + createBalancedTaskValues(taskCount = SERVER_TASKS_PER_CLAN): TaskInfo[] { + return this.createBalancedTaskTypes(taskCount).map((type) => + this.createTaskValues(type), + ); + } + + getTaskTitle(type: ServerTaskName, amount: number): TaskTitle { + return this.getDefinition(type).createTitle(amount); + } + + /** + * Creates a random task for an in-day completed-task replacement. */ createTaskRandomValues(): TaskInfo { - const amount = - Math.floor( - Math.random() * (TASK_CONSTS.AMOUNT.MAX - TASK_CONSTS.AMOUNT.MIN + 1), - ) + TASK_CONSTS.AMOUNT.MIN; + return this.createTaskValues(this.getRandomTaskType()); + } + + private createTaskValues(type: ServerTaskName): TaskInfo { + const definition = this.getDefinition(type); + const amount = definition.createAmount(); const points = Score.DAILY_TASK.COMPLETED; - const coins = Math.floor(points * TASK_CONSTS.COINS.FACTOR); - const taskType = this.getRandomTaskType(); - const titleString = this.getTaskTitle(taskType, amount); return { amount, points, - coins, - type: taskType, - title: titleString, + coins: Math.floor(points * TASK_CONSTS.COINS.FACTOR), + timeLimitMinutes: amount * 2, + type, + title: definition.createTitle(amount), }; } + + private getDefinition(type: ServerTaskName): ServerTaskDefinition { + const definition = ACTIVE_SERVER_TASK_DEFINITIONS.find( + (candidate) => candidate.type === type, + ); + if (!definition) throw new Error(`Unknown task type: ${type}`); + + return definition; + } + + private shuffle(items: T[]): T[] { + const shuffled = [...items]; + + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + return shuffled; + } } diff --git a/src/rewarder/clanRewarder/clanRewarder.service.ts b/src/rewarder/clanRewarder/clanRewarder.service.ts index c555f4334..54497ab29 100644 --- a/src/rewarder/clanRewarder/clanRewarder.service.ts +++ b/src/rewarder/clanRewarder/clanRewarder.service.ts @@ -76,7 +76,9 @@ export class ClanRewarder { ], ]; - const [clanToUpdate, errors] = await this.clanService.readOneById(clan_id); + const [clanToUpdate, errors] = await this.clanService.readOneById(clan_id, { + session, + }); if (errors) return [null, errors]; From 5d059d3ca2356b3c80ac8eddb070cd2a22d09542 Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Sat, 12 Sep 2026 18:38:46 +0300 Subject: [PATCH 2/5] fix: preserve non transactional ID reads --- src/common/service/basicService/BasicService.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/common/service/basicService/BasicService.ts b/src/common/service/basicService/BasicService.ts index 384767eff..7b1f31e08 100644 --- a/src/common/service/basicService/BasicService.ts +++ b/src/common/service/basicService/BasicService.ts @@ -73,13 +73,14 @@ export default class BasicService implements IService { options?: TReadByIdOptions, ): Promise> { try { - const { select, includeRefs, ...settings } = options + const { select, includeRefs, session } = options ? options : { select: undefined, includeRefs: [], session: undefined }; - const resp = await this.model - .findById(_id, select, settings) - .populate(includeRefs); + const query = session + ? this.model.findById(_id, select, { session }) + : this.model.findById(_id, select); + const resp = await query.populate(includeRefs); if (!resp) return [ From 23ed2f97794e80e929e055e19a9cacd76c27738b Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Sun, 13 Sep 2026 12:22:22 +0300 Subject: [PATCH 3/5] fix: playerId to player_id in dailyTask.queue.ts --- src/dailyTasks/dailyTask.queue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dailyTasks/dailyTask.queue.ts b/src/dailyTasks/dailyTask.queue.ts index 43ae36c36..bb3f54728 100644 --- a/src/dailyTasks/dailyTask.queue.ts +++ b/src/dailyTasks/dailyTask.queue.ts @@ -53,7 +53,7 @@ export class DailyTaskProcessor extends WorkerHost { amountLeft: task.amount, }, $unset: { - playerId: '', + player_id: '', startedAt: '', }, }, From e1913e60ebf1c10f95d5bc5574c0bcc822d6371e Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Sun, 13 Sep 2026 13:28:53 +0300 Subject: [PATCH 4/5] fix: playerId to player_id in dailyTasks.schema.ts --- src/dailyTasks/dailyTasks.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dailyTasks/dailyTasks.schema.ts b/src/dailyTasks/dailyTasks.schema.ts index 10b9aecaf..cb60c3fd5 100644 --- a/src/dailyTasks/dailyTasks.schema.ts +++ b/src/dailyTasks/dailyTasks.schema.ts @@ -51,7 +51,7 @@ export class DailyTask { export const DailyTaskSchema = SchemaFactory.createForClass(DailyTask); DailyTaskSchema.set('collection', ModelName.DAILY_TASK); -DailyTaskSchema.index({ playerId: 1 }, { unique: true, sparse: true }); +DailyTaskSchema.index({ player_id: 1 }, { unique: true, sparse: true }); DailyTaskSchema.virtual(ModelName.PLAYER, { ref: ModelName.PLAYER, localField: 'player_id', From 98dbaf9f9f62ef85f150033984bc0cad360d016e Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Sun, 13 Sep 2026 13:54:29 +0300 Subject: [PATCH 5/5] fix: allow multiple unreserved daily tasks --- src/dailyTasks/dailyTasks.schema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/dailyTasks/dailyTasks.schema.ts b/src/dailyTasks/dailyTasks.schema.ts index cb60c3fd5..360927a07 100644 --- a/src/dailyTasks/dailyTasks.schema.ts +++ b/src/dailyTasks/dailyTasks.schema.ts @@ -51,7 +51,15 @@ export class DailyTask { export const DailyTaskSchema = SchemaFactory.createForClass(DailyTask); DailyTaskSchema.set('collection', ModelName.DAILY_TASK); -DailyTaskSchema.index({ player_id: 1 }, { unique: true, sparse: true }); +// Clan and unreserved tasks intentionally use player_id: null. Restrict the +// uniqueness constraint to tasks that are actually reserved by a player. +DailyTaskSchema.index( + { player_id: 1 }, + { + unique: true, + partialFilterExpression: { player_id: { $type: 'objectId' } }, + }, +); DailyTaskSchema.virtual(ModelName.PLAYER, { ref: ModelName.PLAYER, localField: 'player_id',