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

Feature detail

Concept tree + question / generator primitives

Questions, concepts, and seeded generators stay minimal so consumer repos can shape content and rendering without fighting a heavyweight framework — with room for a concept-first rubric (disclosure, scaffold-then-fade) above the core types.

questionconceptgenerator

Worked example

Minimal content primitives

A consumer repo can keep question, concept, and generator contracts small while still driving prerequisite logic and deterministic variants.

Input

{
  "questionType": "trace",
  "conceptNode": {
    "id": "loops",
    "requires": [
      "variables"
    ]
  }
}

Output

{
  "generatedVariantSeed": 42,
  "unlockedAfterPrereqs": [
    "loops",
    "variables"
  ]
}

Real source excerpt

Concept tree primitives

src/concept/index.ts
export interface ConceptNode {
  /** Stable unique identifier. */
  id: string;
  /** Display name. */
  name: string;
  /** One-line human description. */
  description: string;
  /** ConceptNode.id values that must be mastered before this unlocks. */
  prerequisites: string[];
  /** Populated by populateConceptQuestions from the question pool. */
  questionIds: string[];
  /** Optional chapter/unit grouping. */
  chapter?: number;
  /** Optional x% layout position (0-100) for skill-tree rendering. */
  x?: number;
  /** Optional y% layout position (0-100) for skill-tree rendering. */
  y?: number;
}

export type ConceptTree = ConceptNode[];

/** Default mastery threshold (70%) used by cs1301-review-game. */
export const MASTERY_THRESHOLD = 0.7;

/**
 * Fraction-correct across all attempts on a concept's questions.
 * Returns 0 when there are no attempts yet.
 */
export function getConceptMastery(
  _conceptId: string,
  questionIds: string[],
  masteryData: MasteryData
): number {
  if (questionIds.length === 0) return 0;

  let totalCorrect = 0;
  let totalAttempts = 0;

  for (const qId of questionIds) {
    const data = masteryData[qId];
    if (data) {
      totalCorrect += data.correct;
      totalAttempts += data.total;
    }
  }

  if (totalAttempts === 0) return 0;
  return totalCorrect / totalAttempts;
}

/**
 * A concept unlocks once every prerequisite is at or above the threshold.
 * Root concepts (no prerequisites) are always unlocked.
 */
export function isConceptUnlocked(
  conceptId: string,
  tree: ConceptTree,
  masteryData: MasteryData,
  threshold: number = MASTERY_THRESHOLD
): boolean {
  const concept = tree.find(c => c.id === conceptId);
  if (!concept) return false;
  if (concept.prerequisites.length === 0) return true;

  for (const prereqId of concept.prerequisites) {
    const prereq = tree.find(c => c.id === prereqId);
    if (!prereq) continue;
    const mastery = getConceptMastery(prereqId, prereq.questionIds, masteryData);
    if (mastery < threshold) return false;
  }
  return true;
}

/** Flatten all question ids from every currently-unlocked concept. */
export function getUnlockedQuestionIds(
  tree: ConceptTree,
  masteryData: MasteryData,
  threshold: number = MASTERY_THRESHOLD
): string[] {
  const unlocked: string[] = [];
  for (const concept of tree) {
    if (isConceptUnlocked(concept.id, tree, masteryData, threshold)) {
      unlocked.push(...concept.questionIds);
    }
  }
  return unlocked;
}

/** Ids of concepts at or above the threshold. */
export function getMasteredConcepts(
  tree: ConceptTree,
  masteryData: MasteryData,
  threshold: number = MASTERY_THRESHOLD
): string[] {
  return tree
    .filter(c => getConceptMastery(c.id, c.questionIds, masteryData) >= threshold)
    .map(c => c.id);
}

/** Concepts that are unlocked but not yet mastered — what to study next. */