From a2bae473a1777e4642a01408f2a2e04623906da9 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:18:51 +0300 Subject: [PATCH 01/15] Validate amount of testers --- src/box/box.service.ts | 46 +++++++++++++++++++++++++++ src/box/consts/boxSessionConstants.ts | 6 ++++ src/box/dto/configureBox.dto.ts | 8 +++++ 3 files changed, 60 insertions(+) create mode 100644 src/box/consts/boxSessionConstants.ts diff --git a/src/box/box.service.ts b/src/box/box.service.ts index a1a73b97f..9af98e564 100644 --- a/src/box/box.service.ts +++ b/src/box/box.service.ts @@ -33,6 +33,10 @@ import { initializeSession, } from '../common/function/Transactions'; import { CreateBoxDto } from './dto/createBox.dto'; +import { + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, +} from './consts/boxSessionConstants'; @Injectable() export class BoxService { @@ -177,6 +181,48 @@ export class BoxService { }), ], ]; + if (box.testersAmount !== undefined && !Number.isInteger(box.testersAmount)) + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_NUMBER, + field: 'testersAmount', + value: box.testersAmount, + message: 'testersAmount must be an integer', + }), + ], + ]; + if ( + box.testersAmount !== undefined && + box.testersAmount < BOX_SESSION_MIN_PARTICIPANTS + ) + return [ + null, + [ + new ServiceError({ + reason: SEReason.LESS_THAN_MIN, + field: 'testersAmount', + value: box.testersAmount, + message: `testersAmount must be at least ${BOX_SESSION_MIN_PARTICIPANTS}`, + }), + ], + ]; + if ( + box.testersAmount !== undefined && + box.testersAmount > BOX_SESSION_MAX_PARTICIPANTS + ) + return [ + null, + [ + new ServiceError({ + reason: SEReason.MORE_THAN_MAX, + field: 'testersAmount', + value: box.testersAmount, + message: `testersAmount cannot exceed ${BOX_SESSION_MAX_PARTICIPANTS}`, + }), + ], + ]; const { _id, ...fieldsToUpdate } = box; const [isSuccess, errors] = await this.basicService.updateOneById( diff --git a/src/box/consts/boxSessionConstants.ts b/src/box/consts/boxSessionConstants.ts new file mode 100644 index 000000000..10b6987d3 --- /dev/null +++ b/src/box/consts/boxSessionConstants.ts @@ -0,0 +1,6 @@ +export const BOX_SESSION_MAX_PARTICIPANTS = 30; +export const BOX_SESSION_MIN_PARTICIPANTS = 1; +export const BOX_SESSION_CLAN_COUNT = 2; +export const BOX_SESSION_MAX_MEMBERS_PER_CLAN = 15; +export const BOX_SESSION_POINTS_PER_MEMBER = 420; +export const BOX_SESSION_TASK_COUNT = 29; diff --git a/src/box/dto/configureBox.dto.ts b/src/box/dto/configureBox.dto.ts index f2df488d9..12eb2303f 100644 --- a/src/box/dto/configureBox.dto.ts +++ b/src/box/dto/configureBox.dto.ts @@ -7,9 +7,15 @@ import { IsNotEmpty, IsOptional, IsString, + Max, + Min, ValidateNested, } from 'class-validator'; import { ClanToCreateDto } from './clanToCreate.dto'; +import { + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, +} from '../consts/boxSessionConstants'; export class ConfigureBoxDto { /** @@ -31,6 +37,8 @@ export class ConfigureBoxDto { * @example 10 */ @IsInt() + @Min(BOX_SESSION_MIN_PARTICIPANTS) + @Max(BOX_SESSION_MAX_PARTICIPANTS) @IsOptional() testersAmount?: number; From 249cf315bbd0ed31ab7ca8a33ee9c414b7402784 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:19:59 +0300 Subject: [PATCH 02/15] Tests for validating amount of testers --- .../box/BoxService/updateOneById.test.ts | 38 +++++++++++++++++ src/__tests__/box/dto/configureBox.test.ts | 42 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/__tests__/box/dto/configureBox.test.ts diff --git a/src/__tests__/box/BoxService/updateOneById.test.ts b/src/__tests__/box/BoxService/updateOneById.test.ts index d24879664..5249b3cdc 100644 --- a/src/__tests__/box/BoxService/updateOneById.test.ts +++ b/src/__tests__/box/BoxService/updateOneById.test.ts @@ -3,6 +3,10 @@ import { BoxService } from '../../../box/box.service'; import BoxBuilderFactory from '../data/boxBuilderFactory'; import { ObjectId } from 'mongodb'; import BoxModule from '../modules/box.module'; +import { + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, +} from '../../../box/consts/boxSessionConstants'; describe('BoxService.updateOneById() test suite', () => { let boxService: BoxService; @@ -12,6 +16,7 @@ describe('BoxService.updateOneById() test suite', () => { .setAdminPassword('password') .setAdminPlayerId(new ObjectId()) .setAdminProfileId(new ObjectId()) + .setTestersAmount(BOX_SESSION_MIN_PARTICIPANTS) .build(); beforeEach(async () => { @@ -58,6 +63,39 @@ describe('BoxService.updateOneById() test suite', () => { expect(errors[0].field).toBe('_id'); }); + it('Should return ServiceError LESS_THAN_MIN if testersAmount is below the session minimum', async () => { + const [wasUpdated, errors] = await boxService.updateOneById({ + _id: existingBox._id, + testersAmount: BOX_SESSION_MIN_PARTICIPANTS - 1, + }); + + expect(wasUpdated).toBeNull(); + expect(errors).toContainSE_LESS_THAN_MIN(); + expect(errors[0].field).toBe('testersAmount'); + }); + + it('Should return ServiceError MORE_THAN_MAX if testersAmount exceeds the session maximum', async () => { + const [wasUpdated, errors] = await boxService.updateOneById({ + _id: existingBox._id, + testersAmount: BOX_SESSION_MAX_PARTICIPANTS + 1, + }); + + expect(wasUpdated).toBeNull(); + expect(errors).toContainSE_MORE_THAN_MAX(); + expect(errors[0].field).toBe('testersAmount'); + }); + + it('Should return ServiceError NOT_NUMBER if testersAmount is not an integer', async () => { + const [wasUpdated, errors] = await boxService.updateOneById({ + _id: existingBox._id, + testersAmount: 1.5, + }); + + expect(wasUpdated).toBeNull(); + expect(errors).toContainSE_NOT_NUMBER(); + expect(errors[0].field).toBe('testersAmount'); + }); + it('Should return ServiceError NOT_UNIQUE if the box with provided _id adminPassword already exists', async () => { const otherBox = boxBuilder .setAdminPassword('other-box-password') diff --git a/src/__tests__/box/dto/configureBox.test.ts b/src/__tests__/box/dto/configureBox.test.ts new file mode 100644 index 000000000..6a36a03c4 --- /dev/null +++ b/src/__tests__/box/dto/configureBox.test.ts @@ -0,0 +1,42 @@ +import { validate } from 'class-validator'; +import { ConfigureBoxDto } from '../../../box/dto/configureBox.dto'; +import { + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, +} from '../../../box/consts/boxSessionConstants'; + +describe('ConfigureBoxDto', () => { + const createDto = (testersAmount?: number) => { + const dto = new ConfigureBoxDto(); + dto.testersAmount = testersAmount; + return dto; + }; + + it.each([BOX_SESSION_MIN_PARTICIPANTS, BOX_SESSION_MAX_PARTICIPANTS])( + 'accepts %i participants', + async (testersAmount) => { + await expect(validate(createDto(testersAmount))).resolves.toHaveLength(0); + }, + ); + + it('allows testersAmount to be omitted', async () => { + await expect(validate(createDto())).resolves.toHaveLength(0); + }); + + it.each([0, -1, BOX_SESSION_MAX_PARTICIPANTS + 1])( + 'rejects an out-of-range participant count of %i', + async (testersAmount) => { + const errors = await validate(createDto(testersAmount)); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('testersAmount'); + }, + ); + + it('rejects a non-integer participant count', async () => { + const errors = await validate(createDto(1.5)); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isInt'); + }); +}); From 96b32dea604a66882ef29dafbbbb4050167df525 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:29:08 +0300 Subject: [PATCH 03/15] Create clans for box-session --- src/box/util/createBoxClanPlans.ts | 49 ++++++++++++++++++++++++++++++ src/clan/clan.schema.ts | 20 ++++++++++++ src/clan/dto/clan.dto.ts | 16 ++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/box/util/createBoxClanPlans.ts diff --git a/src/box/util/createBoxClanPlans.ts b/src/box/util/createBoxClanPlans.ts new file mode 100644 index 000000000..a195fc805 --- /dev/null +++ b/src/box/util/createBoxClanPlans.ts @@ -0,0 +1,49 @@ +import { + BOX_SESSION_MAX_MEMBERS_PER_CLAN, + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, + BOX_SESSION_POINTS_PER_MEMBER, +} from '../consts/boxSessionConstants'; + +export type BoxClanPlan = { + memberLimit: number; + targetPoints: number; +}; + +/** + * Creates deterministic plans for the two clans in a Box session. + * The first clan receives the extra member when the participant count is odd. + */ +export function createBoxClanPlans( + participants: number, +): [BoxClanPlan, BoxClanPlan] { + if ( + !Number.isInteger(participants) || + participants < BOX_SESSION_MIN_PARTICIPANTS || + participants > BOX_SESSION_MAX_PARTICIPANTS + ) { + throw new RangeError( + `participants must be an integer between ${BOX_SESSION_MIN_PARTICIPANTS} and ${BOX_SESSION_MAX_PARTICIPANTS}`, + ); + } + + const memberLimits = [ + Math.ceil(participants / 2), + Math.floor(participants / 2), + ] as const; + + if ( + memberLimits.some( + (memberLimit) => memberLimit > BOX_SESSION_MAX_MEMBERS_PER_CLAN, + ) + ) { + throw new RangeError( + `A Box session clan cannot exceed ${BOX_SESSION_MAX_MEMBERS_PER_CLAN} members`, + ); + } + + return memberLimits.map((memberLimit) => ({ + memberLimit, + targetPoints: memberLimit * BOX_SESSION_POINTS_PER_MEMBER, + })) as [BoxClanPlan, BoxClanPlan]; +} diff --git a/src/clan/clan.schema.ts b/src/clan/clan.schema.ts index 8359691c2..441ccd290 100644 --- a/src/clan/clan.schema.ts +++ b/src/clan/clan.schema.ts @@ -13,6 +13,10 @@ import { Stall } from './stall/stall.schema'; import { getDefaultStall } from './defaultValues/stall'; import { Environment } from '../common/enum/environment.enum'; import { ClanRule } from './enum/clanRule.enum'; +import { + BOX_SESSION_MAX_MEMBERS_PER_CLAN, + BOX_SESSION_POINTS_PER_MEMBER, +} from '../box/consts/boxSessionConstants'; export type ClanDocument = HydratedDocument; @@ -43,6 +47,22 @@ export class Clan { @Prop({ type: Number, default: 0 }) points: number; + @Prop({ + type: Number, + required: false, + min: 0, + max: BOX_SESSION_MAX_MEMBERS_PER_CLAN, + }) + boxMemberLimit?: number; + + @Prop({ + type: Number, + required: false, + min: 0, + max: BOX_SESSION_MAX_MEMBERS_PER_CLAN * BOX_SESSION_POINTS_PER_MEMBER, + }) + targetPoints?: number; + @Prop({ type: [Number], default: [] }) unlockedMilestones: number[]; diff --git a/src/clan/dto/clan.dto.ts b/src/clan/dto/clan.dto.ts index 083f2df05..11776b556 100644 --- a/src/clan/dto/clan.dto.ts +++ b/src/clan/dto/clan.dto.ts @@ -80,6 +80,22 @@ export class ClanDto { @Expose() points: number; + /** + * Planned member limit for a Box-session clan + * + * @example 15 + */ + @Expose() + boxMemberLimit?: number; + + /** + * Locked target score for a Box-session clan + * + * @example 6300 + */ + @Expose() + targetPoints?: number; + /** * Total battle points accumulated by the clan * From 4e9873715e12b780b9dd3bae023da9c362763aa2 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:30:19 +0300 Subject: [PATCH 04/15] Tests for creating clans --- .../box/util/createBoxClanPlans.test.ts | 52 +++++++++++++++++++ .../clan/ClanSchema/boxSessionFields.test.ts | 18 +++++++ src/__tests__/clan/data/clan/ClanBuilder.ts | 10 ++++ 3 files changed, 80 insertions(+) create mode 100644 src/__tests__/box/util/createBoxClanPlans.test.ts create mode 100644 src/__tests__/clan/ClanSchema/boxSessionFields.test.ts diff --git a/src/__tests__/box/util/createBoxClanPlans.test.ts b/src/__tests__/box/util/createBoxClanPlans.test.ts new file mode 100644 index 000000000..b3e388987 --- /dev/null +++ b/src/__tests__/box/util/createBoxClanPlans.test.ts @@ -0,0 +1,52 @@ +import { createBoxClanPlans } from '../../../box/util/createBoxClanPlans'; +import { + BOX_SESSION_MAX_PARTICIPANTS, + BOX_SESSION_MIN_PARTICIPANTS, + BOX_SESSION_POINTS_PER_MEMBER, +} from '../../../box/consts/boxSessionConstants'; + +describe('createBoxClanPlans()', () => { + it('splits an even participant count equally', () => { + expect(createBoxClanPlans(20)).toEqual([ + { memberLimit: 10, targetPoints: 4200 }, + { memberLimit: 10, targetPoints: 4200 }, + ]); + }); + + it('assigns the extra member to the first clan for an odd count', () => { + expect(createBoxClanPlans(29)).toEqual([ + { memberLimit: 15, targetPoints: 6300 }, + { memberLimit: 14, targetPoints: 5880 }, + ]); + }); + + it('supports the maximum participant count', () => { + const plans = createBoxClanPlans(BOX_SESSION_MAX_PARTICIPANTS); + + expect(plans).toEqual([ + { + memberLimit: 15, + targetPoints: 15 * BOX_SESSION_POINTS_PER_MEMBER, + }, + { + memberLimit: 15, + targetPoints: 15 * BOX_SESSION_POINTS_PER_MEMBER, + }, + ]); + }); + + it('supports the minimum participant count', () => { + expect(createBoxClanPlans(BOX_SESSION_MIN_PARTICIPANTS)).toEqual([ + { memberLimit: 1, targetPoints: BOX_SESSION_POINTS_PER_MEMBER }, + { memberLimit: 0, targetPoints: 0 }, + ]); + }); + + it.each([ + BOX_SESSION_MIN_PARTICIPANTS - 1, + BOX_SESSION_MAX_PARTICIPANTS + 1, + 1.5, + ])('rejects an invalid participant count of %s', (participants) => { + expect(() => createBoxClanPlans(participants)).toThrow(RangeError); + }); +}); diff --git a/src/__tests__/clan/ClanSchema/boxSessionFields.test.ts b/src/__tests__/clan/ClanSchema/boxSessionFields.test.ts new file mode 100644 index 000000000..2c7bcec1c --- /dev/null +++ b/src/__tests__/clan/ClanSchema/boxSessionFields.test.ts @@ -0,0 +1,18 @@ +import ClanBuilderFactory from '../data/clanBuilderFactory'; +import ClanModule from '../modules/clan.module'; + +describe('Clan Box-session fields', () => { + const clanModel = ClanModule.getClanModel(); + + it('persists the Box member limit and target points', async () => { + const clan = ClanBuilderFactory.getBuilder('Clan') + .setBoxMemberLimit(15) + .setTargetPoints(6300) + .build(); + + const createdClan = await clanModel.create(clan); + + expect(createdClan.boxMemberLimit).toBe(15); + expect(createdClan.targetPoints).toBe(6300); + }); +}); diff --git a/src/__tests__/clan/data/clan/ClanBuilder.ts b/src/__tests__/clan/data/clan/ClanBuilder.ts index 5c1fe7747..d84f0f35c 100644 --- a/src/__tests__/clan/data/clan/ClanBuilder.ts +++ b/src/__tests__/clan/data/clan/ClanBuilder.ts @@ -92,6 +92,16 @@ export default class ClanBuilder implements IDataBuilder { return this; } + setBoxMemberLimit(boxMemberLimit: number) { + this.base.boxMemberLimit = boxMemberLimit; + return this; + } + + setTargetPoints(targetPoints: number) { + this.base.targetPoints = targetPoints; + return this; + } + setBattlePoints(battlePoints: number) { this.base.battlePoints = battlePoints; return this; From e5bf8f4635f2bc975bd4e137202bd5cf89b21521 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:39:37 +0300 Subject: [PATCH 05/15] add session clan targets and member limits --- .../accountClaimer/testerAccount.service.ts | 24 +++++++++++++++++-- .../sessionStarter/sessionStarter.service.ts | 22 +++++++++++++++++ src/clan/clan.service.ts | 3 ++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/box/accountClaimer/testerAccount.service.ts b/src/box/accountClaimer/testerAccount.service.ts index f2e23ef9a..943d7ab09 100644 --- a/src/box/accountClaimer/testerAccount.service.ts +++ b/src/box/accountClaimer/testerAccount.service.ts @@ -101,15 +101,35 @@ export class TesterAccountService { const clanWithLeastPlayers = await this.clanModel .findOneAndUpdate( - { _id: { $in: clan_ids } }, + { + _id: { $in: clan_ids }, + $or: [ + { boxMemberLimit: { $exists: false } }, + { boxMemberLimit: null }, + { $expr: { $lt: ['$playerCount', '$boxMemberLimit'] } }, + ], + }, { $inc: { playerCount: 1 } }, { - sort: { playerCount: 1 }, + sort: { playerCount: 1, boxMemberLimit: -1, _id: 1 }, new: true, }, ) .exec(); + if (!clanWithLeastPlayers) + return [ + null, + [ + new ServiceError({ + reason: SEReason.MORE_THAN_MAX, + field: 'boxMemberLimit', + value: clan_ids, + message: 'All Box session clans have reached their member limit', + }), + ], + ]; + const leaderRole = clanWithLeastPlayers.roles.find((role) => { return role.name === 'leader'; }); diff --git a/src/box/sessionStarter/sessionStarter.service.ts b/src/box/sessionStarter/sessionStarter.service.ts index 8588536e2..f48042500 100644 --- a/src/box/sessionStarter/sessionStarter.service.ts +++ b/src/box/sessionStarter/sessionStarter.service.ts @@ -27,6 +27,7 @@ import { initializeSession, } from '../../common/function/Transactions'; import { Environment } from '../../common/enum/environment.enum'; +import { BoxClanPlan, createBoxClanPlans } from '../util/createBoxClanPlans'; /** * Class responsible for starting the testing session process. @@ -78,10 +79,25 @@ export default class SessionStarterService { const [boxInDB, error] = await this.getAndValidateBox(box_id, session); if (error) return [null, error]; + let clanPlans: [BoxClanPlan, BoxClanPlan]; + try { + clanPlans = createBoxClanPlans(boxInDB.testersAmount); + } catch (error) { + return cancelTransaction(session, [ + new ServiceError({ + reason: SEReason.MISCONFIGURED, + field: 'testersAmount', + value: boxInDB.testersAmount, + message: (error as Error).message, + }), + ]); + } + const [clans, err] = await this.createBoxClans( boxInDB.clansToCreate[0].name, boxInDB.clansToCreate[1].name, box_id.toString(), + clanPlans, session, ); if (err) return [null, err]; @@ -141,11 +157,13 @@ export default class SessionStarterService { clanName1: string, clanName2: string, box_id: string, + clanPlans: [BoxClanPlan, BoxClanPlan], session: ClientSession, ): Promise> { const [clan1Resp, clan1Errors] = await this.createBoxClan( clanName1, box_id, + clanPlans[0], session, ); if (clan1Errors) return [null, clan1Errors]; @@ -153,6 +171,7 @@ export default class SessionStarterService { const [clan2Resp, clan2Errors] = await this.createBoxClan( clanName2, box_id, + clanPlans[1], session, ); if (clan2Errors) return [null, clan2Errors]; @@ -173,6 +192,7 @@ export default class SessionStarterService { private async createBoxClan( clanName: string, box_id: string, + clanPlan: BoxClanPlan, session: ClientSession, ): Promise> { const defaultClanData = { @@ -186,6 +206,8 @@ export default class SessionStarterService { await this.clanService.createOneWithoutAdmin( { name: clanName, + boxMemberLimit: clanPlan.memberLimit, + targetPoints: clanPlan.targetPoints, ...defaultClanData, }, session, diff --git a/src/clan/clan.service.ts b/src/clan/clan.service.ts index a952e198d..e4f058620 100644 --- a/src/clan/clan.service.ts +++ b/src/clan/clan.service.ts @@ -163,7 +163,8 @@ export class ClanService { * Creates a clan without assigning an initial administrator. */ public async createOneWithoutAdmin( - clanToCreate: CreateClanDto, + clanToCreate: CreateClanDto & + Partial>, externalSession?: ClientSession, ): Promise> { const [session, initErrors] = externalSession From e4ad17e14fe53ce1051d4711f47ab9fdb806d5c2 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:40:56 +0300 Subject: [PATCH 06/15] Tests for session clan targets and member limits --- .../box/SessionStarterService/start.test.ts | 27 +++++++++++ .../addTesterToClan.test.ts | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/__tests__/box/SessionStarterService/start.test.ts b/src/__tests__/box/SessionStarterService/start.test.ts index dc2ed32e5..e5cf4f6fb 100644 --- a/src/__tests__/box/SessionStarterService/start.test.ts +++ b/src/__tests__/box/SessionStarterService/start.test.ts @@ -43,6 +43,7 @@ describe('SessionStarterService.start() test suite', () => { .setAdminPlayerId(new ObjectId(adminInDb._id)) .setAdminProfileId(new ObjectId(adminProfInDb._id)) .setClansToCreate([{ name: 'sessionClan1' }, { name: 'sessionClan2' }]) + .setTestersAmount(20) .setDailyTasks([task1, task2]) .build(); @@ -167,4 +168,30 @@ describe('SessionStarterService.start() test suite', () => { expect(clanNames).toContain(name); }); }); + + it('Should persist equal member limits and target points for an even participant count', async () => { + await starter.start(existingBox._id); + + const boxInDB = await boxModel.findById(existingBox._id); + const clans = await Promise.all( + boxInDB.createdClan_ids.map((clanId) => clanModel.findById(clanId)), + ); + + expect(clans.map((clan) => clan.boxMemberLimit)).toEqual([10, 10]); + expect(clans.map((clan) => clan.targetPoints)).toEqual([4200, 4200]); + }); + + it('Should give the first clan the extra member for an odd participant count', async () => { + await boxModel.findByIdAndUpdate(existingBox._id, { testersAmount: 29 }); + + await starter.start(existingBox._id); + + const boxInDB = await boxModel.findById(existingBox._id); + const clans = await Promise.all( + boxInDB.createdClan_ids.map((clanId) => clanModel.findById(clanId)), + ); + + expect(clans.map((clan) => clan.boxMemberLimit)).toEqual([15, 14]); + expect(clans.map((clan) => clan.targetPoints)).toEqual([6300, 5880]); + }); }); diff --git a/src/__tests__/box/accountClaimer/TesterAccountService/addTesterToClan.test.ts b/src/__tests__/box/accountClaimer/TesterAccountService/addTesterToClan.test.ts index 653a38593..388cee413 100644 --- a/src/__tests__/box/accountClaimer/TesterAccountService/addTesterToClan.test.ts +++ b/src/__tests__/box/accountClaimer/TesterAccountService/addTesterToClan.test.ts @@ -186,6 +186,37 @@ describe('TesterAccountService.addTesterToClan() test suite', () => { expect(clanAfter.playerCount).toBe(5); }); + it('Should follow Box clan member limits for an odd participant count', async () => { + const tester_ids = await createPlayers(29); + const clan_ids = await createBoxClans([15, 14]); + + await runAddTesterToClanAsync(tester_ids, clan_ids); + + const clansAfter = await Promise.all( + clan_ids.map((clanId) => clanModel.findById(clanId)), + ); + expect(clansAfter.map((clan) => clan.playerCount)).toEqual([15, 14]); + expect(clansAfter.map((clan) => clan.targetPoints)).toEqual([6300, 5880]); + }); + + it('Should not add a tester when all Box clans are full', async () => { + const [tester_id] = await createPlayers(1); + const clan_ids = await createBoxClans([1, 1]); + await clanModel.updateMany({ _id: { $in: clan_ids } }, { playerCount: 1 }); + + const [testerClan, errors] = await service.addTesterToClan( + tester_id, + clan_ids, + ); + + expect(testerClan).toBeNull(); + expect(errors).toContainSE_MORE_THAN_MAX(); + expect(errors[0].field).toBe('boxMemberLimit'); + + const playerAfter = await playerModel.findById(tester_id); + expect(playerAfter.clan_id ?? null).toBeNull(); + }); + it('Should return REQUIRED ServiceError if player_id is null', async () => { const clan_ids = await createClans(1); const [testerClan, errors] = await service.addTesterToClan(null, clan_ids); @@ -354,6 +385,23 @@ describe('TesterAccountService.addTesterToClan() test suite', () => { return createdClan_ids; } + async function createBoxClans(memberLimits: number[]) { + const createdClan_ids: string[] = []; + for (let i = 0; i < memberLimits.length; i++) { + const clanToCreate = clanBuilder + .setName(`box-clan-${i}`) + .setPlayerCount(0) + .setBoxMemberLimit(memberLimits[i]) + .setTargetPoints(memberLimits[i] * 420) + .build(); + const clanResp = await clanModel.create(clanToCreate); + + createdClan_ids.push(clanResp._id.toString()); + } + + return createdClan_ids; + } + /** * Runs the addTesterToClan() method for each player _id asynchronously * @param player_ids From 1e4c2fea1cab364b8e9b39d3cb0493b813b234d8 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:55:06 +0300 Subject: [PATCH 07/15] Create all daily tasks for the clans --- src/box/dto/box.dto.ts | 5 +- src/box/dto/updateBox.dto.ts | 5 +- src/box/schemas/box.schema.ts | 3 ++ .../sessionStarter/sessionStarter.service.ts | 53 ++++++++++++++++--- src/chat/chat.gateway.ts | 6 +++ src/dailyTasks/dailyTasks.module.ts | 7 ++- src/dailyTasks/taskGenerator.service.ts | 22 ++++++-- 7 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/box/dto/box.dto.ts b/src/box/dto/box.dto.ts index 735677e41..74a6c29d5 100644 --- a/src/box/dto/box.dto.ts +++ b/src/box/dto/box.dto.ts @@ -112,7 +112,10 @@ export class BoxDto { accountClaimersIds: string[]; /** - * Daily tasks generated for this box session + * Legacy predefined daily tasks stored in the Box configuration. + * + * @deprecated Testing sessions always use the complete server and UI task + * catalogs. */ @Expose() @Type(() => DailyTask) diff --git a/src/box/dto/updateBox.dto.ts b/src/box/dto/updateBox.dto.ts index 5312af41a..25aa03398 100644 --- a/src/box/dto/updateBox.dto.ts +++ b/src/box/dto/updateBox.dto.ts @@ -128,7 +128,10 @@ export class UpdateBoxDto { accountClaimersIds?: string[]; /** - * Updated daily tasks + * Legacy predefined daily tasks. + * + * @deprecated Testing sessions always use the complete server and UI task + * catalogs. Updating this field does not change the session task pool. */ @IsOptional() @IsArray() diff --git a/src/box/schemas/box.schema.ts b/src/box/schemas/box.schema.ts index 57deeaed6..4cad4c413 100644 --- a/src/box/schemas/box.schema.ts +++ b/src/box/schemas/box.schema.ts @@ -105,6 +105,9 @@ export class Box { /** * array of predefined by the group admin tasks + * + * @deprecated Box testing sessions use the complete server and UI task + * catalogs. This field is retained for backwards compatibility. */ @Prop({ type: [PredefinedDailyTaskSchema], diff --git a/src/box/sessionStarter/sessionStarter.service.ts b/src/box/sessionStarter/sessionStarter.service.ts index f48042500..048ffb9b0 100644 --- a/src/box/sessionStarter/sessionStarter.service.ts +++ b/src/box/sessionStarter/sessionStarter.service.ts @@ -14,7 +14,6 @@ import { Player } from '../../player/schemas/player.schema'; import { Clan } from '../../clan/clan.schema'; import { PasswordGenerator } from '../../common/function/passwordGenerator'; import { SessionStage } from '../enum/SessionStage.enum'; -import { PredefinedDailyTask } from '../dailyTask/predefinedDailyTask.schema'; import { SoulHome } from '../../clanInventory/soulhome/soulhome.schema'; import { Room } from '../../clanInventory/room/room.schema'; import { Stock } from '../../clanInventory/stock/stock.schema'; @@ -28,6 +27,15 @@ import { } from '../../common/function/Transactions'; import { Environment } from '../../common/enum/environment.enum'; import { BoxClanPlan, createBoxClanPlans } from '../util/createBoxClanPlans'; +import { + TaskGeneratorService, + TaskInfo, +} from '../../dailyTasks/taskGenerator.service'; +import { + UIDailyTaskData, + uiDailyTasks, +} from '../../dailyTasks/uiDailyTasks/uiDailyTasks'; +import { BOX_SESSION_TASK_COUNT } from '../consts/boxSessionConstants'; /** * Class responsible for starting the testing session process. @@ -44,6 +52,7 @@ export default class SessionStarterService { @InjectConnection() private readonly connection: Connection, private readonly clanService: ClanService, private readonly dailyTasksService: DailyTasksService, + private readonly taskGeneratorService: TaskGeneratorService, private readonly passwordGenerator: PasswordGenerator, ) { this.basicService = new BasicService(taskModel); @@ -115,7 +124,18 @@ export default class SessionStarterService { ); if (updateErr) return await cancelTransaction(session, updateErr); - const dailyTasksToCreate = boxInDB.dailyTasks.map((task) => task['_doc']); + let dailyTasksToCreate: (TaskInfo | UIDailyTaskData)[]; + try { + dailyTasksToCreate = this.createBoxSessionTaskPool(); + } catch (error) { + return cancelTransaction(session, [ + new ServiceError({ + reason: SEReason.MISCONFIGURED, + field: 'dailyTasks', + message: (error as Error).message, + }), + ]); + } const [, tasksCreationErrors] = await this.createDailyTasks( dailyTasksToCreate, clans[0]._id, @@ -281,7 +301,7 @@ export default class SessionStarterService { * @returns true if tasks was created or ServiceErrors if any occurred */ private async createDailyTasks( - tasks: PredefinedDailyTask[], + tasks: (TaskInfo | UIDailyTaskData)[], clan1_id: string | ObjectId, clan2_id: string | ObjectId, box_id: string, @@ -291,10 +311,8 @@ export default class SessionStarterService { return { ...dailyTask, amountLeft: dailyTask.amount, - title: { fi: dailyTask.title }, - timeLimitMinutes: 30, + title: { ...dailyTask.title }, player_id: null, - _id: undefined, box_id, }; }); @@ -320,6 +338,29 @@ export default class SessionStarterService { return [true, null]; } + /** + * Creates exactly one instance of every server and UI daily task type for a + * Box testing session. + */ + private createBoxSessionTaskPool(): (TaskInfo | UIDailyTaskData)[] { + const tasks = [ + ...this.taskGeneratorService.createAllServerTaskValues(), + ...Object.values(uiDailyTasks), + ]; + const uniqueTypes = new Set(tasks.map((task) => task.type)); + + if ( + tasks.length !== BOX_SESSION_TASK_COUNT || + uniqueTypes.size !== BOX_SESSION_TASK_COUNT + ) { + throw new Error( + `Box session task catalog must contain exactly ${BOX_SESSION_TASK_COUNT} unique task types`, + ); + } + + return tasks; + } + /** * Get and validate a box * @param box_id box _id to get and validate diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index c8c5318a5..96ef1331d 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -123,6 +123,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { true, payload, ); + this.emitterService.EmitNewDailyTaskEvent( + client.user.playerId, + ServerTaskName.INNER_DISCUSSION, + true, + payload, + ); } @SubscribeMessage('clanMessageReaction') diff --git a/src/dailyTasks/dailyTasks.module.ts b/src/dailyTasks/dailyTasks.module.ts index 235ea873b..1563a8396 100644 --- a/src/dailyTasks/dailyTasks.module.ts +++ b/src/dailyTasks/dailyTasks.module.ts @@ -52,6 +52,11 @@ import { DailyTaskProgressService } from './dailyTaskProgress.service'; DailyTasksResetNotifier, ], controllers: [DailyTasksController], - exports: [DailyTasksService, UiDailyTasksService, DailyTaskProgressService], + exports: [ + DailyTasksService, + UiDailyTasksService, + DailyTaskProgressService, + TaskGeneratorService, + ], }) export class DailyTasksModule {} diff --git a/src/dailyTasks/taskGenerator.service.ts b/src/dailyTasks/taskGenerator.service.ts index 39695318e..2a1921086 100644 --- a/src/dailyTasks/taskGenerator.service.ts +++ b/src/dailyTasks/taskGenerator.service.ts @@ -14,7 +14,7 @@ type ServerTaskDefinition = { timeLimitMinutes?: number; }; -type TaskInfo = { +export type TaskInfo = { title: TaskTitle; type: ServerTaskName; points: number; @@ -32,12 +32,12 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ { type: ServerTaskName.BANISH_THE_EARWORM, createAmount: createRandomAmount, - createTitle: (amount) => ({ fi: `Karkoita korvamato ${amount} kertaa` }), + createTitle: (amount) => ({ fi: `Karkoita korvamato ${amount} kertaa.` }), }, { type: ServerTaskName.GO_TO_BATTLE, createAmount: createRandomAmount, - createTitle: (amount) => ({ fi: `Pelaa ${amount} taistelua` }), + createTitle: (amount) => ({ fi: `Pelaa ${amount} taistelua.` }), }, { type: ServerTaskName.STRONGER_SOLDIER, @@ -67,6 +67,13 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', }), }, + { + type: ServerTaskName.INNER_DISCUSSION, + createAmount: () => 1, + createTitle: () => ({ + fi: 'Osallistu klaanin sisäiseen keskusteluun.', + }), + }, { type: ServerTaskName.YOUR_VOICE, createAmount: () => 1, @@ -152,6 +159,15 @@ export class TaskGeneratorService { ); } + /** + * Creates one task for every server task type. + */ + createAllServerTaskValues(): TaskInfo[] { + return Object.values(ServerTaskName).map((type) => + this.createTaskValues(type), + ); + } + getTaskTitle(type: ServerTaskName, amount: number): TaskTitle { return this.getDefinition(type).createTitle(amount); } From 6a50a8160f95721702cea94559d45efec42b9005 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 10:56:39 +0300 Subject: [PATCH 08/15] Add tests for creating all the daily tasks for the clans --- .../box/SessionStarterService/start.test.ts | 31 ++++++++++++++----- .../chat/chatGateway/chatGateway.test.ts | 13 +++++++- .../dailyTasks/dailyTaskScoringValues.test.ts | 24 ++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/__tests__/box/SessionStarterService/start.test.ts b/src/__tests__/box/SessionStarterService/start.test.ts index e5cf4f6fb..171600586 100644 --- a/src/__tests__/box/SessionStarterService/start.test.ts +++ b/src/__tests__/box/SessionStarterService/start.test.ts @@ -10,6 +10,10 @@ import { Box } from '../../../box/schemas/box.schema'; import DailyTasksModule from '../../dailyTasks/modules/dailyTasks.module'; import { SessionStage } from '../../../box/enum/SessionStage.enum'; import ClanModule from '../../clan/modules/clan.module'; +import { BOX_SESSION_TASK_COUNT } from '../../../box/consts/boxSessionConstants'; +import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; +import { UITaskName } from '../../../dailyTasks/enum/uiTaskName.enum'; +import SoulhomeModule from '../../clanInventory/modules/soulhome.module'; describe('SessionStarterService.start() test suite', () => { let starter: SessionStarterService; @@ -21,11 +25,17 @@ describe('SessionStarterService.start() test suite', () => { const playerModel = PlayerModule.getPlayerModel(); const dailyTaskModel = DailyTasksModule.getDailyTaskModel(); const clanModel = ClanModule.getClanModel(); + const soulHomeModel = SoulhomeModule.getSoulhomeModel(); const profileBuilder = ProfileBuilderFactory.getBuilder('Profile'); const playerBuilder = PlayerBuilderFactory.getBuilder('Player'); const dailyTaskBuilder = BoxBuilderFactory.getBuilder('PredefinedDailyTask'); + beforeAll(async () => { + // Ensure the collection exists before SessionStarter opens a transaction. + await soulHomeModel.init(); + }); + beforeEach(async () => { starter = await BoxModule.getSessionStarterService(); @@ -68,16 +78,23 @@ describe('SessionStarterService.start() test suite', () => { expect(isStarted).toBe(true); }); - it('Should create predefined daily tasks for each clan', async () => { + it('Should create every server and UI daily task for each clan', async () => { await starter.start(existingBox._id); const box = await boxModel.findById(existingBox._id); - const clanDailyTask = existingBox.dailyTasks; - const boxDailyTasksInDB = await dailyTaskModel.find({ - clan_id: { $in: box.createdClan_ids }, - }); - - expect(boxDailyTasksInDB).toHaveLength(clanDailyTask.length * 2); + const expectedTypes = [ + ...Object.values(ServerTaskName), + ...Object.values(UITaskName), + ]; + + for (const clanId of box.createdClan_ids) { + const clanTasks = await dailyTaskModel.find({ clan_id: clanId }); + const taskTypes = clanTasks.map((task) => task.type); + + expect(clanTasks).toHaveLength(BOX_SESSION_TASK_COUNT); + expect(new Set(taskTypes)).toHaveProperty('size', BOX_SESSION_TASK_COUNT); + expect(taskTypes).toEqual(expect.arrayContaining(expectedTypes)); + } }); it('Should set testers shared password for a box', async () => { diff --git a/src/__tests__/chat/chatGateway/chatGateway.test.ts b/src/__tests__/chat/chatGateway/chatGateway.test.ts index 682df7cb4..2d98807bd 100644 --- a/src/__tests__/chat/chatGateway/chatGateway.test.ts +++ b/src/__tests__/chat/chatGateway/chatGateway.test.ts @@ -72,7 +72,7 @@ describe('ChatGateway user initialization', () => { ); }); - it('checks both clan-chat daily tasks after a message is handled successfully', async () => { + it('checks all clan-chat daily tasks after a message is handled successfully', async () => { const client = createClient(); client.user = { playerId: 'player-id', clanId: 'clan-id' } as any; clanChatService.handleNewClanMessage.mockResolvedValue([ @@ -114,6 +114,17 @@ describe('ChatGateway user initialization', () => { emotion: ChatEmotion.JOY, }, ); + expect(emitterService.EmitNewDailyTaskEvent).toHaveBeenNthCalledWith( + 3, + 'player-id', + ServerTaskName.INNER_DISCUSSION, + true, + { + clanId: 'clan-id', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }, + ); }); it('does not emit daily task events for global messages', async () => { diff --git a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts index d40d90d44..c2aa8bcd6 100644 --- a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts +++ b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts @@ -46,6 +46,30 @@ describe('daily task scoring values', () => { }); }); + it('configures INNER_DISCUSSION as a one-step server task', () => { + const generator = new TaskGeneratorService(); + const task = generator + .createAllServerTaskValues() + .find(({ type }) => type === ServerTaskName.INNER_DISCUSSION); + + expect(task).toMatchObject({ + type: ServerTaskName.INNER_DISCUSSION, + amount: 1, + }); + }); + + it('creates exactly one task for every server task type', () => { + const generator = new TaskGeneratorService(); + const tasks = generator.createAllServerTaskValues(); + const taskTypes = tasks.map(({ type }) => type); + + expect(tasks).toHaveLength(Object.values(ServerTaskName).length); + expect(new Set(taskTypes).size).toBe(Object.values(ServerTaskName).length); + expect(taskTypes).toEqual( + expect.arrayContaining(Object.values(ServerTaskName)), + ); + }); + it('configures PLAY_WITH_EMOTIONS with one step for each chat emotion', () => { const generator = new TaskGeneratorService(); jest From b02f24bfb8f3b5d6392143f590984d6a0abe8ea5 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:02:50 +0300 Subject: [PATCH 09/15] Prevent clan mutation on box session --- src/clan/clan.controller.ts | 11 ++++++++-- src/clan/clan.module.ts | 2 ++ ...blockClanMutationInTestingSession.guard.ts | 21 +++++++++++++++++++ src/common/controller/APIError.ts | 5 ++++- src/common/controller/APIErrorReason.ts | 5 +++++ 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/clan/guard/blockClanMutationInTestingSession.guard.ts diff --git a/src/clan/clan.controller.ts b/src/clan/clan.controller.ts index 045c33f3c..506a304fa 100644 --- a/src/clan/clan.controller.ts +++ b/src/clan/clan.controller.ts @@ -8,6 +8,7 @@ import { Param, Post, Put, + UseGuards, } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; import { Connection } from 'mongoose'; @@ -60,6 +61,7 @@ import { DailyTaskProgressService } from '../dailyTasks/dailyTaskProgress.servic import { DailyTasksService } from '../dailyTasks/dailyTasks.service'; import { ServerTaskName } from '../dailyTasks/enum/serverTaskName.enum'; import ClanNotifier from './clan.notifier'; +import { BlockClanMutationInTestingSessionGuard } from './guard/blockClanMutationInTestingSession.guard'; @Controller('clan') export class ClanController { @@ -100,6 +102,7 @@ export class ClanController { errors: [400, 401, 403, 409], }) @Post() + @UseGuards(BlockClanMutationInTestingSessionGuard) @Authorize({ action: Action.create, subject: ClanDto }) @UniformResponse(ModelName.CLAN) public async create(@Body() body: CreateClanDto, @LoggedUser() user: User) { @@ -325,6 +328,7 @@ export class ClanController { errors: [400, 401, 403, 404], }) @Delete('/:_id') + @UseGuards(BlockClanMutationInTestingSessionGuard) @Authorize({ action: Action.delete, subject: UpdateClanDto }) @UniformResponse() public async delete(@Param() param: _idDto) { @@ -351,6 +355,7 @@ export class ClanController { }) @UniformResponse(ModelName.CLAN) @Post('join') + @UseGuards(BlockClanMutationInTestingSessionGuard) public async createJoin( @Body() body: JoinRequestDto, @LoggedUser() user: User, @@ -373,9 +378,10 @@ export class ClanController { success: { status: 204, }, - errors: [401, 404], + errors: [401, 403, 404], }) @Post('leave') + @UseGuards(BlockClanMutationInTestingSessionGuard) @HttpCode(204) @Authorize({ action: Action.create, subject: PlayerLeaveClanDto }) public leaveClan(@LoggedUser() user: User) { @@ -394,9 +400,10 @@ export class ClanController { success: { status: 204, }, - errors: [400, 401, 404], + errors: [400, 401, 403, 404], }) @Post('exclude') + @UseGuards(BlockClanMutationInTestingSessionGuard) @HttpCode(204) @DetermineClanId() @Authorize({ action: Action.create, subject: RemovePlayerDTO }) diff --git a/src/clan/clan.module.ts b/src/clan/clan.module.ts index b3f776203..d8bc16887 100644 --- a/src/clan/clan.module.ts +++ b/src/clan/clan.module.ts @@ -21,6 +21,7 @@ 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'; +import { BlockClanMutationInTestingSessionGuard } from './guard/blockClanMutationInTestingSession.guard'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { DailyTasksModule } from '../dailyTasks/dailyTasks.module'; ClanRoleService, ClanRoleVotingProcessor, PasswordGenerator, + BlockClanMutationInTestingSessionGuard, ], exports: [ ClanService, diff --git a/src/clan/guard/blockClanMutationInTestingSession.guard.ts b/src/clan/guard/blockClanMutationInTestingSession.guard.ts new file mode 100644 index 000000000..039450e07 --- /dev/null +++ b/src/clan/guard/blockClanMutationInTestingSession.guard.ts @@ -0,0 +1,21 @@ +import { CanActivate, Injectable } from '@nestjs/common'; +import { APIError } from '../../common/controller/APIError'; +import { APIErrorReason } from '../../common/controller/APIErrorReason'; +import isTestingSession from '../../box/util/isTestingSession'; + +/** + * Blocks normal clan membership mutations while the API is running a Box + * testing session. Internal Box services do not pass through controller + * guards and can still create clans and assign tester accounts. + */ +@Injectable() +export class BlockClanMutationInTestingSessionGuard implements CanActivate { + canActivate(): boolean { + if (!isTestingSession()) return true; + + throw new APIError({ + reason: APIErrorReason.CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION, + message: 'Clan action is blocked during a Box testing session', + }); + } +} diff --git a/src/common/controller/APIError.ts b/src/common/controller/APIError.ts index d46f0d702..23524f071 100644 --- a/src/common/controller/APIError.ts +++ b/src/common/controller/APIError.ts @@ -199,7 +199,10 @@ function determineStatus(reason: APIErrorReason) { APIErrorReason.AUTHENTICATION_FAILED, ]; - const notAuthorized = [APIErrorReason.NOT_AUTHORIZED]; + const notAuthorized = [ + APIErrorReason.NOT_AUTHORIZED, + APIErrorReason.CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION, + ]; const conflict = [APIErrorReason.NOT_UNIQUE]; diff --git a/src/common/controller/APIErrorReason.ts b/src/common/controller/APIErrorReason.ts index 9d6c5e613..7e40f5706 100644 --- a/src/common/controller/APIErrorReason.ts +++ b/src/common/controller/APIErrorReason.ts @@ -87,6 +87,11 @@ export enum APIErrorReason { */ NOT_AUTHORIZED = 'NOT_AUTHORIZED', + /** + * Normal clan mutations are disabled during a Box testing session. + */ + CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION = 'CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION', + /** * The maximum amount of requests allowed for one user is exceeded */ From 76e53e515906040e5d83499994964acfc3983280 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:03:47 +0300 Subject: [PATCH 10/15] Add tests for preventing clan mutation --- .../testingSessionGuard.test.ts | 25 ++++++++++++ ...ClanMutationInTestingSession.guard.test.ts | 40 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/__tests__/clan/ClanController/testingSessionGuard.test.ts create mode 100644 src/__tests__/clan/guard/blockClanMutationInTestingSession.guard.test.ts diff --git a/src/__tests__/clan/ClanController/testingSessionGuard.test.ts b/src/__tests__/clan/ClanController/testingSessionGuard.test.ts new file mode 100644 index 000000000..61a343d42 --- /dev/null +++ b/src/__tests__/clan/ClanController/testingSessionGuard.test.ts @@ -0,0 +1,25 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { ClanController } from '../../../clan/clan.controller'; +import { BlockClanMutationInTestingSessionGuard } from '../../../clan/guard/blockClanMutationInTestingSession.guard'; + +describe('ClanController testing-session mutation guards', () => { + const protectedMethods: (keyof ClanController)[] = [ + 'create', + 'delete', + 'createJoin', + 'leaveClan', + 'excludePlayer', + ]; + + it.each(protectedMethods)( + 'protects %s before the controller method can cause side effects', + (methodName: keyof ClanController) => { + const guards = Reflect.getMetadata( + GUARDS_METADATA, + ClanController.prototype[methodName], + ); + + expect(guards).toContain(BlockClanMutationInTestingSessionGuard); + }, + ); +}); diff --git a/src/__tests__/clan/guard/blockClanMutationInTestingSession.guard.test.ts b/src/__tests__/clan/guard/blockClanMutationInTestingSession.guard.test.ts new file mode 100644 index 000000000..52e43fead --- /dev/null +++ b/src/__tests__/clan/guard/blockClanMutationInTestingSession.guard.test.ts @@ -0,0 +1,40 @@ +import { APIErrorReason } from '../../../common/controller/APIErrorReason'; +import { APIError } from '../../../common/controller/APIError'; +import { envVars } from '../../../common/service/envHandler/envVars'; +import { Environment } from '../../../common/service/envHandler/enum/environment.enum'; +import { BlockClanMutationInTestingSessionGuard } from '../../../clan/guard/blockClanMutationInTestingSession.guard'; + +describe('BlockClanMutationInTestingSessionGuard', () => { + const guard = new BlockClanMutationInTestingSessionGuard(); + const originalEnvironment = envVars.ENVIRONMENT; + + afterEach(() => { + envVars.ENVIRONMENT = originalEnvironment; + }); + + it('allows clan mutations outside a testing session', () => { + envVars.ENVIRONMENT = Environment.PRODUCTION; + + expect(guard.canActivate()).toBe(true); + }); + + it('blocks clan mutations with a stable error reason during a testing session', () => { + envVars.ENVIRONMENT = Environment.TESTING_SESSION; + + expect.assertions(3); + try { + guard.canActivate(); + } catch (error) { + const apiError = error as APIError; + + expect(apiError).toMatchObject({ + reason: APIErrorReason.CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION, + statusCode: 403, + }); + expect(apiError.message).toBe( + 'Clan action is blocked during a Box testing session', + ); + expect(apiError.getStatus()).toBe(403); + } + }); +}); From 461a232549009b84ab62a49b33fb89f2fd525aa7 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:09:21 +0300 Subject: [PATCH 11/15] Handle reset in DailyTasksScheduler --- src/dailyTasks/dailyTasksScheduler.service.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/dailyTasks/dailyTasksScheduler.service.ts b/src/dailyTasks/dailyTasksScheduler.service.ts index c834b80a0..0a5461622 100644 --- a/src/dailyTasks/dailyTasksScheduler.service.ts +++ b/src/dailyTasks/dailyTasksScheduler.service.ts @@ -9,11 +9,10 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import UIDailyTasksService from './uiDailyTasks/uiDailyTasks.service'; import { DailyTasksService } from './dailyTasks.service'; import DailyTasksResetNotifier from './dailyTaskReset.notifier'; +import isTestingSession from '../box/util/isTestingSession'; @Injectable() export class DailyTasksScheduler { - private readonly clanService: BasicService; - private readonly playerService: BasicService; private readonly dailyTasksBasicService: BasicService; constructor( @@ -28,8 +27,6 @@ export class DailyTasksScheduler { private readonly dailyTasksService: DailyTasksService, private readonly dailyTasksResetNotifier: DailyTasksResetNotifier, ) { - this.clanService = new BasicService(clanModel); - this.playerService = new BasicService(playerModel); this.dailyTasksBasicService = new BasicService(dailyTaskModel); } @@ -43,11 +40,12 @@ export class DailyTasksScheduler { @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async resetDailyTasks() { const session = await this.connection.startSession(); + const preserveBoxSessionData = isTestingSession(); + const resetFilter = preserveBoxSessionData ? { box_id: null } : {}; try { await session.withTransaction(async () => { - const [clans, clansErrors] = await this.clanService.readMany(); - if (clansErrors) throw new Error('Failed to get clans'); + const clans = await this.clanModel.find(resetFilter, null, { session }); const newTasks = []; @@ -63,34 +61,32 @@ export class DailyTasksScheduler { newTasks.push(...uiTasks, ...tasks); } - if (newTasks.length <= 0) { - throw new Error('Failed to create tasks'); - } - - const [, taskDeleteErrors] = - await this.dailyTasksBasicService.deleteMany({ filter: {} }); - if (taskDeleteErrors) throw new Error('Failed to delete tasks'); + await this.dailyTaskModel.deleteMany(resetFilter, { session }); - const [, taskCreateErrors] = - await this.dailyTasksBasicService.createMany(newTasks); - if (taskCreateErrors) throw new Error('Failed to add tasks'); + if (newTasks.length > 0) { + const [, taskCreateErrors] = + await this.dailyTasksBasicService.createMany(newTasks, { session }); + if (taskCreateErrors) throw new Error('Failed to add tasks'); + } - const [, clansUpdateErrors] = await this.clanService.updateMany( - [{ $set: { points: 0, unlockedMilestones: [] } }], - { filter: {} }, + await this.clanModel.updateMany( + resetFilter, + { $set: { points: 0, unlockedMilestones: [] } }, + { session }, ); - if (clansUpdateErrors) throw new Error('Failed to update clans'); - - const [, playersUpdateErrors] = await this.playerService.updateMany( - [{ $set: { points: 0, claimableRewards: [] } }], - { filter: {} }, + await this.playerModel.updateMany( + resetFilter, + { $set: { points: 0, claimableRewards: [] } }, + { session }, ); - if (playersUpdateErrors) throw new Error('Failed to update players'); }); - this.dailyTasksResetNotifier.dailyTasksReset(); + if (!preserveBoxSessionData) + this.dailyTasksResetNotifier.dailyTasksReset(); } catch (error) { console.error('Daily task reset failed', error); + } finally { + await session.endSession(); } } } From f66e92f6cbd96d67fc8769bbbc534b91b7352e65 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:10:20 +0300 Subject: [PATCH 12/15] Tests for reseting DailyTasksScheduler --- .../dailyTasks/dailyTasksScheduler.test.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/__tests__/dailyTasks/dailyTasksScheduler.test.ts diff --git a/src/__tests__/dailyTasks/dailyTasksScheduler.test.ts b/src/__tests__/dailyTasks/dailyTasksScheduler.test.ts new file mode 100644 index 000000000..dae78ff45 --- /dev/null +++ b/src/__tests__/dailyTasks/dailyTasksScheduler.test.ts @@ -0,0 +1,141 @@ +import { Environment } from '../../common/service/envHandler/enum/environment.enum'; +import { envVars } from '../../common/service/envHandler/envVars'; +import { DailyTasksScheduler } from '../../dailyTasks/dailyTasksScheduler.service'; + +describe('DailyTasksScheduler.resetDailyTasks()', () => { + const originalEnvironment = envVars.ENVIRONMENT; + + afterEach(() => { + envVars.ENVIRONMENT = originalEnvironment; + }); + + const createScheduler = (clans: any[] = [{ _id: 'clan-id' }]) => { + const session = { + withTransaction: jest.fn(async (callback: () => Promise) => + callback(), + ), + endSession: jest.fn(), + }; + const connection = { + startSession: jest.fn().mockResolvedValue(session), + }; + const clanModel = { + find: jest.fn().mockResolvedValue(clans), + updateMany: jest.fn().mockResolvedValue({ matchedCount: clans.length }), + }; + const playerModel = { + updateMany: jest.fn().mockResolvedValue({ matchedCount: 1 }), + }; + const dailyTaskModel = { + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }), + create: jest.fn().mockResolvedValue([]), + }; + const uiDailyTasksService = { + getUITasksForClan: jest + .fn() + .mockReturnValue([[{ type: 'ui-task', clan_id: 'clan-id' }], null]), + }; + const dailyTasksService = { + generateServerTasksForNewClan: jest + .fn() + .mockReturnValue([[{ type: 'server-task', clan_id: 'clan-id' }], null]), + }; + const notifier = { dailyTasksReset: jest.fn() }; + + const scheduler = new DailyTasksScheduler( + clanModel as any, + playerModel as any, + dailyTaskModel as any, + connection as any, + uiDailyTasksService as any, + dailyTasksService as any, + notifier as any, + ); + + return { + scheduler, + session, + clanModel, + playerModel, + dailyTaskModel, + notifier, + }; + }; + + it('excludes Box data from every reset operation in a testing session', async () => { + envVars.ENVIRONMENT = Environment.TESTING_SESSION; + const { + scheduler, + session, + clanModel, + playerModel, + dailyTaskModel, + notifier, + } = createScheduler(); + + await scheduler.resetDailyTasks(); + + const nonBoxFilter = { box_id: null }; + expect(clanModel.find).toHaveBeenCalledWith(nonBoxFilter, null, { + session, + }); + expect(dailyTaskModel.deleteMany).toHaveBeenCalledWith(nonBoxFilter, { + session, + }); + expect(clanModel.updateMany).toHaveBeenCalledWith( + nonBoxFilter, + { $set: { points: 0, unlockedMilestones: [] } }, + { session }, + ); + expect(playerModel.updateMany).toHaveBeenCalledWith( + nonBoxFilter, + { $set: { points: 0, claimableRewards: [] } }, + { session }, + ); + expect(notifier.dailyTasksReset).not.toHaveBeenCalled(); + expect(session.endSession).toHaveBeenCalled(); + }); + + it('keeps the existing global reset behavior outside a testing session', async () => { + envVars.ENVIRONMENT = Environment.PRODUCTION; + const { + scheduler, + session, + clanModel, + playerModel, + dailyTaskModel, + notifier, + } = createScheduler(); + + await scheduler.resetDailyTasks(); + + expect(clanModel.find).toHaveBeenCalledWith({}, null, { session }); + expect(dailyTaskModel.deleteMany).toHaveBeenCalledWith({}, { session }); + expect(clanModel.updateMany).toHaveBeenCalledWith( + {}, + { $set: { points: 0, unlockedMilestones: [] } }, + { session }, + ); + expect(playerModel.updateMany).toHaveBeenCalledWith( + {}, + { $set: { points: 0, claimableRewards: [] } }, + { session }, + ); + expect(notifier.dailyTasksReset).toHaveBeenCalledTimes(1); + expect(session.endSession).toHaveBeenCalled(); + }); + + it('does not fail when a testing session contains no non-Box clans', async () => { + envVars.ENVIRONMENT = Environment.TESTING_SESSION; + const { scheduler, dailyTaskModel, notifier } = createScheduler([]); + + await scheduler.resetDailyTasks(); + + expect(dailyTaskModel.create).not.toHaveBeenCalled(); + expect(dailyTaskModel.deleteMany).toHaveBeenCalledWith( + { box_id: null }, + expect.any(Object), + ); + expect(notifier.dailyTasksReset).not.toHaveBeenCalled(); + }); +}); From ba3ac9883548ca5caf17f90ac2eedb55950be10a Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:15:00 +0300 Subject: [PATCH 13/15] Add integration tests for box session gameplay --- .../integration/boxSessionLifecycle.test.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/__tests__/box/integration/boxSessionLifecycle.test.ts diff --git a/src/__tests__/box/integration/boxSessionLifecycle.test.ts b/src/__tests__/box/integration/boxSessionLifecycle.test.ts new file mode 100644 index 000000000..ed1a277ff --- /dev/null +++ b/src/__tests__/box/integration/boxSessionLifecycle.test.ts @@ -0,0 +1,165 @@ +import { ObjectId } from 'mongodb'; +import { APIErrorReason } from '../../../common/controller/APIErrorReason'; +import { envVars } from '../../../common/service/envHandler/envVars'; +import { Environment } from '../../../common/service/envHandler/enum/environment.enum'; +import { BlockClanMutationInTestingSessionGuard } from '../../../clan/guard/blockClanMutationInTestingSession.guard'; +import { BOX_SESSION_TASK_COUNT } from '../../../box/consts/boxSessionConstants'; +import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; +import { UITaskName } from '../../../dailyTasks/enum/uiTaskName.enum'; +import BoxModule from '../modules/box.module'; +import BoxBuilderFactory from '../data/boxBuilderFactory'; +import PlayerModule from '../../player/modules/player.module'; +import PlayerBuilderFactory from '../../player/data/playerBuilderFactory'; +import ProfileModule from '../../profile/modules/profile.module'; +import ProfileBuilderFactory from '../../profile/data/profileBuilderFactory'; +import ClanModule from '../../clan/modules/clan.module'; +import DailyTasksModule from '../../dailyTasks/modules/dailyTasks.module'; +import SoulhomeModule from '../../clanInventory/modules/soulhome.module'; + +jest.setTimeout(30_000); + +describe('Box session lifecycle', () => { + const originalEnvironment = envVars.ENVIRONMENT; + const boxModel = BoxModule.getBoxModel(); + const playerModel = PlayerModule.getPlayerModel(); + const profileModel = ProfileModule.getProfileModel(); + const clanModel = ClanModule.getClanModel(); + const dailyTaskModel = DailyTasksModule.getDailyTaskModel(); + const soulHomeModel = SoulhomeModule.getSoulhomeModel(); + + beforeAll(async () => { + envVars.ENVIRONMENT = Environment.TESTING_SESSION; + await soulHomeModel.init(); + }); + + afterAll(() => { + envVars.ENVIRONMENT = originalEnvironment; + }); + + it('preserves the complete 30-player Box session contract', async () => { + const starter = await BoxModule.getSessionStarterService(); + const accountClaimer = await BoxModule.getAccountClaimerService(); + + const adminPlayer = await playerModel.create( + PlayerBuilderFactory.getBuilder('Player') + .setName('lifecycle-admin') + .setUniqueIdentifier('lifecycle-admin') + .setProfileId(new ObjectId()) + .build(), + ); + const adminProfile = await profileModel.create( + ProfileBuilderFactory.getBuilder('Profile') + .setUsername('lifecycle-admin') + .build(), + ); + const box = await boxModel.create( + BoxBuilderFactory.getBuilder('Box') + .setAdminPassword('lifecycle-admin-password') + .setAdminPlayerId(new ObjectId(adminPlayer._id)) + .setAdminProfileId(new ObjectId(adminProfile._id)) + .setClansToCreate([ + { name: 'Lifecycle Clan 1' }, + { name: 'Lifecycle Clan 2' }, + ]) + .setTestersAmount(30) + .setDailyTasks([]) + .build(), + ); + + const [started, startErrors] = await starter.start(box._id); + expect(startErrors).toBeNull(); + expect(started).toBe(true); + + const startedBox = await boxModel.findById(box._id); + expect(startedBox.createdClan_ids).toHaveLength(2); + + const startedClans = await Promise.all( + startedBox.createdClan_ids.map((clanId) => clanModel.findById(clanId)), + ); + expect(startedClans.map((clan) => clan.boxMemberLimit)).toEqual([15, 15]); + expect(startedClans.map((clan) => clan.targetPoints)).toEqual([6300, 6300]); + + const expectedTaskTypes = [ + ...Object.values(ServerTaskName), + ...Object.values(UITaskName), + ]; + for (const clan of startedClans) { + const tasks = await dailyTaskModel.find({ clan_id: clan._id }); + const taskTypes = tasks.map(({ type }) => type); + + expect(tasks).toHaveLength(BOX_SESSION_TASK_COUNT); + expect(new Set(taskTypes).size).toBe(BOX_SESSION_TASK_COUNT); + expect(taskTypes).toEqual(expect.arrayContaining(expectedTaskTypes)); + } + + for (let i = 0; i < 30; i++) { + const [account, claimErrors] = await accountClaimer.claimAccount( + startedBox.testersSharedPassword, + ); + expect(claimErrors).toBeNull(); + expect(account).not.toBeNull(); + } + + const fullClans = await Promise.all( + startedBox.createdClan_ids.map((clanId) => clanModel.findById(clanId)), + ); + expect(fullClans.map((clan) => clan.playerCount)).toEqual([15, 15]); + expect(fullClans.map((clan) => clan.targetPoints)).toEqual([6300, 6300]); + expect( + await playerModel.countDocuments({ + clan_id: { $in: startedBox.createdClan_ids }, + }), + ).toBe(30); + + const [extraAccount, extraClaimErrors] = await accountClaimer.claimAccount( + startedBox.testersSharedPassword, + ); + expect(extraAccount).toBeNull(); + expect(extraClaimErrors).toContainSE_NOT_AUTHORIZED(); + + const stateBeforeBlockedActions = await readSessionState( + startedBox.createdClan_ids, + ); + const guard = new BlockClanMutationInTestingSessionGuard(); + const blockedActions = ['create', 'join', 'leave', 'exclude', 'delete']; + + for (const action of blockedActions) { + try { + guard.canActivate(); + throw new Error(`${action} was not blocked`); + } catch (error) { + expect(error).toMatchObject({ + reason: APIErrorReason.CLAN_ACTION_BLOCKED_DURING_TESTING_SESSION, + statusCode: 403, + }); + } + } + + const stateAfterBlockedActions = await readSessionState( + startedBox.createdClan_ids, + ); + expect(stateAfterBlockedActions).toEqual(stateBeforeBlockedActions); + }); + + async function readSessionState(clanIds: ObjectId[]) { + const clans = await clanModel + .find({ _id: { $in: clanIds } }) + .sort({ _id: 1 }) + .lean(); + + return { + clans: clans.map((clan) => ({ + _id: clan._id.toString(), + playerCount: clan.playerCount, + boxMemberLimit: clan.boxMemberLimit, + targetPoints: clan.targetPoints, + })), + playerCount: await playerModel.countDocuments({ + clan_id: { $in: clanIds }, + }), + taskCount: await dailyTaskModel.countDocuments({ + clan_id: { $in: clanIds }, + }), + }; + } +}); From f97de2a4dc11e67c6305e7e0370ae8f75dd46fb1 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 11:50:37 +0300 Subject: [PATCH 14/15] Improve Finnish grammar --- src/dailyTasks/taskGenerator.service.ts | 4 ++-- src/dailyTasks/uiDailyTasks/uiDailyTasks.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dailyTasks/taskGenerator.service.ts b/src/dailyTasks/taskGenerator.service.ts index 2a1921086..62d14c4aa 100644 --- a/src/dailyTasks/taskGenerator.service.ts +++ b/src/dailyTasks/taskGenerator.service.ts @@ -64,7 +64,7 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ type: ServerTaskName.INNER_VOICE, createAmount: () => 1, createTitle: () => ({ - fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', + fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa, ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', }), }, { @@ -97,7 +97,7 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ type: ServerTaskName.LETTING_GO_OF_THE_OLD, createAmount: () => 1, createTitle: () => ({ - fi: 'Avaa klaanin äänestys. Valitse vaihtoehto ja anna äänesi. Huomaa, miten oma valintasi vaikuttaa yhteiseen päätökseen.', + fi: 'Avaa klaanin äänestys. Valitse vaihtoehto, ja anna äänesi. Huomaa, miten oma valintasi vaikuttaa yhteiseen päätökseen.', }), }, { diff --git a/src/dailyTasks/uiDailyTasks/uiDailyTasks.ts b/src/dailyTasks/uiDailyTasks/uiDailyTasks.ts index b1d5440ad..6b70c7633 100644 --- a/src/dailyTasks/uiDailyTasks/uiDailyTasks.ts +++ b/src/dailyTasks/uiDailyTasks/uiDailyTasks.ts @@ -70,7 +70,7 @@ export const uiDailyTasks: Record = { }, [UITaskName.FOLLOW_THE_VOICE]: { title: { - fi: 'Etsi ohjeet jotka oikeasti ohjaavat toimintaasi.', + fi: 'Etsi ohjeet, jotka oikeasti ohjaavat toimintaasi.', }, type: UITaskName.FOLLOW_THE_VOICE, points: TASK_CONSTS.POINTS.DAILY_TASK.SMALL, From 0cda43d43c264d65920c3817730158da740742b8 Mon Sep 17 00:00:00 2001 From: tickBit Date: Wed, 7 Oct 2026 12:10:34 +0300 Subject: [PATCH 15/15] Finnish grammar also improved in the tests --- src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts index c2aa8bcd6..45a0545ad 100644 --- a/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts +++ b/src/__tests__/dailyTasks/dailyTaskScoringValues.test.ts @@ -28,7 +28,7 @@ describe('daily task scoring values', () => { type: ServerTaskName.INNER_VOICE, amount: 1, title: { - fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', + fi: 'Avaa klaanin asetukset. Muokkaa klaanin mottoa, ja tallenna muutos. Mieti, mitä haluatte viestiä toisillenne ja muille.', }, }); }); @@ -137,7 +137,7 @@ describe('daily task scoring values', () => { type: ServerTaskName.LETTING_GO_OF_THE_OLD, amount: 1, title: { - fi: 'Avaa klaanin äänestys. Valitse vaihtoehto ja anna äänesi. Huomaa, miten oma valintasi vaikuttaa yhteiseen päätökseen.', + fi: 'Avaa klaanin äänestys. Valitse vaihtoehto, ja anna äänesi. Huomaa, miten oma valintasi vaikuttaa yhteiseen päätökseen.', }, }); });