/** * Drill state transitions. Pure, and free of React, so it tests like the domain. * * Choosing the next card needs randomness, so that happens outside and arrives * in the `deal` action. The reducer only ever transforms what it is given. * * Angular: closest to a service's state plus its methods, except nothing here * mutates — each action returns the next state. */ import { gradeBoxes, isCorrect } from "../domain/drill"; import type { Boxes, DeckId, Direction, InputMode } from "../domain/drill"; import type { ControlId, Entry } from "../domain/types"; export interface DrillState { readonly deck: DeckId; readonly direction: Direction; readonly input: InputMode; readonly boxes: Boxes; readonly card: Entry | null; /** Controls tapped or held so far for the current answer. */ readonly chord: readonly ControlId[]; readonly answered: boolean; readonly lastCorrect: boolean; /** Names offered when the card is asked combo-first. */ readonly options: readonly string[]; } export type DrillAction = /** A new card, with its multiple-choice names when asked combo-first. */ | { type: "deal"; card: Entry | null; options?: readonly string[] } | { type: "press"; id: ControlId } | { type: "clear" } | { type: "check" } /** Hold-to-answer: the chord as it stood when the last finger lifted. */ | { type: "release"; chord: readonly ControlId[] } | { type: "reveal" } | { type: "pickOption"; name: string } | { type: "setDeck"; deck: DeckId } | { type: "setDirection"; direction: Direction } | { type: "setInput"; input: InputMode } | { type: "setBoxes"; boxes: Boxes }; export const initialDrillState = ( deck: DeckId, direction: Direction, input: InputMode, boxes: Boxes, ): DrillState => ({ deck, direction, input, boxes, card: null, chord: [], answered: false, lastCorrect: false, options: [], }); /** Grade the current card and park on the answer. */ function grade(state: DrillState, correct: boolean): DrillState { if (!state.card) return state; return { ...state, boxes: gradeBoxes(state.boxes, state.card, correct), answered: true, lastCorrect: correct, }; } export function drillReducer(state: DrillState, action: DrillAction): DrillState { switch (action.type) { case "deal": return { ...state, card: action.card, chord: [], answered: false, lastCorrect: false, options: action.options ?? [], }; case "press": { // Once graded, the panel is showing the answer; presses do nothing until // the next card is dealt. if (state.answered || state.direction !== "play") return state; const held = state.chord.includes(action.id); // Tapping toggles, so a mis-tap can be taken back. Holding accumulates. const chord = state.input === "tap" && held ? state.chord.filter((c) => c !== action.id) : held ? state.chord : [...state.chord, action.id]; return { ...state, chord }; } case "clear": return state.answered ? state : { ...state, chord: [] }; case "check": if (!state.card || state.answered || state.chord.length === 0) return state; return grade(state, isCorrect(state.card, state.chord)); case "release": if (!state.card || state.answered || action.chord.length === 0) return state; return grade( { ...state, chord: action.chord }, isCorrect(state.card, action.chord), ); case "reveal": // "Show me" counts as a miss: you did not know it. return state.answered ? state : grade(state, false); case "pickOption": if (!state.card || state.answered) return state; return grade(state, action.name === state.card.name); case "setDeck": return { ...state, deck: action.deck, card: null, chord: [], answered: false }; case "setDirection": return { ...state, direction: action.direction, card: null, chord: [], answered: false, }; case "setInput": return { ...state, input: action.input, chord: [] }; case "setBoxes": return { ...state, boxes: action.boxes }; } }