@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.
- 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/LockPick/LockPick.jsx +210 -0
- package/src/components/LockPick/LockPick.module.css +205 -0
- package/src/components/LockPick/__tests__/engine.test.js +193 -0
- package/src/components/LockPick/engine.js +172 -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 +6 -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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scores.test.js — the mini-game score table.
|
|
3
|
+
*
|
|
4
|
+
* The migration case is the one that matters: every earlier build
|
|
5
|
+
* wrote a flat `{id: true}` map, and a returning player whose
|
|
6
|
+
* found-games progress silently reset to zero would read that as a
|
|
7
|
+
* bug in the games, not in the storage format.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
migrate, recordResult, bestFor, foundCount, totalScore, readScores, writeScores,
|
|
17
|
+
STORAGE_KEY, STORAGE_VERSION,
|
|
18
|
+
} = require('../scores.js');
|
|
19
|
+
|
|
20
|
+
function fakeStorage(initial) {
|
|
21
|
+
let value = initial;
|
|
22
|
+
return {
|
|
23
|
+
getItem: () => value,
|
|
24
|
+
setItem: (_k, v) => { value = v; },
|
|
25
|
+
read: () => value,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('migrates the version 1 flat map, keeping discovered games', () => {
|
|
30
|
+
const state = migrate({hexrain: true, boats: true, invaders: false});
|
|
31
|
+
assert.equal(state.version, STORAGE_VERSION);
|
|
32
|
+
assert.equal(foundCount(state), 2);
|
|
33
|
+
assert.deepEqual(state.games.hexrain, {found: true, best: null, plays: 0});
|
|
34
|
+
assert.equal(state.games.invaders, undefined, 'a false entry is not a found game');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('reads the current shape back unchanged', () => {
|
|
38
|
+
const stored = {version: 2, games: {boats: {found: true, best: 18, plays: 3}}};
|
|
39
|
+
assert.deepEqual(migrate(stored).games.boats, {found: true, best: 18, plays: 3});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('survives junk in user-writable storage', () => {
|
|
43
|
+
for (const junk of [null, undefined, 42, 'nope', [], {version: 2, games: null}, {version: 2, games: {a: 7}}]) {
|
|
44
|
+
const state = migrate(junk);
|
|
45
|
+
assert.equal(state.version, STORAGE_VERSION);
|
|
46
|
+
assert.equal(typeof state.games, 'object');
|
|
47
|
+
}
|
|
48
|
+
assert.equal(foundCount(migrate({version: 2, games: {a: {found: true, best: 'ten'}}})), 1);
|
|
49
|
+
assert.equal(bestFor(migrate({version: 2, games: {a: {found: true, best: 'ten'}}}), 'a'), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('records a result, keeps the better score, and counts the play', () => {
|
|
53
|
+
let state = migrate(null);
|
|
54
|
+
state = recordResult(state, {id: 'boats', score: 12});
|
|
55
|
+
assert.equal(bestFor(state, 'boats'), 12);
|
|
56
|
+
|
|
57
|
+
state = recordResult(state, {id: 'boats', score: 18});
|
|
58
|
+
assert.equal(bestFor(state, 'boats'), 18);
|
|
59
|
+
|
|
60
|
+
state = recordResult(state, {id: 'boats', score: 3});
|
|
61
|
+
assert.equal(bestFor(state, 'boats'), 18, 'a worse run overwrote the best');
|
|
62
|
+
assert.equal(state.games.boats.plays, 3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a game that reports no score still counts as found', () => {
|
|
66
|
+
const state = recordResult(migrate(null), {id: 'kade-cyclist'});
|
|
67
|
+
assert.equal(state.games['kade-cyclist'].found, true);
|
|
68
|
+
assert.equal(bestFor(state, 'kade-cyclist'), null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('recordResult does not mutate the state it was given', () => {
|
|
72
|
+
const before = migrate({version: 2, games: {boats: {found: true, best: 5, plays: 1}}});
|
|
73
|
+
const snapshot = JSON.stringify(before);
|
|
74
|
+
recordResult(before, {id: 'boats', score: 99});
|
|
75
|
+
assert.equal(JSON.stringify(before), snapshot);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the total is the sum of the bests, ignoring games without one', () => {
|
|
79
|
+
let state = migrate(null);
|
|
80
|
+
state = recordResult(state, {id: 'boats', score: 18});
|
|
81
|
+
state = recordResult(state, {id: 'invaders', score: 3400});
|
|
82
|
+
state = recordResult(state, {id: 'kade-cyclist'});
|
|
83
|
+
assert.equal(totalScore(state), 3418);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('an empty table totals zero rather than NaN', () => {
|
|
87
|
+
assert.equal(totalScore(migrate(null)), 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('round-trips through storage under the documented key', () => {
|
|
91
|
+
const store = fakeStorage(null);
|
|
92
|
+
const state = recordResult(migrate(null), {id: 'boats', score: 7});
|
|
93
|
+
writeScores(state, store);
|
|
94
|
+
assert.match(store.read(), /"version":2/);
|
|
95
|
+
assert.equal(bestFor(readScores(store), 'boats'), 7);
|
|
96
|
+
assert.equal(STORAGE_KEY, 'conduction:minigames');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a throwing storage never takes the game-over dialog down with it', () => {
|
|
100
|
+
const hostile = {
|
|
101
|
+
getItem: () => { throw new Error('blocked'); },
|
|
102
|
+
setItem: () => { throw new Error('blocked'); },
|
|
103
|
+
};
|
|
104
|
+
assert.equal(foundCount(readScores(hostile)), 0);
|
|
105
|
+
assert.doesNotThrow(() => writeScores(migrate(null), hostile));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the counts are scoped to the roster, so a stale game cannot exceed the total', () => {
|
|
109
|
+
/* The storage key is shared across Conduction sites, so a table can
|
|
110
|
+
hold games this site does not list. Counting them gave "6 / 5
|
|
111
|
+
found, 120%" the first time a sixth game shipped. */
|
|
112
|
+
let state = migrate(null);
|
|
113
|
+
state = recordResult(state, {id: 'boats', score: 18});
|
|
114
|
+
state = recordResult(state, {id: 'invaders', score: 3400});
|
|
115
|
+
state = recordResult(state, {id: 'retired-game', score: 999});
|
|
116
|
+
|
|
117
|
+
const roster = ['hexrain', 'boats', 'invaders'];
|
|
118
|
+
assert.equal(foundCount(state, roster), 2, 'a game off the roster was counted as found');
|
|
119
|
+
assert.equal(totalScore(state, roster), 3418, 'a game off the roster paid into the total');
|
|
120
|
+
|
|
121
|
+
assert.equal(foundCount(state), 3, 'without a roster, everything still counts');
|
|
122
|
+
assert.equal(totalScore(state), 4417);
|
|
123
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* share.test.js — the post a player publishes, and the links that open it.
|
|
3
|
+
*
|
|
4
|
+
* The instance parsing is the part with real input variety: people
|
|
5
|
+
* type their handle, their profile URL, or the bare host, and a wrong
|
|
6
|
+
* guess opens a broken tab on a domain that is not theirs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
buildShareText, scoreLines, normaliseInstance, mastodonShareUrl, linkedInShareUrl,
|
|
16
|
+
} = require('../share.js');
|
|
17
|
+
|
|
18
|
+
const GAMES = [
|
|
19
|
+
{id: 'hexrain', label: 'Twelve apps · hex rain'},
|
|
20
|
+
{id: 'boats', label: 'Sink the boats · footer canal'},
|
|
21
|
+
{id: 'invaders', label: 'Hex-vaders · cookie CLI'},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
test('score lines name the game, not where it hides, and skip unplayed games', () => {
|
|
25
|
+
const bests = {hexrain: 12, invaders: 3400};
|
|
26
|
+
const lines = scoreLines(GAMES, (id) => (id in bests ? bests[id] : null));
|
|
27
|
+
assert.deepEqual(lines, ['Twelve apps 12', 'Hex-vaders 3,400']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the post carries the total, the per-game lines, the site and the hashtag', () => {
|
|
31
|
+
const text = buildShareText({
|
|
32
|
+
total: 3412,
|
|
33
|
+
lines: ['Twelve apps 12', 'Hex-vaders 3,400'],
|
|
34
|
+
foundCount: 2,
|
|
35
|
+
totalGames: 5,
|
|
36
|
+
url: 'https://conduction.nl',
|
|
37
|
+
hashtag: '#IReadTheKit',
|
|
38
|
+
});
|
|
39
|
+
assert.match(text, /Total score 3,412 across 2 of 5 hidden Conduction mini-games\./);
|
|
40
|
+
assert.match(text, /Twelve apps 12 · Hex-vaders 3,400/);
|
|
41
|
+
assert.match(text, /https:\/\/conduction\.nl/);
|
|
42
|
+
assert.match(text, /#IReadTheKit$/);
|
|
43
|
+
assert.ok(text.length < 500, 'must fit a 500-character Mastodon instance with room for a screenshot');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a player with no scores yet still gets a postable line', () => {
|
|
47
|
+
const text = buildShareText({total: 0, lines: [], foundCount: 1, totalGames: 5});
|
|
48
|
+
assert.match(text, /Total score 0 across 1 of 5/);
|
|
49
|
+
assert.doesNotMatch(text, /undefined|null|NaN/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('Dutch is written, not interpolated into English', () => {
|
|
53
|
+
const text = buildShareText({total: 42, lines: [], foundCount: 1, totalGames: 5, locale: 'nl'});
|
|
54
|
+
assert.match(text, /Totaalscore 42 op 1 van de 5/);
|
|
55
|
+
assert.doesNotMatch(text, /Total score/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('an instance is recognised however the player writes it', () => {
|
|
59
|
+
for (const input of [
|
|
60
|
+
'chaos.social', 'https://chaos.social', 'https://chaos.social/', 'CHAOS.social',
|
|
61
|
+
'@me@chaos.social', 'https://chaos.social/@me', ' chaos.social ',
|
|
62
|
+
]) {
|
|
63
|
+
assert.equal(normaliseInstance(input), 'chaos.social', `failed on ${JSON.stringify(input)}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('nonsense yields no instance, so nothing opens', () => {
|
|
68
|
+
for (const input of ['', ' ', null, undefined, 'not a host', '@me', 'localhost', 42]) {
|
|
69
|
+
assert.equal(normaliseInstance(input), null, `accepted ${JSON.stringify(input)}`);
|
|
70
|
+
}
|
|
71
|
+
assert.equal(mastodonShareUrl('@me', 'text'), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the Mastodon link targets the share endpoint on the player own instance', () => {
|
|
75
|
+
const url = mastodonShareUrl('@me@mastodon.nl', 'score 12 #IReadTheKit');
|
|
76
|
+
assert.equal(url, 'https://mastodon.nl/share?text=score%2012%20%23IReadTheKit');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the hashtag survives URL encoding on both networks', () => {
|
|
80
|
+
assert.match(mastodonShareUrl('mastodon.nl', '#IReadTheKit'), /%23IReadTheKit/);
|
|
81
|
+
assert.match(linkedInShareUrl('#IReadTheKit'), /%23IReadTheKit/);
|
|
82
|
+
assert.match(linkedInShareUrl('x'), /^https:\/\/www\.linkedin\.com\/feed\/\?shareActive=true/);
|
|
83
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mini-game scores.
|
|
3
|
+
*
|
|
4
|
+
* The score table lives in one localStorage key, on the player's own
|
|
5
|
+
* machine. There is no account and no server: a score is a thing you
|
|
6
|
+
* screenshot and post, not a row we hold. That is the whole storage
|
|
7
|
+
* design, and it is why nothing here needs consent.
|
|
8
|
+
*
|
|
9
|
+
* Shape (version 2):
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* version: 2,
|
|
13
|
+
* games: {
|
|
14
|
+
* hexrain: {found: true, best: 12, plays: 3},
|
|
15
|
+
* boats: {found: true, best: 18, plays: 1},
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Version 1 was a flat `{id: true}` map of discovered games, written
|
|
20
|
+
* by every earlier build. It is migrated on read rather than dropped,
|
|
21
|
+
* because a returning player's found-games progress bar would
|
|
22
|
+
* otherwise reset to zero and read as a bug.
|
|
23
|
+
*
|
|
24
|
+
* The total is the sum of the per-game bests. Games score in different
|
|
25
|
+
* units (boats sunk, apps collected, points), so the total is a tally
|
|
26
|
+
* rather than a rating, and a game with big numbers weighs more. That
|
|
27
|
+
* is a deliberate choice: the alternative, normalising every game onto
|
|
28
|
+
* the same range, makes a score impossible to explain in the one line
|
|
29
|
+
* someone posts with it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const STORAGE_KEY = 'conduction:minigames';
|
|
33
|
+
export const STORAGE_VERSION = 2;
|
|
34
|
+
|
|
35
|
+
function emptyState() {
|
|
36
|
+
return {version: STORAGE_VERSION, games: {}};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalise whatever is in storage into the current shape.
|
|
41
|
+
* Exported for the tests; callers use readScores().
|
|
42
|
+
*/
|
|
43
|
+
export function migrate(raw) {
|
|
44
|
+
if (!raw || typeof raw !== 'object') return emptyState();
|
|
45
|
+
|
|
46
|
+
if (raw.version === STORAGE_VERSION && raw.games && typeof raw.games === 'object') {
|
|
47
|
+
/* Re-read every entry rather than trusting the stored shape: this
|
|
48
|
+
is user-writable storage, and one hand-edited value should not
|
|
49
|
+
be able to make the modal throw on open. */
|
|
50
|
+
const games = {};
|
|
51
|
+
for (const [id, entry] of Object.entries(raw.games)) {
|
|
52
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
53
|
+
games[id] = {
|
|
54
|
+
found: Boolean(entry.found),
|
|
55
|
+
best: Number.isFinite(entry.best) ? entry.best : null,
|
|
56
|
+
plays: Number.isFinite(entry.plays) ? entry.plays : 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {version: STORAGE_VERSION, games};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Version 1: a flat map of discovered games, no scores kept. */
|
|
63
|
+
const games = {};
|
|
64
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
65
|
+
if (value === true) games[id] = {found: true, best: null, plays: 0};
|
|
66
|
+
}
|
|
67
|
+
return {version: STORAGE_VERSION, games};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readScores(storage) {
|
|
71
|
+
const store = storage || (typeof window !== 'undefined' ? window.localStorage : null);
|
|
72
|
+
if (!store) return emptyState();
|
|
73
|
+
try {
|
|
74
|
+
return migrate(JSON.parse(store.getItem(STORAGE_KEY)));
|
|
75
|
+
} catch (e) {
|
|
76
|
+
/* Unparseable or blocked storage: play on with an empty table
|
|
77
|
+
rather than breaking the game-over dialog. */
|
|
78
|
+
return emptyState();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeScores(state, storage) {
|
|
83
|
+
const store = storage || (typeof window !== 'undefined' ? window.localStorage : null);
|
|
84
|
+
if (!store) return;
|
|
85
|
+
try {
|
|
86
|
+
store.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
87
|
+
} catch (e) {/* fail open: a full or blocked store must not end the run */}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fold one game-end result into the table.
|
|
92
|
+
*
|
|
93
|
+
* Returns a new state; never mutates. A run that scores worse than the
|
|
94
|
+
* player's best leaves the best alone, so the number they posted last
|
|
95
|
+
* week does not disappear because they played once more.
|
|
96
|
+
*/
|
|
97
|
+
export function recordResult(state, {id, score} = {}) {
|
|
98
|
+
if (!id) return state;
|
|
99
|
+
const prev = state.games[id] || {found: false, best: null, plays: 0};
|
|
100
|
+
const scored = Number.isFinite(score);
|
|
101
|
+
return {
|
|
102
|
+
version: STORAGE_VERSION,
|
|
103
|
+
games: {
|
|
104
|
+
...state.games,
|
|
105
|
+
[id]: {
|
|
106
|
+
/* Any game-end counts as found: a few games (the cyclist, the
|
|
107
|
+
endless ones) never reach a clean win state. */
|
|
108
|
+
found: true,
|
|
109
|
+
best: scored ? Math.max(prev.best ?? -Infinity, score) : prev.best,
|
|
110
|
+
plays: (prev.plays || 0) + 1,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function bestFor(state, id) {
|
|
117
|
+
const entry = state.games[id];
|
|
118
|
+
return entry && Number.isFinite(entry.best) ? entry.best : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Both counts take the roster of games currently on the site.
|
|
123
|
+
*
|
|
124
|
+
* Without it, a score table that remembers a game the site no longer
|
|
125
|
+
* ships (or one found on a sister site, since the key is the same)
|
|
126
|
+
* counts towards a total it is not listed in: the dialog then reads
|
|
127
|
+
* "6 / 5 mini-games found, 120%". Scoping to the roster keeps the
|
|
128
|
+
* numbers describing the list the player is looking at. Omitting ids
|
|
129
|
+
* counts everything, which is what a caller without a roster wants.
|
|
130
|
+
*/
|
|
131
|
+
function entries(state, ids) {
|
|
132
|
+
const all = Object.entries(state.games);
|
|
133
|
+
if (!ids || !ids.length) return all.map(([, g]) => g);
|
|
134
|
+
const wanted = new Set(ids);
|
|
135
|
+
return all.filter(([id]) => wanted.has(id)).map(([, g]) => g);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function foundCount(state, ids) {
|
|
139
|
+
return entries(state, ids).filter((g) => g && g.found).length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function totalScore(state, ids) {
|
|
143
|
+
return entries(state, ids)
|
|
144
|
+
.reduce((sum, g) => sum + (g && Number.isFinite(g.best) ? g.best : 0), 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatScore(n, locale = 'en') {
|
|
148
|
+
return Number(n || 0).toLocaleString(locale);
|
|
149
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composing the post a player shares, and the links that open it.
|
|
3
|
+
*
|
|
4
|
+
* No API, no tokens, no account: we build a piece of text and hand it
|
|
5
|
+
* to the network the player already uses. Mastodon takes the text in
|
|
6
|
+
* the URL. LinkedIn does not reliably prefill any more, so there the
|
|
7
|
+
* text goes to the clipboard and the composer opens empty, which is
|
|
8
|
+
* why every path here also offers a plain copy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the post text.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately short: it has to survive a 500-character Mastodon
|
|
15
|
+
* instance with a screenshot attached, and it reads as something a
|
|
16
|
+
* person wrote rather than a share widget's output.
|
|
17
|
+
*/
|
|
18
|
+
export function buildShareText({
|
|
19
|
+
total,
|
|
20
|
+
lines = [],
|
|
21
|
+
foundCount,
|
|
22
|
+
totalGames,
|
|
23
|
+
hashtag = '#IReadTheKit',
|
|
24
|
+
url,
|
|
25
|
+
locale = 'en',
|
|
26
|
+
} = {}) {
|
|
27
|
+
const fmt = (n) => Number(n || 0).toLocaleString(locale);
|
|
28
|
+
const parts = [];
|
|
29
|
+
|
|
30
|
+
parts.push(
|
|
31
|
+
locale === 'nl'
|
|
32
|
+
? `Totaalscore ${fmt(total)} op ${foundCount} van de ${totalGames} verstopte spelletjes van Conduction.`
|
|
33
|
+
: `Total score ${fmt(total)} across ${foundCount} of ${totalGames} hidden Conduction mini-games.`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (lines.length) parts.push(lines.join(' · '));
|
|
37
|
+
|
|
38
|
+
parts.push(
|
|
39
|
+
locale === 'nl'
|
|
40
|
+
? 'Jouw beurt. De spelletjes staan verstopt op de site.'
|
|
41
|
+
: 'Your turn. The games are hidden on the site.',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (url) parts.push(url);
|
|
45
|
+
if (hashtag) parts.push(hashtag);
|
|
46
|
+
|
|
47
|
+
return parts.join('\n\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One `Label 1,234` line per game with a best score. */
|
|
51
|
+
export function scoreLines(games, bestOf, locale = 'en') {
|
|
52
|
+
return games
|
|
53
|
+
.map((g) => {
|
|
54
|
+
const best = bestOf(g.id);
|
|
55
|
+
if (best === null || best === undefined) return null;
|
|
56
|
+
/* The label carries "Game · where it hides"; the post only needs
|
|
57
|
+
the game. */
|
|
58
|
+
const name = String(g.label || g.id).split('·')[0].trim();
|
|
59
|
+
return `${name} ${Number(best).toLocaleString(locale)}`;
|
|
60
|
+
})
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalise whatever the player typed for their Mastodon instance.
|
|
66
|
+
* Accepts "chaos.social", "https://chaos.social/", "@me@chaos.social".
|
|
67
|
+
* Returns null when there is no host in it, so the caller can keep the
|
|
68
|
+
* field open instead of opening a broken tab.
|
|
69
|
+
*/
|
|
70
|
+
export function normaliseInstance(input) {
|
|
71
|
+
if (!input || typeof input !== 'string') return null;
|
|
72
|
+
let value = input.trim();
|
|
73
|
+
if (!value) return null;
|
|
74
|
+
/* Order matters. Strip the scheme and the path first, otherwise a
|
|
75
|
+
profile URL (https://chaos.social/@me) has its host thrown away
|
|
76
|
+
and the trailing "@me" is read as the instance. Only then treat a
|
|
77
|
+
remaining @ as a handle (@me@chaos.social). */
|
|
78
|
+
value = value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
|
|
79
|
+
if (value.includes('@')) value = value.slice(value.lastIndexOf('@') + 1);
|
|
80
|
+
if (!value || !value.includes('.') || /\s/.test(value)) return null;
|
|
81
|
+
return value.toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mastodonShareUrl(instance, text) {
|
|
85
|
+
const host = normaliseInstance(instance);
|
|
86
|
+
if (!host) return null;
|
|
87
|
+
return `https://${host}/share?text=${encodeURIComponent(text)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* LinkedIn's composer. The text parameter is honoured inconsistently,
|
|
92
|
+
* so the UI copies the post to the clipboard first and tells the
|
|
93
|
+
* player to paste. Passing it anyway costs nothing when it does work.
|
|
94
|
+
*/
|
|
95
|
+
export function linkedInShareUrl(text) {
|
|
96
|
+
return `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(text)}`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <LockPick />
|
|
3
|
+
*
|
|
4
|
+
* Keepiq's mini-game, and the one everybody has played before: set the
|
|
5
|
+
* pick, turn the cylinder, feel how far it gives. Close to the sweet
|
|
6
|
+
* spot it turns; far from it the pick strains and eventually snaps.
|
|
7
|
+
* Three picks, and each lock you open is narrower than the last.
|
|
8
|
+
*
|
|
9
|
+
* The rules live in ./engine.js with no DOM and no clock. There is no
|
|
10
|
+
* clock here either: a lock is a puzzle you reason your way into, and
|
|
11
|
+
* hurrying someone who is counting clicks would only make it a worse
|
|
12
|
+
* version of the timed games.
|
|
13
|
+
*
|
|
14
|
+
* Usage on a product page:
|
|
15
|
+
*
|
|
16
|
+
* <LockPick />
|
|
17
|
+
*
|
|
18
|
+
* Fires the shared `connext:gameend` event when the last pick snaps,
|
|
19
|
+
* and listens for `connext:gamereplay`.
|
|
20
|
+
*
|
|
21
|
+
* Accessibility: the dial is a real slider, so arrow keys, Home and End
|
|
22
|
+
* work without any code of ours, and the feedback after every turn is
|
|
23
|
+
* a sentence rather than a bar. A player who cannot see the dial can
|
|
24
|
+
* pick every lock in the game from the feedback alone, which is the
|
|
25
|
+
* property the engine tests pin.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
29
|
+
import {translate} from '@docusaurus/Translate';
|
|
30
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
31
|
+
import {createGame, setPosition, turn, summarise, POSITIONS} from './engine';
|
|
32
|
+
import styles from './LockPick.module.css';
|
|
33
|
+
|
|
34
|
+
const GAME_ID = 'lock-pick';
|
|
35
|
+
|
|
36
|
+
/* What the last turn felt like, in words. The thresholds match the
|
|
37
|
+
engine's `give`, so the sentence and the strain always agree. */
|
|
38
|
+
function feelCopy(value) {
|
|
39
|
+
if (value >= 0.97) {
|
|
40
|
+
return translate({id: 'preset.lockPick.feel.almost', message: 'It almost turns. You are within a hair of it.', description: 'Lock-pick feedback when the pick is very close to the sweet spot'});
|
|
41
|
+
}
|
|
42
|
+
if (value >= 0.85) {
|
|
43
|
+
return translate({id: 'preset.lockPick.feel.close', message: 'The cylinder turns a good way, then stops.', description: 'Lock-pick feedback when the pick is close'});
|
|
44
|
+
}
|
|
45
|
+
if (value >= 0.6) {
|
|
46
|
+
return translate({id: 'preset.lockPick.feel.some', message: 'It gives a little.', description: 'Lock-pick feedback when the pick is somewhere near'});
|
|
47
|
+
}
|
|
48
|
+
if (value >= 0.3) {
|
|
49
|
+
return translate({id: 'preset.lockPick.feel.barely', message: 'Barely anything. You are a long way off.', description: 'Lock-pick feedback when the pick is far from the sweet spot'});
|
|
50
|
+
}
|
|
51
|
+
return translate({id: 'preset.lockPick.feel.nothing', message: 'Nothing. The cylinder does not move at all.', description: 'Lock-pick feedback when the pick is nowhere near the sweet spot'});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function LockPick({className}) {
|
|
55
|
+
const {i18n} = useDocusaurusContext();
|
|
56
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
57
|
+
|
|
58
|
+
const [game, setGame] = useState(null);
|
|
59
|
+
const gameRef = useRef(null);
|
|
60
|
+
const endedRef = useRef(false);
|
|
61
|
+
|
|
62
|
+
const running = Boolean(game) && !game.over;
|
|
63
|
+
|
|
64
|
+
const begin = useCallback(() => {
|
|
65
|
+
endedRef.current = false;
|
|
66
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31)});
|
|
67
|
+
gameRef.current = fresh;
|
|
68
|
+
setGame(fresh);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!game || !game.over || endedRef.current) return;
|
|
73
|
+
endedRef.current = true;
|
|
74
|
+
if (typeof window === 'undefined') return;
|
|
75
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
76
|
+
detail: {
|
|
77
|
+
id: GAME_ID,
|
|
78
|
+
won: false,
|
|
79
|
+
score: game.score,
|
|
80
|
+
summary: summarise(game, locale),
|
|
81
|
+
title: translate({id: 'preset.lockPick.over.title', message: 'That was the last pick.', description: 'Headline on the game-over dialog after a lock-pick run'}),
|
|
82
|
+
subtitle: translate({id: 'preset.lockPick.over.subtitle', message: 'The lock is still shut, which is rather the point of a good one.', description: 'Subtitle on the game-over dialog after a lock-pick run'}),
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
}, [game, locale]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (typeof window === 'undefined') return undefined;
|
|
89
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
90
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
91
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
92
|
+
}, [begin]);
|
|
93
|
+
|
|
94
|
+
const moveTo = useCallback((value) => {
|
|
95
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
96
|
+
const next = setPosition(gameRef.current, value);
|
|
97
|
+
gameRef.current = next;
|
|
98
|
+
setGame(next);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const tryTurn = useCallback(() => {
|
|
102
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
103
|
+
const next = turn(gameRef.current);
|
|
104
|
+
gameRef.current = next;
|
|
105
|
+
setGame(next);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const position = game ? game.position : Math.floor(POSITIONS / 2);
|
|
109
|
+
const last = game ? game.last : null;
|
|
110
|
+
/* The cylinder shows what the last turn achieved, not what the
|
|
111
|
+
current position would achieve: showing the latter would hand the
|
|
112
|
+
player the answer by dragging the dial. */
|
|
113
|
+
const turned = last && (last.result === 'held' || last.result === 'snapped') ? last.give : 0;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<section className={[styles.lp, className].filter(Boolean).join(' ')} aria-labelledby="lock-pick-title">
|
|
117
|
+
<header className={styles.head}>
|
|
118
|
+
<div>
|
|
119
|
+
<p className={styles.eyebrow}>
|
|
120
|
+
{translate({id: 'preset.lockPick.eyebrow', message: 'Mini-game', description: 'Eyebrow above the lock-pick game on a product page'})}
|
|
121
|
+
</p>
|
|
122
|
+
<h3 className={styles.title} id="lock-pick-title">
|
|
123
|
+
{translate({id: 'preset.lockPick.title', message: 'Lock pick', description: 'Name of the Keepiq mini-game'})}
|
|
124
|
+
</h3>
|
|
125
|
+
<p className={styles.lede}>
|
|
126
|
+
{translate({id: 'preset.lockPick.lede', message: 'Set the pick, turn the cylinder, feel how far it gives. Every turn tells you how close you were. Three picks, and each lock is tighter than the last.', description: 'One-line explanation of the lock-pick rules'})}
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
130
|
+
<span className={styles.hudPill}>
|
|
131
|
+
{translate({id: 'preset.lockPick.hud.score', message: 'Score {score}', description: 'Score readout on the lock-pick HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
132
|
+
</span>
|
|
133
|
+
<span className={styles.hudPill}>
|
|
134
|
+
{translate({id: 'preset.lockPick.hud.picks', message: 'Picks {picks}', description: 'Remaining-picks readout on the lock-pick HUD'}, {picks: game ? game.picks : 3})}
|
|
135
|
+
</span>
|
|
136
|
+
<span className={styles.hudPill}>
|
|
137
|
+
{translate({id: 'preset.lockPick.hud.opened', message: 'Opened {opened}', description: 'Opened-locks readout on the lock-pick HUD'}, {opened: game ? game.opened : 0})}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
</header>
|
|
141
|
+
|
|
142
|
+
<div className={styles.lock}>
|
|
143
|
+
{/* The cylinder, turned as far as the last attempt managed. */}
|
|
144
|
+
<div
|
|
145
|
+
className={styles.cylinder}
|
|
146
|
+
role="img"
|
|
147
|
+
aria-label={translate(
|
|
148
|
+
{id: 'preset.lockPick.cylinder', message: 'The cylinder turned {percent} per cent on the last try', description: 'Accessible description of the lock cylinder. {percent} is how far it turned.'},
|
|
149
|
+
{percent: Math.round(turned * 100)},
|
|
150
|
+
)}>
|
|
151
|
+
<div className={styles.cylinderFill} style={{transform: `rotate(${-90 + turned * 80}deg)`}} />
|
|
152
|
+
<span className={styles.keyhole} aria-hidden="true" />
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className={styles.dial}>
|
|
156
|
+
<label className={styles.dialLabel} htmlFor="lock-pick-dial">
|
|
157
|
+
{translate({id: 'preset.lockPick.dialLabel', message: 'Where the pick sits', description: 'Label for the lock-pick dial slider'})}
|
|
158
|
+
</label>
|
|
159
|
+
<input
|
|
160
|
+
id="lock-pick-dial"
|
|
161
|
+
className={styles.slider}
|
|
162
|
+
type="range"
|
|
163
|
+
min={0}
|
|
164
|
+
max={POSITIONS - 1}
|
|
165
|
+
step={1}
|
|
166
|
+
value={position}
|
|
167
|
+
disabled={!running}
|
|
168
|
+
onChange={(e) => moveTo(Number(e.target.value))}
|
|
169
|
+
/>
|
|
170
|
+
<div className={styles.pickRow}>
|
|
171
|
+
<span className={styles.pickLabel}>
|
|
172
|
+
{translate({id: 'preset.lockPick.wear', message: 'This pick', description: 'Label for the lock-pick durability bar'})}
|
|
173
|
+
</span>
|
|
174
|
+
<span
|
|
175
|
+
className={styles.wear}
|
|
176
|
+
role="progressbar"
|
|
177
|
+
aria-valuemin={0}
|
|
178
|
+
aria-valuemax={100}
|
|
179
|
+
aria-valuenow={game ? game.durability : 100}>
|
|
180
|
+
<span
|
|
181
|
+
className={[styles.wearFill, game && game.durability <= 35 && styles.wearLow].filter(Boolean).join(' ')}
|
|
182
|
+
style={{width: `${game ? game.durability : 100}%`}}
|
|
183
|
+
/>
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<footer className={styles.foot}>
|
|
190
|
+
<button type="button" className={styles.turn} onClick={tryTurn} disabled={!running}>
|
|
191
|
+
{translate({id: 'preset.lockPick.turn', message: 'Turn the cylinder', description: 'Button that attempts to turn the lock'})}
|
|
192
|
+
</button>
|
|
193
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
194
|
+
{game
|
|
195
|
+
? translate({id: 'preset.lockPick.restart', message: 'New lock', description: 'Button that restarts the lock-pick game'})
|
|
196
|
+
: translate({id: 'preset.lockPick.start', message: 'Take a pick', description: 'Button that starts the lock-pick game'})}
|
|
197
|
+
</button>
|
|
198
|
+
<p className={styles.feedback} role="status" aria-live="polite">
|
|
199
|
+
{last && last.result === 'opened' && translate(
|
|
200
|
+
{id: 'preset.lockPick.feedback.opened', message: 'Open, in {attempts}. Worth {points}. The next one is tighter.', description: 'Feedback after opening a lock. {attempts} is how many turns it took, {points} what it scored.'},
|
|
201
|
+
{attempts: last.attempts, points: last.points},
|
|
202
|
+
)}
|
|
203
|
+
{last && last.result === 'snapped' && translate({id: 'preset.lockPick.feedback.snapped', message: 'The pick snapped. The lock is where you left it, so keep going from there.', description: 'Feedback after a pick breaks'})}
|
|
204
|
+
{last && last.result === 'held' && feelCopy(last.give)}
|
|
205
|
+
{!last && translate({id: 'preset.lockPick.hint', message: 'Move the pick with the slider or the arrow keys, then turn. Wild guesses cost the pick; near misses cost very little.', description: 'Hint under the lock-pick board before the first turn'})}
|
|
206
|
+
</p>
|
|
207
|
+
</footer>
|
|
208
|
+
</section>
|
|
209
|
+
);
|
|
210
|
+
}
|