Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "EventPromise and nextEvent() added to gameplay-utilities",
"packageName": "@minecraft/gameplay-utilities",
"email": "agriffin@microsoft.com",
"dependentChangeType": "patch"
}
36 changes: 36 additions & 0 deletions libraries/gameplay-utilities/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Minecraft Gameplay Utilities

A set of utilities and functions for common gameplay operations. Major pieces are covered below.

## nextEvent() and EventPromise

`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`.

### Can be awaited to receive the event

```ts
const event = await nextEvent(world.afterEvents.buttonPush);
```

### Can be used like a promise

```ts
await nextEvent(world.afterEvents.leverAction).then(
(event) => {
// do something with the event
}).finally(() => {
// something else to do
});
```

### Optionally provide filters for the signal and use helper function

```ts
const creeperDeathEvent = await nextEvent(world.afterEvents.entityDie, { entityTypes: ['minecraft:creeper'] });
```

## How to use @minecraft/gameplay-utilities in your project

@minecraft/gameplay-utilities is published to NPM and follows standard semver semantics. To use it in your project,

- 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.
60 changes: 60 additions & 0 deletions libraries/gameplay-utilities/__mocks__/minecraft-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { EntityEventOptions, WeatherChangeAfterEvent, EntityRemoveAfterEvent } from '@minecraft/server';

export enum WeatherType {
Clear = 'Clear',
Rain = 'Rain',
Thunder = 'Thunder',
}
export function createWeatherEvent(dim: string): WeatherChangeAfterEvent {
return { dimension: dim, newWeather: WeatherType.Clear, previousWeather: WeatherType.Rain };
}
export type WeatherChangeAfterEventCallback = (event: WeatherChangeAfterEvent) => void;
export const MockWeatherChangeEventHandlers: WeatherChangeAfterEventCallback[] = [];

export type EntityRemoveAfterEventCallback = (event: EntityRemoveAfterEvent) => void;
export type MockEntityRemoveAfterEventCallbackData = {
callback: EntityRemoveAfterEventCallback;
options?: EntityEventOptions;
};
export const MockEntityRemoveEventHandlers: MockEntityRemoveAfterEventCallbackData[] = [];

export function clearMockState() {
MockWeatherChangeEventHandlers.length = 0;
MockEntityRemoveEventHandlers.length = 0;
}

export const createMockServerBindings = () => {
return {
world: {
afterEvents: {
weatherChange: {
subscribe: (callback: WeatherChangeAfterEventCallback) => {
MockWeatherChangeEventHandlers.push(callback);
return callback;
},
unsubscribe: (callback: WeatherChangeAfterEventCallback) => {
const index = MockWeatherChangeEventHandlers.indexOf(callback);
if (index !== -1) {
MockWeatherChangeEventHandlers.splice(index, 1);
}
},
},
entityRemove: {
subscribe: (callback: EntityRemoveAfterEventCallback, options?: EntityEventOptions) => {
MockEntityRemoveEventHandlers.push({ callback, options });
return callback;
},
unsubscribe: (callback: EntityRemoveAfterEventCallback) => {
const index = MockEntityRemoveEventHandlers.findIndex(value => value.callback === callback);
if (index !== -1) {
MockEntityRemoveEventHandlers.splice(index, 1);
}
},
},
},
},
};
};
8 changes: 8 additions & 0 deletions libraries/gameplay-utilities/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "@minecraft/api-extractor-base/api-extractor-base.json",
"mainEntryPointFilePath": "<projectFolder>/temp/types/src/index.d.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## API Report File for "@minecraft/gameplay-utilities"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import { system } from '@minecraft/server';
import { world } from '@minecraft/server';

// @public
export interface EventPromise<T> extends Promise<T | undefined> {
cancel(): void;
catch<TReject = never>(onrejected?: ((reason: unknown) => TReject | PromiseLike<TReject>) | null): Promise<T | undefined | TReject>;
finally(onfinally?: (() => void) | null): Promise<T | undefined>;
then<TFulfill = T | undefined, TReject = never>(onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike<TFulfill>) | null, onrejected?: ((reason: unknown) => TReject | PromiseLike<TReject>) | null): Promise<TFulfill | TReject>;
}

// @public
export type FirstArg<T> = T extends (arg: infer U) => void ? U : never;

// @public
export type MinecraftAfterEventSignals = (typeof world.afterEvents)[keyof typeof world.afterEvents] | (typeof system.afterEvents)[keyof typeof system.afterEvents];

// @public
export function nextEvent<U>(signal: MinecraftAfterEventSignals, filter?: U): EventPromise<FirstArg<FirstArg<typeof signal.subscribe>>>;

// (No @packageDocumentation comment for this package)

```
6 changes: 6 additions & 0 deletions libraries/gameplay-utilities/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import configMinecraftScripting from 'eslint-config-minecraft-scripting';

export default [...configMinecraftScripting];
55 changes: 55 additions & 0 deletions libraries/gameplay-utilities/just.config.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { execSync } from 'child_process';
import { argv, series, task, tscTask } from 'just-scripts';
import {
DEFAULT_CLEAN_DIRECTORIES,
apiExtractorTask,
cleanTask,
coreLint,
publishReleaseTask,
vitestTask,
} from '@minecraft/core-build-tasks';
import { copyFileSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const isOnlyBuild = argv()._.findIndex(arg => arg === 'test') === -1;

// Lint
task('lint', coreLint(['src/**/*.ts'], argv().fix));

// Build
task('typescript', tscTask());
task('api-extractor-local', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */));
task('bundle', () => {
execSync(
'npx esbuild ./lib/src/index.js --bundle --outfile=dist/minecraft-gameplay-utilities.js --format=esm --sourcemap --external:@minecraft/server'
);
// Copy over type definitions and rename
const officialTypes = JSON.parse(readFileSync('./package.json', 'utf-8'))['types'];
if (!officialTypes) {
// Has the package.json been restructured?
throw new Error('The package.json file does not contain a "types" field. Unable to copy types to bundle.');
}
const officialTypesPath = resolve(officialTypes);
copyFileSync(officialTypesPath, './dist/minecraft-gameplay-utilities.d.ts');
});
task('build', series('typescript', 'api-extractor-local', 'bundle'));

// Test
task('api-extractor-validate', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */));
task('vitest', vitestTask({ test: argv().test, update: argv().update }));
task('test', series('api-extractor-validate', 'vitest'));

// Clean
task('clean', cleanTask(DEFAULT_CLEAN_DIRECTORIES));

// Post-publish
task('postpublish', () => {
return publishReleaseTask({
repoOwner: 'Mojang',
repoName: 'minecraft-scripting-libraries',
message: 'See attached zip for pre-built minecraft-gameplay-utilities bundle with type declarations.',
});
});
47 changes: 47 additions & 0 deletions libraries/gameplay-utilities/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@minecraft/gameplay-utilities",
"version": "0.1.0",
"author": "Andrew Griffin (agriffin@microsoft.com)",
"contributors": [
{
"name": "Jake Shirley",
"email": "jashir@mojang.com"
}
],
"description": "Gameplay utilities for use with minecraft scripting modules",
"exports": {
"import": "./lib/src/index.js",
"types": "./lib/types/gameplay-utilities-public.d.ts"
},
"type": "module",
"types": "./lib/types/gameplay-utilities-public.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/Mojang/minecraft-scripting-libraries.git",
"directory": "libraries/gameplay-utilities"
},
"scripts": {
"build": "just build",
"lint": "just lint",
"test": "just test",
"clean": "just clean",
"postpublish": "just postpublish"
},
"license": "MIT",
"files": [
"dist",
"lib",
"api-report"
],
"peerDependencies": {
"@minecraft/server": "^1.15.0 || ^2.0.0"
},
"devDependencies": {
"@minecraft/core-build-tasks": "*",
"@minecraft/server": "^2.0.0",
"@minecraft/tsconfig": "*",
"just-scripts": "^2.4.1",
"prettier": "^3.5.3",
"vitest": "^3.0.8"
}
}
65 changes: 65 additions & 0 deletions libraries/gameplay-utilities/src/events/eventPromise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { afterEach, describe, it, vi, expect } from 'vitest';

import {
clearMockState,
createMockServerBindings,
MockWeatherChangeEventHandlers,
createWeatherEvent,
MockEntityRemoveEventHandlers,
} from '../../__mocks__/minecraft-server.js';

vi.mock('@minecraft/server', () => createMockServerBindings());

import { nextEvent } from './eventPromise.js';

describe('EventPromise', () => {
afterEach(() => {
vi.restoreAllMocks();
clearMockState();
});

it('Event is subscribed', () => {
const server = createMockServerBindings();
void nextEvent(server.world.afterEvents.weatherChange);
expect(MockWeatherChangeEventHandlers.length).toBe(1);
});

// specifically this test differs from above because this signal supports filters
it('Event is subscribed without filter', () => {
const server = createMockServerBindings();
void nextEvent(server.world.afterEvents.entityRemove);
expect(MockEntityRemoveEventHandlers.length).toBe(1);
});

it('Event is subscribed with filter', () => {
const server = createMockServerBindings();
void nextEvent(server.world.afterEvents.entityRemove, { entityTypes: ['foobar'] });
expect(MockEntityRemoveEventHandlers.length).toBe(1);
});

it('Event is unsubscribed when called', async () => {
const server = createMockServerBindings();
const prom = nextEvent(server.world.afterEvents.weatherChange);
const event = createWeatherEvent('foo');
MockWeatherChangeEventHandlers.forEach(handler => {
handler(event);
});
await prom;
expect(MockWeatherChangeEventHandlers.length).toBe(0);
});

it('Event is gathered from await promise', async () => {
const server = createMockServerBindings();
const eventExpected = createWeatherEvent('foobar');
setTimeout(() => {
MockWeatherChangeEventHandlers.forEach(handler => {
handler(eventExpected);
});
}, 100);
const eventActual = await nextEvent(server.world.afterEvents.weatherChange);
expect(eventActual).toBe(eventExpected);
});
});
Loading