괄호 안의 항을 2, 3, 4 항으로 설정하는 기능 추가
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const answerFormRef = useRef<NumberAnswerFormHandle>(null);
|
||||
|
||||
const [answerA, setAnswerA] = useState("");
|
||||
const [answerB, setAnswerB] = useState("");
|
||||
const [answers, setAnswers] = useState<string[]>(() =>
|
||||
Array.from({ length: maxTermCount }, () => ""),
|
||||
);
|
||||
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<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}
|
||||
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">
|
||||
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
|
||||
</div>
|
||||
@@ -231,14 +252,12 @@ export default function NumberPlayClient({
|
||||
{!submitted ? (
|
||||
<NumberAnswerForm
|
||||
ref={answerFormRef}
|
||||
answerA={answerA}
|
||||
answerB={answerB}
|
||||
answers={answers}
|
||||
activeInput={activeInput}
|
||||
error={inputError}
|
||||
pendingAnswerLabels={pendingAnswerLabels}
|
||||
onActiveInputChange={setActiveInput}
|
||||
onAnswerAChange={setAnswerA}
|
||||
onAnswerBChange={setAnswerB}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
onInputFocus={scrollKatexToViewportTop}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<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(
|
||||
(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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 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">
|
||||
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<VariablePlayClient problemCount={parseProblemCount(searchParams?.count)} />
|
||||
<VariablePlayClient
|
||||
problemCount={parseProblemCount(searchParams?.count)}
|
||||
maxTermCount={parseMaxTermCount(searchParams?.terms)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ export default function FeedbackPanel({
|
||||
onNext,
|
||||
}: FeedbackPanelProps) {
|
||||
const nextButtonRef = useRef<HTMLButtonElement>(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,
|
||||
|
||||
@@ -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 (
|
||||
<form
|
||||
@@ -334,9 +315,9 @@ const NumberAnswerForm = forwardRef<
|
||||
onPointerDown={(event) => 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"
|
||||
>
|
||||
|
||||
@@ -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<HTMLAnchorElement>(null);
|
||||
@@ -74,7 +99,7 @@ export default function ParenthesesPreparationClient({
|
||||
문제 수
|
||||
</legend>
|
||||
<div
|
||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
||||
className={buttonGroupClassName}
|
||||
role="group"
|
||||
aria-label="문제 수 선택"
|
||||
>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -100,12 +121,13 @@ export default function ParenthesesPreparationClient({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-semibold text-slate-700">
|
||||
괄호 종류
|
||||
</legend>
|
||||
<div
|
||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
||||
className={buttonGroupClassName}
|
||||
role="group"
|
||||
aria-label="괄호 종류 선택"
|
||||
>
|
||||
@@ -118,15 +140,10 @@ export default function ParenthesesPreparationClient({
|
||||
aria-label={`${bracketKind.name} ${
|
||||
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 ${
|
||||
bracketKind.selected
|
||||
? "bg-emerald-700 text-white"
|
||||
: "text-slate-700 hover:bg-slate-100"
|
||||
} ${
|
||||
bracketKind.disabled
|
||||
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
|
||||
: ""
|
||||
}`}
|
||||
className={optionButtonClassName({
|
||||
selected: bracketKind.selected,
|
||||
disabled: bracketKind.disabled,
|
||||
})}
|
||||
>
|
||||
{bracketKind.label}
|
||||
</button>
|
||||
@@ -134,6 +151,34 @@ export default function ParenthesesPreparationClient({
|
||||
</div>
|
||||
</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">
|
||||
<Link
|
||||
ref={startLinkRef}
|
||||
|
||||
@@ -15,50 +15,54 @@ interface ScoreSummaryProps {
|
||||
onRestart: () => 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 (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={firstCorrect ? undefined : "text-red-700"}>
|
||||
<KatexRenderer latex={`${result.userAnswer[0]}`} output="mathml" />
|
||||
</span>
|
||||
<span className={secondCorrect ? undefined : "text-red-700"}>
|
||||
<KatexRenderer
|
||||
latex={`${secondOperator} ${Math.abs(result.userAnswer[1])}`}
|
||||
output="mathml"
|
||||
/>
|
||||
{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 (
|
||||
<span key={index} className={correct ? undefined : "text-red-700"}>
|
||||
<KatexRenderer latex={displayLatex} output="mathml" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={firstCorrect ? undefined : "font-bold text-blue-700"}>
|
||||
<KatexRenderer latex={`${result.correctAnswer[0]}`} output="mathml" />
|
||||
</span>
|
||||
{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 (
|
||||
<span
|
||||
className={secondCorrect ? undefined : "font-bold text-blue-700"}
|
||||
key={index}
|
||||
className={correct ? undefined : "font-bold text-blue-700"}
|
||||
>
|
||||
<KatexRenderer
|
||||
latex={`${secondOperator} ${Math.abs(result.correctAnswer[1])}`}
|
||||
output="mathml"
|
||||
/>
|
||||
<KatexRenderer latex={displayLatex} output="mathml" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -102,10 +106,7 @@ export default function ScoreSummary({
|
||||
|
||||
<ol className="space-y-3">
|
||||
{solvedItems.map(({ problem, result }, index) => {
|
||||
const userAnswerLatex = formatAnswerPairLatex(
|
||||
result.userAnswer[0],
|
||||
result.userAnswer[1],
|
||||
);
|
||||
const userAnswerLatex = formatAnswerLatex(result.userAnswer);
|
||||
|
||||
return (
|
||||
<AnswerResultLine
|
||||
|
||||
@@ -6,11 +6,11 @@ import KatexRenderer from "@/components/math/KatexRenderer";
|
||||
import type { VariableTermChoice } from "@/lib/engine/variable-types";
|
||||
|
||||
interface VariableChoiceAnswerFormProps {
|
||||
choices: [VariableTermChoice[], VariableTermChoice[]];
|
||||
selectedChoiceIds: [string | null, string | null];
|
||||
activeTermIndex: 0 | 1;
|
||||
pendingChoiceLabels: [string, string];
|
||||
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => 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({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-500">
|
||||
{activeTermIndex === 0 ? "첫째 항" : "둘째 항"}을 고르세요.
|
||||
{activeTermIndex + 1}항을 고르세요.
|
||||
</p>
|
||||
</div>
|
||||
<ol
|
||||
@@ -51,7 +51,7 @@ export default function VariableChoiceAnswerForm({
|
||||
>
|
||||
{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({
|
||||
<div
|
||||
className="mt-5 grid gap-3 sm:grid-cols-2"
|
||||
role="group"
|
||||
aria-label={`${activeTermIndex === 0 ? "첫째 항" : "둘째 항"} 보기 선택`}
|
||||
aria-label={`${activeTermIndex + 1}항 보기 선택`}
|
||||
>
|
||||
{activeChoices.map((choice, index) => {
|
||||
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
|
||||
|
||||
@@ -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<HTMLButtonElement>(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}`,
|
||||
|
||||
@@ -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 (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{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 (
|
||||
<span key={index} className={className}>
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+150
-39
@@ -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<T>(items: T[]) {
|
||||
const shuffled = [...items];
|
||||
@@ -85,42 +169,69 @@ function shuffle<T>(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,
|
||||
};
|
||||
}
|
||||
|
||||
+8
-4
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<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) {
|
||||
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;
|
||||
|
||||
const operator = term.coefficient < 0 ? "-" : "+";
|
||||
const positiveTerm = {
|
||||
...term,
|
||||
coefficient: Math.abs(term.coefficient),
|
||||
};
|
||||
|
||||
return `${firstTerm.latex} ${operator} ${formatVariableTermLatex(
|
||||
positiveSecondTerm,
|
||||
)}`;
|
||||
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,64 +242,94 @@ function createAllProblemSeeds() {
|
||||
variableCoefficient <= MAX_COEFFICIENT;
|
||||
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({
|
||||
multiplier,
|
||||
operator,
|
||||
firstTerm: createVariableTerm(variableCoefficient),
|
||||
secondTerm: createConstantTerm(constant),
|
||||
});
|
||||
seeds.push({
|
||||
multiplier,
|
||||
operator,
|
||||
firstTerm: createConstantTerm(constant),
|
||||
secondTerm: createVariableTerm(variableCoefficient),
|
||||
terms,
|
||||
operators,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
terms: Array.from({ length: termCount }, (_, index) =>
|
||||
index === variableIndex
|
||||
? createVariableTerm(variableCoefficient)
|
||||
: createConstantTerm(constant),
|
||||
secondTerm: variableFirst
|
||||
? createConstantTerm(constant)
|
||||
: 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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
+26
-6
@@ -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<GameState>((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<GameState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
resetSession: () => {
|
||||
set({ session: createSession(get().selectedProblemCount) });
|
||||
set({
|
||||
session: createSession(
|
||||
get().selectedProblemCount,
|
||||
get().selectedMaxTermCount,
|
||||
),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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<VariableGameState>((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<VariableGameState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
resetSession: () => {
|
||||
set({ session: createSession(get().selectedProblemCount) });
|
||||
set({
|
||||
session: createSession(
|
||||
get().selectedProblemCount,
|
||||
get().selectedMaxTermCount,
|
||||
),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user