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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Stylish is a decorator library aimed at simplifying development for the Minecraf

- `@ItemComponent` – automatically registers a custom item component.
- `@BlockComponent` – automatically registers a custom block component.
- `@CustomCmd` - automatically registers a custom command.
- `@BindThis` – binds a method to its instance when accessed.
- `@OnStartup` – runs decorated methods when the pack starts.
- `@OnWorldLoad` – runs decorated methods when the world is loaded.
Expand All @@ -23,7 +24,7 @@ npm install @bedrock-oss/stylish
```
2. Enable decorators in your `tsconfig.json`:

```jsonc
```json
{
"compilerOptions": {
"experimentalDecorators": true
Expand Down Expand Up @@ -72,6 +73,7 @@ See the [`docs`](docs/) folder for details on decorators:
- [BindThis](docs/bindthis.md)
- [ItemComponent](docs/itemcomponent.md)
- [BlockComponent](docs/blockcomponent.md)
- [CustomCmd](docs/customcommand.md)
- [Events](docs/events.md)


Expand Down
60 changes: 50 additions & 10 deletions __tests__/customCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,60 @@ describe("CustomCmd decorator", () => {
expect((cmdInstance as any).run).toHaveBeenCalled();
});

test("throws when run is missing", async () => {
// @CustomCmd
// class NoRunInvalid {
// readonly name = "test:norun";
// readonly description = "no";
// readonly permissionLevel = CommandPermissionLevel.Any;
// }
});

describe("CustomCmd enum auto-registration", () => {
test("registers enums from mandatory parameters", async () => {
const mod = await import("../src/customCommandRegistry");
const { CustomCmd, registerAllCustomCommands } = mod;

class NoRun {
readonly name = "test:norun";
readonly description = "no";
readonly permissionLevel = CommandPermissionLevel.Any;
// Provide a mock global enum type identifier to simulate runtime
(globalThis as any).CustomCommandParamType = { Enum: "enum" };

class EnumCmd {
readonly name = "test:enum";
readonly description = "enum test";
readonly permissionLevel = 0; // Any
readonly mandatoryParameters = [
{ name: "test:pos", type: "enum", values: ["a", "b"] },
];
static run = jest.fn();
}
CustomCmd(NoRun as any);
CustomCmd(EnumCmd as any);

const registry = { registerCommand: jest.fn() } as any;
expect(() => registerAllCustomCommands(registry)).toThrow(
/has no run method/
);
const registry = { registerCommand: jest.fn(), registerEnum: jest.fn() } as any;
registerAllCustomCommands(registry);
expect(registry.registerEnum).toHaveBeenCalledWith("test:pos", ["a", "b"]);
});

test("deduplicates enum registrations across mandatory/optional", async () => {
const mod = await import("../src/customCommandRegistry");
const { CustomCmd, registerAllCustomCommands } = mod;
(globalThis as any).CustomCommandParamType = { Enum: "enum" };

class DuplicateEnumCmd {
readonly name = "test:dup";
readonly description = "dup enum";
readonly permissionLevel = 0;
readonly mandatoryParameters = [
{ name: "test:shared", type: "enum", values: ["x", "y"] },
];
readonly optionalParameters = [
{ name: "test:shared", type: "enum", values: ["x", "y"] },
];
static run = jest.fn();
}
CustomCmd(DuplicateEnumCmd as any);

const registry = { registerCommand: jest.fn(), registerEnum: jest.fn() } as any;
registerAllCustomCommands(registry);
expect(registry.registerEnum).toHaveBeenCalledTimes(1);
expect(registry.registerEnum).toHaveBeenCalledWith("test:shared", ["x", "y"]);
});
});
25 changes: 21 additions & 4 deletions docs/customcommand.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CustomCmd – Custom Command decorator
# `CustomCmd`

Attach `@CustomCmd` to a class implementing the Minecraft `CustomCommand` shape. On startup, Stylish will instantiate and register it with the runtime's `customCommandRegistry`, wiring your `run` handler.
Attach `@CustomCmd` to a class implementing the Minecraft Script API `CustomCommand`. The class must either have a static or instance `run` method which will be invoked when the command is ran.

## Example

Expand Down Expand Up @@ -29,5 +29,22 @@ class HelloCommand {
}
```

Decorated classes will be registered with the `CustomCommandRegistry` when
`init()` executes.
Decorated classes will be registered with the `CustomCommandRegistry` when `init()` executes.

## Enum parameter registration

If your command defines `mandatoryParameters` or `optionalParameters` with entries of type `Enum` that include a non-empty `values` array, stylish will automatically invoke `customCommandRegistry.registerEnum(name, values)` the first time it sees each enum name. Duplicate enum names (even if repeated across mandatory/optional arrays) are de-duplicated.

Example:

```ts
readonly mandatoryParameters = [
{
name: "example:mode",
type: CustomCommandParamType.Enum,
values: ["on", "off"]
}
];
```

No additional manual startup code is required for enum registration as it's built for streamlined use.
4 changes: 4 additions & 0 deletions setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ jest.mock('@minecraft/server', () => {
Admin: 2,
Owner: 3,
},
CustomCommandParamType: {
// Provide a symbolic value for Enum parameters; tests also accept string 'enum'
Enum: 'enum'
},
Direction: {
Down: 'Down',
East: 'East',
Expand Down
77 changes: 59 additions & 18 deletions src/customCommandRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import type {
CustomCommand,
CustomCommandRegistry,
} from "@minecraft/server";
import { CustomCommandParamType, type CustomCommand, type CustomCommandParameter, type CustomCommandRegistry } from "@minecraft/server";
import { registerInstanceEventHandlers } from "./eventRegistry";


/**
* Constructor type for Custom Commands. Constructors must not have parameters.
* Definition for each parameter expected by the custom
* command.
*/
export type CustomCommandCtor = new () => CustomCommand & Record<string, any>;
export interface StylishCommandParameter extends CustomCommandParameter {
/** Optional set of enum values (required when type === Enum). */
values?: readonly string[] | string[];
}

/**
* Define the custom command, including name, permissions, and
* parameters.
*/
export interface StylishCustomCommand extends CustomCommand {
mandatoryParameters?: StylishCommandParameter[];
optionalParameters?: StylishCommandParameter[];
}

/**
* Constructor type for Custom Commands. Constructors must not have parameters.
*/
export type CustomCommandCtor = new () => StylishCustomCommand & Record<string, any>;

const registry: CustomCommandCtor[] = [];

/**
* Decorator – attach to each Custom Command class to auto-register it.
*/
export function CustomCmd<T extends CustomCommandCtor>(ctor: T) {
registry.push(ctor);
export function CustomCmd<T extends CustomCommandCtor>(ctor: T): T {
registry.push(ctor as CustomCommandCtor);
return ctor;
}

/**
Expand All @@ -30,20 +45,46 @@ export function registerAllCustomCommands(
const instance = new Ctor();
registerInstanceEventHandlers(instance);

let runFn: ((...args: any[]) => any) | undefined;
const maybeStaticRun = (Ctor as any).run;
if (typeof maybeStaticRun === "function") {
runFn = maybeStaticRun.bind(Ctor);
} else if (typeof (instance as any).run === "function") {
runFn = (instance as any).run.bind(instance);
}

if (!runFn) {
// Look for either a static or instance run method
const hasStaticRun = typeof (Ctor as any).run === "function";
const hasInstanceRun = typeof instance.run === "function";
if (!hasStaticRun && !hasInstanceRun) {
throw new Error(
`Custom command ${Ctor.name} has no run method. Define a static or instance run(origin, ...args).`
`Custom command ${Ctor.name} has no run method. A static or instance run(origin, ...args) is required.`
);
}
const runFn = hasStaticRun
? (Ctor as any).run.bind(Ctor)
: instance.run.bind(instance);


// Auto-register any enum parameter sets (mandatory or optional)
const enumNames = new Set<string>();
/**
* @private
*/
const collect = (arr?: StylishCommandParameter[]) => {
if (!Array.isArray(arr)) return;
for (const def of arr) {
try {
if (def.type === CustomCommandParamType.Enum &&
Array.isArray(def.values) &&
def.values.length > 0 &&
!enumNames.has(def.name) // ignore duplicates
) {
enumNames.add(def.name);
customCommandRegistry.registerEnum(def.name, def.values);
}
} catch (e) {
console.warn(`Failed to register enum parameter for command ${instance.name}:`, e);
}
}
};
collect(instance.mandatoryParameters as StylishCommandParameter[] | undefined);
collect(instance.optionalParameters as StylishCommandParameter[] | undefined);


customCommandRegistry.registerCommand(instance as any, runFn as any);
customCommandRegistry.registerCommand(instance, runFn);
}
}