src/app/models/scenario.ts
Classe générique pour messages commun à toutes les activités.
| parentLevel |
parentLevel: |
| phrase |
phrase: |
| priority |
priority: |
| tags |
tags: |
import { PlayTTSService } from "src/app/services/play-tts.service";
import { ChangeDetectorRef } from "@angular/core";
import { CabriDataService } from "../services/cabri-data.service";
import { GlobalService } from "../services/global.service";
import { ScenarioPhrase } from "./scenario-phrase";
import { Tutorial } from "./tutorial";
import { environment } from "src/environments/environment";
import { AccountService } from "../services/account.service";
import { AppUtils } from "../app-utils";
import { ActivityAnswer, AnswerAwardTrans, AnswerNeedsHelp, AnswerSpeed, AnswerStatus } from "./activity-answer";
import { ReplaySubject } from "rxjs";
/**
* Classe générique pour messages commun à toutes les activités.
*/
export class Feedback {
phrase: string;
tags: object;
priority?: number;
parentLevel: number;
}
export class Scenario {
public page: any;
protected cabriService: CabriDataService;
protected globalService: GlobalService;
protected cd: ChangeDetectorRef;
// callback tuto
public tutoCallbackSave: Tutorial;
public codeReceived: boolean;
public mathiaMessage: string;
public mscPhraseArrayIndex = -1;
public lastPhrase: string;
public skipSequence = false;
public lockCallback = false;
appName: string;
environment: any;
accountService: AccountService;
resolveTimeoutCancelable: (value: unknown) => void;
public answer: ActivityAnswer;
feedbacks: Array<Feedback>;
lastFeedbackPhrasesPlayed: Array<string>;
currentRunMathiaSpeechPromise: Array<{ promise: Promise<void>; scenario: any }> = [];
constructor(
accountService: AccountService,
globalService: GlobalService,
page: any,
cd: ChangeDetectorRef,
public ttsService: PlayTTSService
) {
this.environment = environment;
this.accountService = accountService;
this.globalService = globalService;
this.page = page;
this.cd = cd;
this.lastFeedbackPhrasesPlayed = new Array();
}
runBabylonPageScript(): Promise<void> {
return new Promise(resolve => {
resolve();
});
}
/**
* Custom async setTimeOut for waiting end of exe - skipable by skipSequence()
* @ignore
*/
async timeOut(ms, callback: any = null) {
return new Promise(resolve => {
if (!this.skipSequence) {
setTimeout(() => {
if (callback) {
callback();
}
resolve(true);
}, ms);
} else {
setTimeout(() => {
if (callback) {
callback();
}
resolve(true);
}, 100);
}
});
}
/**
* Custom async setTimeOut for waiting end of exe - cancelable from outside triggering skipScenarioTimeout()
* @ignore
*/
async timeOutCancelable(ms, callback: any = null) {
return new Promise(resolve => {
this.resolveTimeoutCancelable = resolve;
if (!this.skipSequence) {
setTimeout(() => {
if (callback) {
callback();
}
this.resolveTimeoutCancelable = null;
resolve(true);
}, ms);
} else {
setTimeout(() => {
if (callback) {
callback();
}
this.resolveTimeoutCancelable = null;
resolve(true);
}, 100);
}
});
}
/**
* cancel timeOutCancelable()
* @ignore
*/
skipScenarioTimeout() {
if (this.resolveTimeoutCancelable) {
this.resolveTimeoutCancelable(true);
this.resolveTimeoutCancelable = null;
}
}
/**
* Custom async setTimeOut for waiting end of exe - not skipable
* @ignore
*/
async timeOutNoSkip(ms, callback: any = null) {
return new Promise(resolve => {
setTimeout(() => {
if (callback) {
callback();
}
resolve(true);
}, ms);
});
}
detectChanges() {
if (this.cd) {
this.cd.detectChanges();
}
}
/**
* @ignore
*/
nextPlayerInit(): Promise<void> {
return new Promise(async (resolve, reject) => {
resolve();
});
}
/**
* Main method used to run TTS scenarios with callbacks
* @param content scenario sequence array
* @param globalBubble not in use here
* @param fromToolbar obsolete?
*/
runMathiaSpeech(content: any[], globalBubble?: boolean, fromToolbar = false): Promise<void> {
const p = new Promise<void>((resolve, reject) => {
console.log("runMathiaSpeech entered");
this.globalService.mathiaSpeechRunning = true;
let lastElement;
const contentClone = content.slice(0);
contentClone.reverse().forEach(element => {
const current = new Tutorial();
current.phrase = element.phrase;
if (element.phraseTTS) {
current.phraseTTS = element.phraseTTS;
}
if (element.onlySpeech) {
current.onlySpeech = element.onlySpeech;
}
if (element.randomSpeechMode) {
current.randomSpeechMode = element.randomSpeechMode;
}
if (element.textBubble) {
current.textBubble = element.textBubble;
}
if (element.buttons) {
current.buttons = element.buttons;
}
if (element.disableSkip) {
current.disableSkip = element.disableSkip;
}
if (element.buttonText) {
current.buttonText = element.buttonText;
}
if (element.shootingStar) {
current.shootingStar = element.shootingStar;
}
if (element.normalStar) {
current.normalStar = element.normalStar;
}
if (element.moon) {
current.moon = element.moon;
}
if (element.keepBubble) {
current.keepBubble = element.keepBubble;
}
//array read in reverse so the first tutorial is the end and need to resolve
if (!lastElement) {
current.callback = async () => {
if (element.callback) {
if (typeof element.callback === "function" && !this.lockCallback) {
await element.callback();
}
}
this.globalService.mathiaSpeechRunning = false;
this.skipSequence = false;
resolve();
};
} else {
current.callback = async () => {
if (element.callback) {
if (typeof element.callback === "function" && !this.lockCallback) {
await element.callback();
}
} else {
resolve();
}
};
}
current.next = lastElement;
lastElement = current;
});
if (this.lockCallback) {
console.error("rejected by lockCallback");
reject();
} else {
this.runMathiaSpeechCore(lastElement, fromToolbar);
}
});
this.currentRunMathiaSpeechPromise.push({ promise: p, scenario: content });
return p;
}
/**
* @ignore
*/
runMathiaSpeechCore(tutorial: Tutorial, fromToolbar = false, eventProtected = true): void {
if (tutorial) {
if (Array.isArray(tutorial.phrase) && tutorial.phrase.length > 1) {
if (tutorial.randomSpeechMode) {
// select random index from tutorial.phrase array
const randomIndex = Math.floor(Math.random() * tutorial.phrase.length);
// console.log("random index = " + randomIndex);oe
tutorial.phrase = tutorial.phrase[randomIndex];
if (tutorial.phraseTTS) {
tutorial.phraseTTS = tutorial.phraseTTS[randomIndex];
}
} else {
// increment index of tutorial.phrase array
this.mscPhraseArrayIndex++;
if (this.mscPhraseArrayIndex >= tutorial.phrase.length) {
this.mscPhraseArrayIndex = 0;
}
tutorial.phrase = tutorial.phrase[this.mscPhraseArrayIndex];
if (tutorial.phraseTTS) {
tutorial.phraseTTS = tutorial.phraseTTS[this.mscPhraseArrayIndex];
}
}
} else if (Array.isArray(tutorial.phrase) && tutorial.phrase.length === 1) {
tutorial.phrase = tutorial.phrase[0];
if (tutorial.phraseTTS) {
tutorial.phraseTTS = tutorial.phraseTTS[0];
}
}
this.page?.eventMessage?.next(tutorial.phrase);
this.lastPhrase = tutorial.phrase;
if (!tutorial.onlySpeech && this.page?.displayTTSBubble) {
this.page.displayTTSBubble = true;
}
if (!this.skipSequence && this.cd) {
this.detectChanges();
}
if (!this.skipSequence && !this.lockCallback) {
this.play(
tutorial,
async () => {
tutorial.callback().then(() => {
this.page.mathiaSpeechButtonText = null;
if (!this.page.menuOpen && !this.globalService.appIdle) {
this.detectChanges();
return this.runMathiaSpeechCore(tutorial.next, fromToolbar);
} else {
this.detectChanges();
this.tutoCallbackSave = tutorial.next;
}
});
},
fromToolbar,
eventProtected
);
} else if (this.skipSequence === true && !this.lockCallback) {
tutorial.callback().then(async () => {
this.page.mathiaSpeechButtonText = null;
if (!this.page.menuOpen && !this.globalService.appIdle) {
return this.runMathiaSpeechCore(tutorial.next, fromToolbar);
} else {
this.detectChanges();
this.tutoCallbackSave = tutorial.next;
}
});
}
}
}
play(tutorial: Tutorial, callback, fromToolbar, eventProtected) {
if (eventProtected) {
this.ttsService.playTTSEventProtected(tutorial, callback, this.ttsService.dicteeMode, fromToolbar);
} else {
this.ttsService.playTTS(tutorial.phraseTTS).then(() => {
if (callback) {
callback();
}
});
}
}
/**
* method to transform a given text into a scenario sequence by splitting it depending of punctuation
* @param text text to transform
* @param fromToolbar obsolete?
*/
async readText(text, fromToolbar = false, globalBubble = false, keepBubbleOn = false) {
const sequences = this.splitTextPunctationToScenarioPhraseArray(text, keepBubbleOn);
await this.runMathiaSpeech(sequences, globalBubble, fromToolbar);
}
/**
* skip the scenario sequence on skip button click or manual call from anywhere
* @param data
*/
async skipMathiaSpeechSequence(data: any = true) {
if (this.ttsService.protectedTTSisPlaying) {
this.skipSequence = data;
await this.ttsService.killSpeech();
if (this.ttsService.currentTTSPCallback) {
await this.ttsService.endOfTTSPlayprotected();
// console.error("ok endOfTTSPlayprotected")
}
// console.error("before all promises", this.currentRunMathiaSpeechPromise);
const promises = this.currentRunMathiaSpeechPromise.map(m => m.promise);
this.currentRunMathiaSpeechPromise = [];
//await Promise.all(promises);
let security = true;
if (promises.length > 0) {
await Promise.race([
Promise.all(promises),
AppUtils.timeOut(5000, () => {
if (security) {
console.error("timeout promises skipMathiaSpeechSequence");
}
})
]);
}
security = false;
if (this.ttsService.currentTTSPCallback) {
await this.ttsService.endOfTTSPlayprotected();
// console.error("ok endOfTTSPlayprotected")
}
// console.error("ok all promises")
}
}
/**
* splits given text depending on punctuation and returns a SpeechSequence array
*/
splitTextPunctationToScenarioPhraseArray(text: string, keepBubbleOn = false) {
const speechSequence = [];
text = text.replace("...", "…");
if (text.match(/(\.|\?|\!)/g)) {
// Split feedback with punctuation:
const feedbackSequence = text.split(/(\.|\?|\!)/g);
// move punctuation index content to the end of the previous index:
feedbackSequence.forEach((phrase, index) => {
if (phrase.match(/(\.|\?|\!)/g)) {
feedbackSequence[index - 1] = feedbackSequence[index - 1] + phrase;
feedbackSequence.splice(index, 1);
}
});
feedbackSequence.forEach((phrase, index) => {
if (phrase.length === 0 || phrase.trim().length === 0) {
// remove empty or blank indexes:
feedbackSequence.splice(index, 1);
} else if (/^\s/.test(phrase)) {
// remove empty space at the beginning of phrases:
phrase = phrase.substring(1);
feedbackSequence.splice(index, 1, phrase);
}
});
for (const phrase of feedbackSequence) {
if (keepBubbleOn) {
speechSequence.push(new ScenarioPhrase(phrase).keepBubbleOn());
} else {
speechSequence.push(new ScenarioPhrase(phrase));
}
}
} else {
if (keepBubbleOn) {
speechSequence.push(new ScenarioPhrase(text).keepBubbleOn());
} else {
speechSequence.push(new ScenarioPhrase(text));
}
}
return speechSequence;
}
// runMathiaSpeechCore(tutorial: Tutorial): void {
// throw new Error("not implemented in mother class");
// }
/**
* read a text through runMathiaSpeech() to be handled as it (pausable by events, display through tts bubble)
* @param consigne text to read / display
*/
async readCustomText(consigne: string, globalBubble = false) {
const speechSequence: Array<any> = [new ScenarioPhrase([consigne])];
await this.runMathiaSpeech(speechSequence, globalBubble);
}
/**
* convert feedback from fiche raw text to a speechSequence & run it in runMathiaSpeech()
* splits punctuation / removeLineBreaksFromString
*/
async readHTML(html: string, globalBubble = false) {
const speechSequence = [];
const parser = new DOMParser();
html = AppUtils.removeLineBreaksFromString(parser.parseFromString(html, "text/html").body.textContent);
if (html.match(/(\.|\?|\!)/g)) {
// Split feedback with punctuation:
const sequence = html.split(/(\.|\?|\!)/g);
// move punctuation index content to the end of the previous index:
sequence.forEach((phrase, index) => {
if (phrase.match(/(\.|\?|\!)/g)) {
sequence[index - 1] = sequence[index - 1] + phrase;
sequence.splice(index, 1);
}
});
sequence.forEach((phrase, index) => {
if (phrase.length === 0 || phrase.trim().length === 0) {
// remove empty or blank indexes:
sequence.splice(index, 1);
} else if (/^\s/.test(phrase)) {
// remove empty space at the beginning of phrases:
phrase = phrase.substring(1);
sequence.splice(index, 1, phrase);
}
});
for (const [index, iterator] of sequence.entries()) {
speechSequence.push(new ScenarioPhrase(iterator).keepBubbleOn());
}
} else {
speechSequence.push(new ScenarioPhrase(html).keepBubbleOn());
}
await this.runMathiaSpeech(speechSequence, globalBubble);
}
// Dynamic Feedbacks
/**
* get a feedback phrase depending of parameters and answer's context
*/
getContextualFeedbackPhrase(
status: AnswerStatus,
responseTimeInSeconds: number,
award?: string,
slow = 20,
fast = 7,
minAnswersForAverage = 5,
minAnswersForExtreme = 5,
needsHelp = AnswerNeedsHelp.NO
) {
this.answer = new ActivityAnswer(
status,
needsHelp,
responseTimeInSeconds,
slow,
fast,
this.page.currentUser,
minAnswersForAverage,
minAnswersForExtreme,
this.cabriService.currentExercice
);
// AppUtils.debug("this.answer = ", this.answer);
const contextTags = this.answer.getContext();
const feedbacks = this.getFilteredFeedbacksByContext(contextTags);
const sortedByPriorityFeedbacks = this.filterFeedbacksByPriority(feedbacks);
// AppUtils.debug("sortedByPriorityFeedbacks = ", sortedByPriorityFeedbacks);
const selectedFeedback = this.selectFeedbackAvoidingRepetition(sortedByPriorityFeedbacks);
let phrase = selectedFeedback.phrase;
if (award) {
const awardName = this.answer.setAnswerAward(award);
const trad = AnswerAwardTrans[awardName];
phrase = phrase.replace(/#award/, trad);
}
phrase = phrase.replace(/#playerName/, this.page.currentUser.name);
return phrase;
}
// TODO => special method for not answer related phrases using same mechanism ?
getSpecialPhrase() {
// TODO
}
/**
* filter feedbacks by given context tags
* exclusive is tag set to false
*/
getFilteredFeedbacksByContext(tags: object): Feedback[] {
const filteredFeedbacks = this.feedbacks.filter(feedback => {
return Object.entries(tags).every(tag => {
const key = tag[0];
const value = tag[1];
// console.error("getFeedbacks() loop");
if (typeof feedback.tags[key] !== "undefined") {
if (feedback.tags[key] === value) {
return true;
} else {
return false;
}
} else {
return true;
}
});
});
return filteredFeedbacks;
}
/**
* sorts feedbacks by priority + parent level:
* shuffle disabled to test phrase indexes sequencing
*/
filterFeedbacksByPriority(feedbacks: Feedback[]): Feedback[] {
// AppUtils.shuffleArray(feedbacks);
feedbacks.sort((a: Feedback, b: Feedback) => (a.parentLevel > b.parentLevel ? -1 : 1));
// console.error(feedbacks);
feedbacks.sort((a, b) => (a.priority > b.priority ? -1 : 1));
// console.error("feedbacks sorted = ", feedbacks);
return feedbacks;
}
/**
* checks if feedback has been played before (history length = 5) else pick another
*/
selectFeedbackAvoidingRepetition(feedbacks: Feedback[]): Feedback {
return feedbacks.find(feedback => {
const tagsKey = JSON.stringify(feedback.tags);
// console.error("tagsKey = ", tagsKey);
const readFeedbacks = this.lastFeedbackPhrasesPlayed[tagsKey];
if (readFeedbacks && readFeedbacks.indexOf(feedback.phrase) > -1) {
// already read
return false;
} else {
// initialize array
if (!this.lastFeedbackPhrasesPlayed[tagsKey]) {
this.lastFeedbackPhrasesPlayed[tagsKey] = [];
}
// add new feedback to read phrase
this.lastFeedbackPhrasesPlayed[tagsKey].unshift(feedback.phrase);
// max 5 feedbacks in history
let maxPhrases = 5;
if (tagsKey === `{"status":"${AnswerStatus.VALID1ST}","speed":"${AnswerSpeed.FAST}"}`) {
maxPhrases = 12;
}
if (this.lastFeedbackPhrasesPlayed[tagsKey].length > Math.min(maxPhrases, feedbacks.length - 1)) {
// remove oldest read feedback
this.lastFeedbackPhrasesPlayed[tagsKey].pop();
}
// AppUtils.debug("last Feedbacks = ", this.lastFeedbackPhrasesPlayed);
return true;
}
});
}
/**
* Adds feedback array with params to this.feedbacks global Array
*/
addFeedbacks(tags: object, priority: number, phrases: Array<string>, parentLevel = 1) {
phrases.forEach(phrase => {
// console.error("addFeedbacks()");
this.feedbacks.push({ phrase, tags, priority, parentLevel });
});
}
/**
* overloaded upstairs
*/
populateScenario() {}
}