/** Turning a run into a number, a star count and an animal. * * Two decisions here are the whole pedagogy of the game, so they are worth stating: * * *Characters per minute, not words per minute.* German words are long and a * six-year-old types around four words a minute honestly measured. "4" on a results * screen reads as failure; "38 Zeichen pro Minute" reads as a number that visibly * grows. Same data, different message. * * *points = speed × accuracy³, not the textbook net WPM.* The standard formula is * `net = gross − errors/minute`, which goes negative for a beginner - the one result * that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the * speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is * the habit worth building at this age. * * Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only * (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */ import { SKIN } from "../skins"; import type { AnimalLook } from "../skins"; import type { RunState, Stroke } from "./engine"; export interface RunResult { /** Correct keystrokes. */ characters: number; /** Wrong keystrokes, counted once per position (see engine.ts rule 2). */ errors: number; /** ms from the first keystroke to the last. */ duration: number; /** Characters per minute. */ speed: number; /** 0..1 */ accuracy: number; /** `speed × accuracy³`, the number the animal is read off. */ points: number; stars: 0 | 1 | 2 | 3; animal: AnimalId; /** Whether this run unlocks the next lesson on its own. */ passed: boolean; /** Kept for the race-mode ghost and the per-key stats. */ strokes: readonly Stroke[]; } /** The ladder's rungs, slowest first. The ids are the backend's and name the dolphin * skin's sea animals; to any other skin they are just rung 1 … rung 10 - see * `GameWorld` in ../skins/types.ts. */ export type AnimalId = | "snail" | "crab" | "turtle" | "jellyfish" | "fish" | "penguin" | "seal" | "dolphin" | "shark" | "orca"; export interface Animal extends AnimalLook { id: AnimalId; /** Lower bound in points (characters per minute, accuracy-weighted). */ from: number; } /** Ten rungs, slowest first. Thresholds are calibrated against the "5 WPM pro * Klassenstufe" school rule - roughly 25 characters/min at the end of first grade - * with plenty of headroom above it. * * The eighth rung - the dolphin, the T-Rex - sits deliberately high, at about 24 WPM. It * is a realistic second- or third-grade target, which means it stays out of reach and * therefore worth chasing for a long time. Reaching it should feel like an event. * * What each rung is called, looks like and says comes from the skin (`game.ladder`). * Every emoji there is checked to render as the animal it names - the first draft used * 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and * ⭐ for a starfish, which collided with the star rating three lines below it. */ const RUNGS: readonly { id: AnimalId; from: number }[] = [ { id: "snail", from: 0 }, { id: "crab", from: 15 }, { id: "turtle", from: 25 }, { id: "jellyfish", from: 40 }, { id: "fish", from: 55 }, { id: "penguin", from: 75 }, { id: "seal", from: 95 }, { id: "dolphin", from: 120 }, { id: "shark", from: 150 }, { id: "orca", from: 190 }, ]; export const ANIMALS: readonly Animal[] = RUNGS.map((rung) => ({ ...rung, ...SKIN.game.ladder[rung.id] })); /** The last animal shown on the ladder before a run has been fast enough to earn it. * Everything above the dolphin (the rung, whatever the skin draws there) stays hidden * until it is actually reached - see `visibleAnimals`. */ export const SURPRISE_FROM: AnimalId = "dolphin"; export function animalIndex(id: AnimalId): number { return ANIMALS.findIndex((animal) => animal.id === id); } /** Which slice of the ladder a result screen may show. * * Everything up to the dolphin is always visible, earned or not: seeing the animals you * have not reached yet is the whole reason to try again, and a six-year-old needs the * next rung to be visible to aim at it. Above the dolphin the ladder goes dark - those * are a surprise, revealed only once they have actually been reached, and then they stay * revealed. `bestEver` is the fastest animal earned on any lesson so far. */ export function visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): { animals: readonly Animal[]; /** True when faster animals exist that have not been revealed yet. */ moreHidden: boolean; } { const boundary = Math.max( animalIndex(SURPRISE_FROM), animalIndex(earned), bestEver ? animalIndex(bestEver) : -1, ); return { animals: ANIMALS.slice(0, boundary + 1), moreHidden: boundary < ANIMALS.length - 1, }; } const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal])); export function animalById(id: AnimalId): Animal { // Every AnimalId comes from ANIMALS itself, so this cannot miss - but the map lookup // is typed as possibly-undefined and `noUncheckedIndexedAccess` is on. return ANIMAL_BY_ID.get(id) ?? ANIMALS[0]!; } /** The animal for a score. Walks from the fastest down, so the first match wins. */ export function animalFor(points: number): Animal { for (let i = ANIMALS.length - 1; i >= 0; i--) { const animal = ANIMALS[i]!; if (points >= animal.from) return animal; } return ANIMALS[0]!; } /** How far along the current animal this score is, 0..1 - drives the progress bar that * shows how close the next animal is. The top animal is always full. */ export function animalProgress(points: number): number { const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id); const next = ANIMALS[index + 1]; if (!next) return 1; const floor = ANIMALS[index]!.from; return Math.min(1, Math.max(0, (points - floor) / (next.from - floor))); } /** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other * game she will ever play, so it needs no explaining. */ export const STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const; export function starsFor(accuracy: number): 0 | 1 | 2 | 3 { if (accuracy >= STAR_THRESHOLDS.three) return 3; if (accuracy >= STAR_THRESHOLDS.two) return 2; if (accuracy >= STAR_THRESHOLDS.one) return 1; return 0; } /** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */ export function isPassed(accuracy: number): boolean { return accuracy >= STAR_THRESHOLDS.two; } export function grade(state: RunState): RunResult { const characters = state.strokes.filter((stroke) => stroke.correct).length; const errors = state.missed.size; const start = state.startedAt; const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start; // A run of one keystroke has no elapsed time between first and last. Treating that as // "infinitely fast" would hand out a top-tier animal for a single letter, so anything // under a second of real typing is scored as if it took a second. const duration = start !== null && end !== null ? Math.max(0, end - start) : 0; const minutes = Math.max(duration, 1000) / 60000; const speed = characters / minutes; const attempts = characters + errors; const accuracy = attempts === 0 ? 0 : characters / attempts; const points = speed * accuracy ** 3; const stars = starsFor(accuracy); return { characters, errors, duration, speed, accuracy, points, stars, animal: animalFor(points).id, passed: isPassed(accuracy), strokes: state.strokes, }; } /** Which of two results is the better one, for the per-lesson personal best. Stars come * first, points break the tie - so a careful run is never displaced by a sloppy fast * one, and the best animal on a lesson card can never go down. */ export function isBetter(candidate: RunResult, best: { stars: number; points: number }): boolean { if (candidate.stars !== best.stars) return candidate.stars > best.stars; return candidate.points > best.points; }