Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions app/components/adaptiveDifficultyService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* adaptiveDifficultyService.js — Shared adaptive difficulty staircase helpers.
*
* Applies the common BrainSpeedExercises rule:
* - 3 consecutive correct results increase difficulty by one step.
* - 3 consecutive wrong results decrease difficulty by two steps.
*
* @file Shared adaptive staircase service.
*/

/**
* Clamp a numeric difficulty value to an inclusive [min, max] range.
*
* @param {number} value - Value to clamp.
* @param {number} minValue - Lower bound.
* @param {number} maxValue - Upper bound.
* @returns {number} Clamped value.
*/
export function clampDifficultyValue(value, minValue, maxValue) {
return Math.min(Math.max(value, minValue), maxValue);
}

/**
* Apply one adaptive-staircase update for a completed trial/round.
*
* The caller provides:
* - Current difficulty value (level, SOA, interval, etc.)
* - Current consecutive correct/wrong counters
* - Whether this result was correct
* - Numeric step direction/magnitude for harder/easier adjustments
*
* @param {{
* value: number,
* wasCorrect: boolean,
* consecutiveCorrect: number,
* consecutiveWrong: number,
* increaseAfter?: number,
* decreaseAfter?: number,
* harderStep?: number,
* easierStep?: number,
* minValue?: number,
* maxValue?: number,
* }} state
* @returns {{
* value: number,
* consecutiveCorrect: number,
* consecutiveWrong: number,
* valueDelta: number,
* }}
*/
export function updateAdaptiveDifficultyState(state) {
const {
value,
wasCorrect,
consecutiveCorrect,
consecutiveWrong,
increaseAfter = 3,
decreaseAfter = 3,
harderStep = 1,
easierStep = -2,
minValue = Number.NEGATIVE_INFINITY,
maxValue = Number.POSITIVE_INFINITY,
} = state;

let nextValue = value;
let nextConsecutiveCorrect = wasCorrect ? consecutiveCorrect + 1 : 0;
let nextConsecutiveWrong = wasCorrect ? 0 : consecutiveWrong + 1;

if (nextConsecutiveCorrect >= increaseAfter) {
nextValue = clampDifficultyValue(value + harderStep, minValue, maxValue);
nextConsecutiveCorrect = 0;
} else if (nextConsecutiveWrong >= decreaseAfter) {
nextValue = clampDifficultyValue(value + easierStep, minValue, maxValue);
nextConsecutiveWrong = 0;
}

return {
value: nextValue,
consecutiveCorrect: nextConsecutiveCorrect,
consecutiveWrong: nextConsecutiveWrong,
valueDelta: nextValue - value,
};
}
156 changes: 156 additions & 0 deletions app/components/tests/adaptiveDifficultyService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* adaptiveDifficultyService.test.js — Unit tests for adaptive difficulty helpers.
*
* @file Tests for adaptiveDifficultyService.js
*/

import { describe, it, expect } from '@jest/globals';

import {
clampDifficultyValue,
updateAdaptiveDifficultyState,
} from '../adaptiveDifficultyService.js';

describe('clampDifficultyValue()', () => {
it('returns the value when already in range', () => {
expect(clampDifficultyValue(3, 0, 10)).toBe(3);
});

it('clamps to the minimum when below range', () => {
expect(clampDifficultyValue(-1, 0, 10)).toBe(0);
});

it('clamps to the maximum when above range', () => {
expect(clampDifficultyValue(11, 0, 10)).toBe(10);
});
});

describe('updateAdaptiveDifficultyState()', () => {
it('increments correct streak and resets wrong streak on correct result', () => {
const state = updateAdaptiveDifficultyState({
value: 2,
wasCorrect: true,
consecutiveCorrect: 1,
consecutiveWrong: 2,
});

expect(state).toMatchObject({
value: 2,
consecutiveCorrect: 2,
consecutiveWrong: 0,
valueDelta: 0,
});
});

it('increments wrong streak and resets correct streak on wrong result', () => {
const state = updateAdaptiveDifficultyState({
value: 2,
wasCorrect: false,
consecutiveCorrect: 2,
consecutiveWrong: 1,
});

expect(state).toMatchObject({
value: 2,
consecutiveCorrect: 0,
consecutiveWrong: 2,
valueDelta: 0,
});
});

it('applies harderStep after the correct streak threshold', () => {
const state = updateAdaptiveDifficultyState({
value: 2,
wasCorrect: true,
consecutiveCorrect: 2,
consecutiveWrong: 0,
increaseAfter: 3,
harderStep: 1,
minValue: 0,
maxValue: 10,
});

expect(state).toMatchObject({
value: 3,
consecutiveCorrect: 0,
consecutiveWrong: 0,
valueDelta: 1,
});
});

it('applies easierStep after the wrong streak threshold', () => {
const state = updateAdaptiveDifficultyState({
value: 4,
wasCorrect: false,
consecutiveCorrect: 0,
consecutiveWrong: 2,
decreaseAfter: 3,
easierStep: -2,
minValue: 0,
maxValue: 10,
});

expect(state).toMatchObject({
value: 2,
consecutiveCorrect: 0,
consecutiveWrong: 0,
valueDelta: -2,
});
});

it('uses default 3/3 and +1/-2 staircase parameters', () => {
const levelUpState = updateAdaptiveDifficultyState({
value: 0,
wasCorrect: true,
consecutiveCorrect: 2,
consecutiveWrong: 0,
minValue: 0,
maxValue: 10,
});

const levelDownState = updateAdaptiveDifficultyState({
value: 3,
wasCorrect: false,
consecutiveCorrect: 0,
consecutiveWrong: 2,
minValue: 0,
maxValue: 10,
});

expect(levelUpState.value).toBe(1);
expect(levelDownState.value).toBe(1);
});

it('clamps harder/easier adjustments at provided bounds', () => {
const clampedUp = updateAdaptiveDifficultyState({
value: 10,
wasCorrect: true,
consecutiveCorrect: 2,
consecutiveWrong: 0,
harderStep: 1,
minValue: 0,
maxValue: 10,
});

const clampedDown = updateAdaptiveDifficultyState({
value: 0,
wasCorrect: false,
consecutiveCorrect: 0,
consecutiveWrong: 2,
easierStep: -2,
minValue: 0,
maxValue: 10,
});

expect(clampedUp).toMatchObject({
value: 10,
consecutiveCorrect: 0,
valueDelta: 0,
});
expect(clampedDown).toMatchObject({
value: 0,
consecutiveWrong: 0,
valueDelta: 0,
});
});
});
34 changes: 19 additions & 15 deletions app/games/directional-processing/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* @file Directional Processing game logic module.
*/

import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js';

/** All valid motion directions for trial generation. */
export const DIRECTIONS = ['up', 'down', 'left', 'right'];

Expand Down Expand Up @@ -148,23 +150,25 @@ export function recordTrial({ success }) {

if (success) {
score += 1;
consecutiveCorrect += 1;
consecutiveWrong = 0;

if (consecutiveCorrect >= CORRECT_STREAK_TO_ADVANCE) {
currentLevel = Math.min(currentLevel + 1, LEVELS.length - 1);
consecutiveCorrect = 0;
}
} else {
consecutiveWrong += 1;
consecutiveCorrect = 0;

if (consecutiveWrong >= WRONG_STREAK_TO_DROP) {
currentLevel = Math.max(currentLevel - LEVEL_DROP, 0);
consecutiveWrong = 0;
}
}

const staircaseState = updateAdaptiveDifficultyState({
value: currentLevel,
wasCorrect: Boolean(success),
consecutiveCorrect,
consecutiveWrong,
increaseAfter: CORRECT_STREAK_TO_ADVANCE,
decreaseAfter: WRONG_STREAK_TO_DROP,
harderStep: 1,
easierStep: -LEVEL_DROP,
minValue: 0,
maxValue: LEVELS.length - 1,
});

currentLevel = staircaseState.value;
consecutiveCorrect = staircaseState.consecutiveCorrect;
consecutiveWrong = staircaseState.consecutiveWrong;

speedHistory.push(LEVELS[currentLevel].displayDurationMs);

return { level: currentLevel, consecutiveCorrect, consecutiveWrong };
Expand Down
48 changes: 38 additions & 10 deletions app/games/fast-piggie/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* @file Fast Piggie game logic module.
*/

import { updateAdaptiveDifficultyState } from '../../components/adaptiveDifficultyService.js';

// ── Difficulty constants ───────────────────────────────────────────────────
/** Display duration (ms) at level 0. */
const INITIAL_DISPLAY_MS = 800;
Expand Down Expand Up @@ -216,11 +218,25 @@ export function calculateWedgeIndex(clickX, clickY, centerX, centerY, radius, we
export function addScore(guineaPigsThisRound, answerSpeedMs, displayDurationMs) {
score += 1;
roundsPlayed += 1;
consecutiveCorrect += 1;
consecutiveWrong = 0;
if (consecutiveCorrect >= 3) {
imageLevel += 1;
consecutiveCorrect = 0;

const staircaseState = updateAdaptiveDifficultyState({
value: imageLevel,
wasCorrect: true,
consecutiveCorrect,
consecutiveWrong,
increaseAfter: 3,
decreaseAfter: 3,
harderStep: 1,
easierStep: -2,
minValue: 0,
maxValue: Number.POSITIVE_INFINITY,
});

imageLevel = staircaseState.value;
consecutiveCorrect = staircaseState.consecutiveCorrect;
consecutiveWrong = staircaseState.consecutiveWrong;

if (staircaseState.valueDelta === 1) {
if (calculateDisplayDuration(speedLevel) < DISPLAY_STEP_THRESHOLD_MS) {
// Sub-threshold phase: alternate between image-only and both.
if (speedIncreaseNext) {
Expand Down Expand Up @@ -259,13 +275,25 @@ export function addScore(guineaPigsThisRound, answerSpeedMs, displayDurationMs)
*/
export function addMiss(guineaPigsThisRound, displayDurationMs) {
roundsPlayed += 1;
consecutiveCorrect = 0;
consecutiveWrong += 1;
if (consecutiveWrong >= 3) {
speedLevel = Math.max(0, speedLevel - 2);
const staircaseState = updateAdaptiveDifficultyState({
value: speedLevel,
wasCorrect: false,
consecutiveCorrect,
consecutiveWrong,
increaseAfter: 3,
decreaseAfter: 3,
harderStep: 1,
easierStep: -2,
minValue: 0,
maxValue: Number.POSITIVE_INFINITY,
});

speedLevel = staircaseState.value;
consecutiveCorrect = staircaseState.consecutiveCorrect;
consecutiveWrong = staircaseState.consecutiveWrong;
if (staircaseState.valueDelta < 0) {
imageLevel = canonicalImageLevel(speedLevel);
speedIncreaseNext = false;
consecutiveWrong = 0;
}
// Track most guinea pigs displayed in a round (even if missed)
if (typeof guineaPigsThisRound === 'number' && guineaPigsThisRound > mostGuineaPigs) {
Expand Down
Loading
Loading