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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
34
apps/web/src/contexts/DeploymentInfoContext.tsx
Normal file
34
apps/web/src/contexts/DeploymentInfoContext.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user