괄호 안의 항을 2, 3, 4 항으로 설정하는 기능 추가

This commit is contained in:
2026-05-31 13:40:45 +09:00
parent e148a957ea
commit ac3c939051
21 changed files with 708 additions and 344 deletions
@@ -8,6 +8,10 @@ export default function NumberParenthesesClient() {
(state) => state.selectedProblemCount, (state) => state.selectedProblemCount,
); );
const setProblemCount = useGameStore((state) => state.setProblemCount); 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 initSession = useGameStore((state) => state.initSession);
return ( return (
@@ -16,10 +20,12 @@ export default function NumberParenthesesClient() {
title="숫자 괄호 풀기" title="숫자 괄호 풀기"
description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다." description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다."
notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다." notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다."
playHref={`/number-parentheses/play?count=${selectedProblemCount}`} playHref={`/number-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
selectedProblemCount={selectedProblemCount} selectedProblemCount={selectedProblemCount}
selectedMaxTermCount={selectedMaxTermCount}
onProblemCountChange={setProblemCount} onProblemCountChange={setProblemCount}
onStart={() => initSession(selectedProblemCount)} onMaxTermCountChange={setMaxTermCount}
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
/> />
); );
} }
@@ -16,7 +16,7 @@ import StageIndicator from "@/components/game/StageIndicator";
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device"; import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import type { ProblemCount } from "@/store/gameStore"; import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/; const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
@@ -30,16 +30,22 @@ function parseAnswer(value: string) {
interface NumberPlayClientProps { interface NumberPlayClientProps {
problemCount: ProblemCount; problemCount: ProblemCount;
maxTermCount: MaxTermCount;
} }
export default function NumberPlayClient({ export default function NumberPlayClient({
problemCount, problemCount,
maxTermCount,
}: NumberPlayClientProps) { }: NumberPlayClientProps) {
const session = useGameStore((state) => state.session); const session = useGameStore((state) => state.session);
const selectedProblemCount = useGameStore( const selectedProblemCount = useGameStore(
(state) => state.selectedProblemCount, (state) => state.selectedProblemCount,
); );
const setProblemCount = useGameStore((state) => state.setProblemCount); 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 initSession = useGameStore((state) => state.initSession);
const submitAnswer = useGameStore((state) => state.submitAnswer); const submitAnswer = useGameStore((state) => state.submitAnswer);
const nextProblem = useGameStore((state) => state.nextProblem); const nextProblem = useGameStore((state) => state.nextProblem);
@@ -48,8 +54,9 @@ export default function NumberPlayClient({
const problemCardRef = useRef<HTMLDivElement>(null); const problemCardRef = useRef<HTMLDivElement>(null);
const answerFormRef = useRef<NumberAnswerFormHandle>(null); const answerFormRef = useRef<NumberAnswerFormHandle>(null);
const [answerA, setAnswerA] = useState(""); const [answers, setAnswers] = useState<string[]>(() =>
const [answerB, setAnswerB] = useState(""); Array.from({ length: maxTermCount }, () => ""),
);
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0); const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
const [inputError, setInputError] = useState(""); const [inputError, setInputError] = useState("");
@@ -59,7 +66,8 @@ export default function NumberPlayClient({
const isComplete = Boolean( const isComplete = Boolean(
session && session.currentIndex >= session.problems.length, session && session.currentIndex >= session.problems.length,
); );
const sessionNeedsInit = sessionProblemCount !== problemCount; const sessionNeedsInit =
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
const correctCount = const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0; session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0; const totalCount = session?.problems.length ?? 0;
@@ -69,40 +77,53 @@ export default function NumberPlayClient({
setProblemCount(problemCount); setProblemCount(problemCount);
} }
if (selectedMaxTermCount !== maxTermCount) {
setMaxTermCount(maxTermCount);
}
if (sessionNeedsInit) { if (sessionNeedsInit) {
initSession(problemCount); initSession(problemCount, maxTermCount);
} }
}, [ }, [
initSession, initSession,
maxTermCount,
problemCount, problemCount,
selectedMaxTermCount,
selectedProblemCount, selectedProblemCount,
sessionProblemCount, sessionProblemCount,
sessionNeedsInit, sessionNeedsInit,
setMaxTermCount,
setProblemCount, setProblemCount,
]); ]);
useEffect(() => { useEffect(() => {
setAnswerA(""); setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => ""));
setAnswerB("");
setActiveInput(0); setActiveInput(0);
setInputError(""); setInputError("");
}, [currentProblem?.key]); }, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (currentResult || !currentProblem) return; if (currentResult || !currentProblem) return;
const parsedA = parseAnswer(answerA); const parsedAnswers = answers.map(parseAnswer);
const parsedB = parseAnswer(answerB);
if (parsedA === null || parsedB === null) { if (parsedAnswers.some((answer) => answer === null)) {
setInputError("두 칸 모두 실수로 입력하세요."); setInputError("모든 항을 실수로 입력하세요.");
return; return;
} }
setInputError(""); 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() { function handleNextProblem() {
@@ -184,10 +205,13 @@ export default function NumberPlayClient({
const submitted = currentResult !== null; const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const pendingAnswerLabels: [string, string] = [ const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
String(currentProblem.a), if (index === 0) return String(term);
String(currentProblem.operator === "-" ? -currentProblem.b : currentProblem.b),
]; return String(
currentProblem.operators[index - 1] === "-" ? -term : term,
);
});
return ( return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950"> <main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
@@ -214,9 +238,6 @@ export default function NumberPlayClient({
ref={problemCardRef} ref={problemCardRef}
className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8" className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"
> >
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl"> <div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={currentProblem.latexBefore} displayMode /> <KatexRenderer latex={currentProblem.latexBefore} displayMode />
</div> </div>
@@ -231,14 +252,12 @@ export default function NumberPlayClient({
{!submitted ? ( {!submitted ? (
<NumberAnswerForm <NumberAnswerForm
ref={answerFormRef} ref={answerFormRef}
answerA={answerA} answers={answers}
answerB={answerB}
activeInput={activeInput} activeInput={activeInput}
error={inputError} error={inputError}
pendingAnswerLabels={pendingAnswerLabels} pendingAnswerLabels={pendingAnswerLabels}
onActiveInputChange={setActiveInput} onActiveInputChange={setActiveInput}
onAnswerAChange={setAnswerA} onAnswerChange={handleAnswerChange}
onAnswerBChange={setAnswerB}
onInputFocus={scrollKatexToViewportTop} onInputFocus={scrollKatexToViewportTop}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
+17 -2
View File
@@ -1,9 +1,10 @@
import NumberPlayClient from "./NumberPlayClient"; import NumberPlayClient from "./NumberPlayClient";
import type { ProblemCount } from "@/store/gameStore"; import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
interface NumberPlayPageProps { interface NumberPlayPageProps {
searchParams?: { searchParams?: {
count?: string | string[]; count?: string | string[];
terms?: string | string[];
}; };
} }
@@ -15,8 +16,22 @@ function parseProblemCount(count?: string | string[]): ProblemCount {
return count === "5" ? 5 : 10; 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) { export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) {
return ( return (
<NumberPlayClient problemCount={parseProblemCount(searchParams?.count)} /> <NumberPlayClient
problemCount={parseProblemCount(searchParams?.count)}
maxTermCount={parseMaxTermCount(searchParams?.terms)}
/>
); );
} }
@@ -10,6 +10,12 @@ export default function VariableParenthesesClient() {
const setProblemCount = useVariableGameStore( const setProblemCount = useVariableGameStore(
(state) => state.setProblemCount, (state) => state.setProblemCount,
); );
const selectedMaxTermCount = useVariableGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useVariableGameStore(
(state) => state.setMaxTermCount,
);
const initSession = useVariableGameStore((state) => state.initSession); const initSession = useVariableGameStore((state) => state.initSession);
return ( return (
@@ -18,10 +24,12 @@ export default function VariableParenthesesClient() {
title="미지수 괄호 풀기" title="미지수 괄호 풀기"
description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다." description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다."
notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다." notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다."
playHref={`/variable-parentheses/play?count=${selectedProblemCount}`} playHref={`/variable-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
selectedProblemCount={selectedProblemCount} selectedProblemCount={selectedProblemCount}
selectedMaxTermCount={selectedMaxTermCount}
onProblemCountChange={setProblemCount} onProblemCountChange={setProblemCount}
onStart={() => initSession(selectedProblemCount)} onMaxTermCountChange={setMaxTermCount}
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
/> />
); );
} }
@@ -12,15 +12,18 @@ import KatexRenderer from "@/components/math/KatexRenderer";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
import { import {
useVariableGameStore, useVariableGameStore,
type VariableMaxTermCount,
type VariableProblemCount, type VariableProblemCount,
} from "@/store/variableGameStore"; } from "@/store/variableGameStore";
interface VariablePlayClientProps { interface VariablePlayClientProps {
problemCount: VariableProblemCount; problemCount: VariableProblemCount;
maxTermCount: VariableMaxTermCount;
} }
export default function VariablePlayClient({ export default function VariablePlayClient({
problemCount, problemCount,
maxTermCount,
}: VariablePlayClientProps) { }: VariablePlayClientProps) {
const session = useVariableGameStore((state) => state.session); const session = useVariableGameStore((state) => state.session);
const selectedProblemCount = useVariableGameStore( const selectedProblemCount = useVariableGameStore(
@@ -29,15 +32,21 @@ export default function VariablePlayClient({
const setProblemCount = useVariableGameStore( const setProblemCount = useVariableGameStore(
(state) => state.setProblemCount, (state) => state.setProblemCount,
); );
const selectedMaxTermCount = useVariableGameStore(
(state) => state.selectedMaxTermCount,
);
const setMaxTermCount = useVariableGameStore(
(state) => state.setMaxTermCount,
);
const initSession = useVariableGameStore((state) => state.initSession); const initSession = useVariableGameStore((state) => state.initSession);
const submitAnswer = useVariableGameStore((state) => state.submitAnswer); const submitAnswer = useVariableGameStore((state) => state.submitAnswer);
const nextProblem = useVariableGameStore((state) => state.nextProblem); const nextProblem = useVariableGameStore((state) => state.nextProblem);
const resetSession = useVariableGameStore((state) => state.resetSession); const resetSession = useVariableGameStore((state) => state.resetSession);
const [selectedChoiceIds, setSelectedChoiceIds] = useState< const [selectedChoiceIds, setSelectedChoiceIds] = useState<(string | null)[]>(
[string | null, string | null] () => Array.from({ length: maxTermCount }, () => null),
>([null, null]); );
const [activeTermIndex, setActiveTermIndex] = useState<0 | 1>(0); const [activeTermIndex, setActiveTermIndex] = useState(0);
const sessionProblemCount = session?.problems.length ?? null; const sessionProblemCount = session?.problems.length ?? null;
const currentProblem = session?.problems[session.currentIndex] ?? null; const currentProblem = session?.problems[session.currentIndex] ?? null;
@@ -45,7 +54,8 @@ export default function VariablePlayClient({
const isComplete = Boolean( const isComplete = Boolean(
session && session.currentIndex >= session.problems.length, session && session.currentIndex >= session.problems.length,
); );
const sessionNeedsInit = sessionProblemCount !== problemCount; const sessionNeedsInit =
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
const correctCount = const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0; session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0; const totalCount = session?.problems.length ?? 0;
@@ -55,40 +65,46 @@ export default function VariablePlayClient({
setProblemCount(problemCount); setProblemCount(problemCount);
} }
if (selectedMaxTermCount !== maxTermCount) {
setMaxTermCount(maxTermCount);
}
if (sessionNeedsInit) { if (sessionNeedsInit) {
initSession(problemCount); initSession(problemCount, maxTermCount);
} }
}, [ }, [
initSession, initSession,
maxTermCount,
problemCount, problemCount,
selectedMaxTermCount,
selectedProblemCount, selectedProblemCount,
sessionProblemCount, sessionProblemCount,
sessionNeedsInit, sessionNeedsInit,
setMaxTermCount,
setProblemCount, setProblemCount,
]); ]);
useEffect(() => { useEffect(() => {
setSelectedChoiceIds([null, null]); setSelectedChoiceIds(
Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => null),
);
setActiveTermIndex(0); setActiveTermIndex(0);
}, [currentProblem?.key]); }, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
function handleSelectChoice(termIndex: 0 | 1, choiceId: string) { function handleSelectChoice(termIndex: number, choiceId: string) {
const nextSelectedChoiceIds: [string | null, string | null] = [ const nextSelectedChoiceIds = [...selectedChoiceIds];
...selectedChoiceIds,
];
nextSelectedChoiceIds[termIndex] = choiceId; nextSelectedChoiceIds[termIndex] = choiceId;
setSelectedChoiceIds(nextSelectedChoiceIds); setSelectedChoiceIds(nextSelectedChoiceIds);
if (termIndex === 0) { if (termIndex < nextSelectedChoiceIds.length - 1) {
setActiveTermIndex(1); setActiveTermIndex(termIndex + 1);
return; return;
} }
if (currentResult || !currentProblem) return; if (currentResult || !currentProblem) return;
if ( if (
nextSelectedChoiceIds[0] === null || nextSelectedChoiceIds.some((selectedChoiceId) => selectedChoiceId === null)
nextSelectedChoiceIds[1] === null
) { ) {
return; return;
} }
@@ -140,16 +156,15 @@ export default function VariablePlayClient({
const submitted = currentResult !== null; const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const pendingChoiceLabels: [string, string] = [ const pendingChoiceLabels = currentProblem.terms.map((term, index) =>
formatVariableTermLatex(currentProblem.firstTerm),
formatVariableTermLatex({ formatVariableTermLatex({
...currentProblem.secondTerm, ...term,
coefficient: coefficient:
currentProblem.operator === "-" index > 0 && currentProblem.operators[index - 1] === "-"
? -currentProblem.secondTerm.coefficient ? -term.coefficient
: currentProblem.secondTerm.coefficient, : term.coefficient,
}), }),
]; );
return ( return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950"> <main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
@@ -173,9 +188,6 @@ export default function VariablePlayClient({
</div> </div>
<div className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"> <div className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8">
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl"> <div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={currentProblem.latexBefore} displayMode /> <KatexRenderer latex={currentProblem.latexBefore} displayMode />
</div> </div>
+20 -2
View File
@@ -1,9 +1,13 @@
import VariablePlayClient from "./VariablePlayClient"; import VariablePlayClient from "./VariablePlayClient";
import type { VariableProblemCount } from "@/store/variableGameStore"; import type {
VariableMaxTermCount,
VariableProblemCount,
} from "@/store/variableGameStore";
interface VariablePlayPageProps { interface VariablePlayPageProps {
searchParams?: { searchParams?: {
count?: string | string[]; count?: string | string[];
terms?: string | string[];
}; };
} }
@@ -15,10 +19,24 @@ function parseProblemCount(count?: string | string[]): VariableProblemCount {
return count === "5" ? 5 : 10; 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({ export default function VariablePlayPage({
searchParams, searchParams,
}: VariablePlayPageProps) { }: VariablePlayPageProps) {
return ( return (
<VariablePlayClient problemCount={parseProblemCount(searchParams?.count)} /> <VariablePlayClient
problemCount={parseProblemCount(searchParams?.count)}
maxTermCount={parseMaxTermCount(searchParams?.terms)}
/>
); );
} }
+5 -4
View File
@@ -23,10 +23,11 @@ export default function FeedbackPanel({
onNext, onNext,
}: FeedbackPanelProps) { }: FeedbackPanelProps) {
const nextButtonRef = useRef<HTMLButtonElement>(null); const nextButtonRef = useRef<HTMLButtonElement>(null);
const signedTerms = [ const signedTerms = problem.terms.map((term, index) => {
problem.a, if (index === 0) return term;
problem.operator === "-" ? -problem.b : problem.b,
] as const; return problem.operators[index - 1] === "-" ? -term : term;
});
const feedbackItems = signedTerms.map((term, index) => ({ const feedbackItems = signedTerms.map((term, index) => ({
formulaLatex: `${formatSignedNumber( formulaLatex: `${formatSignedNumber(
problem.multiplier, problem.multiplier,
+24 -43
View File
@@ -14,7 +14,7 @@ import type { FocusEvent, FormEvent } from "react";
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device"; import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
export type ActiveAnswerInput = 0 | 1; export type ActiveAnswerInput = number;
const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/; const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/;
const KEYPAD_KEYS = [ const KEYPAD_KEYS = [
@@ -36,13 +36,11 @@ export interface NumberAnswerFormHandle {
} }
interface NumberAnswerFormProps { interface NumberAnswerFormProps {
answerA: string; answers: string[];
answerB: string;
activeInput: ActiveAnswerInput; activeInput: ActiveAnswerInput;
error: string; error: string;
pendingAnswerLabels: [string, string]; pendingAnswerLabels: string[];
onAnswerAChange: (value: string) => void; onAnswerChange: (index: ActiveAnswerInput, value: string) => void;
onAnswerBChange: (value: string) => void;
onActiveInputChange: (input: ActiveAnswerInput) => void; onActiveInputChange: (input: ActiveAnswerInput) => void;
onInputBlur?: () => void; onInputBlur?: () => void;
onInputFocus?: () => void; onInputFocus?: () => void;
@@ -54,13 +52,11 @@ const NumberAnswerForm = forwardRef<
NumberAnswerFormProps NumberAnswerFormProps
>(function NumberAnswerForm( >(function NumberAnswerForm(
{ {
answerA, answers,
answerB,
activeInput, activeInput,
error, error,
pendingAnswerLabels, pendingAnswerLabels,
onAnswerAChange, onAnswerChange,
onAnswerBChange,
onActiveInputChange, onActiveInputChange,
onInputBlur, onInputBlur,
onInputFocus, onInputFocus,
@@ -75,17 +71,16 @@ const NumberAnswerForm = forwardRef<
const [keypadOpen, setKeypadOpen] = useState(false); const [keypadOpen, setKeypadOpen] = useState(false);
const [desktopInputDevice, setDesktopInputDevice] = useState(false); const [desktopInputDevice, setDesktopInputDevice] = useState(false);
const activeAnswer = activeInput === 0 ? answerA : answerB; const activeAnswer = answers[activeInput] ?? "";
const activeLabel = activeInput === 0 ? "첫째 항" : "둘째 항"; const activeLabel = `${activeInput + 1}`;
const activeDescription = `${activeLabel}의 답을 입력하세요.`; const activeDescription = `${activeLabel}의 답을 입력하세요.`;
const activeEnterKeyHint = activeInput === 0 ? "next" : "done"; const lastInputIndex = answers.length - 1;
const activeAnswerChange = const activeEnterKeyHint = activeInput < lastInputIndex ? "next" : "done";
activeInput === 0 ? onAnswerAChange : onAnswerBChange;
function updateAnswer(value: string) { function updateAnswer(value: string) {
if (!ANSWER_INPUT_PATTERN.test(value)) return; if (!ANSWER_INPUT_PATTERN.test(value)) return;
activeAnswerChange(value); onAnswerChange(activeInput, value);
} }
function handleInputFocus() { 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() { function toggleMinus() {
activeAnswerChange( onAnswerChange(
activeInput,
activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`, activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`,
); );
focusInput(); focusInput();
@@ -182,14 +170,14 @@ const NumberAnswerForm = forwardRef<
function moveToInput(input: ActiveAnswerInput, reset = false) { function moveToInput(input: ActiveAnswerInput, reset = false) {
onActiveInputChange(input); onActiveInputChange(input);
if (reset) { if (reset) {
setAnswer(input, ""); onAnswerChange(input, "");
} }
focusInput(); focusInput();
} }
function handleEnter() { function handleEnter() {
if (activeInput === 0) { if (activeInput < lastInputIndex) {
moveToInput(1); moveToInput(activeInput + 1);
return; return;
} }
@@ -197,18 +185,11 @@ const NumberAnswerForm = forwardRef<
focusInput(); focusInput();
} }
const answerButtons = [ const answerButtons = answers.map((value, index) => ({
{ index,
index: 0 as const, label: `${index + 1}`,
label: "첫째 항", value,
value: answerA, }));
},
{
index: 1 as const,
label: "둘째 항",
value: answerB,
},
];
return ( return (
<form <form
@@ -334,9 +315,9 @@ const NumberAnswerForm = forwardRef<
onPointerDown={(event) => event.preventDefault()} onPointerDown={(event) => event.preventDefault()}
onClick={handleEnter} onClick={handleEnter}
aria-label={ 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" 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"
> >
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export type PreparationProblemCount = 5 | 10; export type PreparationProblemCount = 5 | 10;
export type PreparationMaxTermCount = 2 | 3 | 4;
interface ParenthesesPreparationClientProps { interface ParenthesesPreparationClientProps {
eyebrow: string; eyebrow: string;
@@ -12,11 +13,33 @@ interface ParenthesesPreparationClientProps {
notice: string; notice: string;
playHref: string; playHref: string;
selectedProblemCount: PreparationProblemCount; selectedProblemCount: PreparationProblemCount;
selectedMaxTermCount: PreparationMaxTermCount;
onProblemCountChange: (count: PreparationProblemCount) => void; onProblemCountChange: (count: PreparationProblemCount) => void;
onMaxTermCountChange: (count: PreparationMaxTermCount) => void;
onStart: () => void; onStart: () => void;
} }
const problemCounts: PreparationProblemCount[] = [5, 10]; 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 = [ const bracketKinds = [
{ {
@@ -46,7 +69,9 @@ export default function ParenthesesPreparationClient({
notice, notice,
playHref, playHref,
selectedProblemCount, selectedProblemCount,
selectedMaxTermCount,
onProblemCountChange, onProblemCountChange,
onMaxTermCountChange,
onStart, onStart,
}: ParenthesesPreparationClientProps) { }: ParenthesesPreparationClientProps) {
const startLinkRef = useRef<HTMLAnchorElement>(null); const startLinkRef = useRef<HTMLAnchorElement>(null);
@@ -74,7 +99,7 @@ export default function ParenthesesPreparationClient({
</legend> </legend>
<div <div
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm" className={buttonGroupClassName}
role="group" role="group"
aria-label="문제 수 선택" aria-label="문제 수 선택"
> >
@@ -87,11 +112,7 @@ export default function ParenthesesPreparationClient({
type="button" type="button"
onClick={() => onProblemCountChange(count)} onClick={() => onProblemCountChange(count)}
aria-pressed={selected} 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 ${ className={optionButtonClassName({ selected })}
selected
? "bg-emerald-700 text-white"
: "text-slate-700 hover:bg-slate-100"
}`}
> >
{count} {count}
</button> </button>
@@ -100,12 +121,13 @@ export default function ParenthesesPreparationClient({
</div> </div>
</fieldset> </fieldset>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700"> <legend className="text-sm font-semibold text-slate-700">
</legend> </legend>
<div <div
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm" className={buttonGroupClassName}
role="group" role="group"
aria-label="괄호 종류 선택" aria-label="괄호 종류 선택"
> >
@@ -118,15 +140,10 @@ export default function ParenthesesPreparationClient({
aria-label={`${bracketKind.name} ${ aria-label={`${bracketKind.name} ${
bracketKind.disabled ? "준비 중" : "선택됨" bracketKind.disabled ? "준비 중" : "선택됨"
}`} }`}
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 ${ className={optionButtonClassName({
bracketKind.selected selected: bracketKind.selected,
? "bg-emerald-700 text-white" disabled: bracketKind.disabled,
: "text-slate-700 hover:bg-slate-100" })}
} ${
bracketKind.disabled
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
: ""
}`}
> >
{bracketKind.label} {bracketKind.label}
</button> </button>
@@ -134,6 +151,34 @@ export default function ParenthesesPreparationClient({
</div> </div>
</fieldset> </fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className={buttonGroupClassName}
role="group"
aria-label="괄호 안 최대 항 수 선택"
>
{maxTermCounts.map((count) => {
const selected = selectedMaxTermCount === count;
return (
<button
key={count}
type="button"
onClick={() => onMaxTermCountChange(count)}
aria-pressed={selected}
className={optionButtonClassName({ selected })}
>
{count}
</button>
);
})}
</div>
</fieldset>
</div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link <Link
ref={startLinkRef} ref={startLinkRef}
+32 -31
View File
@@ -15,50 +15,54 @@ interface ScoreSummaryProps {
onRestart: () => void; onRestart: () => void;
} }
function formatAnswerPairLatex(first: number, second: number) { function formatAnswerLatex(terms: number[]) {
const operator = second < 0 ? "-" : "+"; 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) { 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 ( return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1"> <span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={firstCorrect ? undefined : "text-red-700"}> {result.userAnswer.map((term, index) => {
<KatexRenderer latex={`${result.userAnswer[0]}`} output="mathml" /> const operator = term < 0 ? "-" : "+";
</span> const correct = term === result.correctAnswer[index];
<span className={secondCorrect ? undefined : "text-red-700"}> const displayLatex =
<KatexRenderer index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
latex={`${secondOperator} ${Math.abs(result.userAnswer[1])}`}
output="mathml" return (
/> <span key={index} className={correct ? undefined : "text-red-700"}>
<KatexRenderer latex={displayLatex} output="mathml" />
</span> </span>
);
})}
</span> </span>
); );
} }
function renderCorrectAnswerPair(result: GradeResult) { 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 ( return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1"> <span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={firstCorrect ? undefined : "font-bold text-blue-700"}> {result.correctAnswer.map((term, index) => {
<KatexRenderer latex={`${result.correctAnswer[0]}`} output="mathml" /> const operator = term < 0 ? "-" : "+";
</span> const correct = result.userAnswer[index] === term;
const displayLatex =
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
return (
<span <span
className={secondCorrect ? undefined : "font-bold text-blue-700"} key={index}
className={correct ? undefined : "font-bold text-blue-700"}
> >
<KatexRenderer <KatexRenderer latex={displayLatex} output="mathml" />
latex={`${secondOperator} ${Math.abs(result.correctAnswer[1])}`}
output="mathml"
/>
</span> </span>
);
})}
</span> </span>
); );
} }
@@ -102,10 +106,7 @@ export default function ScoreSummary({
<ol className="space-y-3"> <ol className="space-y-3">
{solvedItems.map(({ problem, result }, index) => { {solvedItems.map(({ problem, result }, index) => {
const userAnswerLatex = formatAnswerPairLatex( const userAnswerLatex = formatAnswerLatex(result.userAnswer);
result.userAnswer[0],
result.userAnswer[1],
);
return ( return (
<AnswerResultLine <AnswerResultLine
+9 -9
View File
@@ -6,11 +6,11 @@ import KatexRenderer from "@/components/math/KatexRenderer";
import type { VariableTermChoice } from "@/lib/engine/variable-types"; import type { VariableTermChoice } from "@/lib/engine/variable-types";
interface VariableChoiceAnswerFormProps { interface VariableChoiceAnswerFormProps {
choices: [VariableTermChoice[], VariableTermChoice[]]; choices: VariableTermChoice[][];
selectedChoiceIds: [string | null, string | null]; selectedChoiceIds: (string | null)[];
activeTermIndex: 0 | 1; activeTermIndex: number;
pendingChoiceLabels: [string, string]; pendingChoiceLabels: string[];
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void; onSelectChoice: (termIndex: number, choiceId: string) => void;
} }
export default function VariableChoiceAnswerForm({ export default function VariableChoiceAnswerForm({
@@ -29,7 +29,7 @@ export default function VariableChoiceAnswerForm({
firstChoiceRef.current?.focus(); firstChoiceRef.current?.focus();
}, [activeTermIndex]); }, [activeTermIndex]);
function getSelectedChoice(index: 0 | 1) { function getSelectedChoice(index: number) {
const selectedChoiceId = selectedChoiceIds[index]; const selectedChoiceId = selectedChoiceIds[index];
if (!selectedChoiceId) return null; if (!selectedChoiceId) return null;
@@ -42,7 +42,7 @@ export default function VariableChoiceAnswerForm({
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-semibold text-slate-500"> <p className="text-sm font-semibold text-slate-500">
{activeTermIndex === 0 ? "첫째 항" : "둘째 항"} . {activeTermIndex + 1} .
</p> </p>
</div> </div>
<ol <ol
@@ -51,7 +51,7 @@ export default function VariableChoiceAnswerForm({
> >
{selectedChoiceIds.map((choiceId, index) => { {selectedChoiceIds.map((choiceId, index) => {
const selected = activeTermIndex === index; const selected = activeTermIndex === index;
const selectedChoice = getSelectedChoice(index as 0 | 1); const selectedChoice = getSelectedChoice(index);
const completed = choiceId !== null && selectedChoice !== null; const completed = choiceId !== null && selectedChoice !== null;
const statusLabel = const statusLabel =
completed && selectedChoice completed && selectedChoice
@@ -95,7 +95,7 @@ export default function VariableChoiceAnswerForm({
<div <div
className="mt-5 grid gap-3 sm:grid-cols-2" className="mt-5 grid gap-3 sm:grid-cols-2"
role="group" role="group"
aria-label={`${activeTermIndex === 0 ? "첫째 항" : "둘째 항"} 보기 선택`} aria-label={`${activeTermIndex + 1} 보기 선택`}
> >
{activeChoices.map((choice, index) => { {activeChoices.map((choice, index) => {
const selected = selectedChoiceIds[activeTermIndex] === choice.id; const selected = selectedChoiceIds[activeTermIndex] === choice.id;
+11 -14
View File
@@ -6,7 +6,6 @@ import AnswerResultLine from "@/components/game/AnswerResultLine";
import type { import type {
VariableGradeResult, VariableGradeResult,
VariableParenthesisProblem, VariableParenthesisProblem,
VariableParenthesisTerm,
} from "@/lib/engine/variable-types"; } from "@/lib/engine/variable-types";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
@@ -21,12 +20,6 @@ function formatSignedNumber(value: number) {
return value < 0 ? `(${value})` : `${value}`; 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({ export default function VariableFeedbackPanel({
result, result,
problem, problem,
@@ -34,14 +27,18 @@ export default function VariableFeedbackPanel({
onNext, onNext,
}: VariableFeedbackPanelProps) { }: VariableFeedbackPanelProps) {
const nextButtonRef = useRef<HTMLButtonElement>(null); const nextButtonRef = useRef<HTMLButtonElement>(null);
const signedTerms = [ const signedTerms = problem.terms.map((term, index) =>
formatSignedTerm(problem.firstTerm, 1), formatVariableTermLatex({
formatSignedTerm(problem.secondTerm, problem.operator === "-" ? -1 : 1), ...term,
] as const; coefficient:
index > 0 && problem.operators[index - 1] === "-"
? -term.coefficient
: term.coefficient,
}),
);
const feedbackItems = signedTerms.map((termLatex, index) => { const feedbackItems = signedTerms.map((termLatex, index) => {
const termIndex = index as 0 | 1; const userAnswer = result.userAnswer[index];
const userAnswer = result.userAnswer[termIndex]; const correctAnswer = result.correctAnswer[index];
const correctAnswer = result.correctAnswer[termIndex];
return { return {
formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`, formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`,
+7 -11
View File
@@ -21,14 +21,10 @@ interface VariableScoreSummaryProps {
} }
function renderTermPair( function renderTermPair(
terms: [DistributedVariableTerm | null, DistributedVariableTerm | null], terms: (DistributedVariableTerm | null)[],
comparisonTerms: [DistributedVariableTerm | null, DistributedVariableTerm | null], comparisonTerms: (DistributedVariableTerm | null)[],
mode: "user" | "correct", mode: "user" | "correct",
) { ) {
const secondTerm = terms[1];
const secondOperator =
secondTerm && secondTerm.coefficient < 0 ? "-" : "+";
return ( return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1"> <span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{terms.map((term, index) => { {terms.map((term, index) => {
@@ -49,10 +45,9 @@ function renderTermPair(
: correct : correct
? undefined ? undefined
: "font-bold text-blue-700"; : "font-bold text-blue-700";
const operator = term.coefficient < 0 ? "-" : "+";
const displayLatex = const displayLatex =
index === 1 index === 0 ? term.latex : `${operator} ${term.latex.replace(/^-/, "")}`;
? `${secondOperator} ${term.latex.replace(/^-/, "")}`
: term.latex;
return ( return (
<span key={index} className={className}> <span key={index} className={className}>
@@ -111,8 +106,9 @@ export default function VariableScoreSummary({
key={problem.key} key={problem.key}
expressionLatex={problem.latexBefore} expressionLatex={problem.latexBefore}
userAnswerLatex={formatVariableExpressionLatex([ userAnswerLatex={formatVariableExpressionLatex([
result.userAnswer[0] ?? result.correctAnswer[0], ...result.correctAnswer.map(
result.userAnswer[1] ?? result.correctAnswer[1], (term, termIndex) => result.userAnswer[termIndex] ?? term,
),
])} ])}
correctAnswerLatex={problem.latexAfter} correctAnswerLatex={problem.latexAfter}
correct={result.correct} correct={result.correct}
+12 -1
View File
@@ -63,8 +63,19 @@ describe("parenthesis sign problem engine", () => {
expect(keys.size).toBe(10); 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", () => { it("rejects impossible session sizes", () => {
expect(() => generateSession(0)).toThrow(); expect(() => generateSession(0)).toThrow();
expect(() => generateSession(145)).toThrow(); expect(() => generateSession(1297)).toThrow();
}); });
}); });
+150 -39
View File
@@ -8,61 +8,138 @@ const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1; const MAX_MULTIPLIER = -1;
const MIN_TERM = 1; const MIN_TERM = 1;
const MAX_TERM = 9; const MAX_TERM = 9;
const MAX_PROBLEM_COUNT = 9 * 8 * 2; const MAX_TERM_COUNT = 4;
type ProblemSeed = Pick< export type ParenthesisTermCount = 2 | 3 | 4;
ParenthesisSignProblem,
"multiplier" | "a" | "operator" | "b" interface ProblemSeed {
>; multiplier: number;
terms: number[];
operators: ("+" | "-")[];
}
function randomInt(min: number, max: number) { function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
function formatLatexTermPair(first: number, second: number) { function formatLatexTerms(terms: number[]) {
const operator = second < 0 ? "-" : "+"; 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({ function createProblem({
multiplier, multiplier,
a, terms,
operator, operators,
b,
}: ProblemSeed): ParenthesisSignProblem { }: ProblemSeed): ParenthesisSignProblem {
const distributedA = multiplier * a; const signedTerms = terms.map((term, index) => {
const distributedB = operator === "-" ? multiplier * -b : multiplier * b; if (index === 0) return term;
const key = `${multiplier}:${a}:${operator}:${b}`;
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 { return {
id: `parenthesis-sign:${key}`, id: `parenthesis-sign:${key}`,
key, key,
multiplier, multiplier,
terms,
operators,
a, a,
operator, operator,
b, b,
distributedTerms,
distributedA, distributedA,
distributedB, distributedB,
latexBefore: `${multiplier}\\left(${a} ${operator} ${b}\\right)`, latexBefore: `${multiplier}\\left(${formatParenthesisTerms(
latexAfter: formatLatexTermPair(distributedA, distributedB), 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 seeds: ProblemSeed[] = [];
const termCombinations = createTermCombinations(termCount);
const operatorCombinations = createOperatorCombinations(termCount);
for ( for (
let multiplier = MIN_MULTIPLIER; let multiplier = MIN_MULTIPLIER;
multiplier <= MAX_MULTIPLIER; multiplier <= MAX_MULTIPLIER;
multiplier += 1 multiplier += 1
) { ) {
for (let a = MIN_TERM; a <= MAX_TERM; a += 1) { for (const terms of termCombinations) {
for (let b = MIN_TERM; b <= MAX_TERM; b += 1) { for (const operators of operatorCombinations) {
if (a === b) continue; seeds.push({ multiplier, terms, operators });
seeds.push({ multiplier, a, operator: "+", b });
seeds.push({ multiplier, a, operator: "-", b });
} }
} }
} }
@@ -70,7 +147,14 @@ function createAllProblemSeeds() {
return seeds; 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<T>(items: T[]) { function shuffle<T>(items: T[]) {
const shuffled = [...items]; const shuffled = [...items];
@@ -85,42 +169,69 @@ function shuffle<T>(items: T[]) {
return shuffled; return shuffled;
} }
export function generateProblem(): ParenthesisSignProblem { function assertTermCount(termCount: number): asserts termCount is ParenthesisTermCount {
const a = randomInt(MIN_TERM, MAX_TERM); if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
let b = randomInt(MIN_TERM, MAX_TERM); throw new Error("termCount must be 2, 3, or 4.");
}
}
while (a === b) { export function generateProblem(
b = randomInt(MIN_TERM, MAX_TERM); 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({ return createProblem({
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER), multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
a, terms,
operator: Math.random() < 0.5 ? "+" : "-", operators: Array.from({ length: termCount - 1 }, () =>
b, 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) { if (!Number.isInteger(count) || count < 1) {
throw new Error("count must be a positive integer."); throw new Error("count must be a positive integer.");
} }
if (count > MAX_PROBLEM_COUNT) { assertTermCount(termCount);
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
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( export function gradeAnswer(
problem: ParenthesisSignProblem, problem: ParenthesisSignProblem,
answer: UserAnswer, answer: UserAnswer,
): GradeResult { ): GradeResult {
const userAnswer =
answer.terms ?? (answer.a !== undefined && answer.b !== undefined
? [answer.a, answer.b]
: []);
return { return {
correct: correct:
answer.a === problem.distributedA && answer.b === problem.distributedB, userAnswer.length === problem.distributedTerms.length &&
userAnswer: [answer.a, answer.b], userAnswer.every((value, index) => value === problem.distributedTerms[index]),
correctAnswer: [problem.distributedA, problem.distributedB], userAnswer,
correctAnswer: problem.distributedTerms,
}; };
} }
+8 -4
View File
@@ -2,9 +2,12 @@ export interface ParenthesisSignProblem {
id: string; id: string;
key: string; key: string;
multiplier: number; multiplier: number;
terms: number[];
operators: ("+" | "-")[];
a: number; a: number;
operator: "+" | "-"; operator: "+" | "-";
b: number; b: number;
distributedTerms: number[];
distributedA: number; distributedA: number;
distributedB: number; distributedB: number;
latexBefore: string; latexBefore: string;
@@ -12,12 +15,13 @@ export interface ParenthesisSignProblem {
} }
export interface UserAnswer { export interface UserAnswer {
a: number; terms?: number[];
b: number; a?: number;
b?: number;
} }
export interface GradeResult { export interface GradeResult {
correct: boolean; correct: boolean;
userAnswer: [number, number]; userAnswer: number[];
correctAnswer: [number, number]; correctAnswer: number[];
} }
+17 -2
View File
@@ -10,7 +10,7 @@ import type { VariableParenthesisProblem } from "./variable-types";
function findCorrectChoiceIds(problem: VariableParenthesisProblem) { function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
return problem.correctTerms.map((term, index) => { return problem.correctTerms.map((term, index) => {
const choices = problem.choices[index as 0 | 1]; const choices = problem.choices[index];
const choice = choices.find( const choice = choices.find(
(item) => (item) =>
item.coefficient === term.coefficient && item.variable === term.variable, 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."); if (!choice) throw new Error("correct choice not found.");
return choice.id; return choice.id;
}) as [string, string]; });
} }
describe("variable parenthesis engine", () => { describe("variable parenthesis engine", () => {
@@ -97,6 +97,21 @@ describe("variable parenthesis engine", () => {
expect(keys.size).toBe(10); 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", () => { it("formats expressions with variable terms", () => {
expect( expect(
formatVariableExpressionLatex([ formatVariableExpressionLatex([
+145 -64
View File
@@ -11,13 +11,14 @@ const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1; const MAX_MULTIPLIER = -1;
const MIN_COEFFICIENT = 1; const MIN_COEFFICIENT = 1;
const MAX_COEFFICIENT = 9; 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 { interface VariableProblemSeed {
multiplier: number; multiplier: number;
operator: "+" | "-"; terms: VariableParenthesisTerm[];
firstTerm: VariableParenthesisTerm; operators: ("+" | "-")[];
secondTerm: VariableParenthesisTerm;
} }
function randomInt(min: number, max: number) { 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<number>();
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) { function termKey(term: VariableParenthesisTerm) {
return `${term.kind}:${term.coefficient}`; return `${term.kind}:${term.coefficient}`;
} }
@@ -79,18 +100,21 @@ function formatParenthesisTermLatex(term: VariableParenthesisTerm) {
} }
export function formatVariableExpressionLatex( export function formatVariableExpressionLatex(
terms: [DistributedVariableTerm, DistributedVariableTerm], terms: DistributedVariableTerm[],
) { ) {
const [firstTerm, secondTerm] = terms; return terms
const operator = secondTerm.coefficient < 0 ? "-" : "+"; .map((term, index) => {
const positiveSecondTerm = { if (index === 0) return term.latex;
...secondTerm,
coefficient: Math.abs(secondTerm.coefficient), const operator = term.coefficient < 0 ? "-" : "+";
const positiveTerm = {
...term,
coefficient: Math.abs(term.coefficient),
}; };
return `${firstTerm.latex} ${operator} ${formatVariableTermLatex( return `${operator} ${formatVariableTermLatex(positiveTerm)}`;
positiveSecondTerm, })
)}`; .join(" ");
} }
function distributeTerm( function distributeTerm(
@@ -110,7 +134,7 @@ function distributeTerm(
function createChoice( function createChoice(
key: string, key: string,
termIndex: 0 | 1, termIndex: number,
term: DistributedVariableTerm, term: DistributedVariableTerm,
coefficient: number, coefficient: number,
): VariableTermChoice { ): VariableTermChoice {
@@ -126,7 +150,7 @@ function createChoice(
function createChoices( function createChoices(
key: string, key: string,
termIndex: 0 | 1, termIndex: number,
term: DistributedVariableTerm, term: DistributedVariableTerm,
) { ) {
const absoluteCoefficient = Math.abs(term.coefficient); const absoluteCoefficient = Math.abs(term.coefficient);
@@ -139,40 +163,69 @@ function createChoices(
} }
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem { 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 = [ const key = [
multiplier, multiplier,
termKey(firstTerm), ...terms.flatMap((term, index) =>
operator, index === 0 ? [termKey(term)] : [operators[index - 1], termKey(term)],
termKey(secondTerm), ),
].join(":"); ].join(":");
const correctTerms: [DistributedVariableTerm, DistributedVariableTerm] = [ const correctTerms = terms.map((term, index) =>
distributeTerm(multiplier, firstTerm, 1), distributeTerm(
distributeTerm(multiplier, secondTerm, operator === "-" ? -1 : 1), multiplier,
]; term,
const choices: [VariableTermChoice[], VariableTermChoice[]] = [ index === 0 || operators[index - 1] === "+" ? 1 : -1,
createChoices(key, 0, correctTerms[0]), ),
createChoices(key, 1, correctTerms[1]), );
]; const choices = correctTerms.map((term, index) =>
createChoices(key, index, term),
);
return { return {
id: `variable-parenthesis:${key}`, id: `variable-parenthesis:${key}`,
key, key,
multiplier, multiplier,
terms,
operators,
operator, operator,
firstTerm, firstTerm,
secondTerm, secondTerm,
correctTerms, correctTerms,
choices, choices,
latexBefore: `${multiplier}\\left(${formatParenthesisTermLatex( latexBefore: `${multiplier}\\left(${terms
firstTerm, .map((term, index) => {
)} ${operator} ${formatParenthesisTermLatex(secondTerm)}\\right)`, if (index === 0) return formatParenthesisTermLatex(term);
return `${operators[index - 1]} ${formatParenthesisTermLatex(term)}`;
})
.join(" ")}\\right)`,
latexAfter: formatVariableExpressionLatex(correctTerms), 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 seeds: VariableProblemSeed[] = [];
const operatorCombinations = createOperatorCombinations(termCount);
for ( for (
let multiplier = MIN_MULTIPLIER; let multiplier = MIN_MULTIPLIER;
@@ -189,64 +242,94 @@ function createAllProblemSeeds() {
variableCoefficient <= MAX_COEFFICIENT; variableCoefficient <= MAX_COEFFICIENT;
variableCoefficient += 1 variableCoefficient += 1
) { ) {
for (const operator of ["+", "-"] as const) { 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({ seeds.push({
multiplier, multiplier,
operator, terms,
firstTerm: createVariableTerm(variableCoefficient), operators,
secondTerm: createConstantTerm(constant),
});
seeds.push({
multiplier,
operator,
firstTerm: createConstantTerm(constant),
secondTerm: createVariableTerm(variableCoefficient),
}); });
} }
} }
} }
} }
}
return seeds; 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 constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT); const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
const variableFirst = Math.random() < 0.5; const variableIndex = randomInt(0, termCount - 1);
const operator = Math.random() < 0.5 ? "+" : "-";
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
let constantTermIndex = 0;
return createProblem({ return createProblem({
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER), multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
operator, terms: Array.from({ length: termCount }, (_, index) =>
firstTerm: variableFirst index === variableIndex
? createVariableTerm(variableCoefficient) ? createVariableTerm(variableCoefficient)
: createConstantTerm(constant), : constantTerms[constantTermIndex++],
secondTerm: variableFirst ),
? createConstantTerm(constant) operators: Array.from({ length: termCount - 1 }, () =>
: createVariableTerm(variableCoefficient), Math.random() < 0.5 ? "+" : "-",
),
}); });
} }
export function generateVariableParenthesisSession( export function generateVariableParenthesisSession(
count = 10, count = 10,
termCount: VariableParenthesisTermCount = 2,
): VariableParenthesisProblem[] { ): VariableParenthesisProblem[] {
if (!Number.isInteger(count) || count < 1) { if (!Number.isInteger(count) || count < 1) {
throw new Error("count must be a positive integer."); throw new Error("count must be a positive integer.");
} }
if (count > MAX_PROBLEM_COUNT) { assertTermCount(termCount);
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
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( function findSelectedChoice(
problem: VariableParenthesisProblem, problem: VariableParenthesisProblem,
termIndex: 0 | 1, termIndex: number,
choiceId: string | null, choiceId: string | null,
): DistributedVariableTerm | null { ): DistributedVariableTerm | null {
if (!choiceId) return null; if (!choiceId) return null;
@@ -267,16 +350,14 @@ export function gradeVariableParenthesisAnswer(
problem: VariableParenthesisProblem, problem: VariableParenthesisProblem,
answer: VariableUserAnswer, answer: VariableUserAnswer,
): VariableGradeResult { ): VariableGradeResult {
const userAnswer: [ const userAnswer = problem.correctTerms.map((_, index) =>
DistributedVariableTerm | null, findSelectedChoice(problem, index, answer.selectedChoiceIds[index] ?? null),
DistributedVariableTerm | null, );
] = [
findSelectedChoice(problem, 0, answer.selectedChoiceIds[0]),
findSelectedChoice(problem, 1, answer.selectedChoiceIds[1]),
];
const correct = const correct =
userAnswer[0]?.coefficient === problem.correctTerms[0].coefficient && userAnswer.length === problem.correctTerms.length &&
userAnswer[1]?.coefficient === problem.correctTerms[1].coefficient; userAnswer.every(
(term, index) => term?.coefficient === problem.correctTerms[index].coefficient,
);
return { return {
correct, correct,
+7 -5
View File
@@ -24,21 +24,23 @@ export interface VariableParenthesisProblem {
id: string; id: string;
key: string; key: string;
multiplier: number; multiplier: number;
terms: VariableParenthesisTerm[];
operators: ("+" | "-")[];
operator: "+" | "-"; operator: "+" | "-";
firstTerm: VariableParenthesisTerm; firstTerm: VariableParenthesisTerm;
secondTerm: VariableParenthesisTerm; secondTerm: VariableParenthesisTerm;
correctTerms: [DistributedVariableTerm, DistributedVariableTerm]; correctTerms: DistributedVariableTerm[];
choices: [VariableTermChoice[], VariableTermChoice[]]; choices: VariableTermChoice[][];
latexBefore: string; latexBefore: string;
latexAfter: string; latexAfter: string;
} }
export interface VariableUserAnswer { export interface VariableUserAnswer {
selectedChoiceIds: [string | null, string | null]; selectedChoiceIds: (string | null)[];
} }
export interface VariableGradeResult { export interface VariableGradeResult {
correct: boolean; correct: boolean;
userAnswer: [DistributedVariableTerm | null, DistributedVariableTerm | null]; userAnswer: (DistributedVariableTerm | null)[];
correctAnswer: [DistributedVariableTerm, DistributedVariableTerm]; correctAnswer: DistributedVariableTerm[];
} }
+26 -6
View File
@@ -4,6 +4,7 @@ import {
generateSession, generateSession,
gradeAnswer, gradeAnswer,
} from "@/lib/engine/parenthesis-sign"; } from "@/lib/engine/parenthesis-sign";
import type { ParenthesisTermCount } from "@/lib/engine/parenthesis-sign";
import type { import type {
GradeResult, GradeResult,
ParenthesisSignProblem, ParenthesisSignProblem,
@@ -14,42 +15,56 @@ export interface GameSession {
problems: ParenthesisSignProblem[]; problems: ParenthesisSignProblem[];
currentIndex: number; currentIndex: number;
results: (GradeResult | null)[]; results: (GradeResult | null)[];
termCount: ParenthesisTermCount;
} }
export type ProblemCount = 5 | 10; export type ProblemCount = 5 | 10;
export type MaxTermCount = ParenthesisTermCount;
interface GameState { interface GameState {
session: GameSession | null; session: GameSession | null;
selectedProblemCount: ProblemCount; selectedProblemCount: ProblemCount;
selectedMaxTermCount: MaxTermCount;
setProblemCount: (count: ProblemCount) => void; setProblemCount: (count: ProblemCount) => void;
initSession: (count?: ProblemCount) => void; setMaxTermCount: (count: MaxTermCount) => void;
initSession: (count?: ProblemCount, maxTermCount?: MaxTermCount) => void;
submitAnswer: (answer: UserAnswer) => void; submitAnswer: (answer: UserAnswer) => void;
nextProblem: () => void; nextProblem: () => void;
resetSession: () => void; resetSession: () => void;
} }
const DEFAULT_SESSION_COUNT: ProblemCount = 10; const DEFAULT_SESSION_COUNT: ProblemCount = 10;
const DEFAULT_MAX_TERM_COUNT: MaxTermCount = 2;
function createSession(count: ProblemCount = DEFAULT_SESSION_COUNT): GameSession { function createSession(
const problems = generateSession(count); count: ProblemCount = DEFAULT_SESSION_COUNT,
maxTermCount: MaxTermCount = DEFAULT_MAX_TERM_COUNT,
): GameSession {
const problems = generateSession(count, maxTermCount);
return { return {
problems, problems,
currentIndex: 0, currentIndex: 0,
results: problems.map(() => null), results: problems.map(() => null),
termCount: maxTermCount,
}; };
} }
export const useGameStore = create<GameState>((set, get) => ({ export const useGameStore = create<GameState>((set, get) => ({
session: null, session: null,
selectedProblemCount: DEFAULT_SESSION_COUNT, selectedProblemCount: DEFAULT_SESSION_COUNT,
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
setProblemCount: (count) => { setProblemCount: (count) => {
set({ selectedProblemCount: count }); set({ selectedProblemCount: count });
}, },
initSession: (count) => { setMaxTermCount: (count) => {
set({ selectedMaxTermCount: count });
},
initSession: (count, maxTermCount) => {
const selectedProblemCount = count ?? get().selectedProblemCount; const selectedProblemCount = count ?? get().selectedProblemCount;
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
set({ session: createSession(selectedProblemCount) }); set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
}, },
submitAnswer: (answer) => { submitAnswer: (answer) => {
const { session } = get(); const { session } = get();
@@ -83,6 +98,11 @@ export const useGameStore = create<GameState>((set, get) => ({
}); });
}, },
resetSession: () => { resetSession: () => {
set({ session: createSession(get().selectedProblemCount) }); set({
session: createSession(
get().selectedProblemCount,
get().selectedMaxTermCount,
),
});
}, },
})); }));
+26 -5
View File
@@ -4,6 +4,7 @@ import {
generateVariableParenthesisSession, generateVariableParenthesisSession,
gradeVariableParenthesisAnswer, gradeVariableParenthesisAnswer,
} from "@/lib/engine/variable-parenthesis"; } from "@/lib/engine/variable-parenthesis";
import type { VariableParenthesisTermCount } from "@/lib/engine/variable-parenthesis";
import type { import type {
VariableGradeResult, VariableGradeResult,
VariableParenthesisProblem, VariableParenthesisProblem,
@@ -14,44 +15,59 @@ export interface VariableGameSession {
problems: VariableParenthesisProblem[]; problems: VariableParenthesisProblem[];
currentIndex: number; currentIndex: number;
results: (VariableGradeResult | null)[]; results: (VariableGradeResult | null)[];
termCount: VariableParenthesisTermCount;
} }
export type VariableProblemCount = 5 | 10; export type VariableProblemCount = 5 | 10;
export type VariableMaxTermCount = VariableParenthesisTermCount;
interface VariableGameState { interface VariableGameState {
session: VariableGameSession | null; session: VariableGameSession | null;
selectedProblemCount: VariableProblemCount; selectedProblemCount: VariableProblemCount;
selectedMaxTermCount: VariableMaxTermCount;
setProblemCount: (count: VariableProblemCount) => void; setProblemCount: (count: VariableProblemCount) => void;
initSession: (count?: VariableProblemCount) => void; setMaxTermCount: (count: VariableMaxTermCount) => void;
initSession: (
count?: VariableProblemCount,
maxTermCount?: VariableMaxTermCount,
) => void;
submitAnswer: (answer: VariableUserAnswer) => void; submitAnswer: (answer: VariableUserAnswer) => void;
nextProblem: () => void; nextProblem: () => void;
resetSession: () => void; resetSession: () => void;
} }
const DEFAULT_SESSION_COUNT: VariableProblemCount = 10; const DEFAULT_SESSION_COUNT: VariableProblemCount = 10;
const DEFAULT_MAX_TERM_COUNT: VariableMaxTermCount = 2;
function createSession( function createSession(
count: VariableProblemCount = DEFAULT_SESSION_COUNT, count: VariableProblemCount = DEFAULT_SESSION_COUNT,
maxTermCount: VariableMaxTermCount = DEFAULT_MAX_TERM_COUNT,
): VariableGameSession { ): VariableGameSession {
const problems = generateVariableParenthesisSession(count); const problems = generateVariableParenthesisSession(count, maxTermCount);
return { return {
problems, problems,
currentIndex: 0, currentIndex: 0,
results: problems.map(() => null), results: problems.map(() => null),
termCount: maxTermCount,
}; };
} }
export const useVariableGameStore = create<VariableGameState>((set, get) => ({ export const useVariableGameStore = create<VariableGameState>((set, get) => ({
session: null, session: null,
selectedProblemCount: DEFAULT_SESSION_COUNT, selectedProblemCount: DEFAULT_SESSION_COUNT,
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
setProblemCount: (count) => { setProblemCount: (count) => {
set({ selectedProblemCount: count }); set({ selectedProblemCount: count });
}, },
initSession: (count) => { setMaxTermCount: (count) => {
set({ selectedMaxTermCount: count });
},
initSession: (count, maxTermCount) => {
const selectedProblemCount = count ?? get().selectedProblemCount; const selectedProblemCount = count ?? get().selectedProblemCount;
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
set({ session: createSession(selectedProblemCount) }); set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
}, },
submitAnswer: (answer) => { submitAnswer: (answer) => {
const { session } = get(); const { session } = get();
@@ -85,6 +101,11 @@ export const useVariableGameStore = create<VariableGameState>((set, get) => ({
}); });
}, },
resetSession: () => { resetSession: () => {
set({ session: createSession(get().selectedProblemCount) }); set({
session: createSession(
get().selectedProblemCount,
get().selectedMaxTermCount,
),
});
}, },
})); }));