create-tx5dr-plugin 2.0.0 → 2.5.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 +10 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +288 -17
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npx create-tx5dr-plugin [name] [options]
|
|
|
14
14
|
|------|-------------|---------|
|
|
15
15
|
| `--type <utility\|strategy>` | Plugin type | `utility` |
|
|
16
16
|
| `--lang <ts\|js>` | Language | `ts` |
|
|
17
|
-
| `--template <basic\|ui-vanilla\|ui-react\|ui-vue>` | Project template | `basic` |
|
|
17
|
+
| `--template <basic\|ui-vanilla\|ui-react\|ui-vue\|ft8-contest>` | Project template | `basic` |
|
|
18
18
|
| `--help, -h` | Show help | |
|
|
19
19
|
|
|
20
20
|
### Templates
|
|
@@ -25,6 +25,12 @@ npx create-tx5dr-plugin [name] [options]
|
|
|
25
25
|
| `ui-vanilla` | Plugin with vanilla HTML/JS/CSS UI page |
|
|
26
26
|
| `ui-react` | Plugin with React + Vite UI page |
|
|
27
27
|
| `ui-vue` | Plugin with Vue + Vite UI page |
|
|
28
|
+
| `ft8-contest` | TypeScript strategy with composable FT8/FT4 contest rules and tests |
|
|
29
|
+
|
|
30
|
+
`ft8-contest` fixes the type to `strategy` and language to `ts`; conflicting
|
|
31
|
+
explicit `--type` or `--lang` values are rejected. Its build emits declarations
|
|
32
|
+
with TypeScript and a self-contained ESM `dist/index.mjs` with esbuild, so the
|
|
33
|
+
Host can load the linked plugin directory without the project `node_modules`.
|
|
28
34
|
|
|
29
35
|
### Examples
|
|
30
36
|
|
|
@@ -46,6 +52,9 @@ npx create-tx5dr-plugin my-plugin --template ui-vue
|
|
|
46
52
|
|
|
47
53
|
# Vanilla UI (no build step for UI files)
|
|
48
54
|
npx create-tx5dr-plugin my-plugin --template ui-vanilla
|
|
55
|
+
|
|
56
|
+
# FT8 contest strategy with rule modules and test kit
|
|
57
|
+
npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
49
58
|
```
|
|
50
59
|
|
|
51
60
|
## Generated Structure
|
package/dist/index.d.ts
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
* npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
|
|
8
8
|
* npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
|
|
9
9
|
* npx create-tx5dr-plugin my-plugin --template ui-react
|
|
10
|
+
* npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
10
11
|
*/
|
|
11
12
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
* npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
|
|
8
8
|
* npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
|
|
9
9
|
* npx create-tx5dr-plugin my-plugin --template ui-react
|
|
10
|
+
* npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
10
11
|
*/
|
|
11
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
12
14
|
import { join, resolve } from 'node:path';
|
|
13
15
|
import { createInterface } from 'node:readline';
|
|
14
|
-
const
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const VALID_TEMPLATES = ['basic', 'ui-vanilla', 'ui-react', 'ui-vue', 'ft8-contest'];
|
|
15
18
|
// ===== CLI argument parsing =====
|
|
16
19
|
function parseArgs() {
|
|
17
20
|
const args = process.argv.slice(2);
|
|
@@ -58,7 +61,7 @@ function printUsage() {
|
|
|
58
61
|
Options:
|
|
59
62
|
--type <utility|strategy> Plugin type (default: utility)
|
|
60
63
|
--lang <ts|js> Language (default: ts)
|
|
61
|
-
--template <basic|ui-vanilla|ui-react|ui-vue>
|
|
64
|
+
--template <basic|ui-vanilla|ui-react|ui-vue|ft8-contest> Template (default: basic)
|
|
62
65
|
--help, -h Show this help message
|
|
63
66
|
|
|
64
67
|
Templates:
|
|
@@ -66,12 +69,14 @@ function printUsage() {
|
|
|
66
69
|
ui-vanilla Plugin with vanilla HTML/JS/CSS UI page
|
|
67
70
|
ui-react Plugin with React + Vite UI page
|
|
68
71
|
ui-vue Plugin with Vue + Vite UI page
|
|
72
|
+
ft8-contest TypeScript strategy with composable FT8 contest rules
|
|
69
73
|
|
|
70
74
|
Examples:
|
|
71
75
|
npx create-tx5dr-plugin my-plugin
|
|
72
76
|
npx create-tx5dr-plugin my-plugin --type strategy
|
|
73
77
|
npx create-tx5dr-plugin my-plugin --template ui-react
|
|
74
78
|
npx create-tx5dr-plugin my-plugin --template ui-vue --type utility
|
|
79
|
+
npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
75
80
|
`);
|
|
76
81
|
}
|
|
77
82
|
// ===== Interactive prompts =====
|
|
@@ -88,21 +93,29 @@ async function promptConfig(partial) {
|
|
|
88
93
|
console.error('Plugin name is required.');
|
|
89
94
|
process.exit(1);
|
|
90
95
|
}
|
|
91
|
-
let
|
|
92
|
-
if (!
|
|
96
|
+
let template = partial.template;
|
|
97
|
+
if (!template) {
|
|
98
|
+
const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue/ft8-contest) [basic]: ');
|
|
99
|
+
template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
|
|
100
|
+
}
|
|
101
|
+
if (template === 'ft8-contest' && partial.type && partial.type !== 'strategy') {
|
|
102
|
+
console.error('The ft8-contest template requires --type strategy.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
if (template === 'ft8-contest' && partial.lang && partial.lang !== 'ts') {
|
|
106
|
+
console.error('The ft8-contest template requires --lang ts.');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
let type = template === 'ft8-contest' ? 'strategy' : partial.type ?? 'utility';
|
|
110
|
+
if (!partial.type && template !== 'ft8-contest') {
|
|
93
111
|
const answer = await prompt(rl, 'Plugin type (utility/strategy) [utility]: ');
|
|
94
112
|
type = answer === 'strategy' ? 'strategy' : 'utility';
|
|
95
113
|
}
|
|
96
|
-
let lang = partial.lang;
|
|
97
|
-
if (!lang) {
|
|
114
|
+
let lang = template === 'ft8-contest' ? 'ts' : partial.lang ?? 'ts';
|
|
115
|
+
if (!partial.lang && template !== 'ft8-contest') {
|
|
98
116
|
const answer = await prompt(rl, 'Language (ts/js) [ts]: ');
|
|
99
117
|
lang = answer === 'js' ? 'js' : 'ts';
|
|
100
118
|
}
|
|
101
|
-
let template = partial.template;
|
|
102
|
-
if (!template) {
|
|
103
|
-
const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue) [basic]: ');
|
|
104
|
-
template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
|
|
105
|
-
}
|
|
106
119
|
return { name, type, lang, template };
|
|
107
120
|
}
|
|
108
121
|
finally {
|
|
@@ -111,19 +124,42 @@ async function promptConfig(partial) {
|
|
|
111
124
|
}
|
|
112
125
|
// ===== Helpers =====
|
|
113
126
|
function hasUI(config) {
|
|
114
|
-
return config.template
|
|
127
|
+
return config.template === 'ui-vanilla'
|
|
128
|
+
|| config.template === 'ui-react'
|
|
129
|
+
|| config.template === 'ui-vue';
|
|
115
130
|
}
|
|
116
131
|
function hasVite(config) {
|
|
117
132
|
return config.template === 'ui-react' || config.template === 'ui-vue';
|
|
118
133
|
}
|
|
134
|
+
function readContestLogbookUiAssets() {
|
|
135
|
+
const entry = require.resolve('@tx5dr/plugin-api/contest-logbook-ui/contest-log.html');
|
|
136
|
+
const root = resolve(entry, '..');
|
|
137
|
+
const files = new Map();
|
|
138
|
+
const visit = (directory, prefix) => {
|
|
139
|
+
for (const name of readdirSync(directory)) {
|
|
140
|
+
const source = join(directory, name);
|
|
141
|
+
const relative = join(prefix, name);
|
|
142
|
+
if (statSync(source).isDirectory())
|
|
143
|
+
visit(source, relative);
|
|
144
|
+
else
|
|
145
|
+
files.set(`ui/${relative}`, readFileSync(source, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
if (!existsSync(root))
|
|
149
|
+
throw new Error('contest-logbook-ui-assets-missing');
|
|
150
|
+
visit(root, '');
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
119
153
|
// ===== Core template generation =====
|
|
120
154
|
function generatePackageJson(config) {
|
|
121
155
|
const devDeps = {
|
|
122
|
-
'@tx5dr/plugin-api': '
|
|
156
|
+
'@tx5dr/plugin-api': '^2.5.0',
|
|
123
157
|
};
|
|
124
158
|
if (config.lang === 'ts') {
|
|
125
159
|
devDeps['typescript'] = '^5.0.0';
|
|
126
160
|
devDeps['vitest'] = '^1.0.0';
|
|
161
|
+
if (config.template === 'ft8-contest')
|
|
162
|
+
devDeps['esbuild'] = '^0.25.0';
|
|
127
163
|
}
|
|
128
164
|
if (hasVite(config)) {
|
|
129
165
|
devDeps['vite'] = '^6.0.0';
|
|
@@ -141,7 +177,14 @@ function generatePackageJson(config) {
|
|
|
141
177
|
}
|
|
142
178
|
const scripts = {};
|
|
143
179
|
if (config.lang === 'ts') {
|
|
144
|
-
if (
|
|
180
|
+
if (config.template === 'ft8-contest') {
|
|
181
|
+
scripts['build'] = 'npm run build:types && npm run build:bundle';
|
|
182
|
+
scripts['build:types'] = 'tsc --emitDeclarationOnly';
|
|
183
|
+
scripts['build:bundle'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs';
|
|
184
|
+
scripts['typecheck'] = 'tsc --noEmit';
|
|
185
|
+
scripts['dev'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs --watch';
|
|
186
|
+
}
|
|
187
|
+
else if (hasVite(config)) {
|
|
145
188
|
scripts['build'] = 'tsc && npm run build:ui';
|
|
146
189
|
scripts['build:ui'] = 'vite build --config ui/vite.config.ts';
|
|
147
190
|
scripts['dev:server'] = 'tsc --watch';
|
|
@@ -159,11 +202,33 @@ function generatePackageJson(config) {
|
|
|
159
202
|
version: '0.1.0',
|
|
160
203
|
type: 'module',
|
|
161
204
|
...(config.lang === 'ts'
|
|
162
|
-
? {
|
|
205
|
+
? {
|
|
206
|
+
main: config.template === 'ft8-contest' ? 'dist/index.mjs' : 'dist/index.js',
|
|
207
|
+
types: 'dist/index.d.ts',
|
|
208
|
+
}
|
|
163
209
|
: { main: 'index.js' }),
|
|
164
210
|
scripts,
|
|
165
211
|
devDependencies: devDeps,
|
|
166
212
|
};
|
|
213
|
+
if (config.template === 'ft8-contest') {
|
|
214
|
+
pkg.tx5drPlugin = {
|
|
215
|
+
pluginName: config.name,
|
|
216
|
+
title: config.name,
|
|
217
|
+
description: `${config.name} FT8/FT4 contest strategy`,
|
|
218
|
+
minPluginApiVersion: '2.5.0',
|
|
219
|
+
author: 'TX-5DR plugin author',
|
|
220
|
+
license: 'GPL-3.0-only',
|
|
221
|
+
categories: ['contest', 'ft8', 'ft4'],
|
|
222
|
+
keywords: ['amateur-radio', 'contest', config.name],
|
|
223
|
+
entry: 'dist/index.mjs',
|
|
224
|
+
include: [
|
|
225
|
+
{ from: 'dist/index.mjs', to: 'index.mjs' },
|
|
226
|
+
{ from: 'ui', to: 'ui' },
|
|
227
|
+
{ from: 'src/locales', to: 'locales' },
|
|
228
|
+
{ from: 'README.md', to: 'README.md' },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
167
232
|
return JSON.stringify(pkg, null, 2) + '\n';
|
|
168
233
|
}
|
|
169
234
|
function generateTsConfig() {
|
|
@@ -191,6 +256,9 @@ dist/
|
|
|
191
256
|
function generateLocaleZh(config) {
|
|
192
257
|
return JSON.stringify({
|
|
193
258
|
pluginDescription: `${config.name} plugin`,
|
|
259
|
+
contestLogTitle: '比赛日志',
|
|
260
|
+
contestNewCallsign: '本波段新台',
|
|
261
|
+
contestNewMultiplier: '新系数',
|
|
194
262
|
enabled: '启用自动发射控制',
|
|
195
263
|
enabledDesc: '允许此插件通过宿主发射协调器影响当前操作员的自动通联',
|
|
196
264
|
}, null, 2) + '\n';
|
|
@@ -198,6 +266,9 @@ function generateLocaleZh(config) {
|
|
|
198
266
|
function generateLocaleEn(config) {
|
|
199
267
|
return JSON.stringify({
|
|
200
268
|
pluginDescription: `${config.name} plugin`,
|
|
269
|
+
contestLogTitle: 'Contest log',
|
|
270
|
+
contestNewCallsign: 'New on band',
|
|
271
|
+
contestNewMultiplier: 'New multiplier',
|
|
201
272
|
enabled: 'Enable automatic transmit control',
|
|
202
273
|
enabledDesc: 'Allow this plugin to influence operator automation through the host coordinator',
|
|
203
274
|
}, null, 2) + '\n';
|
|
@@ -371,6 +442,156 @@ export const locales: Record<string, Record<string, string>> = {
|
|
|
371
442
|
en: enLocale,
|
|
372
443
|
};
|
|
373
444
|
|
|
445
|
+
export default plugin;
|
|
446
|
+
`;
|
|
447
|
+
}
|
|
448
|
+
function generateTsFT8ContestPlugin(config) {
|
|
449
|
+
return `import {
|
|
450
|
+
type FrameMessage,
|
|
451
|
+
type ParsedFT8Message,
|
|
452
|
+
type SlotInfo,
|
|
453
|
+
type StrategyDecisionMetaV2,
|
|
454
|
+
type StrategyDecisionResult,
|
|
455
|
+
type StrategyPluginContext,
|
|
456
|
+
type StrategyRuntime,
|
|
457
|
+
type StrategyRuntimeCheckpoint,
|
|
458
|
+
type StrategyRuntimeContext,
|
|
459
|
+
type StrategyRuntimeSlot,
|
|
460
|
+
type StrategyRuntimeSlotContentUpdate,
|
|
461
|
+
type StrategyRuntimeSnapshot,
|
|
462
|
+
} from '@tx5dr/plugin-api';
|
|
463
|
+
import {
|
|
464
|
+
cabrilloSubmission,
|
|
465
|
+
CONTEST_LOGBOOK_PERMISSIONS,
|
|
466
|
+
composeFT8ContestPlugin,
|
|
467
|
+
createContestQsoEnvelopeAdapter,
|
|
468
|
+
defineFT8Contest,
|
|
469
|
+
distancePoints,
|
|
470
|
+
fixedWeekendEdition,
|
|
471
|
+
gridExchange,
|
|
472
|
+
requireExchangeAndFinalAck,
|
|
473
|
+
type FT8ContestQso,
|
|
474
|
+
standardFT8ContestLogbook,
|
|
475
|
+
type GridExchange,
|
|
476
|
+
} from '@tx5dr/plugin-api/contest';
|
|
477
|
+
import zhLocale from './locales/zh.json' with { type: 'json' };
|
|
478
|
+
import enLocale from './locales/en.json' with { type: 'json' };
|
|
479
|
+
import jaLocale from './locales/ja.json' with { type: 'json' };
|
|
480
|
+
|
|
481
|
+
export type ContestQso = FT8ContestQso<GridExchange> & {
|
|
482
|
+
frequencyKhz: number;
|
|
483
|
+
cabrilloDateTime: string;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
export const contest = defineFT8Contest<GridExchange, ContestQso>({
|
|
487
|
+
id: '${config.name}',
|
|
488
|
+
rulesetVersion: '2026.1',
|
|
489
|
+
edition: fixedWeekendEdition({
|
|
490
|
+
id: '2026',
|
|
491
|
+
// Replace these example boundaries and record the official rule source.
|
|
492
|
+
startAt: '2026-01-03T00:00:00Z',
|
|
493
|
+
endAt: '2026-01-04T00:00:00Z',
|
|
494
|
+
}),
|
|
495
|
+
bands: ['80M', '40M', '20M', '15M', '10M'],
|
|
496
|
+
exchange: gridExchange(),
|
|
497
|
+
// Completion is explicit because it belongs to the RF fail-closed boundary.
|
|
498
|
+
completion: requireExchangeAndFinalAck(),
|
|
499
|
+
scoring: distancePoints<ContestQso>({ stepKm: 3000 }),
|
|
500
|
+
submission: cabrilloSubmission<ContestQso>({
|
|
501
|
+
headers: () => [['CONTEST', '${config.name.toUpperCase()}']],
|
|
502
|
+
qsoLine: (qso) =>
|
|
503
|
+
\`QSO: \${qso.frequencyKhz} DG \${qso.cabrilloDateTime} \${qso.callsign}\`,
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
export const contestEnvelope = createContestQsoEnvelopeAdapter(contest);
|
|
508
|
+
|
|
509
|
+
export const logbook = standardFT8ContestLogbook({ contest });
|
|
510
|
+
|
|
511
|
+
class ContestRuntime implements StrategyRuntime {
|
|
512
|
+
private state: StrategyRuntimeSlot = 'TX6';
|
|
513
|
+
private slots: Partial<Record<StrategyRuntimeSlot, string>> = {};
|
|
514
|
+
private context: StrategyRuntimeContext = {};
|
|
515
|
+
|
|
516
|
+
constructor(private readonly ctx: StrategyPluginContext) {}
|
|
517
|
+
|
|
518
|
+
checkpoint(): StrategyRuntimeCheckpoint {
|
|
519
|
+
return structuredClone({ state: this.state, slots: this.slots, context: this.context });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
restore(checkpoint: StrategyRuntimeCheckpoint): void {
|
|
523
|
+
const saved = checkpoint as {
|
|
524
|
+
state: StrategyRuntimeSlot;
|
|
525
|
+
slots: Partial<Record<StrategyRuntimeSlot, string>>;
|
|
526
|
+
context: StrategyRuntimeContext;
|
|
527
|
+
};
|
|
528
|
+
this.state = saved.state;
|
|
529
|
+
this.slots = { ...saved.slots };
|
|
530
|
+
this.context = { ...saved.context };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
decide(messages: ParsedFT8Message[], meta: StrategyDecisionMetaV2): StrategyDecisionResult {
|
|
534
|
+
if (meta.signal.aborted) throw meta.signal.reason ?? new Error('Strategy decision aborted');
|
|
535
|
+
// Apply the contest exchange/completion modules in your protocol reducer here.
|
|
536
|
+
return { transmission: this.getTransmitText(), snapshot: this.getSnapshot() };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getTransmitText(): string | null {
|
|
540
|
+
return this.slots[this.state] ?? null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
requestCall(callsign: string, lastMessage?: { message: FrameMessage; slotInfo: SlotInfo }): void {
|
|
544
|
+
this.context.targetCallsign = callsign;
|
|
545
|
+
this.state = 'TX1';
|
|
546
|
+
this.ctx.log.info('Contest call requested', { callsign });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
getSnapshot(): StrategyRuntimeSnapshot {
|
|
550
|
+
return {
|
|
551
|
+
currentState: this.state,
|
|
552
|
+
slots: { ...this.slots },
|
|
553
|
+
context: { ...this.context },
|
|
554
|
+
availableSlots: ['TX1', 'TX2', 'TX3', 'TX4', 'TX5', 'TX6'],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
patchContext(patch: Partial<StrategyRuntimeContext>): void {
|
|
559
|
+
Object.assign(this.context, patch);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
setState(state: StrategyRuntimeSlot): void {
|
|
563
|
+
this.state = state;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
setSlotContent(update: StrategyRuntimeSlotContentUpdate): void {
|
|
567
|
+
this.slots[update.slot] = update.content;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
reset(reason?: string): void {
|
|
571
|
+
this.state = 'TX6';
|
|
572
|
+
this.slots = {};
|
|
573
|
+
this.context = {};
|
|
574
|
+
this.ctx.log.info('Contest strategy reset', { reason });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export const plugin = composeFT8ContestPlugin({
|
|
579
|
+
name: '${config.name}',
|
|
580
|
+
version: '0.1.0',
|
|
581
|
+
minPluginApiVersion: '2.5.0',
|
|
582
|
+
description: 'pluginDescription',
|
|
583
|
+
permissions: CONTEST_LOGBOOK_PERMISSIONS,
|
|
584
|
+
contest,
|
|
585
|
+
logbook,
|
|
586
|
+
runtime: (_contest, context) => new ContestRuntime(context),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
export const locales: Record<string, Record<string, string>> = {
|
|
590
|
+
zh: zhLocale,
|
|
591
|
+
en: enLocale,
|
|
592
|
+
ja: jaLocale,
|
|
593
|
+
};
|
|
594
|
+
|
|
374
595
|
export default plugin;
|
|
375
596
|
`;
|
|
376
597
|
}
|
|
@@ -542,6 +763,48 @@ export default plugin;
|
|
|
542
763
|
}
|
|
543
764
|
// ===== Test templates =====
|
|
544
765
|
function generateTsTest(config) {
|
|
766
|
+
if (config.template === 'ft8-contest') {
|
|
767
|
+
return `import { describe, it, expect } from 'vitest';
|
|
768
|
+
import { createFT8ContestTestKit } from '@tx5dr/plugin-api/contest';
|
|
769
|
+
import { contest, contestEnvelope, plugin } from '../index.js';
|
|
770
|
+
|
|
771
|
+
describe('${config.name}', () => {
|
|
772
|
+
const kit = createFT8ContestTestKit(contest);
|
|
773
|
+
|
|
774
|
+
it('exposes an FT8 strategy plugin', () => {
|
|
775
|
+
expect(plugin.type).toBe('strategy');
|
|
776
|
+
expect(plugin.minPluginApiVersion).toBe('2.5.0');
|
|
777
|
+
expect(contest.operating).toMatchObject({
|
|
778
|
+
humanInitiation: 'required',
|
|
779
|
+
maxConcurrentQsos: 1,
|
|
780
|
+
maxSimultaneousSignals: 1,
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('round-trips the contest exchange', () => {
|
|
785
|
+
kit.exchange({ grid: 'FN31' }, { grid: 'FN31' });
|
|
786
|
+
kit.invalidExchange({ grid: 'ZZ99' }, 'invalid_grid');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('requires exchange and a final acknowledgement', () => {
|
|
790
|
+
kit.completion({
|
|
791
|
+
sentExchange: { grid: 'PL04' },
|
|
792
|
+
receivedExchange: { grid: 'FN31' },
|
|
793
|
+
receivedFinalAck: true,
|
|
794
|
+
}, true);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('creates an identity-bound contest QSO envelope', () => {
|
|
798
|
+
const envelope = contestEnvelope.create({
|
|
799
|
+
sent: { grid: 'PL04' },
|
|
800
|
+
received: { grid: 'FN31' },
|
|
801
|
+
});
|
|
802
|
+
expect(contestEnvelope.validate(envelope).ok).toBe(true);
|
|
803
|
+
expect(envelope.rulesetVersion).toBe(contest.rulesetVersion);
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
`;
|
|
807
|
+
}
|
|
545
808
|
if (config.type === 'strategy') {
|
|
546
809
|
return `import { describe, it, expect } from 'vitest';
|
|
547
810
|
import { createMockContext, createMockSlotInfo, createMockParsedMessage } from '@tx5dr/plugin-api/testing';
|
|
@@ -1106,7 +1369,13 @@ function generateFiles(config) {
|
|
|
1106
1369
|
files.set('.gitignore', generateGitignore());
|
|
1107
1370
|
if (config.lang === 'ts') {
|
|
1108
1371
|
files.set('tsconfig.json', generateTsConfig());
|
|
1109
|
-
if (config.
|
|
1372
|
+
if (config.template === 'ft8-contest') {
|
|
1373
|
+
files.set('src/index.ts', generateTsFT8ContestPlugin(config));
|
|
1374
|
+
for (const [relativePath, content] of readContestLogbookUiAssets())
|
|
1375
|
+
files.set(relativePath, content);
|
|
1376
|
+
files.set('README.md', `# ${config.name}\n\nFT8/FT4 contest strategy plugin for TX-5DR.\n`);
|
|
1377
|
+
}
|
|
1378
|
+
else if (config.type === 'strategy') {
|
|
1110
1379
|
files.set('src/index.ts', generateTsStrategyPlugin(config));
|
|
1111
1380
|
}
|
|
1112
1381
|
else if (hasUI(config)) {
|
|
@@ -1117,6 +1386,8 @@ function generateFiles(config) {
|
|
|
1117
1386
|
}
|
|
1118
1387
|
files.set('src/locales/zh.json', generateLocaleZh(config));
|
|
1119
1388
|
files.set('src/locales/en.json', generateLocaleEn(config));
|
|
1389
|
+
if (config.template === 'ft8-contest')
|
|
1390
|
+
files.set('src/locales/ja.json', JSON.stringify({ pluginDescription: `${config.name} plugin`, contestLogTitle: 'コンテストログ', contestNewCallsign: 'このバンドで未交信', contestNewMultiplier: '新マルチ' }, null, 2) + '\n');
|
|
1120
1391
|
files.set('src/__tests__/plugin.test.ts', generateTsTest(config));
|
|
1121
1392
|
}
|
|
1122
1393
|
else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-tx5dr-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new TX-5DR plugin project",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc",
|
|
25
|
-
"dev": "tsc --watch"
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"smoke:ft8-contest": "node scripts/ft8-contest-smoke.mjs"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@tx5dr/plugin-api": "^2.5.0"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"typescript": "^5.0.0"
|