@boardgamebuddy/game-pack-cli 0.0.1 → 0.0.8
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 +84 -0
- package/cli.js +271 -271
- package/package.json +24 -13
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @boardgamebuddy/game-pack-cli
|
|
2
|
+
|
|
3
|
+
Developer CLI for creating and testing [BoardGameBuddy](https://github.com/BoardGameBuddy) game packs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @boardgamebuddy/game-pack-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
### `bgb new <game-id>`
|
|
14
|
+
|
|
15
|
+
Scaffold a new game pack from the upstream template.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bgb new ticket-to-ride
|
|
19
|
+
bgb new ticket-to-ride --name "Ticket to Ride"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Arguments**
|
|
23
|
+
|
|
24
|
+
| Argument | Description |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `game-id` | Unique identifier for the game. Lowercase alphanumeric and hyphens only (e.g. `ticket-to-ride`). |
|
|
27
|
+
|
|
28
|
+
**Options**
|
|
29
|
+
|
|
30
|
+
| Option | Description |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `-n, --name <displayName>` | Display name shown in the app. Defaults to the title-cased `game-id`. |
|
|
33
|
+
|
|
34
|
+
This command downloads the template from [`BoardGameBuddy/game-packs`](https://github.com/BoardGameBuddy/game-packs) and creates the pack under `<game-id>/` in the current directory:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
ticket-to-ride/
|
|
38
|
+
game.json ← patched with your game-id and display name
|
|
39
|
+
scorer.ts ← implement your scoring logic here
|
|
40
|
+
scorer.js ← compiled output (generated)
|
|
41
|
+
embeddings.json ← card embeddings
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `bgb serve [pack-dir]`
|
|
45
|
+
|
|
46
|
+
Start a local dev server for a game pack with live reload whenever `scorer.ts` changes.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# from inside the pack directory
|
|
50
|
+
cd games/ticket-to-ride
|
|
51
|
+
bgb serve
|
|
52
|
+
|
|
53
|
+
# or pass the path explicitly
|
|
54
|
+
bgb serve games/ticket-to-ride
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The server starts on port `3000` (override with the `PORT` environment variable) and prints a QR code you can scan in the BoardGameBuddy app to load the pack directly.
|
|
58
|
+
|
|
59
|
+
When `scorer.ts` changes, the CLI automatically recompiles it with `tsc` and pushes a reload event to the app via Server-Sent Events.
|
|
60
|
+
|
|
61
|
+
## Developing a game pack
|
|
62
|
+
|
|
63
|
+
1. **Scaffold** the pack:
|
|
64
|
+
```bash
|
|
65
|
+
bgb new my-game
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
2. **Implement scoring** in `my-game/scorer.ts`. The `score` function receives all players and their detected cards and must return a score result for each player:
|
|
69
|
+
```ts
|
|
70
|
+
export function score(players: PlayerInput[]): PlayerScoreResult[] {
|
|
71
|
+
// your logic here
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
3. **Start the dev server**:
|
|
76
|
+
```bash
|
|
77
|
+
bgb serve my-game
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
4. **Open the app**, navigate to Pack Store, and scan the QR code to load your pack. The app reloads automatically whenever you save `scorer.ts`.
|
|
81
|
+
|
|
82
|
+
## Requirements
|
|
83
|
+
|
|
84
|
+
- Node.js >= 18
|
package/cli.js
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* bgb — BoardGameBuddy game pack developer CLI
|
|
4
|
-
*
|
|
5
|
-
* Commands:
|
|
6
|
-
* bgb new <game-id> [--name "Display Name"] scaffold a new pack from the upstream template
|
|
7
|
-
* bgb serve [pack-dir] serve a pack with live reload on scorer.ts changes
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
'use strict';
|
|
11
|
-
|
|
12
|
-
const { program } = require('commander');
|
|
13
|
-
const https = require('https');
|
|
14
|
-
const http = require('http');
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const os = require('os');
|
|
18
|
-
const { spawn } = require('child_process');
|
|
19
|
-
|
|
20
|
-
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
21
|
-
|
|
22
|
-
const TEMPLATE_REPO = 'BoardGameBuddy/game-packs';
|
|
23
|
-
const TEMPLATE_PATH = 'games/_template';
|
|
24
|
-
const TEMPLATE_REF = 'main';
|
|
25
|
-
|
|
26
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
function getLocalIp() {
|
|
29
|
-
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
30
|
-
for (const iface of ifaces) {
|
|
31
|
-
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return 'localhost';
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toTitleCase(str) {
|
|
38
|
-
return str
|
|
39
|
-
.split('-')
|
|
40
|
-
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
41
|
-
.join(' ');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function get(url) {
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const lib = url.startsWith('https') ? https : http;
|
|
47
|
-
lib.get(url, { headers: { 'User-Agent': 'bgb-cli' } }, (res) => {
|
|
48
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
49
|
-
resolve(get(res.headers.location));
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const chunks = [];
|
|
53
|
-
res.on('data', chunk => chunks.push(chunk));
|
|
54
|
-
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
55
|
-
}).on('error', reject);
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function githubContents(repoPath) {
|
|
60
|
-
const url = `https://api.github.com/repos/${TEMPLATE_REPO}/contents/${repoPath}?ref=${TEMPLATE_REF}`;
|
|
61
|
-
const { status, body } = await get(url);
|
|
62
|
-
if (status !== 200) {
|
|
63
|
-
throw new Error(`GitHub API returned ${status} for ${repoPath}:\n${body.toString()}`);
|
|
64
|
-
}
|
|
65
|
-
return JSON.parse(body.toString());
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function downloadTemplate(repoPath, localDir) {
|
|
69
|
-
const entries = await githubContents(repoPath);
|
|
70
|
-
fs.mkdirSync(localDir, { recursive: true });
|
|
71
|
-
for (const entry of entries) {
|
|
72
|
-
const dest = path.join(localDir, entry.name);
|
|
73
|
-
if (entry.type === 'dir') {
|
|
74
|
-
await downloadTemplate(entry.path, dest);
|
|
75
|
-
} else if (entry.type === 'file') {
|
|
76
|
-
const { status, body } = await get(entry.download_url);
|
|
77
|
-
if (status !== 200) throw new Error(`Failed to download ${entry.path}: HTTP ${status}`);
|
|
78
|
-
fs.writeFileSync(dest, body);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ─── bgb new ────────────────────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
program
|
|
86
|
-
.command('new <game-id>')
|
|
87
|
-
.description('Scaffold a new game pack from the upstream template')
|
|
88
|
-
.option('-n, --name <displayName>', 'Display name for the game (defaults to title-cased game-id)')
|
|
89
|
-
.action(async (gameId, opts) => {
|
|
90
|
-
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(gameId)) {
|
|
91
|
-
console.error(`Error: game-id must be lowercase alphanumeric and hyphens only (got: "${gameId}")`);
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const displayName = opts.name || toTitleCase(gameId);
|
|
96
|
-
const targetDir = path.join(process.cwd(),
|
|
97
|
-
|
|
98
|
-
if (fs.existsSync(targetDir)) {
|
|
99
|
-
console.error(`Error: directory already exists: ${targetDir}`);
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
console.log(`Fetching template from ${TEMPLATE_REPO}...`);
|
|
104
|
-
try {
|
|
105
|
-
await downloadTemplate(TEMPLATE_PATH, targetDir);
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.error(`Error: failed to fetch template — ${err.message}`);
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Patch game.json
|
|
112
|
-
const gameJsonPath = path.join(targetDir, 'game.json');
|
|
113
|
-
let gameJson = fs.readFileSync(gameJsonPath, 'utf8');
|
|
114
|
-
gameJson = gameJson.replace(/"mygame"/g, `"${gameId}"`);
|
|
115
|
-
gameJson = gameJson.replace(/"My Game"/g, `"${displayName}"`);
|
|
116
|
-
fs.writeFileSync(gameJsonPath, gameJson);
|
|
117
|
-
|
|
118
|
-
// Patch embeddings.json
|
|
119
|
-
const embeddingsPath = path.join(targetDir, 'embeddings.json');
|
|
120
|
-
if (fs.existsSync(embeddingsPath)) {
|
|
121
|
-
let embeddings = fs.readFileSync(embeddingsPath, 'utf8');
|
|
122
|
-
embeddings = embeddings.replace(/"mygame:/g, `"${gameId}:`);
|
|
123
|
-
fs.writeFileSync(embeddingsPath, embeddings);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
console.log(`\nCreated game pack:
|
|
127
|
-
console.log(` Display name: ${displayName}`);
|
|
128
|
-
console.log(`\nNext steps:`);
|
|
129
|
-
console.log(` cd
|
|
130
|
-
console.log(` # Edit scorer.ts to implement your scoring logic`);
|
|
131
|
-
console.log(` bgb serve . # Start dev server with live reload`);
|
|
132
|
-
console.log('');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
// ─── bgb serve ──────────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
program
|
|
138
|
-
.command('serve [pack-dir]')
|
|
139
|
-
.description('Serve a game pack with live reload on scorer.ts changes')
|
|
140
|
-
.action((packDirArg) => {
|
|
141
|
-
const packDir = packDirArg ? path.resolve(packDirArg) : process.cwd();
|
|
142
|
-
const gameJsonPath = path.join(packDir, 'game.json');
|
|
143
|
-
|
|
144
|
-
if (!fs.existsSync(gameJsonPath)) {
|
|
145
|
-
console.error(`Error: no game.json found in ${packDir}`);
|
|
146
|
-
console.error('Run this command from a pack directory or pass the pack path as argument.');
|
|
147
|
-
process.exit(1);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const packName = path.basename(packDir);
|
|
151
|
-
|
|
152
|
-
const MIME = {
|
|
153
|
-
'.json': 'application/json',
|
|
154
|
-
'.js': 'application/javascript',
|
|
155
|
-
'.html': 'text/html',
|
|
156
|
-
'.txt': 'text/plain',
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const sseClients = new Set();
|
|
160
|
-
|
|
161
|
-
function broadcast(data) {
|
|
162
|
-
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
163
|
-
for (const res of sseClients) res.write(msg);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const server = http.createServer((req, res) => {
|
|
167
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
168
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
169
|
-
|
|
170
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
171
|
-
|
|
172
|
-
const urlPath = req.url.split('?')[0];
|
|
173
|
-
|
|
174
|
-
if (urlPath === '/events') {
|
|
175
|
-
res.writeHead(200, {
|
|
176
|
-
'Content-Type': 'text/event-stream',
|
|
177
|
-
'Cache-Control': 'no-cache',
|
|
178
|
-
'Connection': 'keep-alive',
|
|
179
|
-
'Access-Control-Allow-Origin': '*',
|
|
180
|
-
});
|
|
181
|
-
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
|
182
|
-
sseClients.add(res);
|
|
183
|
-
|
|
184
|
-
const heartbeat = setInterval(() => res.write(': ping\n\n'), 30000);
|
|
185
|
-
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const filePath = path.join(packDir, urlPath === '/' ? '' : urlPath);
|
|
190
|
-
|
|
191
|
-
if (!filePath.startsWith(packDir)) {
|
|
192
|
-
res.writeHead(403); res.end('Forbidden'); return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
console.log(`${req.method} ${urlPath}`);
|
|
196
|
-
|
|
197
|
-
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
198
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
199
|
-
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
200
|
-
fs.createReadStream(filePath).pipe(res);
|
|
201
|
-
} else {
|
|
202
|
-
res.writeHead(404); res.end('Not found');
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
server.listen(PORT, '0.0.0.0', () => {
|
|
207
|
-
const ip = getLocalIp();
|
|
208
|
-
const url = `http://${ip}:${PORT}/`;
|
|
209
|
-
|
|
210
|
-
console.log(`\nServing pack: ${packName}`);
|
|
211
|
-
console.log(`URL: ${url}\n`);
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
const qrcode = require('qrcode-terminal');
|
|
215
|
-
qrcode.generate(url, { small: true }, (qr) => {
|
|
216
|
-
console.log(qr);
|
|
217
|
-
console.log('In the app: Pack Store → QR-Code scannen → fertig.\n');
|
|
218
|
-
console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
|
|
219
|
-
});
|
|
220
|
-
} catch {
|
|
221
|
-
console.log(`In the app: Pack Store → Von URL importieren → ${url}\n`);
|
|
222
|
-
console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
const scorerTs = path.join(packDir, 'scorer.ts');
|
|
227
|
-
if (!fs.existsSync(scorerTs)) {
|
|
228
|
-
console.warn('Warning: scorer.ts not found — live reload disabled.');
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
let debounceTimer = null;
|
|
233
|
-
|
|
234
|
-
function recompile() {
|
|
235
|
-
console.log('scorer.ts changed — recompiling...');
|
|
236
|
-
const proc = spawn(
|
|
237
|
-
'npx',
|
|
238
|
-
['tsc', 'scorer.ts', '--outDir', '.', '--target', 'ES2017', '--module', 'commonjs', '--skipLibCheck'],
|
|
239
|
-
{ cwd: packDir, shell: true }
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
let stderr = '';
|
|
243
|
-
proc.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
244
|
-
proc.stdout.on('data', chunk => { process.stdout.write(chunk); });
|
|
245
|
-
|
|
246
|
-
proc.on('close', (code) => {
|
|
247
|
-
if (code === 0) {
|
|
248
|
-
console.log('Recompile OK — broadcasting reload.');
|
|
249
|
-
broadcast({ type: 'reload' });
|
|
250
|
-
} else {
|
|
251
|
-
const msg = stderr.trim() || 'Compilation failed (see above)';
|
|
252
|
-
console.error(`Recompile failed:\n${msg}`);
|
|
253
|
-
broadcast({ type: 'error', message: msg });
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
fs.watch(scorerTs, { persistent: true }, () => {
|
|
259
|
-
clearTimeout(debounceTimer);
|
|
260
|
-
debounceTimer = setTimeout(recompile, 300);
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// ─── Parse ───────────────────────────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
program
|
|
267
|
-
.name('bgb')
|
|
268
|
-
.description('BoardGameBuddy game pack developer CLI')
|
|
269
|
-
.version('1.0.0');
|
|
270
|
-
|
|
271
|
-
program.parse(process.argv);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bgb — BoardGameBuddy game pack developer CLI
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* bgb new <game-id> [--name "Display Name"] scaffold a new pack from the upstream template
|
|
7
|
+
* bgb serve [pack-dir] serve a pack with live reload on scorer.ts changes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { program } = require('commander');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { spawn } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
21
|
+
|
|
22
|
+
const TEMPLATE_REPO = 'BoardGameBuddy/game-packs';
|
|
23
|
+
const TEMPLATE_PATH = 'games/_template';
|
|
24
|
+
const TEMPLATE_REF = 'main';
|
|
25
|
+
|
|
26
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function getLocalIp() {
|
|
29
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
30
|
+
for (const iface of ifaces) {
|
|
31
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return 'localhost';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toTitleCase(str) {
|
|
38
|
+
return str
|
|
39
|
+
.split('-')
|
|
40
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function get(url) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const lib = url.startsWith('https') ? https : http;
|
|
47
|
+
lib.get(url, { headers: { 'User-Agent': 'bgb-cli' } }, (res) => {
|
|
48
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
49
|
+
resolve(get(res.headers.location));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const chunks = [];
|
|
53
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
54
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
55
|
+
}).on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function githubContents(repoPath) {
|
|
60
|
+
const url = `https://api.github.com/repos/${TEMPLATE_REPO}/contents/${repoPath}?ref=${TEMPLATE_REF}`;
|
|
61
|
+
const { status, body } = await get(url);
|
|
62
|
+
if (status !== 200) {
|
|
63
|
+
throw new Error(`GitHub API returned ${status} for ${repoPath}:\n${body.toString()}`);
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(body.toString());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function downloadTemplate(repoPath, localDir) {
|
|
69
|
+
const entries = await githubContents(repoPath);
|
|
70
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const dest = path.join(localDir, entry.name);
|
|
73
|
+
if (entry.type === 'dir') {
|
|
74
|
+
await downloadTemplate(entry.path, dest);
|
|
75
|
+
} else if (entry.type === 'file') {
|
|
76
|
+
const { status, body } = await get(entry.download_url);
|
|
77
|
+
if (status !== 200) throw new Error(`Failed to download ${entry.path}: HTTP ${status}`);
|
|
78
|
+
fs.writeFileSync(dest, body);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── bgb new ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('new <game-id>')
|
|
87
|
+
.description('Scaffold a new game pack from the upstream template')
|
|
88
|
+
.option('-n, --name <displayName>', 'Display name for the game (defaults to title-cased game-id)')
|
|
89
|
+
.action(async (gameId, opts) => {
|
|
90
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(gameId)) {
|
|
91
|
+
console.error(`Error: game-id must be lowercase alphanumeric and hyphens only (got: "${gameId}")`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const displayName = opts.name || toTitleCase(gameId);
|
|
96
|
+
const targetDir = path.join(process.cwd(), gameId);
|
|
97
|
+
|
|
98
|
+
if (fs.existsSync(targetDir)) {
|
|
99
|
+
console.error(`Error: directory already exists: ${targetDir}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`Fetching template from ${TEMPLATE_REPO}...`);
|
|
104
|
+
try {
|
|
105
|
+
await downloadTemplate(TEMPLATE_PATH, targetDir);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`Error: failed to fetch template — ${err.message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Patch game.json
|
|
112
|
+
const gameJsonPath = path.join(targetDir, 'game.json');
|
|
113
|
+
let gameJson = fs.readFileSync(gameJsonPath, 'utf8');
|
|
114
|
+
gameJson = gameJson.replace(/"mygame"/g, `"${gameId}"`);
|
|
115
|
+
gameJson = gameJson.replace(/"My Game"/g, `"${displayName}"`);
|
|
116
|
+
fs.writeFileSync(gameJsonPath, gameJson);
|
|
117
|
+
|
|
118
|
+
// Patch embeddings.json
|
|
119
|
+
const embeddingsPath = path.join(targetDir, 'embeddings.json');
|
|
120
|
+
if (fs.existsSync(embeddingsPath)) {
|
|
121
|
+
let embeddings = fs.readFileSync(embeddingsPath, 'utf8');
|
|
122
|
+
embeddings = embeddings.replace(/"mygame:/g, `"${gameId}:`);
|
|
123
|
+
fs.writeFileSync(embeddingsPath, embeddings);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`\nCreated game pack: ${gameId}/`);
|
|
127
|
+
console.log(` Display name: ${displayName}`);
|
|
128
|
+
console.log(`\nNext steps:`);
|
|
129
|
+
console.log(` cd ${gameId}`);
|
|
130
|
+
console.log(` # Edit scorer.ts to implement your scoring logic`);
|
|
131
|
+
console.log(` bgb serve . # Start dev server with live reload`);
|
|
132
|
+
console.log('');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─── bgb serve ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
program
|
|
138
|
+
.command('serve [pack-dir]')
|
|
139
|
+
.description('Serve a game pack with live reload on scorer.ts changes')
|
|
140
|
+
.action((packDirArg) => {
|
|
141
|
+
const packDir = packDirArg ? path.resolve(packDirArg) : process.cwd();
|
|
142
|
+
const gameJsonPath = path.join(packDir, 'game.json');
|
|
143
|
+
|
|
144
|
+
if (!fs.existsSync(gameJsonPath)) {
|
|
145
|
+
console.error(`Error: no game.json found in ${packDir}`);
|
|
146
|
+
console.error('Run this command from a pack directory or pass the pack path as argument.');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const packName = path.basename(packDir);
|
|
151
|
+
|
|
152
|
+
const MIME = {
|
|
153
|
+
'.json': 'application/json',
|
|
154
|
+
'.js': 'application/javascript',
|
|
155
|
+
'.html': 'text/html',
|
|
156
|
+
'.txt': 'text/plain',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const sseClients = new Set();
|
|
160
|
+
|
|
161
|
+
function broadcast(data) {
|
|
162
|
+
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
163
|
+
for (const res of sseClients) res.write(msg);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const server = http.createServer((req, res) => {
|
|
167
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
168
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
169
|
+
|
|
170
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
171
|
+
|
|
172
|
+
const urlPath = req.url.split('?')[0];
|
|
173
|
+
|
|
174
|
+
if (urlPath === '/events') {
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
'Content-Type': 'text/event-stream',
|
|
177
|
+
'Cache-Control': 'no-cache',
|
|
178
|
+
'Connection': 'keep-alive',
|
|
179
|
+
'Access-Control-Allow-Origin': '*',
|
|
180
|
+
});
|
|
181
|
+
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
|
182
|
+
sseClients.add(res);
|
|
183
|
+
|
|
184
|
+
const heartbeat = setInterval(() => res.write(': ping\n\n'), 30000);
|
|
185
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const filePath = path.join(packDir, urlPath === '/' ? '' : urlPath);
|
|
190
|
+
|
|
191
|
+
if (!filePath.startsWith(packDir)) {
|
|
192
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`${req.method} ${urlPath}`);
|
|
196
|
+
|
|
197
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
198
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
199
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
200
|
+
fs.createReadStream(filePath).pipe(res);
|
|
201
|
+
} else {
|
|
202
|
+
res.writeHead(404); res.end('Not found');
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
207
|
+
const ip = getLocalIp();
|
|
208
|
+
const url = `http://${ip}:${PORT}/`;
|
|
209
|
+
|
|
210
|
+
console.log(`\nServing pack: ${packName}`);
|
|
211
|
+
console.log(`URL: ${url}\n`);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const qrcode = require('qrcode-terminal');
|
|
215
|
+
qrcode.generate(url, { small: true }, (qr) => {
|
|
216
|
+
console.log(qr);
|
|
217
|
+
console.log('In the app: Pack Store → QR-Code scannen → fertig.\n');
|
|
218
|
+
console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
|
|
219
|
+
});
|
|
220
|
+
} catch {
|
|
221
|
+
console.log(`In the app: Pack Store → Von URL importieren → ${url}\n`);
|
|
222
|
+
console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const scorerTs = path.join(packDir, 'scorer.ts');
|
|
227
|
+
if (!fs.existsSync(scorerTs)) {
|
|
228
|
+
console.warn('Warning: scorer.ts not found — live reload disabled.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let debounceTimer = null;
|
|
233
|
+
|
|
234
|
+
function recompile() {
|
|
235
|
+
console.log('scorer.ts changed — recompiling...');
|
|
236
|
+
const proc = spawn(
|
|
237
|
+
'npx',
|
|
238
|
+
['tsc', 'scorer.ts', '--outDir', '.', '--target', 'ES2017', '--module', 'commonjs', '--skipLibCheck'],
|
|
239
|
+
{ cwd: packDir, shell: true }
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
let stderr = '';
|
|
243
|
+
proc.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
244
|
+
proc.stdout.on('data', chunk => { process.stdout.write(chunk); });
|
|
245
|
+
|
|
246
|
+
proc.on('close', (code) => {
|
|
247
|
+
if (code === 0) {
|
|
248
|
+
console.log('Recompile OK — broadcasting reload.');
|
|
249
|
+
broadcast({ type: 'reload' });
|
|
250
|
+
} else {
|
|
251
|
+
const msg = stderr.trim() || 'Compilation failed (see above)';
|
|
252
|
+
console.error(`Recompile failed:\n${msg}`);
|
|
253
|
+
broadcast({ type: 'error', message: msg });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fs.watch(scorerTs, { persistent: true }, () => {
|
|
259
|
+
clearTimeout(debounceTimer);
|
|
260
|
+
debounceTimer = setTimeout(recompile, 300);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ─── Parse ───────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
program
|
|
267
|
+
.name('bgb')
|
|
268
|
+
.description('BoardGameBuddy game pack developer CLI')
|
|
269
|
+
.version('1.0.0');
|
|
270
|
+
|
|
271
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@boardgamebuddy/game-pack-cli",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Developer CLI for BoardGameBuddy game packs",
|
|
5
|
-
"bin": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@boardgamebuddy/game-pack-cli",
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"description": "Developer CLI for BoardGameBuddy game packs",
|
|
5
|
+
"bin": {
|
|
6
|
+
"bgb": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "git+https://github.com/BoardGameBuddy/game-pack-cli.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"cli.js"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^12.0.0",
|
|
22
|
+
"qrcode-terminal": "^0.12.0"
|
|
23
|
+
}
|
|
24
|
+
}
|