@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,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
|
+
}
|