@pilaf/framework 1.0.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,897 @@
1
+ /**
2
+ * StoryRunner - Executes test stories for Pilaf
3
+ * Supports both YAML files and JavaScript objects
4
+ */
5
+
6
+ let yaml;
7
+ try {
8
+ yaml = require('js-yaml');
9
+ } catch (e) {
10
+ yaml = null;
11
+ }
12
+
13
+ const fs = require('fs');
14
+ const { PilafBackendFactory } = require('@pilaf/backends');
15
+
16
+ /**
17
+ * StoryRunner executes test stories defined in YAML format
18
+ *
19
+ * Story format:
20
+ * name: "Story Name"
21
+ * description: "Story description"
22
+ * setup:
23
+ * server:
24
+ * type: "paper"
25
+ * version: "1.21.8"
26
+ * players:
27
+ * - name: "TestPlayer"
28
+ * username: "testplayer"
29
+ * steps:
30
+ * - name: "Step name"
31
+ * action: "action_type"
32
+ * ...action-specific params
33
+ * teardown:
34
+ * stop_server: true
35
+ */
36
+
37
+ class StoryRunner {
38
+ /**
39
+ * Create a StoryRunner
40
+ * @param {Object} options - Options
41
+ * @param {Object} [options.logger] - Logger instance (defaults to console)
42
+ * @param {Object} [options.reporter] - Reporter instance for collecting results
43
+ */
44
+ constructor(options = {}) {
45
+ this.logger = options?.logger || console;
46
+ this.reporter = options?.reporter;
47
+ this.backends = {
48
+ rcon: null,
49
+ players: new Map() // username -> backend
50
+ };
51
+ this.bots = new Map(); // username -> bot
52
+ this.currentStory = null;
53
+ this.results = [];
54
+ this.variables = new Map(); // Variable storage for store_as mechanism
55
+ }
56
+
57
+ /**
58
+ * Load a story from a YAML file
59
+ * @param {string} filePath - Path to YAML story file
60
+ * @returns {Object} Parsed story object
61
+ */
62
+ loadStory(filePath) {
63
+ if (!yaml) {
64
+ throw new Error('js-yaml module is not installed. Please install it with: npm install js-yaml');
65
+ }
66
+
67
+ try {
68
+ const fileContents = fs.readFileSync(filePath, 'utf8');
69
+ const story = yaml.load(fileContents);
70
+
71
+ // Validate story structure
72
+ if (!story.name) {
73
+ throw new Error('Story must have a "name" field');
74
+ }
75
+ if (!story.steps || !Array.isArray(story.steps)) {
76
+ throw new Error('Story must have a "steps" array');
77
+ }
78
+
79
+ this.currentStory = story;
80
+ this.logger.log(`[StoryRunner] Loaded story: ${story.name}`);
81
+ return story;
82
+ } catch (error) {
83
+ throw new Error(`Failed to load story from ${filePath}: ${error.message}`);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Execute a story
89
+ * @param {Object|string} story - Story object or path to YAML file
90
+ * @returns {Promise<{success: boolean, results: Array}>}
91
+ */
92
+ async execute(story) {
93
+ // Load story if path is provided
94
+ if (typeof story === 'string') {
95
+ this.loadStory(story);
96
+ } else {
97
+ this.currentStory = story;
98
+ }
99
+
100
+ const storyName = this.currentStory.name;
101
+ this.logger.log(`[StoryRunner] Starting story: ${storyName}`);
102
+
103
+ // Clear variables from previous story
104
+ this.variables.clear();
105
+
106
+ const startTime = Date.now();
107
+ const storyResults = {
108
+ story: storyName,
109
+ steps: [],
110
+ success: true,
111
+ error: null,
112
+ duration: 0
113
+ };
114
+
115
+ try {
116
+ // Execute setup
117
+ if (this.currentStory.setup) {
118
+ await this.executeSetup(this.currentStory.setup);
119
+ }
120
+
121
+ // Execute steps
122
+ for (let i = 0; i < this.currentStory.steps.length; i++) {
123
+ const step = this.currentStory.steps[i];
124
+ this.logger.log(`[StoryRunner] Step ${i + 1}/${this.currentStory.steps.length}: ${step.name}`);
125
+
126
+ const stepResult = await this.executeStep(step);
127
+ storyResults.steps.push(stepResult);
128
+
129
+ if (!stepResult.success) {
130
+ storyResults.success = false;
131
+ storyResults.error = `Step "${step.name}" failed: ${stepResult.error}`;
132
+ break;
133
+ }
134
+ }
135
+
136
+ // Execute teardown
137
+ if (this.currentStory.teardown) {
138
+ await this.executeTeardown(this.currentStory.teardown);
139
+ }
140
+ } catch (error) {
141
+ storyResults.success = false;
142
+ storyResults.error = error.message;
143
+ this.logger.log(`[StoryRunner] Error: ${error.message}`);
144
+ if (error.stack) {
145
+ this.logger.log(`[StoryRunner] Stack: ${error.stack.split('\n').slice(0, 3).join('\n')}`);
146
+ }
147
+ }
148
+
149
+ storyResults.duration = Date.now() - startTime;
150
+ this.logger.log(`[StoryRunner] Story ${storyName} ${storyResults.success ? 'PASSED' : 'FAILED'} (${storyResults.duration}ms)`);
151
+
152
+ return storyResults;
153
+ }
154
+
155
+ /**
156
+ * Execute story setup
157
+ * @param {Object} setup - Setup configuration
158
+ * @returns {Promise<void>}
159
+ */
160
+ async executeSetup(setup) {
161
+ // Connect RCON backend
162
+ if (setup.server) {
163
+ const rconConfig = {
164
+ host: process.env.RCON_HOST || 'localhost',
165
+ port: parseInt(process.env.RCON_PORT) || 25575,
166
+ password: process.env.RCON_PASSWORD || 'cavarest'
167
+ };
168
+
169
+ this.backends.rcon = await PilafBackendFactory.create('rcon', rconConfig);
170
+ this.logger.log('[StoryRunner] RCON backend connected');
171
+
172
+ // Wait for server to be ready using RCON list command
173
+ await this.waitForServerReady();
174
+ this.logger.log('[StoryRunner] Server is ready');
175
+ }
176
+
177
+ // Create player backends
178
+ if (setup.players && Array.isArray(setup.players)) {
179
+ for (const playerConfig of setup.players) {
180
+ await this.createPlayer(playerConfig);
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Wait for server to be ready using RCON
187
+ * @returns {Promise<void>}
188
+ */
189
+ async waitForServerReady() {
190
+ const startTime = Date.now();
191
+ const timeout = 120000;
192
+ const interval = 3000;
193
+
194
+ while (Date.now() - startTime < timeout) {
195
+ try {
196
+ await this.backends.rcon.send('list');
197
+ return; // Server is ready
198
+ } catch (error) {
199
+ this.logger.log(`[StoryRunner] Server not ready yet, waiting... (${error.message})`);
200
+ await new Promise(resolve => setTimeout(resolve, interval));
201
+ }
202
+ }
203
+
204
+ throw new Error('Server did not become ready within timeout period');
205
+ }
206
+
207
+ /**
208
+ * Execute story teardown
209
+ * @param {Object} teardown - Teardown configuration
210
+ * @returns {Promise<void>}
211
+ */
212
+ async executeTeardown(teardown) {
213
+ // Disconnect all bots
214
+ for (const [username, bot] of this.bots) {
215
+ const backend = this.backends.players.get(username);
216
+ if (backend) {
217
+ await backend.quitBot(bot);
218
+ }
219
+ }
220
+ this.bots.clear();
221
+
222
+ // Disconnect all player backends
223
+ for (const [username, backend] of this.backends.players) {
224
+ await backend.disconnect();
225
+ }
226
+ this.backends.players.clear();
227
+
228
+ // Always disconnect RCON to prevent open handles
229
+ if (this.backends.rcon) {
230
+ // Stop server if requested (send message before disconnect)
231
+ if (teardown.stop_server) {
232
+ await this.backends.rcon.send('say [Pilaf] Server stopping...');
233
+ }
234
+ // Always disconnect to prevent Jest hanging on open handles
235
+ await this.backends.rcon.disconnect();
236
+ this.backends.rcon = null;
237
+ }
238
+
239
+ // Wait for cleanup to complete
240
+ await new Promise(resolve => setTimeout(resolve, 500));
241
+ }
242
+
243
+ /**
244
+ * Create a player bot
245
+ * @param {Object} playerConfig - Player configuration
246
+ * @param {string} playerConfig.name - Player name
247
+ * @param {string} playerConfig.username - Bot username
248
+ * @returns {Promise<void>}
249
+ */
250
+ async createPlayer(playerConfig) {
251
+ const { name, username } = playerConfig;
252
+
253
+ if (!username) {
254
+ throw new Error(`Player "${name}" must have a username`);
255
+ }
256
+
257
+ // Wait a bit before creating new bot to avoid connection conflicts
258
+ await new Promise(resolve => setTimeout(resolve, 200));
259
+
260
+ // Create player backend
261
+ const backend = await PilafBackendFactory.create('mineflayer', {
262
+ host: process.env.MC_HOST || 'localhost',
263
+ port: parseInt(process.env.MC_PORT) || 25565,
264
+ auth: 'offline',
265
+ rconHost: process.env.RCON_HOST || 'localhost',
266
+ rconPort: parseInt(process.env.RCON_PORT) || 25575,
267
+ rconPassword: process.env.RCON_PASSWORD || 'cavarest'
268
+ });
269
+
270
+ // Wait for server to be ready
271
+ await backend.waitForServerReady({ timeout: 60000, interval: 3000 });
272
+
273
+ // Create bot with retry logic
274
+ let bot;
275
+ let lastError;
276
+ for (let attempt = 1; attempt <= 3; attempt++) {
277
+ try {
278
+ bot = await backend.createBot({
279
+ username,
280
+ spawnTimeout: 60000
281
+ });
282
+ break; // Success, exit retry loop
283
+ } catch (error) {
284
+ lastError = error;
285
+ if (attempt < 3) {
286
+ this.logger.log(`[StoryRunner] Bot creation attempt ${attempt} failed: ${error.message}, retrying...`);
287
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
288
+ }
289
+ }
290
+ }
291
+
292
+ if (!bot) {
293
+ throw new Error(`Failed to create bot after 3 attempts: ${lastError.message}`);
294
+ }
295
+
296
+ this.backends.players.set(username, backend);
297
+ this.bots.set(username, bot);
298
+
299
+ this.logger.log(`[StoryRunner] Created player: ${username}`);
300
+ }
301
+
302
+ /**
303
+ * Resolve variable references in step parameters
304
+ * @param {Object} step - Step configuration
305
+ * @returns {Object} Step with resolved variables
306
+ */
307
+ resolveVariables(step) {
308
+ const resolved = { ...step };
309
+
310
+ const resolveValue = (value) => {
311
+ if (typeof value === 'string') {
312
+ // Check for {variableName} pattern
313
+ const match = value.match(/^\{(.+)\}$/);
314
+ if (match) {
315
+ const varName = match[1];
316
+ if (!this.variables.has(varName)) {
317
+ throw new Error(`Variable "${varName}" not found. Available variables: ${[...this.variables.keys()].join(', ')}`);
318
+ }
319
+ return this.variables.get(varName);
320
+ }
321
+ return value;
322
+ } else if (Array.isArray(value)) {
323
+ return value.map(resolveValue);
324
+ } else if (value && typeof value === 'object') {
325
+ const resolvedObj = {};
326
+ for (const [key, val] of Object.entries(value)) {
327
+ resolvedObj[key] = resolveValue(val);
328
+ }
329
+ return resolvedObj;
330
+ }
331
+ return value;
332
+ };
333
+
334
+ // Resolve all values in the step (except action and store_as which should remain as-is)
335
+ for (const [key, value] of Object.entries(resolved)) {
336
+ if (key !== 'action' && key !== 'store_as') {
337
+ resolved[key] = resolveValue(value);
338
+ }
339
+ }
340
+
341
+ return resolved;
342
+ }
343
+
344
+ /**
345
+ * Execute a single step
346
+ * @param {Object} step - Step configuration
347
+ * @returns {Promise<{success: boolean, error: string|null}>}
348
+ */
349
+ async executeStep(step) {
350
+ const { action, store_as } = step;
351
+
352
+ if (!action) {
353
+ return {
354
+ success: false,
355
+ error: 'Step must have an "action" field',
356
+ step: step.name
357
+ };
358
+ }
359
+
360
+ try {
361
+ // Resolve any variable references in step parameters
362
+ const resolvedParams = this.resolveVariables(step);
363
+
364
+ // Execute action and capture result
365
+ const result = await this.executeAction(action, resolvedParams);
366
+
367
+ // Store result if store_as is specified
368
+ if (store_as && result !== undefined) {
369
+ this.variables.set(store_as, result);
370
+ this.logger.log(`[StoryRunner] Stored result as "${store_as}"`);
371
+ }
372
+
373
+ return {
374
+ success: true,
375
+ error: null,
376
+ step: step.name
377
+ };
378
+ } catch (error) {
379
+ return {
380
+ success: false,
381
+ error: error.message,
382
+ step: step.name
383
+ };
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Execute an action
389
+ * @param {string} action - Action type
390
+ * @param {Object} params - Action parameters
391
+ * @returns {Promise<any>} Action result (if any)
392
+ */
393
+ async executeAction(action, params) {
394
+ const handler = this.actionHandlers[action];
395
+
396
+ if (!handler) {
397
+ throw new Error(`Unknown action: ${action}`);
398
+ }
399
+
400
+ return await handler.call(this, params);
401
+ }
402
+
403
+ /**
404
+ * Action handlers
405
+ */
406
+ actionHandlers = {
407
+ /**
408
+ * Execute a command via RCON
409
+ */
410
+ async execute_command(params) {
411
+ const { command } = params;
412
+ if (!command) {
413
+ throw new Error('execute_command requires "command" parameter');
414
+ }
415
+
416
+ this.logger.log(`[StoryRunner] ACTION: RCON ${command}`);
417
+
418
+ const result = await this.backends.rcon.send(command);
419
+ this.logger.log(`[StoryRunner] RESPONSE: ${result.raw}`);
420
+ },
421
+
422
+ /**
423
+ * Send a chat message from a player
424
+ */
425
+ async chat(params) {
426
+ const { player, message } = params;
427
+ if (!player) {
428
+ throw new Error('chat requires "player" parameter');
429
+ }
430
+ if (!message) {
431
+ throw new Error('chat requires "message" parameter');
432
+ }
433
+
434
+ const bot = this.bots.get(player);
435
+ if (!bot) {
436
+ throw new Error(`Player "${player}" not found`);
437
+ }
438
+
439
+ this.logger.log(`[StoryRunner] ACTION: ${player} chat: ${message}`);
440
+ bot.chat(message);
441
+ this.logger.log(`[StoryRunner] RESPONSE: Message sent`);
442
+ },
443
+
444
+ /**
445
+ * Wait for a specified duration
446
+ */
447
+ async wait(params) {
448
+ const { duration = 1 } = params;
449
+ await new Promise(resolve => setTimeout(resolve, duration * 1000));
450
+ this.logger.log(`[StoryRunner] Waited ${duration}s`);
451
+ },
452
+
453
+ /**
454
+ * Assert a condition
455
+ */
456
+ async assert(params) {
457
+ const { condition, expected, actual, contains, not_empty } = params;
458
+
459
+ if (condition === 'equals') {
460
+ if (actual !== expected) {
461
+ throw new Error(`Assertion failed: expected "${expected}" but got "${actual}"`);
462
+ }
463
+ this.logger.log(`[StoryRunner] Assertion passed: ${actual} equals ${expected}`);
464
+ } else if (condition === 'contains') {
465
+ if (!actual || !actual.includes(expected)) {
466
+ throw new Error(`Assertion failed: "${actual}" does not contain "${expected}"`);
467
+ }
468
+ this.logger.log(`[StoryRunner] Assertion passed: "${actual}" contains "${expected}"`);
469
+ } else if (condition === 'not_empty') {
470
+ const value = not_empty || actual;
471
+ const isEmpty = Array.isArray(value) ? value.length === 0 : !value;
472
+ if (isEmpty) {
473
+ throw new Error(`Assertion failed: value is empty`);
474
+ }
475
+ this.logger.log(`[StoryRunner] Assertion passed: value is not empty`);
476
+ } else if (condition === 'entity_exists') {
477
+ // expected: entity name to check for
478
+ // actual: array of entities from get_entities
479
+ const entity = actual.find(e =>
480
+ e.name === expected ||
481
+ e.customName === expected ||
482
+ e.displayName === expected ||
483
+ e.customName?.text === expected
484
+ );
485
+ if (!entity) {
486
+ // Debug: log available entity names
487
+ const availableNames = actual.map(e => e.name || e.displayName || e.customName).slice(0, 10).join(', ');
488
+ this.logger.log(`[StoryRunner] Available entities (first 10): ${availableNames}`);
489
+ throw new Error(`Assertion failed: entity "${expected}" not found`);
490
+ }
491
+ this.logger.log(`[StoryRunner] Assertion passed: entity "${expected}" exists`);
492
+ } else if (condition === 'entity_not_exists') {
493
+ // expected: entity name to check for
494
+ // actual: array of entities from get_entities
495
+ const entity = actual.find(e =>
496
+ e.name === expected ||
497
+ e.customName === expected ||
498
+ e.displayName === expected ||
499
+ e.customName?.text === expected
500
+ );
501
+ if (entity) {
502
+ throw new Error(`Assertion failed: entity "${expected}" still exists`);
503
+ }
504
+ this.logger.log(`[StoryRunner] Assertion passed: entity "${expected}" does not exist`);
505
+ } else if (condition === 'has_item') {
506
+ // expected: item name
507
+ // actual: inventory from get_player_inventory
508
+ const hasItem = actual.items.some(item => item && item.name === expected);
509
+ if (!hasItem) {
510
+ throw new Error(`Assertion failed: player does not have item "${expected}"`);
511
+ }
512
+ this.logger.log(`[StoryRunner] Assertion passed: player has item "${expected}"`);
513
+ } else if (condition === 'does_not_have_item') {
514
+ // expected: item name
515
+ // actual: inventory from get_player_inventory
516
+ const hasItem = actual.items.some(item => item && item.name === expected);
517
+ if (hasItem) {
518
+ throw new Error(`Assertion failed: player still has item "${expected}"`);
519
+ }
520
+ this.logger.log(`[StoryRunner] Assertion passed: player does not have item "${expected}"`);
521
+ } else if (condition === 'greater_than') {
522
+ const actualNum = parseFloat(actual);
523
+ const expectedNum = parseFloat(expected);
524
+ if (isNaN(actualNum) || isNaN(expectedNum)) {
525
+ throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
526
+ }
527
+ if (actualNum <= expectedNum) {
528
+ throw new Error(`Assertion failed: ${actualNum} is not greater than ${expectedNum}`);
529
+ }
530
+ this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} > ${expectedNum}`);
531
+ } else if (condition === 'less_than') {
532
+ const actualNum = parseFloat(actual);
533
+ const expectedNum = parseFloat(expected);
534
+ if (isNaN(actualNum) || isNaN(expectedNum)) {
535
+ throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
536
+ }
537
+ if (actualNum >= expectedNum) {
538
+ throw new Error(`Assertion failed: ${actualNum} is not less than ${expectedNum}`);
539
+ }
540
+ this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} < ${expectedNum}`);
541
+ } else if (condition === 'greater_than_or_equals') {
542
+ const actualNum = parseFloat(actual);
543
+ const expectedNum = parseFloat(expected);
544
+ if (isNaN(actualNum) || isNaN(expectedNum)) {
545
+ throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
546
+ }
547
+ if (actualNum < expectedNum) {
548
+ throw new Error(`Assertion failed: ${actualNum} is not greater than or equal to ${expectedNum}`);
549
+ }
550
+ this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} >= ${expectedNum}`);
551
+ } else if (condition === 'less_than_or_equals') {
552
+ const actualNum = parseFloat(actual);
553
+ const expectedNum = parseFloat(expected);
554
+ if (isNaN(actualNum) || isNaN(expectedNum)) {
555
+ throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
556
+ }
557
+ if (actualNum > expectedNum) {
558
+ throw new Error(`Assertion failed: ${actualNum} is not less than or equal to ${expectedNum}`);
559
+ }
560
+ this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} <= ${expectedNum}`);
561
+ } else {
562
+ throw new Error(`Unknown assertion condition: ${condition}`);
563
+ }
564
+ },
565
+
566
+ /**
567
+ * REAL logout - disconnect the TCP connection
568
+ * This tests actual server-side session persistence
569
+ */
570
+ async logout(params) {
571
+ const { player } = params;
572
+ if (!player) {
573
+ throw new Error('logout requires "player" parameter');
574
+ }
575
+
576
+ const bot = this.bots.get(player);
577
+ const backend = this.backends.players.get(player);
578
+
579
+ if (!bot || !backend) {
580
+ throw new Error(`Player "${player}" not found`);
581
+ }
582
+
583
+ // REAL TCP disconnect - quit the bot
584
+ const result = await backend.quitBot(bot);
585
+ this.bots.delete(player);
586
+
587
+ if (!result.success) {
588
+ throw new Error(`Logout failed: ${result.reason}`);
589
+ }
590
+
591
+ this.logger.log(`[StoryRunner] ${player} logged out (REAL TCP disconnect)`);
592
+ },
593
+
594
+ /**
595
+ * REAL login - create new TCP connection
596
+ * This tests actual server-side session persistence
597
+ */
598
+ async login(params) {
599
+ const { player } = params;
600
+ if (!player) {
601
+ throw new Error('login requires "player" parameter');
602
+ }
603
+
604
+ // Check if player was previously logged in (has backend config)
605
+ const oldBackend = this.backends.players.get(player);
606
+ if (!oldBackend) {
607
+ throw new Error(`Player "${player}" was never logged in`);
608
+ }
609
+
610
+ // Check if bot already exists
611
+ if (this.bots.get(player)) {
612
+ throw new Error(`Player "${player}" is already logged in`);
613
+ }
614
+
615
+ // Get the connection config from the existing backend
616
+ const host = oldBackend.host;
617
+ const port = oldBackend.port;
618
+ const auth = oldBackend.auth;
619
+
620
+ // CRITICAL: Disconnect the OLD backend to prevent resource leak
621
+ // The bot was already quit during logout, so we just need to clear the backend
622
+ // We don't need to call disconnect() since the bot pool is already empty
623
+ try {
624
+ // Just clear any references - bot was already quit during logout
625
+ oldBackend._botPool?.clear();
626
+ } catch (error) {
627
+ // Ignore cleanup errors
628
+ }
629
+
630
+ // CRITICAL: Create a FRESH backend instance for reconnection
631
+ // This avoids any residual state issues (like GitHub issue #865)
632
+ const freshBackend = await PilafBackendFactory.create('mineflayer', {
633
+ host,
634
+ port,
635
+ auth,
636
+ rconHost: process.env.RCON_HOST || 'localhost',
637
+ rconPort: parseInt(process.env.RCON_PORT) || 25575,
638
+ rconPassword: process.env.RCON_PASSWORD || 'cavarest'
639
+ });
640
+
641
+ // Wait for server to be ready (avoid connection throttling)
642
+ await freshBackend.waitForServerReady({ timeout: 60000, interval: 2000 });
643
+
644
+ // Create new bot (REAL TCP connection)
645
+ const bot = await freshBackend.createBot({
646
+ username: player,
647
+ spawnTimeout: 60000
648
+ });
649
+
650
+ // Update backend reference - old backend reference will be garbage collected
651
+ this.backends.players.set(player, freshBackend);
652
+ this.bots.set(player, bot);
653
+
654
+ this.logger.log(`[StoryRunner] ${player} logged in (REAL TCP connection)`);
655
+ },
656
+
657
+ /**
658
+ * Kill a player (real game event)
659
+ */
660
+ async kill(params) {
661
+ const { player } = params;
662
+ if (!player) {
663
+ throw new Error('kill requires "player" parameter');
664
+ }
665
+
666
+ // Use /kill command via RCON
667
+ await this.backends.rcon.send(`kill ${player}`);
668
+ this.logger.log(`[StoryRunner] ${player} killed`);
669
+ },
670
+
671
+ /**
672
+ * Wait for player respawn (real game event)
673
+ */
674
+ async respawn(params) {
675
+ const { player } = params;
676
+ if (!player) {
677
+ throw new Error('respawn requires "player" parameter');
678
+ }
679
+
680
+ const bot = this.bots.get(player);
681
+ if (!bot) {
682
+ throw new Error(`Player "${player}" not found`);
683
+ }
684
+
685
+ // Wait for respawn event
686
+ await new Promise((resolve, reject) => {
687
+ const timeout = setTimeout(() => {
688
+ bot.removeListener('respawn', respawnHandler);
689
+ reject(new Error('Respawn timeout'));
690
+ }, 10000);
691
+
692
+ const respawnHandler = () => {
693
+ clearTimeout(timeout);
694
+ resolve();
695
+ };
696
+
697
+ bot.once('respawn', respawnHandler);
698
+ });
699
+
700
+ this.logger.log(`[StoryRunner] ${player} respawned`);
701
+ },
702
+
703
+ /**
704
+ * Move player forward
705
+ */
706
+ async move_forward(params) {
707
+ const { player, duration = 1 } = params;
708
+ if (!player) {
709
+ throw new Error('move_forward requires "player" parameter');
710
+ }
711
+
712
+ const bot = this.bots.get(player);
713
+ if (!bot) {
714
+ throw new Error(`Player "${player}" not found`);
715
+ }
716
+
717
+ bot.setControlState('forward', true);
718
+ await new Promise(resolve => setTimeout(resolve, duration * 1000));
719
+ bot.setControlState('forward', false);
720
+
721
+ this.logger.log(`[StoryRunner] ${player} moved forward for ${duration}s`);
722
+ },
723
+
724
+ /**
725
+ * Get entities from player's perspective
726
+ * Returns array of entities visible to the player
727
+ */
728
+ async get_entities(params) {
729
+ const { player } = params;
730
+ if (!player) {
731
+ throw new Error('get_entities requires "player" parameter');
732
+ }
733
+
734
+ const backend = this.backends.players.get(player);
735
+ if (!backend) {
736
+ throw new Error(`Player "${player}" backend not found`);
737
+ }
738
+
739
+ this.logger.log(`[StoryRunner] ACTION: getEntities() for ${player}`);
740
+
741
+ const entities = await backend.getEntities();
742
+ this.logger.log(`[StoryRunner] RESPONSE: Found ${entities.length} entities: ${entities.slice(0, 5).map(e => e.name || e.customName || e.id).join(', ')}${entities.length > 5 ? '...' : ''}`);
743
+
744
+ // Return entities for use in assertions/steps
745
+ return entities;
746
+ },
747
+
748
+ /**
749
+ * Get player inventory
750
+ * Returns player's inventory contents
751
+ */
752
+ async get_player_inventory(params) {
753
+ const { player } = params;
754
+ if (!player) {
755
+ throw new Error('get_player_inventory requires "player" parameter');
756
+ }
757
+
758
+ const backend = this.backends.players.get(player);
759
+ if (!backend) {
760
+ throw new Error(`Player "${player}" backend not found`);
761
+ }
762
+
763
+ this.logger.log(`[StoryRunner] ACTION: getPlayerInventory() for ${player}`);
764
+
765
+ const inventory = await backend.getPlayerInventory(player);
766
+ const itemCount = inventory.items?.length || 0;
767
+ const itemSummary = itemCount > 0
768
+ ? inventory.items.slice(0, 5).map(i => i.type || i.name).join(', ') + (itemCount > 5 ? '...' : '')
769
+ : 'empty';
770
+ this.logger.log(`[StoryRunner] RESPONSE: ${itemCount} items (${itemSummary})`);
771
+
772
+ // Return inventory for use in assertions/steps
773
+ return inventory;
774
+ },
775
+
776
+ /**
777
+ * Execute command as player (not just chat)
778
+ * Runs player commands like /ability, /plugin commands, etc.
779
+ */
780
+ async execute_player_command(params) {
781
+ const { player, command } = params;
782
+ if (!player) {
783
+ throw new Error('execute_player_command requires "player" parameter');
784
+ }
785
+ if (!command) {
786
+ throw new Error('execute_player_command requires "command" parameter');
787
+ }
788
+
789
+ const bot = this.bots.get(player);
790
+ if (!bot) {
791
+ throw new Error(`Player "${player}" not found`);
792
+ }
793
+
794
+ this.logger.log(`[StoryRunner] ACTION: ${player} execute command: ${command}`);
795
+ bot.chat(command);
796
+ this.logger.log(`[StoryRunner] RESPONSE: Command sent`);
797
+ },
798
+
799
+ /**
800
+ * Get player location
801
+ * Returns player's position (x, y, z)
802
+ */
803
+ async get_player_location(params) {
804
+ const { player } = params;
805
+ if (!player) {
806
+ throw new Error('get_player_location requires "player" parameter');
807
+ }
808
+
809
+ this.logger.log(`[StoryRunner] ACTION: ${player} getLocation()`);
810
+
811
+ const bot = this.bots.get(player);
812
+ if (!bot) {
813
+ throw new Error(`Player "${player}" not found`);
814
+ }
815
+
816
+ const position = {
817
+ x: bot.entity.position.x,
818
+ y: bot.entity.position.y,
819
+ z: bot.entity.position.z,
820
+ yaw: bot.entity.yaw,
821
+ pitch: bot.entity.pitch
822
+ };
823
+
824
+ this.logger.log(`[StoryRunner] RESPONSE: x=${position.x.toFixed(2)}, y=${position.y.toFixed(2)}, z=${position.z.toFixed(2)}`);
825
+ return position;
826
+ },
827
+
828
+ /**
829
+ * Get entity location
830
+ * Returns specific entity's position from the entity list
831
+ */
832
+ async get_entity_location(params) {
833
+ const { player, entity_name } = params;
834
+ if (!player) {
835
+ throw new Error('get_entity_location requires "player" parameter');
836
+ }
837
+ if (!entity_name) {
838
+ throw new Error('get_entity_location requires "entity_name" parameter');
839
+ }
840
+
841
+ const backend = this.backends.players.get(player);
842
+ if (!backend) {
843
+ throw new Error(`Player "${player}" backend not found`);
844
+ }
845
+
846
+ const entities = await backend.getEntities();
847
+ const entity = entities.find(e =>
848
+ e.name === entity_name ||
849
+ e.customName === entity_name ||
850
+ e.customName?.text === entity_name
851
+ );
852
+
853
+ if (!entity) {
854
+ throw new Error(`Entity "${entity_name}" not found`);
855
+ }
856
+
857
+ const position = {
858
+ x: entity.position.x,
859
+ y: entity.position.y,
860
+ z: entity.position.z
861
+ };
862
+
863
+ this.logger.log(`[StoryRunner] Entity "${entity_name}" location: ${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`);
864
+ return position;
865
+ },
866
+
867
+ /**
868
+ * Calculate distance between two positions
869
+ */
870
+ async calculate_distance(params) {
871
+ const { from, to } = params;
872
+ if (!from || !to) {
873
+ throw new Error('calculate_distance requires "from" and "to" positions');
874
+ }
875
+
876
+ const dx = to.x - from.x;
877
+ const dy = to.y - from.y;
878
+ const dz = to.z - from.z;
879
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
880
+
881
+ this.logger.log(`[StoryRunner] Distance calculated: ${distance.toFixed(2)} blocks`);
882
+ return distance;
883
+ },
884
+
885
+ /**
886
+ * Stop the server
887
+ */
888
+ async stop_server(params) {
889
+ await this.backends.rcon.send('say [Pilaf] Server stopping...');
890
+ await this.backends.rcon.disconnect();
891
+ this.backends.rcon = null;
892
+ this.logger.log('[StoryRunner] Server stopped');
893
+ }
894
+ };
895
+ }
896
+
897
+ module.exports = { StoryRunner };
@@ -0,0 +1,54 @@
1
+ // packages/framework/lib/helpers/events.js
2
+
3
+ async function waitForEvents(bot, eventName, count, timeout = 5000) {
4
+ const events = [];
5
+ const handler = (data) => events.push({ type: eventName, data });
6
+
7
+ bot.on(eventName, handler);
8
+
9
+ return new Promise((resolve, reject) => {
10
+ const timer = setTimeout(() => {
11
+ bot.removeListener(eventName, handler);
12
+ if (events.length >= count) {
13
+ resolve(events);
14
+ } else {
15
+ reject(new Error(`Timeout waiting for ${count} ${eventName} events (got ${events.length})`));
16
+ }
17
+ }, timeout);
18
+
19
+ const checkComplete = () => {
20
+ if (events.length >= count) {
21
+ clearTimeout(timer);
22
+ bot.removeListener(eventName, handler);
23
+ resolve(events);
24
+ }
25
+ };
26
+
27
+ bot.on(eventName, checkComplete);
28
+ });
29
+ }
30
+
31
+ function captureEvents(bot, eventNames) {
32
+ const captured = [];
33
+ const handlers = {};
34
+
35
+ eventNames.forEach(eventName => {
36
+ const handler = (data) => captured.push({ type: eventName, data });
37
+ handlers[eventName] = handler;
38
+ bot.on(eventName, handler);
39
+ });
40
+
41
+ return {
42
+ events: captured,
43
+ release: () => {
44
+ Object.entries(handlers).forEach(([eventName, handler]) => {
45
+ bot.removeListener(eventName, handler);
46
+ });
47
+ }
48
+ };
49
+ }
50
+
51
+ module.exports = {
52
+ waitForEvents,
53
+ captureEvents
54
+ };
@@ -0,0 +1,43 @@
1
+ // packages/framework/lib/helpers/state.js
2
+
3
+ function captureState(data) {
4
+ return {
5
+ timestamp: Date.now(),
6
+ data: JSON.parse(JSON.stringify(data)) // Deep clone
7
+ };
8
+ }
9
+
10
+ function compareStates(before, after, description = 'State comparison') {
11
+ const changes = [];
12
+
13
+ const beforeKeys = Object.keys(before.data || {});
14
+ const afterKeys = Object.keys(after.data || {});
15
+
16
+ const allKeys = new Set([...beforeKeys, ...afterKeys]);
17
+
18
+ allKeys.forEach(key => {
19
+ const beforeVal = before.data[key];
20
+ const afterVal = after.data[key];
21
+
22
+ if (JSON.stringify(beforeVal) !== JSON.stringify(afterVal)) {
23
+ changes.push({
24
+ key,
25
+ before: beforeVal,
26
+ after: afterVal
27
+ });
28
+ }
29
+ });
30
+
31
+ return {
32
+ description,
33
+ before,
34
+ after,
35
+ changes,
36
+ hasChanges: changes.length > 0
37
+ };
38
+ }
39
+
40
+ module.exports = {
41
+ captureState,
42
+ compareStates
43
+ };
package/lib/index.js ADDED
@@ -0,0 +1,50 @@
1
+ // packages/framework/lib/index.js
2
+ const { PilafReporter } = require('./reporters/pilaf-reporter');
3
+ const { StoryRunner } = require('./StoryRunner');
4
+ const { waitForEvents, captureEvents } = require('./helpers/events');
5
+ const { captureState, compareStates } = require('./helpers/state');
6
+ const { toHaveReceivedLightningStrikes } = require('./matchers/game-matchers');
7
+
8
+ const { PilafBackendFactory } = require('@pilaf/backends');
9
+
10
+ // Backend helpers
11
+ const rcon = {
12
+ connect: async (config) => PilafBackendFactory.create('rcon', config)
13
+ };
14
+
15
+ const mineflayer = {
16
+ createBot: async (options) => {
17
+ const backend = await PilafBackendFactory.create('mineflayer', options);
18
+ return backend.createBot(options);
19
+ }
20
+ };
21
+
22
+ // Main pilaf API
23
+ const pilaf = {
24
+ waitForEvents,
25
+ captureEvents,
26
+ captureState,
27
+ compareStates
28
+ };
29
+
30
+ module.exports = {
31
+ // Reporter
32
+ PilafReporter,
33
+
34
+ // Story Runner
35
+ StoryRunner,
36
+
37
+ // Main API
38
+ pilaf,
39
+ rcon,
40
+ mineflayer,
41
+
42
+ // Helpers
43
+ waitForEvents,
44
+ captureEvents,
45
+ captureState,
46
+ compareStates,
47
+
48
+ // Matchers
49
+ toHaveReceivedLightningStrikes
50
+ };
@@ -0,0 +1,16 @@
1
+ // packages/framework/lib/matchers/game-matchers.js
2
+
3
+ function toHaveReceivedLightningStrikes(received, count) {
4
+ const strikes = received.filter(e =>
5
+ e.type === 'entityHurt' && e.data?.damageSource?.type === 'lightning'
6
+ );
7
+
8
+ return {
9
+ pass: strikes.length === count,
10
+ message: () => `expected ${count} lightning strikes, got ${strikes.length}`
11
+ };
12
+ }
13
+
14
+ module.exports = {
15
+ toHaveReceivedLightningStrikes
16
+ };
@@ -0,0 +1,208 @@
1
+ // packages/framework/lib/reporters/pilaf-reporter.js
2
+ const { ReportGenerator } = require('@pilaf/reporting');
3
+ const path = require('path');
4
+
5
+ class PilafReporter {
6
+ constructor(globalConfig, options = {}) {
7
+ this._globalConfig = globalConfig;
8
+ this._options = options;
9
+ this._results = {
10
+ suiteName: options.suiteName || 'Pilaf Tests',
11
+ durationMs: 0,
12
+ passed: true,
13
+ stories: [],
14
+ consoleLogs: []
15
+ };
16
+ this._runStartTime = 0;
17
+ // Track console logs per test file to avoid mixing
18
+ this._logsPerTest = new Map();
19
+ }
20
+
21
+ onRunStart(aggregatedResults, options) {
22
+ this._runStartTime = Date.now();
23
+ this._logsPerTest.clear();
24
+ }
25
+
26
+ onTestResult(test, testResult, aggregatedResult) {
27
+ const testPath = test.path;
28
+
29
+ // Initialize logs array for this test if not exists
30
+ if (!this._logsPerTest.has(testPath)) {
31
+ this._logsPerTest.set(testPath, []);
32
+ }
33
+
34
+ // Capture console logs from this test result
35
+ if (testResult.console && testResult.console.length > 0) {
36
+ for (const logEntry of testResult.console) {
37
+ // Jest console entries have: { message: string, origin: string, type: string }
38
+ const message = logEntry.message || logEntry;
39
+ this._logsPerTest.get(testPath).push({
40
+ timestamp: Date.now(),
41
+ message: typeof message === 'string' ? message : JSON.stringify(message)
42
+ });
43
+ }
44
+ }
45
+
46
+ // Stories are parsed from console logs in onRunComplete
47
+ }
48
+
49
+ onRunComplete(contexts, aggregatedResults) {
50
+ this._results.durationMs = Date.now() - this._runStartTime;
51
+
52
+ // Parse stories from each test file's logs separately
53
+ const allStories = [];
54
+ const allConsoleLogs = [];
55
+
56
+ for (const [testPath, logs] of this._logsPerTest.entries()) {
57
+ // Parse stories from this test file's logs, including the logs for display
58
+ const stories = this._parseStoriesFromLogs(logs, testPath);
59
+ allStories.push(...stories);
60
+
61
+ // Also collect all console logs for the full log view
62
+ allConsoleLogs.push(...logs);
63
+ }
64
+
65
+ this._results.stories = allStories;
66
+ this._results.consoleLogs = allConsoleLogs;
67
+
68
+ if (this._options.outputPath) {
69
+ const generator = new ReportGenerator();
70
+ generator.generate(this._results, this._options.outputPath);
71
+ console.log(`\n[Pilaf] Report generated: ${this._options.outputPath}`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parse console logs to build story structure with steps and details
77
+ * @param {Array} logs - Console logs for a single test file
78
+ * @param {string} testPath - Path to the test file
79
+ * @returns {Array} Array of story objects
80
+ */
81
+ _parseStoriesFromLogs(logs, testPath) {
82
+ const stories = [];
83
+ const storyPattern = /\[StoryRunner\] Starting story:\s*(.+)/;
84
+ const stepPattern = /\[StoryRunner\] Step (\d+)\/(\d+):\s*(.+)/;
85
+ const storyCompletePattern = /\[StoryRunner\] Story (.+) (PASSED|FAILED)/;
86
+
87
+ let currentStory = null;
88
+ let currentStep = null;
89
+ let storyLogs = []; // Track logs for the current story
90
+
91
+ for (const log of logs) {
92
+ // All logs go to the current story's console logs
93
+ if (currentStory) {
94
+ storyLogs.push(log);
95
+ }
96
+
97
+ // Check for story start
98
+ const storyMatch = log.message.match(storyPattern);
99
+ if (storyMatch) {
100
+ currentStory = {
101
+ name: storyMatch[1].trim(),
102
+ file: testPath,
103
+ passedCount: 0,
104
+ failedCount: 0,
105
+ steps: [],
106
+ consoleLogs: [] // Will be populated with logs for this story
107
+ };
108
+ stories.push(currentStory);
109
+ currentStep = null;
110
+ storyLogs = []; // Start collecting logs for this story
111
+ continue;
112
+ }
113
+
114
+ // Check for story completion
115
+ const completeMatch = log.message.match(storyCompletePattern);
116
+ if (completeMatch) {
117
+ if (completeMatch[2] === 'PASSED') {
118
+ currentStory.passedCount = currentStory.steps.length;
119
+ currentStory.failedCount = 0;
120
+ } else {
121
+ currentStory.passedCount = 0;
122
+ currentStory.failedCount = currentStory.steps.length;
123
+ }
124
+ // Store the collected logs with this story
125
+ currentStory.consoleLogs = [...storyLogs];
126
+ this._results.passed = this._results.passed && currentStory.passedCount > 0;
127
+ currentStory = null;
128
+ currentStep = null;
129
+ storyLogs = [];
130
+ continue;
131
+ }
132
+
133
+ // Only process steps if we have a current story
134
+ if (!currentStory) continue;
135
+
136
+ // Check for step start
137
+ const stepMatch = log.message.match(stepPattern);
138
+ if (stepMatch) {
139
+ const stepNum = parseInt(stepMatch[1], 10);
140
+ const stepName = stepMatch[3].trim();
141
+
142
+ // Determine executor from step name
143
+ let executor = 'ASSERT';
144
+ if (stepName.includes('[RCON]')) {
145
+ executor = 'RCON';
146
+ } else if (stepName.includes('[player:')) {
147
+ const playerMatch = stepName.match(/\[player:\s*(\w+)\]/);
148
+ if (playerMatch) {
149
+ executor = `player: ${playerMatch[1]}`;
150
+ }
151
+ }
152
+
153
+ // Only add step once (in case of duplicate logs)
154
+ if (!currentStory.steps.find(s => s.name === stepName)) {
155
+ currentStep = {
156
+ name: stepName,
157
+ passed: true,
158
+ durationMs: 0,
159
+ executionContext: { executor },
160
+ details: []
161
+ };
162
+ currentStory.steps.push(currentStep);
163
+ } else {
164
+ // If step already exists, get reference to it
165
+ currentStep = currentStory.steps.find(s => s.name === stepName);
166
+ }
167
+ continue;
168
+ }
169
+
170
+ // If we have a current step, add non-step, non-story logs as details
171
+ if (currentStep && !stepMatch && !storyMatch && !completeMatch) {
172
+ // Filter out logs that are part of step/story management
173
+ if (!log.message.includes('[StoryRunner] Step') &&
174
+ !log.message.includes('[StoryRunner] Starting story') &&
175
+ !log.message.includes('[StoryRunner] Story') &&
176
+ log.message.includes('[StoryRunner]')) {
177
+ const detailMessage = log.message.replace('[StoryRunner] ', '').trim();
178
+
179
+ // Check if this is an ACTION or RESPONSE line
180
+ if (detailMessage.startsWith('ACTION:')) {
181
+ currentStep.details.push({
182
+ timestamp: log.timestamp,
183
+ type: 'action',
184
+ message: detailMessage.replace('ACTION:', '').trim()
185
+ });
186
+ } else if (detailMessage.startsWith('RESPONSE:')) {
187
+ currentStep.details.push({
188
+ timestamp: log.timestamp,
189
+ type: 'response',
190
+ message: detailMessage.replace('RESPONSE:', '').trim()
191
+ });
192
+ } else {
193
+ // Other detail types (stored results, assertions, etc.)
194
+ currentStep.details.push({
195
+ timestamp: log.timestamp,
196
+ type: 'other',
197
+ message: detailMessage
198
+ });
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return stories;
205
+ }
206
+ }
207
+
208
+ module.exports = PilafReporter;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@pilaf/framework",
3
+ "version": "1.0.0",
4
+ "main": "lib/index.js",
5
+ "files": [
6
+ "lib/*.js",
7
+ "lib/**/*.js",
8
+ "!lib/**/*.spec.js",
9
+ "!lib/**/*.test.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "jest": "^29.7.0",
18
+ "js-yaml": "^4.1.0",
19
+ "@pilaf/backends": "1.0.0",
20
+ "@pilaf/reporting": "1.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@jest/globals": "^29.7.0"
24
+ },
25
+ "peerDependencies": {
26
+ "jest": ">=29.0.0"
27
+ }
28
+ }