UNPKG

@coorpacademy/progression-engine

Version:

330 lines (326 loc) 11.6 kB
import _getOr from "lodash/fp/getOr"; import _intersection from "lodash/fp/intersection"; import _findIndex from "lodash/fp/findIndex"; import _includes from "lodash/fp/includes"; import _shuffle from "lodash/fp/shuffle"; import _isEmpty from "lodash/fp/isEmpty"; import _isEqual from "lodash/fp/isEqual"; import _sortBy from "lodash/fp/sortBy"; import _filter from "lodash/fp/filter"; import _last from "lodash/fp/last"; import _slice from "lodash/fp/slice"; import _size from "lodash/fp/size"; import _head from "lodash/fp/head"; import _pipe from "lodash/fp/pipe"; import _find from "lodash/fp/find"; import _get from "lodash/fp/get"; import _map from "lodash/fp/map"; import selectRule from '../rule-engine/select-rule'; import updateVariables from '../rule-engine/apply-instructions'; import updateState from '../update-state'; const hasNoMoreLives = (config, state) => !config.livesDisabled && state.lives <= 0; const getContentRef = _get('content.ref'); const hasRemainingLifeRequests = state => state.remainingLifeRequests > 0; const stepIsAlreadyExtraLife = state => getContentRef(state) === 'extraLife'; const hasRulesToApply = chapterContent => { return !!(chapterContent && Array.isArray(chapterContent.rules) && !_isEmpty(chapterContent.rules)); }; export const nextSlidePool = (config, availableContent, state) => { if (state.nextContent.type === 'chapter') { const content = _find({ ref: state.nextContent.ref }, availableContent) || null; return { currentChapterContent: content, nextChapterContent: content, temporaryNextContent: { type: 'slide', ref: '' } }; } const lastSlideRef = _pipe(_get('slides'), _last)(state); const _currentIndex = _findIndex(({ slides }) => !!_find({ _id: lastSlideRef }, slides), availableContent); const currentIndex = _currentIndex !== -1 ? _currentIndex : 0; const currentChapterPool = availableContent[currentIndex] || null; const currentChapterSlideIds = _pipe(_get('slides'), _map('_id'))(currentChapterPool || []); const slidesAnsweredForThisChapter = _intersection(state.slides, currentChapterSlideIds); const isChapterCompleted = _size(slidesAnsweredForThisChapter) >= Math.min(config.slidesToComplete, _size(currentChapterSlideIds)); const hasRules = hasRulesToApply(currentChapterPool); const shouldChangeChapter = !hasRules && isChapterCompleted; if (shouldChangeChapter) { const nextChapterContent = _pipe(_slice(currentIndex + 1, _size(availableContent)), _filter(content => !_isEmpty(content.slides)), _head)(availableContent); return { currentChapterContent: currentChapterPool, nextChapterContent, temporaryNextContent: { type: 'slide', ref: '' } }; } return { currentChapterContent: currentChapterPool, nextChapterContent: currentChapterPool, temporaryNextContent: state.nextContent }; }; const _getChapterContent = (config, availableContent, state) => { const firstContent = _pipe(_filter(content => !_isEmpty(content.slides)), _head)(availableContent); if (!state) { return { currentChapterContent: firstContent, nextChapterContent: firstContent, temporaryNextContent: { type: 'slide', ref: '' } }; } return nextSlidePool(config, availableContent, state); }; const getChapterContent = (config, availableContent, state) => { const res = _getChapterContent(config, availableContent, state); if (!res.currentChapterContent) { return null; } return res; }; const sortByPosition = _sortBy(slide => typeof slide.position === 'number' ? -slide.position : 0); const pickNextSlide = _pipe(_shuffle, sortByPosition, _head); const isTargetingIsCorrect = condition => condition.target.scope === 'slide' && condition.target.field === 'isCorrect'; const getIsCorrect = (isCorrect, chapterRule) => { if (chapterRule.conditions.some(isTargetingIsCorrect)) return isCorrect; return null; }; const computeNextSlide = (config, chapterContent, state) => { const remainingSlides = _filter(_pipe(_get('_id'), slideId => !state || !_includes(slideId, state.slides)), chapterContent.slides); return { type: 'slide', ref: pickNextSlide(remainingSlides)._id }; }; export const prepareStateToSwitchChapters = (chapterRule, state) => { if (!state) { return state; } return updateVariables(chapterRule.instructions)({ ...state, nextContent: chapterRule.destination }); }; export const computeNextStepForNewChapter = (config, state, chapterRule, isCorrect, availableContent) => { // eslint-disable-next-line no-use-before-define const nextStep = computeNextStep(config, prepareStateToSwitchChapters(chapterRule, state), availableContent, null); if (!nextStep) { return null; } return { nextContent: nextStep.nextContent, instructions: chapterRule.instructions.concat(nextStep.instructions || []), isCorrect: getIsCorrect(isCorrect, chapterRule) }; }; const extendPartialAction = (action, state) => { if (!action) { return null; } switch (action.type) { case 'answer': { const nextContent = action.payload.content || (state ? state.nextContent : { ref: '', type: 'node' }); return { type: 'answer', payload: { answer: action.payload.answer, godMode: action.payload.godMode, isCorrect: action.payload.isCorrect, content: nextContent, nextContent, instructions: null } }; } case 'extraLifeAccepted': { const nextContent = state ? state.nextContent : { ref: '', type: 'node' }; return { type: 'extraLifeAccepted', payload: { content: nextContent, nextContent, instructions: null } }; } default: return null; } }; export const computeNextStep = (config, _state, availableContent, partialAction) => { const action = extendPartialAction(partialAction, _state); const isCorrect = !!action && action.type === 'answer' && !!action.payload.isCorrect; const answer = !!action && action.type === 'answer' && action.payload.answer || []; const state = !_state || !action ? _state : updateState(config, _state, [action]); const chapterContent = getChapterContent(config, availableContent, state); if (!chapterContent) { return null; } const { currentChapterContent, nextChapterContent, temporaryNextContent } = chapterContent; const hasRules = hasRulesToApply(nextChapterContent); if (!hasRules) { if (state && hasNoMoreLives(config, state)) { return { nextContent: !stepIsAlreadyExtraLife(state) && hasRemainingLifeRequests(state) ? { type: 'node', ref: 'extraLife' } : { type: 'failure', ref: 'failExitNode' }, instructions: null, isCorrect }; } else if (!nextChapterContent) { // If user has answered all questions, return success endpoint return { nextContent: { type: 'success', ref: 'successExitNode' }, instructions: null, isCorrect }; } } if (hasRules) { const allAnswers = !!state && state.allAnswers || []; // $FlowFixMe nextChapterContent.rules = array not emtpy -> checked by hasRulesToApply const chapterRule = selectRule(nextChapterContent.rules, { ...state, nextContent: temporaryNextContent, allAnswers: [...allAnswers, { slideRef: temporaryNextContent.ref, answer, isCorrect }] }); if (!chapterRule) { return null; } if (chapterRule.destination.type === 'chapter') { return computeNextStepForNewChapter(config, state, chapterRule, isCorrect, availableContent); } return { nextContent: chapterRule.destination, instructions: chapterRule.instructions, isCorrect: _isEqual(currentChapterContent, nextChapterContent) ? getIsCorrect(isCorrect, chapterRule) : isCorrect }; } if (nextChapterContent && Array.isArray(nextChapterContent.slides) && nextChapterContent.slides.length > 0) { const stateWithDecrementedLives = state ? { ...state, nextContent: temporaryNextContent } : state; const nextContent = computeNextSlide(config, nextChapterContent, stateWithDecrementedLives); return { nextContent, instructions: null, isCorrect }; } return null; }; const getNextSlide = (config, state, availableContent) => { if (!state) return _get(['0', 'slides', '0'], availableContent); // We filter slides in other to exclude already answered slides. // In case of lag on recallUpdate lambda that would not update the review database // and the getSlide lambda would return as available slide, an already proposed slide const answeredSlides = _getOr([], 'slides', state); const filteredSlides = _filter(slide => !answeredSlides.includes(slide._id), availableContent[0].slides); const current = _get('step.current', state); if (current <= config.slidesToComplete) { return filteredSlides[0]; } return null; }; const getNextPendingSlide = (currentSlide, oldPendingSlides, newPendingSlide) => { if (_isEmpty(newPendingSlide)) return null; const indexPendingSlide = _findIndex(s => s === currentSlide, oldPendingSlides) + 1; const index = indexPendingSlide === oldPendingSlides.length ? 0 : indexPendingSlide; return oldPendingSlides[index]; }; export const computeNextStepForReview = (config, _state, availableContent, partialAction) => { const action = extendPartialAction(partialAction, _state); const isCorrect = !!action && action.type === 'answer' && !!action.payload.isCorrect; const state = !_state || !action ? _state : updateState(config, _state, [action]); const correctAnswers = state ? _filter(a => a.isCorrect, state.allAnswers) : []; if (correctAnswers.length === config.slidesToComplete) { return { nextContent: { type: 'success', ref: 'successExitNode' }, instructions: null, isCorrect }; } const nextSlide = getNextSlide(config, state, availableContent); if (!nextSlide) { // state is null during creation, and we can have the extreme case of creation without slide if (!state) { return null; } else { // if there is no more slides, two scenarios are possible const pendingSlides = !_state ? [] : _state.pendingSlides; const pendingSlide = getNextPendingSlide(state.nextContent.ref, pendingSlides, state.pendingSlides); // all other questions have been already right answered BUT you fail the last question if (!pendingSlide && !isCorrect) { return { nextContent: { type: 'slide', ref: state.nextContent.ref }, instructions: null, isCorrect }; } if (!pendingSlide) { // all other questions have been already right answered, so we close the progression return null; } // or there are wrong question that should be reviewed again return { nextContent: { type: 'slide', ref: pendingSlide }, instructions: null, isCorrect }; } } return { nextContent: { type: 'slide', ref: nextSlide._id }, instructions: null, isCorrect }; }; //# sourceMappingURL=compute-next-step.js.map