@conduction/docusaurus-preset 3.27.2 → 3.31.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/README.md +34 -0
- package/package.json +6 -5
- package/src/components/AiDisclosure/AiDisclosure.jsx +82 -0
- package/src/components/AiDisclosure/AiDisclosure.module.css +35 -0
- package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +127 -0
- package/src/components/AiDisclosure/__tests__/disclosure.test.js +218 -0
- package/src/components/AiDisclosure/disclosure.js +169 -0
- package/src/components/AppMock/AppMock.jsx +16 -0
- package/src/components/AppMock/variants/AppVersionsMock.jsx +83 -0
- package/src/components/AppMock/variants/DoriathMock.jsx +79 -0
- package/src/components/AppMock/variants/HermiqMock.jsx +71 -0
- package/src/components/AppMock/variants/HrmqMock.jsx +77 -0
- package/src/components/AppMock/variants/PlanixMock.jsx +80 -0
- package/src/components/AppMock/variants/PortaliqMock.jsx +63 -0
- package/src/components/AppMock/variants/ScholiqMock.jsx +74 -0
- package/src/components/AppMock/variants/ShillinqMock.jsx +90 -0
- package/src/components/CookieCli/CookieCli.jsx +197 -24
- package/src/components/CookieCli/CookieCli.module.css +139 -1
- package/src/components/CookieCli/shell.js +161 -0
- package/src/components/CookieCli/spaceInvaders.js +359 -0
- package/src/components/CookieCli/spaceInvaders.test.js +112 -0
- package/src/components/index.js +7 -0
- package/src/data/app-glyphs.json +4 -0
- package/src/theme/BlogPostItem/Content/index.jsx +43 -0
- package/src/theme/DocItem/Content/index.jsx +15 -0
- package/static/img/ai-disclosure/PROVENANCE.md +87 -0
- package/static/img/ai-disclosure/ai-black-transparent.svg +20 -0
- package/static/img/ai-disclosure/ai-black.svg +19 -0
- package/static/img/ai-disclosure/ai-generated-black-transparent.svg +31 -0
- package/static/img/ai-disclosure/ai-generated-black.svg +30 -0
- package/static/img/ai-disclosure/ai-generated-white-transparent.svg +32 -0
- package/static/img/ai-disclosure/ai-generated-white.svg +31 -0
- package/static/img/ai-disclosure/ai-modified-black-transparent.svg +30 -0
- package/static/img/ai-disclosure/ai-modified-black.svg +29 -0
- package/static/img/ai-disclosure/ai-modified-white-transparent.svg +31 -0
- package/static/img/ai-disclosure/ai-modified-white.svg +30 -0
- package/static/img/ai-disclosure/ai-white-transparent.svg +21 -0
- package/static/img/ai-disclosure/ai-white.svg +20 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiny shell behind the cookie CLI prompt.
|
|
3
|
+
*
|
|
4
|
+
* The panel has always *looked* like a terminal. This makes it behave like
|
|
5
|
+
* one for anyone who tries: `ls` lists a small fake filesystem, `cat` reads
|
|
6
|
+
* the files, and one of them is executable. Finding `game.exe` is the point;
|
|
7
|
+
* everything else exists so that finding it feels earned rather than
|
|
8
|
+
* announced.
|
|
9
|
+
*
|
|
10
|
+
* Ported from the kit specimen at preview/cookie-cli.html.
|
|
11
|
+
*
|
|
12
|
+
* Pure: commands take state and return output plus an optional effect, so
|
|
13
|
+
* the React component decides what actually happens. That keeps consent
|
|
14
|
+
* behaviour out of a file about pretending to be bash.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const FS_FILES = [
|
|
18
|
+
{name: 'README.md', type: 'file', size: 421, exec: false},
|
|
19
|
+
{name: 'cookies.toml', type: 'file', size: 847, exec: false},
|
|
20
|
+
{name: 'game.exe', type: 'file', size: 6320, exec: true},
|
|
21
|
+
{name: 'index.html', type: 'file', size: 312, exec: false},
|
|
22
|
+
{name: 'preferences', type: 'dir', size: 64, exec: false},
|
|
23
|
+
{name: 'privacy.md', type: 'file', size: 198, exec: false},
|
|
24
|
+
{name: 'snake.txt', type: 'file', size: 142, exec: false},
|
|
25
|
+
{name: '.config', type: 'dir', size: 64, exec: false, hidden: true},
|
|
26
|
+
{name: '.bashrc', type: 'file', size: 88, exec: false, hidden: true},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const FILE_CONTENTS = {
|
|
30
|
+
'README.md': `# conduction.nl
|
|
31
|
+
|
|
32
|
+
This is the company website. We use cookies for:
|
|
33
|
+
- essential functions (always on)
|
|
34
|
+
- aggregate analytics
|
|
35
|
+
- your preferences
|
|
36
|
+
|
|
37
|
+
Pick what you're OK with above. Or hit a number key.
|
|
38
|
+
|
|
39
|
+
PS: there's a game.exe in here somewhere if you're bored.
|
|
40
|
+
Try \`ls\` to find it.`,
|
|
41
|
+
|
|
42
|
+
'cookies.toml': `# cookies.toml — generated by cookie-consent
|
|
43
|
+
# https://conduction.nl/privacy
|
|
44
|
+
|
|
45
|
+
[essential]
|
|
46
|
+
required = true
|
|
47
|
+
description = "Session, CSRF token, language preference."
|
|
48
|
+
|
|
49
|
+
[analytics]
|
|
50
|
+
required = false
|
|
51
|
+
description = "Aggregate, anonymous."
|
|
52
|
+
|
|
53
|
+
[preferences]
|
|
54
|
+
required = false
|
|
55
|
+
description = "Theme, language, collapsed docs sections."`,
|
|
56
|
+
|
|
57
|
+
'privacy.md': `# privacy
|
|
58
|
+
|
|
59
|
+
We do not sell, share, or rent your data.
|
|
60
|
+
The full policy lives at /privacy.`,
|
|
61
|
+
|
|
62
|
+
'snake.txt': `snake.exe was removed in v2.
|
|
63
|
+
Management felt one game was enough.
|
|
64
|
+
Management was wrong, but game.exe survived.`,
|
|
65
|
+
|
|
66
|
+
'.bashrc': `# nothing interesting here. we checked.
|
|
67
|
+
alias ll='ls -la'`,
|
|
68
|
+
|
|
69
|
+
'index.html': `<!doctype html>
|
|
70
|
+
<!-- you are, in a sense, already here -->`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const HELP = `Available commands:
|
|
74
|
+
ls [-a] list files
|
|
75
|
+
cat <file> print a file
|
|
76
|
+
pwd print working directory
|
|
77
|
+
whoami you tell us
|
|
78
|
+
help this
|
|
79
|
+
clear clear the scrollback
|
|
80
|
+
./game.exe ...try it
|
|
81
|
+
|
|
82
|
+
Number keys toggle cookie options. [A] accept all, [R] reject non-essential.`;
|
|
83
|
+
|
|
84
|
+
function pad(s, n) {
|
|
85
|
+
const str = String(s);
|
|
86
|
+
return str + ' '.repeat(Math.max(0, n - str.length));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run one command line.
|
|
91
|
+
*
|
|
92
|
+
* @returns {{lines: Array<{text: string, tone?: string}>, effect?: string, clear?: boolean}}
|
|
93
|
+
* `effect` is 'game' (launch) or 'exit'; the caller performs it.
|
|
94
|
+
*/
|
|
95
|
+
export function runCommand(raw) {
|
|
96
|
+
const input = raw.trim();
|
|
97
|
+
if (!input) return {lines: []};
|
|
98
|
+
|
|
99
|
+
const [cmd, ...args] = input.split(/\s+/);
|
|
100
|
+
const arg = args.filter((a) => !a.startsWith('-')).join(' ');
|
|
101
|
+
const flags = args.filter((a) => a.startsWith('-')).join('');
|
|
102
|
+
|
|
103
|
+
switch (cmd) {
|
|
104
|
+
case 'ls': {
|
|
105
|
+
const showAll = flags.includes('a');
|
|
106
|
+
const files = FS_FILES.filter((f) => showAll || !f.hidden);
|
|
107
|
+
return {
|
|
108
|
+
lines: files.map((f) => ({
|
|
109
|
+
text: `${pad(f.type === 'dir' ? 'drwxr-xr-x' : f.exec ? '-rwxr-xr-x' : '-rw-r--r--', 12)}${pad(f.size, 7)}${f.name}${f.type === 'dir' ? '/' : ''}`,
|
|
110
|
+
tone: f.exec ? 'ok' : f.type === 'dir' ? 'dir' : undefined,
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'cat': {
|
|
116
|
+
if (!arg) return {lines: [{text: 'cat: missing operand', tone: 'warn'}]};
|
|
117
|
+
const key = arg.replace(/^\.\//, '');
|
|
118
|
+
if (key === 'game.exe') {
|
|
119
|
+
return {lines: [{text: 'cat: game.exe: binary file. try ./game.exe instead.', tone: 'warn'}]};
|
|
120
|
+
}
|
|
121
|
+
const body = FILE_CONTENTS[key];
|
|
122
|
+
if (body === undefined) {
|
|
123
|
+
const known = FS_FILES.some((f) => f.name === key);
|
|
124
|
+
return {lines: [{text: known ? `cat: ${key}: Is a directory` : `cat: ${key}: No such file or directory`, tone: 'warn'}]};
|
|
125
|
+
}
|
|
126
|
+
return {lines: body.split('\n').map((text) => ({text}))};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'pwd':
|
|
130
|
+
return {lines: [{text: '/home/you'}]};
|
|
131
|
+
|
|
132
|
+
case 'whoami':
|
|
133
|
+
return {lines: [{text: 'you, presumably. we did not set a tracking cookie to find out.', tone: 'cmt'}]};
|
|
134
|
+
|
|
135
|
+
case 'help':
|
|
136
|
+
case '--help':
|
|
137
|
+
case 'man':
|
|
138
|
+
return {lines: HELP.split('\n').map((text) => ({text}))};
|
|
139
|
+
|
|
140
|
+
case 'clear':
|
|
141
|
+
return {lines: [], clear: true};
|
|
142
|
+
|
|
143
|
+
case 'exit':
|
|
144
|
+
case 'quit':
|
|
145
|
+
return {lines: [{text: '# there is no exit. pick your cookies.', tone: 'cmt'}], effect: 'exit'};
|
|
146
|
+
|
|
147
|
+
case 'game.exe':
|
|
148
|
+
case './game.exe':
|
|
149
|
+
case 'game':
|
|
150
|
+
return {lines: [], effect: 'game'};
|
|
151
|
+
|
|
152
|
+
case 'sudo':
|
|
153
|
+
return {lines: [{text: 'you are not in the sudoers file. this incident will not be logged, we do not log.', tone: 'warn'}]};
|
|
154
|
+
|
|
155
|
+
case 'rm':
|
|
156
|
+
return {lines: [{text: 'rm: permission denied. the cookies stay until you choose.', tone: 'warn'}]};
|
|
157
|
+
|
|
158
|
+
default:
|
|
159
|
+
return {lines: [{text: `${cmd}: command not found. try \`help\`.`, tone: 'warn'}]};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conduction Space Invaders — the game behind `game.exe` in the cookie CLI.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the kit specimen at preview/cookie-cli.html, where this had
|
|
5
|
+
* lived since the component was designed. The React <CookieCli /> shipped
|
|
6
|
+
* without it for a while, which meant GameModal advertised a game
|
|
7
|
+
* ("Hex-vaders · cookie CLI") that no site actually had.
|
|
8
|
+
*
|
|
9
|
+
* Kept as a plain class rather than React state on purpose. It ticks every
|
|
10
|
+
* 90ms and repaints a 48x14 character grid, which is 672 cells per frame,
|
|
11
|
+
* eleven times a second. Reconciling that through React would do a great
|
|
12
|
+
* deal of work to arrive at the same string. The class builds the frame as
|
|
13
|
+
* HTML and hands it to the caller, which writes it to a ref.
|
|
14
|
+
*
|
|
15
|
+
* Cobalt UFOs, KNVB-orange lasers, per the kit.
|
|
16
|
+
*
|
|
17
|
+
* The engine owns no DOM and reads no globals. It reports frames through
|
|
18
|
+
* `onFrame` and asks to be dismissed through `onExit`, so the React
|
|
19
|
+
* component stays the only thing that touches the document.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const COLS = 48;
|
|
23
|
+
const ROWS = 14;
|
|
24
|
+
|
|
25
|
+
/* The event the shared GameModal listens for. Named `connext:` because the
|
|
26
|
+
modal has used that prefix since the ConNext era; it is a brand-internal
|
|
27
|
+
identifier kept for compatibility, not a live brand reference. */
|
|
28
|
+
const GAME_END_EVENT = 'connext:gameend';
|
|
29
|
+
const GAME_REPLAY_EVENT = 'connext:gamereplay';
|
|
30
|
+
const GAME_ID = 'invaders';
|
|
31
|
+
|
|
32
|
+
function escapeHtml(s) {
|
|
33
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
34
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
35
|
+
}[c]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default class SpaceInvaders {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {function} opts.onFrame - called with {screen, hud, footer} HTML each repaint
|
|
42
|
+
* @param {function} opts.onExit - called when the player quits (ESC / Q)
|
|
43
|
+
*/
|
|
44
|
+
constructor({onFrame, onExit} = {}) {
|
|
45
|
+
this.onFrame = typeof onFrame === 'function' ? onFrame : () => {};
|
|
46
|
+
this.onExit = typeof onExit === 'function' ? onExit : () => {};
|
|
47
|
+
|
|
48
|
+
this.score = 0;
|
|
49
|
+
this.wave = 1;
|
|
50
|
+
this.hits = 0; // bombs that landed on you; costs nothing but pride
|
|
51
|
+
this.cleared = 0; // waves shot down
|
|
52
|
+
this.landed = 0; // waves that reached the floor
|
|
53
|
+
this.hitFlash = 0; // ticks left of the impact glyph
|
|
54
|
+
this.paused = false;
|
|
55
|
+
this.player = {x: Math.floor(COLS / 2) - 1};
|
|
56
|
+
this.lasers = [];
|
|
57
|
+
this.bombs = [];
|
|
58
|
+
this.ufos = [];
|
|
59
|
+
this.ufoDir = 1;
|
|
60
|
+
this.ufoSpeed = 9;
|
|
61
|
+
this.tickCount = 0;
|
|
62
|
+
this.graceTicks = 18; // ~1.6s before bombs start dropping
|
|
63
|
+
|
|
64
|
+
this.onReplay = (e) => {
|
|
65
|
+
if (e.detail && e.detail.id === GAME_ID) this.restart();
|
|
66
|
+
};
|
|
67
|
+
if (typeof window !== 'undefined') {
|
|
68
|
+
window.addEventListener(GAME_REPLAY_EVENT, this.onReplay);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.spawnUfos();
|
|
72
|
+
this.interval = setInterval(() => this.tick(), 90);
|
|
73
|
+
this.render();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
spawnUfos() {
|
|
77
|
+
this.ufos = [];
|
|
78
|
+
const rows = 3, cols = 6;
|
|
79
|
+
const spacing = 7;
|
|
80
|
+
const xStart = Math.floor((COLS - (cols - 1) * spacing - 3) / 2);
|
|
81
|
+
for (let r = 0; r < rows; r++) {
|
|
82
|
+
for (let c = 0; c < cols; c++) {
|
|
83
|
+
this.ufos.push({x: xStart + c * spacing, y: 1 + r * 2, alive: true, exploding: 0, row: r});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onKey(e) {
|
|
89
|
+
/* The old game-over branch is gone with game over itself. [R] used to
|
|
90
|
+
live only in that branch, which would have left it dead: reachable
|
|
91
|
+
by a key hint that never appeared, on a screen that never showed.
|
|
92
|
+
It now restarts at any time. */
|
|
93
|
+
switch (e.key) {
|
|
94
|
+
case 'r': case 'R':
|
|
95
|
+
this.restart(); e.preventDefault(); return;
|
|
96
|
+
default: break;
|
|
97
|
+
}
|
|
98
|
+
switch (e.key) {
|
|
99
|
+
case 'ArrowLeft': case 'a': case 'A':
|
|
100
|
+
this.player.x = Math.max(0, this.player.x - 2);
|
|
101
|
+
e.preventDefault(); break;
|
|
102
|
+
case 'ArrowRight': case 'd': case 'D':
|
|
103
|
+
this.player.x = Math.min(COLS - 3, this.player.x + 2);
|
|
104
|
+
e.preventDefault(); break;
|
|
105
|
+
case ' ': case 'ArrowUp': case 'w': case 'W':
|
|
106
|
+
this.fire(); e.preventDefault(); break;
|
|
107
|
+
case 'Escape': case 'q': case 'Q':
|
|
108
|
+
this.quit(); e.preventDefault(); break;
|
|
109
|
+
case 'p': case 'P':
|
|
110
|
+
this.paused = !this.paused; e.preventDefault(); this.render(); break;
|
|
111
|
+
default: break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fire() {
|
|
116
|
+
if (this.lasers.length >= 3) return;
|
|
117
|
+
this.lasers.push({x: this.player.x + 1, y: ROWS - 2});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
quit() {
|
|
121
|
+
this.stop();
|
|
122
|
+
this.onExit();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stop() {
|
|
126
|
+
clearInterval(this.interval);
|
|
127
|
+
this.interval = null;
|
|
128
|
+
if (typeof window !== 'undefined') {
|
|
129
|
+
window.removeEventListener(GAME_REPLAY_EVENT, this.onReplay);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
restart() {
|
|
134
|
+
this.score = 0;
|
|
135
|
+
this.wave = 1;
|
|
136
|
+
this.hits = 0;
|
|
137
|
+
this.cleared = 0;
|
|
138
|
+
this.landed = 0;
|
|
139
|
+
this.hitFlash = 0;
|
|
140
|
+
this._endEmitted = false;
|
|
141
|
+
this.player = {x: Math.floor(COLS / 2) - 1};
|
|
142
|
+
this.lasers = [];
|
|
143
|
+
this.bombs = [];
|
|
144
|
+
this.ufoSpeed = 9;
|
|
145
|
+
this.tickCount = 0;
|
|
146
|
+
this.graceTicks = 18;
|
|
147
|
+
this.spawnUfos();
|
|
148
|
+
this.render();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Advance to a fresh wave, whether the last one was shot down or landed.
|
|
152
|
+
Clears bombs so the new wave does not open under incoming fire, and
|
|
153
|
+
restores the grace period so there is a beat before it resumes. */
|
|
154
|
+
nextWave() {
|
|
155
|
+
this.wave += 1;
|
|
156
|
+
this.spawnUfos();
|
|
157
|
+
this.ufoSpeed = Math.max(3, 9 - this.wave);
|
|
158
|
+
this.bombs = [];
|
|
159
|
+
this.graceTicks = 12;
|
|
160
|
+
this.render();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
tick() {
|
|
164
|
+
if (this.paused) return;
|
|
165
|
+
this.tickCount++;
|
|
166
|
+
|
|
167
|
+
// lasers up every tick
|
|
168
|
+
this.lasers.forEach((l) => { l.y -= 1; });
|
|
169
|
+
this.lasers = this.lasers.filter((l) => l.y >= 0);
|
|
170
|
+
|
|
171
|
+
// bombs down every 2 ticks
|
|
172
|
+
if (this.tickCount % 2 === 0) {
|
|
173
|
+
this.bombs.forEach((b) => { b.y += 1; });
|
|
174
|
+
this.bombs = this.bombs.filter((b) => b.y < ROWS);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// UFOs march
|
|
178
|
+
if (this.tickCount % this.ufoSpeed === 0) {
|
|
179
|
+
const alive = this.ufos.filter((u) => u.alive);
|
|
180
|
+
if (alive.length) {
|
|
181
|
+
const minX = Math.min(...alive.map((u) => u.x));
|
|
182
|
+
const maxX = Math.max(...alive.map((u) => u.x));
|
|
183
|
+
if (this.ufoDir === 1 && maxX + 3 >= COLS) {
|
|
184
|
+
this.ufoDir = -1;
|
|
185
|
+
alive.forEach((u) => { u.y += 1; });
|
|
186
|
+
} else if (this.ufoDir === -1 && minX <= 0) {
|
|
187
|
+
this.ufoDir = 1;
|
|
188
|
+
alive.forEach((u) => { u.y += 1; });
|
|
189
|
+
} else {
|
|
190
|
+
alive.forEach((u) => { u.x += this.ufoDir; });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// random bombs, after a short grace period so the player can settle in
|
|
196
|
+
if (this.graceTicks > 0) {
|
|
197
|
+
this.graceTicks--;
|
|
198
|
+
} else {
|
|
199
|
+
const aliveUfos = this.ufos.filter((u) => u.alive);
|
|
200
|
+
const bombChance = 0.035 + (this.wave - 1) * 0.01;
|
|
201
|
+
if (aliveUfos.length && this.bombs.length < 4 && Math.random() < bombChance) {
|
|
202
|
+
const u = aliveUfos[Math.floor(Math.random() * aliveUfos.length)];
|
|
203
|
+
this.bombs.push({x: u.x + 1, y: u.y + 1});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// laser vs ufo
|
|
208
|
+
this.lasers.forEach((l) => {
|
|
209
|
+
this.ufos.forEach((u) => {
|
|
210
|
+
if (!u.alive) return;
|
|
211
|
+
if (l.y === u.y && l.x >= u.x && l.x <= u.x + 2) {
|
|
212
|
+
u.alive = false;
|
|
213
|
+
u.exploding = 2;
|
|
214
|
+
l.dead = true;
|
|
215
|
+
this.score += 10 * (3 - u.row);
|
|
216
|
+
const remaining = this.ufos.filter((uu) => uu.alive).length;
|
|
217
|
+
this.ufoSpeed = Math.max(2, 8 - Math.floor((18 - remaining) / 3));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
this.lasers = this.lasers.filter((l) => !l.dead);
|
|
222
|
+
|
|
223
|
+
this.ufos.forEach((u) => { if (u.exploding > 0) u.exploding -= 1; });
|
|
224
|
+
|
|
225
|
+
/* Bombs vs player. You cannot die here.
|
|
226
|
+
This is an easter egg in a cookie banner, not an arcade cabinet:
|
|
227
|
+
ending someone's game with GAME OVER while they were only trying to
|
|
228
|
+
set a cookie preference is a bad trade. A hit still lands and still
|
|
229
|
+
reads as a hit, it just costs a moment of shield flash rather than a
|
|
230
|
+
life. Bombs stay dangerous-looking and become harmless. */
|
|
231
|
+
this.bombs.forEach((b) => {
|
|
232
|
+
if (b.y === ROWS - 1 && b.x >= this.player.x && b.x <= this.player.x + 2) {
|
|
233
|
+
b.dead = true;
|
|
234
|
+
this.hitFlash = 4; // ticks the player renders as an impact
|
|
235
|
+
this.hits += 1;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
this.bombs = this.bombs.filter((b) => !b.dead);
|
|
239
|
+
if (this.hitFlash > 0) this.hitFlash -= 1;
|
|
240
|
+
|
|
241
|
+
/* UFOs reaching the bottom used to end the game outright, regardless of
|
|
242
|
+
lives. Now the wave simply lands and a fresh one spawns, so the run
|
|
243
|
+
continues. Without this the one unavoidable death would still be
|
|
244
|
+
reachable by standing still. */
|
|
245
|
+
if (this.ufos.some((u) => u.alive && u.y >= ROWS - 1)) {
|
|
246
|
+
this.landed += 1;
|
|
247
|
+
this.nextWave();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// wave clear
|
|
252
|
+
if (this.ufos.every((u) => !u.alive && u.exploding === 0)) {
|
|
253
|
+
this.cleared += 1;
|
|
254
|
+
this.nextWave();
|
|
255
|
+
|
|
256
|
+
/* Tell the shared GameModal the game was found, so it records
|
|
257
|
+
discovery and score in its cross-site progress cookie.
|
|
258
|
+
This used to fire on game over. Since there is no game over any
|
|
259
|
+
more, clearing the first wave is the moment: without moving it the
|
|
260
|
+
event would never fire at all and the game would stay permanently
|
|
261
|
+
undiscovered in the modal, which is a silent regression rather than
|
|
262
|
+
a visible one. Once per run. */
|
|
263
|
+
if (!this._endEmitted && typeof window !== 'undefined') {
|
|
264
|
+
this._endEmitted = true;
|
|
265
|
+
window.dispatchEvent(new CustomEvent(GAME_END_EVENT, {
|
|
266
|
+
detail: {
|
|
267
|
+
id: GAME_ID,
|
|
268
|
+
won: true,
|
|
269
|
+
score: this.score,
|
|
270
|
+
summary: `${this.score} pts · wave ${this.wave}`,
|
|
271
|
+
},
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.render();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
render() {
|
|
280
|
+
const grid = [];
|
|
281
|
+
const meta = [];
|
|
282
|
+
for (let y = 0; y < ROWS; y++) {
|
|
283
|
+
grid.push(Array(COLS).fill(' '));
|
|
284
|
+
meta.push(Array(COLS).fill(''));
|
|
285
|
+
}
|
|
286
|
+
const set = (y, x, c, t) => {
|
|
287
|
+
if (y >= 0 && y < ROWS && x >= 0 && x < COLS) { grid[y][x] = c; meta[y][x] = t; }
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
this.ufos.forEach((u) => {
|
|
291
|
+
if (u.alive) {
|
|
292
|
+
set(u.y, u.x, '<', 'ufo'); set(u.y, u.x + 1, 'O', 'ufo'); set(u.y, u.x + 2, '>', 'ufo');
|
|
293
|
+
} else if (u.exploding > 0) {
|
|
294
|
+
set(u.y, u.x, '*', 'boom'); set(u.y, u.x + 1, '#', 'boom'); set(u.y, u.x + 2, '*', 'boom');
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
this.lasers.forEach((l) => set(l.y, l.x, '|', 'laser'));
|
|
299
|
+
this.bombs.forEach((b) => set(b.y, b.x, '*', 'bomb'));
|
|
300
|
+
|
|
301
|
+
/* The ship, or a brief impact burst when something just hit it. The
|
|
302
|
+
flash is the whole feedback for taking a hit now that it costs
|
|
303
|
+
nothing, so without it bombs would land with no effect at all and
|
|
304
|
+
the game would feel broken rather than forgiving. */
|
|
305
|
+
if (this.hitFlash > 0) {
|
|
306
|
+
set(ROWS - 1, this.player.x, '*', 'boom');
|
|
307
|
+
set(ROWS - 1, this.player.x + 1, '#', 'boom');
|
|
308
|
+
set(ROWS - 1, this.player.x + 2, '*', 'boom');
|
|
309
|
+
} else {
|
|
310
|
+
set(ROWS - 1, this.player.x, '<', 'player');
|
|
311
|
+
set(ROWS - 1, this.player.x + 1, 'A', 'player');
|
|
312
|
+
set(ROWS - 1, this.player.x + 2, '>', 'player');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* Coalesce equal-styled runs into one span per run instead of one per
|
|
316
|
+
character. A row is 48 cells but usually only a handful of runs. */
|
|
317
|
+
let screen = '';
|
|
318
|
+
for (let y = 0; y < ROWS; y++) {
|
|
319
|
+
let line = '';
|
|
320
|
+
let runT = '', runS = '';
|
|
321
|
+
const flush = () => {
|
|
322
|
+
if (!runS) return;
|
|
323
|
+
line += runT ? `<span class="g-${runT}">${escapeHtml(runS)}</span>` : escapeHtml(runS);
|
|
324
|
+
runS = '';
|
|
325
|
+
};
|
|
326
|
+
for (let x = 0; x < COLS; x++) {
|
|
327
|
+
if (meta[y][x] === runT) runS += grid[y][x];
|
|
328
|
+
else { flush(); runT = meta[y][x]; runS = grid[y][x]; }
|
|
329
|
+
}
|
|
330
|
+
flush();
|
|
331
|
+
screen += `${line}\n`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* LIVES is gone, because there is nothing to count down. SHIELD says
|
|
335
|
+
the same thing the three little ships used to say (you are fine)
|
|
336
|
+
without implying a budget that can run out. HITS is there so the
|
|
337
|
+
bombs still mean something. */
|
|
338
|
+
const hud = `
|
|
339
|
+
<span class="hud-label">SCORE</span> <span class="hud-val g-laser">${String(this.score).padStart(4, '0')}</span>
|
|
340
|
+
<span class="hud-label">SHIELD</span> <span class="hud-val g-player">${escapeHtml('∞')}</span>
|
|
341
|
+
<span class="hud-label">WAVE</span> <span class="hud-val">${this.wave}</span>
|
|
342
|
+
<span class="hud-label">HITS</span> <span class="hud-val">${this.hits}</span>
|
|
343
|
+
<span style="margin-left:auto" class="hud-label">conduction · space-invaders.exe</span>
|
|
344
|
+
`;
|
|
345
|
+
|
|
346
|
+
let footer;
|
|
347
|
+
if (this.paused) {
|
|
348
|
+
footer = '<span class="warn">-- paused -- press [P] to resume</span>';
|
|
349
|
+
} else if (this.graceTicks > 0) {
|
|
350
|
+
footer = '<span class="ok">-- READY --</span> <span class="cmt">defend the cookies. fire orange lasers at the cobalt UFOs.</span>';
|
|
351
|
+
} else {
|
|
352
|
+
footer = '<span class="cmt"># [← →] move [space] fire orange laser [P] pause [ESC] exit</span>';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.onFrame({screen, hud, footer});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export {COLS, ROWS, GAME_ID, GAME_END_EVENT};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule this game has: you cannot lose it.
|
|
3
|
+
*
|
|
4
|
+
* It is an easter egg inside a cookie banner. Someone opened that banner to
|
|
5
|
+
* set a preference, so ending their game with GAME OVER is a worse outcome
|
|
6
|
+
* than letting them play forever. Both original death paths are covered
|
|
7
|
+
* here, because they were separate: bombs draining lives, and the swarm
|
|
8
|
+
* reaching the floor, which ended the run outright no matter how many lives
|
|
9
|
+
* were left.
|
|
10
|
+
*
|
|
11
|
+
* These assertions were checked against the previous engine before being
|
|
12
|
+
* committed: nine of the ten fail on it. A test for the absence of
|
|
13
|
+
* behaviour is worth very little until you have watched it fail.
|
|
14
|
+
*
|
|
15
|
+
* Runs under `npm test` (node --test) with no DOM: the engine guards its
|
|
16
|
+
* window access, and stop() lets the tick be driven by hand instead of a
|
|
17
|
+
* timer.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import test from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import SpaceInvaders, {ROWS} from './spaceInvaders.js';
|
|
23
|
+
|
|
24
|
+
function newGame() {
|
|
25
|
+
const frames = [];
|
|
26
|
+
const game = new SpaceInvaders({
|
|
27
|
+
onFrame: (f) => frames.push(f),
|
|
28
|
+
onExit: () => assert.fail('the game exited on its own'),
|
|
29
|
+
});
|
|
30
|
+
game.stop(); // drive tick() manually
|
|
31
|
+
return {game, frames};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('bombs landing on the player never end the run', () => {
|
|
35
|
+
const {game} = newGame();
|
|
36
|
+
game.graceTicks = 0;
|
|
37
|
+
/* Drop each bomb one row above the player and tick twice, which is how a
|
|
38
|
+
bomb actually arrives: it descends every other tick, and the collision
|
|
39
|
+
check runs in the same tick as the move. Injecting one directly onto
|
|
40
|
+
the player's row instead tests nothing, because on a move tick it
|
|
41
|
+
advances to ROWS and is filtered as off-screen before the check. */
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
game.bombs.push({x: game.player.x + 1, y: ROWS - 2});
|
|
44
|
+
game.tick();
|
|
45
|
+
game.tick();
|
|
46
|
+
}
|
|
47
|
+
assert.ok(game.hits >= 6, `all six bombs should register a hit, got ${game.hits}`);
|
|
48
|
+
assert.equal(game.lives, undefined, 'there should be no lives counter to run out');
|
|
49
|
+
assert.equal(game.gameOver, undefined, 'there should be no game-over flag');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the swarm reaching the floor rolls into a new wave', () => {
|
|
53
|
+
const {game} = newGame();
|
|
54
|
+
const before = game.wave;
|
|
55
|
+
game.ufos.forEach((u) => { u.alive = true; u.y = ROWS - 1; });
|
|
56
|
+
game.tick();
|
|
57
|
+
assert.equal(game.wave, before + 1, 'the wave should advance');
|
|
58
|
+
assert.equal(game.ufos.filter((u) => u.alive).length, 18, 'a fresh swarm should spawn');
|
|
59
|
+
assert.equal(game.landed, 1, 'the landing should be counted');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('clearing a wave advances it', () => {
|
|
63
|
+
const {game} = newGame();
|
|
64
|
+
const before = game.wave;
|
|
65
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
66
|
+
game.tick();
|
|
67
|
+
assert.equal(game.wave, before + 1);
|
|
68
|
+
assert.equal(game.cleared, 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('no frame ever renders a GAME OVER', () => {
|
|
72
|
+
const {game, frames} = newGame();
|
|
73
|
+
game.graceTicks = 0;
|
|
74
|
+
for (let i = 0; i < 40; i++) {
|
|
75
|
+
game.bombs.push({x: game.player.x + 1, y: ROWS - 1});
|
|
76
|
+
game.tick();
|
|
77
|
+
}
|
|
78
|
+
const bad = frames.filter((f) => /GAME OVER|YOU WIN/i.test(f.footer));
|
|
79
|
+
assert.equal(bad.length, 0, 'the footer should never announce an ending');
|
|
80
|
+
assert.ok(frames.at(-1).hud.includes('∞'), 'the HUD should show an infinite shield');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/* GameModal records discovery from `connext:gameend`. That used to fire on
|
|
84
|
+
game over, so removing game over would have silently stopped the game
|
|
85
|
+
from ever being marked as found. It now fires on the first wave cleared,
|
|
86
|
+
and this is the test that keeps it honest. */
|
|
87
|
+
test('clearing the first wave still reports the game as found', () => {
|
|
88
|
+
const events = [];
|
|
89
|
+
const priorWindow = globalThis.window;
|
|
90
|
+
globalThis.window = {
|
|
91
|
+
addEventListener() {}, removeEventListener() {},
|
|
92
|
+
dispatchEvent: (e) => { events.push(e); return true; },
|
|
93
|
+
};
|
|
94
|
+
globalThis.CustomEvent = globalThis.CustomEvent || class { constructor(type, init) { this.type = type; this.detail = init?.detail; } };
|
|
95
|
+
try {
|
|
96
|
+
const game = new SpaceInvaders({onFrame() {}, onExit() {}});
|
|
97
|
+
game.stop();
|
|
98
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
99
|
+
game.tick();
|
|
100
|
+
const end = events.filter((e) => e.type === 'connext:gameend');
|
|
101
|
+
assert.equal(end.length, 1, 'exactly one gameend should fire');
|
|
102
|
+
assert.equal(end[0].detail.id, 'invaders');
|
|
103
|
+
assert.equal(end[0].detail.won, true);
|
|
104
|
+
|
|
105
|
+
// and only once per run
|
|
106
|
+
game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
|
|
107
|
+
game.tick();
|
|
108
|
+
assert.equal(events.filter((e) => e.type === 'connext:gameend').length, 1);
|
|
109
|
+
} finally {
|
|
110
|
+
globalThis.window = priorWindow;
|
|
111
|
+
}
|
|
112
|
+
});
|
package/src/components/index.js
CHANGED
|
@@ -142,3 +142,10 @@ export {default as Prerequisites, PrerequisiteItem} from './Prerequisites/Prereq
|
|
|
142
142
|
export {default as Troubleshooting, TroubleshootingItem} from './Troubleshooting/Troubleshooting.jsx';
|
|
143
143
|
export {default as NextSteps, NextStep} from './NextSteps/NextSteps.jsx';
|
|
144
144
|
export {default as ContactCta} from './ContactCta/ContactCta.jsx';
|
|
145
|
+
|
|
146
|
+
/* AiDisclosure — EU Article-50 AI-content disclosure banner/mark.
|
|
147
|
+
Frontmatter-driven at the top of docs/blog pages (see the
|
|
148
|
+
DocItem/Content and BlogPostItem/Content theme swizzles); exported
|
|
149
|
+
here so an author can also drop it inline in MDX, e.g. beside a
|
|
150
|
+
single AI-generated figure within an otherwise human-written page. */
|
|
151
|
+
export {default as AiDisclosure} from './AiDisclosure/AiDisclosure.jsx';
|
package/src/data/app-glyphs.json
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"inner": "<rect x=\"5\" y=\"11\" width=\"14\" height=\"10\" rx=\"2\" fill=\"currentColor\"/><path d=\"M8,11V7a4,4,0,0,1,8,0v4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/>",
|
|
20
20
|
"viewBox": "0 0 24 24"
|
|
21
21
|
},
|
|
22
|
+
"hermiq": {
|
|
23
|
+
"inner": "<path fill=\"currentColor\" d=\"M19,1L17.74,3.75L15,5L17.74,6.26L19,9L20.25,6.26L23,5L20.25,3.75M9,4L6.5,9.5L1,12L6.5,14.5L9,20L11.5,14.5L17,12L11.5,9.5M19,15L17.74,17.74L15,19L17.74,20.25L19,23L20.25,20.25L23,19L20.25,17.74\"/>",
|
|
24
|
+
"viewBox": "0 0 24 24"
|
|
25
|
+
},
|
|
22
26
|
"financeq": {
|
|
23
27
|
"inner": "<path fill=\"currentColor\" d=\"M6,16.5L3,19.44V11H6M11,14.66L9.43,13.32L8,14.64V7H11M16,13L13,16V3H16M18.81,12.81L17,11H22V16L20.21,14.21L13,21.36L9.53,18.34L5.75,22H3L9.47,15.66L13,18.64\"/>",
|
|
24
28
|
"viewBox": "0 0 24 24"
|