Skip to content

Commit 9095d1f

Browse files
authored
Add Gameplay-Utilities library with EventThenable (#97)
* Event promise and thenable added to new gameplay utility library Some changes to the readme, advances to the tests Tests and linting done Change files Removing change log files, not sure if correct or not Removed last references to math * PR Requests, removing thenable, event promise becomes private, next event only allows minecraft signals * Working promise * Not sure what is wrong * Something works * Tests passing * Fix build * PR requests
1 parent f784280 commit 9095d1f

15 files changed

+497
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "major",
3+
"comment": "EventPromise and nextEvent() added to gameplay-utilities",
4+
"packageName": "@minecraft/gameplay-utilities",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Minecraft Gameplay Utilities
2+
3+
A set of utilities and functions for common gameplay operations. Major pieces are covered below.
4+
5+
## nextEvent() and EventPromise
6+
7+
`nextEvent()` is a function which takes a Minecraft event signal and wraps a promise around the next event being raised. The function returns an `EventPromise` object which is a promise type. When the event is raised, the promise will resolve with the event data, and unsubscribe from the event's signal. The `EventPromise` type also adds a `cancel()` function which will unsubscribe from the event's signal, and fulfill the promise with `undefined`.
8+
9+
### Can be awaited to receive the event
10+
11+
```ts
12+
const event = await nextEvent(world.afterEvents.buttonPush);
13+
```
14+
15+
### Can be used like a promise
16+
17+
```ts
18+
await nextEvent(world.afterEvents.leverAction).then(
19+
(event) => {
20+
// do something with the event
21+
}).finally(() => {
22+
// something else to do
23+
});
24+
```
25+
26+
### Optionally provide filters for the signal and use helper function
27+
28+
```ts
29+
const creeperDeathEvent = await nextEvent(world.afterEvents.entityDie, { entityTypes: ['minecraft:creeper'] });
30+
```
31+
32+
## How to use @minecraft/gameplay-utilities in your project
33+
34+
@minecraft/gameplay-utilities is published to NPM and follows standard semver semantics. To use it in your project,
35+
36+
- Download `@minecraft/gameplay-utilities` from NPM by doing `npm install @minecraft/gameplay-utilities` within your scripts pack. By using `@minecraft/gameplay-utilities`, you will need to do some sort of bundling to merge the library into your packs code. We recommend using [esbuild](https://esbuild.github.io/getting-started/#your-first-bundle) for simplicity.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { EntityEventOptions, WeatherChangeAfterEvent, EntityRemoveAfterEvent } from '@minecraft/server';
5+
6+
export enum WeatherType {
7+
Clear = 'Clear',
8+
Rain = 'Rain',
9+
Thunder = 'Thunder',
10+
}
11+
export function createWeatherEvent(dim: string): WeatherChangeAfterEvent {
12+
return { dimension: dim, newWeather: WeatherType.Clear, previousWeather: WeatherType.Rain };
13+
}
14+
export type WeatherChangeAfterEventCallback = (event: WeatherChangeAfterEvent) => void;
15+
export const MockWeatherChangeEventHandlers: WeatherChangeAfterEventCallback[] = [];
16+
17+
export type EntityRemoveAfterEventCallback = (event: EntityRemoveAfterEvent) => void;
18+
export type MockEntityRemoveAfterEventCallbackData = {
19+
callback: EntityRemoveAfterEventCallback;
20+
options?: EntityEventOptions;
21+
};
22+
export const MockEntityRemoveEventHandlers: MockEntityRemoveAfterEventCallbackData[] = [];
23+
24+
export function clearMockState() {
25+
MockWeatherChangeEventHandlers.length = 0;
26+
MockEntityRemoveEventHandlers.length = 0;
27+
}
28+
29+
export const createMockServerBindings = () => {
30+
return {
31+
world: {
32+
afterEvents: {
33+
weatherChange: {
34+
subscribe: (callback: WeatherChangeAfterEventCallback) => {
35+
MockWeatherChangeEventHandlers.push(callback);
36+
return callback;
37+
},
38+
unsubscribe: (callback: WeatherChangeAfterEventCallback) => {
39+
const index = MockWeatherChangeEventHandlers.indexOf(callback);
40+
if (index !== -1) {
41+
MockWeatherChangeEventHandlers.splice(index, 1);
42+
}
43+
},
44+
},
45+
entityRemove: {
46+
subscribe: (callback: EntityRemoveAfterEventCallback, options?: EntityEventOptions) => {
47+
MockEntityRemoveEventHandlers.push({ callback, options });
48+
return callback;
49+
},
50+
unsubscribe: (callback: EntityRemoveAfterEventCallback) => {
51+
const index = MockEntityRemoveEventHandlers.findIndex(value => value.callback === callback);
52+
if (index !== -1) {
53+
MockEntityRemoveEventHandlers.splice(index, 1);
54+
}
55+
},
56+
},
57+
},
58+
},
59+
};
60+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
3+
*/
4+
{
5+
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
6+
"extends": "@minecraft/api-extractor-base/api-extractor-base.json",
7+
"mainEntryPointFilePath": "<projectFolder>/temp/types/src/index.d.ts"
8+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
## API Report File for "@minecraft/gameplay-utilities"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import { system } from '@minecraft/server';
8+
import { world } from '@minecraft/server';
9+
10+
// @public
11+
export interface EventPromise<T> extends Promise<T | undefined> {
12+
cancel(): void;
13+
catch<TReject = never>(onrejected?: ((reason: unknown) => TReject | PromiseLike<TReject>) | null): Promise<T | undefined | TReject>;
14+
finally(onfinally?: (() => void) | null): Promise<T | undefined>;
15+
then<TFulfill = T | undefined, TReject = never>(onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike<TFulfill>) | null, onrejected?: ((reason: unknown) => TReject | PromiseLike<TReject>) | null): Promise<TFulfill | TReject>;
16+
}
17+
18+
// @public
19+
export type FirstArg<T> = T extends (arg: infer U) => void ? U : never;
20+
21+
// @public
22+
export type MinecraftAfterEventSignals = (typeof world.afterEvents)[keyof typeof world.afterEvents] | (typeof system.afterEvents)[keyof typeof system.afterEvents];
23+
24+
// @public
25+
export function nextEvent<U>(signal: MinecraftAfterEventSignals, filter?: U): EventPromise<FirstArg<FirstArg<typeof signal.subscribe>>>;
26+
27+
// (No @packageDocumentation comment for this package)
28+
29+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import configMinecraftScripting from 'eslint-config-minecraft-scripting';
5+
6+
export default [...configMinecraftScripting];
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { execSync } from 'child_process';
5+
import { argv, series, task, tscTask } from 'just-scripts';
6+
import {
7+
DEFAULT_CLEAN_DIRECTORIES,
8+
apiExtractorTask,
9+
cleanTask,
10+
coreLint,
11+
publishReleaseTask,
12+
vitestTask,
13+
} from '@minecraft/core-build-tasks';
14+
import { copyFileSync, readFileSync } from 'node:fs';
15+
import { resolve } from 'node:path';
16+
17+
const isOnlyBuild = argv()._.findIndex(arg => arg === 'test') === -1;
18+
19+
// Lint
20+
task('lint', coreLint(['src/**/*.ts'], argv().fix));
21+
22+
// Build
23+
task('typescript', tscTask());
24+
task('api-extractor-local', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */));
25+
task('bundle', () => {
26+
execSync(
27+
'npx esbuild ./lib/src/index.js --bundle --outfile=dist/minecraft-gameplay-utilities.js --format=esm --sourcemap --external:@minecraft/server'
28+
);
29+
// Copy over type definitions and rename
30+
const officialTypes = JSON.parse(readFileSync('./package.json', 'utf-8'))['types'];
31+
if (!officialTypes) {
32+
// Has the package.json been restructured?
33+
throw new Error('The package.json file does not contain a "types" field. Unable to copy types to bundle.');
34+
}
35+
const officialTypesPath = resolve(officialTypes);
36+
copyFileSync(officialTypesPath, './dist/minecraft-gameplay-utilities.d.ts');
37+
});
38+
task('build', series('typescript', 'api-extractor-local', 'bundle'));
39+
40+
// Test
41+
task('api-extractor-validate', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */));
42+
task('vitest', vitestTask({ test: argv().test, update: argv().update }));
43+
task('test', series('api-extractor-validate', 'vitest'));
44+
45+
// Clean
46+
task('clean', cleanTask(DEFAULT_CLEAN_DIRECTORIES));
47+
48+
// Post-publish
49+
task('postpublish', () => {
50+
return publishReleaseTask({
51+
repoOwner: 'Mojang',
52+
repoName: 'minecraft-scripting-libraries',
53+
message: 'See attached zip for pre-built minecraft-gameplay-utilities bundle with type declarations.',
54+
});
55+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@minecraft/gameplay-utilities",
3+
"version": "0.1.0",
4+
"author": "Andrew Griffin ([email protected])",
5+
"contributors": [
6+
{
7+
"name": "Jake Shirley",
8+
"email": "[email protected]"
9+
}
10+
],
11+
"description": "Gameplay utilities for use with minecraft scripting modules",
12+
"exports": {
13+
"import": "./lib/src/index.js",
14+
"types": "./lib/types/gameplay-utilities-public.d.ts"
15+
},
16+
"type": "module",
17+
"types": "./lib/types/gameplay-utilities-public.d.ts",
18+
"repository": {
19+
"type": "git",
20+
"url": "https://github.com/Mojang/minecraft-scripting-libraries.git",
21+
"directory": "libraries/gameplay-utilities"
22+
},
23+
"scripts": {
24+
"build": "just build",
25+
"lint": "just lint",
26+
"test": "just test",
27+
"clean": "just clean",
28+
"postpublish": "just postpublish"
29+
},
30+
"license": "MIT",
31+
"files": [
32+
"dist",
33+
"lib",
34+
"api-report"
35+
],
36+
"peerDependencies": {
37+
"@minecraft/server": "^1.15.0 || ^2.0.0"
38+
},
39+
"devDependencies": {
40+
"@minecraft/core-build-tasks": "*",
41+
"@minecraft/server": "^2.0.0",
42+
"@minecraft/tsconfig": "*",
43+
"just-scripts": "^2.4.1",
44+
"prettier": "^3.5.3",
45+
"vitest": "^3.0.8"
46+
}
47+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { afterEach, describe, it, vi, expect } from 'vitest';
5+
6+
import {
7+
clearMockState,
8+
createMockServerBindings,
9+
MockWeatherChangeEventHandlers,
10+
createWeatherEvent,
11+
MockEntityRemoveEventHandlers,
12+
} from '../../__mocks__/minecraft-server.js';
13+
14+
vi.mock('@minecraft/server', () => createMockServerBindings());
15+
16+
import { nextEvent } from './eventPromise.js';
17+
18+
describe('EventPromise', () => {
19+
afterEach(() => {
20+
vi.restoreAllMocks();
21+
clearMockState();
22+
});
23+
24+
it('Event is subscribed', () => {
25+
const server = createMockServerBindings();
26+
void nextEvent(server.world.afterEvents.weatherChange);
27+
expect(MockWeatherChangeEventHandlers.length).toBe(1);
28+
});
29+
30+
// specifically this test differs from above because this signal supports filters
31+
it('Event is subscribed without filter', () => {
32+
const server = createMockServerBindings();
33+
void nextEvent(server.world.afterEvents.entityRemove);
34+
expect(MockEntityRemoveEventHandlers.length).toBe(1);
35+
});
36+
37+
it('Event is subscribed with filter', () => {
38+
const server = createMockServerBindings();
39+
void nextEvent(server.world.afterEvents.entityRemove, { entityTypes: ['foobar'] });
40+
expect(MockEntityRemoveEventHandlers.length).toBe(1);
41+
});
42+
43+
it('Event is unsubscribed when called', async () => {
44+
const server = createMockServerBindings();
45+
const prom = nextEvent(server.world.afterEvents.weatherChange);
46+
const event = createWeatherEvent('foo');
47+
MockWeatherChangeEventHandlers.forEach(handler => {
48+
handler(event);
49+
});
50+
await prom;
51+
expect(MockWeatherChangeEventHandlers.length).toBe(0);
52+
});
53+
54+
it('Event is gathered from await promise', async () => {
55+
const server = createMockServerBindings();
56+
const eventExpected = createWeatherEvent('foobar');
57+
setTimeout(() => {
58+
MockWeatherChangeEventHandlers.forEach(handler => {
59+
handler(eventExpected);
60+
});
61+
}, 100);
62+
const eventActual = await nextEvent(server.world.afterEvents.weatherChange);
63+
expect(eventActual).toBe(eventExpected);
64+
});
65+
});

0 commit comments

Comments
 (0)