@coorpacademy/progression-engine
Version:
330 lines (326 loc) • 11.6 kB
JavaScript
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