@conduction/docusaurus-preset 3.39.0 → 3.41.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 +1 -1
- package/src/components/LockPick/LockPick.jsx +222 -0
- package/src/components/LockPick/LockPick.module.css +212 -0
- package/src/components/LockPick/__tests__/engine.test.js +193 -0
- package/src/components/LockPick/engine.js +172 -0
- package/src/components/PaintByTokens/PaintByTokens.jsx +226 -0
- package/src/components/PaintByTokens/PaintByTokens.module.css +202 -0
- package/src/components/PaintByTokens/__tests__/engine.test.js +150 -0
- package/src/components/PaintByTokens/engine.js +194 -0
- package/src/components/Redaction/Redaction.jsx +259 -0
- package/src/components/Redaction/Redaction.module.css +180 -0
- package/src/components/Redaction/__tests__/engine.test.js +162 -0
- package/src/components/Redaction/engine.js +186 -0
- package/src/components/index.js +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
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, give, 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 answers the pick as it moves, which is how this game
|
|
111
|
+
has always worked: you feel for the spot and only then commit. The
|
|
112
|
+
first version showed the last turn's result instead, so the only
|
|
113
|
+
way to learn anything was to turn, and every turn wore the pick
|
|
114
|
+
down. Sweeping was impossible and the game became a guessing game
|
|
115
|
+
with a cost per guess.
|
|
116
|
+
|
|
117
|
+
The feel is deliberately coarse, five buckets wide, so the dial
|
|
118
|
+
narrows the answer without handing it over: the last step is still
|
|
119
|
+
a commitment. */
|
|
120
|
+
const turned = game && running ? give(game) : 0;
|
|
121
|
+
const liveFeel = game && running ? feelCopy(turned) : null;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<section className={[styles.lp, className].filter(Boolean).join(' ')} aria-labelledby="lock-pick-title">
|
|
125
|
+
<header className={styles.head}>
|
|
126
|
+
<div>
|
|
127
|
+
<p className={styles.eyebrow}>
|
|
128
|
+
{translate({id: 'preset.lockPick.eyebrow', message: 'Mini-game', description: 'Eyebrow above the lock-pick game on a product page'})}
|
|
129
|
+
</p>
|
|
130
|
+
<h3 className={styles.title} id="lock-pick-title">
|
|
131
|
+
{translate({id: 'preset.lockPick.title', message: 'Lock pick', description: 'Name of the Keepiq mini-game'})}
|
|
132
|
+
</h3>
|
|
133
|
+
<p className={styles.lede}>
|
|
134
|
+
{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'})}
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
138
|
+
<span className={styles.hudPill}>
|
|
139
|
+
{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)})}
|
|
140
|
+
</span>
|
|
141
|
+
<span className={styles.hudPill}>
|
|
142
|
+
{translate({id: 'preset.lockPick.hud.picks', message: 'Picks {picks}', description: 'Remaining-picks readout on the lock-pick HUD'}, {picks: game ? game.picks : 3})}
|
|
143
|
+
</span>
|
|
144
|
+
<span className={styles.hudPill}>
|
|
145
|
+
{translate({id: 'preset.lockPick.hud.opened', message: 'Opened {opened}', description: 'Opened-locks readout on the lock-pick HUD'}, {opened: game ? game.opened : 0})}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
</header>
|
|
149
|
+
|
|
150
|
+
<div className={styles.lock}>
|
|
151
|
+
{/* The cylinder, turned as far as the last attempt managed. */}
|
|
152
|
+
<div
|
|
153
|
+
className={styles.cylinder}
|
|
154
|
+
role="img"
|
|
155
|
+
aria-label={translate(
|
|
156
|
+
{id: 'preset.lockPick.cylinder', message: 'The cylinder turns {percent} per cent where the pick is now', description: 'Accessible description of the lock cylinder. {percent} is how far it turns at the current pick position.'},
|
|
157
|
+
{percent: Math.round(turned * 100)},
|
|
158
|
+
)}>
|
|
159
|
+
<div className={styles.cylinderFill} style={{transform: `rotate(${-90 + turned * 80}deg)`}} />
|
|
160
|
+
<span className={styles.keyhole} aria-hidden="true" />
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className={styles.dial}>
|
|
164
|
+
<label className={styles.dialLabel} htmlFor="lock-pick-dial">
|
|
165
|
+
{translate({id: 'preset.lockPick.dialLabel', message: 'Where the pick sits', description: 'Label for the lock-pick dial slider'})}
|
|
166
|
+
</label>
|
|
167
|
+
<input
|
|
168
|
+
id="lock-pick-dial"
|
|
169
|
+
className={styles.slider}
|
|
170
|
+
type="range"
|
|
171
|
+
min={0}
|
|
172
|
+
max={POSITIONS - 1}
|
|
173
|
+
step={1}
|
|
174
|
+
value={position}
|
|
175
|
+
disabled={!running}
|
|
176
|
+
onChange={(e) => moveTo(Number(e.target.value))}
|
|
177
|
+
/>
|
|
178
|
+
<p className={styles.feel} role="status" aria-live="polite">
|
|
179
|
+
{liveFeel || translate({id: 'preset.lockPick.feelIdle', message: 'Take a pick to start feeling for it.', description: 'Placeholder where the live feel line sits before the game starts'})}
|
|
180
|
+
</p>
|
|
181
|
+
|
|
182
|
+
<div className={styles.pickRow}>
|
|
183
|
+
<span className={styles.pickLabel}>
|
|
184
|
+
{translate({id: 'preset.lockPick.wear', message: 'This pick', description: 'Label for the lock-pick durability bar'})}
|
|
185
|
+
</span>
|
|
186
|
+
<span
|
|
187
|
+
className={styles.wear}
|
|
188
|
+
role="progressbar"
|
|
189
|
+
aria-valuemin={0}
|
|
190
|
+
aria-valuemax={100}
|
|
191
|
+
aria-valuenow={game ? game.durability : 100}>
|
|
192
|
+
<span
|
|
193
|
+
className={[styles.wearFill, game && game.durability <= 35 && styles.wearLow].filter(Boolean).join(' ')}
|
|
194
|
+
style={{width: `${game ? game.durability : 100}%`}}
|
|
195
|
+
/>
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<footer className={styles.foot}>
|
|
202
|
+
<button type="button" className={styles.turn} onClick={tryTurn} disabled={!running}>
|
|
203
|
+
{translate({id: 'preset.lockPick.turn', message: 'Turn the cylinder', description: 'Button that attempts to turn the lock'})}
|
|
204
|
+
</button>
|
|
205
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
206
|
+
{game
|
|
207
|
+
? translate({id: 'preset.lockPick.restart', message: 'New lock', description: 'Button that restarts the lock-pick game'})
|
|
208
|
+
: translate({id: 'preset.lockPick.start', message: 'Take a pick', description: 'Button that starts the lock-pick game'})}
|
|
209
|
+
</button>
|
|
210
|
+
<p className={styles.feedback} role="status" aria-live="polite">
|
|
211
|
+
{last && last.result === 'opened' && translate(
|
|
212
|
+
{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.'},
|
|
213
|
+
{attempts: last.attempts, points: last.points},
|
|
214
|
+
)}
|
|
215
|
+
{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'})}
|
|
216
|
+
{last && last.result === 'held' && feelCopy(last.give)}
|
|
217
|
+
{!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'})}
|
|
218
|
+
</p>
|
|
219
|
+
</footer>
|
|
220
|
+
</section>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
.feel {
|
|
132
|
+
margin: 10px 0 0;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
color: var(--c-cobalt-900);
|
|
135
|
+
min-height: 1.4em;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.pickRow {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 10px;
|
|
142
|
+
margin-top: 14px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.pickLabel {
|
|
146
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
color: var(--c-cobalt-400);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.wear {
|
|
153
|
+
flex: 1;
|
|
154
|
+
height: 6px;
|
|
155
|
+
border-radius: var(--radius-pill);
|
|
156
|
+
background: var(--c-cobalt-200);
|
|
157
|
+
overflow: hidden;
|
|
158
|
+
}
|
|
159
|
+
.wearFill {
|
|
160
|
+
display: block;
|
|
161
|
+
height: 100%;
|
|
162
|
+
background: var(--c-mint-500);
|
|
163
|
+
transition: width 160ms ease-out;
|
|
164
|
+
}
|
|
165
|
+
.wearLow { background: var(--c-orange-knvb); }
|
|
166
|
+
|
|
167
|
+
.foot {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-wrap: wrap;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 10px;
|
|
172
|
+
margin-top: 16px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.turn {
|
|
176
|
+
background: var(--c-blue-cobalt);
|
|
177
|
+
color: white;
|
|
178
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
179
|
+
padding: 10px 18px;
|
|
180
|
+
border-radius: var(--radius-md);
|
|
181
|
+
font-family: inherit;
|
|
182
|
+
font-weight: 500;
|
|
183
|
+
font-size: 14px;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
transition: background 120ms ease;
|
|
186
|
+
}
|
|
187
|
+
.turn:hover:not(:disabled) { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
188
|
+
.turn:disabled { opacity: 0.5; cursor: default; }
|
|
189
|
+
.turn:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
190
|
+
|
|
191
|
+
.start {
|
|
192
|
+
background: white;
|
|
193
|
+
color: var(--c-cobalt-700);
|
|
194
|
+
border: 1px solid var(--c-cobalt-200);
|
|
195
|
+
padding: 10px 18px;
|
|
196
|
+
border-radius: var(--radius-md);
|
|
197
|
+
font-family: inherit;
|
|
198
|
+
font-weight: 500;
|
|
199
|
+
font-size: 14px;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
transition: border-color 120ms ease;
|
|
202
|
+
}
|
|
203
|
+
.start:hover { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
|
|
204
|
+
.start:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
205
|
+
|
|
206
|
+
.feedback {
|
|
207
|
+
margin: 0;
|
|
208
|
+
flex-basis: 100%;
|
|
209
|
+
font-size: 13px;
|
|
210
|
+
color: var(--c-cobalt-700);
|
|
211
|
+
min-height: 1.5em;
|
|
212
|
+
}
|
|
@@ -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
|
+
});
|