@conduction/docusaurus-preset 3.39.0 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.39.0",
3
+ "version": "3.40.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js",
6
6
  "test": "node --test"
@@ -0,0 +1,210 @@
1
+ /**
2
+ * <LockPick />
3
+ *
4
+ * Keepiq's mini-game, and the one everybody has played before: set the
5
+ * pick, turn the cylinder, feel how far it gives. Close to the sweet
6
+ * spot it turns; far from it the pick strains and eventually snaps.
7
+ * Three picks, and each lock you open is narrower than the last.
8
+ *
9
+ * The rules live in ./engine.js with no DOM and no clock. There is no
10
+ * clock here either: a lock is a puzzle you reason your way into, and
11
+ * hurrying someone who is counting clicks would only make it a worse
12
+ * version of the timed games.
13
+ *
14
+ * Usage on a product page:
15
+ *
16
+ * <LockPick />
17
+ *
18
+ * Fires the shared `connext:gameend` event when the last pick snaps,
19
+ * and listens for `connext:gamereplay`.
20
+ *
21
+ * Accessibility: the dial is a real slider, so arrow keys, Home and End
22
+ * work without any code of ours, and the feedback after every turn is
23
+ * a sentence rather than a bar. A player who cannot see the dial can
24
+ * pick every lock in the game from the feedback alone, which is the
25
+ * property the engine tests pin.
26
+ */
27
+
28
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
29
+ import {translate} from '@docusaurus/Translate';
30
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
31
+ import {createGame, setPosition, turn, summarise, POSITIONS} from './engine';
32
+ import styles from './LockPick.module.css';
33
+
34
+ const GAME_ID = 'lock-pick';
35
+
36
+ /* What the last turn felt like, in words. The thresholds match the
37
+ engine's `give`, so the sentence and the strain always agree. */
38
+ function feelCopy(value) {
39
+ if (value >= 0.97) {
40
+ return translate({id: 'preset.lockPick.feel.almost', message: 'It almost turns. You are within a hair of it.', description: 'Lock-pick feedback when the pick is very close to the sweet spot'});
41
+ }
42
+ if (value >= 0.85) {
43
+ return translate({id: 'preset.lockPick.feel.close', message: 'The cylinder turns a good way, then stops.', description: 'Lock-pick feedback when the pick is close'});
44
+ }
45
+ if (value >= 0.6) {
46
+ return translate({id: 'preset.lockPick.feel.some', message: 'It gives a little.', description: 'Lock-pick feedback when the pick is somewhere near'});
47
+ }
48
+ if (value >= 0.3) {
49
+ return translate({id: 'preset.lockPick.feel.barely', message: 'Barely anything. You are a long way off.', description: 'Lock-pick feedback when the pick is far from the sweet spot'});
50
+ }
51
+ return translate({id: 'preset.lockPick.feel.nothing', message: 'Nothing. The cylinder does not move at all.', description: 'Lock-pick feedback when the pick is nowhere near the sweet spot'});
52
+ }
53
+
54
+ export default function LockPick({className}) {
55
+ const {i18n} = useDocusaurusContext();
56
+ const locale = (i18n && i18n.currentLocale) || 'en';
57
+
58
+ const [game, setGame] = useState(null);
59
+ const gameRef = useRef(null);
60
+ const endedRef = useRef(false);
61
+
62
+ const running = Boolean(game) && !game.over;
63
+
64
+ const begin = useCallback(() => {
65
+ endedRef.current = false;
66
+ const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31)});
67
+ gameRef.current = fresh;
68
+ setGame(fresh);
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ if (!game || !game.over || endedRef.current) return;
73
+ endedRef.current = true;
74
+ if (typeof window === 'undefined') return;
75
+ window.dispatchEvent(new CustomEvent('connext:gameend', {
76
+ detail: {
77
+ id: GAME_ID,
78
+ won: false,
79
+ score: game.score,
80
+ summary: summarise(game, locale),
81
+ title: translate({id: 'preset.lockPick.over.title', message: 'That was the last pick.', description: 'Headline on the game-over dialog after a lock-pick run'}),
82
+ subtitle: translate({id: 'preset.lockPick.over.subtitle', message: 'The lock is still shut, which is rather the point of a good one.', description: 'Subtitle on the game-over dialog after a lock-pick run'}),
83
+ },
84
+ }));
85
+ }, [game, locale]);
86
+
87
+ useEffect(() => {
88
+ if (typeof window === 'undefined') return undefined;
89
+ const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
90
+ window.addEventListener('connext:gamereplay', onReplay);
91
+ return () => window.removeEventListener('connext:gamereplay', onReplay);
92
+ }, [begin]);
93
+
94
+ const moveTo = useCallback((value) => {
95
+ if (!gameRef.current || gameRef.current.over) return;
96
+ const next = setPosition(gameRef.current, value);
97
+ gameRef.current = next;
98
+ setGame(next);
99
+ }, []);
100
+
101
+ const tryTurn = useCallback(() => {
102
+ if (!gameRef.current || gameRef.current.over) return;
103
+ const next = turn(gameRef.current);
104
+ gameRef.current = next;
105
+ setGame(next);
106
+ }, []);
107
+
108
+ const position = game ? game.position : Math.floor(POSITIONS / 2);
109
+ const last = game ? game.last : null;
110
+ /* The cylinder shows what the last turn achieved, not what the
111
+ current position would achieve: showing the latter would hand the
112
+ player the answer by dragging the dial. */
113
+ const turned = last && (last.result === 'held' || last.result === 'snapped') ? last.give : 0;
114
+
115
+ return (
116
+ <section className={[styles.lp, className].filter(Boolean).join(' ')} aria-labelledby="lock-pick-title">
117
+ <header className={styles.head}>
118
+ <div>
119
+ <p className={styles.eyebrow}>
120
+ {translate({id: 'preset.lockPick.eyebrow', message: 'Mini-game', description: 'Eyebrow above the lock-pick game on a product page'})}
121
+ </p>
122
+ <h3 className={styles.title} id="lock-pick-title">
123
+ {translate({id: 'preset.lockPick.title', message: 'Lock pick', description: 'Name of the Keepiq mini-game'})}
124
+ </h3>
125
+ <p className={styles.lede}>
126
+ {translate({id: 'preset.lockPick.lede', message: 'Set the pick, turn the cylinder, feel how far it gives. Every turn tells you how close you were. Three picks, and each lock is tighter than the last.', description: 'One-line explanation of the lock-pick rules'})}
127
+ </p>
128
+ </div>
129
+ <div className={styles.hud} role="status" aria-live="polite">
130
+ <span className={styles.hudPill}>
131
+ {translate({id: 'preset.lockPick.hud.score', message: 'Score {score}', description: 'Score readout on the lock-pick HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
132
+ </span>
133
+ <span className={styles.hudPill}>
134
+ {translate({id: 'preset.lockPick.hud.picks', message: 'Picks {picks}', description: 'Remaining-picks readout on the lock-pick HUD'}, {picks: game ? game.picks : 3})}
135
+ </span>
136
+ <span className={styles.hudPill}>
137
+ {translate({id: 'preset.lockPick.hud.opened', message: 'Opened {opened}', description: 'Opened-locks readout on the lock-pick HUD'}, {opened: game ? game.opened : 0})}
138
+ </span>
139
+ </div>
140
+ </header>
141
+
142
+ <div className={styles.lock}>
143
+ {/* The cylinder, turned as far as the last attempt managed. */}
144
+ <div
145
+ className={styles.cylinder}
146
+ role="img"
147
+ aria-label={translate(
148
+ {id: 'preset.lockPick.cylinder', message: 'The cylinder turned {percent} per cent on the last try', description: 'Accessible description of the lock cylinder. {percent} is how far it turned.'},
149
+ {percent: Math.round(turned * 100)},
150
+ )}>
151
+ <div className={styles.cylinderFill} style={{transform: `rotate(${-90 + turned * 80}deg)`}} />
152
+ <span className={styles.keyhole} aria-hidden="true" />
153
+ </div>
154
+
155
+ <div className={styles.dial}>
156
+ <label className={styles.dialLabel} htmlFor="lock-pick-dial">
157
+ {translate({id: 'preset.lockPick.dialLabel', message: 'Where the pick sits', description: 'Label for the lock-pick dial slider'})}
158
+ </label>
159
+ <input
160
+ id="lock-pick-dial"
161
+ className={styles.slider}
162
+ type="range"
163
+ min={0}
164
+ max={POSITIONS - 1}
165
+ step={1}
166
+ value={position}
167
+ disabled={!running}
168
+ onChange={(e) => moveTo(Number(e.target.value))}
169
+ />
170
+ <div className={styles.pickRow}>
171
+ <span className={styles.pickLabel}>
172
+ {translate({id: 'preset.lockPick.wear', message: 'This pick', description: 'Label for the lock-pick durability bar'})}
173
+ </span>
174
+ <span
175
+ className={styles.wear}
176
+ role="progressbar"
177
+ aria-valuemin={0}
178
+ aria-valuemax={100}
179
+ aria-valuenow={game ? game.durability : 100}>
180
+ <span
181
+ className={[styles.wearFill, game && game.durability <= 35 && styles.wearLow].filter(Boolean).join(' ')}
182
+ style={{width: `${game ? game.durability : 100}%`}}
183
+ />
184
+ </span>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <footer className={styles.foot}>
190
+ <button type="button" className={styles.turn} onClick={tryTurn} disabled={!running}>
191
+ {translate({id: 'preset.lockPick.turn', message: 'Turn the cylinder', description: 'Button that attempts to turn the lock'})}
192
+ </button>
193
+ <button type="button" className={styles.start} onClick={begin}>
194
+ {game
195
+ ? translate({id: 'preset.lockPick.restart', message: 'New lock', description: 'Button that restarts the lock-pick game'})
196
+ : translate({id: 'preset.lockPick.start', message: 'Take a pick', description: 'Button that starts the lock-pick game'})}
197
+ </button>
198
+ <p className={styles.feedback} role="status" aria-live="polite">
199
+ {last && last.result === 'opened' && translate(
200
+ {id: 'preset.lockPick.feedback.opened', message: 'Open, in {attempts}. Worth {points}. The next one is tighter.', description: 'Feedback after opening a lock. {attempts} is how many turns it took, {points} what it scored.'},
201
+ {attempts: last.attempts, points: last.points},
202
+ )}
203
+ {last && last.result === 'snapped' && translate({id: 'preset.lockPick.feedback.snapped', message: 'The pick snapped. The lock is where you left it, so keep going from there.', description: 'Feedback after a pick breaks'})}
204
+ {last && last.result === 'held' && feelCopy(last.give)}
205
+ {!last && translate({id: 'preset.lockPick.hint', message: 'Move the pick with the slider or the arrow keys, then turn. Wild guesses cost the pick; near misses cost very little.', description: 'Hint under the lock-pick board before the first turn'})}
206
+ </p>
207
+ </footer>
208
+ </section>
209
+ );
210
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * <LockPick /> styles.
3
+ *
4
+ * Tokens only. One accent: a pick that is nearly spent. Everything the
5
+ * player needs to reason with is a sentence under the lock, so the
6
+ * cylinder and the bars are illustration rather than information.
7
+ */
8
+
9
+ .lp {
10
+ border: 1px solid var(--c-cobalt-100);
11
+ border-radius: var(--radius-lg);
12
+ background: white;
13
+ padding: clamp(16px, 3vw, 28px);
14
+ font-family: var(--conduction-typography-font-family-body);
15
+ }
16
+
17
+ .head {
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ gap: 16px;
21
+ align-items: flex-start;
22
+ justify-content: space-between;
23
+ margin-bottom: 18px;
24
+ }
25
+
26
+ .eyebrow {
27
+ margin: 0 0 4px;
28
+ font-family: var(--conduction-typography-font-family-code);
29
+ font-size: 11px;
30
+ letter-spacing: 0.12em;
31
+ text-transform: uppercase;
32
+ color: var(--c-orange-knvb);
33
+ }
34
+
35
+ .title {
36
+ margin: 0 0 6px;
37
+ font-size: 20px;
38
+ font-weight: 700;
39
+ color: var(--c-cobalt-900);
40
+ }
41
+
42
+ .lede {
43
+ margin: 0;
44
+ max-width: 58ch;
45
+ font-size: 14px;
46
+ line-height: 1.5;
47
+ color: var(--c-cobalt-700);
48
+ }
49
+
50
+ .hud {
51
+ display: flex;
52
+ gap: 8px;
53
+ flex-wrap: wrap;
54
+ font-family: var(--conduction-typography-font-family-code);
55
+ font-size: 12px;
56
+ font-variant-numeric: tabular-nums;
57
+ }
58
+ .hudPill {
59
+ padding: 6px 10px;
60
+ border-radius: var(--radius-pill);
61
+ background: var(--c-cobalt-50);
62
+ color: var(--c-cobalt-900);
63
+ white-space: nowrap;
64
+ }
65
+
66
+ .lock {
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ align-items: center;
70
+ gap: 24px;
71
+ padding: 18px;
72
+ border-radius: var(--radius-md);
73
+ background: var(--c-cobalt-50);
74
+ max-width: 620px;
75
+ }
76
+
77
+ .cylinder {
78
+ position: relative;
79
+ width: 104px;
80
+ height: 104px;
81
+ flex-shrink: 0;
82
+ border-radius: 50%;
83
+ background: white;
84
+ border: 2px solid var(--c-cobalt-200);
85
+ overflow: hidden;
86
+ }
87
+
88
+ /* The bar across the cylinder, rotated as far as the last turn got. */
89
+ .cylinderFill {
90
+ position: absolute;
91
+ top: 50%;
92
+ left: 50%;
93
+ width: 66px;
94
+ height: 8px;
95
+ margin: -4px 0 0 -33px;
96
+ border-radius: var(--radius-pill);
97
+ background: var(--c-blue-cobalt);
98
+ transform-origin: center;
99
+ transition: transform 180ms ease-out;
100
+ }
101
+
102
+ .keyhole {
103
+ position: absolute;
104
+ top: 50%;
105
+ left: 50%;
106
+ width: 14px;
107
+ height: 14px;
108
+ margin: -7px 0 0 -7px;
109
+ border-radius: 50%;
110
+ background: var(--c-cobalt-900);
111
+ }
112
+
113
+ .dial {
114
+ flex: 1;
115
+ min-width: 220px;
116
+ }
117
+
118
+ .dialLabel {
119
+ display: block;
120
+ font-size: 12px;
121
+ color: var(--c-cobalt-700);
122
+ margin-bottom: 6px;
123
+ }
124
+
125
+ .slider {
126
+ width: 100%;
127
+ accent-color: var(--c-blue-cobalt);
128
+ }
129
+ .slider:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 4px; }
130
+
131
+ .pickRow {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 10px;
135
+ margin-top: 14px;
136
+ }
137
+
138
+ .pickLabel {
139
+ font-family: var(--conduction-typography-font-family-code);
140
+ font-size: 11px;
141
+ color: var(--c-cobalt-400);
142
+ white-space: nowrap;
143
+ }
144
+
145
+ .wear {
146
+ flex: 1;
147
+ height: 6px;
148
+ border-radius: var(--radius-pill);
149
+ background: var(--c-cobalt-200);
150
+ overflow: hidden;
151
+ }
152
+ .wearFill {
153
+ display: block;
154
+ height: 100%;
155
+ background: var(--c-mint-500);
156
+ transition: width 160ms ease-out;
157
+ }
158
+ .wearLow { background: var(--c-orange-knvb); }
159
+
160
+ .foot {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ align-items: center;
164
+ gap: 10px;
165
+ margin-top: 16px;
166
+ }
167
+
168
+ .turn {
169
+ background: var(--c-blue-cobalt);
170
+ color: white;
171
+ border: 1px solid var(--c-blue-cobalt);
172
+ padding: 10px 18px;
173
+ border-radius: var(--radius-md);
174
+ font-family: inherit;
175
+ font-weight: 500;
176
+ font-size: 14px;
177
+ cursor: pointer;
178
+ transition: background 120ms ease;
179
+ }
180
+ .turn:hover:not(:disabled) { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
181
+ .turn:disabled { opacity: 0.5; cursor: default; }
182
+ .turn:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
183
+
184
+ .start {
185
+ background: white;
186
+ color: var(--c-cobalt-700);
187
+ border: 1px solid var(--c-cobalt-200);
188
+ padding: 10px 18px;
189
+ border-radius: var(--radius-md);
190
+ font-family: inherit;
191
+ font-weight: 500;
192
+ font-size: 14px;
193
+ cursor: pointer;
194
+ transition: border-color 120ms ease;
195
+ }
196
+ .start:hover { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
197
+ .start:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
198
+
199
+ .feedback {
200
+ margin: 0;
201
+ flex-basis: 100%;
202
+ font-size: 13px;
203
+ color: var(--c-cobalt-700);
204
+ min-height: 1.5em;
205
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * engine.test.js — the lock-pick rules.
3
+ *
4
+ * Two properties carry the game. Every lock must be openable by
5
+ * feeling for it, which is what makes this deduction rather than a
6
+ * lottery: the feedback has to get stronger as the pick gets closer,
7
+ * on every lock, at every difficulty. And a snapped pick must not cost
8
+ * the player the lock they had almost worked out.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const test = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+
16
+ const {
17
+ createGame, setPosition, nudge, give, turn, summarise, POSITIONS, DEFAULTS,
18
+ } = require('../engine.js');
19
+
20
+ /** Play a lock the way a person would: halve the range by feel. */
21
+ function pickByFeel(state, maxTurns = 40) {
22
+ let s = state;
23
+ let low = 0;
24
+ let high = POSITIONS - 1;
25
+ const opened = s.opened;
26
+ for (let i = 0; i < maxTurns && !s.over && s.opened === opened; i++) {
27
+ const mid = Math.round((low + high) / 2);
28
+ s = setPosition(s, mid);
29
+ const before = give(s);
30
+ s = turn(s);
31
+ if (s.opened > opened || s.over) break;
32
+ /* Feel left and right of the guess, and walk towards the stronger
33
+ side. This is what a player does with graded feedback. */
34
+ const left = give(setPosition(s, Math.max(low, mid - 5)));
35
+ const right = give(setPosition(s, Math.min(high, mid + 5)));
36
+ if (left > before && left >= right) high = mid;
37
+ else if (right > before) low = mid;
38
+ else { low = Math.max(low, mid - 10); high = Math.min(high, mid + 10); }
39
+ }
40
+ return s;
41
+ }
42
+
43
+ test('a game starts with three picks, a whole pick, and a lock', () => {
44
+ const s = createGame({seed: 1});
45
+ assert.equal(s.picks, DEFAULTS.picks);
46
+ assert.equal(s.durability, DEFAULTS.durability);
47
+ assert.equal(s.lock.tolerance, DEFAULTS.toleranceStart);
48
+ assert.equal(s.over, false);
49
+ });
50
+
51
+ test('the sweet spot is never against either end of the dial', () => {
52
+ for (let seed = 1; seed <= 50; seed++) {
53
+ let s = createGame({seed});
54
+ for (let lock = 0; lock < 8; lock++) {
55
+ const {sweet, tolerance} = s.lock;
56
+ assert.ok(sweet - tolerance > 0, `seed ${seed}: a lock opens at the very start of the dial`);
57
+ assert.ok(sweet + tolerance < POSITIONS - 1, `seed ${seed}: a lock opens at the very end`);
58
+ s = turn(setPosition(s, sweet));
59
+ }
60
+ }
61
+ });
62
+
63
+ test('the pick moves, and the dial does not wrap', () => {
64
+ const s = createGame({seed: 2});
65
+ assert.equal(setPosition(s, -5).position, 0);
66
+ assert.equal(setPosition(s, 999).position, POSITIONS - 1);
67
+ assert.equal(nudge(setPosition(s, 10), -1).position, 9);
68
+ assert.equal(nudge(setPosition(s, 0), -1).position, 0);
69
+ });
70
+
71
+ test('the cylinder gives more the closer the pick gets, on every lock', () => {
72
+ for (let seed = 1; seed <= 25; seed++) {
73
+ const s = createGame({seed});
74
+ const {sweet} = s.lock;
75
+ let previous = -1;
76
+ /* Walk in from a long way out on whichever side has room: the dial
77
+ does not wrap, so on a lock near one end the far distances all
78
+ clamp to the same position and would compare equal. */
79
+ const towardsEnd = sweet > POSITIONS / 2 ? -1 : 1;
80
+ for (const distance of [40, 30, 20, 14, 10]) {
81
+ const at = sweet + towardsEnd * distance;
82
+ assert.ok(at >= 0 && at < POSITIONS, `seed ${seed}: walked off the dial at ${distance}`);
83
+ const value = give(setPosition(s, at));
84
+ assert.ok(value > previous, `seed ${seed}: the feel did not improve at ${distance} away`);
85
+ previous = value;
86
+ }
87
+ assert.equal(give(setPosition(s, sweet)), 1);
88
+ }
89
+ });
90
+
91
+ test('turning on the sweet spot opens the lock and pays for the pick that is left', () => {
92
+ const s = createGame({seed: 3});
93
+ const opened = turn(setPosition(s, s.lock.sweet));
94
+ assert.equal(opened.opened, 1);
95
+ assert.ok(opened.score >= DEFAULTS.pointsPerLock);
96
+ assert.equal(opened.last.result, 'opened');
97
+ assert.equal(opened.picks, DEFAULTS.picks, 'opening a lock cost a pick');
98
+ assert.equal(opened.durability, DEFAULTS.durability, 'the next lock started on a worn pick');
99
+ });
100
+
101
+ test('each lock opened narrows the next one, down to a floor', () => {
102
+ let s = createGame({seed: 4});
103
+ const seen = [];
104
+ for (let i = 0; i < 12; i++) {
105
+ seen.push(s.lock.tolerance);
106
+ s = turn(setPosition(s, s.lock.sweet));
107
+ }
108
+ assert.equal(seen[0], DEFAULTS.toleranceStart);
109
+ assert.ok(seen[3] < seen[0], 'the locks never got harder');
110
+ assert.equal(Math.min(...seen), DEFAULTS.toleranceFloor, 'the difficulty never reached its floor');
111
+ assert.ok(seen.every((t) => t >= DEFAULTS.toleranceFloor), 'a lock got harder than the floor');
112
+ });
113
+
114
+ test('a turn well off the spot strains the pick, and a wild one costs far more', () => {
115
+ const s = createGame({seed: 5});
116
+ const near = turn(setPosition(s, s.lock.sweet + s.lock.tolerance + 4));
117
+ const far = turn(setPosition(s, s.lock.sweet > 50 ? 0 : POSITIONS - 1));
118
+
119
+ assert.equal(near.last.result, 'held');
120
+ assert.ok(near.durability < DEFAULTS.durability);
121
+
122
+ /* The wild turn must hurt more but must not be fatal on its own:
123
+ the pick has to survive long enough for the player to read the
124
+ feedback it just gave them. */
125
+ assert.equal(far.last.result, 'held', 'one wild turn snapped a fresh pick');
126
+ assert.ok(
127
+ DEFAULTS.durability - far.durability > DEFAULTS.durability - near.durability,
128
+ 'a wild guess cost no more than a near miss',
129
+ );
130
+ });
131
+
132
+ test('two wild turns in a row do end a pick', () => {
133
+ let s = createGame({seed: 5});
134
+ const wild = s.lock.sweet > 50 ? 0 : POSITIONS - 1;
135
+ s = turn(setPosition(s, wild));
136
+ assert.equal(s.picks, DEFAULTS.picks);
137
+ s = turn(setPosition(s, wild));
138
+ assert.equal(s.last.result, 'snapped');
139
+ assert.equal(s.picks, DEFAULTS.picks - 1);
140
+ });
141
+
142
+ test('a snapped pick costs a pick but never the lock', () => {
143
+ let s = createGame({seed: 6});
144
+ const {sweet, tolerance} = s.lock;
145
+ const wild = sweet > 50 ? 0 : POSITIONS - 1;
146
+ while (s.picks === DEFAULTS.picks && !s.over) s = turn(setPosition(s, wild));
147
+ assert.equal(s.picks, DEFAULTS.picks - 1);
148
+ assert.equal(s.last.result, 'snapped');
149
+ assert.equal(s.durability, DEFAULTS.durability, 'the new pick started already worn');
150
+ assert.equal(s.lock.sweet, sweet, 'the lock was re-dealt under the player');
151
+ assert.equal(s.lock.tolerance, tolerance);
152
+ });
153
+
154
+ test('three snapped picks end the run, and nothing moves afterwards', () => {
155
+ let s = createGame({seed: 7});
156
+ const wild = s.lock.sweet > 50 ? 0 : POSITIONS - 1;
157
+ for (let i = 0; i < 200 && !s.over; i++) s = turn(setPosition(s, wild));
158
+ assert.equal(s.over, true);
159
+ assert.equal(s.picks, 0);
160
+ assert.equal(s.snapped, 3);
161
+ assert.equal(turn(s).turns, s.turns, 'the lock still turned after the last pick snapped');
162
+ assert.equal(setPosition(s, 5).position, s.position, 'the pick still moved after game over');
163
+ });
164
+
165
+ test('a player who feels for it opens locks rather than running out of picks', () => {
166
+ /* Twenty seeds, played the way the feedback asks to be played. If
167
+ this fails the game is a lottery, whatever it looks like. */
168
+ let openedTotal = 0;
169
+ for (let seed = 1; seed <= 20; seed++) {
170
+ let s = createGame({seed});
171
+ for (let lock = 0; lock < 3 && !s.over; lock++) s = pickByFeel(s);
172
+ assert.ok(s.opened >= 1, `seed ${seed}: feeling for the spot never opened a single lock`);
173
+ openedTotal += s.opened;
174
+ }
175
+ assert.ok(openedTotal >= 40, `expected most locks to fall to deduction, got ${openedTotal}`);
176
+ });
177
+
178
+ test('the same seed sets the same locks, a different one does not', () => {
179
+ const spots = (seed) => {
180
+ let s = createGame({seed});
181
+ const out = [];
182
+ for (let i = 0; i < 6; i++) { out.push(s.lock.sweet); s = turn(setPosition(s, s.lock.sweet)); }
183
+ return out.join(',');
184
+ };
185
+ assert.equal(spots(11), spots(11));
186
+ assert.notEqual(spots(11), spots(12));
187
+ });
188
+
189
+ test('the summary reads as a sentence in both locales', () => {
190
+ const s = {opened: 5, turns: 23};
191
+ assert.match(summarise(s, 'en'), /5 locks opened · 23 turns/);
192
+ assert.match(summarise(s, 'nl'), /5 sloten open · 23 pogingen/);
193
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Lock pick — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Keepiq's game, and the one everybody already knows how to play: set
5
+ * the pick, turn the cylinder, feel how far it gives. Close to the
6
+ * sweet spot it turns; far from it the pick strains and eventually
7
+ * snaps. Three picks and the lock wins.
8
+ *
9
+ * The feedback is graded rather than binary, which is what makes it a
10
+ * game of deduction instead of a lottery: every turn tells you how
11
+ * close you were, so the next one is a better guess. It also means the
12
+ * lock is pickable without seeing anything, which matters more here
13
+ * than the animation does.
14
+ *
15
+ * Nothing in here runs on a clock. A lock is a state machine, not a
16
+ * race, and that is the difference between this game and the other
17
+ * four.
18
+ */
19
+
20
+ export const POSITIONS = 100;
21
+
22
+ export const DEFAULTS = {
23
+ picks: 3,
24
+ /* How far off the sweet spot the pick may be and still open the
25
+ lock, as positions on the dial. Narrows each time a lock opens,
26
+ down to a floor that is still findable by halving the range. */
27
+ toleranceStart: 9,
28
+ toleranceFloor: 3,
29
+ toleranceStep: 1,
30
+ /* A pick survives a few bad turns near the spot and one wild one.
31
+ Strain is the square of how far off you were, so the punishment
32
+ for a guess a long way out is what ends a pick, not patience. */
33
+ durability: 100,
34
+ strainScale: 2.4,
35
+ /* No single turn may snap a fresh pick. Without this cap a guess at
36
+ the far end of the dial costs a whole pick, so a player who has
37
+ not found the spot yet loses all three before learning anything,
38
+ and the graded feedback the game is built on never gets read. Two
39
+ wild turns in a row still end a pick. */
40
+ maxStrain: 55,
41
+ pointsPerLock: 20,
42
+ /* What is left of the pick when the lock opens is worth points: it
43
+ rewards deduction over brute force, which is the whole game. */
44
+ durabilityBonusDivisor: 5,
45
+ };
46
+
47
+ function mulberry32(seed) {
48
+ let a = seed >>> 0;
49
+ return function random() {
50
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
51
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
52
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
53
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
+ };
55
+ }
56
+
57
+ function newLock(state) {
58
+ const tolerance = Math.max(
59
+ state.cfg.toleranceFloor,
60
+ state.cfg.toleranceStart - state.opened * state.cfg.toleranceStep,
61
+ );
62
+ /* Keep the sweet spot off the very edges: a lock whose answer is 0
63
+ or 99 is opened by the two guesses everybody tries first. */
64
+ const margin = tolerance + 2;
65
+ const span = POSITIONS - 1 - margin * 2;
66
+ return {
67
+ sweet: margin + Math.floor(state.random() * (span + 1)),
68
+ tolerance,
69
+ attempts: 0,
70
+ };
71
+ }
72
+
73
+ export function createGame({seed = Date.now(), config = {}} = {}) {
74
+ const cfg = {...DEFAULTS, ...config};
75
+ const base = {
76
+ cfg,
77
+ random: mulberry32(seed),
78
+ position: Math.floor(POSITIONS / 2),
79
+ picks: cfg.picks,
80
+ durability: cfg.durability,
81
+ score: 0,
82
+ opened: 0,
83
+ snapped: 0,
84
+ turns: 0,
85
+ last: null,
86
+ over: false,
87
+ };
88
+ return {...base, lock: newLock(base)};
89
+ }
90
+
91
+ /** Move the pick. The dial does not wrap: both ends are ends. */
92
+ export function setPosition(state, position) {
93
+ if (state.over) return state;
94
+ const clamped = Math.min(POSITIONS - 1, Math.max(0, Math.round(position)));
95
+ return clamped === state.position ? state : {...state, position: clamped};
96
+ }
97
+
98
+ export function nudge(state, delta) {
99
+ return setPosition(state, state.position + delta);
100
+ }
101
+
102
+ /**
103
+ * How far the cylinder turns at the current position, 0 to 1.
104
+ *
105
+ * Inside the tolerance it is 1: the lock opens. Outside, it falls away
106
+ * with distance, and that number is both the feedback the player reads
107
+ * and the inverse of the strain the pick takes.
108
+ */
109
+ export function give(state) {
110
+ const distance = Math.abs(state.position - state.lock.sweet);
111
+ if (distance <= state.lock.tolerance) return 1;
112
+ const reach = POSITIONS / 2;
113
+ return Math.max(0, 1 - (distance - state.lock.tolerance) / reach);
114
+ }
115
+
116
+ /**
117
+ * Turn the cylinder.
118
+ *
119
+ * Opens the lock, or costs the pick some life. A pick that runs out
120
+ * snaps, and the next one starts fresh on the same lock: the lock is
121
+ * not re-dealt, because a player who has narrowed it down to two
122
+ * positions should not lose that work to a broken pick.
123
+ */
124
+ export function turn(state) {
125
+ if (state.over) return state;
126
+
127
+ const turned = give(state);
128
+ const attempts = state.lock.attempts + 1;
129
+ const base = {...state, turns: state.turns + 1, lock: {...state.lock, attempts}};
130
+
131
+ if (turned >= 1) {
132
+ const bonus = Math.round(base.durability / base.cfg.durabilityBonusDivisor);
133
+ const opened = {
134
+ ...base,
135
+ score: base.score + base.cfg.pointsPerLock + bonus,
136
+ opened: base.opened + 1,
137
+ durability: base.cfg.durability,
138
+ last: {result: 'opened', points: base.cfg.pointsPerLock + bonus, attempts},
139
+ };
140
+ /* A fresh lock, and a fresh pick with it: the run ends on the
141
+ locks that beat you, not on wear from the ones you solved. */
142
+ return {...opened, lock: newLock(opened), position: Math.floor(POSITIONS / 2)};
143
+ }
144
+
145
+ const strain = Math.min(
146
+ base.cfg.maxStrain,
147
+ Math.round((1 - turned) ** 2 * 100 * base.cfg.strainScale / 2) + 4,
148
+ );
149
+ const durability = base.durability - strain;
150
+
151
+ if (durability > 0) {
152
+ return {...base, durability, last: {result: 'held', give: turned, attempts}};
153
+ }
154
+
155
+ const picks = base.picks - 1;
156
+ return {
157
+ ...base,
158
+ picks,
159
+ snapped: base.snapped + 1,
160
+ durability: base.cfg.durability,
161
+ last: {result: 'snapped', give: turned, attempts},
162
+ over: picks <= 0,
163
+ };
164
+ }
165
+
166
+ /** The line that goes on the game-over card and into the post. */
167
+ export function summarise(state, locale = 'en') {
168
+ const n = (v) => Number(v || 0).toLocaleString(locale);
169
+ return locale === 'nl'
170
+ ? `${n(state.opened)} sloten open · ${n(state.turns)} pogingen`
171
+ : `${n(state.opened)} locks opened · ${n(state.turns)} turns`;
172
+ }
@@ -64,6 +64,7 @@ export {default as StampRush} from './StampRush/StampRush.jsx';
64
64
  export {default as DeadlineDefender} from './DeadlineDefender/DeadlineDefender.jsx';
65
65
  export {default as BlueprintRush} from './BlueprintRush/BlueprintRush.jsx';
66
66
  export {default as RecordRun} from './RecordRun/RecordRun.jsx';
67
+ export {default as LockPick} from './LockPick/LockPick.jsx';
67
68
 
68
69
  /* Diagram-set web-component React wrappers (cn-hex, cn-platform,
69
70
  cn-domain-tree, cn-pipeline, cn-side-box, cn-honeycomb-bg, cn-pair,