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-05-15T01:23:45.072Z",
    "updatedAt": "2026-05-15T01:23:45.072Z",
    "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 type QuizEngineLastOutcome = QuizEngineState['lastOutcome'];

export type QuizEngineSnapshot<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> = Pick<
  QuizEngineState<TQuestion>,
  | 'phase'
  | 'route'
  | 'currentConcept'
  | 'currentQuestionId'
  | 'stageIndex'
  | 'stageCount'
  | 'stagedAnswers'
  | 'supportCount'
  | 'recoveryCount'
  | 'completedQuestionIds'
  | 'lastOutcome'
  | 'complete'
>;

const QUIZ_ENGINE_PHASES = new Set<QuizEnginePhase>([
  'routing',
  'question',
  'staged-answer',
  'support',
  'recovery',
  'complete',
]);

const QUIZ_ENGINE_LAST_OUTCOMES = new Set<QuizEngineLastOutcome>([
  'idle',
  'routed',
  'answered',
  'supported',
  'recovered',
  'completed',
  'reset',
]);

function readNonNegativeInteger(value: unknown, fallback: number): number {
  return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : fallback;
}

function readPositiveInteger(value: unknown, fallback: number): number {
  return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}

function readStringList(value: unknown, fallback: readonly string[] = []): string[] {
  return Array.isArray(value)
    ? value.filter((entry): entry is string => typeof entry === 'string')
    : [...fallback];
}

function readNullableString(value: unknown, fallback: string | null = null): string | null {
  return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
}

function readQuizEnginePhase(value: unknown, fallback: QuizEnginePhase): QuizEnginePhase {
  return typeof value === 'string' && QUIZ_ENGINE_PHASES.has(value as QuizEnginePhase)
    ? value as QuizEnginePhase
    : fallback;
}

function readLastOutcome(value: unknown, fallback: QuizEngineLastOutcome): QuizEngineLastOutcome {
  return typeof value === 'string' && QUIZ_ENGINE_LAST_OUTCOMES.has(value as QuizEngineLastOutcome)
    ? value as QuizEngineLastOutcome
    : fallback;
}

export function createQuizEngineSnapshot<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  state: QuizEngineState<TQuestion>
): QuizEngineSnapshot<TQuestion> {
  return {
    phase: state.phase,
    route: state.route,
    currentConcept: state.currentConcept,
    currentQuestionId: state.currentQuestionId,
    stageIndex: state.stageIndex,
    stageCount: state.stageCount,
    stagedAnswers: [...state.stagedAnswers],
    supportCount: state.supportCount,
    recoveryCount: state.recoveryCount,
    completedQuestionIds: [...state.completedQuestionIds],
    lastOutcome: state.lastOutcome,
    complete: state.complete,
  };
}

export function restoreQuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
  snapshot: Partial<QuizEngineSnapshot<TQuestion>> | null | undefined,
  options: {
    question?: TQuestion | null;
    fallback?: Partial<QuizEngineState<TQuestion>>;
    route?: string | null;
  } = {}
): QuizEngineState<TQuestion> {
  const fallback = createQuizEngineState<TQuestion>(options.fallback ?? {});
  const question = options.question ?? fallback.currentQuestion ?? null;
  const fallbackStageCount = readStageCount(question, readPositiveInteger(fallback.stageCount, 1));
  const stageCount = readPositiveInteger(snapshot?.stageCount, fallbackStageCount);
  const maxStageIndex = Math.max(stageCount - 1, 0);
  const fallbackStageIndex = readNonNegativeInteger(fallback.stageIndex, 0);
  const stageIndex = Math.min(readNonNegativeInteger(snapshot?.stageIndex, fallbackStageIndex), maxStageIndex);
  const stagedAnswers = readStringList(snapshot?.stagedAnswers, fallback.stagedAnswers);
  const complete = typeof snapshot?.complete === 'boolean' ? snapshot.complete : fallback.complete;
  const fallbackPhase = deriveQuizEnginePhase({
    question,
    stageIndex,
    stagedAnswers,
    supportActive: fallback.phase === 'support',
    recoveryActive: fallback.phase === 'recovery',
    complete,
  });
  const route = 'route' in options
    ? readNullableString(options.route, null)
    : readNullableString(snapshot?.route, fallback.route);

  return {
    ...fallback,
    phase: complete ? 'complete' : readQuizEnginePhase(snapshot?.phase, fallbackPhase),
    route,
    currentConcept: question?.concept ?? readNullableString(snapshot?.currentConcept, fallback.currentConcept),
    currentQuestionId: question?.id ?? readNullableString(snapshot?.currentQuestionId, fallback.currentQuestionId),
    currentQuestion: question,
    stageIndex,
    stageCount,
    stagedAnswers,
    supportCount: readNonNegativeInteger(snapshot?.supportCount, readNonNegativeInteger(fallback.supportCount, 0)),
    recoveryCount: readNonNegativeInteger(snapshot?.recoveryCount, readNonNegativeInteger(fallback.recoveryCount, 0)),
    completedQuestionIds: Array.from(new Set(readStringList(snapshot?.completedQuestionIds, fallback.completedQuestionIds))),
    lastOutcome: readLastOutcome(snapshot?.lastOutcome, fallback.lastOutcome),
    complete,
  };
}

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,
  };
}