From 923dbf15b634453a3a8a87038515709975378fa0 Mon Sep 17 00:00:00 2001 From: Oscar Armstrong Date: Fri, 16 Oct 2026 03:04:52 +0000 Subject: [PATCH] Clarify scoring with linear bounds and game instructions --- AGENTS.md | 6 + README.md | 1 + docs/GAME-SHELL.md | 10 ++ docs/MAINTENANCE.md | 57 +++++++ public/about.html | 23 +-- public/focus.css | 31 ++++ public/focus.html | 183 ++++++++++++++++------ public/index.html | 183 ++++++++++++++++------ public/night-lab.css | 31 ++++ public/night-lab.html | 183 ++++++++++++++++------ public/studio.css | 31 ++++ public/studio.html | 183 ++++++++++++++++------ src/base/game.js | 11 +- src/design-options/about.html | 11 +- src/design-options/build-options.py | 11 +- src/design-options/game-adapters.js | 4 +- src/design-options/game_menu.py | 2 +- src/design-options/generated-games.js | 19 ++- src/design-options/generated_extension.py | 2 +- src/design-options/how-to-play.css | 31 ++++ src/design-options/how-to-play.html | 38 +++++ src/design-options/player-storage.js | 7 +- src/design-options/round-feedback.js | 12 +- src/design-options/scoring.js | 54 ++++++- src/design-options/seo_extension.py | 6 +- src/design-options/shell_extension.py | 4 +- src/design-options/size-game.js | 2 +- src/design-options/summary-comparisons.js | 3 +- src/design-options/trivia-games.js | 2 +- src/design-options/trivia_extension.py | 2 +- tests/browser-help.cjs | 31 ++++ tests/browser-overlap-scoring.cjs | 23 +++ tests/browser-timeline-scoring.cjs | 5 + tests/verify-half-half.cjs | 2 +- tests/verify-linear-scoring.cjs | 44 ++++++ tests/verify-overlap-scoring.cjs | 25 +++ tests/verify-play-navigation.cjs | 2 +- tests/verify-time-scoring.cjs | 11 ++ tests/verify-timeline-scoring.cjs | 24 +++ 39 files changed, 1066 insertions(+), 244 deletions(-) create mode 100644 src/design-options/how-to-play.css create mode 100644 src/design-options/how-to-play.html create mode 100644 tests/browser-help.cjs create mode 100644 tests/browser-overlap-scoring.cjs create mode 100644 tests/browser-timeline-scoring.cjs create mode 100644 tests/verify-linear-scoring.cjs create mode 100644 tests/verify-overlap-scoring.cjs create mode 100644 tests/verify-time-scoring.cjs create mode 100644 tests/verify-timeline-scoring.cjs diff --git a/AGENTS.md b/AGENTS.md index a27f985..3cb9b19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,9 @@ - Use touch-friendly targets and gestures, accessible labels, and keyboard alternatives where relevant. Do not rely on hover for essential functionality. - Provide functional fallbacks when enhanced browser features are unavailable. - Validate changes at a realistic mobile viewport first, then check larger screens. Preserve a player's progress when applying appearance changes. + +## Scoring policy + +- New games use linear closeness: `round(100 * clamp(1 - error / zeroPointBound, 0, 1))`. Tune the game's error measure and bound; do not add a power curve. +- Score independent dimensions separately and average their closeness before rounding. Opposing errors must not cancel. +- Freeze scoring versions on questions and gate daily changes by challenge date. Preserve historical questions, saved results, and earned scores. diff --git a/README.md b/README.md index 97ac37c..e7ee565 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Free. No login. No downloads. No sound needed. ## Your daily little reality check - **One shared Daily Five.** Five different puzzle types, determined by your device’s date. Players on the same date get the same challenge. +- **One scoring rule.** New practice rounds and daily games from October 16 use linear scoring: halfway to the game’s zero-point error bound earns 50 points. Earlier games retain their original scores. - **Close counts.** Every round is worth 100 points. See your guess and the target together, then inspect small comparisons in your final results. - **Compare with friends.** Share your Daily Five results as a compact scorecard with the date, puzzle emojis, and your total. No answer spoilers. - **Practice when you feel like it.** Open the game menu, then Practice, for a fresh mix or an individual skill. Trivia practice draws from past Daily Fives, keeping upcoming questions out of the pool. diff --git a/docs/GAME-SHELL.md b/docs/GAME-SHELL.md index f9f6584..fda5268 100644 --- a/docs/GAME-SHELL.md +++ b/docs/GAME-SHELL.md @@ -148,3 +148,13 @@ keep scoring and saved question data independent from display text. Optional registry `label` overrides a game name consistently in prompts and summary cards. Run `node tests/browser-results.cjs` to check exact/opposing size errors, long values, source disclosures, and summaries in both themes on small phones. + +New scored games follow the linear scoring policy in `docs/MAINTENANCE.md`. Use +the shared conversion in `scoring.js`; configure the error measure and zero-point +bound. Do not introduce a game-specific power curve. Preserve unversioned +historical questions and only apply the current policy to newly generated rounds. + +The shared How it works dialog lives in `src/design-options/how-to-play.html`, +with scoped styling in `how-to-play.css`. Update its controls, scoring examples, +and legacy explanation when game rules change. It must remain scrollable on +phones with dismissal controls reachable and preserve the active round. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index e81a2da..4369e33 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -60,3 +60,60 @@ for test in tests/verify-*.cjs; do node "$test"; done The build writes the complete playable site into `public/`. GitHub Pages publishes that directory at the domain root through the Actions workflow. CNAME and HTTPS remain configured for `perception.thedanktank.com`. Player identity, saved progress and bounded history remain in the player's browser; scheduling adds no browser storage. The share-preview artwork is generated by `scripts/build-social-image.py`, using the game's bundled fonts and native puzzle drawings. Regeneration needs Pillow with WOFF2 font support; the exported PNG ships as a static asset. + +## When? scoring versions + +New When? practice rounds and daily rounds dated October 16, 2026 onward carry +`timelineScoringVersion: 2` and a frozen `scoringYear`. The zero-point error bound +is `max(5, 0.25 * max(0, scoringYear - answerYear))` years. Score linearly as +`round(100 * max(0, 1 - abs(guessYear - answerYear) / bound))`. A four-year miss +within the five-year baseline earns 20 points. Older events have larger bounds. +Use the challenge year, never the date a replay is opened. Questions without +these fields retain the original 50-year bound and squared scoring curve; do not +rewrite old saved decks or stored scores. Other games' error bounds are unchanged. + +## Time scoring versions + +New Time practice rounds and daily rounds dated October 16, 2026 onward carry +`timeScoringVersion: 2`. Their zero-point error allowance is +`max(1.25 seconds, 0.5 * targetSeconds)`. Score linearly using +`round(100 * max(0, 1 - abs(elapsedSeconds - targetSeconds) / allowance))`. +The floor widens the scoring range on short rounds; it does not create a free +error window. Freeze the version on each question. Unversioned saved questions +and daily dates through October 15 retain the old three-second squared curve. + +## Scoring rule: linear closeness for every game + +All new games must convert their measured error to points linearly: +`round(100 * clamp(1 - error / zeroPointBound, 0, 1))`. Define difficulty through +the game's error measure and bound, rather than adding a power curve. Rhythm +retains its logarithmic distance measure for multiplicative bounds. +Size it up clips width and height independently, averages their closeness, then +rounds once; an oversized dimension cannot cancel an undersized one. + +New practice questions and daily dates on or after October 16, 2026 carry +`scoringVersion: 2`. Question generation, IDs, answers and schedule order stay +unchanged. Do not add that marker to historical or saved questions: their original +scoring remains valid. Time and When? also retain their bound-specific metadata. + +In the linear scoring version, **3D view uses a 90° zero-point rotation bound**; +**Angles uses 180°**. Historical 3D questions retain their 180° bound and original +curve. A 45° 3D miss earns 50; a 90° miss earns zero. + +## Inner-box perspective, duration, and trace overlap + +Questions with `scoringVersion: 2` use these rules immediately in practice and +from October 16, 2026 in daily challenges. Earlier questions retain their original +bounds, curves, and thin-line rendering. + +- Perspective: error is the point distance divided by 40% of the inner-box + diagonal, `0.4 * 0.65 * hypot(360, 260)` (about 115.4 logical units). +- How long?: error is `abs(durationValue(guess) / answer - 1) / 0.5`. + A 25% miss earns 50 points; 50% short or long earns zero. +- Line memory: points are `round(100 * intersection / union)` of the two + round-capped, round-joined strokes, each 12 units wide on a 400×300 board. + A fixed half-unit raster makes scoring independent of viewport, DPR, drawing + speed, direction, and retracing. Extra marks increase the union and lower the + score. The target's dashed center distinguishes it from the player's stroke. +- Color memory keeps its wheel-distance scoring. Internal `colour`/`memory` + identifiers and historical question text stay intact; UI uses American spelling. diff --git a/public/about.html b/public/about.html index 9221fcb..7e65b9d 100644 --- a/public/about.html +++ b/public/about.html @@ -1,31 +1,31 @@ About Perception — How to Play the Daily Puzzle Game - + - + - + - + - - + +
◯ PERCEPTION

How sharp is your perception?

Five quick puzzles. Five different ways to get surprisingly close—or spectacularly wrong.

-

Perception is a free daily browser game combining visual puzzles, timing, memory, and trivia. Explore an answer space: rotate a 3D shape, pick a colour on a wheel, slide through a timeline, or turn a duration dial. Make your guess, then see how close you came.

+

Perception is a free daily browser game combining visual puzzles, timing, memory, and trivia. Explore an answer space: rotate a 3D shape, pick a color on a wheel, slide through a timeline, or turn a duration dial. Make your guess, then see how close you came.

Play today’s Daily Five →

One daily challenge. 500 possible points.

  1. Open the game and jump straight into Daily Five.
  2. Play five different puzzle types. Every round is worth up to 100 points.
  3. See your guess and the target together, with an explanation after each answer.
  4. Finish your five rounds and share your results to compare with friends.
@@ -39,13 +39,18 @@

Eleven ways to test your judgement

Size it upStart with one square. Drag the corner to make a rectangle the requested number of squares wide and tall; the shaded starting square stays fixed as your reference. RhythmWatch a silent pulse and tap its tempo. AnglesRotate a line to match the requested angle. -Colour memoryRemember a swatch, then find it on a colour wheel. +Color memoryRemember a swatch, then find it on a color wheel. Time awarenessStart, wait, and stop when you think the time is up. When?Slide the timeline to the year of an event. How long?Estimate a duration from sport, space, films, or everyday life.

How scoring works

-

Size it up gives width and height equal weight: each dimension earns up to 50 points using squared closeness. A 5% error in both dimensions earns 81/100; a 50% error in one dimension earns zero for that dimension. The reveal shows your dashed outline and the solid target grid.

Closer guesses earn more points. Each game measures error in its own units—degrees, seconds, years, position, or proportion—then uses the same scoring curve. An error of 10% of that game’s zero-point bound earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. An exact answer earns 100.

+

Time uses linear scoring in daily challenges from October 16, 2026, and in new practice rounds. Its zero-point allowance is the greater of 1.25 seconds or 50% of the target duration. Only an exact match earns full accuracy; the minimum allowance is not a free-error window. Earlier daily rounds and saved results keep their original scoring.

+

When? uses linear scoring in daily challenges from October 16, 2026, and in new practice rounds. Its zero-point allowance is the greater of five years or 25% of the event’s age, measured in the challenge year. Within the five-year allowance, each year missed costs 20 points: a four-year miss earns 20. Earlier daily rounds and saved results retain their original scoring.

+

All new practice rounds and daily challenges from October 16, 2026 use linear scoring. Each game measures error against its own zero-point bound, then awards 100 times the remaining closeness. Using 10% of that allowance earns 90 points; 25% earns 75; half earns 50. An exact match earns 100, and reaching the bound earns zero.

+

For the new linear scoring version, 3D view reaches zero points at 90° of rotation error; Angles reaches zero at 180°. A 45° miss earns 50 points in 3D view, while a 90° miss earns 50 in Angles.

+

Size it up scores width and height independently, each worth up to 50 points, then rounds the combined score. A 5% miss in both dimensions earns 90/100. A 50% error in one dimension earns zero for that dimension. Earlier daily rounds and saved questions retain their original scoring.

+

Perspective measures your miss against the inner box: 40% of its diagonal earns zero. How long? reaches zero at 50% shorter or longer than the target; a 25% miss earns 50 points. Line memory uses equal-width strokes: your score is the percentage of their combined area that overlaps. Extra marks count against you. These rules apply to the new scoring version.

The game’s “How scoring works” instructions list the bounds for each puzzle. Your results show small comparisons so you can see where you were close.

A little more practice?

Open the Practice menu to pick an individual game or a fresh mixed five. Daily Five is the only daily challenge; individual games are practice-only. Trivia practice uses questions from previous Daily Fives, keeping today’s, upcoming, and unused trivia out of the practice pool. As the archive grows, sessions offer up to five different past questions.

diff --git a/public/focus.css b/public/focus.css index 101081d..be0b638 100644 --- a/public/focus.css +++ b/public/focus.css @@ -531,3 +531,34 @@ body:has(.summary) .progress i{width:17px;height:3px} .summary .share-status:empty{min-height:0;margin:0} #playarea[data-phase="reveal"] .size-handle{display:none} @media(min-width:761px){body:has(.summary)>main{padding-inline:28px}} +/* Keep explanation scrollable while dismissal stays reachable on phones. */ +#helpdialog{padding:0;width:calc(100% - 24px);max-width:600px;max-height:calc(100dvh - 32px);overflow:auto;overscroll-behavior:contain;scroll-padding:112px 0 88px} +#helpdialog .help-heading{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:22px 24px 16px;background:var(--surface);border-bottom:1px solid var(--line)} +#helpdialog .help-heading .eyebrow{font-size:10px;margin:0 0 5px;letter-spacing:1.4px} +#helpdialog h2{margin:0;font-size:30px;line-height:1.15;letter-spacing:-1px} +#helpdialog .circle{float:none;flex:none;min-width:44px;min-height:44px} +#helpdialog .help-body{padding:0 24px 24px} +#helpdialog p{font-size:14px;line-height:1.65;margin:14px 0;color:var(--muted)} +#helpdialog strong{color:var(--ink)} +#helpdialog h3{font-size:19px;line-height:1.3;letter-spacing:-.4px;margin:26px 0 10px} +#helpdialog .help-modes{display:grid;grid-template-columns:1fr 1fr;gap:16px;border-bottom:1px solid var(--line);padding-bottom:8px} +#helpdialog .help-modes strong{display:block;margin-bottom:4px} +#helpdialog .help-modes p{font-size:13px;margin-top:0} +#helpdialog .help-score-scale{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;list-style:none;padding:16px 0;margin:0} +#helpdialog .help-score-scale li{display:flex;flex-direction:column;gap:8px;min-width:0} +#helpdialog .help-score-bar{display:block;height:8px;overflow:hidden;background:var(--line);border-radius:4px;border:1px solid var(--muted)} +#helpdialog .help-score-bar i{display:block;height:100%;background:var(--accent)} +#helpdialog .help-score-scale strong{font-size:26px;line-height:1.2;font-variant-numeric:tabular-nums} +#helpdialog .help-score-scale small{font-size:12px;color:var(--muted);font-weight:400} +#helpdialog .help-score-scale li>span:last-child{font-size:12px;line-height:1.5;color:var(--muted)} +#helpdialog .help-example{padding:14px 16px;border-left:3px solid var(--accent);background:var(--paper);border-radius:0 8px 8px 0} +#helpdialog .help-version{font-size:12px;border-top:1px solid var(--line);padding-top:14px} +#helpdialog details{border-bottom:1px solid var(--line)} +#helpdialog summary{cursor:pointer;padding:15px 4px;min-height:48px;font-size:14px;font-weight:600;line-height:1.5;color:var(--ink)} +#helpdialog summary span{display:block;margin:4px 0 0 17px;font-size:12px;color:var(--muted);font-weight:400} +#helpdialog details>div{padding:0 4px 6px 17px} +#helpdialog details>div p:first-child{margin-top:0} +#helpdialog .help-controls{margin-top:20px} +#helpdialog .help-footer{position:sticky;bottom:0;padding:14px 24px;background:var(--surface);border-top:1px solid var(--line);z-index:2} +#helpdialog .help-footer .primary{width:100%;min-height:48px} +@media(max-width:360px){#helpdialog .help-heading{padding:18px 18px 14px}#helpdialog .help-body{padding:0 18px 18px}#helpdialog .help-footer{padding:12px 18px}#helpdialog .help-modes{grid-template-columns:1fr;gap:0}} diff --git a/public/focus.html b/public/focus.html index 806746d..1b67550 100644 --- a/public/focus.html +++ b/public/focus.html @@ -1,5 +1,5 @@ -Perception — Daily Visual Puzzles & Trivia - + - + - + - + - - + +
p.PERCEPTION
A daily perception game
-
ROUND 01 / 05
0 / 500
-
-
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
+
ROUND 01 / 05
0 / 500
+
+
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open the game menu, then Practice, to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Size it up: start with one square and resize the rectangle the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. Drag the corner freely. Arrow keys adjust each dimension; Shift makes larger steps. The reveal overlays the target grid on your dashed outline. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm: 1.5 times or two-thirds of the target. Size it up: width and height each earn up to 50 points; a 50% dimension error earns zero for that dimension. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

Perception — Daily Visual Puzzles & Trivia - + - + - + - + - - + +
p.PERCEPTION
A daily perception game
-
ROUND 01 / 05
0 / 500
-
-
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
+
ROUND 01 / 05
0 / 500
+
+
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open the game menu, then Practice, to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Size it up: start with one square and resize the rectangle the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. Drag the corner freely. Arrow keys adjust each dimension; Shift makes larger steps. The reveal overlays the target grid on your dashed outline. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm: 1.5 times or two-thirds of the target. Size it up: width and height each earn up to 50 points; a 50% dimension error earns zero for that dimension. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

Perception — Daily Visual Puzzles & Trivia - + - + - + - + - - + +
p.PERCEPTION
A daily perception game
-
ROUND 01 / 05
0 / 500
-
-
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
+
ROUND 01 / 05
0 / 500
+
+
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open the game menu, then Practice, to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Size it up: start with one square and resize the rectangle the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. Drag the corner freely. Arrow keys adjust each dimension; Shift makes larger steps. The reveal overlays the target grid on your dashed outline. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm: 1.5 times or two-thirds of the target. Size it up: width and height each earn up to 50 points; a 50% dimension error earns zero for that dimension. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

Perception — Daily Visual Puzzles & Trivia - + - + - + - + - - + +
p.PERCEPTION
A daily perception game
-
ROUND 01 / 05
0 / 500
-
-
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
+
ROUND 01 / 05
0 / 500
+
+
See a little sharper.

Play Perception

Trust your eye.

Practice

Pick a skill

Play. Repeat. Improve.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open the game menu, then Practice, to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Size it up: start with one square and resize the rectangle the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. Drag the corner freely. Arrow keys adjust each dimension; Shift makes larger steps. The reveal overlays the target grid on your dashed outline. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm: 1.5 times or two-thirds of the target. Size it up: width and height each earn up to 50 points; a 50% dimension error earns zero for that dimension. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

', '

Recent results

') themed+='\nbindHistoryNavigation();\nbindGameChrome();\n' html=html.replace('
','
') -common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text()+Path('src/design-options/memory-countdown.css').read_text()+Path('src/design-options/view-idle.css').read_text()+Path('src/design-options/app-layout.css').read_text()+Path('src/design-options/size-game.css').read_text()+Path('src/design-options/game-skin.css').read_text()+Path('src/design-options/result-layout.css').read_text() +common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text()+Path('src/design-options/memory-countdown.css').read_text()+Path('src/design-options/view-idle.css').read_text()+Path('src/design-options/app-layout.css').read_text()+Path('src/design-options/size-game.css').read_text()+Path('src/design-options/game-skin.css').read_text()+Path('src/design-options/result-layout.css').read_text()+Path('src/design-options/how-to-play.css').read_text() theme_assets={key:key+'.css?v='+hashlib.sha256((base+css+common_css).encode()).hexdigest()[:12] for key,css in styles.items()} theme_preference='window.perceptionThemeAssets='+json.dumps(theme_assets)+';\n'+theme_preference for key,css in styles.items(): diff --git a/src/design-options/game-adapters.js b/src/design-options/game-adapters.js index ba4dcab..7de1267 100644 --- a/src/design-options/game-adapters.js +++ b/src/design-options/game-adapters.js @@ -7,12 +7,12 @@ const memoryArtwork={ registerGame('view3d',{initialPhase:'ready',reveal:(q,value)=>animateViewResult(q,value),render:q=>viewRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember this view.':'Match the view.',subtitle:phase==='ready'?'Look closely. Then turn it back.':'Rotate the shape to match what you saw.',hint:'Study the shape. You’ll have three seconds.'})}); registerGame('balance',{reveal:revealBalance,render:q=>balanceRound(q),presentation:q=>q.fruit?{title:'Make an even split.',subtitle:'One cut. Two equal portions.'}:{title:q.title,subtitle:'Find the centre of balance.'}}); registerGame('halfhalf',{reveal:revealBalance,render:q=>halfRound(q),presentation:()=>({title:'Make an even split.',subtitle:'One cut. Two equal portions.'})}); -registerGame('motion',{initialPhase:'ready',reveal:(q,value)=>drawTrace(value,q.answer),ready:{artwork:memoryArtwork.motion,caption:'A line will appear here.'},render:q=>motionRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember the line.':'Trace it from memory.',subtitle:phase==='ready'?'Look once. Then trace it from memory.':'One stroke. As close as you can.'})}); +registerGame('motion',{initialPhase:'ready',reveal:(q,value)=>drawTrace(value,q.answer,q),ready:{artwork:memoryArtwork.motion,caption:'A line will appear here.'},render:q=>motionRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember the line.':'Trace it from memory.',subtitle:phase==='ready'?'Look once. Then trace it from memory.':'One stroke. As close as you can.'})}); registerGame('perspective',{reveal:(q,value)=>drawPerspective(q,value,true),render:q=>perspectiveRound(q),presentation:()=>({title:'Where do the lines meet?',subtitle:'Find the vanishing point.'})}); registerGame('proportion',{reveal:(q,value)=>drawProportion(value,q,true),render:q=>proportionRound(q),presentation:q=>isSizeQuestion(q)?{title:q.wide+' '+(q.wide===1?'square':'squares')+' wide.\n'+q.tall+' '+(q.tall===1?'square':'squares')+' tall.',subtitle:'The shaded square is your reference.'}:{title:q.title,subtitle:'Stretch a rectangle to match the ratio.'}}); registerGame('rhythm',{render:q=>rhythmRound(q),presentation:()=>({title:'Find your rhythm.',subtitle:'Watch the pulse. Then tap it back.',hint:'Five flashes. Five taps. No sound.'})}); registerGame('angle',{reveal:(q,value)=>drawAngle(value,q.answer),render:q=>angleRound(q),presentation:q=>({title:'Make a '+q.answer+'° angle.',subtitle:'One number. Trust your eye.'})}); -registerGame('colour',{initialPhase:'ready',reveal:q=>{$('wheel').setAttribute('aria-disabled','true');drawWheel(q.answer);},ready:{artwork:memoryArtwork.colour,caption:'A colour will appear here.'},render:q=>memoryRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember a colour.':'Find that colour.',subtitle:phase==='ready'?'Look closely. Then find it on the wheel.':'Choose the hue and how vivid it is.',hint:phase==='ready'?'You’ll have two seconds to remember it.':''})}); +registerGame('colour',{label:'Color memory',initialPhase:'ready',reveal:q=>{$('wheel').setAttribute('aria-disabled','true');drawWheel(q.answer);},ready:{artwork:memoryArtwork.colour,caption:'A color will appear here.'},render:q=>memoryRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember a color.':'Find that color.',subtitle:phase==='ready'?'Look closely. Then find it on the wheel.':'Choose the hue and how vivid it is.',hint:phase==='ready'?'You’ll have two seconds to remember it.':''})}); registerGame('time',{label:'Time',reveal:(q,value)=>{$('game-stage').querySelector('.time-cue').innerHTML=`${value.toFixed(2)}SECONDS ELAPSED`;},render:q=>timeRound(q),presentation:()=>({title:'Trust your internal clock.',subtitle:'Start. Wait. Stop.'})}); registerGame('timeline',{render:q=>timelineRound(q),presentation:()=>({subtitle:'A moment in history. Your best guess.',hint:'Scroll to a year. The centre is your pick.'})}); registerGame('duration',{render:q=>durationRound(q),presentation:q=>({subtitle:q.caption,hint:'Turn the dial to your best guess.'})}); diff --git a/src/design-options/game_menu.py b/src/design-options/game_menu.py index cb107b0..50921de 100644 --- a/src/design-options/game_menu.py +++ b/src/design-options/game_menu.py @@ -12,7 +12,7 @@ 'proportion': ('Measure with your eye', ''), 'rhythm': ('Catch the beat', ''), 'angle': ('Feel the degrees', ''), - 'memory': ('Hold a colour in mind', ''), + 'memory': ('Hold a color in mind', ''), 'time': ('Count on your instinct', ''), 'timeline': ('Place it in history', ''), 'duration': ('Put time in perspective', ''), diff --git a/src/design-options/generated-games.js b/src/design-options/generated-games.js index 0d8883e..f88e2fd 100644 --- a/src/design-options/generated-games.js +++ b/src/design-options/generated-games.js @@ -29,6 +29,11 @@ function syncGameURL(game,mode){ history.replaceState(null,'',url.pathname+url.search+url.hash); } function makeDeck(day,game=mini){ + const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); + const deck=makeDeckForSeed(key,game); + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; +} +function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; // Accept an RNG for existing tooling; normal play passes a date or a practice-session seed. const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); @@ -36,13 +41,13 @@ function makeDeck(day,game=mini){ if(!GAME_TYPES.includes(type))throw new Error('Unknown mini-game'); if(type==='balance'){const kinds=shuffleWith(rng(hashSeed(key+'|half-half-objects-v1')),HALF_OBJECTS);return kinds.map((kind,round)=>halfQuestion(rng(hashSeed(key+'|half-half-v1|'+round)),round,kind));} if(type==='proportion'&&(!validDay(key)||dayNumber(key)>=dayNumber(SIZE_DAILY_START)))return Array.from({length:5},(_,round)=>sizeQuestion(rng(hashSeed(key+'|proportion|v2|'+round)),round)); - if(['angle','time','rhythm','proportion','motion','view3d'].includes(type))return Array.from({length:5},(_,round)=>generatedQuestion(type,rng(hashSeed(key+'|'+type+'|v1|'+round)),round)); - return makeGameDeck(rng(hashSeed(key+'|'+type+'|v1')),type); + if(['angle','time','rhythm','proportion','motion','view3d'].includes(type))return Array.from({length:5},(_,round)=>{const q=generatedQuestion(type,rng(hashSeed(key+'|'+type+'|v1|'+round)),round);if(type==='time'&&(!validDay(key)||dayNumber(key)>=dayNumber(TIME_SCORING_START)))q.timeScoringVersion=2;return q;}); + return makeGameDeck(rng(hashSeed(key+'|'+type+'|v1')),type,validDay(key)?key:null); }; if(game==='mix'&&/^\d{4}-\d{1,2}-\d{1,2}$/.test(key)){ const dateKey=key.split('-').map((n,i)=>i?String(Number(n)).padStart(2,'0'):n).join('-'); const daily=typeof DAILY_TRIVIA_SCHEDULE==='undefined'?null:DAILY_TRIVIA_SCHEDULE[dateKey]; - if(daily)return daily.types.map(type=>daily.trivia[type]?scheduledTriviaQuestion(daily.trivia[type]):forGame(type)[0]); + if(daily)return daily.types.map(type=>daily.trivia[type]?scheduledTriviaQuestion(daily.trivia[type],key):forGame(type)[0]); // A delayed schedule job must never accidentally recycle trivia. Procedural games // keep Daily five playable beyond the published horizon, deterministically. return shuffleWith(rng(hashSeed(key+'|schedule-v1')),GAME_TYPES.filter(type=>!['timeline','duration'].includes(type))).slice(0,5).map(type=>forGame(type)[0]); @@ -108,12 +113,14 @@ function makeBlockShape(random){ return {boxes}; } -function scheduledTriviaQuestion(id){ +function scheduledTriviaQuestion(id,challengeDay=null){ const f=TRIVIA_BY_ID[id];if(!f)throw new Error('Missing scheduled trivia'); - return {id:f.id,type:f.type,skill:f.type==='timeline'?'When?':'How long?',title:f.title,answer:f.answer,caption:f.caption,explain:f.explain,source:[f.source.name,f.source.url],tip:'Answer: '+(f.type==='timeline'?f.answer:durationText(f.answer))}; + const q={id:f.id,type:f.type,skill:f.type==='timeline'?'When?':'How long?',title:f.title,answer:f.answer,caption:f.caption,explain:f.explain,source:[f.source.name,f.source.url],tip:'Answer: '+(f.type==='timeline'?f.answer:durationText(f.answer))}; + if(f.type==='timeline'&&(challengeDay===null||dayNumber(challengeDay)>=dayNumber(TIMELINE_SCORING_START))){q.timelineScoringVersion=2;q.scoringYear=Number((challengeDay||calendarDay()).split('-')[0]);} + return q; } function practiceLabel(game,mode){ - const names={mix:'Fresh mix',view3d:'3D View',balance:'Half & Half',halfhalf:'Half & Half',motion:'Line memory',perspective:'Perspective',proportion:'Size it up',rhythm:'Rhythm',angle:'Angles',memory:'Colour memory',time:'Time',timeline:'When?',duration:'How long?'}; + const names={mix:'Fresh mix',view3d:'3D View',balance:'Half & Half',halfhalf:'Half & Half',motion:'Line memory',perspective:'Perspective',proportion:'Size it up',rhythm:'Rhythm',angle:'Angles',memory:'Color memory',time:'Time',timeline:'When?',duration:'How long?'}; return mode==='practice'?'Practice: '+(names[game]||game):'Practice'; } diff --git a/src/design-options/generated_extension.py b/src/design-options/generated_extension.py index b1e897e..762a671 100644 --- a/src/design-options/generated_extension.py +++ b/src/design-options/generated_extension.py @@ -1,7 +1,7 @@ from pathlib import Path def augment_generated(script): - script=script.replace('function makeDeck(random,game=mini){','function makeGameDeck(random,game=mini){',1) + script=script.replace('function makeDeck(random,game=mini){','function makeGameDeck(random,game=mini,challengeDay=null){',1) mix_start=script.index(" if(game==='mix')") mix_end=script.index("\n if(game==='view3d')",mix_start) script=script[:mix_start]+script[mix_end:] diff --git a/src/design-options/how-to-play.css b/src/design-options/how-to-play.css new file mode 100644 index 0000000..7b7f1c4 --- /dev/null +++ b/src/design-options/how-to-play.css @@ -0,0 +1,31 @@ +/* Keep explanation scrollable while dismissal stays reachable on phones. */ +#helpdialog{padding:0;width:calc(100% - 24px);max-width:600px;max-height:calc(100dvh - 32px);overflow:auto;overscroll-behavior:contain;scroll-padding:112px 0 88px} +#helpdialog .help-heading{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:22px 24px 16px;background:var(--surface);border-bottom:1px solid var(--line)} +#helpdialog .help-heading .eyebrow{font-size:10px;margin:0 0 5px;letter-spacing:1.4px} +#helpdialog h2{margin:0;font-size:30px;line-height:1.15;letter-spacing:-1px} +#helpdialog .circle{float:none;flex:none;min-width:44px;min-height:44px} +#helpdialog .help-body{padding:0 24px 24px} +#helpdialog p{font-size:14px;line-height:1.65;margin:14px 0;color:var(--muted)} +#helpdialog strong{color:var(--ink)} +#helpdialog h3{font-size:19px;line-height:1.3;letter-spacing:-.4px;margin:26px 0 10px} +#helpdialog .help-modes{display:grid;grid-template-columns:1fr 1fr;gap:16px;border-bottom:1px solid var(--line);padding-bottom:8px} +#helpdialog .help-modes strong{display:block;margin-bottom:4px} +#helpdialog .help-modes p{font-size:13px;margin-top:0} +#helpdialog .help-score-scale{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;list-style:none;padding:16px 0;margin:0} +#helpdialog .help-score-scale li{display:flex;flex-direction:column;gap:8px;min-width:0} +#helpdialog .help-score-bar{display:block;height:8px;overflow:hidden;background:var(--line);border-radius:4px;border:1px solid var(--muted)} +#helpdialog .help-score-bar i{display:block;height:100%;background:var(--accent)} +#helpdialog .help-score-scale strong{font-size:26px;line-height:1.2;font-variant-numeric:tabular-nums} +#helpdialog .help-score-scale small{font-size:12px;color:var(--muted);font-weight:400} +#helpdialog .help-score-scale li>span:last-child{font-size:12px;line-height:1.5;color:var(--muted)} +#helpdialog .help-example{padding:14px 16px;border-left:3px solid var(--accent);background:var(--paper);border-radius:0 8px 8px 0} +#helpdialog .help-version{font-size:12px;border-top:1px solid var(--line);padding-top:14px} +#helpdialog details{border-bottom:1px solid var(--line)} +#helpdialog summary{cursor:pointer;padding:15px 4px;min-height:48px;font-size:14px;font-weight:600;line-height:1.5;color:var(--ink)} +#helpdialog summary span{display:block;margin:4px 0 0 17px;font-size:12px;color:var(--muted);font-weight:400} +#helpdialog details>div{padding:0 4px 6px 17px} +#helpdialog details>div p:first-child{margin-top:0} +#helpdialog .help-controls{margin-top:20px} +#helpdialog .help-footer{position:sticky;bottom:0;padding:14px 24px;background:var(--surface);border-top:1px solid var(--line);z-index:2} +#helpdialog .help-footer .primary{width:100%;min-height:48px} +@media(max-width:360px){#helpdialog .help-heading{padding:18px 18px 14px}#helpdialog .help-body{padding:0 18px 18px}#helpdialog .help-footer{padding:12px 18px}#helpdialog .help-modes{grid-template-columns:1fr;gap:0}} diff --git a/src/design-options/how-to-play.html b/src/design-options/how-to-play.html new file mode 100644 index 0000000..28b9532 --- /dev/null +++ b/src/design-options/how-to-play.html @@ -0,0 +1,38 @@ + +

HOW PERCEPTION WORKS

Close counts.

+
+

Make your best guess. Lock it in. See your answer beside the target and learn how close you came.

+

Daily FiveFive different games. The same challenge for everyone, changing at midnight Central Time. Up to 500 points.

PracticeOpen the game menu to choose a skill or a fresh mix. Trivia practice uses past daily questions.

+
+

How points work

+

Each round is worth 0–100 points. Each game has an error allowance: the miss that earns zero. Points fall evenly as you use that allowance.

+
    +
  1. 100 ptsExact
    match
  2. +
  3. 50 ptsHalf the
    allowance used
  4. +
  5. 0 ptsAt the limit
    or beyond
  6. +
+

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

+

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

+
+
+

The games & their allowances

+

Open a game for the controls, scoring rule, and an example.

+
3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

+
Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

+
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

+
Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

+
Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

+
Angles 180°

Turn the line to the requested angle. Measure counterclockwise from the right-facing baseline.

We use the shortest angular distance. 45° off earns 75; 90° off earns 50; 180° off earns zero.

+
Color memory One wheel radius

Remember the swatch for two seconds, then choose its color on the wheel. Hue goes around the wheel; vividness increases toward the edge.

We measure straight-line distance between your pick and the target on the wheel. Half a wheel radius away earns 50 points; one radius or more earns zero. This measures wheel position, rather than differences between hex codes.

+
Time 50% of the target, minimum 1.25 s

Press Start, wait for the requested time, then Stop. There’s no clock until the reveal.

The zero-point miss is half the target duration or 1.25 seconds, whichever is larger. For a 10-second target, 2.5 seconds early or late earns 50; 5 seconds off earns zero. For a 2-second target, the allowance is 1.25 seconds.

+
When? 25% of its age, minimum 5 years

Scroll the year wheel to put your answer in the center. Lock it in to see the answer and source.

The zero-point miss is one-quarter of the event’s age or five years, whichever is larger. For a recent event, four years off still earns 20 points. For an event 100 years old, the allowance is 25 years. Age is fixed to the challenge year, so an old challenge’s scoring stays the same.

+
How long? 50% of the answer

Turn the duration dial to your best estimate. Use the unit controls and +/− buttons to refine it, then lock in to see the answer and source.

A guess 50% shorter or longer earns zero. For a two-hour answer, 90 minutes or 2½ hours earns 50; one hour or three hours earns zero. We score the duration shown by the controls.

+
+
Touch, mouse & keyboard

Drag, tap, or click the game controls. Focus a control to use the keyboard; arrow keys adjust most answers. Size it up uses left/right for width and up/down for height, with Shift for larger steps. For Line memory, arrows move the cursor and Space adds a point. Color memory also has hue and saturation sliders.

Memory games are silent. Switching away resets an active memory preview or timer.

+
Playing an older challenge?

Daily Fives through October 15, 2026 and previously saved questions keep their original rules and earned scores. Their points fall more steeply: we square the remaining closeness. Using half the allowance earns 25 points instead of 50. That’s why a 3.6 percentage-point miss in an older Half & Half round earns 67 points; under the current rule it earns 82.

Older allowances: 3D view 180°; Line memory average path distance of 20% of the full board diagonal; Perspective 40% of the full board diagonal; Time 3 seconds; When? 50 years; How long? one-quarter to four times the answer, measured proportionally. Angles, Half & Half, Color memory, and Rhythm use the allowances listed above with the older curve.

Older Proportions rounds ask for a ratio, independent of rectangle size, with zero at two-thirds or 1½ times the target ratio. Older Size it up rounds square width and height closeness separately, then average them. These versions remain available when replaying their challenges.

+
+ +
diff --git a/src/design-options/player-storage.js b/src/design-options/player-storage.js index 81a1782..3fbfbb9 100644 --- a/src/design-options/player-storage.js +++ b/src/design-options/player-storage.js @@ -42,6 +42,9 @@ function validSavedGame(game){ if(!game||!Array.isArray(game.deck)||game.deck.length!==5||!Array.isArray(game.results)||game.results.length>5)return false; for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; + if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; + if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; const pairs=points=>Array.isArray(points)&&points.length>=3&&points.every(p=>Array.isArray(p)&&p.length===2&&p.every(Number.isFinite)); if(q.type==='view3d'&&(!q.shape||!Array.isArray(q.shape.boxes)||q.shape.boxes.length<3||q.shape.boxes.length>5||!q.shape.boxes.every(b=>Array.isArray(b)&&b.length===8&&b.slice(0,6).every(Number.isFinite)&&b.slice(3,6).every(n=>n>0)&&/^#[0-9a-f]{6}$/i.test(b[6])&&Array.isArray(b[7])&&b[7].length===6&&b[7].every(v=>typeof v==='boolean'))))return false; @@ -51,6 +54,8 @@ function validSavedGame(game){ if(q.type==='perspective'&&(!q.answer||typeof q.answer.x!=='number'||typeof q.answer.y!=='number'))return false; if(q.type==='colour'&&(!Array.isArray(q.answer)||q.answer.length!==3))return false; if(['angle','time','rhythm','balance','timeline','duration'].includes(q.type)&&!q.fruit&&typeof q.answer!=='number')return false; + if(q.type==='time'&&q.timeScoringVersion!==undefined&&(q.timeScoringVersion!==2||q.answer<=0))return false; + if(q.type==='timeline'&&q.timelineScoringVersion!==undefined&&(q.timelineScoringVersion!==2||!Number.isInteger(q.scoringYear)||q.scoringYear<1850||q.scoringYear>9999))return false; if(q.type==='proportion'){ if(isSizeQuestion(q)){ if(!Number.isInteger(q.wide)||q.wide<1||q.wide>5||!Number.isInteger(q.tall)||q.tall<1||q.tall>4||!Number.isInteger(q.reference)||q.reference<32||q.reference>54||q.answer?.width!==q.wide*q.reference||q.answer?.height!==q.tall*q.reference||q.answer.width>260||q.answer.height>175||!q.start||q.start.width<32||q.start.width>304||q.start.height<32||q.start.height>207)return false; @@ -88,7 +93,7 @@ function saveDailyGame(){ } function browserHistoryHTML(){ const stats=playerData.stats,active=[0,1].includes(dayNumber(calendarDay())-dayNumber(stats.lastDay))?stats.streak:0; - const names={mix:'Daily five',view3d:'3D view',balance:'Half & Half',halfhalf:'Half & Half',motion:'Line memory',perspective:'Perspective',proportion:'Size it up / Proportions',rhythm:'Rhythm',angle:'Angles',memory:'Colour memory',time:'Time',timeline:'When?',duration:'How long?'}; + const names={mix:'Daily five',view3d:'3D view',balance:'Half & Half',halfhalf:'Half & Half',motion:'Line memory',perspective:'Perspective',proportion:'Size it up / Proportions',rhythm:'Rhythm',angle:'Angles',memory:'Color memory',time:'Time',timeline:'When?',duration:'How long?'}; const label=day=>{const [y,m,d]=day.split('-').map(Number);return new Date(y,m-1,d).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});}; return `

Daily five streak: ${active} ${active===1?'day':'days'} · Best: ${stats.best} · Played: ${stats.played}

${playerData.history.slice(-14).reverse().map(h=>`
${escapeHTML(label(h.day))} · ${names[h.game]||'Daily game'}${h.scores.reduce((a,b)=>a+b,0)}/500
`).join('')||'

No saved games yet.

'}

Saved in this browser. Clearing site data resets your history. No cross-device sync.

`; } diff --git a/src/design-options/round-feedback.js b/src/design-options/round-feedback.js index 5023dc8..85562b5 100644 --- a/src/design-options/round-feedback.js +++ b/src/design-options/round-feedback.js @@ -6,14 +6,14 @@ function roundErrorDisplay(q,value){ case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); - case 'perspective':return percent(pointError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); - case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:Math.abs(value-q.answer)<.005?'Spot on':valuesizeAxisDisplay(value,q,axis))};}return percent(Math.abs((value.width/value.height)/q.answer-1)*100,'Ratio error'); - case 'colour':return percent(wheelDistance(value,q.answer)*100,'Colour error','of the wheel radius'); - case 'timeline':return {value:String(Math.abs(value-q.answer)),unit:Math.abs(value-q.answer)===1?'year':'years',label:'Year error'}; - case 'duration':return {value:durationText(Math.abs(durationValue(value)-q.answer)),unit:'',label:'Duration error',note:value===q.answer?'Spot on':value{const {h,s}=rgbHsv(rgb),angle=h*Math.PI/180; return {x:Math.cos(angle)*s,y:Math.sin(angle)*s};}; @@ -23,20 +61,20 @@ function angularDistance(a,b){const delta=Math.abs(a-b)%360;return Math.min(delt function scoringError(value,q){ let error; switch(q.type){ - case 'view3d':error=orientationError(value,q.answer);break; + case 'view3d':return orientationError(value,q.answer)/(q.scoringVersion===2?VIEW3D_LINEAR_BOUND:SCORING_RANGES.view3d); case 'halfhalf':error=halfError(value,q);break; case 'balance':error=q.fruit?halfError(value,q):Math.abs(value-q.answer)/balanceWidth(q);break; case 'angle':error=angularDistance(value,q.answer);break; - case 'time':case 'timeline': - error=Math.abs(value-q.answer);break; - case 'motion':if(value.length<2)return Infinity;error=traceError(value,q.answer);break; - case 'perspective':error=pointError(value,q.answer);break; + case 'timeline':return Math.abs(value-q.answer)/timelineErrorBound(q); + case 'time':return Math.abs(value-q.answer)/timeErrorBound(q); + case 'motion':if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; + case 'perspective':return pointError(value,q.answer)/(.4*perspectiveDiagonal(q)); case 'proportion':if(isSizeQuestion(q)){const e=sizeErrors(value,q);return (e.width+e.height)/(2*SIZE_ERROR_BOUND);}error=proportionError(value,q.answer);break; case 'colour':error=wheelDistance(value,q.answer);break; case 'rhythm':error=Math.abs(Math.log(value/q.answer));break; - case 'duration':error=Math.abs(Math.log(durationValue(value)/q.answer));break; + case 'duration':if(q.scoringVersion===2)return Math.abs(durationValue(value)/q.answer-1)/.5;error=Math.abs(Math.log(durationValue(value)/q.answer));break; default:return value===q.answer?0:Infinity; } return error/SCORING_RANGES[q.type]; } -function scoreFor(value,q){return isSizeQuestion(q)?sizeScore(value,q):scoreFromError(scoringError(value,q));} +function scoreFor(value,q){if(isSizeQuestion(q))return sizeScore(value,q);const error=scoringError(value,q);return q.scoringVersion===2||usesAgeBasedTimeline(q)||usesRelativeTime(q)?linearScoreFromError(error):scoreFromError(error);} diff --git a/src/design-options/seo_extension.py b/src/design-options/seo_extension.py index 2146a0c..5165c6e 100644 --- a/src/design-options/seo_extension.py +++ b/src/design-options/seo_extension.py @@ -3,9 +3,9 @@ ORIGIN='https://perception.thedanktank.com/' SOCIAL_IMAGE=ORIGIN+'assets/social/perception-mix.png' -SOCIAL_ALT='Five Perception puzzles: rotate a 3D shape, remember a colour, judge an angle, tap a rhythm, and place a historical event on a timeline.' +SOCIAL_ALT='Five Perception puzzles: rotate a 3D shape, remember a color, judge an angle, tap a rhythm, and place a historical event on a timeline.' TITLE='Perception — Daily Visual Puzzles & Trivia' -DESCRIPTION='Five quick daily puzzles. Match colours, judge angles, feel time, and test your trivia. Play free, score up to 500, and share your results.' +DESCRIPTION='Five quick daily puzzles. Match colors, judge angles, feel time, and test your trivia. Play free, score up to 500, and share your results.' def metadata(about=False): title='About Perception — How to Play the Daily Puzzle Game' if about else TITLE @@ -39,7 +39,7 @@ def augment_seo(html): html=re.sub(r'.*?','',html,count=1) html=re.sub(r']*>','',html,count=1) html=html.replace('',metadata()+'') - html=html.replace('
','
') + html=html.replace('
','
') return html def build_discovery_files(): diff --git a/src/design-options/shell_extension.py b/src/design-options/shell_extension.py index c3cc4a1..8aa7dd4 100644 --- a/src/design-options/shell_extension.py +++ b/src/design-options/shell_extension.py @@ -37,8 +37,8 @@ def augment_shell(html, script): 'viewRound': ("drawShape3D($('view-canvas'),{yaw:0,pitch:0,roll:0},{shape:q.shape});", "onGameRedraw(()=>drawShape3D($('view-canvas'),$('playarea').dataset.phase==='preview'?q.answer:{yaw:0,pitch:0,roll:0},{shape:q.shape}));"), 'viewPicker': ('redraw();}', "onGameRedraw(()=>{if(locked){drawShape3D(canvas,orientation,{shape:q.shape});drawShape3D(canvas,q.answer,{shape:q.shape,overlay:true,outlineOnly:true,dashed:true});}else redraw();});redraw();}"), 'balanceRound': ('drawBalance(q,support);}', 'onGameRedraw(()=>drawBalance(q,support,locked&&scoreFor(support,q)!==100?Math.sign(q.answer-support)*18:0,locked));drawBalance(q,support);}'), - 'motionRound': ('drawTrace([]);', "onGameRedraw(()=>drawTrace($('playarea').dataset.phase==='preview'?q.answer:[]));drawTrace([]);"), - 'tracePicker': ('drawTrace([]);}', 'onGameRedraw(()=>drawTrace(points,locked?q.answer:undefined));drawTrace([]);}'), + 'motionRound': ('drawTrace([],undefined,q);', "onGameRedraw(()=>drawTrace($('playarea').dataset.phase==='preview'?q.answer:[],undefined,q));drawTrace([],undefined,q);"), + 'tracePicker': ('drawTrace([],undefined,q);}', 'onGameRedraw(()=>drawTrace(points,locked?q.answer:undefined,q));drawTrace([],undefined,q);}'), 'perspectiveRound': ('drawPerspective(q);}', 'onGameRedraw(()=>drawPerspective(q,point,locked));drawPerspective(q);}'), 'legacyProportionRound': ('legacyDrawProportion(size);}', 'onGameRedraw(()=>legacyDrawProportion(size,locked?q.answer:undefined));legacyDrawProportion(size);}'), 'colourPicker': ('drawWheel();\n}', 'onGameRedraw(()=>drawWheel(locked?q.answer:undefined));drawWheel();\n}') diff --git a/src/design-options/size-game.js b/src/design-options/size-game.js index 5795c51..0c9cbcb 100644 --- a/src/design-options/size-game.js +++ b/src/design-options/size-game.js @@ -18,7 +18,7 @@ function sizeQuestion(random,round){ function clampSize(size){return {width:Math.max(SIZE_BOUNDS.min,Math.min(SIZE_BOUNDS.width,size.width)),height:Math.max(SIZE_BOUNDS.min,Math.min(SIZE_BOUNDS.height,size.height))};} function sizeErrors(value,q){return {width:Math.abs(value.width/q.answer.width-1),height:Math.abs(value.height/q.answer.height-1)};} function sizeScore(value,q){ - const e=sizeErrors(value,q),axis=n=>Math.max(0,1-n/SIZE_ERROR_BOUND)**2; + const e=sizeErrors(value,q),axis=n=>{const closeness=Math.max(0,1-n/SIZE_ERROR_BOUND);return q.scoringVersion===2?closeness:closeness**2;}; if(!Number.isFinite(e.width)||!Number.isFinite(e.height)||value.width<=0||value.height<=0)return 0; return Math.round(50*(axis(e.width)+axis(e.height))); } diff --git a/src/design-options/summary-comparisons.js b/src/design-options/summary-comparisons.js index be8d215..b241082 100644 --- a/src/design-options/summary-comparisons.js +++ b/src/design-options/summary-comparisons.js @@ -35,7 +35,8 @@ function restoredComparison({q,guess}){ let content=''; const line=(a,b,target=false)=>``; if(q.type==='angle'){const end=a=>[200+Math.cos(a*Math.PI/180)*125,150-Math.sin(a*Math.PI/180)*125];content=line([200,150],end(q.answer),true)+line([200,150],end(guess));} - if(q.type==='motion')content=``; + if(q.type==='motion'){const width=q.scoringVersion===2?TRACE_STROKE_WIDTH:5;content=`${q.scoringVersion===2?``:''}`;} + if(q.type==='perspective')content=line([guess.x,guess.y],[q.answer.x,q.answer.y],true)+``; if(q.type==='balance'){content=``+line([q.answer,30],[q.answer,235],true)+``;} if(q.type==='proportion'&&isSizeQuestion(q)){const canvas=document.createElement('canvas');canvas.width=400;canvas.height=400;paintSize(canvas,guess,q,true);return `${escapeHTML(sizeFeedback(guess,q))}. Dashed: your rectangle; solid grid: target.`;} diff --git a/src/design-options/trivia-games.js b/src/design-options/trivia-games.js index b86ddd8..1a3118b 100644 --- a/src/design-options/trivia-games.js +++ b/src/design-options/trivia-games.js @@ -2,7 +2,7 @@ function pastTriviaIds(type,before=calendarDay()){ const cutoff=before.split('-').map((n,i)=>i?String(Number(n)).padStart(2,'0'):n).join('-'); return [...new Set(Object.entries(DAILY_TRIVIA_SCHEDULE).filter(([day])=>dayday.trivia[type]).filter(id=>id&&TRIVIA_BY_ID[id]?.type===type))]; } -function triviaDeck(random,type){return shuffleWith(random,pastTriviaIds(type)).slice(0,5).map(scheduledTriviaQuestion);} +function triviaDeck(random,type,challengeDay=null){return shuffleWith(random,pastTriviaIds(type)).slice(0,5).map(id=>scheduledTriviaQuestion(id,challengeDay));} function emptyTriviaPractice(){ shell({type:mini,skill:mini==='timeline'?'When?':'How long?',title:'The practice archive is growing.'},'

Practice uses questions from past Daily Fives. There are no past questions of this type yet. Play today’s Daily Five while the archive grows.

','',{phase:'empty'}); $('roundlabel').textContent='PRACTICE';$('scoremax').textContent=''; diff --git a/src/design-options/trivia_extension.py b/src/design-options/trivia_extension.py index 496f1ab..e8b3307 100644 --- a/src/design-options/trivia_extension.py +++ b/src/design-options/trivia_extension.py @@ -2,7 +2,7 @@ import json, os def augment(html, script): - script=script.replace("if(game==='mix')", "if(['timeline','duration'].includes(game))return triviaDeck(random,game);\n if(game==='mix')",1) + script=script.replace("if(game==='mix')", "if(['timeline','duration'].includes(game))return triviaDeck(random,game,challengeDay);\n if(game==='mix')",1) script=script.replace("'memory','time']).slice", "'memory','time','timeline','duration']).slice",1) script=script.replace('function scoreFor(value,q){',"function scoreFor(value,q){\n if(['timeline','duration'].includes(q.type))return triviaScore(value,q);") script=script.replace("if(q.type==='view3d'){viewRound", "if(q.type==='timeline'){timelineRound(q);return;}\n if(q.type==='duration'){durationRound(q);return;}\n if(q.type==='view3d'){viewRound",1) diff --git a/tests/browser-help.cjs b/tests/browser-help.cjs new file mode 100644 index 0000000..80ea0bf --- /dev/null +++ b/tests/browser-help.cjs @@ -0,0 +1,31 @@ +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({viewport:{width:390,height:844},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[]; + page.on('pageerror',e=>errors.push(e.message));await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort()); + const output='/workspace/artifacts/how-it-works';fs.mkdirSync(output,{recursive:true}); + await page.goto('http://127.0.0.1:4175/?game=proportion&mode=practice');await page.locator('#size-handle').press('Shift+ArrowRight'); + const original=await page.evaluate(()=>JSON.stringify({deck,index,results,guess})); + for(const theme of ['night-lab','focus']){ + await page.selectOption('#theme-select',theme); + for(const viewport of [{width:320,height:667},{width:390,height:844},{width:1024,height:768}]){ + await page.setViewportSize(viewport);await page.locator('#help').click(); + assert(await page.locator('#helpdialog').evaluate(d=>d.open));assert.equal(await page.locator('#helpdialog').getAttribute('aria-labelledby'),'help-title'); + await page.locator('#helpdialog').evaluate(d=>{d.scrollTop=0;d.querySelectorAll('details').forEach(el=>el.open=false)}); + assert.equal(await page.locator('.help-games details').count(),11); + const fit=async()=>{const r=await page.locator('#helpdialog').evaluate(d=>{const r=d.getBoundingClientRect(),close=d.querySelector('#closehelp').getBoundingClientRect(),done=d.querySelector('#gotit').getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,sw:d.scrollWidth,cw:d.clientWidth,close:close.toJSON(),done:done.toJSON(),w:innerWidth,h:innerHeight}});assert(r.left>=0&&r.right<=r.w&&r.top>=0&&r.bottom<=r.h,'dialog outside viewport');assert(r.sw<=r.cw+1,'horizontal overflow');assert(r.close.top>=0&&r.close.bottom<=r.h,'close unreachable');assert(r.done.top>=0&&r.done.bottom<=r.h,'footer unreachable');};await fit(); + if(viewport.width===390)await page.screenshot({path:output+'/scoring-'+theme+'.png'}); + // Every explanation remains readable and operable at narrow phone widths. + for(const detail of await page.locator('.help-games details').all()){await detail.locator('summary').focus();await page.keyboard.press('Enter');assert(await detail.evaluate(d=>d.open));await fit();await page.keyboard.press('Enter');} + await page.locator('.help-games details').filter({has:page.locator('summary',{hasText:'Size it up'})}).locator('summary').click(); + if(viewport.width===390)await page.screenshot({path:output+'/game-rules-'+theme+'.png'}); + await page.locator('#gotit').click();assert(!await page.locator('#helpdialog').evaluate(d=>d.open));assert.equal(await page.evaluate(()=>JSON.stringify({deck,index,results,guess})),original,'help changed game progress'); + await page.locator('#help').click();await page.keyboard.press('Escape');assert(!await page.locator('#helpdialog').evaluate(d=>d.open));assert(await page.locator('#help').evaluate(b=>document.activeElement===b)); + } + } + const examples=await page.evaluate(()=>[ + scoreFor(45,{type:'angle',answer:0,scoringVersion:2}), + scoreFor({yaw:5.5,pitch:0,roll:0},{type:'view3d',answer:{yaw:0,pitch:0,roll:0},scoringVersion:2}), + scoreFor(7.5,{type:'time',answer:10,timeScoringVersion:2,scoringVersion:2}), + scoreFor(5400,{type:'duration',answer:7200,scoringVersion:2})]);assert.deepEqual(examples,[75,94,50,50]); + assert.deepEqual(errors,[]);console.log('Verified How it works on narrow phone, phone and desktop in both themes: all eleven keyboard disclosures, visible dismissal controls, no horizontal overflow, Escape/focus return, preserved round state, and scoring examples.'); +}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-overlap-scoring.cjs b/tests/browser-overlap-scoring.cjs new file mode 100644 index 0000000..b47a823 --- /dev/null +++ b/tests/browser-overlap-scoring.cjs @@ -0,0 +1,23 @@ +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({viewport:{width:390,height:844},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort());await page.clock.install(); + const output='/workspace/artifacts/overlap-scoring';fs.mkdirSync(output,{recursive:true}); + for(const theme of ['night-lab','focus']){ + await page.goto('http://127.0.0.1:4175/?game=motion&mode=practice');await page.selectOption('#theme-select',theme); + await page.evaluate(()=>{deck=makeDeck('practice:overlap-mobile','motion');index=0;results=[];render();});await page.locator('#show-line').click(); + await page.screenshot({path:output+'/line-preview-'+theme+'.png'});await page.clock.fastForward(3100); + // Draw the actual generated curve with a small offset using touch input. + const points=await page.locator('#trace-canvas').evaluate(c=>{const r=c.getBoundingClientRect(),scale=Math.min(r.width/400,r.height/300);return deck[0].answer.map(p=>({x:r.x+(r.width-400*scale)/2+(p.x+3)*scale,y:r.y+(r.height-300*scale)/2+p.y*scale}));}); + const session=await page.context().newCDPSession(page);await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[points[0]]});for(const p of points.slice(1))await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[p]});await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await session.detach(); + await page.screenshot({path:output+'/line-play-'+theme+'.png'});await page.locator('#trace-lock').click(); + const result=await page.evaluate(()=>({score:results[0].score,check:Math.round(100*traceOverlap(results[0].guess,deck[0].answer)),saved:JSON.stringify(results)}));assert.equal(result.score,result.check);assert(result.score>40&&result.score<100); + assert(await page.locator('.round-result-error').innerText().then(s=>s.includes('Line overlap'))); + await page.screenshot({path:output+'/line-reveal-'+theme+'.png'}); + for(const viewport of [{width:320,height:667},{width:768,height:1024}]){await page.setViewportSize(viewport);await page.waitForTimeout(40);assert.equal(await page.evaluate(()=>JSON.stringify(results)),result.saved);assert(await page.evaluate(()=>document.body.scrollWidth<=innerWidth));} + await page.setViewportSize({width:390,height:844});await page.selectOption('#theme-select',theme==='focus'?'night-lab':'focus');assert.equal(await page.evaluate(()=>JSON.stringify(results)),result.saved); + await page.goto('http://127.0.0.1:4175/?game=memory&mode=practice');await page.locator('#game-menu-toggle').click();const menu=await page.locator('#game-menu').innerText();assert(menu.includes('Color memory'));assert(!/colour/i.test(menu));await page.keyboard.press('Escape');assert(!/colour/i.test(await page.locator('#playarea').innerText())); + await page.goto('http://127.0.0.1:4175/?game=perspective&mode=practice');await page.evaluate(()=>{deck=makeDeck('practice:inner-box','perspective');index=0;results=[];render();reveal({x:deck[0].answer.x+PERSPECTIVE_INNER_DIAGONAL*.2,y:deck[0].answer.y});});assert.equal(await page.evaluate(()=>results[0].score),50);assert((await page.locator('.round-result-error').innerText()).includes('inner-box'));await page.screenshot({path:output+'/perspective-reveal-'+theme+'.png'}); + } + assert.deepEqual(errors,[]);console.log('Verified mobile thick-stroke preview/play/reveal with real touch, matching overlap score, resize/theme persistence, Color menu/prompt spelling, and inner-box Perspective feedback in both themes.'); +}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-timeline-scoring.cjs b/tests/browser-timeline-scoring.cjs new file mode 100644 index 0000000..745db3a --- /dev/null +++ b/tests/browser-timeline-scoring.cjs @@ -0,0 +1,5 @@ +// Requires a local build server on port 4175 and Playwright/Chromium. +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({viewport:{width:375,height:667},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort());await page.goto('http://127.0.0.1:4175/?game=timeline'); +for(const legacy of [false,true]){await page.evaluate(legacy=>{const id=Object.values(TRIVIA_BY_ID).find(f=>f.type==='timeline').id,q={...scheduledTriviaQuestion(id,legacy?'2026-10-15':'2026-10-16'),title:'A recent event',answer:2025};deck=Array(5).fill(q);index=0;results=[];render();},legacy);await page.locator('#year-wheel').press('End');for(let i=0;i<5;i++)await page.locator('#year-wheel').press('ArrowUp');await page.locator('#trivia-lock').click();assert.equal(await page.evaluate(()=>results[0].guess),2021);assert.equal(await page.evaluate(()=>results[0].score),legacy?85:20);const committed=await page.evaluate(()=>JSON.stringify(results));for(const theme of ['focus','night-lab']){await page.selectOption('#theme-select',theme);await page.waitForTimeout(80);assert.equal(await page.evaluate(()=>JSON.stringify(results)),committed);assert(await page.evaluate(()=>document.body.scrollHeight<=innerHeight+1));const text=await page.locator('#actionarea').innerText();assert.equal(text.includes('0 points at 5 years off'),!legacy);if(!legacy){const out=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/timeline-scoring';fs.mkdirSync(out,{recursive:true});await page.screenshot({path:out+'/'+theme+'.png'});}}} +assert.deepEqual(errors,[]);console.log('Verified actual keyboard selection: four-year miss earns 20 under the new baseline and 85 under legacy scoring; both themes preserve the result and fit a small phone.');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/verify-half-half.cjs b/tests/verify-half-half.cjs index 5dd626b..c195bdc 100644 --- a/tests/verify-half-half.cjs +++ b/tests/verify-half-half.cjs @@ -21,7 +21,7 @@ const plate={type:'balance',answer:50,points:[[0,0],[100,0],[100,100],[0,100]],s assert.equal(run(`scoreFor(50,${JSON.stringify(plate)})`),100,'Legacy saved balance scores remain valid'); const fruit=run("makeDeck('test','halfhalf')[0]");assert.equal(run(`scoreFor({x:0,y:0,nx:0,ny:0},${JSON.stringify(fruit)})`),0,'Invalid cut earns zero'); assert.equal(run(`scoreFor({x:1000,y:1000,nx:1,ny:0},${JSON.stringify(fruit)})`),0,'Cut missing the fruit earns zero'); -const html=fs.readFileSync('public/index.html','utf8');assert((/]*data-mini="balance"[^>]*>[\s\S]*?Half & Half<\/strong>/.test(html)));assert(html.includes('20 percentage points from an even split (70/30)')); +const html=fs.readFileSync('public/index.html','utf8');assert((/]*data-mini="balance"[^>]*>[\s\S]*?Half & Half<\/strong>/.test(html)));assert(html.includes('Half & Half 20 percentage points'));assert(html.includes('50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero.')); console.log('Verified 500 deterministic fruit puzzles, 4,000 cut orientations, exact area splits, bounded saves, invalid cuts, and legacy score compatibility.'); const drawing={};for(const name of ['clearRect','save','restore','translate','scale','rotate','transform','beginPath','moveTo','lineTo','closePath','clip','fillRect','fill','stroke','arc','ellipse','bezierCurveTo','quadraticCurveTo','setLineDash'])drawing[name]=()=>{};drawing.createLinearGradient=()=>({addColorStop(){}});ctx.renderCanvas={getContext(){return drawing;}};for(const kind of [...run('HALF_OBJECTS'),'pear','strawberry','mango','watermelon','peach']){run(`q=halfQuestion(rng(42),0,'${kind}');drawHalfFruit(renderCanvas,q,null);drawHalfFruit(renderCanvas,q,q.answer,true)`);}console.log('Verified initial and split rendering for all five fruits.'); diff --git a/tests/verify-linear-scoring.cjs b/tests/verify-linear-scoring.cjs new file mode 100644 index 0000000..27e3945 --- /dev/null +++ b/tests/verify-linear-scoring.cjs @@ -0,0 +1,44 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx),plain=v=>JSON.parse(JSON.stringify(v)); +for(const game of ['view3d','balance','motion','perspective','proportion','rhythm','angle','memory','time','timeline','duration']){ + ctx.game=game;const deck=run("makeDeck('practice:linear-rule',game)");assert(deck.length);assert(deck.every(q=>q.scoringVersion===2),game); + for(const q of deck){ctx.testQ=q;assert.equal(run('scoreFor(testQ.answer,testQ)'),100,game+' exact');} + assert.deepEqual(plain(run("makeDeck('practice:linear-rule',game)")),plain(deck)); + for(const day of ['2026-10-14','2026-10-15']){ctx.day=day;assert(run('makeDeck(day,game).every(q=>q.scoringVersion===undefined)'),game+' historical version changed');} + ctx.day='2026-10-16';assert(run('makeDeck(day,game).every(q=>q.scoringVersion===2)'),game+' future version missing'); +} +for(const fraction of [0,.1,.25,.5,.75,1]){ + const expected=Math.round(100*(1-fraction));ctx.fraction=fraction; + const cases=[ + [{type:'angle',answer:0},fraction*180], + [{type:'view3d',answer:{yaw:0,pitch:0,roll:0}},{yaw:fraction*90,pitch:0,roll:0}], + [{type:'perspective',answer:{x:0,y:0}},{x:fraction*run("PERSPECTIVE_INNER_DIAGONAL")*.4,y:0}], + [{type:'halfhalf',fruit:{},points:[{x:0,y:0},{x:100,y:0},{x:100,y:100},{x:0,y:100}]},{x:50+20*fraction,y:50,nx:1,ny:0}], + [{type:'colour',answer:[255,255,255]},run('hsvRgb(0,fraction,1)')], + [{type:'rhythm',answer:100},100*Math.exp(fraction*Math.log(1.5))], + [{type:'duration',answer:6000},6000*(1+fraction*.5)], + [{type:'time',answer:10,timeScoringVersion:2},10+fraction*5], + [{type:'timeline',answer:2025,scoringYear:2026,timelineScoringVersion:2},2025+fraction*5] + ]; + for(const [q,guess]of cases){ctx.testQ={...q,scoringVersion:2};ctx.testGuess=guess;const actual=run('scoreFor(testGuess,testQ)');assert(Math.abs(actual-expected)<=(['duration','colour'].includes(q.type)?1:0),q.type+' fraction '+fraction+': '+actual+' vs '+expected);} +} +ctx.size=run("makeDeck('practice:linear-size','proportion')[0]"); +assert.equal(run('scoreFor({width:size.answer.width*1.05,height:size.answer.height*.95},size)'),90); +assert.equal(run('scoreFor({width:size.answer.width*1.25,height:size.answer.height*.75},size)'),50); +assert.equal(run('scoreFor({width:size.answer.width*1.5,height:size.answer.height},size)'),50); +assert.equal(run('scoreFor({...size.answer,width:size.answer.width*2},{...size,scoringVersion:2})'),50,'oversize must be clipped before averaging'); +assert.equal(run('scoreFor(size.answer,{...size,scoringVersion:undefined})'),100); +assert.equal(run('scoreFor({width:size.answer.width*1.05,height:size.answer.height*.95},{...size,scoringVersion:undefined})'),81,'historical size curve stays squared'); +assert.equal(run("scoreFor(36,{type:'angle',answer:0})"),64);assert.equal(run("scoreFor(36,{type:'angle',answer:0,scoringVersion:2})"),80); +assert.equal(run('linearScoreFromError(Infinity)'),0);assert.equal(run('linearScoreFromError(-1)'),100);assert.equal(run('linearScoreFromError(2)'),0); +const newDeck=run("makeDeck('2026-10-16','angle')");ctx.newDeck=newDeck;assert(run('validSavedGame({deck:newDeck,results:[]})'));assert(!run('validSavedGame({deck:newDeck.map(q=>({...q,scoringVersion:3})),results:[]})')); +assert(run("makeDeck('practice:half-explanation','balance').every(q=>q.explain.includes('53/47 split earns 85 points')&&q.explain.includes('60/40 split earns 50 points'))")); +console.log('Verified linear scoring for all eleven games, shared normalized error levels, equal independent dimensions, clamps, versioned generation, saved-version validation and legacy squared scores.'); + +for(const [degrees,expected]of [[0,100],[9,90],[45,50],[90,0],[135,0],[180,0]]){ctx.degrees=degrees;assert.equal(run("scoreFor({yaw:degrees,pitch:0,roll:0},{type:'view3d',answer:{yaw:0,pitch:0,roll:0},scoringVersion:2})"),expected);} +assert.equal(run("scoreFor({yaw:90,pitch:0,roll:0},{type:'view3d',answer:{yaw:0,pitch:0,roll:0}})"),25,'legacy 3D bound and curve remain intact'); +assert.equal(run("scoreFor(90,{type:'angle',answer:0,scoringVersion:2})"),50); +assert.equal(run("scoreFor(180,{type:'angle',answer:0,scoringVersion:2})"),0); +console.log('Verified new 3D zero-point bound of 90 degrees, unchanged 180-degree Angles bound, and historical 3D scoring.'); diff --git a/tests/verify-overlap-scoring.cjs b/tests/verify-overlap-scoring.cjs new file mode 100644 index 0000000..d1eba99 --- /dev/null +++ b/tests/verify-overlap-scoring.cjs @@ -0,0 +1,25 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx); +const target=[{x:50,y:150},{x:350,y:150}];ctx.q={type:'motion',answer:target,scoringVersion:2};ctx.target=target; +const score=points=>{ctx.testGuess=points;return run('scoreFor(testGuess,q)')}; +assert.equal(score(target),100);assert.equal(score([...target].reverse()),100); +assert.equal(score([{x:50,y:150},{x:150,y:150},{x:200,y:150},{x:350,y:150}]),100,'sampling speed does not change occupancy'); +assert.equal(score([...target,...target.slice().reverse()]),100,'retracing must not add weight'); +assert.equal(score([]),0);assert.equal(score([target[0]]),0); +assert.equal(score(target.map(p=>({...p,y:p.y+12}))),0,'one stroke width away has no overlap'); +const half=score([{x:50,y:150},{x:200,y:150}]);assert(half>=49&&half<=52,'half stroke earns about half points: '+half); +const shifted=score(target.map(p=>({...p,y:p.y+4})));assert(shifted>=45&&shifted<=55,'four-unit shift has about half overlap: '+shifted); +const scribble=[];for(let y=0;y<=300;y+=6){const row=y/6;scribble.push({x:row%2?400:0,y},{x:row%2?0:400,y});}assert(score(scribble)<5,'covering board cannot earn a useful score'); +for(const y of [0,300]){const edge=[{x:0,y},{x:400,y}];ctx.q.answer=edge;assert.equal(score(edge),100,'board-edge exact stroke');}ctx.q.answer=target; +assert.equal(run("scoreFor(target.map(p=>({...p,y:p.y+50})),{type:'motion',answer:target})"),25,'historical trace distance and squared curve'); +for(const delta of [0,.1,.25,.5,1])for(const direction of [-1,1]){ctx.guessDuration=10000*(1+direction*delta);if(ctx.guessDuration<=0)continue;assert.equal(run("scoreFor(guessDuration,{type:'duration',answer:10000,scoringVersion:2})"),Math.max(0,Math.round(100*(1-delta/.5))));} +assert.equal(run("scoreFor(20000,{type:'duration',answer:10000})"),25,'legacy log duration'); +assert.equal(run("scoreFor({x:PERSPECTIVE_INNER_DIAGONAL*.2,y:0},{type:'perspective',answer:{x:0,y:0},scoringVersion:2})"),50); +assert.equal(run("scoreFor({x:100,y:0},{type:'perspective',answer:{x:0,y:0}})"),25,'legacy board scale'); +assert.equal(run("roundErrorDisplay(q,target).label"),'Line overlap'); +assert.equal(run("roundErrorDisplay({type:'perspective',answer:{x:0,y:0},scoringVersion:2},{x:0,y:0}).note"),'of the inner-box diagonal'); +assert.equal(run("gameRegistry.get('colour').label"),'Color memory'); +assert.equal(run("makeDeck('2026-10-15','memory')[0].skill"),'Colour memory','historical payload retained'); +console.log('Verified stroke IoU, reverse/subdivided/retraced strokes, empty/offset/partial/edge/scribble cases, duration symmetry, inner-box scale, feedback, American display spelling, and legacy scores.'); diff --git a/tests/verify-play-navigation.cjs b/tests/verify-play-navigation.cjs index 52d0f87..b5ac325 100644 --- a/tests/verify-play-navigation.cjs +++ b/tests/verify-play-navigation.cjs @@ -23,7 +23,7 @@ el('daily').listeners.click();url=new URL(ctx.location.href);assert(!url.searchP assert(fs.readFileSync('public/index.html','utf8').includes("start(new URLSearchParams(location.search).get('mode')==='practice'?'practice':'daily')"));console.log('Verified selected game and mixed-practice mode follow the URL, while challenge and campaign parameters remain intact.'); run("mini='view3d';start('practice')");assert.equal(el('practice-label').textContent,'Practice: 3D View'); -run("mini='memory';start('practice')");assert.equal(el('practice-label').textContent,'Practice: Colour memory'); +run("mini='memory';start('practice')");assert.equal(el('practice-label').textContent,'Practice: Color memory'); run("mini='mix';start('practice')");assert.equal(el('practice-label').textContent,'Practice: Fresh mix'); el('daily').listeners.click();assert.equal(el('practice-label').textContent,'Practice'); console.log('Verified Practice label follows the selected game and resets for Daily Five.'); diff --git a/tests/verify-time-scoring.cjs b/tests/verify-time-scoring.cjs new file mode 100644 index 0000000..dcc5463 --- /dev/null +++ b/tests/verify-time-scoring.cjs @@ -0,0 +1,11 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx); +for(const [target,bound]of [[2,1.25],[2.5,1.25],[3,1.5],[5,2.5],[10,5],[15,7.5]]){ctx.q={type:'time',answer:target,timeScoringVersion:2};assert.equal(run('timeErrorBound(q)'),bound);for(const [fraction,expected]of [[0,100],[.1,90],[.5,50],[.8,20],[1,0],[2,0]])for(const sign of [-1,1]){ctx.elapsed=target+sign*fraction*bound;assert.equal(run('scoreFor(elapsed,q)'),expected);}} +ctx.q={type:'time',answer:2,timeScoringVersion:2};assert.equal(run('scoreFor(2.25,q)'),80,'floor is not a free-error window');assert.equal(run('scoreFor(Infinity,q)'),0); +assert.equal(run("scoreFor(11,{type:'time',answer:10})"),44,'historical score stays squared');assert.equal(run("scoreFor(11,{type:'time',answer:10,timeScoringVersion:2})"),80); +for(const day of ['2026-10-14','2026-10-15']){ctx.day=day;assert(run("makeDeck(day,'time').every(q=>q.timeScoringVersion===undefined)"));assert(run("makeDeck(day,'mix').filter(q=>q.type==='time').every(q=>q.timeScoringVersion===undefined)"));} +for(const day of ['2026-10-16','2027-1-1','practice:relative-time']){ctx.day=day;assert(run("makeDeck(day,'time').every(q=>q.timeScoringVersion===2)"));} +const deck=run("makeDeck('2026-10-16','time')");ctx.testDeck=deck;assert(run('validSavedGame({deck:testDeck,results:[]})'));assert(!run('validSavedGame({deck:testDeck.map(q=>({...q,timeScoringVersion:3})),results:[]})')); +console.log('Verified Time’s 1.25-second floor, 50% relative bound, linear scores, symmetry, clamps, no free-error window, future/practice cutover and legacy saved scoring.'); diff --git a/tests/verify-timeline-scoring.cjs b/tests/verify-timeline-scoring.cjs new file mode 100644 index 0000000..afed525 --- /dev/null +++ b/tests/verify-timeline-scoring.cjs @@ -0,0 +1,24 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx),plain=v=>JSON.parse(JSON.stringify(v)); +const recent={type:'timeline',answer:2025,timelineScoringVersion:2,scoringYear:2026};ctx.q=recent; +assert.equal(run('timelineErrorBound(q)'),5); +for(const [error,score]of [[0,100],[1,80],[2,60],[3,40],[4,20],[5,0],[10,0]])for(const sign of [-1,1]){ctx.guessValue=recent.answer+sign*error;assert.equal(run('scoreFor(guessValue,q)'),score);} +assert.equal(run('scoreFor(Infinity,q)'),0); +for(const [age,bound]of [[0,5],[1,5],[20,5],[40,10],[100,25],[126,31.5]]){ctx.age=age;assert.equal(run('timelineErrorBound({...q,answer:q.scoringYear-age})'),bound);} +assert.equal(run('timelineErrorBound({...q,answer:2030})'),5,'future event cannot create a negative bound'); +assert.equal(run('scoreFor(2021,q)'),20); +run("calendarDay=()=> '2040-1-1'");assert.equal(run('scoreFor(2021,q)'),20,'replay date must not alter score'); +const old={type:'timeline',answer:2025};ctx.oldQuestion=old;assert.equal(run('scoreFor(2021,oldQuestion)'),85,'unversioned saved questions retain squared 50-year scoring'); +const id=run("Object.values(TRIVIA_BY_ID).find(f=>f.type==='timeline').id");ctx.factId=id; +const oldFact=run("scheduledTriviaQuestion(factId,'2026-10-15')");assert.equal(oldFact.timelineScoringVersion,undefined); +const future=run("scheduledTriviaQuestion(factId,'2026-10-16')");assert.equal(future.timelineScoringVersion,2);assert.equal(future.scoringYear,2026); +assert.equal(run("scheduledTriviaQuestion(factId,'2027-1-1').scoringYear"),2027); +for(const day of ['2026-10-14','2026-10-15']){ctx.testDay=day;assert(run("makeDeck(testDay,'timeline').every(q=>q.timelineScoringVersion===undefined)"));assert(run("makeDeck(testDay,'mix').filter(q=>q.type==='timeline').every(q=>q.timelineScoringVersion===undefined)"));} +run("calendarDay=()=> '2026-10-15'");assert(run("makeDeck('practice:new-timeline','timeline').every(q=>q.timelineScoringVersion===2&&q.scoringYear===2026)")); +for(const [day,entry]of Object.entries(JSON.parse(fs.readFileSync('data/daily-trivia.json')).days))if(day>='2026-10-16'&&entry.types.includes('timeline')){ctx.testDay=day;const q=run("makeDeck(testDay,'mix').find(q=>q.type==='timeline')");assert.equal(q.timelineScoringVersion,2);assert.equal(q.scoringYear,Number(day.slice(0,4)));assert.deepEqual(plain(run("makeDeck(testDay,'mix')")),plain(run("makeDeck(testDay,'mix')")));} +ctx.future=future;assert(run('validSavedGame({deck:Array(5).fill(future),results:[]})')); +assert(!run('validSavedGame({deck:Array(5).fill({...future,timelineScoringVersion:3}),results:[]})')); +assert(!run('validSavedGame({deck:Array(5).fill({...future,scoringYear:NaN}),results:[]})')); +console.log('Verified five-year linear baseline (four years off = 20), age-based bounds, symmetry, clamps, frozen challenge years, future daily/practice generation, saved version validation, and legacy scoring.');