diff --git a/src/engine/task/README.md b/src/engine/task/README.md new file mode 100644 index 000000000..94a183b88 --- /dev/null +++ b/src/engine/task/README.md @@ -0,0 +1,139 @@ +# Task system + +The task system allows you to write content which will be executed on the tick cycles of the server. + +You can configure a task to execute every `n` ticks (minimum of `1` for every tick), and you can also choose a number of other behaviours, such as whether the task should execute immediately or after a delay, as well as set to repeat indefinitely. + +## Scheduling a task + +You can schedule a task by registering it with a `TaskScheduler`. + +The task in the example of below runs with an interval of `2`, i.e. it will be executed every 2 ticks. + +```ts +class MyTask extends Task { + public constructor() { + super({ interval: 2 }); + } + + public execute(): void { + console.log('2 ticks'); + } +} + +const scheduler = new TaskScheduler(); + +scheduler.addTask(new MyTask()); + +scheduler.tick(); +scheduler.tick(); // '2 ticks' +``` + +Every two times that `scheduler.tick()` is called, it will run the `execute` function of your task. + +## Task configuration + +You can pass a `TaskConfig` object to the `Task` constructor in order to configure various aspects of your task. + +### Timing + +The most simple configuration option for a `Task` is the `interval` option. Your task will be executed every `interval` amount of ticks. + +```ts +/** +* The number of ticks between each execution of the task. +*/ +interval: number; +``` + +For example, with an interval of `1`, your task will run every tick. The default value is `1`. + +### Immediate execution + +You can configure your task to execute immediately with the `immediate` option. + +```ts +/** +* Should the task be executed on the first tick after it is added? +*/ +immediate: boolean; +``` + +For example, if `immediate` is `true` and `interval` is `5`, your task will run on the 1st and 6th ticks (and so on). + +### Repeating + +You can use the `repeat` option to tell your task to run forever. + +```ts +/** +* Should the task be repeated indefinitely? +*/ +repeat: boolean; +``` + +You can use `this.stop()` inside the task to stop it from repeating further. + +### Stacking + +The `stackType` and `stackGroup` properties allow you to control how your task interacts with other, similar tasks. + +```ts +/** +* How the task should be stacked with other tasks of the same stack group. +*/ +stackType: TaskStackType; + +/** +* The stack group for this task. +*/ +stackGroup: string; +``` + +When `stackType` is set to `TaskStackType.NEVER`, other tasks with the same `stackGroup` will be stopped when your task is enqueued. A `stackType` of `TaskStackType.STACK` will allow your task to run with others of the same group. + +The default type is `TaskStackType.STACK` and the group is `TaskStackGroup.ACTION` (`'action'`) + +## Task Subtypes + +Rather than extending `Task`, there are a number of subclasses you can extend which will give you some syntactic sugar around common functionality. + +- `ActorTask` + + This is the base task to be performed by an `Actor`. It will automatically listen to the actor's walking queue, and stop the task if it has a `breakType` of `ON_MOVE`. + +- `ActorWalkToTask` + + This task will make an actor walk to a `Position` or `LandscapeObject` and will expose the `atDestination` property for your extended task to query. You can then begin executing your task logic. + +- `ActorLandscapeObjectInteractionTask` + + This task extends `ActorWalkToTask` and will make an actor walk to a given `LandscapeObject`, before exposing the `landscapeObject` property for your task to use. + +- `ActorWorldItemInteractionTask` + + This task extends `ActorWalkToTask` and will make an actor walk to a given `WorldItem`, before exposing the `worldItem` property for your task to use. + +# Future improvements + +- Stalling executions for certain tasks when interface is open + - should we create a `PlayerTask` to contain this behaviour? The `breakType` behaviour could be moved to this base, rather than `ActorTask` + +- Consider refactoring this system to use functional programming patterns. Composition should be favoured over inheritance generally, and there are some examples of future tasks which may be easier if we could compose tasks from building blocks. Consider the implementation of some task which requires both a `LandscapeObject` and a `WorldItem` - we currently would need to create some custom task which borrowed behaviour from the `ActorLandscapeObjectInteractionTask` and `ActorWorldItemInteractionTask`. TypeScript mixins could be useful here. + +# Content requiring conversion to task system + +Highest priority is to convert pieces of content which make use of the old `task` system. These are: + +- Magic attack +- Magic teleports +- Prayer +- Combat +- Forging (smithing) +- Woodcutting + +The following areas will make interesting use of the task system and would serve as a good demonstration: + +- Health regen +- NPC movement +- Firemaking diff --git a/src/engine/task/impl/actor-landscape-object-interaction-task.ts b/src/engine/task/impl/actor-landscape-object-interaction-task.ts new file mode 100644 index 000000000..33455274f --- /dev/null +++ b/src/engine/task/impl/actor-landscape-object-interaction-task.ts @@ -0,0 +1,106 @@ +import { LandscapeObject } from '@runejs/filestore'; +import { activeWorld, Position } from '@engine/world'; +import { Actor } from '@engine/world/actor'; +import { ActorWalkToTask } from './actor-walk-to-task'; + +/** + * A task for an actor to interact with a {@link LandscapeObject}. + * + * This task extends {@link ActorWalkToTask} and will walk the actor to the object. + * Once the actor is within range of the object, the task will expose the {@link landscapeObject} property + * + * @author jameskmonger + */ +export abstract class ActorLandscapeObjectInteractionTask extends ActorWalkToTask { + private _landscapeObject: LandscapeObject; + private _objectPosition: Position; + + /** + * Gets the {@link LandscapeObject} that this task is interacting with. + * + * @returns If the object is still present, and the actor is at the destination, the object. + * Otherwise, `null`. + * + * TODO (jameskmonger) unit test this + */ + protected get landscapeObject(): LandscapeObject | null { + // TODO (jameskmonger) consider if we want to do these checks rather than delegating to the child task + // as currently the subclass has to store it in a subclass property if it wants to use it + // without these checks + if (!this.atDestination) { + return null; + } + + if (!this._landscapeObject) { + return null; + } + + return this._landscapeObject; + } + + /** + * Get the position of this task's landscape object + * + * @returns The position of this task's landscape object, or null if the landscape object is not present + */ + protected get landscapeObjectPosition(): Position { + if (!this._landscapeObject) { + return null; + } + + return this._objectPosition; + } + + /** + * @param actor The actor executing this task. + * @param landscapeObject The landscape object to interact with. + * @param sizeX The size of the LandscapeObject in the X direction. + * @param sizeY The size of the LandscapeObject in the Y direction. + */ + constructor ( + actor: TActor, + landscapeObject: LandscapeObject, + sizeX: number = 1, + sizeY: number = 1 + ) { + super( + actor, + landscapeObject, + Math.max(sizeX, sizeY) + ); + + if (!landscapeObject) { + this.stop(); + return; + } + + // create the Position here to prevent instantiating a new Position every tick + this._objectPosition = new Position(landscapeObject.x, landscapeObject.y, landscapeObject.level); + this._landscapeObject = landscapeObject; + } + + /** + * Checks for the continued presence of the {@link LandscapeObject} and stops the task if it is no longer present. + * + * TODO (jameskmonger) unit test this + */ + public execute() { + super.execute(); + + if (!this.isActive || !this.atDestination) { + return; + } + + if (!this._landscapeObject) { + this.stop(); + return; + } + + const { object: worldObject } = activeWorld.findObjectAtLocation(this.actor, this._landscapeObject.objectId, this._objectPosition); + + if (!worldObject) { + this.stop(); + return; + } + } +} diff --git a/src/engine/task/impl/actor-task.ts b/src/engine/task/impl/actor-task.ts new file mode 100644 index 000000000..83fb871e8 --- /dev/null +++ b/src/engine/task/impl/actor-task.ts @@ -0,0 +1,65 @@ +import { Subscription } from 'rxjs'; +import { Actor } from '@engine/world/actor'; +import { TaskBreakType, TaskConfig } from '../types'; +import { Task } from '../task'; + +/** + * A task that is executed by an actor. + * + * If the task has a break type of ON_MOVE, the ActorTask will subscribe to the actor's + * movement events and will stop executing when the actor moves. + * + * @author jameskmonger + */ +export abstract class ActorTask extends Task { + /** + * A function that is called when a movement event is queued on the actor. + * + * This will be `null` if the task does not break on movement. + */ + private walkingQueueSubscription: Subscription | null = null; + + /** + * @param actor The actor executing this task. + * @param config The task configuration. + */ + constructor( + protected readonly actor: TActor, + config?: TaskConfig + ) { + super(config); + + this.listenForMovement(); + } + + /** + * Called when the task is stopped and unsubscribes from the actor's walking queue if necessary. + * + * TODO (jameskmonger) unit test this + */ + public onStop(): void { + if (this.walkingQueueSubscription) { + this.walkingQueueSubscription.unsubscribe(); + } + } + + /** + * If required, listen to the actor's walking queue to stop the task + * + * This function uses `setImmediate` to ensure that the subscription to the + * walking queue is not created + * + * TODO (jameskmonger) unit test this + */ + private listenForMovement(): void { + if (!this.breaksOn(TaskBreakType.ON_MOVE)) { + return; + } + + setImmediate(() => { + this.walkingQueueSubscription = this.actor.walkingQueue.movementQueued$.subscribe(() => { + this.stop(); + }); + }); + } +} diff --git a/src/engine/task/impl/actor-walk-to-task.ts b/src/engine/task/impl/actor-walk-to-task.ts new file mode 100644 index 000000000..f56438940 --- /dev/null +++ b/src/engine/task/impl/actor-walk-to-task.ts @@ -0,0 +1,110 @@ +import { LandscapeObject } from '@runejs/filestore'; +import { Position } from '@engine/world/position'; +import { Actor } from '@engine/world/actor'; +import { TaskStackType, TaskBreakType, TaskStackGroup } from '../types'; +import { ActorTask } from './actor-task'; + +/** + * This ActorWalkToTask interface allows us to merge with the ActorWalkToTask class + * and add optional methods to the class. + * + * There is no way to add optional methods directly to an abstract class. + * + * @author jameskmonger + */ +export interface ActorWalkToTask extends ActorTask { + /** + * An optional function that is called when the actor arrives at the destination. + */ + onArrive?(): void; +} + +/** + * An abstract task that will make an Actor walk to a specific position, + * before calling the `arrive` function and continuing execution. + * + * The task will be stopped if the adds a new movement to their walking queue. + * + * @author jameskmonger + */ +export abstract class ActorWalkToTask extends ActorTask { + private _atDestination: boolean = false; + + /** + * `true` if the actor has arrived at the destination. + */ + protected get atDestination(): boolean { + return this._atDestination; + } + + /** + * @param actor The actor executing this task. + * @param destination The destination position. + * @param distance The distance from the destination position that the actor must be within to arrive. + */ + constructor ( + actor: TActor, + protected readonly destination: TTarget, + protected readonly distance = 1, + ) { + super( + actor, + { + interval: 1, + stackType: TaskStackType.NEVER, + stackGroup: TaskStackGroup.ACTION, + breakTypes: [ TaskBreakType.ON_MOVE ], + immediate: false, + repeat: true, + } + ); + + if(destination instanceof Position) { + this.actor.pathfinding.walkTo(destination, { }) + } else { + this.actor.pathfinding.walkTo(new Position(destination.x, destination.y), { }) + } + } + + /** + * Every tick of the task, check if the actor has arrived at the destination. + * + * You can check `this.arrived` to see if the actor has arrived. + * + * If the actor has previously arrived at the destination, but is no longer within distance, + * the task will be stopped. + * + * @returns `true` if the task was stopped this tick, `false` otherwise. + * + * TODO (jameskmonger) unit test this + */ + public execute() { + if (!this.isActive) { + return; + } + + // TODO this uses actual distances rather than tile distances + // is this correct? + const withinDistance = this.actor.position.withinInteractionDistance(this.destination, this.distance) + + // the WalkToTask itself is complete when the actor has arrived at the destination + // execution will now continue in the extended class + if (this._atDestination) { + // TODO consider making this optional + if (!withinDistance) { + this._atDestination = false; + this.stop(); + } + + return; + } + + if (withinDistance) { + this._atDestination = true; + + if (this.onArrive) { + this.onArrive(); + } + } + } +} diff --git a/src/engine/task/impl/actor-world-item-interaction-task.ts b/src/engine/task/impl/actor-world-item-interaction-task.ts new file mode 100644 index 000000000..44ad2c156 --- /dev/null +++ b/src/engine/task/impl/actor-world-item-interaction-task.ts @@ -0,0 +1,79 @@ +import { WorldItem } from '@engine/world'; +import { Actor } from '../../world/actor/actor'; +import { ActorWalkToTask } from './actor-walk-to-task'; + +/** + * A task for an actor to interact with a world item. + * + * This task extends {@link ActorWalkToTask} and will walk the actor to the world item. + * Once the actor is within range of the world item, the task will expose the {@link worldItem} property + * + * @author jameskmonger + */ +export abstract class ActorWorldItemInteractionTask extends ActorWalkToTask { + private _worldItem: WorldItem; + + /** + * Gets the world item that this task is interacting with. + * + * @returns If the world item is still present, and the actor is at the destination, the world item. + * Otherwise, `null`. + * + * TODO (jameskmonger) unit test this + */ + protected get worldItem(): WorldItem | null { + // TODO (jameskmonger) consider if we want to do these checks rather than delegating to the child task + // as currently the subclass has to store it in a subclass property if it wants to use it + // without these checks + if (!this.atDestination) { + return null; + } + + if (!this._worldItem || this._worldItem.removed) { + return null; + } + + return this._worldItem; + } + + /** + * @param actor The actor executing this task. + * @param worldItem The world item to interact with. + */ + constructor ( + actor: TActor, + worldItem: WorldItem, + ) { + super( + actor, + worldItem.position, + 1 + ); + + if (!worldItem) { + this.stop(); + return; + } + + this._worldItem = worldItem; + + } + + /** + * Checks for the continued presence of the world item and stops the task if it is no longer present. + * + * TODO (jameskmonger) unit test this + */ + public execute() { + super.execute(); + + if (!this.isActive || !this.atDestination) { + return; + } + + if (!this._worldItem || this._worldItem.removed) { + this.stop(); + return; + } + } +} diff --git a/src/engine/task/impl/index.ts b/src/engine/task/impl/index.ts new file mode 100644 index 000000000..f3bef0c93 --- /dev/null +++ b/src/engine/task/impl/index.ts @@ -0,0 +1,4 @@ +export { ActorTask } from './actor-task' +export { ActorWalkToTask } from './actor-walk-to-task' +export { ActorWorldItemInteractionTask } from './actor-world-item-interaction-task' +export { ActorLandscapeObjectInteractionTask } from './actor-landscape-object-interaction-task' diff --git a/src/engine/task/index.ts b/src/engine/task/index.ts new file mode 100644 index 000000000..dc98dfe78 --- /dev/null +++ b/src/engine/task/index.ts @@ -0,0 +1,3 @@ +export { Task } from './task' +export { TaskScheduler } from './task-scheduler' +export { TaskStackType, TaskBreakType, TaskStackGroup, TaskConfig } from './types' diff --git a/src/engine/task/task-scheduler.test.ts b/src/engine/task/task-scheduler.test.ts new file mode 100644 index 000000000..5d538ed24 --- /dev/null +++ b/src/engine/task/task-scheduler.test.ts @@ -0,0 +1,106 @@ +import { Task } from './task'; +import { TaskScheduler } from './task-scheduler'; +import { TaskStackType } from './types'; +import { createMockTask } from './utils/_testing'; + +describe('TaskScheduler', () => { + let taskScheduler: TaskScheduler; + beforeEach(() => { + taskScheduler = new TaskScheduler(); + }); + + describe('when enqueueing a task', () => { + let executeMock: jest.Mock; + let task: Task + beforeEach(() => { + ({ task, executeMock } = createMockTask()); + }); + + it('should add the task to the running list when ticked', () => { + taskScheduler.enqueue(task); + taskScheduler.tick(); + expect(executeMock).toHaveBeenCalled(); + }); + + it('should not add the task to the running list until the next tick', () => { + taskScheduler.enqueue(task); + expect(executeMock).not.toHaveBeenCalled(); + }); + + describe('when ticked multiple times', () => { + beforeEach(() => { + taskScheduler.enqueue(task); + taskScheduler.tick(); + taskScheduler.tick(); + }); + + it('should tick the task twice', () => { + expect(executeMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('when the task is stopped', () => { + beforeEach(() => { + taskScheduler.enqueue(task); + taskScheduler.tick(); + }); + + it('should not tick the task after stopping', () => { + task.stop(); + taskScheduler.tick(); + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when enqueueing a task that cannot stack', () => { + const interval = 0; + const stackType = TaskStackType.NEVER; + const stackGroup = 'foo'; + + let firstExecuteMock: jest.Mock; + let firstTask: Task + beforeEach(() => { + ({ task: firstTask, executeMock: firstExecuteMock } = createMockTask(interval, stackType, stackGroup)); + }); + + it('should stop any other tasks with the same stack group', () => { + const { task: secondTask, executeMock: secondExecuteMock } = createMockTask(interval, stackType, stackGroup); + + taskScheduler.enqueue(firstTask); + taskScheduler.enqueue(secondTask); + taskScheduler.tick(); + + expect(firstExecuteMock).not.toHaveBeenCalled(); + expect(secondExecuteMock).toHaveBeenCalled(); + }); + + it('should not stop any other tasks with a different stack group', () => { + const otherStackGroup = 'bar'; + const { task: secondTask, executeMock: secondExecuteMock } = createMockTask(interval, stackType, otherStackGroup); + + taskScheduler.enqueue(firstTask); + taskScheduler.enqueue(secondTask); + taskScheduler.tick(); + + expect(firstExecuteMock).toHaveBeenCalled(); + expect(secondExecuteMock).toHaveBeenCalled(); + }); + }); + + describe('when clearing the scheduler', () => { + let executeMock: jest.Mock; + let task: Task + beforeEach(() => { + ({ task, executeMock } = createMockTask()); + }); + + it('should stop all tasks', () => { + taskScheduler.enqueue(task); + taskScheduler.tick(); + taskScheduler.clear(); + taskScheduler.tick(); + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/engine/task/task-scheduler.ts b/src/engine/task/task-scheduler.ts new file mode 100644 index 000000000..090ce5f2d --- /dev/null +++ b/src/engine/task/task-scheduler.ts @@ -0,0 +1,98 @@ +import { Queue } from '@engine/util/queue'; +import { Task } from './task'; +import { TaskStackType } from './types'; + +/** + * A class that ticks tasks in a queue, and removes them when they are no longer active. + * + * @author jameskmonger + */ +export class TaskScheduler { + /** + * A queue of tasks that are waiting to be added to the running list. + */ + private pendingTasks = new Queue(); + + /** + * The list of tasks that are currently running. + */ + private runningTasks: Task[] = []; + + /** + * Register any pending tasks, and tick any running tasks. + */ + public tick(): void { + // Add any pending tasks to the running list + while(this.pendingTasks.isNotEmpty) { + const task = this.pendingTasks.dequeue(); + + if (!task || !task.isActive) { + continue; + } + + this.runningTasks.push(task); + } + + // Use an iterator so that we can remove tasks from the list while iterating + for(const [index, task] of this.runningTasks.entries()) { + if (!task) { + continue; + } + + task.tick(); + + if (!task.isActive) { + this.runningTasks.splice(index, 1); + } + } + } + + /** + * Add a task to the end of the pending queue. + * + * If the task has a stack type of `NEVER`, any other tasks in the scheduler + * with the same stack group will be stopped. + * + * @param task The task to add. + */ + public enqueue(task: Task): void { + if (!task.isActive) { + return; + } + + // if the task can't stack with others of a similar type, we need to stop them + if (task.stackType === TaskStackType.NEVER) { + // Use an iterator so that we can remove tasks from the list while iterating + for(const [index, otherTask] of this.runningTasks.entries()) { + if (!otherTask) { + continue; + } + + if (otherTask.stackGroup === task.stackGroup) { + otherTask.stop(); + this.runningTasks.splice(index, 1); + } + } + + for(const otherTask of this.pendingTasks.items) { + if (!otherTask) { + continue; + } + + if (otherTask.stackGroup === task.stackGroup) { + otherTask.stop(); + } + } + } + + this.pendingTasks.enqueue(task); + } + + /** + * Clear all tasks from the scheduler. + */ + public clear(): void { + this.pendingTasks.clear(); + this.runningTasks = []; + } +} diff --git a/src/engine/task/task.test.ts b/src/engine/task/task.test.ts new file mode 100644 index 000000000..0fbf203c0 --- /dev/null +++ b/src/engine/task/task.test.ts @@ -0,0 +1,213 @@ +import { Task } from './task' +import { TaskStackType } from './types'; +import { createMockTask } from './utils/_testing'; + +describe('Task', () => { + // stacking mechanics are tested in the scheduler + const stackType = TaskStackType.NEVER; + const stackGroup = 'foo'; + const breakType = []; + + describe('when interval is 0', () => { + const interval = 0; + + // no point setting this to true as the interval is 0 + const immediate = false; + + let executeMock: jest.Mock; + let task: Task + + describe('and repeat is true', () => { + const repeat = false; + beforeEach(() => { + ({ task, executeMock } = createMockTask(interval, stackType, stackGroup, immediate, breakType, repeat, )); + }); + + describe('when ticked once', () => { + beforeEach(() => { + task.tick(); + }); + + it('should execute twice', () => { + expect(executeMock).toHaveBeenCalled(); + }); + }); + + describe('when ticked twice', () => { + beforeEach(() => { + task.tick(); + task.tick(); + }); + + it('should execute twice', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('and repeat is false', () => { + const repeat = false; + beforeEach(() => { + ({ task, executeMock } = createMockTask(interval, stackType, stackGroup, immediate, breakType, repeat)); + }); + + describe('when ticked once', () => { + beforeEach(() => { + task.tick(); + }); + + it('should execute once', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when ticked twice', () => { + beforeEach(() => { + task.tick(); + task.tick(); + }); + + it('should execute once', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + + + describe('when interval is 2', () => { + const interval = 2; + + // not testing repeat here as it is tested above + const repeat = false; + + let executeMock: jest.Mock; + let task: Task + + describe('and immediate is true', () => { + const immediate = true; + + beforeEach(() => { + ({ task, executeMock } = createMockTask(interval, stackType, stackGroup, immediate, breakType, repeat)); + }); + + describe('when ticked once', () => { + beforeEach(() => { + task.tick(); + }); + + it('should execute once', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when ticked twice', () => { + beforeEach(() => { + task.tick(); + task.tick(); + }); + + // task will execute on ticks 1 and 3 + it('should execute once', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('and immediate is false', () => { + const immediate = false; + + beforeEach(() => { + ({ task, executeMock } = createMockTask(interval, stackType, stackGroup, immediate, breakType, repeat)); + }); + + describe('when ticked once', () => { + beforeEach(() => { + task.tick(); + }); + + it('should not execute', () => { + expect(executeMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('when ticked twice', () => { + beforeEach(() => { + task.tick(); + task.tick(); + }); + + // task will execute on ticks 2 and 4 + it('should execute once', () => { + expect(executeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + + describe('when there is an onStop callback', () => { + let task: Task; + let onStopMock: jest.Mock; + let executeMock: jest.Mock; + + beforeEach(() => { + onStopMock = jest.fn(); + executeMock = jest.fn(); + task = new class extends Task { + constructor() { + super(); + } + + public execute(): void { + executeMock(); + } + + public onStop(): void { + onStopMock(); + } + } + }); + + describe('when the task is stopped', () => { + beforeEach(() => { + task.stop(); + }); + + it('should call the onStop callback', () => { + expect(onStopMock).toHaveBeenCalled(); + }); + + it('should not call the execute callback', () => { + expect(executeMock).not.toHaveBeenCalled(); + }); + + describe('when the task is ticked', () => { + beforeEach(() => { + task.tick(); + }); + + it('should not call the onStop callback', () => { + expect(onStopMock).toHaveBeenCalledTimes(1); + }); + + it('should not call the execute callback', () => { + expect(executeMock).not.toHaveBeenCalled(); + }); + }); + + describe('when the task is stopped again', () => { + beforeEach(() => { + task.stop(); + }); + + it('should not call the onStop callback', () => { + expect(onStopMock).toHaveBeenCalledTimes(1); + }); + + it('should not call the execute callback', () => { + expect(executeMock).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/engine/task/task.ts b/src/engine/task/task.ts new file mode 100644 index 000000000..285115b97 --- /dev/null +++ b/src/engine/task/task.ts @@ -0,0 +1,167 @@ +import { TaskBreakType, TaskConfig, TaskStackGroup, TaskStackType } from './types'; + +const DEFAULT_TASK_CONFIG: Required = { + interval: 1, + stackType: TaskStackType.STACK, + stackGroup: TaskStackGroup.ACTION, + immediate: false, + breakTypes: [], + repeat: true +}; + +function readConfigValue(key: keyof TaskConfig, config?: TaskConfig): any { + if (!config) { + return DEFAULT_TASK_CONFIG[key]; + } + + return config[key] !== undefined ? config[key] : DEFAULT_TASK_CONFIG[key]; +} + +/** + * This Task interface allows us to merge with the Task class + * and add optional methods to the class. + * + * There is no way to add optional methods directly to an abstract class. + * + * @author jameskmonger + */ +export interface Task { + /** + * A callback that is called when the task is stopped. + */ + onStop?(): void; +} + +/** + * A Task which can be ticked and executes after a specified number of ticks. + * + * The task can be configured to execute once, or repeatedly, and can also be executed immediately. + * + * @author jameskmonger + */ +export abstract class Task { + /** + * How the task should be stacked with other tasks of the same stack group. + */ + public readonly stackType: TaskStackType; + + /** + * The stack group for this task. + */ + public readonly stackGroup: string; + + /** + * Conditions under which the task should be broken. + */ + public readonly breakTypes: TaskBreakType[]; + + /** + * The number of ticks between each execution of the task. + */ + private interval: number; + + /** + * The number of ticks remaining before the task is executed. + */ + private ticksRemaining: number; + + /** + * Should the task be repeated indefinitely? + */ + private repeat: boolean; + + private _isActive = true; + + /** + * Is the task active? + */ + public get isActive(): boolean { + return this._isActive; + } + + /** + * @param config the configuration options for the task + * + * @see TaskConfig for more information on the configuration options + */ + public constructor(config?: TaskConfig) { + this.interval = readConfigValue('interval', config); + this.stackType = readConfigValue('stackType', config); + this.stackGroup = readConfigValue('stackGroup', config); + + const immediate = readConfigValue('immediate', config); + this.ticksRemaining = immediate ? 0 : this.interval; + this.breakTypes = readConfigValue('breakTypes', config); + this.repeat = readConfigValue('repeat', config); + } + + /** + * Whether this task breaks on the specified {@link TaskBreakType}. + * + * @param breakType the break type to check + * + * @returns true if the task breaks on the specified break type + */ + public breaksOn(breakType: TaskBreakType): boolean { + return this.breakTypes.includes(breakType); + } + + /** + * Stop the task from executing. + * + * @returns true if the task was stopped, false if the task was already stopped + */ + public stop(): boolean { + // can't stop a task that's already stopped + if (!this._isActive) { + return false; + } + + this._isActive = false; + + if (this.onStop) { + this.onStop(); + } + + return true; + } + + /** + * Tick the task, decrementing the number of ticks remaining. + * + * If the number of ticks remaining reaches zero, the task is executed. + * + * If the task is configured to repeat, the number of ticks remaining is reset to the interval. + * Otherwise, the task is stopped. + */ + public tick(): void { + if (!this._isActive) { + return; + } + + this.ticksRemaining--; + + if (this.ticksRemaining <= 0) { + // TODO maybe track and expose executionCount to this child function + this.execute(); + + // TODO should we allow the repeat count to be specified? + if (this.repeat) { + this.ticksRemaining = this.interval; + } else { + // TODO should I be calling a public function rather than setting the private variable? + this.stop(); + } + } + } + + /** + * The task's execution logic. + * + * Ensure that you call `super.execute()` if you override this method! + * + * TODO (jameskmonger) consider some kind of workaround to enforce a super call + * https://github.com/microsoft/TypeScript/issues/21388#issuecomment-360214959 + */ + public abstract execute(): void; +} diff --git a/src/engine/task/types.ts b/src/engine/task/types.ts new file mode 100644 index 000000000..f7151c598 --- /dev/null +++ b/src/engine/task/types.ts @@ -0,0 +1,81 @@ +/** + * An enum to control the different stacking modes for tasks. + * + * @author jameskmonger + */ +export enum TaskStackType { + /** + * This task cannot be stacked with other tasks of the same stack group. + */ + NEVER, + + /** + * This task can be stacked with other tasks of the same stack group. + */ + STACK, +} + +/** + * An enum to control the different stack groups for tasks. + * + * When a task has a stack type of `NEVER`, other tasks with the same stack group will be cancelled. + * + * @author jameskmonger + */ +export enum TaskStackGroup { + /** + * An action task undertaken by an actor. + */ + ACTION = 'action', +} + +/** + * An enum to control the different breaking modes for tasks. + * + * @author jameskmonger + */ +export enum TaskBreakType { + /** + * This task gets stopped when the player moves + */ + ON_MOVE, +} + +/** + * The configuration options for a Task. + * + * All options are optional as they have default values. + * + * @author jameskmonger + */ +export type TaskConfig = Partial>; diff --git a/src/engine/task/utils/_testing.ts b/src/engine/task/utils/_testing.ts new file mode 100644 index 000000000..ddcdfddd0 --- /dev/null +++ b/src/engine/task/utils/_testing.ts @@ -0,0 +1,31 @@ +import { Task } from '../task'; +import { TaskBreakType, TaskStackGroup, TaskStackType } from '../types'; + +export function createMockTask( + interval: number = 0, + stackType: TaskStackType = TaskStackType.STACK, + stackGroup: string = TaskStackGroup.ACTION, + immediate: boolean = false, + breakTypes: TaskBreakType[] = [], + repeat: boolean = true +){ + const executeMock = jest.fn(); + const task = new class extends Task { + constructor() { + super({ + interval, + stackType, + stackGroup, + immediate, + breakTypes, + repeat + }); + } + + public execute(): void { + executeMock(); + } + } + + return { task, executeMock } +} diff --git a/src/engine/world/actor/actor.ts b/src/engine/world/actor/actor.ts index 40dabfb8d..e7743c412 100644 --- a/src/engine/world/actor/actor.ts +++ b/src/engine/world/actor/actor.ts @@ -13,6 +13,8 @@ import { Animation, Graphic, UpdateFlags } from './update-flags'; import { Skills } from './skills'; import { Pathfinding } from './pathfinding'; import { ActorMetadata } from './metadata'; +import { Task, TaskScheduler } from '@engine/task'; +import { logger } from '@runejs/common'; export type ActorType = 'player' | 'npc'; @@ -52,6 +54,13 @@ export abstract class Actor { protected randomMovementInterval; protected _instance: WorldInstance = null; + /** + * Is this actor currently active? If true, the actor will have its task queue processed. + * + * This is true for players that are currently logged in, and NPCs that are currently in the world. + */ + protected active: boolean; + /** * @deprecated - use new action system instead */ @@ -64,6 +73,8 @@ export abstract class Actor { private _faceDirection: number; private _bonuses: { offensive: OffensiveBonuses, defensive: DefensiveBonuses, skill: SkillBonuses }; + private readonly scheduler = new TaskScheduler(); + protected constructor(actorType: ActorType) { this.type = actorType; this._walkDirection = -1; @@ -72,6 +83,54 @@ export abstract class Actor { this.clearBonuses(); } + /** + * Instantiate a task with the Actor instance and a set of arguments. + * + * @param taskClass The task class to instantiate. Must be a subclass of {@link Task} + * @param args The arguments to pass to the task constructor + * + * If the task has a stack type of `NEVER`, other tasks in the same {@link TaskStackGroup} will be cancelled. + */ + public enqueueTask(taskClass: new (actor: Actor) => Task, ...args: never[]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Task, args: [ T1, T2, T3, T4, T5, T6 ]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Task, args: [ T1, T2, T3, T4, T5 ]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Task, args: [ T1, T2, T3, T4 ]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1, arg2: T2, arg3: T3) => Task, args: [ T1, T2, T3 ]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1, arg2: T2) => Task, args: [ T1, T2 ]): void; + public enqueueTask(taskClass: new (actor: Actor, arg1: T1) => Task, args: [ T1 ]): void; + public enqueueTask(taskClass: new (actor: Actor, ...args: T[]) => Task, args: T[]): void { + if (!this.active) { + logger.warn(`Attempted to instantiate task for inactive actor`); + return; + } + + if (args) { + this.enqueueBaseTask( + new taskClass(this, ...args) + ); + } else { + this.enqueueBaseTask( + new taskClass(this) + ); + } + } + + /** + * Adds a task to the actor's scheduler queue. These tasks will be stopped when they become inactive. + * + * If the task has a stack type of `NEVER`, other tasks in the same group will be cancelled. + * + * @param task The task to add + */ + public enqueueBaseTask(task: Task): void { + if (!this.active) { + logger.warn(`Attempted to enqueue task for inactive actor`); + return; + } + + this.scheduler.enqueue(task); + } + public clearBonuses(): void { this._bonuses = { offensive: { @@ -439,6 +498,28 @@ export abstract class Actor { return true; } + /** + * Initialise the actor. + */ + protected init() { + this.active = true; + } + + /** + * Destroy this actor. + * + * This will stop the processing of its action queue. + */ + protected destroy() { + this.active = false; + + this.scheduler.clear(); + } + + protected tick() { + this.scheduler.tick(); + } + public abstract equals(actor: Actor): boolean; public get position(): Position { @@ -520,5 +601,4 @@ export abstract class Actor { public get bonuses(): { offensive: OffensiveBonuses, defensive: DefensiveBonuses, skill: SkillBonuses } { return this._bonuses; } - } diff --git a/src/engine/world/actor/npc.ts b/src/engine/world/actor/npc.ts index 4de8df707..2a1d96246 100644 --- a/src/engine/world/actor/npc.ts +++ b/src/engine/world/actor/npc.ts @@ -103,6 +103,8 @@ export class Npc extends Actor { } public async init(): Promise { + super.init(); + activeWorld.chunkManager.getChunkForWorldPosition(this.position).addNpc(this); if(this.movementRadius > 0) { @@ -147,6 +149,7 @@ export class Npc extends Actor { } public kill(respawn: boolean = true): void { + this.destroy(); activeWorld.chunkManager.getChunkForWorldPosition(this.position).removeNpc(this); clearInterval(this.randomMovementInterval); @@ -158,6 +161,8 @@ export class Npc extends Actor { } public async tick(): Promise { + super.tick(); + return new Promise(resolve => { this.walkingQueue.process(); resolve(); diff --git a/src/engine/world/actor/player/player.ts b/src/engine/world/actor/player/player.ts index f91927aac..2ecddd5af 100644 --- a/src/engine/world/actor/player/player.ts +++ b/src/engine/world/actor/player/player.ts @@ -130,7 +130,6 @@ export class Player extends Actor { private readonly _outgoingPackets: OutboundPacketHandler; private readonly _equipment: ItemContainer; private _rights: Rights; - private loggedIn: boolean; private _loginDate: Date; private _lastAddress: string; private firstTimePlayer: boolean; @@ -172,7 +171,8 @@ export class Player extends Actor { } public async init(): Promise { - this.loggedIn = true; + super.init(); + this.updateFlags.mapRegionUpdateRequired = true; this.updateFlags.appearanceUpdateRequired = true; @@ -277,7 +277,7 @@ export class Player extends Actor { } public logout(): void { - if(!this.loggedIn) { + if(!this.active) { return; } @@ -288,6 +288,8 @@ export class Player extends Actor { activeWorld.playerTree.remove(this.quadtreeKey); this.save(); + this.destroy(); + this.actionsCancelled.complete(); this.walkingQueue.movementEvent.complete(); this.walkingQueue.movementQueued.complete(); @@ -297,7 +299,6 @@ export class Player extends Actor { activeWorld.chunkManager.getChunkForWorldPosition(this.position).removePlayer(this); activeWorld.deregisterPlayer(this); - this.loggedIn = false; logger.info(`${this.username} has logged out.`); } @@ -371,6 +372,8 @@ export class Player extends Actor { } public async tick(): Promise { + super.tick(); + return new Promise(resolve => { this.walkingQueue.process(); diff --git a/src/engine/world/world.ts b/src/engine/world/world.ts index 64fd586a1..9763bec11 100644 --- a/src/engine/world/world.ts +++ b/src/engine/world/world.ts @@ -12,6 +12,7 @@ import { loadActionFiles } from '@engine/action'; import { ChunkManager, ConstructedRegion, getTemplateLocalX, getTemplateLocalY } from '@engine/world/map'; import { TravelLocations, ExamineCache, parseScenerySpawns } from '@engine/world/config'; import { loadPlugins } from '@engine/plugins'; +import { TaskScheduler, Task } from '@engine/task'; export interface QuadtreeKey { @@ -39,6 +40,7 @@ export class World { public readonly npcTree: Quadtree; public readonly globalInstance = new WorldInstance(); public readonly tickComplete: Subject = new Subject(); + private readonly scheduler = new TaskScheduler(); private readonly debugCycleDuration: boolean = process.argv.indexOf('-tickTime') !== -1; @@ -69,6 +71,19 @@ export class World { logger.info(`Shutting down world...`); } + /** + * Adds a task to the world scheduler queue. These tasks will run forever until they are cancelled. + * + * @warning Did you mean to add a world task, rather than an Actor task? + * + * If the task has a stack type of `NEVER`, other tasks in the same group will be cancelled. + * + * @param task The task to add + */ + public enqueueTask(task: Task): void { + this.scheduler.enqueue(task); + } + /** * Searched for an object by ID at the given position in any of the player's active instances. * @param actor The actor to find the object for. @@ -441,6 +456,9 @@ export class World { public async worldTick(): Promise { const hrStart = Date.now(); + + this.scheduler.tick(); + const activePlayers: Player[] = this.playerList.filter(player => player !== null); if(activePlayers.length === 0) {