RGreview-game-corethe engine behind concept-first review games

Feature detail

Workflow quiz engine / session / debug

The quiz engine runs a turn through six phases; session and debug helpers let consumer shells persist and force routes deterministically.

workflow/quiz-engineworkflow/sessionworkflow/debug

Worked example

Session + debug contract

Workflow helpers keep storage keys and deterministic debug routes stable across consumer apps.

Input

{
  "identity": {
    "sessionId": "demo-session",
    "learnerId": "learner@example.edu",
    "anonymousId": null
  },
  "namespace": "stats-exam-prep"
}

Output

{
  "storageKey": "stats-exam-prep:demo-session:learner@example.edu",
  "snapshot": {
    "version": 1,
    "sessionId": "demo-session",
    "learnerId": "learner@example.edu",
    "anonymousId": null,
    "createdAt": "2026-04-23T00:15:24.336Z",
    "updatedAt": "2026-04-23T00:15:24.336Z",
    "route": "/quiz?exam=exam3",
    "currentConceptId": "factoring",
    "currentQuestionId": "q-1",
    "complete": false,
    "state": {
      "stage": "recognize",
      "seen": 1
    },
    "metadata": {}
  },
  "wfRoute": "/quiz?wf=1&route=support&concept=factoring&question=q-1&learner=learner%40example.edu"
}

Real source excerpt

Quiz engine phases and transitions

src/workflow/quiz-engine.ts
export type QuizEnginePhase =
  | 'routing'
  | 'question'
  | 'staged-answer'
  | 'support'
  | 'recovery'
  | 'complete';

export interface QuizEngineQuestion {
  id: string;
  concept: string;
  type: string;
  stageCount?: number;
  supportsRecovery?: boolean;
}

export interface QuizEngineConfig<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
  questions: readonly TQuestion[];
  defaultStageCount?: number;
  allowSupport?: boolean;
  allowRecovery?: boolean;
  routeQuestionId?: (route: string) => string | null;
  stageCountForQuestion?: (question: TQuestion) => number | undefined;
}

export interface QuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
  phase: QuizEnginePhase;
  route: string | null;
  currentConcept: string | null;
  currentQuestionId: string | null;
  currentQuestion: TQuestion | null;
  stageIndex: number;
  stageCount: number;
  stagedAnswers: string[];
  supportCount: number;
  recoveryCount: number;
  completedQuestionIds: string[];
  lastOutcome: 'idle' | 'routed' | 'answered' | 'supported' | 'recovered' | 'completed' | 'reset';
  complete: boolean;
}

export interface QuizEngineAction<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
  type:
    | 'route'
    | 'select-question'
    | 'sync-question-state'
    | 'advance-stage'
    | 'support'
    | 'recovery'
    | 'complete'
    | 'reset';
  route?: string;
  question?: TQuestion;
  questionId?: string;
  conceptId?: string;
  answer?: string;
  stageCount?: number;
  stageIndex?: number;
  completedQuestionId?: string;
  currentQuestion?: TQuestion | null;
  stageAnswers?: readonly string[];
  complete?: boolean;
  supportActive?: boolean;
  recoveryActive?: boolean;
  outcome?: QuizEngineState<TQuestion>['lastOutcome'];
}

function readStageCount(question: QuizEngineQuestion | null, fallback = 1): number {
  const stageCount = question?.stageCount ?? fallback;
  return Number.isInteger(stageCount) && stageCount > 0 ? stageCount : fallback;
}

function deriveQuizEnginePhase(options: {
  question: QuizEngineQuestion | null;
  stageIndex: number;
  stagedAnswers: readonly string[];
  supportActive?: boolean;
  recoveryActive?: boolean;
  complete?: boolean;
}): QuizEnginePhase {
  if (options.complete) return 'complete';
  if (options.recoveryActive) return 'recovery';
  if (options.supportActive) return 'support';
  if (!options.question) return 'routing';
  if (options.stageIndex > 0 || options.stagedAnswers.length > 0) return 'staged-answer';
  return 'question';
}

export function createQuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  snapshot: Partial<QuizEngineState<TQuestion>> = {}
): QuizEngineState<TQuestion> {
  return {
    phase: snapshot.phase ?? 'routing',
    route: snapshot.route ?? null,
    currentConcept: snapshot.currentConcept ?? null,
    currentQuestionId: snapshot.currentQuestionId ?? null,
    currentQuestion: snapshot.currentQuestion ?? null,
    stageIndex: snapshot.stageIndex ?? 0,
    stageCount: snapshot.stageCount ?? readStageCount(snapshot.currentQuestion ?? null, 1),
    stagedAnswers: [...(snapshot.stagedAnswers ?? [])],
    supportCount: snapshot.supportCount ?? 0,
    recoveryCount: snapshot.recoveryCount ?? 0,
    completedQuestionIds: [...(snapshot.completedQuestionIds ?? [])],
    lastOutcome: snapshot.lastOutcome ?? 'idle',
    complete: snapshot.complete ?? false,
  };
}

export function routeQuizEngine<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>,
  route: string,
  config: Pick<QuizEngineConfig<TQuestion>, 'routeQuestionId' | 'questions' | 'stageCountForQuestion'> = {
    questions: [],
  }
): QuizEngineState<TQuestion> {
  const currentQuestionId = config.routeQuestionId?.(route) ?? null;
  const currentQuestion = config.questions.find(question => question.id === currentQuestionId) ?? null;

  return {
    ...state,
    phase: 'routing',
    route,
    currentQuestionId,
    currentConcept: currentQuestion?.concept ?? null,
    currentQuestion,
    stageIndex: 0,
    stageCount: config.stageCountForQuestion?.(currentQuestion as TQuestion) ?? readStageCount(currentQuestion, 1),
    stagedAnswers: [],
    lastOutcome: 'routed',
    complete: false,
  };
}

export function selectQuizQuestion<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>,
  question: TQuestion,
  config: Pick<QuizEngineConfig<TQuestion>, 'stageCountForQuestion' | 'defaultStageCount'> = {}
): QuizEngineState<TQuestion> {
  return {
    ...state,
    phase: 'question',
    currentQuestionId: question.id,
    currentConcept: question.concept,
    currentQuestion: question,
    stageIndex: 0,
    stageCount:
      config.stageCountForQuestion?.(question)
      ?? question.stageCount
      ?? config.defaultStageCount
      ?? 1,
    stagedAnswers: [],
    lastOutcome: 'routed',
    complete: false,
  };
}

export function advanceQuizStage<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>,
  answer: string,
  options: {
    stageIndex?: number;
    stageCount?: number;
    completeWhenLastStage?: boolean;
  } = {}
): QuizEngineState<TQuestion> {
  const nextStageIndex = options.stageIndex ?? state.stageIndex + 1;
  const stageCount = options.stageCount ?? state.stageCount;
  const stagedAnswers = [...state.stagedAnswers, answer];
  const isFinalStage = nextStageIndex >= Math.max(stageCount - 1, 0);

  return {
    ...state,
    phase: isFinalStage ? 'question' : 'staged-answer',
    stageIndex: nextStageIndex,
    stageCount,
    stagedAnswers,
    lastOutcome: 'answered',
    complete: options.completeWhenLastStage ? isFinalStage : state.complete,
  };
}

export function syncQuizEngineQuestionState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>,
  options: {
    route?: string | null;
    question?: TQuestion | null;
    stageIndex?: number;
    stageCount?: number;
    stagedAnswers?: readonly string[];
    supportActive?: boolean;
    recoveryActive?: boolean;
    complete?: boolean;
    outcome?: QuizEngineState<TQuestion>['lastOutcome'];
    completedQuestionId?: string | null;
  } = {}
): QuizEngineState<TQuestion> {
  const question = options.question ?? state.currentQuestion ?? null;
  const stageIndex = options.stageIndex ?? state.stageIndex;
  const stageCount = options.stageCount ?? readStageCount(question, state.stageCount || 1);
  const stagedAnswers = [...(options.stagedAnswers ?? state.stagedAnswers)];
  const completedQuestionId = options.completedQuestionId ?? (options.complete ? question?.id ?? null : null);
  const completedQuestionIds = completedQuestionId
    ? Array.from(new Set([...state.completedQuestionIds, completedQuestionId]))
    : [...state.completedQuestionIds];

  return {
    ...state,
    route: options.route ?? state.route,
    currentQuestion: question,
    currentQuestionId: question?.id ?? null,
    currentConcept: question?.concept ?? null,
    stageIndex,
    stageCount,
    stagedAnswers,
    phase: deriveQuizEnginePhase({
      question,
      stageIndex,
      stagedAnswers,
      supportActive: options.supportActive,
      recoveryActive: options.recoveryActive,
      complete: options.complete,
    }),
    completedQuestionIds,
    lastOutcome: options.outcome ?? state.lastOutcome,
    complete: options.complete ?? false,
  };
}

export function enterSupportState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>
): QuizEngineState<TQuestion> {
  return {
    ...state,
    phase: 'support',
    supportCount: state.supportCount + 1,
    lastOutcome: 'supported',
  };
}

export function enterRecoveryState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>
): QuizEngineState<TQuestion> {
  return {
    ...state,
    phase: 'recovery',
    recoveryCount: state.recoveryCount + 1,
    lastOutcome: 'recovered',
  };
}

export function completeQuizQuestion<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>,
  completedQuestionId: string = state.currentQuestionId ?? ''
): QuizEngineState<TQuestion> {
  const completedQuestionIds = completedQuestionId
    ? Array.from(new Set([...state.completedQuestionIds, completedQuestionId]))
    : [...state.completedQuestionIds];

  return {
    ...state,
    phase: 'complete',
    completedQuestionIds,
    lastOutcome: 'completed',
    complete: true,
  };
}