Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by finishing worlds, not by paying for it. A counter that only ever goes up is one more stat competing for attention on the result sheet and the home screen, and one more field to carry through the run payload, the progress file and both test suites. Stars and the animal ladder already say how a run went, so nothing is lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -372,7 +372,6 @@ class TippenSettingsIn(BaseModel):
|
|||||||
class TippenProgressOut(BaseModel):
|
class TippenProgressOut(BaseModel):
|
||||||
lessons: dict[str, TippenLessonProgressOut]
|
lessons: dict[str, TippenLessonProgressOut]
|
||||||
key_stats: dict[str, TippenKeyStatOut]
|
key_stats: dict[str, TippenKeyStatOut]
|
||||||
pearls: int
|
|
||||||
aquarium: list[str]
|
aquarium: list[str]
|
||||||
streak: TippenStreakOut
|
streak: TippenStreakOut
|
||||||
settings: TippenSettingsOut
|
settings: TippenSettingsOut
|
||||||
@@ -395,7 +394,6 @@ class TippenRunIn(BaseModel):
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int = Field(ge=0)
|
|
||||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
|
|||||||
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
||||||
for key, stat in progress.key_stats.items()
|
for key, stat in progress.key_stats.items()
|
||||||
},
|
},
|
||||||
pearls=progress.pearls,
|
|
||||||
aquarium=list(progress.aquarium),
|
aquarium=list(progress.aquarium),
|
||||||
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
||||||
settings=TippenSettingsOut(
|
settings=TippenSettingsOut(
|
||||||
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
|
|||||||
animal=body.animal,
|
animal=body.animal,
|
||||||
points=body.points,
|
points=body.points,
|
||||||
passed=body.passed,
|
passed=body.passed,
|
||||||
pearls=body.pearls,
|
|
||||||
strokes=tuple(
|
strokes=tuple(
|
||||||
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class RunResult:
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int
|
|
||||||
strokes: tuple[Stroke, ...]
|
strokes: tuple[Stroke, ...]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
|
|||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
||||||
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
||||||
pearls: int = 0
|
|
||||||
#: Pets that have moved into the aquarium, in the order they arrived.
|
#: Pets that have moved into the aquarium, in the order they arrived.
|
||||||
aquarium: list[CreatureId] = Field(default_factory=list)
|
aquarium: list[CreatureId] = Field(default_factory=list)
|
||||||
streak: Streak = Field(default_factory=Streak)
|
streak: Streak = Field(default_factory=Streak)
|
||||||
@@ -265,7 +263,7 @@ def record_run(
|
|||||||
curriculum: Curriculum,
|
curriculum: Curriculum,
|
||||||
day: str | None = None,
|
day: str | None = None,
|
||||||
) -> RecordOutcome:
|
) -> RecordOutcome:
|
||||||
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
"""Record a finished run: stars, animal, key stats, streak, and the unlock.
|
||||||
|
|
||||||
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||||
fifth attempt whatever the score. Speed is nowhere in it.
|
fifth attempt whatever the score. Speed is nowhere in it.
|
||||||
@@ -317,7 +315,6 @@ def record_run(
|
|||||||
"lessons": lessons,
|
"lessons": lessons,
|
||||||
"aquarium": aquarium,
|
"aquarium": aquarium,
|
||||||
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
||||||
"pearls": progress.pearls + result.pearls,
|
|
||||||
"streak": _bump_streak(progress.streak, day),
|
"streak": _bump_streak(progress.streak, day),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict:
|
|||||||
"animal": "fish",
|
"animal": "fish",
|
||||||
"points": 42.0,
|
"points": 42.0,
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"pearls": 5,
|
|
||||||
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,6 @@ async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked(
|
|||||||
body = (await client.get("/api/tippen/progress")).json()
|
body = (await client.get("/api/tippen/progress")).json()
|
||||||
assert body["lessons"]["l01"]["unlocked"] is True
|
assert body["lessons"]["l01"]["unlocked"] is True
|
||||||
assert body["lessons"]["l02"]["unlocked"] is False
|
assert body["lessons"]["l02"]["unlocked"] is False
|
||||||
assert body["pearls"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
||||||
|
|||||||
@@ -53,13 +53,12 @@ def _curriculum() -> Curriculum:
|
|||||||
return Curriculum(worlds=worlds, lessons=lessons)
|
return Curriculum(worlds=worlds, lessons=lessons)
|
||||||
|
|
||||||
|
|
||||||
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
|
def _result(*, stars: int, passed: bool, points: float = 10.0) -> RunResult:
|
||||||
return RunResult(
|
return RunResult(
|
||||||
stars=stars, # type: ignore[arg-type]
|
stars=stars, # type: ignore[arg-type]
|
||||||
animal="fish",
|
animal="fish",
|
||||||
points=points,
|
points=points,
|
||||||
passed=passed,
|
passed=passed,
|
||||||
pearls=pearls,
|
|
||||||
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,7 +98,6 @@ def test_save_then_load_round_trips(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert reloaded.lessons["l01"].best_stars == 3
|
assert reloaded.lessons["l01"].best_stars == 3
|
||||||
assert reloaded.lessons["l02"].unlocked is True
|
assert reloaded.lessons["l02"].unlocked is True
|
||||||
assert reloaded.pearls == 3
|
|
||||||
# Atomic write leaves no temp file behind.
|
# Atomic write leaves no temp file behind.
|
||||||
assert list(tmp_path.glob("*.tmp*")) == []
|
assert list(tmp_path.glob("*.tmp*")) == []
|
||||||
|
|
||||||
@@ -209,16 +207,15 @@ def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
|
|||||||
assert step3.progress.aquarium == ["clownfish"]
|
assert step3.progress.aquarium == ["clownfish"]
|
||||||
|
|
||||||
|
|
||||||
def test_pearls_and_streak_accumulate() -> None:
|
def test_streak_accumulates() -> None:
|
||||||
curriculum = _curriculum()
|
curriculum = _curriculum()
|
||||||
progress = fresh_progress(curriculum)
|
progress = fresh_progress(curriculum)
|
||||||
day1 = record_run(
|
day1 = record_run(
|
||||||
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
|
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
|
||||||
)
|
)
|
||||||
day2 = record_run(
|
day2 = record_run(
|
||||||
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
|
day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
|
||||||
)
|
)
|
||||||
assert day2.progress.pearls == 7
|
|
||||||
assert day2.progress.streak.days == 2
|
assert day2.progress.streak.days == 2
|
||||||
assert day2.progress.streak.last_played == "2026-01-02"
|
assert day2.progress.streak.last_played == "2026-01-02"
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ export interface TippenSettings {
|
|||||||
export interface TippenProgress {
|
export interface TippenProgress {
|
||||||
lessons: Record<string, TippenLessonProgress>;
|
lessons: Record<string, TippenLessonProgress>;
|
||||||
key_stats: Record<string, TippenKeyStat>;
|
key_stats: Record<string, TippenKeyStat>;
|
||||||
pearls: number;
|
|
||||||
aquarium: string[];
|
aquarium: string[];
|
||||||
streak: TippenStreak;
|
streak: TippenStreak;
|
||||||
settings: TippenSettings;
|
settings: TippenSettings;
|
||||||
@@ -257,7 +256,6 @@ export interface TippenRunInput {
|
|||||||
animal: string;
|
animal: string;
|
||||||
points: number;
|
points: number;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
pearls: number;
|
|
||||||
strokes: TippenStroke[];
|
strokes: TippenStroke[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,6 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
compact={screen === "run"}
|
compact={screen === "run"}
|
||||||
status={
|
status={
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||||
<span>🦪 {progress.pearls}</span>
|
|
||||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Shown on the right - pearls, streak, a back hint. */
|
/** Shown on the right - streak, best animal, a back hint. */
|
||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
/** Smaller header while a lesson is running, so the target line gets the room. */
|
/** Smaller header while a lesson is running, so the target line gets the room. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
||||||
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
|
|
||||||
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
||||||
<Stat
|
<Stat
|
||||||
label="Schnellstes Tier"
|
label="Schnellstes Tier"
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export function ResultSheet({
|
|||||||
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
||||||
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
||||||
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
||||||
<Stat label="Perlen" value={`+${result.pearls}`} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How close the next animal is. A near miss is the strongest reason to press
|
{/* How close the next animal is. A near miss is the strongest reason to press
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ describe("grade", () => {
|
|||||||
expect(grade(state).errors).toBe(1);
|
expect(grade(state).errors).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("awards pearls even for a bad run", () => {
|
|
||||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stars", () => {
|
describe("stars", () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ function basicProgress(over: Partial<Progress> = {}): Progress {
|
|||||||
return {
|
return {
|
||||||
lessons: {},
|
lessons: {},
|
||||||
keyStats: {},
|
keyStats: {},
|
||||||
pearls: 0,
|
|
||||||
aquarium: [],
|
aquarium: [],
|
||||||
streak: { days: 0, lastPlayed: null },
|
streak: { days: 0, lastPlayed: null },
|
||||||
settings: { sound: true, keyboardHint: "auto" },
|
settings: { sound: true, keyboardHint: "auto" },
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
||||||
* was typed, so a half-finished bubbles round still earns its pearls. */
|
* was typed, so a half-finished bubbles round still earns its stars. */
|
||||||
export function abandonRun(state: RunState, now: number): RunState {
|
export function abandonRun(state: RunState, now: number): RunState {
|
||||||
if (isFinished(state) || state.startedAt === null) return state;
|
if (isFinished(state) || state.startedAt === null) return state;
|
||||||
return { ...state, finishedAt: now };
|
return { ...state, finishedAt: now };
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export interface RunResult {
|
|||||||
animal: AnimalId;
|
animal: AnimalId;
|
||||||
/** Whether this run unlocks the next lesson on its own. */
|
/** Whether this run unlocks the next lesson on its own. */
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
/** Pearls earned - the aquarium currency. */
|
|
||||||
pearls: number;
|
|
||||||
/** Kept for the race-mode ghost and the per-key stats. */
|
/** Kept for the race-mode ghost and the per-key stats. */
|
||||||
strokes: readonly Stroke[];
|
strokes: readonly Stroke[];
|
||||||
}
|
}
|
||||||
@@ -162,12 +160,6 @@ export function isPassed(accuracy: number): boolean {
|
|||||||
return accuracy >= STAR_THRESHOLDS.two;
|
return accuracy >= STAR_THRESHOLDS.two;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
|
|
||||||
* every single run, including a bad one - the aquarium should never stall. */
|
|
||||||
function pearlsFor(characters: number, stars: number): number {
|
|
||||||
return Math.floor(characters / 5) + stars * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function grade(state: RunState): RunResult {
|
export function grade(state: RunState): RunResult {
|
||||||
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
||||||
const errors = state.missed.size;
|
const errors = state.missed.size;
|
||||||
@@ -196,7 +188,6 @@ export function grade(state: RunState): RunResult {
|
|||||||
stars,
|
stars,
|
||||||
animal: animalFor(points).id,
|
animal: animalFor(points).id,
|
||||||
passed: isPassed(accuracy),
|
passed: isPassed(accuracy),
|
||||||
pearls: pearlsFor(characters, stars),
|
|
||||||
strokes: state.strokes,
|
strokes: state.strokes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export interface Settings {
|
|||||||
export interface Progress {
|
export interface Progress {
|
||||||
lessons: Record<string, LessonProgress>;
|
lessons: Record<string, LessonProgress>;
|
||||||
keyStats: Record<string, KeyStat>;
|
keyStats: Record<string, KeyStat>;
|
||||||
pearls: number;
|
|
||||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||||
aquarium: CreatureId[];
|
aquarium: CreatureId[];
|
||||||
streak: { days: number; lastPlayed: string | null };
|
streak: { days: number; lastPlayed: string | null };
|
||||||
@@ -63,7 +62,6 @@ export function progressFromApi(progress: ApiProgress): Progress {
|
|||||||
return {
|
return {
|
||||||
lessons,
|
lessons,
|
||||||
keyStats,
|
keyStats,
|
||||||
pearls: progress.pearls,
|
|
||||||
aquarium: progress.aquarium as CreatureId[],
|
aquarium: progress.aquarium as CreatureId[],
|
||||||
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
||||||
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
||||||
@@ -83,7 +81,6 @@ export function toRunInput(lessonId: string, result: RunResult): TippenRunInput
|
|||||||
animal: result.animal,
|
animal: result.animal,
|
||||||
points: result.points,
|
points: result.points,
|
||||||
passed: result.passed,
|
passed: result.passed,
|
||||||
pearls: result.pearls,
|
|
||||||
strokes,
|
strokes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user