@conduction/docusaurus-preset 3.38.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/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 +5 -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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <RecordRun />
|
|
3
|
+
*
|
|
4
|
+
* Connext's mini-game. A record travels down the stack, and the stack
|
|
5
|
+
* is where records get stuck: a format nothing can read, a permission
|
|
6
|
+
* nobody granted, a connector that was never built. Steer around what
|
|
7
|
+
* would stop it, and pick up the apps that carry it further.
|
|
8
|
+
*
|
|
9
|
+
* The only one of the games with a position to steer rather than an
|
|
10
|
+
* answer to choose, which is why it sits on the page about the stack
|
|
11
|
+
* as a whole rather than on an app's own page.
|
|
12
|
+
*
|
|
13
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
*
|
|
17
|
+
* <RecordRun />
|
|
18
|
+
*
|
|
19
|
+
* Fires the shared `connext:gameend` event on game over and listens
|
|
20
|
+
* for `connext:gamereplay`, like every other mini-game.
|
|
21
|
+
*
|
|
22
|
+
* Accessibility: three lane buttons, each usable by tab, by click and
|
|
23
|
+
* by its number key (arrow keys work too). Every lane says in text
|
|
24
|
+
* what is coming towards the record, so the board is readable without
|
|
25
|
+
* seeing the grid, and the run is announced as it happens.
|
|
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 {
|
|
32
|
+
createGame, step, move, moveTo, summarise, LANES, BLOCK, APP,
|
|
33
|
+
} from './engine';
|
|
34
|
+
import styles from './RecordRun.module.css';
|
|
35
|
+
|
|
36
|
+
const GAME_ID = 'record-run';
|
|
37
|
+
const TICK_MS = 60;
|
|
38
|
+
|
|
39
|
+
function blockCopy(what) {
|
|
40
|
+
if (what === 'format') {
|
|
41
|
+
return translate({id: 'preset.recordRun.block.format', message: 'Format nothing reads', description: 'Obstacle in the record-run game: an unreadable format'});
|
|
42
|
+
}
|
|
43
|
+
if (what === 'permission') {
|
|
44
|
+
return translate({id: 'preset.recordRun.block.permission', message: 'Permission nobody granted', description: 'Obstacle in the record-run game: a missing permission'});
|
|
45
|
+
}
|
|
46
|
+
return translate({id: 'preset.recordRun.block.connector', message: 'Connector that is not there', description: 'Obstacle in the record-run game: a missing connector'});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appCopy(what) {
|
|
50
|
+
if (what === 'register') {
|
|
51
|
+
return translate({id: 'preset.recordRun.app.register', message: 'Register', description: 'Pick-up in the record-run game: a register'});
|
|
52
|
+
}
|
|
53
|
+
if (what === 'catalogue') {
|
|
54
|
+
return translate({id: 'preset.recordRun.app.catalogue', message: 'Catalogue', description: 'Pick-up in the record-run game: a catalogue'});
|
|
55
|
+
}
|
|
56
|
+
return translate({id: 'preset.recordRun.app.portal', message: 'Portal', description: 'Pick-up in the record-run game: a portal'});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cellCopy(cell) {
|
|
60
|
+
if (!cell || cell.kind === 'clear') return null;
|
|
61
|
+
return cell.kind === BLOCK ? blockCopy(cell.what) : appCopy(cell.what);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function RecordRun({className}) {
|
|
65
|
+
const {i18n} = useDocusaurusContext();
|
|
66
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
67
|
+
|
|
68
|
+
const [game, setGame] = useState(null);
|
|
69
|
+
const gameRef = useRef(null);
|
|
70
|
+
const startedAtRef = useRef(0);
|
|
71
|
+
const endedRef = useRef(false);
|
|
72
|
+
|
|
73
|
+
const running = Boolean(game) && !game.over;
|
|
74
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
75
|
+
|
|
76
|
+
const begin = useCallback(() => {
|
|
77
|
+
endedRef.current = false;
|
|
78
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
79
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
80
|
+
gameRef.current = fresh;
|
|
81
|
+
setGame(fresh);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!running) return undefined;
|
|
86
|
+
const id = setInterval(() => {
|
|
87
|
+
const next = step(gameRef.current, now());
|
|
88
|
+
if (next !== gameRef.current) {
|
|
89
|
+
gameRef.current = next;
|
|
90
|
+
setGame(next);
|
|
91
|
+
}
|
|
92
|
+
}, TICK_MS);
|
|
93
|
+
return () => clearInterval(id);
|
|
94
|
+
}, [running]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!game || !game.over || endedRef.current) return;
|
|
98
|
+
endedRef.current = true;
|
|
99
|
+
if (typeof window === 'undefined') return;
|
|
100
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
101
|
+
detail: {
|
|
102
|
+
id: GAME_ID,
|
|
103
|
+
won: false,
|
|
104
|
+
score: game.score,
|
|
105
|
+
summary: summarise(game, locale),
|
|
106
|
+
title: translate({id: 'preset.recordRun.over.title', message: 'The record got stuck.', description: 'Headline on the game-over dialog after a record-run run'}),
|
|
107
|
+
subtitle: translate({id: 'preset.recordRun.over.subtitle', message: 'Three times, on the three things that always stop one.', description: 'Subtitle on the game-over dialog after a record-run run'}),
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
110
|
+
}, [game, locale]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (typeof window === 'undefined') return undefined;
|
|
114
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
115
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
116
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
117
|
+
}, [begin]);
|
|
118
|
+
|
|
119
|
+
const steer = useCallback((lane) => {
|
|
120
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
121
|
+
const next = moveTo(gameRef.current, lane);
|
|
122
|
+
gameRef.current = next;
|
|
123
|
+
setGame(next);
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!running || typeof window === 'undefined') return undefined;
|
|
128
|
+
const onKey = (e) => {
|
|
129
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const next = move(gameRef.current, e.key === 'ArrowLeft' ? -1 : 1);
|
|
132
|
+
gameRef.current = next;
|
|
133
|
+
setGame(next);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const n = Number(e.key);
|
|
137
|
+
if (Number.isInteger(n) && n >= 1 && n <= LANES) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
steer(n - 1);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
window.addEventListener('keydown', onKey);
|
|
143
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
144
|
+
}, [running, steer]);
|
|
145
|
+
|
|
146
|
+
const rows = game ? game.rows : [];
|
|
147
|
+
const lane = game ? game.lane : 1;
|
|
148
|
+
const last = game && game.last ? game.last : null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<section className={[styles.rr, className].filter(Boolean).join(' ')} aria-labelledby="record-run-title">
|
|
152
|
+
<header className={styles.head}>
|
|
153
|
+
<div>
|
|
154
|
+
<p className={styles.eyebrow}>
|
|
155
|
+
{translate({id: 'preset.recordRun.eyebrow', message: 'Mini-game', description: 'Eyebrow above the record-run game'})}
|
|
156
|
+
</p>
|
|
157
|
+
<h3 className={styles.title} id="record-run-title">
|
|
158
|
+
{translate({id: 'preset.recordRun.title', message: 'Record run', description: 'Name of the Connext mini-game'})}
|
|
159
|
+
</h3>
|
|
160
|
+
<p className={styles.lede}>
|
|
161
|
+
{translate({id: 'preset.recordRun.lede', message: 'One record, on its way through the stack. Steer around the three things that always stop one, and pick up the apps that carry it further.', description: 'One-line explanation of the record-run rules'})}
|
|
162
|
+
</p>
|
|
163
|
+
</div>
|
|
164
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
165
|
+
<span className={styles.hudPill}>
|
|
166
|
+
{translate({id: 'preset.recordRun.hud.score', message: 'Score {score}', description: 'Score readout on the record-run HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
167
|
+
</span>
|
|
168
|
+
<span className={styles.hudPill}>
|
|
169
|
+
{translate({id: 'preset.recordRun.hud.lives', message: 'Retries {lives}', description: 'Remaining-lives readout on the record-run HUD'}, {lives: game ? game.lives : 3})}
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
</header>
|
|
173
|
+
|
|
174
|
+
<div className={styles.track} aria-hidden="true">
|
|
175
|
+
{/* Furthest row first, so the board reads top to bottom the way
|
|
176
|
+
it moves. */}
|
|
177
|
+
{rows.map((row) => (
|
|
178
|
+
<div className={styles.row} key={row.id}>
|
|
179
|
+
{row.cells.map((cell, i) => (
|
|
180
|
+
<span
|
|
181
|
+
key={i}
|
|
182
|
+
className={[
|
|
183
|
+
styles.cell,
|
|
184
|
+
cell.kind === BLOCK && styles.cellBlock,
|
|
185
|
+
cell.kind === APP && styles.cellApp,
|
|
186
|
+
i === lane && styles.cellLane,
|
|
187
|
+
].filter(Boolean).join(' ')}>
|
|
188
|
+
{cellCopy(cell)}
|
|
189
|
+
</span>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
))}
|
|
193
|
+
{!game && (
|
|
194
|
+
<p className={styles.idle}>
|
|
195
|
+
{translate({id: 'preset.recordRun.idle', message: 'A record, three lanes, and everything that gets in the way.', description: 'Placeholder on the record-run track before the game starts'})}
|
|
196
|
+
</p>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className={styles.lanes}>
|
|
201
|
+
{Array.from({length: LANES}).map((_, i) => {
|
|
202
|
+
const incoming = rows.length ? rows[rows.length - 1].cells[i] : null;
|
|
203
|
+
const what = cellCopy(incoming);
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
key={i}
|
|
207
|
+
type="button"
|
|
208
|
+
className={[styles.lane, i === lane && styles.laneHere].filter(Boolean).join(' ')}
|
|
209
|
+
onClick={() => steer(i)}
|
|
210
|
+
disabled={!running}
|
|
211
|
+
aria-pressed={i === lane}
|
|
212
|
+
aria-label={translate(
|
|
213
|
+
{id: 'preset.recordRun.laneLabel', message: 'Lane {n}. Coming next: {what}', description: 'Accessible label for a record-run lane button. {what} is what the next row holds in that lane.'},
|
|
214
|
+
{n: i + 1, what: what || translate({id: 'preset.recordRun.laneClear', message: 'clear', description: 'What a record-run lane holds when the next row is empty there'})},
|
|
215
|
+
)}>
|
|
216
|
+
<span className={styles.laneKey} aria-hidden="true">{i + 1}</span>
|
|
217
|
+
<span className={styles.laneWhat} aria-hidden="true">
|
|
218
|
+
{what || translate({id: 'preset.recordRun.laneClear', message: 'clear', description: 'What a record-run lane holds when the next row is empty there'})}
|
|
219
|
+
</span>
|
|
220
|
+
</button>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<footer className={styles.foot}>
|
|
226
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
227
|
+
{game
|
|
228
|
+
? translate({id: 'preset.recordRun.restart', message: 'Restart', description: 'Button that restarts the record-run game'})
|
|
229
|
+
: translate({id: 'preset.recordRun.start', message: 'Send a record', description: 'Button that starts the record-run game'})}
|
|
230
|
+
</button>
|
|
231
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
232
|
+
{last && last.result === 'blocked' && translate(
|
|
233
|
+
{id: 'preset.recordRun.feedback.blocked', message: 'Stuck on: {what}', description: 'Feedback after the record hits an obstacle. {what} is the obstacle.'},
|
|
234
|
+
{what: blockCopy(last.what)},
|
|
235
|
+
)}
|
|
236
|
+
{last && last.result === 'collected' && translate(
|
|
237
|
+
{id: 'preset.recordRun.feedback.collected', message: 'Picked up: {what}', description: 'Feedback after the record collects an app. {what} is the app.'},
|
|
238
|
+
{what: appCopy(last.what)},
|
|
239
|
+
)}
|
|
240
|
+
{(!last || last.result === 'through') && translate({id: 'preset.recordRun.hint', message: 'Arrow keys, the number keys, or click a lane. Open lanes pay a little, apps pay more.', description: 'Hint under the record-run board explaining the controls'})}
|
|
241
|
+
</p>
|
|
242
|
+
</footer>
|
|
243
|
+
</section>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <RecordRun /> styles.
|
|
3
|
+
*
|
|
4
|
+
* Tokens only. One accent: whatever is about to stop the record. The
|
|
5
|
+
* lane buttons repeat what each lane holds in words, so the grid is a
|
|
6
|
+
* picture of the state rather than the only copy of it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.rr {
|
|
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
|
+
.track {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: 6px;
|
|
70
|
+
padding: 12px;
|
|
71
|
+
border-radius: var(--radius-md);
|
|
72
|
+
background: var(--c-cobalt-50);
|
|
73
|
+
max-width: 620px;
|
|
74
|
+
min-height: 180px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.row {
|
|
78
|
+
display: grid;
|
|
79
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
80
|
+
gap: 6px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.cell {
|
|
84
|
+
min-height: 26px;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
padding: 2px 6px;
|
|
89
|
+
border-radius: var(--radius-sm);
|
|
90
|
+
background: white;
|
|
91
|
+
border: 1px solid var(--c-cobalt-100);
|
|
92
|
+
font-size: 10px;
|
|
93
|
+
line-height: 1.15;
|
|
94
|
+
text-align: center;
|
|
95
|
+
color: var(--c-cobalt-400);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* The lane the record is in, marked down the whole column so the
|
|
99
|
+
player can see where they are without following the record. */
|
|
100
|
+
.cellLane {
|
|
101
|
+
border-color: var(--c-blue-cobalt);
|
|
102
|
+
box-shadow: inset 0 0 0 1px var(--c-blue-cobalt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.cellBlock {
|
|
106
|
+
background: var(--c-orange-knvb);
|
|
107
|
+
border-color: var(--c-orange-knvb);
|
|
108
|
+
color: white;
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.cellApp {
|
|
113
|
+
background: var(--c-mint-300);
|
|
114
|
+
border-color: var(--c-mint-500);
|
|
115
|
+
color: var(--c-cobalt-900);
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.idle {
|
|
120
|
+
margin: auto;
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
color: var(--c-cobalt-400);
|
|
123
|
+
text-align: center;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.lanes {
|
|
127
|
+
display: grid;
|
|
128
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
129
|
+
gap: 6px;
|
|
130
|
+
margin-top: 8px;
|
|
131
|
+
max-width: 620px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.lane {
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 4px;
|
|
139
|
+
padding: 10px 8px;
|
|
140
|
+
border: 1px solid var(--c-cobalt-200);
|
|
141
|
+
border-radius: var(--radius-md);
|
|
142
|
+
background: white;
|
|
143
|
+
font-family: inherit;
|
|
144
|
+
font-size: 11px;
|
|
145
|
+
color: var(--c-cobalt-700);
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
148
|
+
}
|
|
149
|
+
.lane:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
150
|
+
.lane:disabled { opacity: 0.55; cursor: default; }
|
|
151
|
+
.lane:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
152
|
+
|
|
153
|
+
.laneHere {
|
|
154
|
+
border-color: var(--c-blue-cobalt);
|
|
155
|
+
background: var(--c-cobalt-50);
|
|
156
|
+
font-weight: 600;
|
|
157
|
+
color: var(--c-cobalt-900);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.laneKey {
|
|
161
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
162
|
+
font-size: 10px;
|
|
163
|
+
color: var(--c-cobalt-400);
|
|
164
|
+
border: 1px solid var(--c-cobalt-200);
|
|
165
|
+
border-radius: 3px;
|
|
166
|
+
padding: 0 5px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.laneWhat { text-align: center; line-height: 1.2; }
|
|
170
|
+
|
|
171
|
+
.foot {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-wrap: wrap;
|
|
174
|
+
align-items: center;
|
|
175
|
+
gap: 12px;
|
|
176
|
+
margin-top: 16px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.start {
|
|
180
|
+
background: var(--c-blue-cobalt);
|
|
181
|
+
color: white;
|
|
182
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
183
|
+
padding: 10px 18px;
|
|
184
|
+
border-radius: var(--radius-md);
|
|
185
|
+
font-family: inherit;
|
|
186
|
+
font-weight: 500;
|
|
187
|
+
font-size: 14px;
|
|
188
|
+
cursor: pointer;
|
|
189
|
+
transition: background 120ms ease;
|
|
190
|
+
}
|
|
191
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
192
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
193
|
+
|
|
194
|
+
.hint {
|
|
195
|
+
margin: 0;
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
color: var(--c-cobalt-400);
|
|
198
|
+
max-width: 52ch;
|
|
199
|
+
min-height: 1.5em;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
203
|
+
.row:last-child { animation: rrArrive 120ms ease-out; }
|
|
204
|
+
@keyframes rrArrive {
|
|
205
|
+
from { opacity: 0.4; transform: translateY(-4px); }
|
|
206
|
+
to { opacity: 1; transform: none; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the record-run rules.
|
|
3
|
+
*
|
|
4
|
+
* The rule that has to hold on every board: a row never blocks more
|
|
5
|
+
* than one lane. Two blocks in one row can strand a record that was
|
|
6
|
+
* already committed to a lane, and a death the player could not have
|
|
7
|
+
* avoided is the fastest way to make an arcade game feel rigged.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
createGame, step, move, moveTo, stepMs, summarise,
|
|
17
|
+
LANES, CLEAR, BLOCK, APP, BLOCKS, APPS, DEFAULTS,
|
|
18
|
+
} = require('../engine.js');
|
|
19
|
+
|
|
20
|
+
/** Force the row the record is about to meet. */
|
|
21
|
+
function withArriving(state, cells) {
|
|
22
|
+
const rows = [...state.rows];
|
|
23
|
+
rows[rows.length - 1] = {cells, id: 'forced'};
|
|
24
|
+
return {...state, rows};
|
|
25
|
+
}
|
|
26
|
+
const clearRow = () => Array.from({length: LANES}, () => ({kind: CLEAR}));
|
|
27
|
+
|
|
28
|
+
test('a new game starts centred, alive, and with a full board', () => {
|
|
29
|
+
const s = createGame({seed: 1, now: 0});
|
|
30
|
+
assert.equal(s.lane, 1);
|
|
31
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
32
|
+
assert.equal(s.rows.length, DEFAULTS.rows);
|
|
33
|
+
assert.equal(s.over, false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('no row ever blocks more than one lane', () => {
|
|
37
|
+
/* Fifty seeds, a hundred rows each: a generator bug shows up as one
|
|
38
|
+
bad row in thousands, not as every row. */
|
|
39
|
+
for (let seed = 1; seed <= 50; seed++) {
|
|
40
|
+
let s = createGame({seed, now: 0});
|
|
41
|
+
let t = 0;
|
|
42
|
+
for (let i = 0; i < 100; i++) {
|
|
43
|
+
for (const row of s.rows) {
|
|
44
|
+
const blocks = row.cells.filter((c) => c.kind === BLOCK).length;
|
|
45
|
+
assert.ok(blocks <= 1, `seed ${seed}: a row blocked ${blocks} lanes`);
|
|
46
|
+
}
|
|
47
|
+
t += stepMs(s);
|
|
48
|
+
/* Stay on a lane that is clear, so the run does not end early. */
|
|
49
|
+
const arriving = s.rows[s.rows.length - 1];
|
|
50
|
+
const safe = arriving.cells.findIndex((c) => c.kind !== BLOCK);
|
|
51
|
+
s = step(moveTo(s, safe), t);
|
|
52
|
+
if (s.over) break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('every cell the generator produces is one of the kinds the board knows', () => {
|
|
58
|
+
let s = createGame({seed: 7, now: 0});
|
|
59
|
+
let t = 0;
|
|
60
|
+
for (let i = 0; i < 200; i++) {
|
|
61
|
+
for (const row of s.rows) {
|
|
62
|
+
for (const cell of row.cells) {
|
|
63
|
+
assert.ok([CLEAR, BLOCK, APP].includes(cell.kind), `unknown cell ${cell.kind}`);
|
|
64
|
+
if (cell.kind === BLOCK) assert.ok(BLOCKS.includes(cell.what));
|
|
65
|
+
if (cell.kind === APP) assert.ok(APPS.includes(cell.what));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
t += stepMs(s);
|
|
69
|
+
const safe = s.rows[s.rows.length - 1].cells.findIndex((c) => c.kind !== BLOCK);
|
|
70
|
+
s = step(moveTo(s, safe), t);
|
|
71
|
+
if (s.over) break;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the record moves between lanes, and the edges hold', () => {
|
|
76
|
+
const s = createGame({seed: 2, now: 0});
|
|
77
|
+
assert.equal(move(s, -1).lane, 0);
|
|
78
|
+
assert.equal(move(move(s, -1), -1).lane, 0, 'the record walked off the left edge');
|
|
79
|
+
assert.equal(move(s, 1).lane, 2);
|
|
80
|
+
assert.equal(move(move(s, 1), 1).lane, 2, 'the record walked off the right edge');
|
|
81
|
+
assert.equal(moveTo(s, 2).lane, 2);
|
|
82
|
+
assert.equal(moveTo(s, 9).lane, s.lane, 'a lane that does not exist was accepted');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('nothing happens until the step is due', () => {
|
|
86
|
+
const s = createGame({seed: 3, now: 0});
|
|
87
|
+
const early = step(s, s.nextStepAt - 1);
|
|
88
|
+
assert.equal(early.travelled, 0);
|
|
89
|
+
assert.equal(early.score, 0);
|
|
90
|
+
const due = step(s, s.nextStepAt);
|
|
91
|
+
assert.equal(due.travelled, 1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an open lane pays a little, and keeps the board full', () => {
|
|
95
|
+
let s = createGame({seed: 4, now: 0});
|
|
96
|
+
s = withArriving(s, clearRow());
|
|
97
|
+
const after = step(s, s.nextStepAt);
|
|
98
|
+
assert.equal(after.score, DEFAULTS.pointsPerRow);
|
|
99
|
+
assert.equal(after.lives, DEFAULTS.lives);
|
|
100
|
+
assert.equal(after.rows.length, DEFAULTS.rows, 'the board ran out of rows');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('an app in the lane is picked up and pays properly', () => {
|
|
104
|
+
let s = createGame({seed: 5, now: 0});
|
|
105
|
+
const cells = clearRow();
|
|
106
|
+
cells[s.lane] = {kind: APP, what: 'register'};
|
|
107
|
+
s = step(withArriving(s, cells), s.nextStepAt);
|
|
108
|
+
assert.equal(s.score, DEFAULTS.pointsPerApp);
|
|
109
|
+
assert.equal(s.collected, 1);
|
|
110
|
+
assert.equal(s.last.result, 'collected');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a block in the lane costs a life and says what stopped it', () => {
|
|
114
|
+
let s = createGame({seed: 6, now: 0});
|
|
115
|
+
const cells = clearRow();
|
|
116
|
+
cells[s.lane] = {kind: BLOCK, what: 'permission'};
|
|
117
|
+
s = step(withArriving(s, cells), s.nextStepAt);
|
|
118
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
119
|
+
assert.equal(s.blocked, 1);
|
|
120
|
+
assert.equal(s.last.result, 'blocked');
|
|
121
|
+
assert.equal(s.last.what, 'permission');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a block in another lane is not the record problem', () => {
|
|
125
|
+
let s = createGame({seed: 6, now: 0});
|
|
126
|
+
const cells = clearRow();
|
|
127
|
+
cells[(s.lane + 1) % LANES] = {kind: BLOCK, what: 'format'};
|
|
128
|
+
s = step(withArriving(s, cells), s.nextStepAt);
|
|
129
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
130
|
+
assert.equal(s.score, DEFAULTS.pointsPerRow);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('three blocks end the run, and nothing moves afterwards', () => {
|
|
134
|
+
let s = createGame({seed: 8, now: 0});
|
|
135
|
+
let t = 0;
|
|
136
|
+
for (let i = 0; i < 3; i++) {
|
|
137
|
+
const cells = clearRow();
|
|
138
|
+
cells[s.lane] = {kind: BLOCK, what: 'connector'};
|
|
139
|
+
t = s.nextStepAt;
|
|
140
|
+
s = step(withArriving(s, cells), t);
|
|
141
|
+
}
|
|
142
|
+
assert.equal(s.over, true);
|
|
143
|
+
assert.equal(s.lives, 0);
|
|
144
|
+
assert.equal(step(s, t + 10000).travelled, s.travelled);
|
|
145
|
+
assert.equal(move(s, 1).lane, s.lane, 'the record still steered after game over');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the board speeds up with the score, down to a floor a person can react in', () => {
|
|
149
|
+
const fresh = createGame({seed: 9, now: 0});
|
|
150
|
+
assert.equal(stepMs(fresh), DEFAULTS.stepStartMs);
|
|
151
|
+
assert.ok(stepMs({...fresh, score: 100}) < DEFAULTS.stepStartMs);
|
|
152
|
+
assert.equal(stepMs({...fresh, score: 10000}), DEFAULTS.stepFloorMs);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a player who always takes a clear lane survives a long run', () => {
|
|
156
|
+
let s = createGame({seed: 11, now: 0});
|
|
157
|
+
let t = 0;
|
|
158
|
+
for (let i = 0; i < 300; i++) {
|
|
159
|
+
const arriving = s.rows[s.rows.length - 1];
|
|
160
|
+
const safe = arriving.cells.findIndex((c) => c.kind !== BLOCK);
|
|
161
|
+
assert.ok(safe >= 0, 'a row left the record nowhere to go');
|
|
162
|
+
t = s.nextStepAt;
|
|
163
|
+
s = step(moveTo(s, safe), t);
|
|
164
|
+
}
|
|
165
|
+
assert.equal(s.over, false);
|
|
166
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
167
|
+
assert.ok(s.score > 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the same seed runs the same board, a different one does not', () => {
|
|
171
|
+
const shape = (seed) => {
|
|
172
|
+
let s = createGame({seed, now: 0});
|
|
173
|
+
let t = 0;
|
|
174
|
+
const out = [];
|
|
175
|
+
for (let i = 0; i < 10; i++) {
|
|
176
|
+
out.push(s.rows.map((r) => r.cells.map((c) => c.kind[0]).join('')).join('|'));
|
|
177
|
+
t = s.nextStepAt;
|
|
178
|
+
const safe = s.rows[s.rows.length - 1].cells.findIndex((c) => c.kind !== BLOCK);
|
|
179
|
+
s = step(moveTo(s, safe), t);
|
|
180
|
+
}
|
|
181
|
+
return out.join('//');
|
|
182
|
+
};
|
|
183
|
+
assert.equal(shape(31), shape(31));
|
|
184
|
+
assert.notEqual(shape(31), shape(32));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
188
|
+
const s = {travelled: 42, collected: 7};
|
|
189
|
+
assert.match(summarise(s, 'en'), /42 hops · 7 apps picked up/);
|
|
190
|
+
assert.match(summarise(s, 'nl'), /42 stappen · 7 apps onderweg/);
|
|
191
|
+
});
|