diff --git a/app/(game)/number-parentheses/NumberParenthesesClient.tsx b/app/(game)/number-parentheses/NumberParenthesesClient.tsx index 71b4ae2..9991406 100644 --- a/app/(game)/number-parentheses/NumberParenthesesClient.tsx +++ b/app/(game)/number-parentheses/NumberParenthesesClient.tsx @@ -8,6 +8,10 @@ export default function NumberParenthesesClient() { (state) => state.selectedProblemCount, ); const setProblemCount = useGameStore((state) => state.setProblemCount); + const selectedMaxTermCount = useGameStore( + (state) => state.selectedMaxTermCount, + ); + const setMaxTermCount = useGameStore((state) => state.setMaxTermCount); const initSession = useGameStore((state) => state.initSession); return ( @@ -16,10 +20,12 @@ export default function NumberParenthesesClient() { title="숫자 괄호 풀기" description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다." notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다." - playHref={`/number-parentheses/play?count=${selectedProblemCount}`} + playHref={`/number-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`} selectedProblemCount={selectedProblemCount} + selectedMaxTermCount={selectedMaxTermCount} onProblemCountChange={setProblemCount} - onStart={() => initSession(selectedProblemCount)} + onMaxTermCountChange={setMaxTermCount} + onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)} /> ); } diff --git a/app/(game)/number-parentheses/play/NumberPlayClient.tsx b/app/(game)/number-parentheses/play/NumberPlayClient.tsx index 2a4ba9c..788a2c5 100644 --- a/app/(game)/number-parentheses/play/NumberPlayClient.tsx +++ b/app/(game)/number-parentheses/play/NumberPlayClient.tsx @@ -16,7 +16,7 @@ import StageIndicator from "@/components/game/StageIndicator"; import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device"; import KatexRenderer from "@/components/math/KatexRenderer"; import { useGameStore } from "@/store/gameStore"; -import type { ProblemCount } from "@/store/gameStore"; +import type { MaxTermCount, ProblemCount } from "@/store/gameStore"; const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/; @@ -30,16 +30,22 @@ function parseAnswer(value: string) { interface NumberPlayClientProps { problemCount: ProblemCount; + maxTermCount: MaxTermCount; } export default function NumberPlayClient({ problemCount, + maxTermCount, }: NumberPlayClientProps) { const session = useGameStore((state) => state.session); const selectedProblemCount = useGameStore( (state) => state.selectedProblemCount, ); const setProblemCount = useGameStore((state) => state.setProblemCount); + const selectedMaxTermCount = useGameStore( + (state) => state.selectedMaxTermCount, + ); + const setMaxTermCount = useGameStore((state) => state.setMaxTermCount); const initSession = useGameStore((state) => state.initSession); const submitAnswer = useGameStore((state) => state.submitAnswer); const nextProblem = useGameStore((state) => state.nextProblem); @@ -48,8 +54,9 @@ export default function NumberPlayClient({ const problemCardRef = useRef(null); const answerFormRef = useRef(null); - const [answerA, setAnswerA] = useState(""); - const [answerB, setAnswerB] = useState(""); + const [answers, setAnswers] = useState(() => + Array.from({ length: maxTermCount }, () => ""), + ); const [activeInput, setActiveInput] = useState(0); const [inputError, setInputError] = useState(""); @@ -59,7 +66,8 @@ export default function NumberPlayClient({ const isComplete = Boolean( session && session.currentIndex >= session.problems.length, ); - const sessionNeedsInit = sessionProblemCount !== problemCount; + const sessionNeedsInit = + sessionProblemCount !== problemCount || session?.termCount !== maxTermCount; const correctCount = session?.results.filter((result) => result?.correct).length ?? 0; const totalCount = session?.problems.length ?? 0; @@ -69,40 +77,53 @@ export default function NumberPlayClient({ setProblemCount(problemCount); } + if (selectedMaxTermCount !== maxTermCount) { + setMaxTermCount(maxTermCount); + } + if (sessionNeedsInit) { - initSession(problemCount); + initSession(problemCount, maxTermCount); } }, [ initSession, + maxTermCount, problemCount, + selectedMaxTermCount, selectedProblemCount, sessionProblemCount, sessionNeedsInit, + setMaxTermCount, setProblemCount, ]); useEffect(() => { - setAnswerA(""); - setAnswerB(""); + setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => "")); setActiveInput(0); setInputError(""); - }, [currentProblem?.key]); + }, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]); function handleSubmit(event: FormEvent) { event.preventDefault(); if (currentResult || !currentProblem) return; - const parsedA = parseAnswer(answerA); - const parsedB = parseAnswer(answerB); + const parsedAnswers = answers.map(parseAnswer); - if (parsedA === null || parsedB === null) { - setInputError("두 칸 모두 실수로 입력하세요."); + if (parsedAnswers.some((answer) => answer === null)) { + setInputError("모든 항을 실수로 입력하세요."); return; } setInputError(""); - submitAnswer({ a: parsedA, b: parsedB }); + submitAnswer({ terms: parsedAnswers as number[] }); + } + + function handleAnswerChange(index: ActiveAnswerInput, value: string) { + setAnswers((currentAnswers) => + currentAnswers.map((answer, answerIndex) => + answerIndex === index ? value : answer, + ), + ); } function handleNextProblem() { @@ -184,10 +205,13 @@ export default function NumberPlayClient({ const submitted = currentResult !== null; const isLastProblem = session.currentIndex === session.problems.length - 1; - const pendingAnswerLabels: [string, string] = [ - String(currentProblem.a), - String(currentProblem.operator === "-" ? -currentProblem.b : currentProblem.b), - ]; + const pendingAnswerLabels = currentProblem.terms.map((term, index) => { + if (index === 0) return String(term); + + return String( + currentProblem.operators[index - 1] === "-" ? -term : term, + ); + }); return (
@@ -214,9 +238,6 @@ export default function NumberPlayClient({ ref={problemCardRef} className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8" > -

- 괄호를 풀어 두 항을 순서대로 입력하세요. -

@@ -231,14 +252,12 @@ export default function NumberPlayClient({ {!submitted ? ( diff --git a/app/(game)/number-parentheses/play/page.tsx b/app/(game)/number-parentheses/play/page.tsx index e493800..a84a5a9 100644 --- a/app/(game)/number-parentheses/play/page.tsx +++ b/app/(game)/number-parentheses/play/page.tsx @@ -1,9 +1,10 @@ import NumberPlayClient from "./NumberPlayClient"; -import type { ProblemCount } from "@/store/gameStore"; +import type { MaxTermCount, ProblemCount } from "@/store/gameStore"; interface NumberPlayPageProps { searchParams?: { count?: string | string[]; + terms?: string | string[]; }; } @@ -15,8 +16,22 @@ function parseProblemCount(count?: string | string[]): ProblemCount { return count === "5" ? 5 : 10; } +function parseMaxTermCount(terms?: string | string[]): MaxTermCount { + if (Array.isArray(terms)) { + return parseMaxTermCount(terms[0]); + } + + if (terms === "3") return 3; + if (terms === "4") return 4; + + return 2; +} + export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) { return ( - + ); } diff --git a/app/(game)/variable-parentheses/VariableParenthesesClient.tsx b/app/(game)/variable-parentheses/VariableParenthesesClient.tsx index 0041a82..9300c9f 100644 --- a/app/(game)/variable-parentheses/VariableParenthesesClient.tsx +++ b/app/(game)/variable-parentheses/VariableParenthesesClient.tsx @@ -10,6 +10,12 @@ export default function VariableParenthesesClient() { const setProblemCount = useVariableGameStore( (state) => state.setProblemCount, ); + const selectedMaxTermCount = useVariableGameStore( + (state) => state.selectedMaxTermCount, + ); + const setMaxTermCount = useVariableGameStore( + (state) => state.setMaxTermCount, + ); const initSession = useVariableGameStore((state) => state.initSession); return ( @@ -18,10 +24,12 @@ export default function VariableParenthesesClient() { title="미지수 괄호 풀기" description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다." notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다." - playHref={`/variable-parentheses/play?count=${selectedProblemCount}`} + playHref={`/variable-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`} selectedProblemCount={selectedProblemCount} + selectedMaxTermCount={selectedMaxTermCount} onProblemCountChange={setProblemCount} - onStart={() => initSession(selectedProblemCount)} + onMaxTermCountChange={setMaxTermCount} + onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)} /> ); } diff --git a/app/(game)/variable-parentheses/play/VariablePlayClient.tsx b/app/(game)/variable-parentheses/play/VariablePlayClient.tsx index 0f74193..d9c9013 100644 --- a/app/(game)/variable-parentheses/play/VariablePlayClient.tsx +++ b/app/(game)/variable-parentheses/play/VariablePlayClient.tsx @@ -12,15 +12,18 @@ import KatexRenderer from "@/components/math/KatexRenderer"; import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; import { useVariableGameStore, + type VariableMaxTermCount, type VariableProblemCount, } from "@/store/variableGameStore"; interface VariablePlayClientProps { problemCount: VariableProblemCount; + maxTermCount: VariableMaxTermCount; } export default function VariablePlayClient({ problemCount, + maxTermCount, }: VariablePlayClientProps) { const session = useVariableGameStore((state) => state.session); const selectedProblemCount = useVariableGameStore( @@ -29,15 +32,21 @@ export default function VariablePlayClient({ const setProblemCount = useVariableGameStore( (state) => state.setProblemCount, ); + const selectedMaxTermCount = useVariableGameStore( + (state) => state.selectedMaxTermCount, + ); + const setMaxTermCount = useVariableGameStore( + (state) => state.setMaxTermCount, + ); const initSession = useVariableGameStore((state) => state.initSession); const submitAnswer = useVariableGameStore((state) => state.submitAnswer); const nextProblem = useVariableGameStore((state) => state.nextProblem); const resetSession = useVariableGameStore((state) => state.resetSession); - const [selectedChoiceIds, setSelectedChoiceIds] = useState< - [string | null, string | null] - >([null, null]); - const [activeTermIndex, setActiveTermIndex] = useState<0 | 1>(0); + const [selectedChoiceIds, setSelectedChoiceIds] = useState<(string | null)[]>( + () => Array.from({ length: maxTermCount }, () => null), + ); + const [activeTermIndex, setActiveTermIndex] = useState(0); const sessionProblemCount = session?.problems.length ?? null; const currentProblem = session?.problems[session.currentIndex] ?? null; @@ -45,7 +54,8 @@ export default function VariablePlayClient({ const isComplete = Boolean( session && session.currentIndex >= session.problems.length, ); - const sessionNeedsInit = sessionProblemCount !== problemCount; + const sessionNeedsInit = + sessionProblemCount !== problemCount || session?.termCount !== maxTermCount; const correctCount = session?.results.filter((result) => result?.correct).length ?? 0; const totalCount = session?.problems.length ?? 0; @@ -55,40 +65,46 @@ export default function VariablePlayClient({ setProblemCount(problemCount); } + if (selectedMaxTermCount !== maxTermCount) { + setMaxTermCount(maxTermCount); + } + if (sessionNeedsInit) { - initSession(problemCount); + initSession(problemCount, maxTermCount); } }, [ initSession, + maxTermCount, problemCount, + selectedMaxTermCount, selectedProblemCount, sessionProblemCount, sessionNeedsInit, + setMaxTermCount, setProblemCount, ]); useEffect(() => { - setSelectedChoiceIds([null, null]); + setSelectedChoiceIds( + Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => null), + ); setActiveTermIndex(0); - }, [currentProblem?.key]); + }, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]); - function handleSelectChoice(termIndex: 0 | 1, choiceId: string) { - const nextSelectedChoiceIds: [string | null, string | null] = [ - ...selectedChoiceIds, - ]; + function handleSelectChoice(termIndex: number, choiceId: string) { + const nextSelectedChoiceIds = [...selectedChoiceIds]; nextSelectedChoiceIds[termIndex] = choiceId; setSelectedChoiceIds(nextSelectedChoiceIds); - if (termIndex === 0) { - setActiveTermIndex(1); + if (termIndex < nextSelectedChoiceIds.length - 1) { + setActiveTermIndex(termIndex + 1); return; } if (currentResult || !currentProblem) return; if ( - nextSelectedChoiceIds[0] === null || - nextSelectedChoiceIds[1] === null + nextSelectedChoiceIds.some((selectedChoiceId) => selectedChoiceId === null) ) { return; } @@ -140,16 +156,15 @@ export default function VariablePlayClient({ const submitted = currentResult !== null; const isLastProblem = session.currentIndex === session.problems.length - 1; - const pendingChoiceLabels: [string, string] = [ - formatVariableTermLatex(currentProblem.firstTerm), + const pendingChoiceLabels = currentProblem.terms.map((term, index) => formatVariableTermLatex({ - ...currentProblem.secondTerm, + ...term, coefficient: - currentProblem.operator === "-" - ? -currentProblem.secondTerm.coefficient - : currentProblem.secondTerm.coefficient, + index > 0 && currentProblem.operators[index - 1] === "-" + ? -term.coefficient + : term.coefficient, }), - ]; + ); return (
@@ -173,9 +188,6 @@ export default function VariablePlayClient({
-

- 괄호를 풀어 첫째 항과 둘째 항의 답을 보기에서 고르세요. -

diff --git a/app/(game)/variable-parentheses/play/page.tsx b/app/(game)/variable-parentheses/play/page.tsx index 7e1bf6d..b64e7ae 100644 --- a/app/(game)/variable-parentheses/play/page.tsx +++ b/app/(game)/variable-parentheses/play/page.tsx @@ -1,9 +1,13 @@ import VariablePlayClient from "./VariablePlayClient"; -import type { VariableProblemCount } from "@/store/variableGameStore"; +import type { + VariableMaxTermCount, + VariableProblemCount, +} from "@/store/variableGameStore"; interface VariablePlayPageProps { searchParams?: { count?: string | string[]; + terms?: string | string[]; }; } @@ -15,10 +19,24 @@ function parseProblemCount(count?: string | string[]): VariableProblemCount { return count === "5" ? 5 : 10; } +function parseMaxTermCount(terms?: string | string[]): VariableMaxTermCount { + if (Array.isArray(terms)) { + return parseMaxTermCount(terms[0]); + } + + if (terms === "3") return 3; + if (terms === "4") return 4; + + return 2; +} + export default function VariablePlayPage({ searchParams, }: VariablePlayPageProps) { return ( - + ); } diff --git a/components/game/FeedbackPanel.tsx b/components/game/FeedbackPanel.tsx index 43578b6..213f340 100644 --- a/components/game/FeedbackPanel.tsx +++ b/components/game/FeedbackPanel.tsx @@ -23,10 +23,11 @@ export default function FeedbackPanel({ onNext, }: FeedbackPanelProps) { const nextButtonRef = useRef(null); - const signedTerms = [ - problem.a, - problem.operator === "-" ? -problem.b : problem.b, - ] as const; + const signedTerms = problem.terms.map((term, index) => { + if (index === 0) return term; + + return problem.operators[index - 1] === "-" ? -term : term; + }); const feedbackItems = signedTerms.map((term, index) => ({ formulaLatex: `${formatSignedNumber( problem.multiplier, diff --git a/components/game/NumberAnswerForm.tsx b/components/game/NumberAnswerForm.tsx index 4f674d8..f34495e 100644 --- a/components/game/NumberAnswerForm.tsx +++ b/components/game/NumberAnswerForm.tsx @@ -14,7 +14,7 @@ import type { FocusEvent, FormEvent } from "react"; import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device"; import KatexRenderer from "@/components/math/KatexRenderer"; -export type ActiveAnswerInput = 0 | 1; +export type ActiveAnswerInput = number; const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/; const KEYPAD_KEYS = [ @@ -36,13 +36,11 @@ export interface NumberAnswerFormHandle { } interface NumberAnswerFormProps { - answerA: string; - answerB: string; + answers: string[]; activeInput: ActiveAnswerInput; error: string; - pendingAnswerLabels: [string, string]; - onAnswerAChange: (value: string) => void; - onAnswerBChange: (value: string) => void; + pendingAnswerLabels: string[]; + onAnswerChange: (index: ActiveAnswerInput, value: string) => void; onActiveInputChange: (input: ActiveAnswerInput) => void; onInputBlur?: () => void; onInputFocus?: () => void; @@ -54,13 +52,11 @@ const NumberAnswerForm = forwardRef< NumberAnswerFormProps >(function NumberAnswerForm( { - answerA, - answerB, + answers, activeInput, error, pendingAnswerLabels, - onAnswerAChange, - onAnswerBChange, + onAnswerChange, onActiveInputChange, onInputBlur, onInputFocus, @@ -75,17 +71,16 @@ const NumberAnswerForm = forwardRef< const [keypadOpen, setKeypadOpen] = useState(false); const [desktopInputDevice, setDesktopInputDevice] = useState(false); - const activeAnswer = activeInput === 0 ? answerA : answerB; - const activeLabel = activeInput === 0 ? "첫째 항" : "둘째 항"; + const activeAnswer = answers[activeInput] ?? ""; + const activeLabel = `${activeInput + 1}항`; const activeDescription = `${activeLabel}의 답을 입력하세요.`; - const activeEnterKeyHint = activeInput === 0 ? "next" : "done"; - const activeAnswerChange = - activeInput === 0 ? onAnswerAChange : onAnswerBChange; + const lastInputIndex = answers.length - 1; + const activeEnterKeyHint = activeInput < lastInputIndex ? "next" : "done"; function updateAnswer(value: string) { if (!ANSWER_INPUT_PATTERN.test(value)) return; - activeAnswerChange(value); + onAnswerChange(activeInput, value); } function handleInputFocus() { @@ -154,16 +149,9 @@ const NumberAnswerForm = forwardRef< }; }, []); - function setAnswer(input: ActiveAnswerInput, value: string) { - if (input === 0) { - onAnswerAChange(value); - } else { - onAnswerBChange(value); - } - } - function toggleMinus() { - activeAnswerChange( + onAnswerChange( + activeInput, activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`, ); focusInput(); @@ -182,14 +170,14 @@ const NumberAnswerForm = forwardRef< function moveToInput(input: ActiveAnswerInput, reset = false) { onActiveInputChange(input); if (reset) { - setAnswer(input, ""); + onAnswerChange(input, ""); } focusInput(); } function handleEnter() { - if (activeInput === 0) { - moveToInput(1); + if (activeInput < lastInputIndex) { + moveToInput(activeInput + 1); return; } @@ -197,18 +185,11 @@ const NumberAnswerForm = forwardRef< focusInput(); } - const answerButtons = [ - { - index: 0 as const, - label: "첫째 항", - value: answerA, - }, - { - index: 1 as const, - label: "둘째 항", - value: answerB, - }, - ]; + const answerButtons = answers.map((value, index) => ({ + index, + label: `${index + 1}항`, + value, + })); return (
event.preventDefault()} onClick={handleEnter} aria-label={ - activeInput === 0 - ? "첫째 항 입력 완료 후 둘째 항으로 이동" - : "둘째 항 입력 완료 후 제출" + activeInput < lastInputIndex + ? "현재 항 입력 완료 후 다음 항으로 이동" + : "마지막 항 입력 완료 후 제출" } className="col-span-2 inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 text-base font-semibold text-white shadow-sm transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2" > diff --git a/components/game/ParenthesesPreparationClient.tsx b/components/game/ParenthesesPreparationClient.tsx index 69a1877..2499bcd 100644 --- a/components/game/ParenthesesPreparationClient.tsx +++ b/components/game/ParenthesesPreparationClient.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useEffect, useRef } from "react"; export type PreparationProblemCount = 5 | 10; +export type PreparationMaxTermCount = 2 | 3 | 4; interface ParenthesesPreparationClientProps { eyebrow: string; @@ -12,11 +13,33 @@ interface ParenthesesPreparationClientProps { notice: string; playHref: string; selectedProblemCount: PreparationProblemCount; + selectedMaxTermCount: PreparationMaxTermCount; onProblemCountChange: (count: PreparationProblemCount) => void; + onMaxTermCountChange: (count: PreparationMaxTermCount) => void; onStart: () => void; } const problemCounts: PreparationProblemCount[] = [5, 10]; +const maxTermCounts: PreparationMaxTermCount[] = [2, 3, 4]; + +const buttonGroupClassName = + "inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"; + +function optionButtonClassName({ + selected, + disabled = false, +}: { + selected: boolean; + disabled?: boolean; +}) { + return `h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${ + selected ? "bg-emerald-700 text-white" : "text-slate-700 hover:bg-slate-100" + } ${ + disabled + ? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100" + : "" + }`; +} const bracketKinds = [ { @@ -46,7 +69,9 @@ export default function ParenthesesPreparationClient({ notice, playHref, selectedProblemCount, + selectedMaxTermCount, onProblemCountChange, + onMaxTermCountChange, onStart, }: ParenthesesPreparationClientProps) { const startLinkRef = useRef(null); @@ -74,7 +99,7 @@ export default function ParenthesesPreparationClient({ 문제 수
@@ -87,11 +112,7 @@ export default function ParenthesesPreparationClient({ type="button" onClick={() => onProblemCountChange(count)} aria-pressed={selected} - className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${ - selected - ? "bg-emerald-700 text-white" - : "text-slate-700 hover:bg-slate-100" - }`} + className={optionButtonClassName({ selected })} > {count} @@ -100,39 +121,63 @@ export default function ParenthesesPreparationClient({
-
- - 괄호 종류 - -
- {bracketKinds.map((bracketKind) => ( - - ))} -
-
+
+
+ + 괄호 종류 + +
+ {bracketKinds.map((bracketKind) => ( + + ))} +
+
+ +
+ + 괄호 안 최대 항 수 + +
+ {maxTermCounts.map((count) => { + const selected = selectedMaxTermCount === count; + + return ( + + ); + })} +
+
+
void; } -function formatAnswerPairLatex(first: number, second: number) { - const operator = second < 0 ? "-" : "+"; +function formatAnswerLatex(terms: number[]) { + return terms + .map((term, index) => { + if (index === 0) return `${term}`; - return `${first} ${operator} ${Math.abs(second)}`; + const operator = term < 0 ? "-" : "+"; + return `${operator} ${Math.abs(term)}`; + }) + .join(" "); } function renderUserAnswerPair(result: GradeResult) { - const secondOperator = result.userAnswer[1] < 0 ? "-" : "+"; - const firstCorrect = result.userAnswer[0] === result.correctAnswer[0]; - const secondCorrect = result.userAnswer[1] === result.correctAnswer[1]; - return ( - - - - - - + {result.userAnswer.map((term, index) => { + const operator = term < 0 ? "-" : "+"; + const correct = term === result.correctAnswer[index]; + const displayLatex = + index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`; + + return ( + + + + ); + })} ); } function renderCorrectAnswerPair(result: GradeResult) { - const secondOperator = result.correctAnswer[1] < 0 ? "-" : "+"; - const firstCorrect = result.userAnswer[0] === result.correctAnswer[0]; - const secondCorrect = result.userAnswer[1] === result.correctAnswer[1]; - return ( - - - - - - + {result.correctAnswer.map((term, index) => { + const operator = term < 0 ? "-" : "+"; + const correct = result.userAnswer[index] === term; + const displayLatex = + index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`; + + return ( + + + + ); + })} ); } @@ -102,10 +106,7 @@ export default function ScoreSummary({
    {solvedItems.map(({ problem, result }, index) => { - const userAnswerLatex = formatAnswerPairLatex( - result.userAnswer[0], - result.userAnswer[1], - ); + const userAnswerLatex = formatAnswerLatex(result.userAnswer); return ( void; + choices: VariableTermChoice[][]; + selectedChoiceIds: (string | null)[]; + activeTermIndex: number; + pendingChoiceLabels: string[]; + onSelectChoice: (termIndex: number, choiceId: string) => void; } export default function VariableChoiceAnswerForm({ @@ -29,7 +29,7 @@ export default function VariableChoiceAnswerForm({ firstChoiceRef.current?.focus(); }, [activeTermIndex]); - function getSelectedChoice(index: 0 | 1) { + function getSelectedChoice(index: number) { const selectedChoiceId = selectedChoiceIds[index]; if (!selectedChoiceId) return null; @@ -42,7 +42,7 @@ export default function VariableChoiceAnswerForm({

    - {activeTermIndex === 0 ? "첫째 항" : "둘째 항"}을 고르세요. + {activeTermIndex + 1}항을 고르세요.

      {selectedChoiceIds.map((choiceId, index) => { const selected = activeTermIndex === index; - const selectedChoice = getSelectedChoice(index as 0 | 1); + const selectedChoice = getSelectedChoice(index); const completed = choiceId !== null && selectedChoice !== null; const statusLabel = completed && selectedChoice @@ -95,7 +95,7 @@ export default function VariableChoiceAnswerForm({
      {activeChoices.map((choice, index) => { const selected = selectedChoiceIds[activeTermIndex] === choice.id; diff --git a/components/game/VariableFeedbackPanel.tsx b/components/game/VariableFeedbackPanel.tsx index 780d14b..5dad0f1 100644 --- a/components/game/VariableFeedbackPanel.tsx +++ b/components/game/VariableFeedbackPanel.tsx @@ -6,7 +6,6 @@ import AnswerResultLine from "@/components/game/AnswerResultLine"; import type { VariableGradeResult, VariableParenthesisProblem, - VariableParenthesisTerm, } from "@/lib/engine/variable-types"; import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; @@ -21,12 +20,6 @@ function formatSignedNumber(value: number) { return value < 0 ? `(${value})` : `${value}`; } -function formatSignedTerm(term: VariableParenthesisTerm, sign: 1 | -1) { - const coefficient = term.coefficient * sign; - - return formatVariableTermLatex({ ...term, coefficient }); -} - export default function VariableFeedbackPanel({ result, problem, @@ -34,14 +27,18 @@ export default function VariableFeedbackPanel({ onNext, }: VariableFeedbackPanelProps) { const nextButtonRef = useRef(null); - const signedTerms = [ - formatSignedTerm(problem.firstTerm, 1), - formatSignedTerm(problem.secondTerm, problem.operator === "-" ? -1 : 1), - ] as const; + const signedTerms = problem.terms.map((term, index) => + formatVariableTermLatex({ + ...term, + coefficient: + index > 0 && problem.operators[index - 1] === "-" + ? -term.coefficient + : term.coefficient, + }), + ); const feedbackItems = signedTerms.map((termLatex, index) => { - const termIndex = index as 0 | 1; - const userAnswer = result.userAnswer[termIndex]; - const correctAnswer = result.correctAnswer[termIndex]; + const userAnswer = result.userAnswer[index]; + const correctAnswer = result.correctAnswer[index]; return { formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`, diff --git a/components/game/VariableScoreSummary.tsx b/components/game/VariableScoreSummary.tsx index abc212f..0299acf 100644 --- a/components/game/VariableScoreSummary.tsx +++ b/components/game/VariableScoreSummary.tsx @@ -21,14 +21,10 @@ interface VariableScoreSummaryProps { } function renderTermPair( - terms: [DistributedVariableTerm | null, DistributedVariableTerm | null], - comparisonTerms: [DistributedVariableTerm | null, DistributedVariableTerm | null], + terms: (DistributedVariableTerm | null)[], + comparisonTerms: (DistributedVariableTerm | null)[], mode: "user" | "correct", ) { - const secondTerm = terms[1]; - const secondOperator = - secondTerm && secondTerm.coefficient < 0 ? "-" : "+"; - return ( {terms.map((term, index) => { @@ -49,10 +45,9 @@ function renderTermPair( : correct ? undefined : "font-bold text-blue-700"; + const operator = term.coefficient < 0 ? "-" : "+"; const displayLatex = - index === 1 - ? `${secondOperator} ${term.latex.replace(/^-/, "")}` - : term.latex; + index === 0 ? term.latex : `${operator} ${term.latex.replace(/^-/, "")}`; return ( @@ -111,8 +106,9 @@ export default function VariableScoreSummary({ key={problem.key} expressionLatex={problem.latexBefore} userAnswerLatex={formatVariableExpressionLatex([ - result.userAnswer[0] ?? result.correctAnswer[0], - result.userAnswer[1] ?? result.correctAnswer[1], + ...result.correctAnswer.map( + (term, termIndex) => result.userAnswer[termIndex] ?? term, + ), ])} correctAnswerLatex={problem.latexAfter} correct={result.correct} diff --git a/lib/engine/parenthesis-sign.test.ts b/lib/engine/parenthesis-sign.test.ts index f9836ec..c80055f 100644 --- a/lib/engine/parenthesis-sign.test.ts +++ b/lib/engine/parenthesis-sign.test.ts @@ -63,8 +63,19 @@ describe("parenthesis sign problem engine", () => { expect(keys.size).toBe(10); }); + it("generates the selected number of terms inside parentheses", () => { + const problem = generateProblem(4); + + expect(problem.terms).toHaveLength(4); + expect(problem.operators).toHaveLength(3); + expect(problem.distributedTerms).toHaveLength(4); + expect( + gradeAnswer(problem, { terms: problem.distributedTerms }).correct, + ).toBe(true); + }); + it("rejects impossible session sizes", () => { expect(() => generateSession(0)).toThrow(); - expect(() => generateSession(145)).toThrow(); + expect(() => generateSession(1297)).toThrow(); }); }); diff --git a/lib/engine/parenthesis-sign.ts b/lib/engine/parenthesis-sign.ts index 3bbdb22..99666c3 100644 --- a/lib/engine/parenthesis-sign.ts +++ b/lib/engine/parenthesis-sign.ts @@ -8,61 +8,138 @@ const MIN_MULTIPLIER = -9; const MAX_MULTIPLIER = -1; const MIN_TERM = 1; const MAX_TERM = 9; -const MAX_PROBLEM_COUNT = 9 * 8 * 2; +const MAX_TERM_COUNT = 4; -type ProblemSeed = Pick< - ParenthesisSignProblem, - "multiplier" | "a" | "operator" | "b" ->; +export type ParenthesisTermCount = 2 | 3 | 4; + +interface ProblemSeed { + multiplier: number; + terms: number[]; + operators: ("+" | "-")[]; +} function randomInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } -function formatLatexTermPair(first: number, second: number) { - const operator = second < 0 ? "-" : "+"; +function formatLatexTerms(terms: number[]) { + return terms + .map((term, index) => { + if (index === 0) return `${term}`; - return `${first} ${operator} ${Math.abs(second)}`; + const operator = term < 0 ? "-" : "+"; + return `${operator} ${Math.abs(term)}`; + }) + .join(" "); +} + +function formatParenthesisTerms( + terms: number[], + operators: ("+" | "-")[], +) { + return terms + .map((term, index) => { + if (index === 0) return `${term}`; + + return `${operators[index - 1]} ${term}`; + }) + .join(" "); } function createProblem({ multiplier, - a, - operator, - b, + terms, + operators, }: ProblemSeed): ParenthesisSignProblem { - const distributedA = multiplier * a; - const distributedB = operator === "-" ? multiplier * -b : multiplier * b; - const key = `${multiplier}:${a}:${operator}:${b}`; + const signedTerms = terms.map((term, index) => { + if (index === 0) return term; + + return operators[index - 1] === "-" ? -term : term; + }); + const distributedTerms = signedTerms.map((term) => multiplier * term); + const [a, b] = terms; + const [operator] = operators; + const [distributedA, distributedB] = distributedTerms; + const key = [ + multiplier, + ...terms.flatMap((term, index) => + index === 0 ? [`${term}`] : [operators[index - 1], `${term}`], + ), + ].join(":"); return { id: `parenthesis-sign:${key}`, key, multiplier, + terms, + operators, a, operator, b, + distributedTerms, distributedA, distributedB, - latexBefore: `${multiplier}\\left(${a} ${operator} ${b}\\right)`, - latexAfter: formatLatexTermPair(distributedA, distributedB), + latexBefore: `${multiplier}\\left(${formatParenthesisTerms( + terms, + operators, + )}\\right)`, + latexAfter: formatLatexTerms(distributedTerms), }; } -function createAllProblemSeeds() { +function createTermCombinations(termCount: ParenthesisTermCount) { + const combinations: number[][] = []; + + function addTerms(nextTerms: number[]) { + if (nextTerms.length === termCount) { + combinations.push(nextTerms); + return; + } + + for (let term = MIN_TERM; term <= MAX_TERM; term += 1) { + if (nextTerms.includes(term)) continue; + + addTerms([...nextTerms, term]); + } + } + + addTerms([]); + + return combinations; +} + +function createOperatorCombinations(termCount: ParenthesisTermCount) { + const combinations: ("+" | "-")[][] = []; + const operatorCount = termCount - 1; + + function addOperators(nextOperators: ("+" | "-")[]) { + if (nextOperators.length === operatorCount) { + combinations.push(nextOperators); + return; + } + + addOperators([...nextOperators, "+"]); + addOperators([...nextOperators, "-"]); + } + + addOperators([]); + + return combinations; +} + +function createAllProblemSeeds(termCount: ParenthesisTermCount) { const seeds: ProblemSeed[] = []; + const termCombinations = createTermCombinations(termCount); + const operatorCombinations = createOperatorCombinations(termCount); for ( let multiplier = MIN_MULTIPLIER; multiplier <= MAX_MULTIPLIER; multiplier += 1 ) { - for (let a = MIN_TERM; a <= MAX_TERM; a += 1) { - for (let b = MIN_TERM; b <= MAX_TERM; b += 1) { - if (a === b) continue; - - seeds.push({ multiplier, a, operator: "+", b }); - seeds.push({ multiplier, a, operator: "-", b }); + for (const terms of termCombinations) { + for (const operators of operatorCombinations) { + seeds.push({ multiplier, terms, operators }); } } } @@ -70,7 +147,14 @@ function createAllProblemSeeds() { return seeds; } -const ALL_PROBLEM_SEEDS = createAllProblemSeeds(); +const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record< + ParenthesisTermCount, + ProblemSeed[] +> = { + 2: createAllProblemSeeds(2), + 3: createAllProblemSeeds(3), + 4: createAllProblemSeeds(4), +}; function shuffle(items: T[]) { const shuffled = [...items]; @@ -85,42 +169,69 @@ function shuffle(items: T[]) { return shuffled; } -export function generateProblem(): ParenthesisSignProblem { - const a = randomInt(MIN_TERM, MAX_TERM); - let b = randomInt(MIN_TERM, MAX_TERM); +function assertTermCount(termCount: number): asserts termCount is ParenthesisTermCount { + if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) { + throw new Error("termCount must be 2, 3, or 4."); + } +} - while (a === b) { - b = randomInt(MIN_TERM, MAX_TERM); +export function generateProblem( + termCount: ParenthesisTermCount = 2, +): ParenthesisSignProblem { + assertTermCount(termCount); + + const terms: number[] = []; + + while (terms.length < termCount) { + const term = randomInt(MIN_TERM, MAX_TERM); + + if (!terms.includes(term)) { + terms.push(term); + } } return createProblem({ multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER), - a, - operator: Math.random() < 0.5 ? "+" : "-", - b, + terms, + operators: Array.from({ length: termCount - 1 }, () => + Math.random() < 0.5 ? "+" : "-", + ), }); } -export function generateSession(count = 10): ParenthesisSignProblem[] { +export function generateSession( + count = 10, + termCount: ParenthesisTermCount = 2, +): ParenthesisSignProblem[] { if (!Number.isInteger(count) || count < 1) { throw new Error("count must be a positive integer."); } - if (count > MAX_PROBLEM_COUNT) { - throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`); + assertTermCount(termCount); + + const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount]; + + if (count > seeds.length) { + throw new Error(`count cannot exceed ${seeds.length}.`); } - return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem); + return shuffle(seeds).slice(0, count).map(createProblem); } export function gradeAnswer( problem: ParenthesisSignProblem, answer: UserAnswer, ): GradeResult { + const userAnswer = + answer.terms ?? (answer.a !== undefined && answer.b !== undefined + ? [answer.a, answer.b] + : []); + return { correct: - answer.a === problem.distributedA && answer.b === problem.distributedB, - userAnswer: [answer.a, answer.b], - correctAnswer: [problem.distributedA, problem.distributedB], + userAnswer.length === problem.distributedTerms.length && + userAnswer.every((value, index) => value === problem.distributedTerms[index]), + userAnswer, + correctAnswer: problem.distributedTerms, }; } diff --git a/lib/engine/types.ts b/lib/engine/types.ts index 23adb11..f8bd24a 100644 --- a/lib/engine/types.ts +++ b/lib/engine/types.ts @@ -2,9 +2,12 @@ export interface ParenthesisSignProblem { id: string; key: string; multiplier: number; + terms: number[]; + operators: ("+" | "-")[]; a: number; operator: "+" | "-"; b: number; + distributedTerms: number[]; distributedA: number; distributedB: number; latexBefore: string; @@ -12,12 +15,13 @@ export interface ParenthesisSignProblem { } export interface UserAnswer { - a: number; - b: number; + terms?: number[]; + a?: number; + b?: number; } export interface GradeResult { correct: boolean; - userAnswer: [number, number]; - correctAnswer: [number, number]; + userAnswer: number[]; + correctAnswer: number[]; } diff --git a/lib/engine/variable-parenthesis.test.ts b/lib/engine/variable-parenthesis.test.ts index 66068bd..d2bba27 100644 --- a/lib/engine/variable-parenthesis.test.ts +++ b/lib/engine/variable-parenthesis.test.ts @@ -10,7 +10,7 @@ import type { VariableParenthesisProblem } from "./variable-types"; function findCorrectChoiceIds(problem: VariableParenthesisProblem) { return problem.correctTerms.map((term, index) => { - const choices = problem.choices[index as 0 | 1]; + const choices = problem.choices[index]; const choice = choices.find( (item) => item.coefficient === term.coefficient && item.variable === term.variable, @@ -19,7 +19,7 @@ function findCorrectChoiceIds(problem: VariableParenthesisProblem) { if (!choice) throw new Error("correct choice not found."); return choice.id; - }) as [string, string]; + }); } describe("variable parenthesis engine", () => { @@ -97,6 +97,21 @@ describe("variable parenthesis engine", () => { expect(keys.size).toBe(10); }); + it("generates the selected number of variable choices", () => { + const problem = generateVariableParenthesisProblem(4); + + expect(problem.terms).toHaveLength(4); + expect(problem.operators).toHaveLength(3); + expect(problem.correctTerms).toHaveLength(4); + expect(problem.choices).toHaveLength(4); + expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1); + expect( + gradeVariableParenthesisAnswer(problem, { + selectedChoiceIds: findCorrectChoiceIds(problem), + }).correct, + ).toBe(true); + }); + it("formats expressions with variable terms", () => { expect( formatVariableExpressionLatex([ diff --git a/lib/engine/variable-parenthesis.ts b/lib/engine/variable-parenthesis.ts index 7bbe06d..c053fe0 100644 --- a/lib/engine/variable-parenthesis.ts +++ b/lib/engine/variable-parenthesis.ts @@ -11,13 +11,14 @@ const MIN_MULTIPLIER = -9; const MAX_MULTIPLIER = -1; const MIN_COEFFICIENT = 1; const MAX_COEFFICIENT = 9; -const MAX_PROBLEM_COUNT = 9 * 9 * 9 * 2 * 2; +const MAX_TERM_COUNT = 4; + +export type VariableParenthesisTermCount = 2 | 3 | 4; interface VariableProblemSeed { multiplier: number; - operator: "+" | "-"; - firstTerm: VariableParenthesisTerm; - secondTerm: VariableParenthesisTerm; + terms: VariableParenthesisTerm[]; + operators: ("+" | "-")[]; } function randomInt(min: number, max: number) { @@ -52,6 +53,26 @@ function createVariableTerm(coefficient: number): VariableParenthesisTerm { }; } +function createUniqueConstantTerms( + baseCoefficient: number, + count: number, +): VariableParenthesisTerm[] { + const used = new Set(); + const terms: VariableParenthesisTerm[] = []; + let candidate = baseCoefficient; + + while (terms.length < count) { + const coef = ((candidate - 1) % MAX_COEFFICIENT) + 1; + if (!used.has(coef)) { + used.add(coef); + terms.push(createConstantTerm(coef)); + } + candidate += 1; + } + + return terms; +} + function termKey(term: VariableParenthesisTerm) { return `${term.kind}:${term.coefficient}`; } @@ -79,18 +100,21 @@ function formatParenthesisTermLatex(term: VariableParenthesisTerm) { } export function formatVariableExpressionLatex( - terms: [DistributedVariableTerm, DistributedVariableTerm], + terms: DistributedVariableTerm[], ) { - const [firstTerm, secondTerm] = terms; - const operator = secondTerm.coefficient < 0 ? "-" : "+"; - const positiveSecondTerm = { - ...secondTerm, - coefficient: Math.abs(secondTerm.coefficient), - }; + return terms + .map((term, index) => { + if (index === 0) return term.latex; - return `${firstTerm.latex} ${operator} ${formatVariableTermLatex( - positiveSecondTerm, - )}`; + const operator = term.coefficient < 0 ? "-" : "+"; + const positiveTerm = { + ...term, + coefficient: Math.abs(term.coefficient), + }; + + return `${operator} ${formatVariableTermLatex(positiveTerm)}`; + }) + .join(" "); } function distributeTerm( @@ -110,7 +134,7 @@ function distributeTerm( function createChoice( key: string, - termIndex: 0 | 1, + termIndex: number, term: DistributedVariableTerm, coefficient: number, ): VariableTermChoice { @@ -126,7 +150,7 @@ function createChoice( function createChoices( key: string, - termIndex: 0 | 1, + termIndex: number, term: DistributedVariableTerm, ) { const absoluteCoefficient = Math.abs(term.coefficient); @@ -139,40 +163,69 @@ function createChoices( } function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem { - const { multiplier, operator, firstTerm, secondTerm } = seed; + const { multiplier, terms, operators } = seed; + const [firstTerm, secondTerm] = terms; + const [operator] = operators; const key = [ multiplier, - termKey(firstTerm), - operator, - termKey(secondTerm), + ...terms.flatMap((term, index) => + index === 0 ? [termKey(term)] : [operators[index - 1], termKey(term)], + ), ].join(":"); - const correctTerms: [DistributedVariableTerm, DistributedVariableTerm] = [ - distributeTerm(multiplier, firstTerm, 1), - distributeTerm(multiplier, secondTerm, operator === "-" ? -1 : 1), - ]; - const choices: [VariableTermChoice[], VariableTermChoice[]] = [ - createChoices(key, 0, correctTerms[0]), - createChoices(key, 1, correctTerms[1]), - ]; + const correctTerms = terms.map((term, index) => + distributeTerm( + multiplier, + term, + index === 0 || operators[index - 1] === "+" ? 1 : -1, + ), + ); + const choices = correctTerms.map((term, index) => + createChoices(key, index, term), + ); return { id: `variable-parenthesis:${key}`, key, multiplier, + terms, + operators, operator, firstTerm, secondTerm, correctTerms, choices, - latexBefore: `${multiplier}\\left(${formatParenthesisTermLatex( - firstTerm, - )} ${operator} ${formatParenthesisTermLatex(secondTerm)}\\right)`, + latexBefore: `${multiplier}\\left(${terms + .map((term, index) => { + if (index === 0) return formatParenthesisTermLatex(term); + + return `${operators[index - 1]} ${formatParenthesisTermLatex(term)}`; + }) + .join(" ")}\\right)`, latexAfter: formatVariableExpressionLatex(correctTerms), }; } -function createAllProblemSeeds() { +function createOperatorCombinations(termCount: VariableParenthesisTermCount) { + const combinations: ("+" | "-")[][] = []; + + function addOperators(nextOperators: ("+" | "-")[]) { + if (nextOperators.length === termCount - 1) { + combinations.push(nextOperators); + return; + } + + addOperators([...nextOperators, "+"]); + addOperators([...nextOperators, "-"]); + } + + addOperators([]); + + return combinations; +} + +function createAllProblemSeeds(termCount: VariableParenthesisTermCount) { const seeds: VariableProblemSeed[] = []; + const operatorCombinations = createOperatorCombinations(termCount); for ( let multiplier = MIN_MULTIPLIER; @@ -189,19 +242,22 @@ function createAllProblemSeeds() { variableCoefficient <= MAX_COEFFICIENT; variableCoefficient += 1 ) { - for (const operator of ["+", "-"] as const) { - seeds.push({ - multiplier, - operator, - firstTerm: createVariableTerm(variableCoefficient), - secondTerm: createConstantTerm(constant), - }); - seeds.push({ - multiplier, - operator, - firstTerm: createConstantTerm(constant), - secondTerm: createVariableTerm(variableCoefficient), - }); + for (let variableIndex = 0; variableIndex < termCount; variableIndex += 1) { + const constantTerms = createUniqueConstantTerms(constant, termCount - 1); + let constantTermIndex = 0; + const terms = Array.from({ length: termCount }, (_, index) => + index === variableIndex + ? createVariableTerm(variableCoefficient) + : constantTerms[constantTermIndex++], + ); + + for (const operators of operatorCombinations) { + seeds.push({ + multiplier, + terms, + operators, + }); + } } } } @@ -210,43 +266,70 @@ function createAllProblemSeeds() { return seeds; } -const ALL_PROBLEM_SEEDS = createAllProblemSeeds(); +const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record< + VariableParenthesisTermCount, + VariableProblemSeed[] +> = { + 2: createAllProblemSeeds(2), + 3: createAllProblemSeeds(3), + 4: createAllProblemSeeds(4), +}; + +function assertTermCount( + termCount: number, +): asserts termCount is VariableParenthesisTermCount { + if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) { + throw new Error("termCount must be 2, 3, or 4."); + } +} + +export function generateVariableParenthesisProblem( + termCount: VariableParenthesisTermCount = 2, +): VariableParenthesisProblem { + assertTermCount(termCount); -export function generateVariableParenthesisProblem(): VariableParenthesisProblem { const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT); const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT); - const variableFirst = Math.random() < 0.5; - const operator = Math.random() < 0.5 ? "+" : "-"; + const variableIndex = randomInt(0, termCount - 1); + + const constantTerms = createUniqueConstantTerms(constant, termCount - 1); + let constantTermIndex = 0; return createProblem({ multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER), - operator, - firstTerm: variableFirst - ? createVariableTerm(variableCoefficient) - : createConstantTerm(constant), - secondTerm: variableFirst - ? createConstantTerm(constant) - : createVariableTerm(variableCoefficient), + terms: Array.from({ length: termCount }, (_, index) => + index === variableIndex + ? createVariableTerm(variableCoefficient) + : constantTerms[constantTermIndex++], + ), + operators: Array.from({ length: termCount - 1 }, () => + Math.random() < 0.5 ? "+" : "-", + ), }); } export function generateVariableParenthesisSession( count = 10, + termCount: VariableParenthesisTermCount = 2, ): VariableParenthesisProblem[] { if (!Number.isInteger(count) || count < 1) { throw new Error("count must be a positive integer."); } - if (count > MAX_PROBLEM_COUNT) { - throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`); + assertTermCount(termCount); + + const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount]; + + if (count > seeds.length) { + throw new Error(`count cannot exceed ${seeds.length}.`); } - return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem); + return shuffle(seeds).slice(0, count).map(createProblem); } function findSelectedChoice( problem: VariableParenthesisProblem, - termIndex: 0 | 1, + termIndex: number, choiceId: string | null, ): DistributedVariableTerm | null { if (!choiceId) return null; @@ -267,16 +350,14 @@ export function gradeVariableParenthesisAnswer( problem: VariableParenthesisProblem, answer: VariableUserAnswer, ): VariableGradeResult { - const userAnswer: [ - DistributedVariableTerm | null, - DistributedVariableTerm | null, - ] = [ - findSelectedChoice(problem, 0, answer.selectedChoiceIds[0]), - findSelectedChoice(problem, 1, answer.selectedChoiceIds[1]), - ]; + const userAnswer = problem.correctTerms.map((_, index) => + findSelectedChoice(problem, index, answer.selectedChoiceIds[index] ?? null), + ); const correct = - userAnswer[0]?.coefficient === problem.correctTerms[0].coefficient && - userAnswer[1]?.coefficient === problem.correctTerms[1].coefficient; + userAnswer.length === problem.correctTerms.length && + userAnswer.every( + (term, index) => term?.coefficient === problem.correctTerms[index].coefficient, + ); return { correct, diff --git a/lib/engine/variable-types.ts b/lib/engine/variable-types.ts index b860148..623209d 100644 --- a/lib/engine/variable-types.ts +++ b/lib/engine/variable-types.ts @@ -24,21 +24,23 @@ export interface VariableParenthesisProblem { id: string; key: string; multiplier: number; + terms: VariableParenthesisTerm[]; + operators: ("+" | "-")[]; operator: "+" | "-"; firstTerm: VariableParenthesisTerm; secondTerm: VariableParenthesisTerm; - correctTerms: [DistributedVariableTerm, DistributedVariableTerm]; - choices: [VariableTermChoice[], VariableTermChoice[]]; + correctTerms: DistributedVariableTerm[]; + choices: VariableTermChoice[][]; latexBefore: string; latexAfter: string; } export interface VariableUserAnswer { - selectedChoiceIds: [string | null, string | null]; + selectedChoiceIds: (string | null)[]; } export interface VariableGradeResult { correct: boolean; - userAnswer: [DistributedVariableTerm | null, DistributedVariableTerm | null]; - correctAnswer: [DistributedVariableTerm, DistributedVariableTerm]; + userAnswer: (DistributedVariableTerm | null)[]; + correctAnswer: DistributedVariableTerm[]; } diff --git a/store/gameStore.ts b/store/gameStore.ts index abbfe88..466478c 100644 --- a/store/gameStore.ts +++ b/store/gameStore.ts @@ -4,6 +4,7 @@ import { generateSession, gradeAnswer, } from "@/lib/engine/parenthesis-sign"; +import type { ParenthesisTermCount } from "@/lib/engine/parenthesis-sign"; import type { GradeResult, ParenthesisSignProblem, @@ -14,42 +15,56 @@ export interface GameSession { problems: ParenthesisSignProblem[]; currentIndex: number; results: (GradeResult | null)[]; + termCount: ParenthesisTermCount; } export type ProblemCount = 5 | 10; +export type MaxTermCount = ParenthesisTermCount; interface GameState { session: GameSession | null; selectedProblemCount: ProblemCount; + selectedMaxTermCount: MaxTermCount; setProblemCount: (count: ProblemCount) => void; - initSession: (count?: ProblemCount) => void; + setMaxTermCount: (count: MaxTermCount) => void; + initSession: (count?: ProblemCount, maxTermCount?: MaxTermCount) => void; submitAnswer: (answer: UserAnswer) => void; nextProblem: () => void; resetSession: () => void; } const DEFAULT_SESSION_COUNT: ProblemCount = 10; +const DEFAULT_MAX_TERM_COUNT: MaxTermCount = 2; -function createSession(count: ProblemCount = DEFAULT_SESSION_COUNT): GameSession { - const problems = generateSession(count); +function createSession( + count: ProblemCount = DEFAULT_SESSION_COUNT, + maxTermCount: MaxTermCount = DEFAULT_MAX_TERM_COUNT, +): GameSession { + const problems = generateSession(count, maxTermCount); return { problems, currentIndex: 0, results: problems.map(() => null), + termCount: maxTermCount, }; } export const useGameStore = create((set, get) => ({ session: null, selectedProblemCount: DEFAULT_SESSION_COUNT, + selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT, setProblemCount: (count) => { set({ selectedProblemCount: count }); }, - initSession: (count) => { + setMaxTermCount: (count) => { + set({ selectedMaxTermCount: count }); + }, + initSession: (count, maxTermCount) => { const selectedProblemCount = count ?? get().selectedProblemCount; + const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount; - set({ session: createSession(selectedProblemCount) }); + set({ session: createSession(selectedProblemCount, selectedMaxTermCount) }); }, submitAnswer: (answer) => { const { session } = get(); @@ -83,6 +98,11 @@ export const useGameStore = create((set, get) => ({ }); }, resetSession: () => { - set({ session: createSession(get().selectedProblemCount) }); + set({ + session: createSession( + get().selectedProblemCount, + get().selectedMaxTermCount, + ), + }); }, })); diff --git a/store/variableGameStore.ts b/store/variableGameStore.ts index aacb0bb..87aa19e 100644 --- a/store/variableGameStore.ts +++ b/store/variableGameStore.ts @@ -4,6 +4,7 @@ import { generateVariableParenthesisSession, gradeVariableParenthesisAnswer, } from "@/lib/engine/variable-parenthesis"; +import type { VariableParenthesisTermCount } from "@/lib/engine/variable-parenthesis"; import type { VariableGradeResult, VariableParenthesisProblem, @@ -14,44 +15,59 @@ export interface VariableGameSession { problems: VariableParenthesisProblem[]; currentIndex: number; results: (VariableGradeResult | null)[]; + termCount: VariableParenthesisTermCount; } export type VariableProblemCount = 5 | 10; +export type VariableMaxTermCount = VariableParenthesisTermCount; interface VariableGameState { session: VariableGameSession | null; selectedProblemCount: VariableProblemCount; + selectedMaxTermCount: VariableMaxTermCount; setProblemCount: (count: VariableProblemCount) => void; - initSession: (count?: VariableProblemCount) => void; + setMaxTermCount: (count: VariableMaxTermCount) => void; + initSession: ( + count?: VariableProblemCount, + maxTermCount?: VariableMaxTermCount, + ) => void; submitAnswer: (answer: VariableUserAnswer) => void; nextProblem: () => void; resetSession: () => void; } const DEFAULT_SESSION_COUNT: VariableProblemCount = 10; +const DEFAULT_MAX_TERM_COUNT: VariableMaxTermCount = 2; function createSession( count: VariableProblemCount = DEFAULT_SESSION_COUNT, + maxTermCount: VariableMaxTermCount = DEFAULT_MAX_TERM_COUNT, ): VariableGameSession { - const problems = generateVariableParenthesisSession(count); + const problems = generateVariableParenthesisSession(count, maxTermCount); return { problems, currentIndex: 0, results: problems.map(() => null), + termCount: maxTermCount, }; } export const useVariableGameStore = create((set, get) => ({ session: null, selectedProblemCount: DEFAULT_SESSION_COUNT, + selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT, setProblemCount: (count) => { set({ selectedProblemCount: count }); }, - initSession: (count) => { + setMaxTermCount: (count) => { + set({ selectedMaxTermCount: count }); + }, + initSession: (count, maxTermCount) => { const selectedProblemCount = count ?? get().selectedProblemCount; + const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount; - set({ session: createSession(selectedProblemCount) }); + set({ session: createSession(selectedProblemCount, selectedMaxTermCount) }); }, submitAnswer: (answer) => { const { session } = get(); @@ -85,6 +101,11 @@ export const useVariableGameStore = create((set, get) => ({ }); }, resetSession: () => { - set({ session: createSession(get().selectedProblemCount) }); + set({ + session: createSession( + get().selectedProblemCount, + get().selectedMaxTermCount, + ), + }); }, }));