fix: add shuffling to progressive difficulty mode & UI improvements

Progressive Difficulty Randomization:
- Fixed problem generator to shuffle within difficulty windows (20% range)
- Problems now have variety while maintaining easy→hard progression
- Prevents strict ordering where all top numbers appear sorted
- Added window-based sampling to balance progression with randomness
- Export countRegroupingOperations for testing

Testing:
- Added comprehensive test suite (7 tests) for progressive difficulty
- Tests verify randomization, progression, variety, and edge cases
- All tests passing

UI Fixes:
- Added Deployment Info button to hamburger menu
- Created DeploymentInfoContext for modal control
- Aligned page indicator and download button tops (both use top: '4')
- Fixed mobile settings button drag bounds (queries actual button position)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-12 13:58:31 -06:00
parent ca9066d8d3
commit 38e9982c3d
9 changed files with 279 additions and 23 deletions

View File

@@ -0,0 +1,148 @@
/**
* Tests for progressive difficulty mode in problem generator
* Verifies that problems are shuffled within difficulty windows
* while maintaining overall easy→hard progression
*/
import { describe, expect, it } from 'vitest'
import { generateProblems, countRegroupingOperations } from '../problemGenerator'
describe('Progressive Difficulty Mode', () => {
it('should randomize problems within difficulty windows, not strict order', () => {
// Generate with progressive difficulty enabled
const problems = generateProblems(
20, // total
0.7, // pAnyStart
0.5, // pAllStart
true, // interpolate (progressive difficulty ON)
42, // seed
{ min: 2, max: 2 } // digitRange
)
// Extract top numbers (first operand)
const topNumbers = problems.map((p) => p.a)
// Problems should NOT be in strict ascending order
// (which was the bug - they were all sorted: 10, 11, 12, 13...)
let strictlyAscending = true
for (let i = 1; i < topNumbers.length; i++) {
if (topNumbers[i] < topNumbers[i - 1]) {
strictlyAscending = false
break
}
}
expect(strictlyAscending).toBe(false)
})
it('should maintain overall easy→hard progression', () => {
const problems = generateProblems(30, 0.7, 0.5, true, 123, { min: 2, max: 2 })
// Calculate difficulty (carry count) for each problem
const difficulties = problems.map((p) => countRegroupingOperations(p.a, p.b))
// Split into three sections: beginning, middle, end
const sectionSize = Math.floor(problems.length / 3)
const beginningDifficulties = difficulties.slice(0, sectionSize)
const middleDifficulties = difficulties.slice(sectionSize, sectionSize * 2)
const endDifficulties = difficulties.slice(sectionSize * 2)
// Calculate average difficulty for each section
const avgBeginning =
beginningDifficulties.reduce((a, b) => a + b, 0) / beginningDifficulties.length
const avgMiddle = middleDifficulties.reduce((a, b) => a + b, 0) / middleDifficulties.length
const avgEnd = endDifficulties.reduce((a, b) => a + b, 0) / endDifficulties.length
// Beginning should be easier than middle, middle easier than end
expect(avgBeginning).toBeLessThanOrEqual(avgMiddle)
expect(avgMiddle).toBeLessThanOrEqual(avgEnd)
})
it('should have variety in adjacent problems', () => {
const problems = generateProblems(20, 0.7, 0.5, true, 456, { min: 2, max: 2 })
// Count how many times adjacent problems have different top numbers
let differentCount = 0
for (let i = 1; i < problems.length; i++) {
if (problems[i].a !== problems[i - 1].a) {
differentCount++
}
}
// At least 50% of adjacent problems should have different top numbers
// (strict ordering would have very low variety)
const varietyRatio = differentCount / (problems.length - 1)
expect(varietyRatio).toBeGreaterThan(0.5)
})
it('should produce different sequences with different seeds', () => {
const problems1 = generateProblems(20, 0.7, 0.5, true, 100, { min: 2, max: 2 })
const problems2 = generateProblems(20, 0.7, 0.5, true, 200, { min: 2, max: 2 })
const sequence1 = problems1.map((p) => `${p.a}+${p.b}`).join(',')
const sequence2 = problems2.map((p) => `${p.a}+${p.b}`).join(',')
// Different seeds should produce different problem sequences
expect(sequence1).not.toBe(sequence2)
})
it('should work with small digit ranges', () => {
// Test with limited problem space (single-digit + single-digit)
const problems = generateProblems(10, 0, 0, true, 789, { min: 1, max: 1 })
expect(problems).toHaveLength(10)
// All problems should be within digit range (1-digit = 0-9)
for (const problem of problems) {
expect(problem.a).toBeGreaterThanOrEqual(0)
expect(problem.a).toBeLessThan(10)
expect(problem.b).toBeGreaterThanOrEqual(0)
expect(problem.b).toBeLessThan(10)
}
})
it('should handle cases where more problems requested than available', () => {
// Request more problems than possible unique combinations
const problems = generateProblems(
200, // More than possible unique 1-digit problems
0,
0,
true,
999,
{ min: 1, max: 1 }
)
expect(problems).toHaveLength(200)
// Should still maintain progression even with cycles
const difficulties = problems.map((p) => countRegroupingOperations(p.a, p.b))
const first10Avg = difficulties.slice(0, 10).reduce((a, b) => a + b, 0) / 10
const last10Avg =
difficulties.slice(-10).reduce((a, b) => a + b, 0) / 10
// Last 10 should still be harder than first 10 (or equal if very limited problem space)
expect(last10Avg).toBeGreaterThanOrEqual(first10Avg)
})
it('should not have long runs of identical top numbers', () => {
const problems = generateProblems(30, 0.7, 0.5, true, 333, { min: 2, max: 2 })
// Count maximum run length of same top number
let maxRunLength = 1
let currentRunLength = 1
for (let i = 1; i < problems.length; i++) {
if (problems[i].a === problems[i - 1].a) {
currentRunLength++
maxRunLength = Math.max(maxRunLength, currentRunLength)
} else {
currentRunLength = 1
}
}
// No more than 3 consecutive problems with same top number
// (strict ordering would have very long runs)
expect(maxRunLength).toBeLessThanOrEqual(3)
})
})

View File

@@ -36,7 +36,7 @@ export function FloatingPageIndicator({
className={css({
position: 'absolute',
// Mobile: top-left with margin, Desktop: centered at top
top: isMobile ? '3' : '4',
top: '4',
left: isMobile ? '3' : '50%',
transform: isMobile ? 'none' : 'translateX(-50%)',
zIndex: 10,

View File

@@ -168,9 +168,7 @@ export function MobileSettingsButton({ config, onClick }: MobileSettingsButtonPr
color: isDark ? 'gray.100' : 'gray.900',
rounded: 'xl',
p: '3',
boxShadow: isDragging
? '0 8px 24px rgba(0, 0, 0, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.15)',
boxShadow: isDragging ? '0 8px 24px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
cursor: isDragging ? 'grabbing' : 'grab',
@@ -178,9 +176,7 @@ export function MobileSettingsButton({ config, onClick }: MobileSettingsButtonPr
maxWidth: 'calc(100vw - 32px)',
userSelect: 'none',
_hover: {
boxShadow: isDragging
? '0 8px 24px rgba(0, 0, 0, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.2)',
boxShadow: isDragging ? '0 8px 24px rgba(0, 0, 0, 0.3)' : '0 6px 16px rgba(0, 0, 0, 0.2)',
bg: isDark ? 'gray.750' : 'gray.50',
},
})}

View File

@@ -125,7 +125,7 @@ function checkRegrouping(a: number, b: number): { hasAny: boolean; hasMultiple:
* Count the number of regrouping operations (carries) in an addition problem
* Returns a number representing difficulty: 0 = no regrouping, 1+ = increasing difficulty
*/
function countRegroupingOperations(a: number, b: number): number {
export function countRegroupingOperations(a: number, b: number): number {
const maxPlaces = Math.max(countDigits(a), countDigits(b))
let carryCount = 0
let carry = 0
@@ -418,16 +418,33 @@ export function generateProblems(
// frac=1 (end) → sample from hard problems (high index)
const targetIndex = Math.floor(frac * (sortedByDifficulty.length - 1))
// Try to get a problem near the target difficulty that we haven't used yet
let problem = sortedByDifficulty[targetIndex]
const key = `${problem.a},${problem.b}`
// Define a window around the target index to add variety
// Window size is 20% of total problems, minimum 3
const windowSize = Math.max(3, Math.floor(sortedByDifficulty.length * 0.2))
const windowStart = Math.max(0, targetIndex - Math.floor(windowSize / 2))
const windowEnd = Math.min(sortedByDifficulty.length, windowStart + windowSize)
// If already used, search nearby (forward then backward) for unused problem
// This maintains approximate difficulty while avoiding duplicates
if (seen.has(key)) {
// Collect unused problems within the window
const candidatesInWindow: AdditionProblem[] = []
for (let j = windowStart; j < windowEnd; j++) {
const candidate = sortedByDifficulty[j]
const candidateKey = `${candidate.a},${candidate.b}`
if (!seen.has(candidateKey)) {
candidatesInWindow.push(candidate)
}
}
let problem: AdditionProblem | undefined
if (candidatesInWindow.length > 0) {
// Randomly pick from unused problems in the window
const randomIdx = Math.floor(rand() * candidatesInWindow.length)
problem = candidatesInWindow[randomIdx]
const key = `${problem.a},${problem.b}`
seen.add(key)
} else {
// If no unused problems in window, search outward from target
let found = false
for (let offset = 1; offset < sortedByDifficulty.length; offset++) {
// Try forward first (slightly harder), then backward (slightly easier)
for (const direction of [1, -1]) {
const idx = targetIndex + direction * offset
if (idx >= 0 && idx < sortedByDifficulty.length) {
@@ -444,7 +461,6 @@ export function generateProblems(
}
// If still not found, we've exhausted ALL unique problems
// Clear the "seen" set and start a new cycle through the sorted array
// This maintains the difficulty progression across cycles
if (!found) {
cycleCount++
console.log(
@@ -452,9 +468,16 @@ export function generateProblems(
)
seen.clear()
// Use the target problem for this position (beginning of new cycle)
problem = sortedByDifficulty[targetIndex]
const key = `${problem.a},${problem.b}`
seen.add(key)
}
} else {
}
// Safety: If problem is still undefined, use the target problem
if (!problem) {
problem = sortedByDifficulty[targetIndex]
const key = `${problem.a},${problem.b}`
seen.add(key)
}

View File

@@ -11,6 +11,7 @@ import { container, hstack } from '../../styled-system/patterns'
import { Z_INDEX } from '../constants/zIndex'
import { useFullscreen } from '../contexts/FullscreenContext'
import { useTheme } from '../contexts/ThemeContext'
import { useDeploymentInfo } from '../contexts/DeploymentInfoContext'
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
import { LanguageSelector } from './LanguageSelector'
@@ -59,6 +60,7 @@ function MenuContent({
handleNestedDropdownChange,
isMobile,
resolvedTheme,
openDeploymentInfo,
}: {
isFullscreen: boolean
isArcadePage: boolean
@@ -69,6 +71,7 @@ function MenuContent({
handleNestedDropdownChange?: (isOpen: boolean) => void
isMobile?: boolean
resolvedTheme?: 'light' | 'dark'
openDeploymentInfo?: () => void
}) {
const isDark = resolvedTheme === 'dark'
@@ -257,6 +260,33 @@ function MenuContent({
<span>Exit Arcade</span>
</div>
)}
{openDeploymentInfo && (
<div
onClick={() => {
openDeploymentInfo()
onNavigate?.()
}}
style={controlButtonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(34, 197, 94, 0.2)'
: 'rgba(34, 197, 94, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(134, 239, 172, 1)'
: 'rgba(21, 128, 61, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '18px' }}></span>
<span>Deployment Info</span>
</div>
)}
</div>
{/* Column 2: Style + Language + Theme */}
@@ -390,12 +420,14 @@ function HamburgerMenu({
pathname,
toggleFullscreen,
router,
openDeploymentInfo,
}: {
isFullscreen: boolean
isArcadePage: boolean
pathname: string | null
toggleFullscreen: () => void
router: any
openDeploymentInfo: () => void
}) {
const [open, setOpen] = useState(false)
const [nestedDropdownOpen, setNestedDropdownOpen] = useState(false)
@@ -538,6 +570,7 @@ function HamburgerMenu({
handleNestedDropdownChange={handleNestedDropdownChange}
isMobile={true}
resolvedTheme={resolvedTheme}
openDeploymentInfo={openDeploymentInfo}
/>
</div>
@@ -648,6 +681,7 @@ function HamburgerMenu({
handleNestedDropdownChange={handleNestedDropdownChange}
isMobile={false}
resolvedTheme={resolvedTheme}
openDeploymentInfo={openDeploymentInfo}
/>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -725,6 +759,7 @@ function MinimalNav({
pathname={pathname}
toggleFullscreen={toggleFullscreen}
router={router}
openDeploymentInfo={openDeploymentInfo}
/>
</div>
@@ -783,6 +818,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
const isArcadePage = pathname?.startsWith('/arcade')
const isHomePage = pathname === '/'
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
const { open: openDeploymentInfo } = useDeploymentInfo()
// Try to get home hero context (if on homepage)
const homeHero = useOptionalHomeHero()
@@ -958,6 +994,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
pathname={pathname}
toggleFullscreen={toggleFullscreen}
router={router}
openDeploymentInfo={openDeploymentInfo}
/>
</div>
</div>

View File

@@ -14,6 +14,7 @@ import { createQueryClient } from '@/lib/queryClient'
import type { Locale } from '@/i18n/messages'
import { AbacusSettingsSync } from './AbacusSettingsSync'
import { DeploymentInfo } from './DeploymentInfo'
import { DeploymentInfoProvider } from '@/contexts/DeploymentInfoContext'
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
import { MyAbacus } from './MyAbacus'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
@@ -37,9 +38,11 @@ function InnerProviders({ children }: { children: ReactNode }) {
<FullscreenProvider>
<HomeHeroProvider>
<MyAbacusProvider>
{children}
<DeploymentInfo />
<MyAbacus />
<DeploymentInfoProvider>
{children}
<DeploymentInfo />
<MyAbacus />
</DeploymentInfoProvider>
</MyAbacusProvider>
</HomeHeroProvider>
</FullscreenProvider>

View File

@@ -1,9 +1,14 @@
'use client'
import { useDeploymentInfo } from '@/contexts/DeploymentInfoContext'
import { DeploymentInfoContent } from './DeploymentInfoContent'
import { DeploymentInfoModal } from './DeploymentInfoModal'
export function DeploymentInfo() {
const { isOpen, toggle } = useDeploymentInfo()
return (
<DeploymentInfoModal>
<DeploymentInfoModal externalOpen={isOpen} onExternalOpenChange={toggle}>
<DeploymentInfoContent />
</DeploymentInfoModal>
)

View File

@@ -8,10 +8,20 @@ import { css } from '../../styled-system/css'
interface DeploymentInfoModalProps {
children: React.ReactNode
externalOpen?: boolean
onExternalOpenChange?: (open: boolean) => void
}
export function DeploymentInfoModal({ children }: DeploymentInfoModalProps) {
const [open, setOpen] = useState(false)
export function DeploymentInfoModal({
children,
externalOpen,
onExternalOpenChange,
}: DeploymentInfoModalProps) {
const [internalOpen, setInternalOpen] = useState(false)
// Use external control if provided, otherwise use internal state
const open = externalOpen !== undefined ? externalOpen : internalOpen
const setOpen = onExternalOpenChange || setInternalOpen
useEffect(() => {
// Keyboard shortcut: Cmd/Ctrl + Shift + I

View File

@@ -0,0 +1,34 @@
'use client'
import { createContext, useContext, useState, type ReactNode } from 'react'
interface DeploymentInfoContextType {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
const DeploymentInfoContext = createContext<DeploymentInfoContextType | null>(null)
export function DeploymentInfoProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const open = () => setIsOpen(true)
const close = () => setIsOpen(false)
const toggle = () => setIsOpen((prev) => !prev)
return (
<DeploymentInfoContext.Provider value={{ isOpen, open, close, toggle }}>
{children}
</DeploymentInfoContext.Provider>
)
}
export function useDeploymentInfo() {
const context = useContext(DeploymentInfoContext)
if (!context) {
throw new Error('useDeploymentInfo must be used within DeploymentInfoProvider')
}
return context
}