fix: prevent regrouping problems in no-regrouping skills and enable progressive difficulty toggle
Problem Generation Fixes: - Fixed validation.ts to preserve pAnyStart=0 and pAllStart=0 values (were being overridden to defaults) - Fixed problemGenerator.ts category switching logic to respect difficulty constraints - When pAny=0 (no regrouping), prevent switching to regrouping categories when stuck - Fixed last-resort fallback to use appropriate generator instead of random numbers - Added operator property to last-resort problems UI Improvements: - Made progressive difficulty toggle available in all modes (smart, manual, mastery) - Default interpolate to false for mastery mode, true for others - Removed forced interpolate=false in MasteryModePanel to respect user preference - Created mini/compact skill panes for mixed mode (reduced padding, font sizes, spacing) The root cause was two bugs: 1. Category switching after 50 failed attempts would randomly pick ANY category including regrouping 2. Last-resort code generated random numbers without checking regrouping constraints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { OperatorSection } from './config-panel/OperatorSection'
|
||||
import { ProgressiveDifficultyToggle } from './config-panel/ProgressiveDifficultyToggle'
|
||||
import { SmartModeControls } from './config-panel/SmartModeControls'
|
||||
import { ManualModeControls } from './config-panel/ManualModeControls'
|
||||
import { ProgressionModePanel } from './config-panel/ProgressionModePanel'
|
||||
import { MasteryModePanel } from './config-panel/MasteryModePanel'
|
||||
|
||||
interface ConfigPanelProps {
|
||||
formState: WorksheetFormState
|
||||
@@ -105,7 +105,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Progressive Difficulty Toggle - Available for both modes */}
|
||||
{/* Progressive Difficulty Toggle - Available for all modes */}
|
||||
<ProgressiveDifficultyToggle
|
||||
interpolate={formState.interpolate}
|
||||
onChange={(interpolate) => onChange({ interpolate })}
|
||||
@@ -124,7 +124,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
|
||||
|
||||
{/* Mastery Mode Controls */}
|
||||
{formState.mode === 'mastery' && (
|
||||
<ProgressionModePanel formState={formState} onChange={onChange} isDark={isDark} />
|
||||
<MasteryModePanel formState={formState} onChange={onChange} isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -27,6 +27,10 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
|
||||
console.log(`[WorksheetPreview] fetchWorksheetPreview called (ID: ${fetchId})`, {
|
||||
seed: formState.seed,
|
||||
problemsPerPage: formState.problemsPerPage,
|
||||
pAnyStart: formState.pAnyStart,
|
||||
pAllStart: formState.pAllStart,
|
||||
mode: formState.mode,
|
||||
operator: formState.operator,
|
||||
})
|
||||
|
||||
// Set current date for preview
|
||||
|
||||
@@ -28,36 +28,64 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
||||
const [isAllSkillsModalOpen, setIsAllSkillsModalOpen] = useState(false)
|
||||
const [isCustomizeMixModalOpen, setIsCustomizeMixModalOpen] = useState(false)
|
||||
|
||||
// Get current operator (default to addition, filter out 'mixed')
|
||||
const rawOperator = formState.operator ?? 'addition'
|
||||
const operator: 'addition' | 'subtraction' = rawOperator === 'mixed' ? 'addition' : rawOperator
|
||||
// Get current operator (default to addition)
|
||||
const operator = formState.operator ?? 'addition'
|
||||
|
||||
// Get skills for current operator
|
||||
const availableSkills = getSkillsByOperator(operator)
|
||||
// For mixed mode, we need to track skills for BOTH operators
|
||||
const isMixedMode = operator === 'mixed'
|
||||
|
||||
// Get current skill ID from form state, or use first available skill
|
||||
const currentSkillId = formState.currentSkillId ?? availableSkills[0]?.id
|
||||
// Get skills for current operator(s)
|
||||
const additionSkills = getSkillsByOperator('addition')
|
||||
const subtractionSkills = getSkillsByOperator('subtraction')
|
||||
|
||||
// Get current skill definition
|
||||
const currentSkill = currentSkillId ? getSkillById(currentSkillId as SkillId) : availableSkills[0]
|
||||
// Get current skill IDs for each operator
|
||||
const currentAdditionSkillId =
|
||||
formState.currentAdditionSkillId ?? additionSkills[0]?.id ?? 'sd-no-regroup'
|
||||
const currentSubtractionSkillId =
|
||||
formState.currentSubtractionSkillId ?? subtractionSkills[0]?.id ?? 'sd-sub-no-borrow'
|
||||
|
||||
// Get current skill definitions
|
||||
const currentAdditionSkill = getSkillById(currentAdditionSkillId as SkillId)
|
||||
const currentSubtractionSkill = getSkillById(currentSubtractionSkillId as SkillId)
|
||||
|
||||
// For single-operator modes, use the appropriate skill
|
||||
const currentSkill = isMixedMode
|
||||
? currentAdditionSkill // Just use one for single-mode UI components
|
||||
: operator === 'subtraction'
|
||||
? currentSubtractionSkill
|
||||
: currentAdditionSkill
|
||||
|
||||
const availableSkills = isMixedMode
|
||||
? [...additionSkills, ...subtractionSkills] // Combined for modal
|
||||
: operator === 'subtraction'
|
||||
? subtractionSkills
|
||||
: additionSkills
|
||||
|
||||
// Load mastery states from API
|
||||
useEffect(() => {
|
||||
async function loadMasteryStates() {
|
||||
try {
|
||||
setIsLoadingMastery(true)
|
||||
const response = await fetch(`/api/worksheets/mastery?operator=${operator}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load mastery states')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Convert to Map<SkillId, boolean>
|
||||
const statesMap = new Map<SkillId, boolean>()
|
||||
for (const record of data.masteryStates) {
|
||||
statesMap.set(record.skillId as SkillId, record.isMastered)
|
||||
// For mixed mode, load both addition and subtraction mastery states
|
||||
const operatorsToLoad = isMixedMode ? ['addition', 'subtraction'] : [operator]
|
||||
|
||||
const allStates = new Map<SkillId, boolean>()
|
||||
|
||||
for (const op of operatorsToLoad) {
|
||||
const response = await fetch(`/api/worksheets/mastery?operator=${op}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load mastery states for ${op}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Merge into combined map
|
||||
for (const record of data.masteryStates) {
|
||||
allStates.set(record.skillId as SkillId, record.isMastered)
|
||||
}
|
||||
}
|
||||
setMasteryStates(statesMap)
|
||||
|
||||
setMasteryStates(allStates)
|
||||
} catch (error) {
|
||||
console.error('Failed to load mastery states:', error)
|
||||
} finally {
|
||||
@@ -66,35 +94,52 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
||||
}
|
||||
|
||||
loadMasteryStates()
|
||||
}, [operator])
|
||||
}, [operator, isMixedMode])
|
||||
|
||||
// Apply current skill configuration to form state
|
||||
useEffect(() => {
|
||||
if (!currentSkill) return
|
||||
if (isMixedMode) {
|
||||
// Mixed mode: Use current addition and subtraction skills
|
||||
if (!currentAdditionSkill || !currentSubtractionSkill) return
|
||||
|
||||
console.log('[MasteryModePanel] Applying skill config:', {
|
||||
skillId: currentSkill.id,
|
||||
skillName: currentSkill.name,
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
})
|
||||
console.log('[MasteryModePanel] Applying mixed mode config:', {
|
||||
additionSkill: currentAdditionSkill.id,
|
||||
subtractionSkill: currentSubtractionSkill.id,
|
||||
})
|
||||
|
||||
// Apply skill's configuration to form state
|
||||
// This updates the preview to show problems appropriate for this skill
|
||||
onChange({
|
||||
// Keep mode as 'mastery' - displayRules will still apply conditional scaffolding
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
interpolate: false, // CRITICAL: Disable progressive difficulty in mastery mode
|
||||
} as Partial<WorksheetFormState>)
|
||||
// Store both skill IDs - worksheet generation will query these
|
||||
onChange({
|
||||
currentAdditionSkillId: currentAdditionSkill.id,
|
||||
currentSubtractionSkillId: currentSubtractionSkill.id,
|
||||
operator: 'mixed',
|
||||
// Do NOT force interpolate - let user control it via the toggle
|
||||
} as Partial<WorksheetFormState>)
|
||||
} else {
|
||||
// Single operator mode: Use the current skill
|
||||
if (!currentSkill) return
|
||||
|
||||
console.log('[MasteryModePanel] Applying skill config:', {
|
||||
skillId: currentSkill.id,
|
||||
skillName: currentSkill.name,
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
})
|
||||
|
||||
// Apply skill's configuration to form state
|
||||
onChange({
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
// Do NOT force interpolate - let user control it via the toggle
|
||||
} as Partial<WorksheetFormState>)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSkill?.id]) // Only run when skill ID changes, not when onChange changes
|
||||
}, [isMixedMode, currentAdditionSkill?.id, currentSubtractionSkill?.id, currentSkill?.id])
|
||||
|
||||
// Handler: Navigate to previous skill
|
||||
const handlePreviousSkill = () => {
|
||||
@@ -102,7 +147,13 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
||||
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
|
||||
if (currentIndex > 0) {
|
||||
const prevSkill = availableSkills[currentIndex - 1]
|
||||
onChange({ currentSkillId: prevSkill.id } as Partial<WorksheetFormState>)
|
||||
|
||||
// Update the appropriate skill ID based on operator
|
||||
if (operator === 'addition') {
|
||||
onChange({ currentAdditionSkillId: prevSkill.id } as Partial<WorksheetFormState>)
|
||||
} else if (operator === 'subtraction') {
|
||||
onChange({ currentSubtractionSkillId: prevSkill.id } as Partial<WorksheetFormState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +163,13 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
||||
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
|
||||
if (currentIndex < availableSkills.length - 1) {
|
||||
const nextSkill = availableSkills[currentIndex + 1]
|
||||
onChange({ currentSkillId: nextSkill.id } as Partial<WorksheetFormState>)
|
||||
|
||||
// Update the appropriate skill ID based on operator
|
||||
if (operator === 'addition') {
|
||||
onChange({ currentAdditionSkillId: nextSkill.id } as Partial<WorksheetFormState>)
|
||||
} else if (operator === 'subtraction') {
|
||||
onChange({ currentSubtractionSkillId: nextSkill.id } as Partial<WorksheetFormState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +208,260 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed mode: Show both skills
|
||||
if (isMixedMode) {
|
||||
if (!currentAdditionSkill || !currentSubtractionSkill) {
|
||||
return (
|
||||
<div
|
||||
data-component="mastery-mode-panel"
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<p className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>Loading skills...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const additionMastered = masteryStates.get(currentAdditionSkill.id) ?? false
|
||||
const subtractionMastered = masteryStates.get(currentSubtractionSkill.id) ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="mastery-mode-panel"
|
||||
data-mode="mixed"
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={css({ marginBottom: '1rem' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
Mixed Operations Mode
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
Practicing operator recognition with problems from both current addition and subtraction
|
||||
skill levels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Skills Display - Mini Compact Cards */}
|
||||
<div className={css({ display: 'flex', gap: '0.75rem', marginTop: '1rem' })}>
|
||||
{/* Addition Skill - Mini */}
|
||||
<div
|
||||
data-skill-card="addition-mini"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '0.25rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.025em',
|
||||
})}
|
||||
>
|
||||
Addition
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.375rem' })}>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
lineHeight: '1.2',
|
||||
})}
|
||||
>
|
||||
{currentAdditionSkill.name}
|
||||
</h4>
|
||||
{additionMastered && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1',
|
||||
})}
|
||||
title="Mastered"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginTop: '0.25rem',
|
||||
lineHeight: '1.3',
|
||||
})}
|
||||
>
|
||||
{currentAdditionSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subtraction Skill - Mini */}
|
||||
<div
|
||||
data-skill-card="subtraction-mini"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '0.25rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.025em',
|
||||
})}
|
||||
>
|
||||
Subtraction
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.375rem' })}>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
lineHeight: '1.2',
|
||||
})}
|
||||
>
|
||||
{currentSubtractionSkill.name}
|
||||
</h4>
|
||||
{subtractionMastered && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1',
|
||||
})}
|
||||
title="Mastered"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginTop: '0.25rem',
|
||||
lineHeight: '1.3',
|
||||
})}
|
||||
>
|
||||
{currentSubtractionSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View All Skills Button */}
|
||||
<div className={css({ marginTop: '1.5rem' })}>
|
||||
<button
|
||||
type="button"
|
||||
data-action="view-all-skills"
|
||||
onClick={() => setIsAllSkillsModalOpen(true)}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: isDark ? 'gray.500' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
View All Skills
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* All Skills Modal */}
|
||||
<AllSkillsModal
|
||||
isOpen={isAllSkillsModalOpen}
|
||||
onClose={() => setIsAllSkillsModalOpen(false)}
|
||||
skills={availableSkills}
|
||||
currentSkillId={currentAdditionSkill.id}
|
||||
masteryStates={masteryStates}
|
||||
onSelectSkill={(skillId) => {
|
||||
// Determine which operator this skill belongs to
|
||||
const skill = getSkillById(skillId)
|
||||
if (!skill) return
|
||||
|
||||
if (skill.operator === 'addition') {
|
||||
onChange({ currentAdditionSkillId: skillId } as Partial<WorksheetFormState>)
|
||||
} else {
|
||||
onChange({ currentSubtractionSkillId: skillId } as Partial<WorksheetFormState>)
|
||||
}
|
||||
}}
|
||||
onToggleMastery={async (skillId, isMastered) => {
|
||||
const newStates = new Map(masteryStates)
|
||||
newStates.set(skillId, isMastered)
|
||||
setMasteryStates(newStates)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/worksheets/mastery', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, isMastered }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update mastery state')
|
||||
} catch (error) {
|
||||
console.error('Failed to update mastery state:', error)
|
||||
const revertedStates = new Map(masteryStates)
|
||||
revertedStates.set(skillId, !isMastered)
|
||||
setMasteryStates(revertedStates)
|
||||
}
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single operator mode
|
||||
if (!currentSkill) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
generateProblems,
|
||||
generateSubtractionProblems,
|
||||
generateMixedProblems,
|
||||
generateMasteryMixedProblems,
|
||||
} from './problemGenerator'
|
||||
import { generateTypstSource } from './typstGenerator'
|
||||
import type { WorksheetFormState } from './types'
|
||||
import { getSkillById } from './skills'
|
||||
|
||||
export interface PreviewResult {
|
||||
success: boolean
|
||||
@@ -37,33 +39,78 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||
|
||||
// Generate all problems for full preview based on operator
|
||||
const operator = validatedConfig.operator ?? 'addition'
|
||||
const problems =
|
||||
operator === 'addition'
|
||||
? generateProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed,
|
||||
validatedConfig.digitRange
|
||||
)
|
||||
: operator === 'subtraction'
|
||||
? generateSubtractionProblems(
|
||||
const mode = config.mode ?? 'smart'
|
||||
|
||||
let problems
|
||||
|
||||
// Special handling for mastery + mixed mode
|
||||
if (mode === 'mastery' && operator === 'mixed') {
|
||||
// Query both skill configs
|
||||
const addSkillId = config.currentAdditionSkillId
|
||||
const subSkillId = config.currentSubtractionSkillId
|
||||
|
||||
if (!addSkillId || !subSkillId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Mixed mastery mode requires both addition and subtraction skill IDs',
|
||||
}
|
||||
}
|
||||
|
||||
const addSkill = getSkillById(addSkillId as any)
|
||||
const subSkill = getSkillById(subSkillId as any)
|
||||
|
||||
if (!addSkill || !subSkill) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid skill IDs',
|
||||
}
|
||||
}
|
||||
|
||||
// Use skill-specific configs
|
||||
problems = generateMasteryMixedProblems(
|
||||
validatedConfig.total,
|
||||
{
|
||||
digitRange: addSkill.digitRange,
|
||||
pAnyStart: addSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: addSkill.regroupingConfig.pAllStart,
|
||||
},
|
||||
{
|
||||
digitRange: subSkill.digitRange,
|
||||
pAnyStart: subSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: subSkill.regroupingConfig.pAllStart,
|
||||
},
|
||||
validatedConfig.seed
|
||||
)
|
||||
} else {
|
||||
// Standard problem generation
|
||||
problems =
|
||||
operator === 'addition'
|
||||
? generateProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
: generateMixedProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
validatedConfig.seed,
|
||||
validatedConfig.digitRange
|
||||
)
|
||||
: operator === 'subtraction'
|
||||
? generateSubtractionProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
: generateMixedProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
}
|
||||
|
||||
// Generate Typst sources (one per page)
|
||||
const typstSources = generateTypstSource(validatedConfig, problems)
|
||||
|
||||
@@ -284,19 +284,30 @@ export function generateProblems(
|
||||
}
|
||||
ok = uniquePush(problems, a, b, seen)
|
||||
|
||||
// If stuck, try a different category
|
||||
// If stuck, try a different category - but respect the difficulty constraints
|
||||
// Don't switch to a harder category if pAny is 0 (no regrouping allowed)
|
||||
if (!ok && tries % 50 === 0) {
|
||||
picked = pick(['both', 'onesOnly', 'non'], rand)
|
||||
if (pAny > 0) {
|
||||
// Can use any category
|
||||
picked = pick(['both', 'onesOnly', 'non'], rand)
|
||||
}
|
||||
// If pAny is 0, keep trying 'non' - don't switch to regrouping categories
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: add any valid problem in digit range
|
||||
// Last resort: use the appropriate generator one more time, even if not unique
|
||||
// This respects the difficulty constraints (non/onesOnly/both)
|
||||
if (!ok) {
|
||||
const digitsA = randint(minDigits, maxDigits, rand)
|
||||
const digitsB = randint(minDigits, maxDigits, rand)
|
||||
const a = generateNumber(digitsA, rand)
|
||||
const b = generateNumber(digitsB, rand)
|
||||
uniquePush(problems, a, b, seen)
|
||||
let a: number, b: number
|
||||
if (picked === 'both') {
|
||||
;[a, b] = generateBoth(rand, minDigits, maxDigits)
|
||||
} else if (picked === 'onesOnly') {
|
||||
;[a, b] = generateOnesOnly(rand, minDigits, maxDigits)
|
||||
} else {
|
||||
;[a, b] = generateNonRegroup(rand, minDigits, maxDigits)
|
||||
}
|
||||
// Allow duplicate as last resort - add with operator property
|
||||
problems.push({ a, b, operator: '+' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,8 +421,10 @@ export function generateOnesOnlyBorrow(
|
||||
return [minuend, subtrahend]
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return minDigits === 1 ? [5, 7] : [52, 17]
|
||||
// Fallback: Return safe problems that require ones-place borrowing
|
||||
// For single-digit: Use teens minus singles (13-7, 15-8)
|
||||
// For two-digit: Use problems like 52-17
|
||||
return minDigits === 1 ? [13, 7] : [52, 17]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,11 +609,23 @@ export function generateSubtractionProblems(
|
||||
let minuend = generateNumber(digitsM, rand)
|
||||
let subtrahend = generateNumber(digitsS, rand)
|
||||
|
||||
// Ensure minuend >= subtrahend
|
||||
if (minuend < subtrahend) {
|
||||
// Ensure minuend > subtrahend (strictly greater to avoid 0-0 and ensure borrowing is possible)
|
||||
if (minuend <= subtrahend) {
|
||||
;[minuend, subtrahend] = [subtrahend, minuend]
|
||||
}
|
||||
|
||||
// Final safety check: ensure minuend is actually greater
|
||||
if (minuend <= subtrahend) {
|
||||
// Both were equal or something went wrong - use safe fallback
|
||||
if (minDigits === 1) {
|
||||
minuend = 13
|
||||
subtrahend = 7
|
||||
} else {
|
||||
minuend = 52
|
||||
subtrahend = 17
|
||||
}
|
||||
}
|
||||
|
||||
uniquePush(minuend, subtrahend)
|
||||
}
|
||||
}
|
||||
@@ -608,6 +633,68 @@ export function generateSubtractionProblems(
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate mixed addition and subtraction problems for mastery mode
|
||||
* Uses separate configs for addition and subtraction based on current skill levels
|
||||
*
|
||||
* @param count Number of problems to generate
|
||||
* @param additionConfig Configuration for addition problems (from current addition skill)
|
||||
* @param subtractionConfig Configuration for subtraction problems (from current subtraction skill)
|
||||
* @param seed Random seed
|
||||
* @returns Array of mixed problems, shuffled randomly
|
||||
*/
|
||||
export function generateMasteryMixedProblems(
|
||||
count: number,
|
||||
additionConfig: {
|
||||
digitRange: { min: number; max: number }
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
},
|
||||
subtractionConfig: {
|
||||
digitRange: { min: number; max: number }
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
},
|
||||
seed: number
|
||||
): WorksheetProblem[] {
|
||||
// Generate half from each operator
|
||||
const halfCount = Math.floor(count / 2)
|
||||
const addCount = halfCount
|
||||
const subCount = count - halfCount // Handle odd counts
|
||||
|
||||
// Generate addition problems using addition skill config
|
||||
const addProblems = generateProblems(
|
||||
addCount,
|
||||
additionConfig.pAnyStart,
|
||||
additionConfig.pAllStart,
|
||||
false, // No interpolation in mastery mode
|
||||
seed,
|
||||
additionConfig.digitRange
|
||||
)
|
||||
|
||||
// Generate subtraction problems using subtraction skill config
|
||||
const subProblems = generateSubtractionProblems(
|
||||
subCount,
|
||||
subtractionConfig.digitRange,
|
||||
subtractionConfig.pAnyStart,
|
||||
subtractionConfig.pAllStart,
|
||||
false, // No interpolation in mastery mode
|
||||
seed + 1000000 // Different seed space
|
||||
)
|
||||
|
||||
// Combine and shuffle
|
||||
const allProblems = [...addProblems, ...subProblems]
|
||||
const rand = createPRNG(seed + 2000000)
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = allProblems.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[allProblems[i], allProblems[j]] = [allProblems[j], allProblems[i]]
|
||||
}
|
||||
|
||||
return allProblems
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate mixed addition and subtraction problems
|
||||
* Randomly alternates between addition and subtraction (50/50)
|
||||
|
||||
@@ -37,8 +37,12 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
||||
const rows = Math.ceil(total / cols)
|
||||
|
||||
// Validate probabilities (0-1 range)
|
||||
const pAnyStart = formState.pAnyStart ?? 0.75
|
||||
const pAllStart = formState.pAllStart ?? 0.25
|
||||
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
|
||||
// because 0 is a valid value (e.g., "no regrouping" skills set pAnyStart=0)
|
||||
const pAnyStart =
|
||||
formState.pAnyStart !== undefined && formState.pAnyStart !== null ? formState.pAnyStart : 0.75
|
||||
const pAllStart =
|
||||
formState.pAllStart !== undefined && formState.pAllStart !== null ? formState.pAllStart : 0.25
|
||||
if (pAnyStart < 0 || pAnyStart > 1) {
|
||||
errors.push('pAnyStart must be between 0 and 1')
|
||||
}
|
||||
@@ -111,7 +115,13 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
||||
date: formState.date?.trim() || getDefaultDate(),
|
||||
pAnyStart,
|
||||
pAllStart,
|
||||
interpolate: formState.interpolate ?? true,
|
||||
// Default interpolate based on mode: true for smart/manual, false for mastery
|
||||
interpolate:
|
||||
formState.interpolate !== undefined
|
||||
? formState.interpolate
|
||||
: mode === 'mastery'
|
||||
? false
|
||||
: true,
|
||||
|
||||
// V4: Digit range for problem generation
|
||||
digitRange,
|
||||
|
||||
Reference in New Issue
Block a user