diff --git a/components/level/game.tsx b/components/level/game.tsx index 526b0b103..723c94894 100644 --- a/components/level/game.tsx +++ b/components/level/game.tsx @@ -487,23 +487,13 @@ export default function Game({ }, [disableAutoUndo, disableCheckpoints, disablePlayAttempts, enableSessionCheckpoint, fetchPlayAttempt, game.displayName, isComplete, level._id, level.data, level.leastMoves, loadCheckpoint, onComplete, onMove, onNext, onPrev, onSolve, pro, saveCheckpoint, saveSessionToSessionStorage, trackStats]); useEffect(() => { - if (disableCheckpoints || !pro || !checkpoints) { + if (disableCheckpoints || !pro || !checkpoints || !isComplete(gameState)) { return; } - const atEnd = isComplete(gameState); - const bestCheckpoint = checkpoints[BEST_CHECKPOINT_INDEX]; - function newBest() { - if (!bestCheckpoint) { - return true; - } - - return gameState.moves.length < bestCheckpoint.length; - } - - if (atEnd && newBest()) { + if (!bestCheckpoint || gameState.moves.length < bestCheckpoint.length) { saveCheckpoint(BEST_CHECKPOINT_INDEX); } }, [checkpoints, disableCheckpoints, enrichedLevel.userMoves, gameState, isComplete, pro, saveCheckpoint]); diff --git a/lib/withAuth.ts b/lib/withAuth.ts index d302d16d3..ba8a8f1d2 100644 --- a/lib/withAuth.ts +++ b/lib/withAuth.ts @@ -107,7 +107,7 @@ export default function withAuth( // eslint-disable-next-line @typescript-eslint/no-explicit-any res.json = (data: any) => { - if (data && data.error) { + if (data && data.error && process.env.NODE_ENV !== 'test') { if (!isLocal()) { newrelic?.addCustomAttribute && newrelic.addCustomAttribute('jsonError', data.error); } else { diff --git a/pages/api/level-of-day/index.ts b/pages/api/level-of-day/index.ts index 4dd5ae3ca..cc3e0937f 100644 --- a/pages/api/level-of-day/index.ts +++ b/pages/api/level-of-day/index.ts @@ -7,7 +7,7 @@ import KeyValue from '@root/models/db/keyValue'; import { Types } from 'mongoose'; import { NextApiResponse } from 'next'; import apiWrapper, { NextApiRequestWrapper } from '../../../helpers/apiWrapper'; -import { enrichLevels, getEnrichLevelsPipelineSteps } from '../../../helpers/enrich'; +import { getEnrichLevelsPipelineSteps } from '../../../helpers/enrich'; import { TimerUtil } from '../../../helpers/getTs'; import { logger } from '../../../helpers/logger'; import dbConnect from '../../../lib/dbConnect'; @@ -24,80 +24,26 @@ export function getLevelOfDayKVKey() { return KV_LEVEL_OF_DAY_KEY_PREFIX + new Date(TimerUtil.getTs() * 1000).toISOString().slice(0, 10); } -export async function getLevelOfDay(gameId: GameId, reqUser?: User | null) { - await dbConnect(); - const key = getLevelOfDayKVKey(); - const levelKV = await KeyValueModel.findOne({ key: key, gameId: gameId }).lean(); - - if (levelKV) { - const levelAgg = await LevelModel.aggregate([ - { - $match: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _id: new Types.ObjectId(levelKV.value as any), - gameId: gameId, - } - }, - { - $lookup: { - from: UserModel.collection.name, - localField: 'userId', - foreignField: '_id', - as: 'userId', - pipeline: [ - { - $project: { - ...USER_DEFAULT_PROJECTION - } - } - ] - } - }, - { - $unwind: { - path: '$userId', - preserveNullAndEmptyArrays: true - } - }, - { - $project: { - ...LEVEL_DEFAULT_PROJECTION - } - }, - ...getEnrichLevelsPipelineSteps(reqUser, '_id', '') - ]); - - if (levelAgg && levelAgg.length > 0) { - cleanUser(levelAgg[0].userId); - - return levelAgg[0] as EnrichedLevel; - } else { - logger.error(`Level of the day ${levelKV.value} not found. Could it have been deleted?`); - - return null; - } - } - +async function getNewLevelOfDay(key: string, gameId: GameId) { const game = getGameFromId(gameId); const difficultyEstimate = game.type === GameType.COMPLETE_AND_SHORTEST ? 'calc_difficulty_completion_estimate' : 'calc_difficulty_estimate'; - const previouslySelected = await KeyValueModel.findOne({ key: KV_LEVEL_OF_DAY_LIST }).lean(); - // generate a new level based on criteria... + const previouslySelected = await KeyValueModel.findOne({ key: KV_LEVEL_OF_DAY_LIST, gameId: gameId }).lean(); + const MIN_STEPS = 12; const MAX_STEPS = 100; const MIN_REVIEWS = 3; const MIN_LAPLACE = 0.66; + const levels = await LevelModel.find({ isDeleted: { $ne: true }, isDraft: false, gameId: gameId, leastMoves: { - // least moves between 10 and 100 $gte: MIN_STEPS, $lte: MAX_STEPS, }, [difficultyEstimate]: { $gte: 0, $exists: true }, calc_reviews_count: { - // at least 3 reviews $gte: MIN_REVIEWS, }, calc_reviews_score_laplace: { @@ -106,16 +52,13 @@ export async function getLevelOfDay(gameId: GameId, reqUser?: User | null) { _id: { $nin: previouslySelected?.value || [], }, - }, '_id gameId name slug width height data leastMoves calc_difficulty_estimate calc_difficulty_completion_estimate', { - // sort by calculated difficulty estimate and then by id + }, '_id calc_difficulty_estimate calc_difficulty_completion_estimate', { sort: { [difficultyEstimate]: 1, _id: 1, }, }).lean(); - let genLevel = levels[0]; - const todaysDayOfWeek = new Date(TimerUtil.getTs() * 1000).getUTCDay(); const dayOfWeekDifficultyMap = [ 40, // sunday @@ -126,56 +69,61 @@ export async function getLevelOfDay(gameId: GameId, reqUser?: User | null) { 500, // friday 600, // saturday ]; + let newLevelId: Types.ObjectId | null = null; for (let i = 0; i < levels.length; i++) { const level = levels[i]; - if (level.calc_difficulty_estimate > dayOfWeekDifficultyMap[todaysDayOfWeek]) { - genLevel = levels[i]; + if (level[difficultyEstimate] > dayOfWeekDifficultyMap[todaysDayOfWeek]) { + newLevelId = level._id; break; } } - if (!genLevel) { - logger.warn('Could not generate a new level of the day as there are no candidates left to choose from'); - logger.warn('Going to choose the last level published as the level of the day'); + if (!newLevelId) { + logger.error(`Could not generate a new level of the day for ${gameId} as there are no candidates left to choose from`); - genLevel = await LevelModel.findOne({ + // choose the last level published as the level of the day as a backup + const latestLevel = await LevelModel.findOne({ isDeleted: { $ne: true }, isDraft: false, gameId: gameId, - }, '_id gameId name userId slug width height data leastMoves calc_difficulty_estimate calc_difficulty_completion_estimate', { - // sort by calculated difficulty estimate and then by id + _id: { + $nin: previouslySelected?.value || [], + }, + }, '_id', { sort: { _id: -1, }, - }).lean() as Level; + }).lean(); - if (!genLevel) { - logger.error('Could not even find a level to choose from for ' + gameId + '. This is a serious error'); + if (!latestLevel) { + logger.error(`Could not find any level of the day for ${gameId}`); return null; } + + newLevelId = latestLevel._id; } - // Create a new mongodb transaction and update levels-of-the-day value and also add another key value for this level const session = await KeyValueModel.startSession(); try { await session.withTransaction(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (previouslySelected?.value as any)?.push(genLevel._id); - await KeyValueModel.updateOne({ key: 'level-of-day-list', gameId: gameId }, { + (previouslySelected?.value as any)?.push(newLevelId); + + await KeyValueModel.updateOne({ key: KV_LEVEL_OF_DAY_LIST, gameId: gameId }, { $set: { - gameId: genLevel.gameId, - value: previouslySelected?.value || [genLevel._id], + gameId: gameId, + value: previouslySelected?.value || [newLevelId], } }, { session: session, upsert: true }); await KeyValueModel.updateOne({ key: key, gameId: gameId }, { $set: { - gameId: genLevel.gameId, - value: new Types.ObjectId(genLevel._id), + gameId: gameId, + value: newLevelId, } }, { session: session, upsert: true }); }); session.endSession(); @@ -186,11 +134,73 @@ export async function getLevelOfDay(gameId: GameId, reqUser?: User | null) { return null; } - const enriched = await enrichLevels([genLevel], reqUser || null); + return newLevelId; +} + +export async function getLevelOfDay(gameId: GameId, reqUser?: User | null) { + await dbConnect(); + const key = getLevelOfDayKVKey(); + const levelKV = await KeyValueModel.findOne({ key: key, gameId: gameId }).lean(); + let levelId: Types.ObjectId | null = null; + + if (!levelKV) { + levelId = await getNewLevelOfDay(key, gameId); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + levelId = Types.ObjectId.isValid(levelKV.value as any as string) ? new Types.ObjectId(levelKV.value as any as string) : null; + } + + if (!levelId) { + logger.error(`Level of the day ${levelId} not found. Could it have been deleted?`); + + return null; + } + + const levelAgg = await LevelModel.aggregate([ + { + $match: { + _id: levelId, + gameId: gameId, + } + }, + { + $lookup: { + from: UserModel.collection.name, + localField: 'userId', + foreignField: '_id', + as: 'userId', + pipeline: [ + { + $project: { + ...USER_DEFAULT_PROJECTION + } + } + ] + } + }, + { + $unwind: { + path: '$userId', + preserveNullAndEmptyArrays: true + } + }, + { + $project: { + ...LEVEL_DEFAULT_PROJECTION + } + }, + ...getEnrichLevelsPipelineSteps(reqUser, '_id', '') + ]); + + if (!levelAgg || levelAgg.length === 0) { + logger.error(`Level of the day ${levelId} not found`); + + return null; + } - cleanUser(enriched[0].userId); + cleanUser(levelAgg[0].userId); - return enriched[0]; + return levelAgg[0]; } export default apiWrapper({ diff --git a/tests/pages/api/level-of-the-day/level-of-the-day.test.ts b/tests/pages/api/level-of-the-day/level-of-the-day.test.ts index 62deae683..1a074fbb1 100644 --- a/tests/pages/api/level-of-the-day/level-of-the-day.test.ts +++ b/tests/pages/api/level-of-the-day/level-of-the-day.test.ts @@ -33,15 +33,14 @@ const DefaultReq = { }, }; -const MOCK_DATE = new Date('2021-01-11T03:00:00.000Z'); // test a date on a sunday to handle more test cases +const MOCK_DATE = new Date('2021-01-10T03:00:00.000Z'); // test a date on a sunday to handle more test cases describe('GET /api/level-of-day', () => { test('should return 200', async () => { - // Artifically increase calc_playattempts_duration_sum to make it more likely to be selected MockDate.set(MOCK_DATE); const updated = await LevelModel.updateOne({ - _id: TestId.LEVEL_3, + _id: TestId.LEVEL_2, }, { $set: { isDraft: false, @@ -54,7 +53,7 @@ describe('GET /api/level-of-day', () => { }, }); const updated2 = await LevelModel.updateOne({ - _id: TestId.LEVEL_2, + _id: TestId.LEVEL_3, }, { $set: { isDraft: false, @@ -90,14 +89,14 @@ describe('GET /api/level-of-day', () => { const curLevelOfDayKey = getLevelOfDayKVKey(); - expect(curLevelOfDayKey).toBe('level-of-day-2021-01-11'); + expect(curLevelOfDayKey).toBe('level-of-day-2021-01-10'); const lvlOfDay = await KeyValueModel.findOne({ key: curLevelOfDayKey, }); expect(lvlOfDay).toBeDefined(); expect(lvlOfDay.gameId).toBe(DEFAULT_GAME_ID); - expect(lvlOfDay?.value).toStrictEqual(new Types.ObjectId(TestId.LEVEL_3)); + expect(lvlOfDay.value).toStrictEqual(new Types.ObjectId(TestId.LEVEL_3)); }, }); }); @@ -120,7 +119,7 @@ describe('GET /api/level-of-day', () => { expect(response._id).toBe(TestId.LEVEL_3); const curLevelOfDayKey = getLevelOfDayKVKey(); - expect(curLevelOfDayKey).toBe('level-of-day-2021-01-11'); + expect(curLevelOfDayKey).toBe('level-of-day-2021-01-10'); const lvlOfDay = await KeyValueModel.findOne({ key: curLevelOfDayKey, }); @@ -155,7 +154,7 @@ describe('GET /api/level-of-day', () => { expect(asEnriched.userMoves).toBe(80); const curLevelOfDayKey = getLevelOfDayKVKey(); - expect(curLevelOfDayKey).toBe('level-of-day-2021-01-11'); + expect(curLevelOfDayKey).toBe('level-of-day-2021-01-10'); const lvlOfDay = await KeyValueModel.findOne({ key: curLevelOfDayKey, }); @@ -194,7 +193,7 @@ describe('GET /api/level-of-day', () => { // const curLevelOfDayKey = getLevelOfDayKVKey(); - expect(curLevelOfDayKey).toBe('level-of-day-2021-01-12'); + expect(curLevelOfDayKey).toBe('level-of-day-2021-01-11'); const lvlOfDay = await KeyValueModel.findOne({ key: curLevelOfDayKey, }); @@ -209,6 +208,7 @@ describe('GET /api/level-of-day', () => { }); }); test('changing to the next day should return the a different level', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); MockDate.set(MOCK_DATE); const day2 = Date.now() + (1000 * 60 * 60 * 24 ); // Note... Date.now() here is being mocked each time too! @@ -230,22 +230,21 @@ describe('GET /api/level-of-day', () => { expect(response.error).toBeUndefined(); expect(res.status).toBe(200); - expect(response._id).toBe(TestId.LEVEL_2); + expect(response._id).toBe(TestId.LEVEL_4); - // const curLevelOfDayKey = getLevelOfDayKVKey(); - expect(curLevelOfDayKey).toBe('level-of-day-2021-01-12'); + expect(curLevelOfDayKey).toBe('level-of-day-2021-01-11'); const lvlOfDay = await KeyValueModel.findOne({ key: curLevelOfDayKey, }); expect(lvlOfDay).toBeDefined(); - expect(lvlOfDay?.value).toStrictEqual(new Types.ObjectId(TestId.LEVEL_2)); + expect(lvlOfDay?.value).toStrictEqual(new Types.ObjectId(TestId.LEVEL_4)); const list = await KeyValueModel.find({ key: KV_LEVEL_OF_DAY_LIST }); expect(list.length).toBe(1); - expect(list[0].value).toEqual([new Types.ObjectId(TestId.LEVEL_3), new Types.ObjectId(TestId.LEVEL_2)]); + expect(list[0].value).toEqual([new Types.ObjectId(TestId.LEVEL_3), new Types.ObjectId(TestId.LEVEL_4)]); }, }); }); @@ -267,9 +266,9 @@ describe('GET /api/level-of-day', () => { const res = await fetch(); const response = await res.json(); - expect(response.error).toBeUndefined(); // we actually won't return error... We'll just select the latest level + expect(response.error).toBeUndefined(); expect(res.status).toBe(200); - expect(response._id).toBe(TestId.LEVEL_4); + expect(response._id).toBe(TestId.LEVEL_2); }, }); }); @@ -283,7 +282,7 @@ describe('GET /api/level-of-day', () => { MockDate.set(day2); await LevelModel.deleteOne({ - _id: TestId.LEVEL_2, + _id: TestId.LEVEL_4, }); await testApiHandler({ pagesHandler: async (_, res) => {