@conduction/docusaurus-preset 3.42.0 → 3.44.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * <PipeFit /> styles.
3
+ *
4
+ * A line of connectors read left to right, each drawn as two openings
5
+ * and a barrel. Tokens only, one accent on a join that does not meet.
6
+ * Every state is also in the button's label, so the route is solvable
7
+ * without the picture.
8
+ */
9
+
10
+ .pf {
11
+ border: 1px solid var(--c-cobalt-100);
12
+ border-radius: var(--radius-lg);
13
+ background: white;
14
+ padding: clamp(16px, 3vw, 28px);
15
+ font-family: var(--conduction-typography-font-family-body);
16
+ }
17
+
18
+ .head {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ gap: 16px;
22
+ align-items: flex-start;
23
+ justify-content: space-between;
24
+ margin-bottom: 16px;
25
+ }
26
+
27
+ .eyebrow {
28
+ margin: 0 0 4px;
29
+ font-family: var(--conduction-typography-font-family-code);
30
+ font-size: 11px;
31
+ letter-spacing: 0.12em;
32
+ text-transform: uppercase;
33
+ color: var(--c-orange-knvb);
34
+ }
35
+
36
+ .title {
37
+ margin: 0 0 6px;
38
+ font-size: 20px;
39
+ font-weight: 700;
40
+ color: var(--c-cobalt-900);
41
+ }
42
+
43
+ .lede {
44
+ margin: 0;
45
+ max-width: 62ch;
46
+ font-size: 14px;
47
+ line-height: 1.5;
48
+ color: var(--c-cobalt-700);
49
+ }
50
+
51
+ .hud {
52
+ display: flex;
53
+ gap: 8px;
54
+ flex-wrap: wrap;
55
+ font-family: var(--conduction-typography-font-family-code);
56
+ font-size: 12px;
57
+ font-variant-numeric: tabular-nums;
58
+ }
59
+ .hudPill {
60
+ padding: 6px 10px;
61
+ border-radius: var(--radius-pill);
62
+ background: var(--c-cobalt-50);
63
+ color: var(--c-cobalt-900);
64
+ white-space: nowrap;
65
+ }
66
+
67
+ .route {
68
+ display: flex;
69
+ align-items: stretch;
70
+ gap: 4px;
71
+ padding: 16px 12px;
72
+ border-radius: var(--radius-md);
73
+ background: var(--c-cobalt-50);
74
+ max-width: 720px;
75
+ overflow-x: auto;
76
+ }
77
+
78
+ .end {
79
+ position: relative;
80
+ flex-shrink: 0;
81
+ width: 78px;
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ padding: 6px;
86
+ border-radius: var(--radius-md);
87
+ background: var(--c-cobalt-900);
88
+ color: white;
89
+ font-family: var(--conduction-typography-font-family-code);
90
+ font-size: 10px;
91
+ text-align: center;
92
+ }
93
+
94
+ /* The three heights an opening can sit at. The end blocks mark theirs
95
+ with a notch, the connectors with their mouths. */
96
+ .end::after {
97
+ content: '';
98
+ position: absolute;
99
+ width: 8px;
100
+ height: 10px;
101
+ background: var(--c-mint-500);
102
+ border-radius: 2px;
103
+ }
104
+ .end.port-0::after { top: 8px; right: -4px; }
105
+ .end.port-1::after { top: 50%; margin-top: -5px; right: -4px; }
106
+ .end.port-2::after { bottom: 8px; right: -4px; }
107
+ .end:last-child::after { right: auto; left: -4px; }
108
+
109
+ .piece {
110
+ position: relative;
111
+ flex: 1;
112
+ min-width: 68px;
113
+ height: 84px;
114
+ border: 1px solid var(--c-cobalt-200);
115
+ border-radius: var(--radius-md);
116
+ background: white;
117
+ cursor: pointer;
118
+ padding: 0;
119
+ transition: border-color 120ms ease, background 120ms ease;
120
+ }
121
+ .piece:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
122
+ .piece:disabled { cursor: default; opacity: 0.7; }
123
+ .piece:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
124
+
125
+ .joined { border-color: var(--c-mint-500); }
126
+ .gap { border-color: var(--c-orange-knvb); background: var(--c-cobalt-50); }
127
+
128
+ .mouth {
129
+ position: absolute;
130
+ width: 10px;
131
+ height: 12px;
132
+ border-radius: 2px;
133
+ background: var(--c-blue-cobalt);
134
+ }
135
+ .mouth:first-of-type { left: -1px; }
136
+ .mouth:last-of-type { right: -1px; }
137
+ .mouth.port-0 { top: 10px; }
138
+ .mouth.port-1 { top: 50%; margin-top: -6px; }
139
+ .mouth.port-2 { bottom: 10px; }
140
+
141
+ .barrel {
142
+ position: absolute;
143
+ left: 12px;
144
+ right: 12px;
145
+ top: 50%;
146
+ height: 4px;
147
+ margin-top: -2px;
148
+ border-radius: 2px;
149
+ background: var(--c-cobalt-200);
150
+ }
151
+
152
+ .clock {
153
+ height: 6px;
154
+ border-radius: var(--radius-pill);
155
+ background: var(--c-cobalt-200);
156
+ overflow: hidden;
157
+ margin-top: 12px;
158
+ max-width: 720px;
159
+ }
160
+ .clockFill {
161
+ height: 100%;
162
+ background: var(--c-mint-500);
163
+ transition: width 100ms linear;
164
+ }
165
+ .clockLow { background: var(--c-orange-knvb); }
166
+
167
+ .idle {
168
+ margin: 0;
169
+ font-size: 14px;
170
+ color: var(--c-cobalt-400);
171
+ }
172
+
173
+ .foot {
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ align-items: center;
177
+ gap: 10px;
178
+ margin-top: 14px;
179
+ }
180
+
181
+ .start {
182
+ background: var(--c-blue-cobalt);
183
+ color: white;
184
+ border: 1px solid var(--c-blue-cobalt);
185
+ padding: 10px 18px;
186
+ border-radius: var(--radius-md);
187
+ font-family: inherit;
188
+ font-weight: 500;
189
+ font-size: 14px;
190
+ cursor: pointer;
191
+ transition: background 120ms ease;
192
+ }
193
+ .start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
194
+ .start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
195
+
196
+ .hint {
197
+ margin: 0;
198
+ flex-basis: 100%;
199
+ font-size: 12px;
200
+ color: var(--c-cobalt-400);
201
+ max-width: 60ch;
202
+ min-height: 1.4em;
203
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * engine.test.js — the pipe-fit rules.
3
+ *
4
+ * The property everything rests on: every route dealt can actually be
5
+ * finished. The route is built from a working line and then scrambled,
6
+ * so a solution exists by construction, and the test proves it by
7
+ * finding one. An unsolvable puzzle on a clock reads as a broken game,
8
+ * and the player has no way to tell the difference.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const test = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+
16
+ const {
17
+ createGame, turn, step, connected, openings, clockMs, remaining, summarise,
18
+ PORTS, DEFAULTS,
19
+ } = require('../engine.js');
20
+
21
+ /**
22
+ * Solve a route by walking it: each piece has exactly one turn that
23
+ * meets what is carried in, so the line settles in one pass.
24
+ */
25
+ function solve(state, now = 0) {
26
+ let s = state;
27
+ const finished = s.routes;
28
+ for (let i = 0; i < s.route.pieces.length; i++) {
29
+ /* Whatever the piece before it hands over. */
30
+ let carry = s.route.source;
31
+ for (let k = 0; k < i; k++) carry = openings(s.route.pieces[k]).right;
32
+
33
+ for (let t = 0; t < PORTS; t++) {
34
+ if (openings(s.route.pieces[i]).left === carry) break;
35
+ s = turn(s, i, now);
36
+ /* The last turn of a route replaces it with the next one, so
37
+ stop here: carrying on would solve a route nobody asked for,
38
+ which is how this walk first read as a failure. */
39
+ if (s.over || s.routes > finished) return s;
40
+ }
41
+ }
42
+ return s;
43
+ }
44
+
45
+ test('a route is dealt, out of true, with a source and a target', () => {
46
+ const s = createGame({seed: 1, now: 0});
47
+ assert.equal(s.route.pieces.length, DEFAULTS.lengthStart);
48
+ assert.ok(s.route.source >= 0 && s.route.source < PORTS);
49
+ assert.ok(s.route.target >= 0 && s.route.target < PORTS);
50
+ assert.equal(connected(s.route), false, 'the route was dealt already finished');
51
+ });
52
+
53
+ test('every route dealt can be finished', () => {
54
+ /* Thirty seeds, several routes each: an unsolvable deal shows up as
55
+ one route in dozens, not as every route. */
56
+ for (let seed = 1; seed <= 30; seed++) {
57
+ let s = createGame({seed, now: 0});
58
+ for (let round = 0; round < 4; round++) {
59
+ const before = s.routes;
60
+ s = solve(s, round * 10);
61
+ assert.equal(s.routes, before + 1, `seed ${seed}: a route could not be finished`);
62
+ }
63
+ }
64
+ });
65
+
66
+ test('turning moves both openings together, which is the whole puzzle', () => {
67
+ const piece = {shape: {left: 0, right: 1}, turn: 0};
68
+ const before = openings(piece);
69
+ const after = openings({...piece, turn: 1});
70
+ assert.equal(after.left, (before.left + 1) % PORTS);
71
+ assert.equal(after.right, (before.right + 1) % PORTS);
72
+ });
73
+
74
+ test('a line is only connected when every join meets and it reaches the target', () => {
75
+ const route = {
76
+ source: 0,
77
+ target: 2,
78
+ pieces: [{shape: {left: 0, right: 1}, turn: 0}, {shape: {left: 1, right: 2}, turn: 0}],
79
+ };
80
+ assert.equal(connected(route), true);
81
+
82
+ const broken = {...route, pieces: [{...route.pieces[0], turn: 1}, route.pieces[1]]};
83
+ assert.equal(connected(broken), false, 'a broken join read as connected');
84
+
85
+ const wrongTarget = {...route, target: 0};
86
+ assert.equal(connected(wrongTarget), false, 'a line that ends nowhere read as connected');
87
+ });
88
+
89
+ test('finishing a route pays a bonus and deals the next one', () => {
90
+ let s = createGame({seed: 3, now: 0});
91
+ s = solve(s, 0);
92
+ assert.equal(s.routes, 1);
93
+ assert.ok(s.score >= DEFAULTS.pointsPerRoute);
94
+ assert.ok(s.route, 'no next route arrived');
95
+ assert.equal(connected(s.route), false);
96
+ assert.equal(s.lives, DEFAULTS.lives, 'finishing a route cost a life');
97
+ });
98
+
99
+ test('routes get longer, but never longer than a person will finish', () => {
100
+ let s = createGame({seed: 4, now: 0});
101
+ const lengths = [s.route.pieces.length];
102
+ for (let i = 0; i < 12 && !s.over; i++) {
103
+ s = solve(s, i * 10);
104
+ lengths.push(s.route.pieces.length);
105
+ }
106
+ assert.ok(Math.max(...lengths) > DEFAULTS.lengthStart, 'the routes never grew');
107
+ assert.ok(Math.max(...lengths) <= DEFAULTS.lengthMax, 'a route grew past its own ceiling');
108
+ });
109
+
110
+ test('turning is never punished, only the route that is never finished', () => {
111
+ let s = createGame({seed: 5, now: 0});
112
+ for (let i = 0; i < 20; i++) s = turn(s, i % s.route.pieces.length, 10);
113
+ assert.equal(s.lives, DEFAULTS.lives, 'fiddling with the connectors cost a life');
114
+ assert.ok(s.turns >= 20);
115
+ });
116
+
117
+ test('a connector that does not exist is ignored', () => {
118
+ const s = createGame({seed: 6, now: 0});
119
+ assert.equal(turn(s, 99, 0), s);
120
+ assert.equal(turn(s, -1, 0), s);
121
+ });
122
+
123
+ test('the payload arriving on an unfinished route costs a life', () => {
124
+ let s = createGame({seed: 7, now: 0});
125
+ const arrives = s.route.expiresAt;
126
+ s = step(s, arrives + 1);
127
+ assert.equal(s.lives, DEFAULTS.lives - 1);
128
+ assert.equal(s.last.result, 'spilled');
129
+ assert.ok(s.route, 'no fresh route after the spill');
130
+ });
131
+
132
+ test('three spills end it, and nothing turns afterwards', () => {
133
+ let s = createGame({seed: 8, now: 0});
134
+ let t = 0;
135
+ for (let i = 0; i < DEFAULTS.lives; i++) {
136
+ t = s.route.expiresAt + 1;
137
+ s = step(s, t);
138
+ }
139
+ assert.equal(s.over, true);
140
+ assert.equal(s.lives, 0);
141
+ assert.equal(turn(s, 0, t + 10).turns, s.turns);
142
+ assert.equal(step(s, t + 10000).lives, 0);
143
+ });
144
+
145
+ test('nothing spills before the payload is due', () => {
146
+ const s = createGame({seed: 9, now: 0});
147
+ assert.equal(step(s, s.route.expiresAt - 1).lives, DEFAULTS.lives);
148
+ });
149
+
150
+ test('the clock tightens with the score, down to a workable floor', () => {
151
+ const fresh = createGame({seed: 10, now: 0});
152
+ assert.equal(clockMs(fresh), DEFAULTS.clockStartMs);
153
+ assert.ok(clockMs({...fresh, score: 80}) < DEFAULTS.clockStartMs);
154
+ assert.equal(clockMs({...fresh, score: 9000}), DEFAULTS.clockFloorMs);
155
+ });
156
+
157
+ test('the countdown runs full to empty and no further', () => {
158
+ const s = createGame({seed: 11, now: 0});
159
+ assert.equal(remaining(s, 0), 1);
160
+ assert.equal(remaining(s, DEFAULTS.clockStartMs * 3), 0);
161
+ });
162
+
163
+ test('the same seed deals the same routes, a different one does not', () => {
164
+ const shape = (seed) => {
165
+ const s = createGame({seed, now: 0});
166
+ return `${s.route.source}>${s.route.pieces.map((p) => `${p.shape.left}${p.shape.right}:${p.turn}`).join(',')}>${s.route.target}`;
167
+ };
168
+ assert.equal(shape(21), shape(21));
169
+ assert.notEqual(shape(21), shape(22));
170
+ });
171
+
172
+ test('the summary reads as a sentence in both locales', () => {
173
+ const s = {routes: 5, turns: 34};
174
+ assert.match(summarise(s, 'en'), /5 routes connected · 34 turns/);
175
+ assert.match(summarise(s, 'nl'), /5 koppelingen gelegd · 34 keer gedraaid/);
176
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Pipe fit — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Integriq's game. A record is leaving one system for another and the
5
+ * route between them is half built. Turn each connector until the line
6
+ * runs end to end, before the payload arrives and finds a gap.
7
+ *
8
+ * A route is a row of connectors, each with an opening on its left and
9
+ * its right. Turning one moves both openings together, so fixing the
10
+ * join on one side can break the join on the other: that is the whole
11
+ * puzzle, and it is exactly what integrating two systems feels like.
12
+ *
13
+ * Time and randomness are injected; the component owns the clock.
14
+ */
15
+
16
+ /* The three heights a connector can open at. A join works when the
17
+ right-hand opening of one meets the left-hand opening of the next. */
18
+ export const PORTS = 3;
19
+
20
+ export const DEFAULTS = {
21
+ lives: 3,
22
+ lengthStart: 3,
23
+ lengthMax: 6,
24
+ /* A route grows every few clears rather than every one: a puzzle
25
+ that gets longer each time outruns the clock before it gets
26
+ interesting. */
27
+ growEvery: 2,
28
+ clockStartMs: 26000,
29
+ clockFloorMs: 11000,
30
+ rampPerPoint: 55,
31
+ pointsPerTurn: 1,
32
+ pointsPerRoute: 20,
33
+ };
34
+
35
+ function mulberry32(seed) {
36
+ let a = seed >>> 0;
37
+ return function random() {
38
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
39
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
40
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
41
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
42
+ };
43
+ }
44
+
45
+ export function clockMs(state) {
46
+ return Math.max(state.cfg.clockFloorMs, state.cfg.clockStartMs - state.score * state.cfg.rampPerPoint);
47
+ }
48
+
49
+ /**
50
+ * A connector is `{shape, turn}`. `shape` is the pair of openings as
51
+ * built, `turn` is how many times it has been turned; the openings
52
+ * that count are the shape rotated by the turn.
53
+ */
54
+ export function openings(piece) {
55
+ return {
56
+ left: (piece.shape.left + piece.turn) % PORTS,
57
+ right: (piece.shape.right + piece.turn) % PORTS,
58
+ };
59
+ }
60
+
61
+ /** Does the whole route line up, source to consumer? */
62
+ export function connected(route) {
63
+ if (!route || !route.pieces.length) return false;
64
+ let carry = route.source;
65
+ for (const piece of route.pieces) {
66
+ const {left, right} = openings(piece);
67
+ if (left !== carry) return false;
68
+ carry = right;
69
+ }
70
+ return carry === route.target;
71
+ }
72
+
73
+ /**
74
+ * Build a route that is solvable, then turn the pieces out of true.
75
+ *
76
+ * Built from a working line rather than at random: a route assembled
77
+ * by chance can be unsolvable, and an unsolvable puzzle on a clock
78
+ * reads to the player as a broken game.
79
+ */
80
+ function deal(state, now, length) {
81
+ const source = Math.floor(state.random() * PORTS);
82
+ let carry = source;
83
+ const pieces = [];
84
+
85
+ for (let i = 0; i < length; i++) {
86
+ const right = Math.floor(state.random() * PORTS);
87
+ /* The shape is stored as if unturned, so that turning it back is
88
+ always possible. */
89
+ pieces.push({shape: {left: carry, right}, turn: 0});
90
+ carry = right;
91
+ }
92
+
93
+ const route = {source, target: carry, pieces, startedAt: now, expiresAt: now + clockMs(state)};
94
+
95
+ /* Now scramble. Keep scrambling until it is actually out of true,
96
+ or a route could be dealt already solved. */
97
+ let scrambled = route;
98
+ for (let attempt = 0; attempt < 8; attempt++) {
99
+ scrambled = {
100
+ ...route,
101
+ pieces: route.pieces.map((p) => ({...p, turn: Math.floor(state.random() * PORTS)})),
102
+ };
103
+ if (!connected(scrambled)) break;
104
+ }
105
+ return {...state, route: scrambled};
106
+ }
107
+
108
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
109
+ const cfg = {...DEFAULTS, ...config};
110
+ const base = {
111
+ cfg,
112
+ random: mulberry32(seed),
113
+ route: null,
114
+ score: 0,
115
+ lives: cfg.lives,
116
+ routes: 0,
117
+ turns: 0,
118
+ last: null,
119
+ over: false,
120
+ };
121
+ return deal(base, now, cfg.lengthStart);
122
+ }
123
+
124
+ export function remaining(state, now) {
125
+ if (!state.route) return 0;
126
+ const total = state.route.expiresAt - state.route.startedAt;
127
+ if (total <= 0) return 0;
128
+ return Math.min(1, Math.max(0, (state.route.expiresAt - now) / total));
129
+ }
130
+
131
+ function nextLength(state) {
132
+ const grown = state.cfg.lengthStart + Math.floor((state.routes + 1) / state.cfg.growEvery);
133
+ return Math.min(state.cfg.lengthMax, grown);
134
+ }
135
+
136
+ /**
137
+ * Turn one connector.
138
+ *
139
+ * Completing the route scores and deals the next one. Turning is never
140
+ * punished: the mistake this game is about is the route that was never
141
+ * finished, not the fiddling on the way there.
142
+ */
143
+ export function turn(state, index, now) {
144
+ if (state.over || !state.route) return state;
145
+ if (!Number.isInteger(index) || index < 0 || index >= state.route.pieces.length) return state;
146
+
147
+ const pieces = state.route.pieces.map((p, i) => (i === index ? {...p, turn: (p.turn + 1) % PORTS} : p));
148
+ const route = {...state.route, pieces};
149
+ const turned = {...state, route, turns: state.turns + 1, score: state.score + state.cfg.pointsPerTurn};
150
+
151
+ if (!connected(route)) return {...turned, last: {result: 'turned', at: now}};
152
+
153
+ const cleared = {
154
+ ...turned,
155
+ score: turned.score + turned.cfg.pointsPerRoute,
156
+ routes: turned.routes + 1,
157
+ last: {result: 'connected', at: now},
158
+ };
159
+ return deal(cleared, now, nextLength(cleared));
160
+ }
161
+
162
+ /** The payload arrives; an unfinished route costs a life. */
163
+ export function step(state, now) {
164
+ if (state.over || !state.route) return state;
165
+ if (now < state.route.expiresAt) return state;
166
+
167
+ const lives = state.lives - 1;
168
+ const hit = {...state, lives, last: {result: 'spilled', at: now}, over: lives <= 0};
169
+ return hit.over ? {...hit, route: null} : deal(hit, now, state.route.pieces.length);
170
+ }
171
+
172
+ /** The line that goes on the game-over card and into the post. */
173
+ export function summarise(state, locale = 'en') {
174
+ const n = (v) => Number(v || 0).toLocaleString(locale);
175
+ return locale === 'nl'
176
+ ? `${n(state.routes)} koppelingen gelegd · ${n(state.turns)} keer gedraaid`
177
+ : `${n(state.routes)} routes connected · ${n(state.turns)} turns`;
178
+ }