From 3526fa647081a04dbf3b87cb8c685be401a65df0 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Mon, 8 Sep 2025 16:25:07 -0700 Subject: [PATCH 1/8] 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 --- ...-8aec48fc-0353-4ce9-81d8-835ede162757.json | 7 + libraries/gameplay-utilities/README.md | 40 ++++ .../gameplay-utilities/api-extractor.json | 7 + .../api-report/gameplay-utilities.api.md | 47 +++++ .../gameplay-utilities/eslint.config.mjs | 6 + libraries/gameplay-utilities/just.config.cts | 55 +++++ libraries/gameplay-utilities/package.json | 47 +++++ .../src/events/eventThenable.test.ts | 102 ++++++++++ .../src/events/eventThenable.ts | 68 +++++++ .../gameplay-utilities/src/events/index.ts | 4 + libraries/gameplay-utilities/src/index.ts | 5 + .../gameplay-utilities/src/thenable.test.ts | 93 +++++++++ libraries/gameplay-utilities/src/thenable.ts | 191 ++++++++++++++++++ libraries/gameplay-utilities/tsconfig.json | 11 + libraries/gameplay-utilities/vite.config.mts | 9 + package-lock.json | 20 ++ 16 files changed, 712 insertions(+) create mode 100644 change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json create mode 100644 libraries/gameplay-utilities/README.md create mode 100644 libraries/gameplay-utilities/api-extractor.json create mode 100644 libraries/gameplay-utilities/api-report/gameplay-utilities.api.md create mode 100644 libraries/gameplay-utilities/eslint.config.mjs create mode 100644 libraries/gameplay-utilities/just.config.cts create mode 100644 libraries/gameplay-utilities/package.json create mode 100644 libraries/gameplay-utilities/src/events/eventThenable.test.ts create mode 100644 libraries/gameplay-utilities/src/events/eventThenable.ts create mode 100644 libraries/gameplay-utilities/src/events/index.ts create mode 100644 libraries/gameplay-utilities/src/index.ts create mode 100644 libraries/gameplay-utilities/src/thenable.test.ts create mode 100644 libraries/gameplay-utilities/src/thenable.ts create mode 100644 libraries/gameplay-utilities/tsconfig.json create mode 100644 libraries/gameplay-utilities/vite.config.mts diff --git a/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json b/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json new file mode 100644 index 0000000..c5f8711 --- /dev/null +++ b/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "Event promise and thenable added to new gameplay utility library", + "packageName": "@minecraft/gameplay-utilities", + "email": "agriffin@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/libraries/gameplay-utilities/README.md b/libraries/gameplay-utilities/README.md new file mode 100644 index 0000000..9c93c1c --- /dev/null +++ b/libraries/gameplay-utilities/README.md @@ -0,0 +1,40 @@ +# Minecraft Gameplay Utilities + +A set of utilities and functions for common gameplay operations. Major pieces are covered below. + +## Thenable + +A promise-like object which allows for cancellation through external resolution with it's `fulfill` and `reject` functions. + +## EventThenable + +This object provides a "wait for next event" utility. A wrapper around the `Thenable` object which is designed to be used with Minecraft script event signals. Provide the constructor with a signal and it will resolve the promise when the next event for the provided signal is raised. Also provides a `cancel` function to unregister the event and fulfill the promise with `undefined`. + +### Can be awaited to receive the event + +```ts +const event = await new EventThenable(world.afterEvents.buttonPush); +``` + +### Can be used like a promise + +```ts +new EventThenable(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 waitForNextEvent(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. diff --git a/libraries/gameplay-utilities/api-extractor.json b/libraries/gameplay-utilities/api-extractor.json new file mode 100644 index 0000000..6ca38f6 --- /dev/null +++ b/libraries/gameplay-utilities/api-extractor.json @@ -0,0 +1,7 @@ +/** + * 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" +} diff --git a/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md new file mode 100644 index 0000000..8761146 --- /dev/null +++ b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md @@ -0,0 +1,47 @@ +## 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 + +// @public +export interface EventSignal { + // (undocumented) + subscribe(closure: (event: T) => void, filter?: U): (event: T) => void; + // (undocumented) + unsubscribe(closure: (event: T) => void): void; +} + +// @public +export class EventThenable extends Thenable { + constructor(signal: EventSignal, filter?: U); + cancel(): void; +} + +// @public +export enum PromiseState { + // (undocumented) + FULFILLED = "fulfilled", + // (undocumented) + PENDING = "pending", + // (undocumented) + REJECTED = "rejected" +} + +// @public +export class Thenable { + constructor(callback: (fulfill: (value: T) => void, reject: (reason: unknown) => void) => void); + catch(onRejected: (reason: unknown) => unknown): Thenable; + finally(onFinally: () => void): Thenable; + fulfill(value: T | Thenable): void; + reject(error: unknown): void; + state(): PromiseState; + then(onFulfilled?: (val: T) => U, onRejected?: (reason: unknown) => unknown): Thenable; +} + +// @public +export function waitForNextEvent(signal: EventSignal, filter?: U): EventThenable; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/libraries/gameplay-utilities/eslint.config.mjs b/libraries/gameplay-utilities/eslint.config.mjs new file mode 100644 index 0000000..0ec03a7 --- /dev/null +++ b/libraries/gameplay-utilities/eslint.config.mjs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import configMinecraftScripting from 'eslint-config-minecraft-scripting'; + +export default [...configMinecraftScripting]; diff --git a/libraries/gameplay-utilities/just.config.cts b/libraries/gameplay-utilities/just.config.cts new file mode 100644 index 0000000..9ba46ab --- /dev/null +++ b/libraries/gameplay-utilities/just.config.cts @@ -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/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.', + }); +}); diff --git a/libraries/gameplay-utilities/package.json b/libraries/gameplay-utilities/package.json new file mode 100644 index 0000000..9ddcc59 --- /dev/null +++ b/libraries/gameplay-utilities/package.json @@ -0,0 +1,47 @@ +{ + "name": "@minecraft/gameplay-utilities", + "version": "0.1.0", + "author": "Raphael Landaverde (rlanda@microsoft.com)", + "contributors": [ + { + "name": "Jake Shirley", + "email": "jashir@mojang.com" + } + ], + "description": "Gameplay utilities for use with minecraft scripting modules", + "exports": { + "import": "./lib/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" + } +} diff --git a/libraries/gameplay-utilities/src/events/eventThenable.test.ts b/libraries/gameplay-utilities/src/events/eventThenable.test.ts new file mode 100644 index 0000000..dd676af --- /dev/null +++ b/libraries/gameplay-utilities/src/events/eventThenable.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { assert, describe, expect, it } from 'vitest'; +import { EventThenable } from './eventThenable.js'; +import { PromiseState } from '../thenable.js'; + +describe('EventThenable', () => { + class Event { + num: number = 0; + } + + describe('Non-Filtering Signals', () => { + class Signal { + public closure_: ((e: Event) => void) | undefined = undefined; + + public sendEvent(e: Event) { + if (this.closure_ !== undefined) { + this.closure_(e); + } + } + + public subscribe(closure: (e: Event) => void) { + this.closure_ = closure; + return closure; + } + + public unsubscribe(_: (e: Event) => void) { + this.closure_ = undefined; + } + } + const signal = new Signal(); + + it('successfully resolve event', () => { + const e = new EventThenable(signal); + assert(signal.closure_ !== undefined); + signal.sendEvent({ num: 4 }); + expect(e.state()).toBe(PromiseState.FULFILLED); + }); + + it('successfully use then on an EventThenable', () => { + new EventThenable(signal).then((event?: Event) => { + assert(event !== undefined); + expect(event.num).toBe(5); + }); + signal.sendEvent({ num: 5 }); + }); + + it('successfully cancel an EventThenable', () => { + const e = new EventThenable(signal); + e.then((event?: Event) => { + assert(event === undefined); + }); + e.cancel(); + expect(e.state()).toBe(PromiseState.FULFILLED); + }); + }); + + describe('Filterable Signals', () => { + class EventFilters { + public someFilterValue: number = 0; + } + + class Signal { + public closure_: ((e: Event) => void) | undefined = undefined; + public filters_: EventFilters | undefined = undefined; + + public sendEvent(e: Event) { + if (this.closure_ !== undefined) { + this.closure_(e); + } + } + + public subscribe(closure: (e: Event) => void, options?: EventFilters) { + this.closure_ = closure; + this.filters_ = options; + return closure; + } + + public unsubscribe(_: (e: Event) => void) { + this.closure_ = undefined; + } + } + const signal = new Signal(); + + // checking that the filters are being passed through to the signal properly + it('successfully create EventThenable with filtered signal with filter', () => { + const e = new EventThenable(signal, { someFilterValue: 18 }); + assert(signal.filters_ !== undefined); + expect(signal.filters_.someFilterValue).toBe(18); + signal.sendEvent({ num: 4 }); + expect(e.state()).toBe(PromiseState.FULFILLED); + }); + + it('successfully create EventThenable with filtered signal with no filter', () => { + const e = new EventThenable(signal); + assert(signal.filters_ === undefined); + signal.sendEvent({ num: 4 }); + expect(e.state()).toBe(PromiseState.FULFILLED); + }); + }); +}); diff --git a/libraries/gameplay-utilities/src/events/eventThenable.ts b/libraries/gameplay-utilities/src/events/eventThenable.ts new file mode 100644 index 0000000..de673f6 --- /dev/null +++ b/libraries/gameplay-utilities/src/events/eventThenable.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Thenable } from '../thenable.js'; + +/** + * Interface representing the functions required to subscribe and unsubscribe to events. + * + * @public + */ +export interface EventSignal { + subscribe(closure: (event: T) => void, filter?: U): (event: T) => void; + unsubscribe(closure: (event: T) => void): void; +} + +/** + * Helper to create a new EventThenable from an event signal. + * + * @public + */ +export function waitForNextEvent(signal: EventSignal, filter?: U) { + return new EventThenable(signal, filter); +} + +/** + * A promise wrapper utility which returns a new promise that will resolve when the next + * event is raised. + * + * @public + */ +export class EventThenable extends Thenable { + private onCancel?: () => void; + + constructor(signal: EventSignal, filter?: U) { + let cancelFn: (() => void) | undefined = undefined; + super((resolve, _) => { + let sub: (event: T) => void; + if (filter === undefined) { + sub = signal.subscribe(event => { + this.onCancel = undefined; + signal.unsubscribe(sub); + resolve(event); + }); + } else { + sub = signal.subscribe(event => { + this.onCancel = undefined; + signal.unsubscribe(sub); + resolve(event); + }, filter); + } + + cancelFn = () => { + signal.unsubscribe(sub); + resolve(undefined); + }; + }); + this.onCancel = cancelFn; + } + + /** + * Cancels the promise by resolving it with undefined and unsubscribing from the event signal. + */ + cancel() { + if (this.onCancel) { + this.onCancel(); + } + } +} diff --git a/libraries/gameplay-utilities/src/events/index.ts b/libraries/gameplay-utilities/src/events/index.ts new file mode 100644 index 0000000..cdcbff7 --- /dev/null +++ b/libraries/gameplay-utilities/src/events/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './eventThenable.js'; diff --git a/libraries/gameplay-utilities/src/index.ts b/libraries/gameplay-utilities/src/index.ts new file mode 100644 index 0000000..62db0a7 --- /dev/null +++ b/libraries/gameplay-utilities/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './events/index.js'; +export * from './thenable.js'; diff --git a/libraries/gameplay-utilities/src/thenable.test.ts b/libraries/gameplay-utilities/src/thenable.test.ts new file mode 100644 index 0000000..6c58b0c --- /dev/null +++ b/libraries/gameplay-utilities/src/thenable.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { assert, describe, expect, it } from 'vitest'; +import { PromiseState, Thenable } from './thenable.js'; + +describe('Thenable', () => { + it('successfully create thenable', () => { + const t = new Thenable(() => {}); + expect(t.state()).toBe(PromiseState.PENDING); + }); + + it('successfully fulfill', () => { + const t = new Thenable((res, _) => { + res(undefined); + }); + expect(t.state()).toBe(PromiseState.FULFILLED); + }); + + it('successfully reject', () => { + const t = new Thenable((_, rej) => { + rej(undefined); + }); + t.catch(() => {}); + expect(t.state()).toBe(PromiseState.REJECTED); + }); + + it('successfully chain resolve', async () => { + let finished = false; + const t = new Thenable((res, _) => { + res(12); + }) + .then(val => { + expect(val).toBe(12); + }) + .catch(_ => { + assert.fail('should not have rejected'); + }) + .finally(() => { + finished = true; + }); + await t; + expect(finished).toBe(true); + }); + + it('successfully chain reject', async () => { + let finished = false; + const t = new Thenable((_, rej) => { + rej('rejection'); + }) + .then( + _ => { + assert.fail('should not have fulfilled'); + }, + reason => { + expect(reason).toBe('rejection'); + } + ) + .finally(() => { + finished = true; + }); + await t; + expect(finished).toBe(true); + }); + + it('successfully fulfill through external api', async () => { + let resolved = false; + const t = new Thenable(() => {}); + const t2 = t.then(val => { + expect(val).toBe(9); + resolved = true; + return val; + }); + t.fulfill(9); + const v = await t2; + expect(v).toBe(9); + expect(resolved).toBe(true); + }); + + it('successfully reject through external api', async () => { + let resolved = false; + const t = new Thenable(() => {}); + const t2 = t.catch(reason => { + expect(reason).toBe(9); + resolved = true; + return reason; + }); + t.reject(9); + const v = await t2; + expect(v).toBe(9); + expect(resolved).toBe(true); + }); +}); diff --git a/libraries/gameplay-utilities/src/thenable.ts b/libraries/gameplay-utilities/src/thenable.ts new file mode 100644 index 0000000..ec28f36 --- /dev/null +++ b/libraries/gameplay-utilities/src/thenable.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * A promise's state which can be checked on the Thenable object's state function + * + * @public + */ +export enum PromiseState { + PENDING = 'pending', + FULFILLED = 'fulfilled', + REJECTED = 'rejected', +} + +/** + * A generic promise-like object that can be used like a normal promise but + * implement some additional functionality that promises do not have. + * + * @public + */ +export class Thenable { + private promiseState: PromiseState = PromiseState.PENDING; + private dataValue?: unknown = undefined; + private chainedPromise: + | { onFulfilled: (value: T) => unknown; onRejected: (reason: unknown) => unknown } + | undefined = undefined; + + /** + * Rejects or fulfills a promise + * + * @param value - The data that will be passed to the next chained object + * @param fulfilled - Whether or not the promise is being fulfilled (true) or rejected (false) + */ + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + private completePromise_ = (value: T | Thenable | unknown, fulfilled: boolean) => { + if (this.promiseState !== PromiseState.PENDING) { + return; + } + + // value is another promise, await + if ( + value instanceof Thenable || + (typeof value === 'object' && + value !== undefined && + // eslint-disable-next-line unicorn/no-null + value !== null && + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (value as any).then && + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + typeof (value as any).then === 'function') + ) { + return (value as Thenable).then( + (val: T) => { + this.fulfill(val); + }, + (reason: unknown) => { + this.reject(reason); + } + ); + } + + this.dataValue = value; + this.promiseState = fulfilled ? PromiseState.FULFILLED : PromiseState.REJECTED; + if (this.chainedPromise) { + if (fulfilled) { + this.chainedPromise.onFulfilled(value as T); + } else { + this.chainedPromise.onRejected(value); + } + } + }; + + constructor(callback: (fulfill: (value: T) => void, reject: (reason: unknown) => void) => void) { + try { + callback( + (value: T) => { + this.fulfill(value); + }, + (reason: unknown) => { + this.reject(reason); + } + ); + } catch (error) { + this.reject(error); + } + } + + /** + * Fulfills the promise. + * + * @param value - The value to fulfill with. + */ + fulfill(value: T | Thenable) { + this.completePromise_(value, true); + } + + /** + * Rejects the promise. + * + * @param error - The error to reject with. + */ + reject(error: unknown) { + this.completePromise_(error, false); + } + + /** + * Gets the current state of the promise. + * + * @returns The state of the promise. + */ + state(): PromiseState { + return this.promiseState; + } + + /** + * Constructs a new promise that will be chanined to execute after this promise is fulfilled or rejected. + * + * @param onFulfilled - Action to perform if the promise is fulfilled. + * @param onRejected - Action to perform if the promise is rejected. + * @returns A new promise. + */ + then(onFulfilled?: (val: T) => U, onRejected?: (reason: unknown) => unknown): Thenable { + return new Thenable((fulfill, reject) => { + this.chainedPromise = { + onFulfilled: function (value: T) { + if (!onFulfilled) { + fulfill(value as never); + return; + } + + try { + fulfill(onFulfilled(value)); + } catch (error) { + reject(error); + } + }, + onRejected: function (value: unknown) { + if (!onRejected) { + reject(value); + return; + } + + try { + fulfill(onRejected(value) as never); + } catch (error) { + reject(error); + } + }, + }; + + // if already resolved, just call the chained promise + if (this.promiseState !== PromiseState.PENDING && this.chainedPromise) { + if (this.promiseState === PromiseState.FULFILLED) { + this.chainedPromise.onFulfilled(this.dataValue as T); + } else { + this.chainedPromise.onRejected(this.dataValue); + } + } + }); + } + + /** + * Similar to then, however only the rejection state is used. + * + * @param onRejected - Action to perform if the promise is rejected. + * @returns A new promise. + */ + catch(onRejected: (reason: unknown) => unknown) { + return this.then(undefined, onRejected); + } + + /** + * Runs the provided function when the promise resolved, regardless if it was + * fulfilled or rejected. + * + * @param onFinally - Action to perform if the promise is resolved. + * @returns A new promise. + */ + finally(onFinally: () => void) { + return this.then( + (value: T) => { + onFinally(); + return value; + }, + (error: unknown) => { + onFinally(); + return error; + } + ); + } +} diff --git a/libraries/gameplay-utilities/tsconfig.json b/libraries/gameplay-utilities/tsconfig.json new file mode 100644 index 0000000..23606e9 --- /dev/null +++ b/libraries/gameplay-utilities/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@minecraft/tsconfig/base.json", + "include": ["src/**/*"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "outDir": "lib", + "declarationDir": "temp/types", + "module": "node16", + "moduleResolution": "node16" + } +} diff --git a/libraries/gameplay-utilities/vite.config.mts b/libraries/gameplay-utilities/vite.config.mts new file mode 100644 index 0000000..c72faff --- /dev/null +++ b/libraries/gameplay-utilities/vite.config.mts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { exclude: [...configDefaults.exclude, '**/build/**', '**/lib/**', '**/lib-commonjs/**'], watch: false }, +}); diff --git a/package-lock.json b/package-lock.json index 9a16f9c..c83391d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,22 @@ "npm": ">=10.0.0" } }, + "libraries/gameplay-utilities": { + "name": "@minecraft/gameplay-utilities", + "version": "2.2.8", + "license": "MIT", + "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" + }, + "peerDependencies": { + "@minecraft/server": "^1.15.0 || ^2.0.0" + } + }, "libraries/math": { "name": "@minecraft/math", "version": "2.2.9", @@ -1071,6 +1087,10 @@ "resolved": "tools/core-build-tasks", "link": true }, + "node_modules/@minecraft/gameplay-utilities": { + "resolved": "libraries/gameplay-utilities", + "link": true + }, "node_modules/@minecraft/markup-generators-plugin": { "resolved": "tools/markup-generators-plugin", "link": true From 9af77a54ddb8eb34670bf693c2f83a7ea4c40a50 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Fri, 12 Sep 2025 15:08:47 -0700 Subject: [PATCH 2/8] PR Requests, removing thenable, event promise becomes private, next event only allows minecraft signals --- ...-8aec48fc-0353-4ce9-81d8-835ede162757.json | 2 +- libraries/gameplay-utilities/README.md | 14 +- .../api-report/gameplay-utilities.api.md | 2 +- .../{eventThenable.ts => eventPromise.ts} | 34 ++-- .../src/events/eventThenable.test.ts | 102 ---------- .../gameplay-utilities/src/events/index.ts | 2 +- libraries/gameplay-utilities/src/index.ts | 1 - .../gameplay-utilities/src/thenable.test.ts | 93 --------- libraries/gameplay-utilities/src/thenable.ts | 191 ------------------ 9 files changed, 27 insertions(+), 414 deletions(-) rename libraries/gameplay-utilities/src/events/{eventThenable.ts => eventPromise.ts} (57%) delete mode 100644 libraries/gameplay-utilities/src/events/eventThenable.test.ts delete mode 100644 libraries/gameplay-utilities/src/thenable.test.ts delete mode 100644 libraries/gameplay-utilities/src/thenable.ts diff --git a/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json b/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json index c5f8711..f05ce7a 100644 --- a/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json +++ b/change/@minecraft-gameplay-utilities-8aec48fc-0353-4ce9-81d8-835ede162757.json @@ -1,6 +1,6 @@ { "type": "major", - "comment": "Event promise and thenable added to new gameplay utility library", + "comment": "EventPromise and nextEvent() added to gameplay-utilities", "packageName": "@minecraft/gameplay-utilities", "email": "agriffin@microsoft.com", "dependentChangeType": "patch" diff --git a/libraries/gameplay-utilities/README.md b/libraries/gameplay-utilities/README.md index 9c93c1c..6ea9a3e 100644 --- a/libraries/gameplay-utilities/README.md +++ b/libraries/gameplay-utilities/README.md @@ -2,24 +2,20 @@ A set of utilities and functions for common gameplay operations. Major pieces are covered below. -## Thenable +## nextEvent() and EventPromise -A promise-like object which allows for cancellation through external resolution with it's `fulfill` and `reject` functions. - -## EventThenable - -This object provides a "wait for next event" utility. A wrapper around the `Thenable` object which is designed to be used with Minecraft script event signals. Provide the constructor with a signal and it will resolve the promise when the next event for the provided signal is raised. Also provides a `cancel` function to unregister the event and fulfill the promise with `undefined`. +`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 new EventThenable(world.afterEvents.buttonPush); +const event = await nextEvent(world.afterEvents.buttonPush); ``` ### Can be used like a promise ```ts -new EventThenable(world.afterEvents.leverAction).then( +await nextEvent(world.afterEvents.leverAction).then( (event) => { // do something with the event }).finally(() => { @@ -30,7 +26,7 @@ new EventThenable(world.afterEvents.leverAction).then( ### Optionally provide filters for the signal and use helper function ```ts -const creeperDeathEvent = await waitForNextEvent(world.afterEvents.entityDie, { entityTypes: ['minecraft:creeper'] }); +const creeperDeathEvent = await nextEvent(world.afterEvents.entityDie, { entityTypes: ['minecraft:creeper'] }); ``` ## How to use @minecraft/gameplay-utilities in your project diff --git a/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md index 8761146..1cbeea7 100644 --- a/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md +++ b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md @@ -40,7 +40,7 @@ export class Thenable { } // @public -export function waitForNextEvent(signal: EventSignal, filter?: U): EventThenable; +export function nextEvent(signal: EventSignal, filter?: U): EventThenable; // (No @packageDocumentation comment for this package) diff --git a/libraries/gameplay-utilities/src/events/eventThenable.ts b/libraries/gameplay-utilities/src/events/eventPromise.ts similarity index 57% rename from libraries/gameplay-utilities/src/events/eventThenable.ts rename to libraries/gameplay-utilities/src/events/eventPromise.ts index de673f6..51ab849 100644 --- a/libraries/gameplay-utilities/src/events/eventThenable.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.ts @@ -1,37 +1,43 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { Thenable } from '../thenable.js'; +import { world, system } from '@minecraft/server'; /** - * Interface representing the functions required to subscribe and unsubscribe to events. - * + * A promise wrapper utility which returns a new promise that will resolve when the next + * event is raised. + * * @public */ -export interface EventSignal { - subscribe(closure: (event: T) => void, filter?: U): (event: T) => void; - unsubscribe(closure: (event: T) => void): void; +export interface EventPromise { + /** + * Cancels the promise and unsubscribes from the event signal. Cancellation is done + * by fulfilling with undefined. + * + * @public + */ + cancel(): void; } /** - * Helper to create a new EventThenable from an event signal. + * Helper to create a new EventPromise from an after event signal. * * @public */ -export function waitForNextEvent(signal: EventSignal, filter?: U) { - return new EventThenable(signal, filter); +export function nextEvent(signal: (typeof world.afterEvents[keyof typeof world.afterEvents]) | (typeof system.afterEvents[keyof typeof system.afterEvents]), filter?: U) : EventPromise { + return new EventPromiseImpl(signal, filter); } /** * A promise wrapper utility which returns a new promise that will resolve when the next * event is raised. * - * @public + * @private */ -export class EventThenable extends Thenable { +class EventPromiseImpl extends Promise { private onCancel?: () => void; - constructor(signal: EventSignal, filter?: U) { + constructor(signal: typeof world.afterEvents[keyof typeof world.afterEvents], filter?: U) { let cancelFn: (() => void) | undefined = undefined; super((resolve, _) => { let sub: (event: T) => void; @@ -61,8 +67,6 @@ export class EventThenable extends Thenable { * Cancels the promise by resolving it with undefined and unsubscribing from the event signal. */ cancel() { - if (this.onCancel) { - this.onCancel(); - } + this.onCancel?.(); } } diff --git a/libraries/gameplay-utilities/src/events/eventThenable.test.ts b/libraries/gameplay-utilities/src/events/eventThenable.test.ts deleted file mode 100644 index dd676af..0000000 --- a/libraries/gameplay-utilities/src/events/eventThenable.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { assert, describe, expect, it } from 'vitest'; -import { EventThenable } from './eventThenable.js'; -import { PromiseState } from '../thenable.js'; - -describe('EventThenable', () => { - class Event { - num: number = 0; - } - - describe('Non-Filtering Signals', () => { - class Signal { - public closure_: ((e: Event) => void) | undefined = undefined; - - public sendEvent(e: Event) { - if (this.closure_ !== undefined) { - this.closure_(e); - } - } - - public subscribe(closure: (e: Event) => void) { - this.closure_ = closure; - return closure; - } - - public unsubscribe(_: (e: Event) => void) { - this.closure_ = undefined; - } - } - const signal = new Signal(); - - it('successfully resolve event', () => { - const e = new EventThenable(signal); - assert(signal.closure_ !== undefined); - signal.sendEvent({ num: 4 }); - expect(e.state()).toBe(PromiseState.FULFILLED); - }); - - it('successfully use then on an EventThenable', () => { - new EventThenable(signal).then((event?: Event) => { - assert(event !== undefined); - expect(event.num).toBe(5); - }); - signal.sendEvent({ num: 5 }); - }); - - it('successfully cancel an EventThenable', () => { - const e = new EventThenable(signal); - e.then((event?: Event) => { - assert(event === undefined); - }); - e.cancel(); - expect(e.state()).toBe(PromiseState.FULFILLED); - }); - }); - - describe('Filterable Signals', () => { - class EventFilters { - public someFilterValue: number = 0; - } - - class Signal { - public closure_: ((e: Event) => void) | undefined = undefined; - public filters_: EventFilters | undefined = undefined; - - public sendEvent(e: Event) { - if (this.closure_ !== undefined) { - this.closure_(e); - } - } - - public subscribe(closure: (e: Event) => void, options?: EventFilters) { - this.closure_ = closure; - this.filters_ = options; - return closure; - } - - public unsubscribe(_: (e: Event) => void) { - this.closure_ = undefined; - } - } - const signal = new Signal(); - - // checking that the filters are being passed through to the signal properly - it('successfully create EventThenable with filtered signal with filter', () => { - const e = new EventThenable(signal, { someFilterValue: 18 }); - assert(signal.filters_ !== undefined); - expect(signal.filters_.someFilterValue).toBe(18); - signal.sendEvent({ num: 4 }); - expect(e.state()).toBe(PromiseState.FULFILLED); - }); - - it('successfully create EventThenable with filtered signal with no filter', () => { - const e = new EventThenable(signal); - assert(signal.filters_ === undefined); - signal.sendEvent({ num: 4 }); - expect(e.state()).toBe(PromiseState.FULFILLED); - }); - }); -}); diff --git a/libraries/gameplay-utilities/src/events/index.ts b/libraries/gameplay-utilities/src/events/index.ts index cdcbff7..b190e66 100644 --- a/libraries/gameplay-utilities/src/events/index.ts +++ b/libraries/gameplay-utilities/src/events/index.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from './eventThenable.js'; +export * from './eventPromise.js'; diff --git a/libraries/gameplay-utilities/src/index.ts b/libraries/gameplay-utilities/src/index.ts index 62db0a7..d601766 100644 --- a/libraries/gameplay-utilities/src/index.ts +++ b/libraries/gameplay-utilities/src/index.ts @@ -2,4 +2,3 @@ // Licensed under the MIT License. export * from './events/index.js'; -export * from './thenable.js'; diff --git a/libraries/gameplay-utilities/src/thenable.test.ts b/libraries/gameplay-utilities/src/thenable.test.ts deleted file mode 100644 index 6c58b0c..0000000 --- a/libraries/gameplay-utilities/src/thenable.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { assert, describe, expect, it } from 'vitest'; -import { PromiseState, Thenable } from './thenable.js'; - -describe('Thenable', () => { - it('successfully create thenable', () => { - const t = new Thenable(() => {}); - expect(t.state()).toBe(PromiseState.PENDING); - }); - - it('successfully fulfill', () => { - const t = new Thenable((res, _) => { - res(undefined); - }); - expect(t.state()).toBe(PromiseState.FULFILLED); - }); - - it('successfully reject', () => { - const t = new Thenable((_, rej) => { - rej(undefined); - }); - t.catch(() => {}); - expect(t.state()).toBe(PromiseState.REJECTED); - }); - - it('successfully chain resolve', async () => { - let finished = false; - const t = new Thenable((res, _) => { - res(12); - }) - .then(val => { - expect(val).toBe(12); - }) - .catch(_ => { - assert.fail('should not have rejected'); - }) - .finally(() => { - finished = true; - }); - await t; - expect(finished).toBe(true); - }); - - it('successfully chain reject', async () => { - let finished = false; - const t = new Thenable((_, rej) => { - rej('rejection'); - }) - .then( - _ => { - assert.fail('should not have fulfilled'); - }, - reason => { - expect(reason).toBe('rejection'); - } - ) - .finally(() => { - finished = true; - }); - await t; - expect(finished).toBe(true); - }); - - it('successfully fulfill through external api', async () => { - let resolved = false; - const t = new Thenable(() => {}); - const t2 = t.then(val => { - expect(val).toBe(9); - resolved = true; - return val; - }); - t.fulfill(9); - const v = await t2; - expect(v).toBe(9); - expect(resolved).toBe(true); - }); - - it('successfully reject through external api', async () => { - let resolved = false; - const t = new Thenable(() => {}); - const t2 = t.catch(reason => { - expect(reason).toBe(9); - resolved = true; - return reason; - }); - t.reject(9); - const v = await t2; - expect(v).toBe(9); - expect(resolved).toBe(true); - }); -}); diff --git a/libraries/gameplay-utilities/src/thenable.ts b/libraries/gameplay-utilities/src/thenable.ts deleted file mode 100644 index ec28f36..0000000 --- a/libraries/gameplay-utilities/src/thenable.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * A promise's state which can be checked on the Thenable object's state function - * - * @public - */ -export enum PromiseState { - PENDING = 'pending', - FULFILLED = 'fulfilled', - REJECTED = 'rejected', -} - -/** - * A generic promise-like object that can be used like a normal promise but - * implement some additional functionality that promises do not have. - * - * @public - */ -export class Thenable { - private promiseState: PromiseState = PromiseState.PENDING; - private dataValue?: unknown = undefined; - private chainedPromise: - | { onFulfilled: (value: T) => unknown; onRejected: (reason: unknown) => unknown } - | undefined = undefined; - - /** - * Rejects or fulfills a promise - * - * @param value - The data that will be passed to the next chained object - * @param fulfilled - Whether or not the promise is being fulfilled (true) or rejected (false) - */ - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - private completePromise_ = (value: T | Thenable | unknown, fulfilled: boolean) => { - if (this.promiseState !== PromiseState.PENDING) { - return; - } - - // value is another promise, await - if ( - value instanceof Thenable || - (typeof value === 'object' && - value !== undefined && - // eslint-disable-next-line unicorn/no-null - value !== null && - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (value as any).then && - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - typeof (value as any).then === 'function') - ) { - return (value as Thenable).then( - (val: T) => { - this.fulfill(val); - }, - (reason: unknown) => { - this.reject(reason); - } - ); - } - - this.dataValue = value; - this.promiseState = fulfilled ? PromiseState.FULFILLED : PromiseState.REJECTED; - if (this.chainedPromise) { - if (fulfilled) { - this.chainedPromise.onFulfilled(value as T); - } else { - this.chainedPromise.onRejected(value); - } - } - }; - - constructor(callback: (fulfill: (value: T) => void, reject: (reason: unknown) => void) => void) { - try { - callback( - (value: T) => { - this.fulfill(value); - }, - (reason: unknown) => { - this.reject(reason); - } - ); - } catch (error) { - this.reject(error); - } - } - - /** - * Fulfills the promise. - * - * @param value - The value to fulfill with. - */ - fulfill(value: T | Thenable) { - this.completePromise_(value, true); - } - - /** - * Rejects the promise. - * - * @param error - The error to reject with. - */ - reject(error: unknown) { - this.completePromise_(error, false); - } - - /** - * Gets the current state of the promise. - * - * @returns The state of the promise. - */ - state(): PromiseState { - return this.promiseState; - } - - /** - * Constructs a new promise that will be chanined to execute after this promise is fulfilled or rejected. - * - * @param onFulfilled - Action to perform if the promise is fulfilled. - * @param onRejected - Action to perform if the promise is rejected. - * @returns A new promise. - */ - then(onFulfilled?: (val: T) => U, onRejected?: (reason: unknown) => unknown): Thenable { - return new Thenable((fulfill, reject) => { - this.chainedPromise = { - onFulfilled: function (value: T) { - if (!onFulfilled) { - fulfill(value as never); - return; - } - - try { - fulfill(onFulfilled(value)); - } catch (error) { - reject(error); - } - }, - onRejected: function (value: unknown) { - if (!onRejected) { - reject(value); - return; - } - - try { - fulfill(onRejected(value) as never); - } catch (error) { - reject(error); - } - }, - }; - - // if already resolved, just call the chained promise - if (this.promiseState !== PromiseState.PENDING && this.chainedPromise) { - if (this.promiseState === PromiseState.FULFILLED) { - this.chainedPromise.onFulfilled(this.dataValue as T); - } else { - this.chainedPromise.onRejected(this.dataValue); - } - } - }); - } - - /** - * Similar to then, however only the rejection state is used. - * - * @param onRejected - Action to perform if the promise is rejected. - * @returns A new promise. - */ - catch(onRejected: (reason: unknown) => unknown) { - return this.then(undefined, onRejected); - } - - /** - * Runs the provided function when the promise resolved, regardless if it was - * fulfilled or rejected. - * - * @param onFinally - Action to perform if the promise is resolved. - * @returns A new promise. - */ - finally(onFinally: () => void) { - return this.then( - (value: T) => { - onFinally(); - return value; - }, - (error: unknown) => { - onFinally(); - return error; - } - ); - } -} From b0490c9a6d97abf204ab9739b2cd4f1ea2bd4690 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Tue, 16 Sep 2025 16:24:05 -0700 Subject: [PATCH 3/8] Working promise --- .../src/events/eventPromise.ts | 113 ++++++++++++++---- 1 file changed, 87 insertions(+), 26 deletions(-) diff --git a/libraries/gameplay-utilities/src/events/eventPromise.ts b/libraries/gameplay-utilities/src/events/eventPromise.ts index 51ab849..43b6c84 100644 --- a/libraries/gameplay-utilities/src/events/eventPromise.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.ts @@ -6,26 +6,61 @@ import { world, system } from '@minecraft/server'; /** * A promise wrapper utility which returns a new promise that will resolve when the next * event is raised. - * + * * @public */ -export interface EventPromise { +export interface EventPromise extends Promise { /** * Cancels the promise and unsubscribes from the event signal. Cancellation is done * by fulfilling with undefined. - * + * * @public */ cancel(): void; + + /** + * Promise-like interface then. + * + * @param onfulfilled Called if the promise fulfills + * @param onrejected Called if the promise rejects + */ + then( + onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike) | null, + onrejected?: ((reason: unknown) => TReject | PromiseLike) | null + ): Promise; + + /** + * Promise-like interface catch. + * + * @param onrejected Called if the promise rejects + */ + catch( + onrejected?: ((reason: unknown) => TReject | PromiseLike) | null + ): Promise; + + /** + * Promise-like interface finally. + * + * @param onfinally Called when the promise resolves + */ + finally(onfinally?: (() => void) | null): Promise; } +type MinecraftAfterEventSignals = + | (typeof world.afterEvents)[keyof typeof world.afterEvents] + | (typeof system.afterEvents)[keyof typeof system.afterEvents]; +type FirstArg = T extends (arg: infer U) => void ? U : never; + /** * Helper to create a new EventPromise from an after event signal. * * @public */ -export function nextEvent(signal: (typeof world.afterEvents[keyof typeof world.afterEvents]) | (typeof system.afterEvents[keyof typeof system.afterEvents]), filter?: U) : EventPromise { - return new EventPromiseImpl(signal, filter); +export function nextEvent( + signal: MinecraftAfterEventSignals, + filter?: U +): EventPromise>> { + return new EventPromiseImpl>, U>(signal, filter); } /** @@ -34,39 +69,65 @@ export function nextEvent(signal: (typeof world.afterEvents[keyof typeof worl * * @private */ -class EventPromiseImpl extends Promise { +class EventPromiseImpl implements Promise { + [Symbol.toStringTag] = 'Promise'; + private promise: Promise; private onCancel?: () => void; - constructor(signal: typeof world.afterEvents[keyof typeof world.afterEvents], filter?: U) { - let cancelFn: (() => void) | undefined = undefined; - super((resolve, _) => { - let sub: (event: T) => void; + constructor(signal: MinecraftAfterEventSignals, filter?: U) { + this.promise = new Promise((resolve, _) => { + if (signal === undefined || signal.subscribe === undefined || signal.unsubscribe === undefined) { + resolve(undefined); + return; + } + + const sub = (event: T) => { + this.onCancel = undefined; + signal.unsubscribe(sub as never); + resolve(event); + }; if (filter === undefined) { - sub = signal.subscribe(event => { - this.onCancel = undefined; - signal.unsubscribe(sub); - resolve(event); - }); + signal.subscribe(sub as never); } else { - sub = signal.subscribe(event => { - this.onCancel = undefined; - signal.unsubscribe(sub); - resolve(event); - }, filter); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (signal.subscribe as (listener: (event: T) => void, filter: U) => (...a: any) => void)(sub, filter); } - cancelFn = () => { - signal.unsubscribe(sub); + this.onCancel = () => { + signal.unsubscribe(sub as never); resolve(undefined); }; }); - this.onCancel = cancelFn; } - /** - * Cancels the promise by resolving it with undefined and unsubscribing from the event signal. - */ cancel() { this.onCancel?.(); } + + then( + onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike) | null, + onrejected?: ((reason: unknown) => TReject | PromiseLike) | null + ): Promise { + return this.promise.then(onfulfilled, onrejected); + } + + /** + * Promise-like interface catch. + * + * @param onrejected Called if the promise rejects + */ + catch( + onrejected?: ((reason: unknown) => TReject | PromiseLike) | null + ): Promise { + return this.promise.catch(onrejected); + } + + /** + * Promise-like interface finally. + * + * @param onfinally Called when the promise resolves + */ + finally(onfinally?: (() => void) | null): Promise { + return this.promise.finally(onfinally); + } } From a6e6c6b4ee0ce35cedc451e51f27facf5d205913 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Wed, 17 Sep 2025 12:08:59 -0700 Subject: [PATCH 4/8] Not sure what is wrong --- .../api-report/gameplay-utilities.api.md | 36 +++++-------------- .../src/events/eventPromise.test.ts | 35 ++++++++++++++++++ .../src/events/eventPromise.ts | 36 ++++++++++--------- package-lock.json | 2 +- 4 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 libraries/gameplay-utilities/src/events/eventPromise.test.ts diff --git a/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md index 1cbeea7..e42ca23 100644 --- a/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md +++ b/libraries/gameplay-utilities/api-report/gameplay-utilities.api.md @@ -4,43 +4,25 @@ ```ts -// @public -export interface EventSignal { - // (undocumented) - subscribe(closure: (event: T) => void, filter?: U): (event: T) => void; - // (undocumented) - unsubscribe(closure: (event: T) => void): void; -} +import { system } from '@minecraft/server'; +import { world } from '@minecraft/server'; // @public -export class EventThenable extends Thenable { - constructor(signal: EventSignal, filter?: U); +export interface EventPromise extends Promise { cancel(): void; + catch(onrejected?: ((reason: unknown) => TReject | PromiseLike) | null): Promise; + finally(onfinally?: (() => void) | null): Promise; + then(onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike) | null, onrejected?: ((reason: unknown) => TReject | PromiseLike) | null): Promise; } // @public -export enum PromiseState { - // (undocumented) - FULFILLED = "fulfilled", - // (undocumented) - PENDING = "pending", - // (undocumented) - REJECTED = "rejected" -} +export type FirstArg = T extends (arg: infer U) => void ? U : never; // @public -export class Thenable { - constructor(callback: (fulfill: (value: T) => void, reject: (reason: unknown) => void) => void); - catch(onRejected: (reason: unknown) => unknown): Thenable; - finally(onFinally: () => void): Thenable; - fulfill(value: T | Thenable): void; - reject(error: unknown): void; - state(): PromiseState; - then(onFulfilled?: (val: T) => U, onRejected?: (reason: unknown) => unknown): Thenable; -} +export type MinecraftAfterEventSignals = (typeof world.afterEvents)[keyof typeof world.afterEvents] | (typeof system.afterEvents)[keyof typeof system.afterEvents]; // @public -export function nextEvent(signal: EventSignal, filter?: U): EventThenable; +export function nextEvent(signal: MinecraftAfterEventSignals, filter?: U): EventPromise>>; // (No @packageDocumentation comment for this package) diff --git a/libraries/gameplay-utilities/src/events/eventPromise.test.ts b/libraries/gameplay-utilities/src/events/eventPromise.test.ts new file mode 100644 index 0000000..eaa2e38 --- /dev/null +++ b/libraries/gameplay-utilities/src/events/eventPromise.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { world } from '@minecraft/server'; +import { nextEvent } from './eventPromise.js'; + +/* +function createWorldMock() { + return { + afterEvents: { + weatherChange: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }, + entityDie: { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }, + }, + }; +} + +vi.mock('@minecraft/server', () => { + const world = createWorldMock(); + return { world } as const; +}); +*/ + +describe('EventPromise', () => { + it('Event is subscribed', () => { + void nextEvent(world.afterEvents.weatherChange); + expect(world.afterEvents.weatherChange).toBeCalled(); + }); +}); diff --git a/libraries/gameplay-utilities/src/events/eventPromise.ts b/libraries/gameplay-utilities/src/events/eventPromise.ts index 43b6c84..c20f8c7 100644 --- a/libraries/gameplay-utilities/src/events/eventPromise.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.ts @@ -21,8 +21,9 @@ export interface EventPromise extends Promise { /** * Promise-like interface then. * - * @param onfulfilled Called if the promise fulfills - * @param onrejected Called if the promise rejects + * @param onfulfilled - Called if the promise fulfills + * @param onrejected - Called if the promise rejects + * @public */ then( onfulfilled?: ((value: T | undefined) => TFulfill | PromiseLike) | null, @@ -32,7 +33,8 @@ export interface EventPromise extends Promise { /** * Promise-like interface catch. * - * @param onrejected Called if the promise rejects + * @param onrejected - Called if the promise rejects + * @public */ catch( onrejected?: ((reason: unknown) => TReject | PromiseLike) | null @@ -41,15 +43,27 @@ export interface EventPromise extends Promise { /** * Promise-like interface finally. * - * @param onfinally Called when the promise resolves + * @param onfinally - Called when the promise resolves + * @public */ finally(onfinally?: (() => void) | null): Promise; } -type MinecraftAfterEventSignals = +/** + * The types of after event signals that exist in Minecraft's API that EventPromise can use. + * + * @public + */ +export type MinecraftAfterEventSignals = | (typeof world.afterEvents)[keyof typeof world.afterEvents] | (typeof system.afterEvents)[keyof typeof system.afterEvents]; -type FirstArg = T extends (arg: infer U) => void ? U : never; + +/** + * Obtains the first argument of a function + * + * @public + */ +export type FirstArg = T extends (arg: infer U) => void ? U : never; /** * Helper to create a new EventPromise from an after event signal. @@ -111,22 +125,12 @@ class EventPromiseImpl implements Promise { return this.promise.then(onfulfilled, onrejected); } - /** - * Promise-like interface catch. - * - * @param onrejected Called if the promise rejects - */ catch( onrejected?: ((reason: unknown) => TReject | PromiseLike) | null ): Promise { return this.promise.catch(onrejected); } - /** - * Promise-like interface finally. - * - * @param onfinally Called when the promise resolves - */ finally(onfinally?: (() => void) | null): Promise { return this.promise.finally(onfinally); } diff --git a/package-lock.json b/package-lock.json index c83391d..5ebc630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ }, "libraries/gameplay-utilities": { "name": "@minecraft/gameplay-utilities", - "version": "2.2.8", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@minecraft/core-build-tasks": "*", From 2abe1fdc53f338f200580bfaf4b2a262b8d43836 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Wed, 17 Sep 2025 12:45:54 -0700 Subject: [PATCH 5/8] Something works --- .../src/events/eventPromise.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libraries/gameplay-utilities/src/events/eventPromise.test.ts b/libraries/gameplay-utilities/src/events/eventPromise.test.ts index eaa2e38..c9f62f8 100644 --- a/libraries/gameplay-utilities/src/events/eventPromise.test.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.test.ts @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, expect, it } from 'vitest'; -import { world } from '@minecraft/server'; +import { describe, expect, it, vi } from 'vitest'; import { nextEvent } from './eventPromise.js'; +import { WeatherChangeAfterEvent } from '@minecraft/server'; -/* function createWorldMock() { return { afterEvents: { @@ -21,15 +20,16 @@ function createWorldMock() { }; } -vi.mock('@minecraft/server', () => { - const world = createWorldMock(); - return { world } as const; -}); -*/ - describe('EventPromise', () => { it('Event is subscribed', () => { + const world = createWorldMock(); + let receivedCallback: ((event: WeatherChangeAfterEvent) => void) | undefined = undefined; + expect(receivedCallback).toBeUndefined(); + vi.spyOn(world.afterEvents.weatherChange, 'subscribe').mockImplementation(callback => { + receivedCallback = callback as (event: WeatherChangeAfterEvent) => void; + }); void nextEvent(world.afterEvents.weatherChange); - expect(world.afterEvents.weatherChange).toBeCalled(); + expect(world.afterEvents.weatherChange.subscribe).toBeCalled(); + expect(receivedCallback).toBeDefined(); }); }); From f21880f78722008a4370bf51664430e60e49ca85 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Wed, 17 Sep 2025 14:02:13 -0700 Subject: [PATCH 6/8] Tests passing --- .../__mocks__/minecraft-server.ts | 60 ++++++++++++++ .../src/events/eventPromise.test.ts | 80 +++++++++++++------ libraries/gameplay-utilities/tsconfig.json | 2 +- libraries/gameplay-utilities/vite.config.mts | 8 +- 4 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 libraries/gameplay-utilities/__mocks__/minecraft-server.ts diff --git a/libraries/gameplay-utilities/__mocks__/minecraft-server.ts b/libraries/gameplay-utilities/__mocks__/minecraft-server.ts new file mode 100644 index 0000000..e267a8d --- /dev/null +++ b/libraries/gameplay-utilities/__mocks__/minecraft-server.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { 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); + } + }, + }, + }, + }, + }; +}; diff --git a/libraries/gameplay-utilities/src/events/eventPromise.test.ts b/libraries/gameplay-utilities/src/events/eventPromise.test.ts index c9f62f8..954be6a 100644 --- a/libraries/gameplay-utilities/src/events/eventPromise.test.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.test.ts @@ -1,35 +1,65 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, expect, it, vi } from 'vitest'; +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'; -import { WeatherChangeAfterEvent } from '@minecraft/server'; - -function createWorldMock() { - return { - afterEvents: { - weatherChange: { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - }, - entityDie: { - subscribe: vi.fn(), - unsubscribe: vi.fn(), - }, - }, - }; -} describe('EventPromise', () => { + afterEach(() => { + vi.restoreAllMocks(); + clearMockState(); + }); + it('Event is subscribed', () => { - const world = createWorldMock(); - let receivedCallback: ((event: WeatherChangeAfterEvent) => void) | undefined = undefined; - expect(receivedCallback).toBeUndefined(); - vi.spyOn(world.afterEvents.weatherChange, 'subscribe').mockImplementation(callback => { - receivedCallback = callback as (event: WeatherChangeAfterEvent) => void; + 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); }); - void nextEvent(world.afterEvents.weatherChange); - expect(world.afterEvents.weatherChange.subscribe).toBeCalled(); - expect(receivedCallback).toBeDefined(); + 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); }); }); diff --git a/libraries/gameplay-utilities/tsconfig.json b/libraries/gameplay-utilities/tsconfig.json index 23606e9..505bcbe 100644 --- a/libraries/gameplay-utilities/tsconfig.json +++ b/libraries/gameplay-utilities/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@minecraft/tsconfig/base.json", - "include": ["src/**/*"], + "include": ["src/**/*", "__mocks__/**/*"], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { "outDir": "lib", diff --git a/libraries/gameplay-utilities/vite.config.mts b/libraries/gameplay-utilities/vite.config.mts index c72faff..7adb717 100644 --- a/libraries/gameplay-utilities/vite.config.mts +++ b/libraries/gameplay-utilities/vite.config.mts @@ -5,5 +5,11 @@ import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ - test: { exclude: [...configDefaults.exclude, '**/build/**', '**/lib/**', '**/lib-commonjs/**'], watch: false }, + test: { + exclude: [...configDefaults.exclude, '**/build/**', '**/lib/**', '**/lib-commonjs/**'], + watch: false, + alias: { + '@minecraft/server': './__mocks__/minecraft-server.ts', + }, + }, }); From 56d98c2b46bf66480fc968539a9cf03cf9887837 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Wed, 17 Sep 2025 15:07:33 -0700 Subject: [PATCH 7/8] Fix build --- libraries/gameplay-utilities/api-extractor.json | 3 ++- libraries/gameplay-utilities/just.config.cts | 2 +- libraries/gameplay-utilities/package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/gameplay-utilities/api-extractor.json b/libraries/gameplay-utilities/api-extractor.json index 6ca38f6..a7deb6c 100644 --- a/libraries/gameplay-utilities/api-extractor.json +++ b/libraries/gameplay-utilities/api-extractor.json @@ -3,5 +3,6 @@ */ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "@minecraft/api-extractor-base/api-extractor-base.json" + "extends": "@minecraft/api-extractor-base/api-extractor-base.json", + "mainEntryPointFilePath": "/temp/types/src/index.d.ts" } diff --git a/libraries/gameplay-utilities/just.config.cts b/libraries/gameplay-utilities/just.config.cts index 9ba46ab..462abfc 100644 --- a/libraries/gameplay-utilities/just.config.cts +++ b/libraries/gameplay-utilities/just.config.cts @@ -24,7 +24,7 @@ task('typescript', tscTask()); task('api-extractor-local', apiExtractorTask('./api-extractor.json', isOnlyBuild /* localBuild */)); task('bundle', () => { execSync( - 'npx esbuild ./lib/index.js --bundle --outfile=dist/minecraft-gameplay-utilities.js --format=esm --sourcemap --external:@minecraft/server' + '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']; diff --git a/libraries/gameplay-utilities/package.json b/libraries/gameplay-utilities/package.json index 9ddcc59..7aa3277 100644 --- a/libraries/gameplay-utilities/package.json +++ b/libraries/gameplay-utilities/package.json @@ -10,7 +10,7 @@ ], "description": "Gameplay utilities for use with minecraft scripting modules", "exports": { - "import": "./lib/index.js", + "import": "./lib/src/index.js", "types": "./lib/types/gameplay-utilities-public.d.ts" }, "type": "module", From b07f178869e068d796cd5060474d9e371fa65100 Mon Sep 17 00:00:00 2001 From: Andrew Griffin Date: Fri, 19 Sep 2025 10:26:02 -0700 Subject: [PATCH 8/8] PR requests --- .../gameplay-utilities/__mocks__/minecraft-server.ts | 2 +- libraries/gameplay-utilities/package.json | 2 +- .../gameplay-utilities/src/events/eventPromise.ts | 11 ++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/libraries/gameplay-utilities/__mocks__/minecraft-server.ts b/libraries/gameplay-utilities/__mocks__/minecraft-server.ts index e267a8d..7cc70b4 100644 --- a/libraries/gameplay-utilities/__mocks__/minecraft-server.ts +++ b/libraries/gameplay-utilities/__mocks__/minecraft-server.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { EntityEventOptions, WeatherChangeAfterEvent, EntityRemoveAfterEvent } from '@minecraft/server'; +import type { EntityEventOptions, WeatherChangeAfterEvent, EntityRemoveAfterEvent } from '@minecraft/server'; export enum WeatherType { Clear = 'Clear', diff --git a/libraries/gameplay-utilities/package.json b/libraries/gameplay-utilities/package.json index 7aa3277..3bf9f74 100644 --- a/libraries/gameplay-utilities/package.json +++ b/libraries/gameplay-utilities/package.json @@ -1,7 +1,7 @@ { "name": "@minecraft/gameplay-utilities", "version": "0.1.0", - "author": "Raphael Landaverde (rlanda@microsoft.com)", + "author": "Andrew Griffin (agriffin@microsoft.com)", "contributors": [ { "name": "Jake Shirley", diff --git a/libraries/gameplay-utilities/src/events/eventPromise.ts b/libraries/gameplay-utilities/src/events/eventPromise.ts index c20f8c7..0ce0f0e 100644 --- a/libraries/gameplay-utilities/src/events/eventPromise.ts +++ b/libraries/gameplay-utilities/src/events/eventPromise.ts @@ -58,13 +58,6 @@ export type MinecraftAfterEventSignals = | (typeof world.afterEvents)[keyof typeof world.afterEvents] | (typeof system.afterEvents)[keyof typeof system.afterEvents]; -/** - * Obtains the first argument of a function - * - * @public - */ -export type FirstArg = T extends (arg: infer U) => void ? U : never; - /** * Helper to create a new EventPromise from an after event signal. * @@ -73,8 +66,8 @@ export type FirstArg = T extends (arg: infer U) => void ? U : never; export function nextEvent( signal: MinecraftAfterEventSignals, filter?: U -): EventPromise>> { - return new EventPromiseImpl>, U>(signal, filter); +): EventPromise[0]>[0]> { + return new EventPromiseImpl[0]>[0], U>(signal, filter); } /**