@conduction/docusaurus-preset 3.37.0 → 3.39.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/MISSING_COMPONENTS.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/no-icu-messages.test.js +57 -0
- package/src/components/AiDisclosure/AiDisclosure.jsx +45 -21
- package/src/components/AiDisclosure/AiDisclosure.module.css +38 -3
- package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +25 -13
- package/src/components/AiDisclosure/__tests__/disclosure.test.js +60 -0
- package/src/components/AiDisclosure/disclosure.js +60 -4
- package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
- package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
- package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
- package/src/components/BlueprintRush/engine.js +172 -0
- package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
- package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
- package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
- package/src/components/DeadlineDefender/engine.js +188 -0
- package/src/components/DetailHero/DetailHero.jsx +11 -4
- package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
- package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
- package/src/components/FeaturedCard/FeaturedCard.module.css +10 -0
- package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
- package/src/components/GameModal/GameModal.jsx +255 -50
- package/src/components/GameModal/GameModal.module.css +103 -0
- package/src/components/GameModal/__tests__/scores.test.js +123 -0
- package/src/components/GameModal/__tests__/share.test.js +83 -0
- package/src/components/GameModal/scores.js +149 -0
- package/src/components/GameModal/share.js +97 -0
- package/src/components/RecordRun/RecordRun.jsx +245 -0
- package/src/components/RecordRun/RecordRun.module.css +208 -0
- package/src/components/RecordRun/__tests__/engine.test.js +191 -0
- package/src/components/RecordRun/engine.js +180 -0
- package/src/components/StampRush/StampRush.jsx +232 -0
- package/src/components/StampRush/StampRush.module.css +188 -0
- package/src/components/StampRush/__tests__/engine.test.js +182 -0
- package/src/components/StampRush/engine.js +185 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
- package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
- package/src/components/index.js +5 -0
- package/src/data/app-downloads.js +21 -0
- package/src/index.js +10 -0
- package/src/theme/Footer/index.jsx +10 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <BlueprintRush />
|
|
3
|
+
*
|
|
4
|
+
* Buildiq's mini-game. A blueprint asks for the parts an app needs,
|
|
5
|
+
* in the reader's words rather than ours: where the records live, what
|
|
6
|
+
* one looks like, how people fill it in, who may see it. The tray
|
|
7
|
+
* offers those parts plus a few that belong to some other app. Fit the
|
|
8
|
+
* blueprint before the clock runs out; every finished app buys time.
|
|
9
|
+
*
|
|
10
|
+
* A puzzle rather than a reaction test, on purpose: the other two
|
|
11
|
+
* games are about speed, and a third one of those would be the same
|
|
12
|
+
* game a third time. Here the clock is the only pressure and the work
|
|
13
|
+
* is reading.
|
|
14
|
+
*
|
|
15
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
16
|
+
*
|
|
17
|
+
* Usage on a product page:
|
|
18
|
+
*
|
|
19
|
+
* <BlueprintRush />
|
|
20
|
+
*
|
|
21
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
22
|
+
* for `connext:gamereplay`, like every other mini-game.
|
|
23
|
+
*
|
|
24
|
+
* Accessibility: the tray is a row of buttons, each labelled with the
|
|
25
|
+
* part it places, and the slots are a list that says what is still
|
|
26
|
+
* missing. Nothing depends on position or colour.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
30
|
+
import {translate} from '@docusaurus/Translate';
|
|
31
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
32
|
+
import {createGame, step, place, timeLeft, summarise} from './engine';
|
|
33
|
+
import styles from './BlueprintRush.module.css';
|
|
34
|
+
|
|
35
|
+
const GAME_ID = 'blueprint-rush';
|
|
36
|
+
const TICK_MS = 100;
|
|
37
|
+
|
|
38
|
+
/* What the blueprint asks for, and what the tray offers, in the words
|
|
39
|
+
a business owner would use. The part key is never shown. */
|
|
40
|
+
function partCopy(part) {
|
|
41
|
+
switch (part) {
|
|
42
|
+
case 'register':
|
|
43
|
+
return {
|
|
44
|
+
slot: translate({id: 'preset.blueprintRush.slot.register', message: 'Somewhere to keep the records', description: 'Blueprint slot asking for a register'}),
|
|
45
|
+
tray: translate({id: 'preset.blueprintRush.tray.register', message: 'A register', description: 'Tray part: a register'}),
|
|
46
|
+
};
|
|
47
|
+
case 'schema':
|
|
48
|
+
return {
|
|
49
|
+
slot: translate({id: 'preset.blueprintRush.slot.schema', message: 'What one record looks like', description: 'Blueprint slot asking for a schema'}),
|
|
50
|
+
tray: translate({id: 'preset.blueprintRush.tray.schema', message: 'A set of fields', description: 'Tray part: a schema'}),
|
|
51
|
+
};
|
|
52
|
+
case 'form':
|
|
53
|
+
return {
|
|
54
|
+
slot: translate({id: 'preset.blueprintRush.slot.form', message: 'How people fill one in', description: 'Blueprint slot asking for a form'}),
|
|
55
|
+
tray: translate({id: 'preset.blueprintRush.tray.form', message: 'A form', description: 'Tray part: a form'}),
|
|
56
|
+
};
|
|
57
|
+
case 'view':
|
|
58
|
+
return {
|
|
59
|
+
slot: translate({id: 'preset.blueprintRush.slot.view', message: 'How people find one back', description: 'Blueprint slot asking for a list view'}),
|
|
60
|
+
tray: translate({id: 'preset.blueprintRush.tray.view', message: 'A list with search', description: 'Tray part: a list view'}),
|
|
61
|
+
};
|
|
62
|
+
case 'flow':
|
|
63
|
+
return {
|
|
64
|
+
slot: translate({id: 'preset.blueprintRush.slot.flow', message: 'What happens after someone saves', description: 'Blueprint slot asking for a flow'}),
|
|
65
|
+
tray: translate({id: 'preset.blueprintRush.tray.flow', message: 'A flow', description: 'Tray part: a flow'}),
|
|
66
|
+
};
|
|
67
|
+
case 'permission':
|
|
68
|
+
return {
|
|
69
|
+
slot: translate({id: 'preset.blueprintRush.slot.permission', message: 'Who is allowed to see it', description: 'Blueprint slot asking for permissions'}),
|
|
70
|
+
tray: translate({id: 'preset.blueprintRush.tray.permission', message: 'A group and its rights', description: 'Tray part: permissions'}),
|
|
71
|
+
};
|
|
72
|
+
case 'widget':
|
|
73
|
+
return {
|
|
74
|
+
slot: translate({id: 'preset.blueprintRush.slot.widget', message: 'What the manager sees on Monday', description: 'Blueprint slot asking for a dashboard widget'}),
|
|
75
|
+
tray: translate({id: 'preset.blueprintRush.tray.widget', message: 'A dashboard widget', description: 'Tray part: a dashboard widget'}),
|
|
76
|
+
};
|
|
77
|
+
default:
|
|
78
|
+
return {
|
|
79
|
+
slot: translate({id: 'preset.blueprintRush.slot.notification', message: 'Who hears about it', description: 'Blueprint slot asking for a notification'}),
|
|
80
|
+
tray: translate({id: 'preset.blueprintRush.tray.notification', message: 'A notification', description: 'Tray part: a notification'}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default function BlueprintRush({className}) {
|
|
86
|
+
const {i18n} = useDocusaurusContext();
|
|
87
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
88
|
+
|
|
89
|
+
const [game, setGame] = useState(null);
|
|
90
|
+
const [seconds, setSeconds] = useState(0);
|
|
91
|
+
const gameRef = useRef(null);
|
|
92
|
+
const startedAtRef = useRef(0);
|
|
93
|
+
const endedRef = useRef(false);
|
|
94
|
+
|
|
95
|
+
const running = Boolean(game) && !game.over;
|
|
96
|
+
|
|
97
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
98
|
+
|
|
99
|
+
const begin = useCallback(() => {
|
|
100
|
+
endedRef.current = false;
|
|
101
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
102
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
103
|
+
gameRef.current = fresh;
|
|
104
|
+
setGame(fresh);
|
|
105
|
+
setSeconds(Math.ceil(timeLeft(fresh, 0) / 1000));
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!running) return undefined;
|
|
110
|
+
const id = setInterval(() => {
|
|
111
|
+
const t = now();
|
|
112
|
+
const next = step(gameRef.current, t);
|
|
113
|
+
gameRef.current = next;
|
|
114
|
+
setGame(next);
|
|
115
|
+
setSeconds(Math.ceil(timeLeft(next, t) / 1000));
|
|
116
|
+
}, TICK_MS);
|
|
117
|
+
return () => clearInterval(id);
|
|
118
|
+
}, [running]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!game || !game.over || endedRef.current) return;
|
|
122
|
+
endedRef.current = true;
|
|
123
|
+
if (typeof window === 'undefined') return;
|
|
124
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
125
|
+
detail: {
|
|
126
|
+
id: GAME_ID,
|
|
127
|
+
won: false,
|
|
128
|
+
score: game.score,
|
|
129
|
+
summary: summarise(game, locale),
|
|
130
|
+
title: translate({id: 'preset.blueprintRush.over.title', message: 'Time, and the blueprint is still open.', description: 'Headline on the game-over dialog after a blueprint-rush run'}),
|
|
131
|
+
subtitle: translate({id: 'preset.blueprintRush.over.subtitle', message: 'Every finished app bought you seconds. The wrong parts spent them.', description: 'Subtitle on the game-over dialog after a blueprint-rush run'}),
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
}, [game, locale]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (typeof window === 'undefined') return undefined;
|
|
138
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
139
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
140
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
141
|
+
}, [begin]);
|
|
142
|
+
|
|
143
|
+
const put = useCallback((part) => {
|
|
144
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
145
|
+
const t = now();
|
|
146
|
+
const next = place(gameRef.current, part, t);
|
|
147
|
+
gameRef.current = next;
|
|
148
|
+
setGame(next);
|
|
149
|
+
setSeconds(Math.ceil(timeLeft(next, t) / 1000));
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
const blueprint = game ? game.blueprint : null;
|
|
153
|
+
const filled = blueprint ? blueprint.filled : [];
|
|
154
|
+
const last = game && game.last ? game.last : null;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<section className={[styles.br, className].filter(Boolean).join(' ')} aria-labelledby="blueprint-rush-title">
|
|
158
|
+
<header className={styles.head}>
|
|
159
|
+
<div>
|
|
160
|
+
<p className={styles.eyebrow}>
|
|
161
|
+
{translate({id: 'preset.blueprintRush.eyebrow', message: 'Mini-game', description: 'Eyebrow above the blueprint-rush game on a product page'})}
|
|
162
|
+
</p>
|
|
163
|
+
<h3 className={styles.title} id="blueprint-rush-title">
|
|
164
|
+
{translate({id: 'preset.blueprintRush.title', message: 'Blueprint rush', description: 'Name of the Buildiq mini-game'})}
|
|
165
|
+
</h3>
|
|
166
|
+
<p className={styles.lede}>
|
|
167
|
+
{translate({id: 'preset.blueprintRush.lede', message: 'Every app needs the same few parts. Pick them off the shelf before the clock runs out, and skip the ones that belong to somebody else’s app.', description: 'One-line explanation of the blueprint-rush rules'})}
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
171
|
+
<span className={styles.hudPill}>
|
|
172
|
+
{translate({id: 'preset.blueprintRush.hud.score', message: 'Score {score}', description: 'Score readout on the blueprint-rush HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
173
|
+
</span>
|
|
174
|
+
<span className={[styles.hudPill, running && seconds <= 5 && styles.hudLow].filter(Boolean).join(' ')}>
|
|
175
|
+
{translate({id: 'preset.blueprintRush.hud.time', message: '{seconds}s left', description: 'Remaining-time readout on the blueprint-rush HUD'}, {seconds: running ? seconds : 0})}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
</header>
|
|
179
|
+
|
|
180
|
+
<div className={styles.board}>
|
|
181
|
+
<ol className={styles.slots}>
|
|
182
|
+
{(blueprint ? blueprint.needed : []).map((part) => {
|
|
183
|
+
const done = filled.includes(part);
|
|
184
|
+
const copy = partCopy(part);
|
|
185
|
+
return (
|
|
186
|
+
<li key={part} className={[styles.slot, done && styles.slotDone].filter(Boolean).join(' ')}>
|
|
187
|
+
<span className={styles.slotMark} aria-hidden="true" />
|
|
188
|
+
<span className={styles.slotText}>{copy.slot}</span>
|
|
189
|
+
<span className={styles.slotState}>
|
|
190
|
+
{done
|
|
191
|
+
? translate({id: 'preset.blueprintRush.slot.done', message: 'in place', description: 'State of a blueprint slot that has been filled'})
|
|
192
|
+
: translate({id: 'preset.blueprintRush.slot.open', message: 'open', description: 'State of a blueprint slot that is still empty'})}
|
|
193
|
+
</span>
|
|
194
|
+
</li>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
{!blueprint && (
|
|
198
|
+
<li className={styles.slot}>
|
|
199
|
+
<span className={styles.slotText}>
|
|
200
|
+
{translate({id: 'preset.blueprintRush.idle', message: 'An empty blueprint, and a shelf full of parts.', description: 'Placeholder on the blueprint before the game starts'})}
|
|
201
|
+
</span>
|
|
202
|
+
</li>
|
|
203
|
+
)}
|
|
204
|
+
</ol>
|
|
205
|
+
|
|
206
|
+
<div className={styles.tray}>
|
|
207
|
+
{(blueprint ? blueprint.tray : []).map((part) => {
|
|
208
|
+
const done = filled.includes(part);
|
|
209
|
+
return (
|
|
210
|
+
<button
|
|
211
|
+
key={part}
|
|
212
|
+
type="button"
|
|
213
|
+
className={[styles.part, done && styles.partUsed].filter(Boolean).join(' ')}
|
|
214
|
+
onClick={() => put(part)}
|
|
215
|
+
disabled={!running || done}>
|
|
216
|
+
{partCopy(part).tray}
|
|
217
|
+
</button>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<footer className={styles.foot}>
|
|
224
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
225
|
+
{game
|
|
226
|
+
? translate({id: 'preset.blueprintRush.restart', message: 'Restart', description: 'Button that restarts the blueprint-rush game'})
|
|
227
|
+
: translate({id: 'preset.blueprintRush.start', message: 'Open a blueprint', description: 'Button that starts the blueprint-rush game'})}
|
|
228
|
+
</button>
|
|
229
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
230
|
+
{last && last.result === 'built' && translate({id: 'preset.blueprintRush.feedback.built', message: 'App built. That bought you seven seconds.', description: 'Feedback after completing a blueprint'})}
|
|
231
|
+
{last && last.result === 'wrong' && translate({id: 'preset.blueprintRush.feedback.wrong', message: 'That part belongs to another app. Two and a half seconds gone.', description: 'Feedback after picking a part the blueprint does not need'})}
|
|
232
|
+
{last && last.result === 'duplicate' && translate({id: 'preset.blueprintRush.feedback.duplicate', message: 'That one is already in place.', description: 'Feedback after picking a part that is already on the blueprint'})}
|
|
233
|
+
{(!last || last.result === 'placed' || last.result === 'timeout') && translate({id: 'preset.blueprintRush.hint', message: 'Read the blueprint, then take what it asks for. Each app in a row is worth more than the last.', description: 'Hint under the blueprint-rush board'})}
|
|
234
|
+
</p>
|
|
235
|
+
</footer>
|
|
236
|
+
</section>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <BlueprintRush /> styles.
|
|
3
|
+
*
|
|
4
|
+
* Tokens only. One accent: the clock in its last five seconds. The
|
|
5
|
+
* blueprint reads as a checklist because that is what it is, and the
|
|
6
|
+
* state of every slot is a word, not a colour.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.br {
|
|
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
|
+
.hudLow {
|
|
66
|
+
background: var(--c-orange-knvb);
|
|
67
|
+
color: white;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.board {
|
|
71
|
+
display: grid;
|
|
72
|
+
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
|
73
|
+
gap: 20px;
|
|
74
|
+
align-items: start;
|
|
75
|
+
max-width: 760px;
|
|
76
|
+
}
|
|
77
|
+
@media (max-width: 720px) {
|
|
78
|
+
.board { grid-template-columns: 1fr; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.slots {
|
|
82
|
+
list-style: none;
|
|
83
|
+
margin: 0;
|
|
84
|
+
padding: 14px;
|
|
85
|
+
border: 1px dashed var(--c-cobalt-200);
|
|
86
|
+
border-radius: var(--radius-md);
|
|
87
|
+
background: var(--c-cobalt-50);
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: 8px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.slot {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
font-size: 14px;
|
|
98
|
+
color: var(--c-cobalt-700);
|
|
99
|
+
line-height: 1.35;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.slotMark {
|
|
103
|
+
width: 10px;
|
|
104
|
+
height: 12px;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
clip-path: var(--hex-pointy-top);
|
|
107
|
+
background: var(--c-cobalt-300);
|
|
108
|
+
}
|
|
109
|
+
.slotDone .slotMark { background: var(--c-mint-500); }
|
|
110
|
+
|
|
111
|
+
.slotText { flex: 1; }
|
|
112
|
+
.slotDone .slotText { color: var(--c-cobalt-900); }
|
|
113
|
+
|
|
114
|
+
.slotState {
|
|
115
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
116
|
+
font-size: 11px;
|
|
117
|
+
color: var(--c-cobalt-400);
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
}
|
|
120
|
+
.slotDone .slotState { color: var(--c-mint-500); }
|
|
121
|
+
|
|
122
|
+
.tray {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-wrap: wrap;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
align-content: flex-start;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.part {
|
|
130
|
+
padding: 10px 14px;
|
|
131
|
+
border: 1px solid var(--c-cobalt-200);
|
|
132
|
+
border-radius: var(--radius-pill);
|
|
133
|
+
background: white;
|
|
134
|
+
font-family: inherit;
|
|
135
|
+
font-size: 13px;
|
|
136
|
+
color: var(--c-cobalt-900);
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
139
|
+
}
|
|
140
|
+
.part:hover:not(:disabled) { border-color: var(--c-blue-cobalt); background: var(--c-cobalt-50); }
|
|
141
|
+
.part:disabled { cursor: default; }
|
|
142
|
+
.part:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
143
|
+
|
|
144
|
+
.partUsed {
|
|
145
|
+
opacity: 0.45;
|
|
146
|
+
text-decoration: line-through;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.foot {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-wrap: wrap;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: 12px;
|
|
154
|
+
margin-top: 16px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.start {
|
|
158
|
+
background: var(--c-blue-cobalt);
|
|
159
|
+
color: white;
|
|
160
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
161
|
+
padding: 10px 18px;
|
|
162
|
+
border-radius: var(--radius-md);
|
|
163
|
+
font-family: inherit;
|
|
164
|
+
font-weight: 500;
|
|
165
|
+
font-size: 14px;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
transition: background 120ms ease;
|
|
168
|
+
}
|
|
169
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
170
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
171
|
+
|
|
172
|
+
.hint {
|
|
173
|
+
margin: 0;
|
|
174
|
+
font-size: 12px;
|
|
175
|
+
color: var(--c-cobalt-400);
|
|
176
|
+
max-width: 52ch;
|
|
177
|
+
min-height: 1.5em;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
181
|
+
.slotDone .slotMark { animation: brSet 200ms ease-out; }
|
|
182
|
+
@keyframes brSet {
|
|
183
|
+
from { transform: scale(0.4); }
|
|
184
|
+
to { transform: scale(1); }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the blueprint-rush rules.
|
|
3
|
+
*
|
|
4
|
+
* The one that matters: the tray must always contain every part the
|
|
5
|
+
* blueprint asks for. A deal that is missing one is unwinnable, and an
|
|
6
|
+
* unwinnable board in a timed game reads to the player as the game
|
|
7
|
+
* being broken, which it would be.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
|
|
15
|
+
const {createGame, step, place, timeLeft, summarise, PARTS, DEFAULTS} = require('../engine.js');
|
|
16
|
+
|
|
17
|
+
/** Complete the current blueprint correctly. */
|
|
18
|
+
function build(state, now = 0) {
|
|
19
|
+
let s = state;
|
|
20
|
+
for (const part of [...s.blueprint.needed]) s = place(s, part, now);
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('a game opens with a blueprint, a tray and a clock', () => {
|
|
25
|
+
const s = createGame({seed: 1, now: 0});
|
|
26
|
+
assert.equal(s.blueprint.needed.length, DEFAULTS.slots);
|
|
27
|
+
assert.equal(s.blueprint.filled.length, 0);
|
|
28
|
+
assert.equal(timeLeft(s, 0), DEFAULTS.startMs);
|
|
29
|
+
assert.equal(s.over, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('the tray always holds every part the blueprint asks for, plus the distractors', () => {
|
|
33
|
+
/* Twenty deals, because a shuffle bug shows up as one bad board in
|
|
34
|
+
a handful, not as every board. */
|
|
35
|
+
for (let seed = 1; seed <= 20; seed++) {
|
|
36
|
+
let s = createGame({seed, now: 0});
|
|
37
|
+
for (let round = 0; round < 3; round++) {
|
|
38
|
+
const {needed, tray} = s.blueprint;
|
|
39
|
+
for (const part of needed) {
|
|
40
|
+
assert.ok(tray.includes(part), `seed ${seed}: tray cannot complete the blueprint`);
|
|
41
|
+
}
|
|
42
|
+
assert.equal(tray.length, DEFAULTS.slots + DEFAULTS.distractors);
|
|
43
|
+
assert.equal(new Set(tray).size, tray.length, 'a part appears twice in the tray');
|
|
44
|
+
for (const part of tray) assert.ok(PARTS.includes(part), `unknown part ${part}`);
|
|
45
|
+
s = build(s, 100 * round);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('placing a needed part fills its slot without scoring yet', () => {
|
|
51
|
+
let s = createGame({seed: 3, now: 0});
|
|
52
|
+
const first = s.blueprint.needed[0];
|
|
53
|
+
s = place(s, first, 10);
|
|
54
|
+
assert.deepEqual(s.blueprint.filled, [first]);
|
|
55
|
+
assert.equal(s.score, 0);
|
|
56
|
+
assert.equal(s.built, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('completing a blueprint scores, buys time and deals the next one', () => {
|
|
60
|
+
let s = createGame({seed: 4, now: 0});
|
|
61
|
+
const before = timeLeft(s, 0);
|
|
62
|
+
const firstNeeded = [...s.blueprint.needed];
|
|
63
|
+
s = build(s, 0);
|
|
64
|
+
assert.equal(s.built, 1);
|
|
65
|
+
assert.equal(s.score, DEFAULTS.pointsPerApp);
|
|
66
|
+
assert.equal(timeLeft(s, 0), before + DEFAULTS.bonusMs);
|
|
67
|
+
assert.equal(s.blueprint.filled.length, 0, 'the next blueprint started already filled');
|
|
68
|
+
assert.equal(s.blueprint.needed.length, DEFAULTS.slots);
|
|
69
|
+
assert.notDeepEqual(s.blueprint.needed, firstNeeded, 'the same blueprint was dealt twice in a row');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a streak of finished apps pays more each time', () => {
|
|
73
|
+
let s = createGame({seed: 5, now: 0});
|
|
74
|
+
s = build(s, 0);
|
|
75
|
+
const first = s.score;
|
|
76
|
+
s = build(s, 10);
|
|
77
|
+
assert.equal(s.score - first, DEFAULTS.pointsPerApp + DEFAULTS.comboBonus);
|
|
78
|
+
assert.equal(s.bestCombo, 2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a part from some other app costs seconds, not a life', () => {
|
|
82
|
+
let s = createGame({seed: 6, now: 0});
|
|
83
|
+
const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
|
|
84
|
+
const before = timeLeft(s, 0);
|
|
85
|
+
s = place(s, wrong, 20);
|
|
86
|
+
assert.equal(timeLeft(s, 0), before - DEFAULTS.penaltyMs);
|
|
87
|
+
assert.equal(s.wrong, 1);
|
|
88
|
+
assert.equal(s.over, false, 'a wrong part ended the run');
|
|
89
|
+
assert.equal(s.blueprint.filled.length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('placing the same part twice is a mistake, and the slot stays filled once', () => {
|
|
93
|
+
let s = createGame({seed: 7, now: 0});
|
|
94
|
+
const part = s.blueprint.needed[0];
|
|
95
|
+
s = place(s, part, 10);
|
|
96
|
+
/* Compare the deadline, not the time left: the clock moves between
|
|
97
|
+
the two reads and would hide or invent a penalty of its own. */
|
|
98
|
+
const deadlineBefore = s.endsAt;
|
|
99
|
+
s = place(s, part, 20);
|
|
100
|
+
assert.deepEqual(s.blueprint.filled, [part]);
|
|
101
|
+
assert.equal(s.endsAt, deadlineBefore - DEFAULTS.penaltyMs);
|
|
102
|
+
assert.equal(s.last.result, 'duplicate');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a wrong part breaks the streak', () => {
|
|
106
|
+
let s = createGame({seed: 8, now: 0});
|
|
107
|
+
s = build(s, 0);
|
|
108
|
+
assert.equal(s.combo, 1);
|
|
109
|
+
const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
|
|
110
|
+
s = place(s, wrong, 10);
|
|
111
|
+
assert.equal(s.combo, 0);
|
|
112
|
+
assert.equal(s.bestCombo, 1, 'the best streak was forgotten');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the clock ends the run, and nothing scores afterwards', () => {
|
|
116
|
+
let s = createGame({seed: 9, now: 0});
|
|
117
|
+
s = step(s, DEFAULTS.startMs + 1);
|
|
118
|
+
assert.equal(s.over, true);
|
|
119
|
+
assert.equal(timeLeft(s, DEFAULTS.startMs + 1), 0);
|
|
120
|
+
|
|
121
|
+
const frozen = place(s, s.blueprint.needed[0], DEFAULTS.startMs + 10);
|
|
122
|
+
assert.equal(frozen.score, s.score);
|
|
123
|
+
assert.equal(frozen.built, s.built);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a penalty that empties the clock ends the run there and then', () => {
|
|
127
|
+
let s = createGame({seed: 10, now: 0, config: {startMs: 2000}});
|
|
128
|
+
const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
|
|
129
|
+
s = place(s, wrong, 100);
|
|
130
|
+
assert.equal(s.over, true, 'the run kept going on an empty clock');
|
|
131
|
+
assert.equal(timeLeft(s, 100), 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the clock never reads negative', () => {
|
|
135
|
+
const s = createGame({seed: 11, now: 0});
|
|
136
|
+
assert.equal(timeLeft(s, DEFAULTS.startMs * 10), 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the same seed deals the same blueprints, a different one does not', () => {
|
|
140
|
+
const deals = (seed) => {
|
|
141
|
+
let s = createGame({seed, now: 0});
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let i = 0; i < 4; i++) { out.push(s.blueprint.needed.join(',')); s = build(s, i); }
|
|
144
|
+
return out.join('|');
|
|
145
|
+
};
|
|
146
|
+
assert.equal(deals(42), deals(42));
|
|
147
|
+
assert.notEqual(deals(42), deals(43));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
151
|
+
const s = {built: 6, bestCombo: 3};
|
|
152
|
+
assert.match(summarise(s, 'en'), /6 apps built · streak 3/);
|
|
153
|
+
assert.match(summarise(s, 'nl'), /6 apps gebouwd · reeks 3/);
|
|
154
|
+
});
|