@conduction/docusaurus-preset 3.38.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.
Files changed (41) hide show
  1. package/MISSING_COMPONENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/no-icu-messages.test.js +57 -0
  4. package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
  5. package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
  6. package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
  7. package/src/components/BlueprintRush/engine.js +172 -0
  8. package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
  9. package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
  10. package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
  11. package/src/components/DeadlineDefender/engine.js +188 -0
  12. package/src/components/DetailHero/DetailHero.jsx +11 -4
  13. package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
  14. package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
  15. package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
  16. package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
  17. package/src/components/GameModal/GameModal.jsx +255 -50
  18. package/src/components/GameModal/GameModal.module.css +103 -0
  19. package/src/components/GameModal/__tests__/scores.test.js +123 -0
  20. package/src/components/GameModal/__tests__/share.test.js +83 -0
  21. package/src/components/GameModal/scores.js +149 -0
  22. package/src/components/GameModal/share.js +97 -0
  23. package/src/components/LockPick/LockPick.jsx +210 -0
  24. package/src/components/LockPick/LockPick.module.css +205 -0
  25. package/src/components/LockPick/__tests__/engine.test.js +193 -0
  26. package/src/components/LockPick/engine.js +172 -0
  27. package/src/components/RecordRun/RecordRun.jsx +245 -0
  28. package/src/components/RecordRun/RecordRun.module.css +208 -0
  29. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  30. package/src/components/RecordRun/engine.js +180 -0
  31. package/src/components/StampRush/StampRush.jsx +232 -0
  32. package/src/components/StampRush/StampRush.module.css +188 -0
  33. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  34. package/src/components/StampRush/engine.js +185 -0
  35. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  36. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  37. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  38. package/src/components/index.js +6 -0
  39. package/src/data/app-downloads.js +21 -0
  40. package/src/index.js +10 -0
  41. package/src/theme/Footer/index.jsx +10 -1
@@ -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
+ }